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};
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
87impl Grid {
88 /// Borrow the chunk at `chunk_idx` if it has been materialised.
89 /// `None` means the chunk is implicitly all-air.
90 #[must_use]
91 pub fn chunk(&self, chunk_idx: IVec3) -> Option<&Vxl> {
92 self.chunks.get(&chunk_idx)
93 }
94
95 /// Mutably borrow a materialised chunk. Returns `None` for
96 /// implicit-air chunks; use [`Grid::ensure_chunk`] when you
97 /// need a `&mut Vxl` for an edit that may write voxels.
98 pub fn chunk_mut(&mut self, chunk_idx: IVec3) -> Option<&mut Vxl> {
99 self.chunks.get_mut(&chunk_idx)
100 }
101
102 /// Borrow `chunk_idx`'s [`Vxl`], creating an empty all-air
103 /// chunk first if it doesn't exist yet. The returned `&mut`
104 /// is valid for editing via [`roxlap_formats::edit`] — the new
105 /// chunk has [`Vxl::reserve_edit_capacity`] already applied.
106 pub fn ensure_chunk(&mut self, chunk_idx: IVec3) -> &mut Vxl {
107 self.chunks.entry(chunk_idx).or_insert_with(empty_chunk_vxl)
108 }
109
110 /// Number of materialised chunks. Implicit-air chunks don't
111 /// count.
112 #[must_use]
113 pub fn chunk_count(&self) -> usize {
114 self.chunks.len()
115 }
116
117 /// S4B.2.c.3: build a per-chunk [`roxlap_core::GridView`] table
118 /// over this grid's XY chunk footprint at `chz = 0`.
119 ///
120 /// Returns `None` if no chz=0 chunk is populated (the entire
121 /// grid would render as implicit air anyway).
122 ///
123 /// Iterates `chunks` once to find the chx/chy bounding box,
124 /// then a second time to fill the row-major
125 /// `Vec<Option<GridView<'_>>>`. Empty XY slots (implicit-air
126 /// chunks inside the box) get `None`.
127 ///
128 /// Pair with [`roxlap_core::ChunkGrid`] + [`roxlap_core::
129 /// GridView::from_chunk_grid`] to drive the Approach B render
130 /// path:
131 ///
132 /// ```ignore
133 /// let backing = grid.chunk_xy_backing().unwrap();
134 /// let cg = roxlap_core::ChunkGrid {
135 /// chunks: &backing.chunks,
136 /// origin_chunk_xy: backing.origin_chunk_xy,
137 /// chunks_x: backing.chunks_x,
138 /// chunks_y: backing.chunks_y,
139 /// };
140 /// let view = roxlap_core::GridView::from_chunk_grid(
141 /// &cg, crate::CHUNK_SIZE_XY,
142 /// );
143 /// ```
144 ///
145 /// Only chz=0 chunks contribute (multi-z handoff lands in
146 /// S4B.3); higher-chz chunks in [`Self::chunks`] are ignored
147 /// here.
148 #[must_use]
149 pub fn chunk_xy_backing(&self) -> Option<ChunkXyBacking<'_>> {
150 let mut min_x = i32::MAX;
151 let mut min_y = i32::MAX;
152 let mut max_x = i32::MIN;
153 let mut max_y = i32::MIN;
154 let mut any = false;
155 for chunk_idx in self.chunks.keys() {
156 if chunk_idx.z != 0 {
157 continue;
158 }
159 min_x = min_x.min(chunk_idx.x);
160 min_y = min_y.min(chunk_idx.y);
161 max_x = max_x.max(chunk_idx.x);
162 max_y = max_y.max(chunk_idx.y);
163 any = true;
164 }
165 if !any {
166 return None;
167 }
168 #[allow(clippy::cast_sign_loss)]
169 let chunks_x = (max_x - min_x + 1) as u32;
170 #[allow(clippy::cast_sign_loss)]
171 let chunks_y = (max_y - min_y + 1) as u32;
172 let mut table: Vec<Option<roxlap_core::GridView<'_>>> =
173 vec![None; (chunks_x * chunks_y) as usize];
174 for (chunk_idx, vxl) in &self.chunks {
175 if chunk_idx.z != 0 {
176 continue;
177 }
178 let dx = chunk_idx.x - min_x;
179 let dy = chunk_idx.y - min_y;
180 #[allow(clippy::cast_sign_loss)]
181 let i = (dy as u32 * chunks_x + dx as u32) as usize;
182 table[i] = Some(roxlap_core::GridView::from_single_vxl(vxl));
183 }
184 Some(ChunkXyBacking {
185 chunks: table,
186 origin_chunk_xy: [min_x, min_y],
187 origin_chunk_z: 0,
188 chunks_x,
189 chunks_y,
190 chunks_z: 1,
191 })
192 }
193
194 /// S4B.6.a: 3D-aware version of [`Self::chunk_xy_backing`].
195 /// Enumerates ALL chunks across the chx/chy/chz bounding box
196 /// (not just `chz=0`) so a stacked grid can be rendered once
197 /// S4B.6.c switches the rasterizer to a chunk-z-aware column
198 /// walker.
199 ///
200 /// Iterates `chunks` once for the XYZ bbox, then a second time
201 /// to fill the row-major-per-z `Vec<Option<GridView<'_>>>`.
202 /// Index layout: `[(dz * chunks_y + dy) * chunks_x + dx]` —
203 /// matches [`roxlap_core::ChunkGrid`]'s indexing exactly.
204 ///
205 /// Returns `None` for empty grids.
206 #[must_use]
207 pub fn chunk_xyz_backing(&self) -> Option<ChunkXyBacking<'_>> {
208 let mut min_x = i32::MAX;
209 let mut min_y = i32::MAX;
210 let mut min_z = i32::MAX;
211 let mut max_x = i32::MIN;
212 let mut max_y = i32::MIN;
213 let mut max_z = i32::MIN;
214 let mut any = false;
215 for chunk_idx in self.chunks.keys() {
216 min_x = min_x.min(chunk_idx.x);
217 min_y = min_y.min(chunk_idx.y);
218 min_z = min_z.min(chunk_idx.z);
219 max_x = max_x.max(chunk_idx.x);
220 max_y = max_y.max(chunk_idx.y);
221 max_z = max_z.max(chunk_idx.z);
222 any = true;
223 }
224 if !any {
225 return None;
226 }
227 #[allow(clippy::cast_sign_loss)]
228 let chunks_x = (max_x - min_x + 1) as u32;
229 #[allow(clippy::cast_sign_loss)]
230 let chunks_y = (max_y - min_y + 1) as u32;
231 #[allow(clippy::cast_sign_loss)]
232 let chunks_z = (max_z - min_z + 1) as u32;
233 let mut table: Vec<Option<roxlap_core::GridView<'_>>> =
234 vec![None; (chunks_x * chunks_y * chunks_z) as usize];
235 for (chunk_idx, vxl) in &self.chunks {
236 let dx = chunk_idx.x - min_x;
237 let dy = chunk_idx.y - min_y;
238 let dz = chunk_idx.z - min_z;
239 #[allow(clippy::cast_sign_loss)]
240 let (dx, dy, dz) = (dx as u32, dy as u32, dz as u32);
241 let i = ((dz * chunks_y + dy) * chunks_x + dx) as usize;
242 table[i] = Some(roxlap_core::GridView::from_single_vxl(vxl));
243 }
244 Some(ChunkXyBacking {
245 chunks: table,
246 origin_chunk_xy: [min_x, min_y],
247 origin_chunk_z: min_z,
248 chunks_x,
249 chunks_y,
250 chunks_z,
251 })
252 }
253}
254
255/// S4B.2.c.3: chx/chy chunk table built from a [`Grid`].
256///
257/// Owns the `Vec<Option<GridView>>` so [`roxlap_core::ChunkGrid`]
258/// (which borrows the table) can live alongside the GridView
259/// constructed from it. Used by the Approach B render path —
260/// see [`Grid::chunk_xy_backing`].
261///
262/// S4B.6.a: gained `chunks_z` + `origin_chunk_z` for tall-world
263/// support. Pre-S4B.6.a `chunk_xy_backing` always populates these
264/// as `chunks_z=1 origin_chunk_z=0` (= chz=0 only); S4B.6.c will
265/// switch the render path to `chunk_xyz_backing` once the
266/// rasterizer is stack-aware.
267pub struct ChunkXyBacking<'a> {
268 /// Per-chunk views over the chx/chy/chz extent.
269 /// Length `chunks_x * chunks_y * chunks_z`; index layout
270 /// `[(dz * chunks_y + dy) * chunks_x + dx]`. `None` for
271 /// implicit-air chunks inside the bbox.
272 pub chunks: Vec<Option<roxlap_core::GridView<'a>>>,
273 /// XY index of the chunk at `chunks[0]` — the minimum chx/chy
274 /// among populated chunks at `chz = origin_chunk_z`.
275 pub origin_chunk_xy: [i32; 2],
276 /// Z index of the chunk at `chunks[0]`.
277 pub origin_chunk_z: i32,
278 /// Number of chunks along the X axis. Row stride.
279 pub chunks_x: u32,
280 /// Number of chunks along the Y axis.
281 pub chunks_y: u32,
282 /// Number of chunks along the Z axis. `1` from
283 /// [`Grid::chunk_xy_backing`]; `>1` only via the S4B.6.a
284 /// [`Grid::chunk_xyz_backing`].
285 pub chunks_z: u32,
286}
287
288#[cfg(test)]
289pub(crate) mod tests {
290 use super::*;
291 use crate::{GridTransform, CHUNK_SIZE_Z};
292 use roxlap_formats::edit::expandrle;
293
294 /// Decode `column`'s slab bytes and return `true` iff `z` is
295 /// covered by any solid run. Mirrors voxlap's column-walk
296 /// semantics — the b2 buffer is `[top0, bot0, top1, bot1, ...,
297 /// MAXZDIM_sentinel]`, with each `[top, bot)` pair denoting a
298 /// solid range.
299 #[allow(clippy::cast_possible_wrap)]
300 pub(crate) fn voxel_is_solid(vxl: &Vxl, x: u32, y: u32, z: u32) -> bool {
301 let idx = (y * vxl.vsid + x) as usize;
302 let column = vxl.column_data(idx);
303 // Pre-fill with MAXZDIM so unwritten slots terminate the walk
304 // (matches voxlap's b2 init convention in `all_air_neighbor`
305 // and friends — expandrle only writes the prefix it needs).
306 let maxzdim = CHUNK_SIZE_Z as i32;
307 let mut b2 = vec![maxzdim; 2 * (CHUNK_SIZE_Z as usize) + 4];
308 expandrle(column, &mut b2);
309 let z = z as i32;
310 let mut i = 0;
311 while b2[i] < maxzdim {
312 let top = b2[i];
313 let bot = b2[i + 1];
314 if z >= top && z < bot {
315 return true;
316 }
317 i += 2;
318 }
319 false
320 }
321
322 #[test]
323 fn empty_chunk_has_correct_vsid() {
324 let vxl = empty_chunk_vxl();
325 assert_eq!(vxl.vsid, CHUNK_SIZE_XY);
326 }
327
328 #[test]
329 fn empty_chunk_is_all_air() {
330 let vxl = empty_chunk_vxl();
331 // Sample a few representative voxels — full coverage is in
332 // `empty_chunk_no_voxel_solid_anywhere` below.
333 for &(x, y, z) in &[
334 (0u32, 0u32, 0u32),
335 (0, 0, 100),
336 (0, 0, 200),
337 (CHUNK_SIZE_XY - 1, CHUNK_SIZE_XY - 1, 0),
338 (64, 64, 128),
339 ] {
340 assert!(
341 !voxel_is_solid(&vxl, x, y, z),
342 "voxel ({x}, {y}, {z}) should be air"
343 );
344 }
345 }
346
347 #[test]
348 fn empty_chunk_air_above_bedrock_on_grid_sample() {
349 // Stride 16 across the chunk catches structural breakage
350 // (a corner column wrong, a z-band wrong, etc.) without the
351 // 4M-query cost of a brute-force scan in debug mode.
352 // Voxlap's slab format keeps z=255 solid as the "below the
353 // world" sentinel; the renderer's `treat_z_max_as_air` flag
354 // handles displaying it as transparent. See
355 // `project_below_bedrock_all_sky.md` for the S1.X fix.
356 let vxl = empty_chunk_vxl();
357 let bedrock_z = CHUNK_SIZE_Z - 1;
358 for y in (0..CHUNK_SIZE_XY).step_by(16) {
359 for x in (0..CHUNK_SIZE_XY).step_by(16) {
360 for z in (0..bedrock_z).step_by(16) {
361 assert!(
362 !voxel_is_solid(&vxl, x, y, z),
363 "voxel ({x}, {y}, {z}) leaked solid in empty chunk"
364 );
365 }
366 // bedrock z is solid (placeholder).
367 assert!(voxel_is_solid(&vxl, x, y, bedrock_z));
368 }
369 }
370 }
371
372 #[test]
373 fn empty_chunk_keeps_bedrock_placeholder() {
374 // Voxlap's invariant: every column carries an implicit
375 // solid voxel at z = MAXZDIM-1 = 255 even after a full
376 // carve. The renderer reads this as the bedrock placeholder.
377 let vxl = empty_chunk_vxl();
378 assert!(voxel_is_solid(&vxl, 0, 0, CHUNK_SIZE_Z - 1));
379 assert!(voxel_is_solid(&vxl, 64, 64, CHUNK_SIZE_Z - 1));
380 }
381
382 #[test]
383 fn ensure_chunk_creates_when_missing() {
384 let mut g = Grid::new(GridTransform::identity());
385 assert_eq!(g.chunk_count(), 0);
386 assert!(g.chunk(IVec3::ZERO).is_none());
387 let _ = g.ensure_chunk(IVec3::ZERO);
388 assert_eq!(g.chunk_count(), 1);
389 assert!(g.chunk(IVec3::ZERO).is_some());
390 }
391
392 #[test]
393 fn ensure_chunk_returns_existing() {
394 // Calling ensure_chunk a second time on the same index
395 // doesn't replace the chunk. Verify by writing through the
396 // first call and reading through the second.
397 let mut g = Grid::new(GridTransform::identity());
398 let chunk = IVec3::new(2, -1, 0);
399 g.ensure_chunk(chunk);
400 // Voxel local (5, 6, 7) inside chunk (2, -1, 0) is
401 // grid-local global (2*128 + 5, -1*128 + 6, 0*256 + 7) =
402 // (261, -122, 7).
403 g.set_voxel(IVec3::new(261, -122, 7), Some(0x80_aa_bb_cc));
404 let vxl = g.ensure_chunk(chunk);
405 assert!(voxel_is_solid(vxl, 5, 6, 7));
406 assert_eq!(g.chunk_count(), 1);
407 }
408
409 #[test]
410 fn chunk_mut_returns_none_for_missing() {
411 let mut g = Grid::new(GridTransform::identity());
412 assert!(g.chunk_mut(IVec3::ZERO).is_none());
413 }
414
415 /// S4B.6.a: legacy `chunk_xy_backing` ignores chz!=0 chunks and
416 /// always returns `chunks_z=1 origin_chunk_z=0`. Sanity-check
417 /// the new field defaults so pre-S4B.6 render path stays
418 /// byte-identical.
419 #[test]
420 fn chunk_xy_backing_returns_chunks_z_one() {
421 let mut g = Grid::new(GridTransform::identity());
422 g.ensure_chunk(IVec3::new(0, 0, 0));
423 g.ensure_chunk(IVec3::new(1, 0, 0));
424 // Add a chunk at chz=1 — chunk_xy_backing should ignore it.
425 g.ensure_chunk(IVec3::new(0, 0, 1));
426 let backing = g.chunk_xy_backing().expect("two chz=0 chunks present");
427 assert_eq!(backing.chunks_z, 1);
428 assert_eq!(backing.origin_chunk_z, 0);
429 assert_eq!(backing.chunks_x, 2);
430 assert_eq!(backing.chunks_y, 1);
431 assert_eq!(backing.chunks.len(), 2);
432 }
433
434 /// S4B.6.a: new `chunk_xyz_backing` enumerates ALL chunks
435 /// including chz!=0. Indexing layout must match
436 /// `roxlap_core::ChunkGrid`: row-major per z slab.
437 #[test]
438 fn chunk_xyz_backing_with_stacked_chunks_enumerates_all_z() {
439 let mut g = Grid::new(GridTransform::identity());
440 g.ensure_chunk(IVec3::new(0, 0, 0));
441 g.ensure_chunk(IVec3::new(1, 0, 0));
442 g.ensure_chunk(IVec3::new(0, 0, 1));
443 // Leave (1, 0, 1) implicit-air.
444 let backing = g.chunk_xyz_backing().expect("at least one chunk");
445 assert_eq!(backing.chunks_x, 2);
446 assert_eq!(backing.chunks_y, 1);
447 assert_eq!(backing.chunks_z, 2);
448 assert_eq!(backing.origin_chunk_xy, [0, 0]);
449 assert_eq!(backing.origin_chunk_z, 0);
450 assert_eq!(backing.chunks.len(), 2 * 1 * 2);
451 // Index layout: [(dz * chunks_y + dy) * chunks_x + dx]
452 // (0, 0, 0) → dx=0, dy=0, dz=0 → 0
453 // (1, 0, 0) → dx=1, dy=0, dz=0 → 1
454 // (0, 0, 1) → dx=0, dy=0, dz=1 → 2
455 // (1, 0, 1) → dx=1, dy=0, dz=1 → 3 (implicit-air → None)
456 assert!(backing.chunks[0].is_some(), "(0, 0, 0) present");
457 assert!(backing.chunks[1].is_some(), "(1, 0, 0) present");
458 assert!(backing.chunks[2].is_some(), "(0, 0, 1) present");
459 assert!(backing.chunks[3].is_none(), "(1, 0, 1) implicit-air");
460 }
461
462 /// S4B.6.a: chunk_xyz_backing with negative chz origin —
463 /// origin_chunk_z = min_z must reflect the actual minimum chz.
464 #[test]
465 fn chunk_xyz_backing_with_negative_origin_chunk_z() {
466 let mut g = Grid::new(GridTransform::identity());
467 g.ensure_chunk(IVec3::new(0, 0, -2));
468 g.ensure_chunk(IVec3::new(0, 0, 0));
469 let backing = g.chunk_xyz_backing().expect("at least one chunk");
470 assert_eq!(backing.chunks_z, 3); // chz range [-2, 0]
471 assert_eq!(backing.origin_chunk_z, -2);
472 assert!(backing.chunks[0].is_some(), "chz=-2 → dz=0");
473 assert!(backing.chunks[1].is_none(), "chz=-1 → dz=1 implicit-air");
474 assert!(backing.chunks[2].is_some(), "chz=0 → dz=2");
475 }
476}