Skip to main content

steel_worldgen/noise/
normal_noise.rs

1//! Normal (Double Perlin) noise implementation matching vanilla Minecraft's NormalNoise.java
2//!
3//! This combines two `PerlinNoise` samplers with slightly different coordinate scaling
4//! to create smoother, more natural-looking noise. It's used for biome climate parameters.
5
6use std::ops;
7use std::simd::cmp::{SimdPartialEq, SimdPartialOrd};
8use std::simd::f64x4;
9use std::simd::num::SimdFloat;
10use std::simd::{Mask, Simd, SimdCast, SimdElement, StdFloat};
11
12use crate::noise::PerlinNoise;
13use crate::random::{PositionalRandom, RandomSource, RandomSplitter, name_hash::NameHash};
14
15/// Input factor for the second Perlin sampler.
16///
17/// This is the exact value from vanilla `NormalNoise.java`.
18/// The second sampler's coordinates are multiplied by this factor to create
19/// variation between the two samplers.
20#[expect(
21    clippy::unreadable_literal,
22    reason = "exact vanilla constant; underscores would obscure precision"
23)]
24pub const INPUT_FACTOR: f64 = 1.0181268882175227;
25
26/// Value factor numerator matching vanilla's inline literal `0.16666666666666666` (1/6).
27///
28/// Vanilla declares a constant `TARGET_DEVIATION = 0.3333333333333333` (1/3) but never
29/// uses it — the constructor hardcodes `0.16666666666666666` (1/6) as the numerator in
30/// `valueFactor = 0.16666... / expectedDeviation(span)`. We name this differently to
31/// avoid confusion with vanilla's dead `TARGET_DEVIATION` constant.
32#[expect(
33    clippy::unreadable_literal,
34    reason = "exact vanilla constant; underscores would obscure precision"
35)]
36const VALUE_FACTOR_NUMERATOR: f64 = 0.16666666666666666;
37
38/// Normal (Double Perlin) noise generator.
39///
40/// Combines two `PerlinNoise` samplers with different coordinate scales to create
41/// smoother noise. The result is scaled by a value factor based on the octave span.
42#[derive(Debug, Clone)]
43pub struct NormalNoise {
44    /// First Perlin noise sampler
45    first: PerlinNoise,
46    /// Second Perlin noise sampler (coordinates scaled by `INPUT_FACTOR`)
47    second: PerlinNoise,
48    /// Factor applied to the sum of both samplers
49    value_factor: f64,
50    /// Maximum possible output value
51    max_value: f64,
52}
53
54impl NormalNoise {
55    /// Create a new `NormalNoise` from a mutable sequential random source.
56    ///
57    /// This matches vanilla's `NormalNoise` constructor:
58    /// 1. Create first `PerlinNoise` (which advances the random state by consuming 262 + forking)
59    /// 2. Create second `PerlinNoise` (which sees the advanced state)
60    ///
61    /// This ensures the two `PerlinNoise` instances have different seeds.
62    #[must_use]
63    pub fn create_from_random(
64        random: &mut RandomSource,
65        first_octave: i32,
66        amplitudes: &[f64],
67    ) -> Self {
68        let first = PerlinNoise::create_from_random(random, first_octave, amplitudes);
69        let second = PerlinNoise::create_from_random(random, first_octave, amplitudes);
70
71        Self::finish(first, second, amplitudes)
72    }
73
74    /// Create a new `NormalNoise` from a positional random splitter.
75    ///
76    /// **Note**: This creates a sequential random source from the splitter's noise ID,
77    /// then delegates to `create_from_random` for vanilla-matching behavior.
78    #[must_use]
79    pub fn create(
80        splitter: &RandomSplitter,
81        noise_id: &str,
82        first_octave: i32,
83        amplitudes: &[f64],
84    ) -> Self {
85        let mut random = splitter.with_hash_of(&NameHash::new(noise_id));
86        Self::create_from_random(&mut random, first_octave, amplitudes)
87    }
88
89    /// Create a `NormalNoise` using the legacy nether biome initialization path.
90    ///
91    /// This uses `PerlinNoise::create_legacy_for_nether` instead of the hash-based
92    /// positional seeding. The `ImprovedNoise` instances are created directly from
93    /// a sequential `LegacyRandomSource`. Matches vanilla's
94    /// `NormalNoise.createLegacyNetherBiome()`.
95    #[must_use]
96    pub fn create_legacy_nether_biome(
97        random: &mut RandomSource,
98        first_octave: i32,
99        amplitudes: &[f64],
100    ) -> Self {
101        let first = PerlinNoise::create_legacy_for_nether(random, first_octave, amplitudes);
102        let second = PerlinNoise::create_legacy_for_nether(random, first_octave, amplitudes);
103
104        Self::finish(first, second, amplitudes)
105    }
106
107    /// Finish construction with the two `PerlinNoise` instances.
108    fn finish(first: PerlinNoise, second: PerlinNoise, amplitudes: &[f64]) -> Self {
109        // Find the span of non-zero octaves
110        let mut min_octave = i32::MAX;
111        let mut max_octave = i32::MIN;
112        for (i, &amp) in amplitudes.iter().enumerate() {
113            if amp != 0.0 {
114                min_octave = min_octave.min(i as i32);
115                max_octave = max_octave.max(i as i32);
116            }
117        }
118
119        // All-zero amplitudes: silent noise, always returns 0.
120        if min_octave == i32::MAX {
121            return Self {
122                first,
123                second,
124                value_factor: 0.0,
125                max_value: 0.0,
126            };
127        }
128
129        // Calculate value factor based on octave span
130        let octave_span = max_octave - min_octave;
131        let value_factor = VALUE_FACTOR_NUMERATOR / expected_deviation(octave_span);
132        let max_value = (first.max_value() + second.max_value()) * value_factor;
133
134        Self {
135            first,
136            second,
137            value_factor,
138            max_value,
139        }
140    }
141
142    /// Sample the noise at the given coordinates.
143    ///
144    /// The result combines two Perlin noise samples:
145    /// - First sampler at (x, y, z)
146    /// - Second sampler at (x * `INPUT_FACTOR`, y * `INPUT_FACTOR`, z * `INPUT_FACTOR`)
147    ///
148    /// The sum is then scaled by the value factor.
149    #[inline]
150    #[must_use]
151    pub fn get_value(&self, x: f64, y: f64, z: f64) -> f64 {
152        let x2 = x * INPUT_FACTOR;
153        let y2 = y * INPUT_FACTOR;
154        let z2 = z * INPUT_FACTOR;
155        (self.first.get_value(x, y, z) + self.second.get_value(x2, y2, z2)) * self.value_factor
156    }
157
158    /// Calculate normal noise value using SIMD vectors.
159    #[inline]
160    #[must_use]
161    pub fn get_value_simd<F, const N: usize>(
162        &self,
163        x: Simd<F, N>,
164        y: Simd<F, N>,
165        z: Simd<F, N>,
166    ) -> Simd<F, N>
167    where
168        F: SimdElement + SimdCast,
169        Simd<F, N>: SimdFloat<Cast<i32> = Simd<i32, N>>
170            + SimdPartialOrd
171            + SimdPartialEq<Mask = Mask<<F as SimdElement>::Mask, N>>
172            + ops::Add<Output = Simd<F, N>>
173            + ops::Sub<Output = Simd<F, N>>
174            + ops::Mul<Output = Simd<F, N>>
175            + ops::Div<Output = Simd<F, N>>
176            + ops::Neg<Output = Simd<F, N>>
177            + StdFloat,
178    {
179        let x2 = x * Simd::splat(INPUT_FACTOR).cast::<F>();
180        let y2 = y * Simd::splat(INPUT_FACTOR).cast::<F>();
181        let z2 = z * Simd::splat(INPUT_FACTOR).cast::<F>();
182        (self.first.get_value_simd(x, y, z) + self.second.get_value_simd(x2, y2, z2))
183            * Simd::splat(self.value_factor).cast()
184    }
185
186    /// Sample the noise at `(x, 0.0, z)`.
187    #[inline]
188    #[must_use]
189    pub fn get_value_xz(&self, x: f64, z: f64) -> f64 {
190        let x2 = x * INPUT_FACTOR;
191        let z2 = z * INPUT_FACTOR;
192        (self.first.get_value_xz(x, z) + self.second.get_value_xz(x2, z2)) * self.value_factor
193    }
194
195    /// Sample the noise at `(x, y, 0.0)`.
196    #[inline]
197    #[must_use]
198    pub fn get_value_xy(&self, x: f64, y: f64) -> f64 {
199        let x2 = x * INPUT_FACTOR;
200        let y2 = y * INPUT_FACTOR;
201        (self.first.get_value_xy(x, y) + self.second.get_value_xy(x2, y2)) * self.value_factor
202    }
203
204    /// Sample 4 Y values at fixed `(x, z)` in one call.
205    #[inline]
206    #[must_use]
207    pub fn get_value_y_4x(&self, x: f64, ys: f64x4, z: f64) -> f64x4 {
208        let x2 = x * INPUT_FACTOR;
209        let ys2 = ys * f64x4::splat(INPUT_FACTOR);
210        let z2 = z * INPUT_FACTOR;
211        (self
212            .first
213            .get_value_with_y_params_4x(x, ys, z, 0.0, 0.0, false)
214            + self
215                .second
216                .get_value_with_y_params_4x(x2, ys2, z2, 0.0, 0.0, false))
217            * f64x4::splat(self.value_factor)
218    }
219
220    /// Sample N Y values at fixed `(x, z)` in one call.
221    ///
222    /// SIMD form of [`Self::get_value`] for transpiled density-function trees
223    /// that batch N cell-corner Ys together. Per-lane math is identical to
224    /// the scalar path, so `get_value_y_simd(x, splat(y), z)[i] == get_value(x, y, z)`
225    /// for any finite `y`.
226    #[inline]
227    #[must_use]
228    pub fn get_value_y_simd<const N: usize>(
229        &self,
230        x: f64,
231        ys: Simd<f64, N>,
232        z: f64,
233    ) -> Simd<f64, N> {
234        let x2 = x * INPUT_FACTOR;
235        let ys2 = ys * Simd::splat(INPUT_FACTOR);
236        let z2 = z * INPUT_FACTOR;
237        (self
238            .first
239            .get_value_with_y_params_simd::<N>(x, ys, z, 0.0, 0.0, false)
240            + self
241                .second
242                .get_value_with_y_params_simd::<N>(x2, ys2, z2, 0.0, 0.0, false))
243            * Simd::splat(self.value_factor)
244    }
245
246    /// Get the maximum possible output value.
247    #[inline]
248    #[must_use]
249    pub const fn max_value(&self) -> f64 {
250        self.max_value
251    }
252}
253
254/// Calculate the expected deviation for a given octave span.
255///
256/// This is used to normalize the output of the combined noise.
257/// Formula: 0.1 * (1 + 1/(span + 1))
258#[inline]
259fn expected_deviation(octave_span: i32) -> f64 {
260    0.1 * (1.0 + 1.0 / f64::from(octave_span + 1))
261}
262
263#[cfg(test)]
264#[expect(
265    clippy::unreadable_literal,
266    reason = "test vectors from vanilla; underscores would obscure precision"
267)]
268mod tests {
269    use super::*;
270    use crate::random::{Random, xoroshiro::Xoroshiro};
271    use std::simd::f64x4;
272
273    #[test]
274    fn test_normal_noise_deterministic() {
275        let mut rng = Xoroshiro::from_seed(12345);
276        let splitter = rng.next_positional();
277
278        let amplitudes = [1.0, 1.0, 1.0];
279        let noise1 = NormalNoise::create(&splitter, "test_noise", -3, &amplitudes);
280        let noise2 = NormalNoise::create(&splitter, "test_noise", -3, &amplitudes);
281
282        let v1 = noise1.get_value(100.0, 64.0, 100.0);
283        let v2 = noise2.get_value(100.0, 64.0, 100.0);
284        assert!((v1 - v2).abs() < 1e-15);
285    }
286
287    #[test]
288    fn test_normal_noise_spatial_variation() {
289        let mut rng = Xoroshiro::from_seed(42);
290        let splitter = rng.next_positional();
291
292        let noise = NormalNoise::create(&splitter, "test_noise", -4, &[1.0, 1.0, 1.0, 1.0]);
293
294        // Sample at different locations
295        let values: Vec<f64> = (0..10)
296            .map(|i| noise.get_value(f64::from(i) * 50.0, 64.0, f64::from(i) * 50.0))
297            .collect();
298
299        // Check there's variation
300        let min = values.iter().copied().fold(f64::INFINITY, f64::min);
301        let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
302        assert!(max - min > 0.01, "Noise should have spatial variation");
303    }
304
305    #[test]
306    fn test_first_and_second_differ() {
307        let mut rng = Xoroshiro::from_seed(12345);
308        let splitter = rng.next_positional();
309
310        let noise = NormalNoise::create(&splitter, "test_noise", -3, &[1.0, 1.0, 1.0]);
311
312        // The first and second samplers should produce different raw values
313        // (but we can only test via the combined output)
314        let v1 = noise.get_value(1000.0, 0.0, 1000.0);
315        let v2 = noise.get_value(1001.0, 0.0, 1000.0);
316        // Values at different coordinates should differ
317        assert!((v1 - v2).abs() > 0.0001);
318    }
319
320    #[test]
321    fn test_get_value_simd_matches_scalar() {
322        let mut rng = Xoroshiro::from_seed(98_765);
323        let splitter = rng.next_positional();
324        let noise = NormalNoise::create(&splitter, "simd_xyz", -6, &[1.0, 0.0, 1.0, 1.0, 0.5]);
325        let xs = [0.0, 1.25, -1000.0, 33_554_431.5];
326        let ys = [0.0, 64.5, -32.25, 255.75];
327        let zs = [0.0, -30.75, 4096.5, -33_554_432.25];
328
329        let simd = noise.get_value_simd(
330            f64x4::from_array(xs),
331            f64x4::from_array(ys),
332            f64x4::from_array(zs),
333        );
334
335        for i in 0..4 {
336            let scalar = noise.get_value(xs[i], ys[i], zs[i]);
337            #[expect(
338                clippy::float_cmp,
339                reason = "SIMD path must be bit-identical to scalar noise for vanilla determinism"
340            )]
341            let matches = scalar == simd[i];
342            assert!(
343                matches,
344                "Mismatch at ({}, {}, {}): scalar={}, simd={}",
345                xs[i], ys[i], zs[i], scalar, simd[i],
346            );
347        }
348    }
349
350    #[test]
351    fn test_zero_axis_helpers_match_full_noise() {
352        let mut rng = Xoroshiro::from_seed(98_765);
353        let splitter = rng.next_positional();
354        let noise = NormalNoise::create(&splitter, "zero_axis", -6, &[1.0, 0.0, 1.0, 1.0, 0.5]);
355        let samples = [
356            (0.0, 0.0),
357            (1.25, -30.75),
358            (-1000.0, 4096.5),
359            (33_554_431.5, -33_554_432.25),
360            (-0.000_000_1, 0.000_000_1),
361        ];
362
363        for &(a, b) in &samples {
364            #[expect(
365                clippy::float_cmp,
366                reason = "zero-axis helpers must be bit-identical to the full scalar path"
367            )]
368            {
369                assert_eq!(noise.get_value_xz(a, b), noise.get_value(a, 0.0, b));
370                assert_eq!(noise.get_value_xy(a, b), noise.get_value(a, b, 0.0));
371            }
372        }
373    }
374
375    #[test]
376    fn test_expected_deviation() {
377        // Check the formula produces expected values
378        assert!((expected_deviation(0) - 0.2).abs() < 1e-10);
379        assert!((expected_deviation(1) - 0.15).abs() < 1e-10);
380        assert!((expected_deviation(2) - 0.13333333333333333).abs() < 1e-10);
381    }
382
383    #[test]
384    fn test_input_factor() {
385        // Verify the constant matches vanilla
386        assert!((INPUT_FACTOR - 1.0181268882175227).abs() < 1e-15);
387    }
388
389    #[test]
390    fn test_get_value_4x_matches_scalar() {
391        let mut rng = Xoroshiro::from_seed(54321);
392        let splitter = rng.next_positional();
393        let noise = NormalNoise::create(&splitter, "test_4x", -7, &[1.0; 8]);
394
395        // Various (x, z) and 4-Y batches.
396        let test_cases: &[(f64, [f64; 4], f64)] = &[
397            (0.0, [0.0, 8.0, 16.0, 24.0], 0.0),
398            (12.5, [-5.0, 10.0, 25.0, 40.0], 7.25),
399            (-100.5, [64.0, 65.0, 66.0, 67.0], 200.0),
400            (1.0, [0.0; 4], -1.0),
401        ];
402
403        for &(x, ys, z) in test_cases {
404            let ys_v = f64x4::from_array(ys);
405            let simd = noise.get_value_y_4x(x, ys_v, z);
406            let generic = noise.get_value_y_simd(x, ys_v, z);
407            for i in 0..4 {
408                let scalar = noise.get_value(x, ys[i], z);
409                let simd_val = simd[i];
410                let generic_val = generic[i];
411                #[expect(
412                    clippy::float_cmp,
413                    reason = "SIMD/scalar paths must produce bit-identical results for vanilla determinism"
414                )]
415                let bit_match = scalar == simd_val;
416                assert!(
417                    bit_match,
418                    "Mismatch at x={x}, y={}, z={z}: scalar={scalar}, simd={simd_val}",
419                    ys[i]
420                );
421                #[expect(
422                    clippy::float_cmp,
423                    reason = "explicit 4x and generic SIMD paths should be equivalent"
424                )]
425                let generic_match = simd_val == generic_val;
426                assert!(
427                    generic_match,
428                    "Generic mismatch at x={x}, y={}, z={z}: 4x={simd_val}, generic={generic_val}",
429                    ys[i]
430                );
431            }
432        }
433    }
434}