Skip to main content

steel_worldgen/noise/
noise_chunk.rs

1//! `NoiseChunk`: cell-based terrain density evaluation with trilinear interpolation.
2//!
3//! Matches vanilla's `NoiseChunk` + `NoiseBasedChunkGenerator.doFill()` flow.
4//!
5//! Vanilla wraps density functions with `Interpolated` markers. Only the inner
6//! functions (arguments to `Interpolated`) are evaluated at cell corners; the
7//! outer operations (squeeze, min, etc.) are applied per-block after trilinear
8//! interpolation. Each `Interpolated` marker gets its own independent channel.
9//!
10//! Cell dimensions depend on the dimension's noise settings.
11
12use std::marker::PhantomData;
13use std::simd::f64x4;
14
15use steel_math::lerp;
16use steel_worldgen::density::{ColumnCache, DimensionNoises, NoiseSettings};
17
18use crate::noise::Beardifier;
19
20/// Maximum number of interpolation channels supported.
21/// Overworld uses 8 (1 terrain + 4 noodle caves + 3 vein channels), nether/end use 1.
22const MAX_INTERP: usize = 16;
23
24/// Maximum slice length (`z_corners` * `corners_y`) across all dimensions.
25/// Overworld: (16/4+1) * (384/8+1) = 5 * 49 = 245. Rounded up for headroom.
26const MAX_SLICE_LEN: usize = 256;
27
28/// Stores density values at cell corners for a single chunk and provides
29/// trilinear interpolation between corners for block-level resolution.
30///
31/// Supports multiple interpolation channels matching vanilla's multi-interpolator
32/// system. Each `Interpolated` marker in the density function tree gets its own
33/// channel, filled at cell corners and interpolated independently.
34///
35/// Storage is per-corner `SoA` — `slice[corner_idx * MAX_INTERP + ch]` — so 4
36/// adjacent channels' values at a given corner sit in contiguous memory,
37/// enabling a single `f64x4` load and SIMD-batched trilinear interpolation
38/// across 4 channels per block.
39pub struct NoiseChunk<N: DimensionNoises> {
40    /// One slice per cell-X boundary, holding density values at the cell
41    /// corners on that X-plane. Length is `cell_count_xz + 1`. Indexed as
42    /// `slices[cx][corner_idx * MAX_INTERP + ch]` where
43    /// `corner_idx = z_corner * corners_y + y_corner` (range `[0, slice_len)`)
44    /// and `ch` is the interpolation channel (range `[0, interp_count)`).
45    ///
46    /// We keep all slices materialized rather than alternating two buffers so
47    /// the slice-fill phase can run in parallel: each `cx` boundary's noise
48    /// tree evaluation is independent. The per-block trilerp loop then
49    /// indexes `slices[cx]` and `slices[cx + 1]` sequentially.
50    slices: Vec<Box<[f64; MAX_INTERP * MAX_SLICE_LEN]>>,
51    /// Number of active interpolation channels.
52    interp_count: usize,
53    /// Number of Y corners per Z column (`cell_count_y` + 1).
54    corners_y: usize,
55
56    /// Per-corner block-Y values, precomputed once at construction.
57    /// Same for every slice fill (depends only on `cell_min_y`,
58    /// `cell_height`, and `corners_y`).
59    block_ys: Vec<i32>,
60
61    /// First cell X/Z in world coordinates (cell index, not block).
62    first_cell_x: i32,
63    first_cell_z: i32,
64    /// Minimum cell Y index.
65    cell_min_y: i32,
66    /// Number of cells in Y direction.
67    cell_count_y: usize,
68    /// Number of cells per chunk in XZ.
69    cell_count_xz: usize,
70
71    _phantom: PhantomData<N>,
72}
73
74impl<N: DimensionNoises> NoiseChunk<N> {
75    /// Create a new `NoiseChunk` for the given chunk position.
76    ///
77    /// `chunk_min_block_x` and `chunk_min_block_z` are the world-space block
78    /// coordinates of the chunk's northwest corner.
79    #[must_use]
80    #[expect(
81        clippy::missing_panics_doc,
82        reason = "panic is a compile-time constant check"
83    )]
84    pub fn new(chunk_min_block_x: i32, chunk_min_block_z: i32) -> Self {
85        let cell_width = N::Settings::CELL_WIDTH;
86        let cell_height = N::Settings::CELL_HEIGHT;
87        let min_y = N::Settings::MIN_Y;
88        let height = N::Settings::HEIGHT;
89
90        let first_cell_x = chunk_min_block_x.div_euclid(cell_width);
91        let first_cell_z = chunk_min_block_z.div_euclid(cell_width);
92        let cell_min_y = min_y.div_euclid(cell_height);
93
94        let cell_count_xz = (16 / cell_width) as usize;
95        let cell_count_y = (height / cell_height) as usize;
96        let corners_y = cell_count_y + 1;
97        let z_corners = cell_count_xz + 1;
98        let slice_len = z_corners * corners_y;
99
100        let interp_count = N::interpolated_count();
101        assert!(
102            slice_len <= MAX_SLICE_LEN,
103            "slice_len {slice_len} exceeds MAX_SLICE_LEN {MAX_SLICE_LEN}"
104        );
105        assert!(
106            interp_count <= MAX_INTERP,
107            "interp_count {interp_count} exceeds MAX_INTERP {MAX_INTERP}"
108        );
109
110        let block_ys: Vec<i32> = (0..corners_y)
111            .map(|cy| (cy as i32 + cell_min_y) * cell_height)
112            .collect();
113
114        let n_slices = cell_count_xz + 1;
115        let mut slices = Vec::with_capacity(n_slices);
116        for _ in 0..n_slices {
117            // The boxed fixed-size array keeps the `[f64; N]` type that the SIMD
118            // `fill` path and its `get_unchecked` SAFETY proofs rely on. This is a
119            // per-chunk constructor, not a hot path, so the stack temporary is fine.
120            #[expect(
121                clippy::large_stack_arrays,
122                reason = "fixed-size boxed array keeps the [f64; N] type the SIMD fill path relies on; cold per-chunk constructor"
123            )]
124            slices.push(Box::new([0.0; MAX_INTERP * MAX_SLICE_LEN]));
125        }
126
127        Self {
128            slices,
129            interp_count,
130            corners_y,
131            block_ys,
132            first_cell_x,
133            first_cell_z,
134            cell_min_y,
135            cell_count_y,
136            cell_count_xz,
137            _phantom: PhantomData,
138        }
139    }
140
141    /// Fill the slice buffer for the given cell X. Free-standing function so
142    /// each parallel slice-fill can run on its own thread with its own
143    /// `ColumnCache` clone.
144    #[expect(
145        clippy::too_many_arguments,
146        reason = "slice filling needs the precomputed geometry and per-thread cache"
147    )]
148    fn fill_slice_into(
149        slice: &mut [f64; MAX_INTERP * MAX_SLICE_LEN],
150        cell_x: i32,
151        block_ys: &[i32],
152        blended_column: &mut [f64],
153        interp_count: usize,
154        corners_y: usize,
155        cell_count_xz: usize,
156        first_cell_z: i32,
157        noises: &N,
158        cache: &mut N::ColumnCache,
159    ) {
160        let cell_width = N::Settings::CELL_WIDTH;
161
162        let block_x = cell_x * cell_width;
163
164        let mut values = [0.0f64; MAX_INTERP];
165
166        // Scratch buffer for the 4-Y SIMD batch. Lane-major SoA: lane `i`'s
167        // `interp_count` channels live at `values_4x[i * interp_count..]`.
168        let mut values_4x = [0.0f64; 4 * MAX_INTERP];
169
170        for cz in 0..=cell_count_xz {
171            let cell_z = first_cell_z + cz as i32;
172            let block_z = cell_z * cell_width;
173
174            // Ensure column cache for this (x, z)
175            cache.ensure(block_x, block_z, noises);
176
177            // SIMD-batch blended noise for the entire Y column.
178            noises.compute_noise_column(block_x, block_ys, block_z, blended_column);
179
180            // 4-Y SIMD-batched corner fill. Tail is handled by the scalar
181            // loop below for any remaining `corners_y % 4` corners.
182            let mut cy = 0;
183            while cy + 4 <= corners_y {
184                let ys_v = f64x4::from_array([
185                    f64::from(block_ys[cy]),
186                    f64::from(block_ys[cy + 1]),
187                    f64::from(block_ys[cy + 2]),
188                    f64::from(block_ys[cy + 3]),
189                ]);
190                let blended_v = f64x4::from_array([
191                    blended_column[cy],
192                    blended_column[cy + 1],
193                    blended_column[cy + 2],
194                    blended_column[cy + 3],
195                ]);
196
197                noises.fill_cell_corner_densities_4x(
198                    cache,
199                    block_x,
200                    ys_v,
201                    block_z,
202                    blended_v,
203                    &mut values_4x[..4 * interp_count],
204                );
205
206                for lane in 0..4 {
207                    let lane_cy = cy + lane;
208                    let src = &values_4x[lane * interp_count..(lane + 1) * interp_count];
209                    let corner_idx = cz * corners_y + lane_cy;
210                    let base = corner_idx * MAX_INTERP;
211                    slice[base..base + interp_count].copy_from_slice(src);
212                }
213
214                cy += 4;
215            }
216
217            while cy < corners_y {
218                let block_y = block_ys[cy];
219
220                noises.fill_cell_corner_densities(
221                    cache,
222                    block_x,
223                    block_y,
224                    block_z,
225                    blended_column[cy],
226                    &mut values[..interp_count],
227                );
228
229                let corner_idx = cz * corners_y + cy;
230                let base = corner_idx * MAX_INTERP;
231                slice[base..base + interp_count].copy_from_slice(&values[..interp_count]);
232
233                cy += 1;
234            }
235        }
236    }
237
238    /// Fill the chunk with terrain blocks using multi-channel trilinear interpolation.
239    ///
240    /// For each block position:
241    /// 1. Trilinearly interpolate each channel independently from cell corners
242    /// 2. Apply outer operations (squeeze, min, etc.) via `combine_interpolated`
243    /// 3. Call `place_block` with the final density
244    #[expect(
245        clippy::too_many_lines,
246        reason = "single SIMD trilinear-interpolation kernel; splitting the loop nest would scatter the per-corner SAFETY invariants"
247    )]
248    #[expect(
249        clippy::similar_names,
250        reason = "factor_{x,y,z}_v vector splats deliberately mirror their scalar factor_{x,y,z} sources"
251    )]
252    pub fn fill<F>(
253        &mut self,
254        noises: &N,
255        cache: &mut N::ColumnCache,
256        beardifier: Option<&Beardifier>,
257        mut place_block: F,
258    ) where
259        F: FnMut(usize, i32, usize, f64, &[f64], &mut N::ColumnCache),
260    {
261        let cell_width = N::Settings::CELL_WIDTH;
262        let cell_height = N::Settings::CELL_HEIGHT;
263        let cell_count_xz = self.cell_count_xz;
264        let cell_count_y = self.cell_count_y;
265        let interp_count = self.interp_count;
266        let corners_y = self.corners_y;
267        let first_cell_x = self.first_cell_x;
268        let first_cell_z = self.first_cell_z;
269        let block_ys: &[i32] = &self.block_ys;
270
271        // Pre-fill ALL slices sequentially. Each `(cell_x boundary)` slice is an
272        // independent noise-tree evaluation; the grid in `cache` is set up by the
273        // caller via `init_grid` and is read-only here, while each slice only
274        // overwrites the cache's per-column active fields — so one cache is reused
275        // across slices without cloning. The chunk pipeline already parallelises
276        // across chunks, so parallelising the 5 slices here would nest rayon work
277        // and add coordination + cache-clone overhead with no spare cores to use.
278        let n_slices = cell_count_xz + 1;
279        let mut local_blended = vec![0.0f64; corners_y];
280        for cx_off in 0..n_slices {
281            let cell_x = first_cell_x + cx_off as i32;
282            Self::fill_slice_into(
283                &mut self.slices[cx_off],
284                cell_x,
285                block_ys,
286                &mut local_blended,
287                interp_count,
288                corners_y,
289                cell_count_xz,
290                first_cell_z,
291                noises,
292                cache,
293            );
294        }
295
296        let mut interpolated = [0.0f64; MAX_INTERP];
297
298        for cell_x_idx in 0..cell_count_xz {
299            for cell_z_idx in 0..cell_count_xz {
300                for x_in_cell in 0..cell_width {
301                    let factor_x = f64::from(x_in_cell) / f64::from(cell_width);
302                    let local_x = (cell_x_idx as i32 * cell_width + x_in_cell) as usize;
303
304                    for z_in_cell in 0..cell_width {
305                        let factor_z = f64::from(z_in_cell) / f64::from(cell_width);
306                        let local_z = (cell_z_idx as i32 * cell_width + z_in_cell) as usize;
307
308                        // Pre-compute flat indices for this Z column
309                        let z0_base = cell_z_idx * corners_y;
310                        let z1_base = (cell_z_idx + 1) * corners_y;
311
312                        // Process entire Y column at this (x, z)
313                        for cell_y_idx in (0..cell_count_y).rev() {
314                            for y_in_cell in (0..cell_height).rev() {
315                                let factor_y = f64::from(y_in_cell) / f64::from(cell_height);
316
317                                let world_y =
318                                    (self.cell_min_y + cell_y_idx as i32) * cell_height + y_in_cell;
319
320                                // Trilinearly interpolate each channel.
321                                //
322                                // SoA layout puts the 4 (or fewer) channel
323                                // values for one corner in contiguous memory,
324                                // so a single `f64x4` load per corner replaces
325                                // four scattered scalar loads in the legacy
326                                // AoS path. Math is per-lane independent and
327                                // matches the scalar order exactly, so the
328                                // result is bit-identical to vanilla.
329                                //
330                                // SAFETY: max index = (z1_base + cell_y_idx + 1) * MAX_INTERP + (ch_batch+3)
331                                //         ≤ ((cell_count_xz+1)*corners_y - 1) * MAX_INTERP + MAX_INTERP - 1
332                                //         < MAX_SLICE_LEN * MAX_INTERP
333                                let i0_base = (z0_base + cell_y_idx) * MAX_INTERP;
334                                let i1_base = (z1_base + cell_y_idx) * MAX_INTERP;
335                                let i0_next = i0_base + MAX_INTERP;
336                                let i1_next = i1_base + MAX_INTERP;
337                                let s0 = &*self.slices[cell_x_idx];
338                                let s1 = &*self.slices[cell_x_idx + 1];
339                                let factor_y_v = f64x4::splat(factor_y);
340                                let factor_x_v = f64x4::splat(factor_x);
341                                let factor_z_v = f64x4::splat(factor_z);
342
343                                let mut ch_batch = 0;
344                                while ch_batch + 4 <= interp_count {
345                                    // SAFETY: ch_batch+3 < interp_count ≤ MAX_INTERP, all base indices in bounds.
346                                    unsafe {
347                                        let n000 = f64x4::from_slice(s0.get_unchecked(
348                                            i0_base + ch_batch..i0_base + ch_batch + 4,
349                                        ));
350                                        let n001 = f64x4::from_slice(s0.get_unchecked(
351                                            i1_base + ch_batch..i1_base + ch_batch + 4,
352                                        ));
353                                        let n100 = f64x4::from_slice(s1.get_unchecked(
354                                            i0_base + ch_batch..i0_base + ch_batch + 4,
355                                        ));
356                                        let n101 = f64x4::from_slice(s1.get_unchecked(
357                                            i1_base + ch_batch..i1_base + ch_batch + 4,
358                                        ));
359                                        let n010 = f64x4::from_slice(s0.get_unchecked(
360                                            i0_next + ch_batch..i0_next + ch_batch + 4,
361                                        ));
362                                        let n011 = f64x4::from_slice(s0.get_unchecked(
363                                            i1_next + ch_batch..i1_next + ch_batch + 4,
364                                        ));
365                                        let n110 = f64x4::from_slice(s1.get_unchecked(
366                                            i0_next + ch_batch..i0_next + ch_batch + 4,
367                                        ));
368                                        let n111 = f64x4::from_slice(s1.get_unchecked(
369                                            i1_next + ch_batch..i1_next + ch_batch + 4,
370                                        ));
371
372                                        let d00 = n000 + factor_y_v * (n010 - n000);
373                                        let d10 = n100 + factor_y_v * (n110 - n100);
374                                        let d01 = n001 + factor_y_v * (n011 - n001);
375                                        let d11 = n101 + factor_y_v * (n111 - n101);
376                                        let d0 = d00 + factor_x_v * (d10 - d00);
377                                        let d1 = d01 + factor_x_v * (d11 - d01);
378                                        let result = d0 + factor_z_v * (d1 - d0);
379                                        let arr = result.to_array();
380                                        let dst =
381                                            interpolated.get_unchecked_mut(ch_batch..ch_batch + 4);
382                                        dst.copy_from_slice(&arr);
383                                    }
384                                    ch_batch += 4;
385                                }
386                                // Scalar tail (when interp_count is not a multiple of 4).
387                                while ch_batch < interp_count {
388                                    let ch = ch_batch;
389                                    // SAFETY: ch < interp_count ≤ MAX_INTERP; indices in bounds (see comment above).
390                                    unsafe {
391                                        let n000 = *s0.get_unchecked(i0_base + ch);
392                                        let n001 = *s0.get_unchecked(i1_base + ch);
393                                        let n100 = *s1.get_unchecked(i0_base + ch);
394                                        let n101 = *s1.get_unchecked(i1_base + ch);
395                                        let n010 = *s0.get_unchecked(i0_next + ch);
396                                        let n011 = *s0.get_unchecked(i1_next + ch);
397                                        let n110 = *s1.get_unchecked(i0_next + ch);
398                                        let n111 = *s1.get_unchecked(i1_next + ch);
399
400                                        let d00 = lerp(factor_y, n000, n010);
401                                        let d10 = lerp(factor_y, n100, n110);
402                                        let d01 = lerp(factor_y, n001, n011);
403                                        let d11 = lerp(factor_y, n101, n111);
404                                        let d0 = lerp(factor_x, d00, d10);
405                                        let d1 = lerp(factor_x, d01, d11);
406                                        *interpolated.get_unchecked_mut(ch) =
407                                            lerp(factor_z, d0, d1);
408                                    }
409                                    ch_batch += 1;
410                                }
411
412                                // Apply outer operations per-block.
413                                // x/z are 0 because vanilla's outer operations (squeeze, add, mul,
414                                // quarter_negative, blend_alpha, blend_offset) are x/z-independent;
415                                // only Y matters for YClampedGradient.
416                                let mut density = noises.combine_interpolated(
417                                    cache,
418                                    &interpolated[..interp_count],
419                                    0,
420                                    world_y,
421                                    0,
422                                );
423
424                                // Vanilla integrates beardifier as `add(final_density, beardifier)`
425                                // wrapped in `cacheAllInCell` — i.e. evaluated per-block, after the
426                                // outer ops on `final_density` have run. Adding it at cell corners
427                                // would put it inside the squeeze and trilerp it linearly across
428                                // the cell, both of which diverge from vanilla for large beardifier
429                                // values inside a structure's pieces.
430                                let world_x = cell_x_idx as i32 * cell_width
431                                    + x_in_cell
432                                    + self.first_cell_x * cell_width;
433                                let world_z = cell_z_idx as i32 * cell_width
434                                    + z_in_cell
435                                    + self.first_cell_z * cell_width;
436                                if let Some(beard) = beardifier {
437                                    density += beard.compute(world_x, world_y, world_z);
438                                }
439
440                                place_block(
441                                    local_x,
442                                    world_y,
443                                    local_z,
444                                    density,
445                                    &interpolated[..interp_count],
446                                    cache,
447                                );
448                            }
449                        }
450                    }
451                }
452            }
453
454            // No swap needed: all slices are pre-filled and indexed directly
455            // via `self.slices[cell_x_idx]` / `[cell_x_idx + 1]`.
456        }
457    }
458}