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}