roxlap_scene/chunks.rs
1//! Sparse chunk storage helpers.
2//!
3//! A grid's [`Grid::chunks`] map holds populated chunks keyed by
4//! their `(chx, chy, chz)` index. A missing entry is an implicit
5//! all-air chunk; this module provides the constructor for fresh
6//! all-air chunks plus the `chunk` / `chunk_mut` / `ensure_chunk`
7//! lookup API.
8//!
9//! [`Grid::chunks`]: crate::Grid::chunks
10
11use glam::IVec3;
12use roxlap_formats::edit::{set_spans, Vspan};
13use roxlap_formats::vxl::Vxl;
14
15use crate::{Grid, CHUNK_SIZE_XY, CHUNK_SIZE_Z};
16
17/// Bytes of edit-pool headroom reserved per chunk on creation.
18/// 256 bytes/column × 128² columns ≈ 4 MiB; a generous budget for
19/// runtime edits within a single chunk before [`voxalloc`] starts
20/// returning out-of-space. Tunable later if memory becomes an
21/// issue.
22///
23/// [`voxalloc`]: roxlap_formats::vxl::Vxl::voxalloc
24const CHUNK_EDIT_HEADROOM_PER_COLUMN: usize = 256;
25
26/// Construct a fresh all-air [`Vxl`] sized for one chunk
27/// (`vsid = CHUNK_SIZE_XY`).
28///
29/// Strategy mirrors `roxlap_cavegen::pack_dense_grid_to_vxl`: seed
30/// each column with one solid voxel at z=0 + implicit-solid below
31/// (the voxlap "loadnul" shape), then carve the entire z range to
32/// air via [`set_spans`]. Finishes with [`Vxl::reserve_edit_capacity`]
33/// so subsequent runtime edits don't need a separate upgrade pass.
34///
35/// This is the canonical empty-chunk constructor — every code
36/// path that materialises a sparse chunk goes through it (see
37/// [`Grid::ensure_chunk`]).
38pub(crate) fn empty_chunk_vxl() -> Vxl {
39 let vsid = CHUNK_SIZE_XY;
40 let n_cols = (vsid as usize) * (vsid as usize);
41
42 // 1. Seed: every column = 4-byte slab header + 1 colour. Colour
43 // is irrelevant — the whole column gets carved below.
44 let mut data: Vec<u8> = Vec::with_capacity(n_cols * 8);
45 let mut column_offset: Vec<u32> = Vec::with_capacity(n_cols + 1);
46 for _ in 0..n_cols {
47 column_offset.push(u32::try_from(data.len()).expect("offset fits in u32"));
48 data.extend_from_slice(&[0, 0, 0, 0]); // header
49 data.extend_from_slice(&[0, 0, 0, 0]); // 1 placeholder colour
50 }
51 column_offset.push(u32::try_from(data.len()).expect("offset fits in u32"));
52
53 let mut vxl = Vxl {
54 vsid,
55 // Per-grid placement lives on `GridTransform`; the per-chunk
56 // Vxl's intrinsic camera fields are unused at this layer.
57 ipo: [0.0; 3],
58 ist: [1.0, 0.0, 0.0],
59 ihe: [0.0, 0.0, 1.0],
60 ifo: [0.0, 1.0, 0.0],
61 data: data.into_boxed_slice(),
62 column_offset: column_offset.into_boxed_slice(),
63 mip_base_offsets: Box::new([0, n_cols + 1]),
64 vbit: Box::new([]),
65 vbiti: 0,
66 };
67 vxl.reserve_edit_capacity(n_cols * CHUNK_EDIT_HEADROOM_PER_COLUMN);
68
69 // 2. Carve [0, 255] in every column to make it all-air.
70 // `Vspan.z1` is inclusive per voxlap's vspans convention.
71 let mut spans: Vec<Vspan> = Vec::with_capacity(n_cols);
72 for y in 0..vsid {
73 for x in 0..vsid {
74 spans.push(Vspan {
75 x,
76 y,
77 z0: 0,
78 z1: u8::MAX,
79 });
80 }
81 }
82 set_spans(&mut vxl, &spans, None);
83
84 vxl
85}
86
87/// True if voxel `(x, y, z)` is solid within one chunk's [`Vxl`] —
88/// i.e. covered by a solid run in column `(x, y)`. `(x, y)` are
89/// `< CHUNK_SIZE_XY`, `z < CHUNK_SIZE_Z`.
90///
91/// PF.6 — walks the slab chain in place with an early-out at `z`,
92/// mirroring `expandrle`'s run derivation ONE RUN AT A TIME instead of
93/// heap-allocating a 516-int buffer and decoding the whole column per
94/// query (this sits on every CPU shadow-ray step and every
95/// `Scene::raycast` step). Run k spans `[top_k, bot_k)` where `top_0 =
96/// slab[1]`, each following non-degenerate slab header closes the
97/// previous run at `slab[v+3]` and opens the next at `slab[v+1]`, and
98/// the final run extends to the column bottom — exactly the list
99/// `expandrle` would emit.
100#[allow(clippy::cast_possible_wrap)]
101pub(crate) fn vxl_voxel_solid(vxl: &Vxl, x: u32, y: u32, z: u32) -> bool {
102 let idx = (y * vxl.vsid + x) as usize;
103 let slab = vxl.column_data(idx);
104 let z = z as i32;
105 let mut top = i32::from(slab[1]);
106 let mut v = 0usize;
107 while slab[v] != 0 {
108 v += usize::from(slab[v]) * 4;
109 if slab[v + 3] >= slab[v + 1] {
110 // Degenerate slab (no air gap above): merges into the
111 // current run — same skip `expandrle` takes.
112 continue;
113 }
114 let bot = i32::from(slab[v + 3]);
115 if z < bot {
116 // z is above this run's bottom: solid iff inside the run
117 // (below `top` would be the preceding air gap).
118 return z >= top;
119 }
120 top = i32::from(slab[v + 1]);
121 }
122 // Last run extends to the column bottom (bedrock).
123 z >= top
124}
125
126/// PF.6 — chunk-cached solid sampler for DDA marches. Consecutive steps
127/// almost always stay inside one chunk; caching the last `chunks`
128/// HashMap probe turns the per-step cost into one compare + the in-place
129/// slab walk. `chunk_at` exposes presence so callers can skip absent
130/// (all-air) chunks wholesale.
131pub(crate) struct SolidSampler<'a> {
132 grid: &'a Grid,
133 cached_idx: IVec3,
134 cached: Option<&'a Vxl>,
135 primed: bool,
136}
137
138impl<'a> SolidSampler<'a> {
139 /// The chunk holding grid-local voxel-space chunk index `chunk_idx`,
140 /// through the one-entry cache.
141 pub(crate) fn chunk_at(&mut self, chunk_idx: IVec3) -> Option<&'a Vxl> {
142 if !self.primed || chunk_idx != self.cached_idx {
143 self.cached = self.grid.chunk(chunk_idx);
144 self.cached_idx = chunk_idx;
145 self.primed = true;
146 }
147 self.cached
148 }
149}
150
151impl Grid {
152 /// True if the grid-local integer voxel `voxel` is solid (inside a
153 /// solid run of its chunk). An implicit-air or absent chunk reads
154 /// as `false`. `voxel` is a grid-local voxel coordinate
155 /// (pre-transform) — get one from a world point via
156 /// [`crate::world_to_grid_local`] + [`crate::voxel_global`]. Useful
157 /// for picking, collision, and world queries.
158 #[must_use]
159 pub fn voxel_solid(&self, voxel: IVec3) -> bool {
160 let (chunk_idx, in_chunk) = crate::voxel_split(voxel);
161 match self.chunk(chunk_idx) {
162 Some(vxl) => vxl_voxel_solid(vxl, in_chunk.x, in_chunk.y, in_chunk.z),
163 None => false,
164 }
165 }
166
167 /// PF.6 — a chunk-cached [`SolidSampler`] over this grid, for DDA
168 /// marches that issue many [`Self::voxel_solid`]-style queries with
169 /// strong chunk locality (shadow rays, `Scene::raycast`).
170 pub(crate) fn solid_sampler(&self) -> SolidSampler<'_> {
171 SolidSampler {
172 grid: self,
173 cached_idx: IVec3::ZERO,
174 cached: None,
175 primed: false,
176 }
177 }
178
179 /// Packed BGRA colour of the textured voxel at grid-local `voxel`,
180 /// or `None` for air / untextured cells. Thin wrapper over
181 /// [`roxlap_formats::vxl::Vxl::voxel_color`] after the chunk split —
182 /// the colour-inspection companion to [`Self::voxel_solid`]. Use it
183 /// to read back what a pick / raycast hit looks like.
184 #[must_use]
185 pub fn voxel_color(&self, voxel: IVec3) -> Option<u32> {
186 let (chunk_idx, in_chunk) = crate::voxel_split(voxel);
187 self.chunk(chunk_idx)?
188 .voxel_color(in_chunk.x, in_chunk.y, in_chunk.z)
189 }
190
191 /// Borrow the chunk at `chunk_idx` if it has been materialised.
192 /// `None` means the chunk is implicitly all-air.
193 #[must_use]
194 pub fn chunk(&self, chunk_idx: IVec3) -> Option<&Vxl> {
195 self.chunks.get(&chunk_idx)
196 }
197
198 /// Bake per-voxel lighting (voxlap `updatevxl`/`estnorm` shading)
199 /// into every materialised chunk's brightness bytes, in place.
200 /// `lightmode` is voxlap's mode (1 = directional estnorm shading,
201 /// the look the cave + terrain demos use). Both the CPU rasteriser
202 /// and the GPU marcher read these pre-baked brightness bytes, so
203 /// call this once after building a grid and again over edited
204 /// chunks after a carve (then bump their versions so the GPU
205 /// re-uploads — edits already do, via [`Grid::set_voxel`] &c.).
206 ///
207 /// Each chunk is baked neighbour-aware in all three axes: estnorm's
208 /// (and AO's) ±2-voxel padding that crosses a chunk face reads the
209 /// actual neighbour chunk (when populated) — XY neighbours and, for
210 /// stacked grids, the chunks above/below on `chz±1` — so brightness
211 /// / occlusion is continuous across every seam. Point lights aren't
212 /// applied (directional-only) — matching the demos' bake. No-op for
213 /// an empty grid.
214 pub fn bake_lightmode(&mut self, lightmode: u32) {
215 self.bake_lightmode_with_ao(lightmode, roxlap_core::AoParams::default());
216 }
217
218 /// [`Self::bake_lightmode`] with explicit [`AoParams`] — the AO bake
219 /// (lightmode 3) tunes `strength`/`radius` through these.
220 ///
221 /// [`AoParams`]: roxlap_core::AoParams
222 pub fn bake_lightmode_with_ao(&mut self, lightmode: u32, ao: roxlap_core::AoParams) {
223 if lightmode == 0 {
224 return;
225 }
226 #[allow(clippy::cast_possible_wrap)]
227 let cs_xy = CHUNK_SIZE_XY as i32;
228 #[allow(clippy::cast_possible_wrap)]
229 let cs_z = CHUNK_SIZE_Z as i32;
230 let chunk_idxs: Vec<IVec3> = self.chunks.keys().copied().collect();
231 // PF.11 — wave-parallel cache phase: each chunk's estnorm cache
232 // reads the grid immutably and is independent of the others, so a
233 // wave of `current_num_threads` caches builds in parallel; the
234 // apply phase (itself row-parallel inside
235 // `apply_lighting_with_cache`) then runs per chunk. Waves bound
236 // the resident cache memory. Byte-identical to the serial bake —
237 // caches are pure functions of the (unmodified-during-the-wave)
238 // grid, and each apply writes only its own chunk.
239 use rayon::prelude::*;
240 let wave = rayon::current_num_threads().max(1);
241 for batch in chunk_idxs.chunks(wave) {
242 let caches: Vec<(IVec3, roxlap_core::EstNormCache)> = batch
243 .par_iter()
244 .map(|&chunk_idx| {
245 (
246 chunk_idx,
247 self.build_chunk_estnorm_cache(chunk_idx, 0, 0, cs_xy, cs_xy),
248 )
249 })
250 .collect();
251 for (chunk_idx, cache) in caches {
252 let target = self.chunks.get_mut(&chunk_idx).expect("populated chunk");
253 roxlap_core::apply_lighting_with_cache(
254 &mut target.data,
255 &target.column_offset,
256 CHUNK_SIZE_XY,
257 0,
258 0,
259 0,
260 cs_xy,
261 cs_xy,
262 cs_z,
263 &cache,
264 lightmode,
265 &[],
266 ao,
267 );
268 }
269 }
270 }
271
272 /// PF.11 — re-bake lighting over just the grid-local voxel bbox
273 /// `[lo, hi]` (inclusive), neighbour-aware across chunk seams in all
274 /// three axes: the write region is padded by `±ESTNORMRAD` internally
275 /// (an edit changes the estnorm of nearby voxels too — pass only the
276 /// geometric edit extent, mirroring `update_lighting`'s convention),
277 /// and any chunk the padded region touches gets its strip re-baked.
278 /// Touched chunks get their versions bumped so the GPU re-uploads.
279 ///
280 /// This is the runtime-edit primitive the full-grid
281 /// [`Self::bake_lightmode`] is far too heavy for: a bullet-hole
282 /// rebake touches a few hundred columns instead of a whole chunk
283 /// (the cave demo measured ~0.04 ms vs 4–7 ms). Mip regeneration is
284 /// NOT performed — near-field renders read mip 0; callers streaming
285 /// distant edited chunks should remip as they already do for edits.
286 #[allow(clippy::cast_possible_wrap)]
287 pub fn bake_lightmode_bbox(&mut self, lo: IVec3, hi: IVec3, lightmode: u32) {
288 if lightmode == 0 {
289 return;
290 }
291 let cs_xy = CHUNK_SIZE_XY as i32;
292 let cs_z = CHUNK_SIZE_Z as i32;
293 let pad = roxlap_core::ESTNORMRAD;
294 // Padded half-open apply region in grid-local voxel coords.
295 let a_lo = IVec3::new(lo.x - pad, lo.y - pad, lo.z - pad);
296 let a_hi = IVec3::new(hi.x + pad + 1, hi.y + pad + 1, hi.z + pad + 1);
297 if a_lo.x >= a_hi.x || a_lo.y >= a_hi.y || a_lo.z >= a_hi.z {
298 return;
299 }
300 // Chunk range the padded region touches (inclusive).
301 let c_lo = IVec3::new(
302 a_lo.x.div_euclid(cs_xy),
303 a_lo.y.div_euclid(cs_xy),
304 a_lo.z.div_euclid(cs_z),
305 );
306 let c_hi = IVec3::new(
307 (a_hi.x - 1).div_euclid(cs_xy),
308 (a_hi.y - 1).div_euclid(cs_xy),
309 (a_hi.z - 1).div_euclid(cs_z),
310 );
311 for chz in c_lo.z..=c_hi.z {
312 for chy in c_lo.y..=c_hi.y {
313 for chx in c_lo.x..=c_hi.x {
314 let chunk_idx = IVec3::new(chx, chy, chz);
315 if !self.chunks.contains_key(&chunk_idx) {
316 continue;
317 }
318 // Clip the padded region to this chunk, chunk-local.
319 let base = IVec3::new(chx * cs_xy, chy * cs_xy, chz * cs_z);
320 let lx0 = (a_lo.x - base.x).max(0);
321 let ly0 = (a_lo.y - base.y).max(0);
322 let lz0 = (a_lo.z - base.z).max(0);
323 let lx1 = (a_hi.x - base.x).min(cs_xy);
324 let ly1 = (a_hi.y - base.y).min(cs_xy);
325 let lz1 = (a_hi.z - base.z).min(cs_z);
326 if lx0 >= lx1 || ly0 >= ly1 || lz0 >= lz1 {
327 continue;
328 }
329 let cache = self.build_chunk_estnorm_cache(chunk_idx, lx0, ly0, lx1, ly1);
330 let target = self.chunks.get_mut(&chunk_idx).expect("populated chunk");
331 roxlap_core::apply_lighting_with_cache(
332 &mut target.data,
333 &target.column_offset,
334 CHUNK_SIZE_XY,
335 lx0,
336 ly0,
337 lz0,
338 lx1,
339 ly1,
340 lz1,
341 &cache,
342 lightmode,
343 &[],
344 roxlap_core::AoParams::default(),
345 );
346 // PF.12 — brightness bytes changed within the clipped
347 // region only.
348 self.bump_chunk_version_bbox(
349 chunk_idx,
350 IVec3::new(lx0, ly0, lz0),
351 IVec3::new(lx1 - 1, ly1 - 1, lz1 - 1),
352 );
353 }
354 }
355 }
356 }
357
358 /// PF.11 — build the neighbour-aware estnorm cache for a chunk-local
359 /// region of `chunk_idx` from an immutable grid borrow: the reader
360 /// resolves a chunk-local `(px, py)` (which may extend `±ESTNORMRAD`
361 /// outside the region / the target chunk) into the neighbour chunk
362 /// owning that column, and `chz_delta` walks the stacked neighbour in
363 /// z (continuous AO + estnorm across every seam). Padding over an
364 /// unpopulated neighbour returns `None` (= treated as air).
365 #[allow(clippy::cast_possible_wrap)]
366 fn build_chunk_estnorm_cache(
367 &self,
368 chunk_idx: IVec3,
369 x0: i32,
370 y0: i32,
371 x1: i32,
372 y1: i32,
373 ) -> roxlap_core::EstNormCache {
374 let cs_xy = CHUNK_SIZE_XY as i32;
375 let reader = |px: i32, py: i32, chz_delta: i32| -> Option<&[u8]> {
376 let nb_chx = chunk_idx.x + px.div_euclid(cs_xy);
377 let nb_chy = chunk_idx.y + py.div_euclid(cs_xy);
378 let in_x = px.rem_euclid(cs_xy);
379 let in_y = py.rem_euclid(cs_xy);
380 let chunk = self.chunk(IVec3::new(nb_chx, nb_chy, chunk_idx.z + chz_delta))?;
381 let col_idx = (in_y as u32) * CHUNK_SIZE_XY + (in_x as u32);
382 let off = chunk.column_offset[col_idx as usize] as usize;
383 Some(&chunk.data[off..])
384 };
385 roxlap_core::EstNormCache::build_with_reader_z(reader, x0, y0, x1, y1)
386 }
387
388 /// Mutably borrow a materialised chunk. Returns `None` for
389 /// implicit-air chunks; use [`Grid::ensure_chunk`] when you
390 /// need a `&mut Vxl` for an edit that may write voxels.
391 pub fn chunk_mut(&mut self, chunk_idx: IVec3) -> Option<&mut Vxl> {
392 self.chunks.get_mut(&chunk_idx)
393 }
394
395 /// Borrow `chunk_idx`'s [`Vxl`], creating an empty all-air
396 /// chunk first if it doesn't exist yet. The returned `&mut`
397 /// is valid for editing via [`roxlap_formats::edit`] — the new
398 /// chunk has [`Vxl::reserve_edit_capacity`] already applied.
399 pub fn ensure_chunk(&mut self, chunk_idx: IVec3) -> &mut Vxl {
400 // PF.13 (H9) — materialising a chunk mutates the chunk set.
401 if !self.chunks.contains_key(&chunk_idx) {
402 self.note_chunk_set_changed();
403 }
404 self.chunks.entry(chunk_idx).or_insert_with(empty_chunk_vxl)
405 }
406
407 /// Number of materialised chunks. Implicit-air chunks don't
408 /// count.
409 #[must_use]
410 pub fn chunk_count(&self) -> usize {
411 self.chunks.len()
412 }
413
414 /// S4B.2.c.3: build a per-chunk [`roxlap_core::GridView`] table
415 /// over this grid's XY chunk footprint at `chz = 0`.
416 ///
417 /// Returns `None` if no chz=0 chunk is populated (the entire
418 /// grid would render as implicit air anyway).
419 ///
420 /// Iterates `chunks` once to find the chx/chy bounding box,
421 /// then a second time to fill the row-major
422 /// `Vec<Option<GridView<'_>>>`. Empty XY slots (implicit-air
423 /// chunks inside the box) get `None`.
424 ///
425 /// Pair with [`roxlap_core::ChunkGrid`] + [`roxlap_core::
426 /// GridView::from_chunk_grid`] to drive the Approach B render
427 /// path:
428 ///
429 /// ```ignore
430 /// let backing = grid.chunk_xy_backing().unwrap();
431 /// let cg = roxlap_core::ChunkGrid {
432 /// chunks: &backing.chunks,
433 /// origin_chunk_xy: backing.origin_chunk_xy,
434 /// chunks_x: backing.chunks_x,
435 /// chunks_y: backing.chunks_y,
436 /// };
437 /// let view = roxlap_core::GridView::from_chunk_grid(
438 /// &cg, crate::CHUNK_SIZE_XY,
439 /// );
440 /// ```
441 ///
442 /// Only chz=0 chunks contribute (multi-z handoff lands in
443 /// S4B.3); higher-chz chunks in [`Self::chunks`] are ignored
444 /// here.
445 #[must_use]
446 pub fn chunk_xy_backing(&self) -> Option<ChunkXyBacking<'_>> {
447 let mut min_x = i32::MAX;
448 let mut min_y = i32::MAX;
449 let mut max_x = i32::MIN;
450 let mut max_y = i32::MIN;
451 let mut any = false;
452 for chunk_idx in self.chunks.keys() {
453 if chunk_idx.z != 0 {
454 continue;
455 }
456 min_x = min_x.min(chunk_idx.x);
457 min_y = min_y.min(chunk_idx.y);
458 max_x = max_x.max(chunk_idx.x);
459 max_y = max_y.max(chunk_idx.y);
460 any = true;
461 }
462 if !any {
463 return None;
464 }
465 #[allow(clippy::cast_sign_loss)]
466 let chunks_x = (max_x - min_x + 1) as u32;
467 #[allow(clippy::cast_sign_loss)]
468 let chunks_y = (max_y - min_y + 1) as u32;
469 let mut table: Vec<Option<roxlap_core::GridView<'_>>> =
470 vec![None; (chunks_x * chunks_y) as usize];
471 for (chunk_idx, vxl) in &self.chunks {
472 if chunk_idx.z != 0 {
473 continue;
474 }
475 let dx = chunk_idx.x - min_x;
476 let dy = chunk_idx.y - min_y;
477 #[allow(clippy::cast_sign_loss)]
478 let i = (dy as u32 * chunks_x + dx as u32) as usize;
479 table[i] = Some(roxlap_core::GridView::from_single_vxl(vxl));
480 }
481 Some(ChunkXyBacking {
482 chunks: table,
483 origin_chunk_xy: [min_x, min_y],
484 origin_chunk_z: 0,
485 chunks_x,
486 chunks_y,
487 chunks_z: 1,
488 })
489 }
490
491 /// S4B.6.a: 3D-aware version of [`Self::chunk_xy_backing`].
492 /// Enumerates ALL chunks across the chx/chy/chz bounding box
493 /// (not just `chz=0`) so a stacked grid can be rendered once
494 /// S4B.6.c switches the rasterizer to a chunk-z-aware column
495 /// walker.
496 ///
497 /// Iterates `chunks` once for the XYZ bbox, then a second time
498 /// to fill the row-major-per-z `Vec<Option<GridView<'_>>>`.
499 /// Index layout: `[(dz * chunks_y + dy) * chunks_x + dx]` —
500 /// matches [`roxlap_core::ChunkGrid`]'s indexing exactly.
501 ///
502 /// Returns `None` for empty grids.
503 #[must_use]
504 pub fn chunk_xyz_backing(&self) -> Option<ChunkXyBacking<'_>> {
505 let mut min_x = i32::MAX;
506 let mut min_y = i32::MAX;
507 let mut min_z = i32::MAX;
508 let mut max_x = i32::MIN;
509 let mut max_y = i32::MIN;
510 let mut max_z = i32::MIN;
511 let mut any = false;
512 for chunk_idx in self.chunks.keys() {
513 min_x = min_x.min(chunk_idx.x);
514 min_y = min_y.min(chunk_idx.y);
515 min_z = min_z.min(chunk_idx.z);
516 max_x = max_x.max(chunk_idx.x);
517 max_y = max_y.max(chunk_idx.y);
518 max_z = max_z.max(chunk_idx.z);
519 any = true;
520 }
521 if !any {
522 return None;
523 }
524 #[allow(clippy::cast_sign_loss)]
525 let chunks_x = (max_x - min_x + 1) as u32;
526 #[allow(clippy::cast_sign_loss)]
527 let chunks_y = (max_y - min_y + 1) as u32;
528 #[allow(clippy::cast_sign_loss)]
529 let chunks_z = (max_z - min_z + 1) as u32;
530 let mut table: Vec<Option<roxlap_core::GridView<'_>>> =
531 vec![None; (chunks_x * chunks_y * chunks_z) as usize];
532 for (chunk_idx, vxl) in &self.chunks {
533 let dx = chunk_idx.x - min_x;
534 let dy = chunk_idx.y - min_y;
535 let dz = chunk_idx.z - min_z;
536 #[allow(clippy::cast_sign_loss)]
537 let (dx, dy, dz) = (dx as u32, dy as u32, dz as u32);
538 let i = ((dz * chunks_y + dy) * chunks_x + dx) as usize;
539 table[i] = Some(roxlap_core::GridView::from_single_vxl(vxl));
540 }
541 Some(ChunkXyBacking {
542 chunks: table,
543 origin_chunk_xy: [min_x, min_y],
544 origin_chunk_z: min_z,
545 chunks_x,
546 chunks_y,
547 chunks_z,
548 })
549 }
550}
551
552/// S4B.2.c.3: chx/chy chunk table built from a [`Grid`].
553///
554/// Owns the `Vec<Option<GridView>>` so [`roxlap_core::ChunkGrid`]
555/// (which borrows the table) can live alongside the GridView
556/// constructed from it. Used by the Approach B render path —
557/// see [`Grid::chunk_xy_backing`].
558///
559/// S4B.6.a: gained `chunks_z` + `origin_chunk_z` for tall-world
560/// support. Pre-S4B.6.a `chunk_xy_backing` always populates these
561/// as `chunks_z=1 origin_chunk_z=0` (= chz=0 only); S4B.6.c will
562/// switch the render path to `chunk_xyz_backing` once the
563/// rasterizer is stack-aware.
564pub struct ChunkXyBacking<'a> {
565 /// Per-chunk views over the chx/chy/chz extent.
566 /// Length `chunks_x * chunks_y * chunks_z`; index layout
567 /// `[(dz * chunks_y + dy) * chunks_x + dx]`. `None` for
568 /// implicit-air chunks inside the bbox.
569 pub chunks: Vec<Option<roxlap_core::GridView<'a>>>,
570 /// XY index of the chunk at `chunks[0]` — the minimum chx/chy
571 /// among populated chunks at `chz = origin_chunk_z`.
572 pub origin_chunk_xy: [i32; 2],
573 /// Z index of the chunk at `chunks[0]`.
574 pub origin_chunk_z: i32,
575 /// Number of chunks along the X axis. Row stride.
576 pub chunks_x: u32,
577 /// Number of chunks along the Y axis.
578 pub chunks_y: u32,
579 /// Number of chunks along the Z axis. `1` from
580 /// [`Grid::chunk_xy_backing`]; `>1` only via the S4B.6.a
581 /// [`Grid::chunk_xyz_backing`].
582 pub chunks_z: u32,
583}
584
585#[cfg(test)]
586pub(crate) mod tests {
587 use super::*;
588 use crate::{GridTransform, CHUNK_SIZE_Z};
589
590 /// Decode `column`'s slab bytes and return `true` iff `z` is
591 /// covered by any solid run. Mirrors voxlap's column-walk
592 /// semantics — the b2 buffer is `[top0, bot0, top1, bot1, ...,
593 /// MAXZDIM_sentinel]`, with each `[top, bot)` pair denoting a
594 /// solid range.
595 pub(crate) fn voxel_is_solid(vxl: &Vxl, x: u32, y: u32, z: u32) -> bool {
596 super::vxl_voxel_solid(vxl, x, y, z)
597 }
598
599 #[test]
600 fn voxel_solid_reflects_set_voxel() {
601 // Grid::voxel_solid (the public picking query) reads back an
602 // edit: the set voxel is solid, its neighbour is air, and an
603 // unmaterialised chunk reads as air.
604 let mut g = Grid::new(GridTransform::identity());
605 g.set_voxel(IVec3::new(5, 6, 7), Some(0x80_aa_bb_cc));
606 assert!(g.voxel_solid(IVec3::new(5, 6, 7)), "set voxel is solid");
607 assert!(!g.voxel_solid(IVec3::new(5, 6, 8)), "neighbour is air");
608 assert!(
609 !g.voxel_solid(IVec3::new(900, 900, 7)),
610 "absent chunk reads as air",
611 );
612 }
613
614 #[test]
615 fn voxel_solid_handles_negative_coords() {
616 // Negative grid-local voxels decompose via div_euclid (addr
617 // semantics); the query must follow the same split.
618 let mut g = Grid::new(GridTransform::identity());
619 g.set_voxel(IVec3::new(-1, -1, 10), Some(0x80_11_22_33));
620 assert!(g.voxel_solid(IVec3::new(-1, -1, 10)));
621 assert!(!g.voxel_solid(IVec3::new(-1, -1, 11)));
622 }
623
624 #[test]
625 fn empty_chunk_has_correct_vsid() {
626 let vxl = empty_chunk_vxl();
627 assert_eq!(vxl.vsid, CHUNK_SIZE_XY);
628 }
629
630 #[test]
631 fn empty_chunk_is_all_air() {
632 let vxl = empty_chunk_vxl();
633 // Sample a few representative voxels — full coverage is in
634 // `empty_chunk_no_voxel_solid_anywhere` below.
635 for &(x, y, z) in &[
636 (0u32, 0u32, 0u32),
637 (0, 0, 100),
638 (0, 0, 200),
639 (CHUNK_SIZE_XY - 1, CHUNK_SIZE_XY - 1, 0),
640 (64, 64, 128),
641 ] {
642 assert!(
643 !voxel_is_solid(&vxl, x, y, z),
644 "voxel ({x}, {y}, {z}) should be air"
645 );
646 }
647 }
648
649 #[test]
650 fn empty_chunk_air_above_bedrock_on_grid_sample() {
651 // Stride 16 across the chunk catches structural breakage
652 // (a corner column wrong, a z-band wrong, etc.) without the
653 // 4M-query cost of a brute-force scan in debug mode.
654 // Voxlap's slab format keeps z=255 solid as the "below the
655 // world" sentinel; the renderer's `treat_z_max_as_air` flag
656 // handles displaying it as transparent. See
657 // `project_below_bedrock_all_sky.md` for the S1.X fix.
658 let vxl = empty_chunk_vxl();
659 let bedrock_z = CHUNK_SIZE_Z - 1;
660 for y in (0..CHUNK_SIZE_XY).step_by(16) {
661 for x in (0..CHUNK_SIZE_XY).step_by(16) {
662 for z in (0..bedrock_z).step_by(16) {
663 assert!(
664 !voxel_is_solid(&vxl, x, y, z),
665 "voxel ({x}, {y}, {z}) leaked solid in empty chunk"
666 );
667 }
668 // bedrock z is solid (placeholder).
669 assert!(voxel_is_solid(&vxl, x, y, bedrock_z));
670 }
671 }
672 }
673
674 #[test]
675 fn empty_chunk_keeps_bedrock_placeholder() {
676 // Voxlap's invariant: every column carries an implicit
677 // solid voxel at z = MAXZDIM-1 = 255 even after a full
678 // carve. The renderer reads this as the bedrock placeholder.
679 let vxl = empty_chunk_vxl();
680 assert!(voxel_is_solid(&vxl, 0, 0, CHUNK_SIZE_Z - 1));
681 assert!(voxel_is_solid(&vxl, 64, 64, CHUNK_SIZE_Z - 1));
682 }
683
684 #[test]
685 fn ensure_chunk_creates_when_missing() {
686 let mut g = Grid::new(GridTransform::identity());
687 assert_eq!(g.chunk_count(), 0);
688 assert!(g.chunk(IVec3::ZERO).is_none());
689 let _ = g.ensure_chunk(IVec3::ZERO);
690 assert_eq!(g.chunk_count(), 1);
691 assert!(g.chunk(IVec3::ZERO).is_some());
692 }
693
694 #[test]
695 fn ensure_chunk_returns_existing() {
696 // Calling ensure_chunk a second time on the same index
697 // doesn't replace the chunk. Verify by writing through the
698 // first call and reading through the second.
699 let mut g = Grid::new(GridTransform::identity());
700 let chunk = IVec3::new(2, -1, 0);
701 g.ensure_chunk(chunk);
702 // Voxel local (5, 6, 7) inside chunk (2, -1, 0) is
703 // grid-local global (2*128 + 5, -1*128 + 6, 0*256 + 7) =
704 // (261, -122, 7).
705 g.set_voxel(IVec3::new(261, -122, 7), Some(0x80_aa_bb_cc));
706 let vxl = g.ensure_chunk(chunk);
707 assert!(voxel_is_solid(vxl, 5, 6, 7));
708 assert_eq!(g.chunk_count(), 1);
709 }
710
711 #[test]
712 fn chunk_mut_returns_none_for_missing() {
713 let mut g = Grid::new(GridTransform::identity());
714 assert!(g.chunk_mut(IVec3::ZERO).is_none());
715 }
716
717 /// S4B.6.a: legacy `chunk_xy_backing` ignores chz!=0 chunks and
718 /// always returns `chunks_z=1 origin_chunk_z=0`. Sanity-check
719 /// the new field defaults so pre-S4B.6 render path stays
720 /// byte-identical.
721 #[test]
722 fn chunk_xy_backing_returns_chunks_z_one() {
723 let mut g = Grid::new(GridTransform::identity());
724 g.ensure_chunk(IVec3::new(0, 0, 0));
725 g.ensure_chunk(IVec3::new(1, 0, 0));
726 // Add a chunk at chz=1 — chunk_xy_backing should ignore it.
727 g.ensure_chunk(IVec3::new(0, 0, 1));
728 let backing = g.chunk_xy_backing().expect("two chz=0 chunks present");
729 assert_eq!(backing.chunks_z, 1);
730 assert_eq!(backing.origin_chunk_z, 0);
731 assert_eq!(backing.chunks_x, 2);
732 assert_eq!(backing.chunks_y, 1);
733 assert_eq!(backing.chunks.len(), 2);
734 }
735
736 /// S4B.6.a: new `chunk_xyz_backing` enumerates ALL chunks
737 /// including chz!=0. Indexing layout must match
738 /// `roxlap_core::ChunkGrid`: row-major per z slab.
739 #[test]
740 fn chunk_xyz_backing_with_stacked_chunks_enumerates_all_z() {
741 let mut g = Grid::new(GridTransform::identity());
742 g.ensure_chunk(IVec3::new(0, 0, 0));
743 g.ensure_chunk(IVec3::new(1, 0, 0));
744 g.ensure_chunk(IVec3::new(0, 0, 1));
745 // Leave (1, 0, 1) implicit-air.
746 let backing = g.chunk_xyz_backing().expect("at least one chunk");
747 assert_eq!(backing.chunks_x, 2);
748 assert_eq!(backing.chunks_y, 1);
749 assert_eq!(backing.chunks_z, 2);
750 assert_eq!(backing.origin_chunk_xy, [0, 0]);
751 assert_eq!(backing.origin_chunk_z, 0);
752 assert_eq!(backing.chunks.len(), 4); // dims [2, 1, 2]
753 // Index layout: [(dz * chunks_y + dy) * chunks_x + dx]
754 // (0, 0, 0) → dx=0, dy=0, dz=0 → 0
755 // (1, 0, 0) → dx=1, dy=0, dz=0 → 1
756 // (0, 0, 1) → dx=0, dy=0, dz=1 → 2
757 // (1, 0, 1) → dx=1, dy=0, dz=1 → 3 (implicit-air → None)
758 assert!(backing.chunks[0].is_some(), "(0, 0, 0) present");
759 assert!(backing.chunks[1].is_some(), "(1, 0, 0) present");
760 assert!(backing.chunks[2].is_some(), "(0, 0, 1) present");
761 assert!(backing.chunks[3].is_none(), "(1, 0, 1) implicit-air");
762 }
763
764 /// S4B.6.a: chunk_xyz_backing with negative chz origin —
765 /// origin_chunk_z = min_z must reflect the actual minimum chz.
766 #[test]
767 fn chunk_xyz_backing_with_negative_origin_chunk_z() {
768 let mut g = Grid::new(GridTransform::identity());
769 g.ensure_chunk(IVec3::new(0, 0, -2));
770 g.ensure_chunk(IVec3::new(0, 0, 0));
771 let backing = g.chunk_xyz_backing().expect("at least one chunk");
772 assert_eq!(backing.chunks_z, 3); // chz range [-2, 0]
773 assert_eq!(backing.origin_chunk_z, -2);
774 assert!(backing.chunks[0].is_some(), "chz=-2 → dz=0");
775 assert!(backing.chunks[1].is_none(), "chz=-1 → dz=1 implicit-air");
776 assert!(backing.chunks[2].is_some(), "chz=0 → dz=2");
777 }
778
779 /// PF.11 — `bake_lightmode_bbox` around an edit must reproduce, byte
780 /// for byte, what a full-grid re-bake would write: the padded write
781 /// region covers every voxel whose estnorm the edit could change
782 /// (±ESTNORMRAD), including strips in neighbour chunks across seams.
783 #[test]
784 fn bbox_rebake_matches_full_rebake() {
785 // Terrain spanning a 2×2 chunk seam, with relief so estnorm is
786 // non-trivial around the edit.
787 let build = || {
788 let mut g = Grid::new(GridTransform::identity());
789 g.set_rect(
790 IVec3::new(0, 0, 160),
791 IVec3::new(255, 255, 255),
792 Some(0x80_66_77_88),
793 );
794 for i in 0..10 {
795 let (x, y) = (23 * i % 240 + 8, 37 * i % 240 + 8);
796 g.set_sphere(IVec3::new(x, y, 165), 6, None);
797 }
798 g.bake_lightmode(1);
799 g
800 };
801 let mut full = build();
802 let mut bbox = build();
803
804 // Identical edit in both, straddling the chunk (0,0)/(1,0) seam
805 // so the neighbour-strip rebake is exercised.
806 let (lo, hi) = (IVec3::new(120, 60, 158), IVec3::new(136, 76, 200));
807 full.set_rect(lo, hi, None);
808 bbox.set_rect(lo, hi, None);
809
810 // Ground truth: full re-bake. Candidate: bbox-only re-bake.
811 full.bake_lightmode(1);
812 bbox.bake_lightmode_bbox(lo, hi, 1);
813
814 for (idx, a) in &full.chunks {
815 let b = bbox.chunks.get(idx).expect("same chunk set");
816 assert_eq!(
817 a.data, b.data,
818 "chunk {idx:?} bytes diverge between full and bbox rebake",
819 );
820 }
821 }
822
823 /// PF.12 — `remip_bbox` must be byte-identical to a full
824 /// `generate_mips` when the bbox covers the edits: data bytes,
825 /// column offsets, and the mip table all match — across repeated
826 /// incremental rounds, corner-of-chunk edits, and voxalloc-scattered
827 /// columns.
828 #[test]
829 fn remip_bbox_matches_generate_mips() {
830 use roxlap_formats::edit::{set_sphere_with_colfunc, SpanOp};
831
832 // Realistic chunk: terrain + caves, then a full initial ladder.
833 let mut g = Grid::new(GridTransform::identity());
834 g.set_rect(
835 IVec3::new(0, 0, 150),
836 IVec3::new(127, 127, 255),
837 Some(0x80_66_77_88),
838 );
839 for i in 0..8 {
840 let (x, y) = (29 * i % 100 + 12, 41 * i % 100 + 12);
841 g.set_sphere(IVec3::new(x, y, 158), 5, None);
842 }
843 let base = g.chunks.get_mut(&IVec3::ZERO).expect("chunk");
844 base.generate_mips(6);
845
846 let mut full = base.clone();
847 let mut inc = base.clone();
848
849 // Round 1: a carve straddling brick/column-group boundaries plus
850 // a corner edit (exercises clamping at the chunk edge).
851 let edits: [(IVec3, u32); 3] = [
852 (IVec3::new(63, 64, 170), 7),
853 (IVec3::new(0, 0, 155), 4),
854 (IVec3::new(126, 100, 165), 5),
855 ];
856 for round in 0..2 {
857 let mut lo = IVec3::splat(i32::MAX);
858 let mut hi = IVec3::splat(i32::MIN);
859 for (k, &(c, r)) in edits.iter().enumerate() {
860 if k % 2 != round % 2 {
861 continue; // vary the edit set per round
862 }
863 #[allow(clippy::cast_possible_wrap)]
864 let ri = r as i32;
865 for v in [&mut full, &mut inc] {
866 set_sphere_with_colfunc(v, c.into(), r, SpanOp::Carve, |_, _, _| {
867 0x80_31_41_59_u32 as i32
868 });
869 }
870 lo = lo.min(c - IVec3::splat(ri));
871 hi = hi.max(c + IVec3::splat(ri));
872 }
873 full.generate_mips(6);
874 inc.remip_bbox(lo.x, lo.y, hi.x, hi.y, 6);
875
876 assert_eq!(
877 full.mip_base_offsets, inc.mip_base_offsets,
878 "round {round}: mip tables diverge",
879 );
880 assert_eq!(
881 full.column_offset, inc.column_offset,
882 "round {round}: column offsets diverge",
883 );
884 assert_eq!(full.data, inc.data, "round {round}: data bytes diverge");
885 }
886 }
887
888 /// PF.6 — the in-place slab walk in `vxl_voxel_solid` must agree
889 /// with the `expandrle` run list (the pre-PF.6 reference decoder)
890 /// for every z, on columns with multiple slabs (caves), single
891 /// slabs, and untouched bedrock.
892 #[test]
893 fn vxl_voxel_solid_matches_expandrle_reference() {
894 use roxlap_formats::edit::expandrle;
895
896 let mut g = Grid::new(GridTransform::identity());
897 // Carve two separate air pockets into one column (multi-slab)
898 // plus a sphere for varied neighbours.
899 g.set_rect(IVec3::new(3, 4, 40), IVec3::new(4, 5, 60), None);
900 g.set_rect(IVec3::new(3, 4, 100), IVec3::new(4, 5, 140), None);
901 g.set_voxel(IVec3::new(3, 4, 120), Some(0x80_11_22_33)); // island inside the pocket
902 g.set_sphere(IVec3::new(8, 8, 80), 6, None);
903 let vxl = g.chunk(IVec3::ZERO).expect("chunk materialised");
904
905 let maxzdim = CHUNK_SIZE_Z as i32;
906 for (x, y) in [(3u32, 4u32), (8, 8), (0, 0), (8, 2), (12, 8)] {
907 // Reference: full expandrle decode → run-list scan.
908 let column = vxl.column_data((y * vxl.vsid + x) as usize);
909 let mut b2 = vec![maxzdim; 2 * (CHUNK_SIZE_Z as usize) + 4];
910 expandrle(column, &mut b2);
911 for z in 0..CHUNK_SIZE_Z {
912 let zi = z as i32;
913 let mut reference = false;
914 let mut i = 0;
915 while b2[i] < maxzdim {
916 if zi >= b2[i] && zi < b2[i + 1] {
917 reference = true;
918 break;
919 }
920 i += 2;
921 }
922 assert_eq!(
923 vxl_voxel_solid(vxl, x, y, z),
924 reference,
925 "column ({x}, {y}) z={z} disagrees with expandrle",
926 );
927 }
928 }
929 }
930}