Skip to main content

steel_worldgen/noise/
blended_noise.rs

1//! `BlendedNoise` implementation matching vanilla Minecraft's `BlendedNoise.java`
2//!
3//! Combines three `PerlinNoise` instances (min limit, max limit, main) for terrain generation.
4//! The main noise determines the blend factor between the min and max limit noises.
5
6use std::simd::Simd;
7use std::simd::cmp::SimdPartialOrd;
8
9use crate::noise::PerlinNoise;
10use crate::random::RandomSource;
11use steel_math::{clamped_lerp, clamped_lerp_simd, wrap, wrap_simd};
12
13/// Base frequency multiplier for all `BlendedNoise` coordinate transforms.
14const COORDINATE_SCALE: f64 = 684.412;
15
16/// Runtime `BlendedNoise` sampler with three seeded `PerlinNoise` instances.
17///
18/// Matches vanilla's `BlendedNoise` density function.
19#[derive(Debug, Clone)]
20pub struct BlendedNoise {
21    min_limit_noise: PerlinNoise,
22    max_limit_noise: PerlinNoise,
23    main_noise: PerlinNoise,
24    xz_multiplier: f64,
25    y_multiplier: f64,
26    xz_factor: f64,
27    y_factor: f64,
28    smear_scale_multiplier: f64,
29    max_value: f64,
30}
31
32impl BlendedNoise {
33    /// Create a new `BlendedNoise` from a random source and scale parameters.
34    ///
35    /// This matches vanilla's `BlendedNoise(RandomSource, ...)` constructor which
36    /// uses the legacy initialization path with `createLegacyForBlendedNoise`.
37    #[must_use]
38    pub fn new(
39        random: &mut RandomSource,
40        xz_scale: f64,
41        y_scale: f64,
42        xz_factor: f64,
43        y_factor: f64,
44        smear_scale_multiplier: f64,
45    ) -> Self {
46        // min/max limit: 16 octaves (-15 to 0), main: 8 octaves (-7 to 0)
47        let min_limit_noise = PerlinNoise::create_legacy_for_nether(random, -15, &[1.0; 16]);
48        let max_limit_noise = PerlinNoise::create_legacy_for_nether(random, -15, &[1.0; 16]);
49        let main_noise = PerlinNoise::create_legacy_for_nether(random, -7, &[1.0; 8]);
50
51        let xz_multiplier = COORDINATE_SCALE * xz_scale;
52        let y_multiplier = COORDINATE_SCALE * y_scale;
53        let max_value = min_limit_noise.max_broken_value(y_multiplier);
54
55        Self {
56            min_limit_noise,
57            max_limit_noise,
58            main_noise,
59            xz_multiplier,
60            y_multiplier,
61            xz_factor,
62            y_factor,
63            smear_scale_multiplier,
64            max_value,
65        }
66    }
67
68    /// Compute the blended noise value at the given block coordinates.
69    #[must_use]
70    pub fn compute(&self, block_x: f64, block_y: f64, block_z: f64) -> f64 {
71        let limit_x = block_x * self.xz_multiplier;
72        let limit_y = block_y * self.y_multiplier;
73        let limit_z = block_z * self.xz_multiplier;
74        let main_x = limit_x / self.xz_factor;
75        let main_y = limit_y / self.y_factor;
76        let main_z = limit_z / self.xz_factor;
77        let limit_smear = self.y_multiplier * self.smear_scale_multiplier;
78        let main_smear = limit_smear / self.y_factor;
79
80        // Sample main noise (8 octaves, highest frequency first)
81        let mut main_noise_value = 0.0;
82        let mut pow = 1.0;
83        for i in 0..8 {
84            if let Some(noise) = self.main_noise.get_octave_noise(i) {
85                main_noise_value += noise.noise_with_y_scale(
86                    wrap(main_x * pow),
87                    wrap(main_y * pow),
88                    wrap(main_z * pow),
89                    main_smear * pow,
90                    main_y * pow,
91                ) / pow;
92            }
93            pow /= 2.0;
94        }
95
96        // Determine blend factor and which limit noises to sample
97        let factor = f64::midpoint(main_noise_value / 10.0, 1.0);
98        let is_max = factor >= 1.0;
99        let is_min = factor <= 0.0;
100
101        // Sample limit noises (16 octaves each, highest frequency first)
102        let mut blend_min = 0.0;
103        let mut blend_max = 0.0;
104        pow = 1.0;
105        for i in 0..16 {
106            let wx = wrap(limit_x * pow);
107            let wy = wrap(limit_y * pow);
108            let wz = wrap(limit_z * pow);
109            let y_scale_pow = limit_smear * pow;
110
111            if !is_max && let Some(noise) = self.min_limit_noise.get_octave_noise(i) {
112                blend_min += noise.noise_with_y_scale(wx, wy, wz, y_scale_pow, limit_y * pow) / pow;
113            }
114
115            if !is_min && let Some(noise) = self.max_limit_noise.get_octave_noise(i) {
116                blend_max += noise.noise_with_y_scale(wx, wy, wz, y_scale_pow, limit_y * pow) / pow;
117            }
118
119            pow /= 2.0;
120        }
121
122        clamped_lerp(blend_min / 512.0, blend_max / 512.0, factor) / 128.0
123    }
124
125    /// Compute blended noise for N points sharing the same (x, z) but with
126    /// different y values. Returns results as an array.
127    ///
128    /// This uses SIMD to vectorize the math-heavy portions (gradient dots,
129    /// smoothstep, trilinear lerp) across the N Y lanes, while sharing
130    /// the x/z coordinate work.
131    #[inline]
132    #[must_use]
133    pub fn compute_simd<const N: usize>(
134        &self,
135        block_x: f64,
136        block_ys: [f64; N],
137        block_z: f64,
138    ) -> [f64; N] {
139        let limit_x = block_x * self.xz_multiplier;
140        let limit_ys = Simd::from_array(block_ys) * Simd::splat(self.y_multiplier);
141        let limit_z = block_z * self.xz_multiplier;
142        let main_x = limit_x / self.xz_factor;
143        let main_ys = limit_ys / Simd::splat(self.y_factor);
144        let main_z = limit_z / self.xz_factor;
145        let limit_smear = self.y_multiplier * self.smear_scale_multiplier;
146        let main_smear = limit_smear / self.y_factor;
147
148        let mut main_noise_values = Simd::splat(0.0);
149        let mut pow = 1.0;
150        for i in 0..8 {
151            if let Some(noise) = self.main_noise.get_octave_noise(i) {
152                let pow_v = Simd::splat(pow);
153                let scaled_ys = main_ys * pow_v;
154                main_noise_values += noise.noise_with_y_scale_simd(
155                    wrap(main_x * pow),
156                    wrap_simd(scaled_ys),
157                    wrap(main_z * pow),
158                    main_smear * pow,
159                    scaled_ys,
160                ) / pow_v;
161            }
162            pow /= 2.0;
163        }
164
165        let factors = (main_noise_values / Simd::splat(10.0) + Simd::splat(1.0)) / Simd::splat(2.0);
166
167        let all_max = factors.simd_ge(Simd::splat(1.0)).all();
168        let all_min = factors.simd_le(Simd::splat(0.0)).all();
169
170        let mut blend_min = Simd::splat(0.0);
171        let mut blend_max = Simd::splat(0.0);
172        pow = 1.0;
173        for i in 0..16 {
174            let pow_v = Simd::splat(pow);
175            let scaled_ys = limit_ys * pow_v;
176            let wx = wrap(limit_x * pow);
177            let wys = wrap_simd(scaled_ys);
178            let wz = wrap(limit_z * pow);
179            let y_scale_pow = limit_smear * pow;
180
181            if !all_max && let Some(noise) = self.min_limit_noise.get_octave_noise(i) {
182                blend_min +=
183                    noise.noise_with_y_scale_simd(wx, wys, wz, y_scale_pow, scaled_ys) / pow_v;
184            }
185
186            if !all_min && let Some(noise) = self.max_limit_noise.get_octave_noise(i) {
187                blend_max +=
188                    noise.noise_with_y_scale_simd(wx, wys, wz, y_scale_pow, scaled_ys) / pow_v;
189            }
190
191            pow /= 2.0;
192        }
193
194        let min_scaled = blend_min / Simd::splat(512.0);
195        let max_scaled = blend_max / Simd::splat(512.0);
196        let result = clamped_lerp_simd(min_scaled, max_scaled, factors) / Simd::splat(128.0);
197        result.to_array()
198    }
199
200    /// Compute blended noise for a column of Y values, returning the results.
201    ///
202    /// Uses SIMD to process 4 Y values at a time.
203    pub fn compute_column(&self, block_x: i32, block_ys: &[i32], block_z: i32, out: &mut [f64]) {
204        let count = block_ys.len().min(out.len());
205        let block_x = f64::from(block_x);
206        let block_z = f64::from(block_z);
207        let mut processed = 0;
208
209        // SIMD batches of 4
210        let chunks_4 = count / 4;
211        for chunk in 0..chunks_4 {
212            let base = chunk * 4;
213            let batch_ys = [
214                f64::from(block_ys[base]),
215                f64::from(block_ys[base + 1]),
216                f64::from(block_ys[base + 2]),
217                f64::from(block_ys[base + 3]),
218            ];
219            out[base..base + 4].copy_from_slice(&self.compute_simd(block_x, batch_ys, block_z));
220        }
221        processed += chunks_4 * 4;
222
223        // SIMD batches of 2 (max 1 chunk possible after chunks of 4)
224        if count - processed >= 2 {
225            let batch_ys = [
226                f64::from(block_ys[processed]),
227                f64::from(block_ys[processed + 1]),
228            ];
229            out[processed..processed + 2]
230                .copy_from_slice(&self.compute_simd(block_x, batch_ys, block_z));
231            processed += 2;
232        }
233
234        // Scalar remainder (handles the final 0 or 1 element)
235        for i in processed..count {
236            out[i] = self.compute(block_x, f64::from(block_ys[i]), block_z);
237        }
238    }
239
240    /// Maximum possible output value.
241    #[inline]
242    #[must_use]
243    pub const fn max_value(&self) -> f64 {
244        self.max_value
245    }
246
247    /// Minimum possible output value (negative of max).
248    #[inline]
249    #[must_use]
250    pub fn min_value(&self) -> f64 {
251        -self.max_value
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::random::xoroshiro::Xoroshiro;
259
260    fn make_source(seed: u64) -> RandomSource {
261        RandomSource::Xoroshiro(Xoroshiro::from_seed(seed))
262    }
263
264    #[test]
265    fn test_compute_4x_matches_scalar() {
266        let bn = BlendedNoise::new(&mut make_source(42), 1.0, 1.0, 80.0, 160.0, 8.0);
267
268        // Test column of Y values at various (x, z)
269        let test_cases: &[(f64, [f64; 4], f64)] = &[
270            (0., [0., 8., 16., 24.], 0.),
271            (16., [32., 40., 48., 56.], 16.),
272            (-8., [-64., -32., 0., 32.], 8.),
273            (100., [60., 64., 68., 72.], -50.),
274            (0., [-4., -2., 0., 2.], 0.),
275        ];
276
277        for &(x, ys, z) in test_cases {
278            let simd = bn.compute_simd(x, ys, z);
279            for i in 0..4 {
280                let scalar = bn.compute(x, ys[i], z);
281                assert!(
282                    (scalar - simd[i]).abs() < 1e-12,
283                    "Mismatch at ({x}, {}, {z}): scalar={scalar}, simd={}, diff={}",
284                    ys[i],
285                    simd[i],
286                    (scalar - simd[i]).abs(),
287                );
288            }
289        }
290    }
291
292    #[test]
293    fn test_compute_column_matches_scalar() {
294        let bn = BlendedNoise::new(&mut make_source(42), 1.0, 1.0, 80.0, 160.0, 8.0);
295
296        // 49 Y values like the actual overworld (cell_min_y=-8, corners_y=49, cell_height=8)
297        let block_ys: Vec<i32> = (0..49).map(|cy| (cy - 8) * 8).collect();
298
299        let scalar_results: Vec<f64> = block_ys
300            .iter()
301            .map(|&y| bn.compute(0., f64::from(y), 0.))
302            .collect();
303
304        let mut column_results = vec![0.0; block_ys.len()];
305        bn.compute_column(0, &block_ys, 0, &mut column_results);
306
307        for (i, &y) in block_ys.iter().enumerate() {
308            assert!(
309                (scalar_results[i] - column_results[i]).abs() < 1e-12,
310                "Column mismatch at y={y}: scalar={}, column={}, diff={}",
311                scalar_results[i],
312                column_results[i],
313                (scalar_results[i] - column_results[i]).abs(),
314            );
315        }
316    }
317
318    #[test]
319    fn test_blended_noise_deterministic() {
320        let bn1 = BlendedNoise::new(&mut make_source(12345), 1.0, 1.0, 80.0, 160.0, 8.0);
321        let bn2 = BlendedNoise::new(&mut make_source(12345), 1.0, 1.0, 80.0, 160.0, 8.0);
322
323        let v1 = bn1.compute(0., 64., 0.);
324        let v2 = bn2.compute(0., 64., 0.);
325        assert!(
326            (v1 - v2).abs() < 1e-15,
327            "BlendedNoise not deterministic: {v1} vs {v2}",
328        );
329    }
330
331    #[test]
332    fn test_blended_noise_spatial_variation() {
333        let bn = BlendedNoise::new(&mut make_source(42), 1.0, 1.0, 80.0, 160.0, 8.0);
334
335        let values: Vec<f64> = (-5..5)
336            .map(|x| bn.compute(f64::from(x * 16), 64., 0.))
337            .collect();
338
339        let min = values.iter().copied().fold(f64::INFINITY, f64::min);
340        let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
341        assert!(
342            max - min > 1e-6,
343            "BlendedNoise should have variation: {values:?}"
344        );
345    }
346
347    #[test]
348    fn test_blended_noise_range() {
349        let bn = BlendedNoise::new(&mut make_source(42), 1.0, 1.0, 80.0, 160.0, 8.0);
350
351        for x in -10..10 {
352            for y in (-4..20).step_by(4) {
353                let v = bn.compute(f64::from(x * 16), f64::from(y * 4), f64::from(x * 16));
354                assert!(
355                    v.abs() <= bn.max_value() + 0.01,
356                    "BlendedNoise value {v} exceeds max {} at ({}, {}, {})",
357                    bn.max_value(),
358                    x * 16,
359                    y * 4,
360                    x * 16,
361                );
362            }
363        }
364    }
365}