roxlap_gpu/decompress.rs
1//! GPU.2 — Vxl → (occupancy bitmap, colour offsets, packed colour
2//! array). Pure CPU; no wgpu deps in this module. Shape:
3//!
4//! * `occupancy[x, y]` is 8 contiguous u32 words covering z=0..256,
5//! one bit per voxel with z-innermost ordering. Bit position of
6//! voxel `(x, y, z)` is `z + (x + y*vsid)*CHUNK_Z`; the word
7//! index is `(x + y*vsid)*8 + z/32` and the bit-in-word is `z & 31`.
8//! This packs each column's 256 z-bits into 8 contiguous u32s so
9//! the GPU shader can rank-count solid voxels in O(8 popcount)
10//! instead of O(z) sequential bit fetches.
11//! * `color_offsets[x + y*vsid]` — u32 = base index into `colors`
12//! for that column's voxels in ascending z. `vsid*vsid + 1`
13//! entries; trailing sentinel = `colors.len()`.
14//! * `colors[..]` — packed u32 per occupied voxel, ordered first by
15//! column index then by ascending z within the column.
16//!
17//! The voxlap slab format interleaves floor and ceiling colour
18//! ranges across slab boundaries, with implicit "bedrock" voxels
19//! filling the gap between a slab's textured floor and the next
20//! slab's air-gap top. Bedrock has no per-voxel colour in the slab
21//! data — voxlap stores only textured surfaces.
22//!
23//! **Bedrock-as-solid** (cliff-face fix): each [`MipUpload`] carries
24//! *two* bitmaps — `occupancy` (textured voxels only, for the colour
25//! rank) and `solid_occupancy` (textured surfaces **plus** the
26//! implicit bedrock interior below them). The marcher hit-tests
27//! `solid_occupancy`, so vertical wall/cliff faces are opaque; a
28//! bedrock hit (solid but uncoloured) inherits the colour of the
29//! textured surface above it. Bedrock is still stored as **1 bit**,
30//! not a per-voxel colour, so the colour array stays
31//! `O(textured voxels)` — storing bedrock colours would balloon a
32//! vsid=128 chunk from ~80 KiB to ~10 MiB. The cost is one extra
33//! occupancy bitmap (occupancy storage doubles; colours unchanged).
34//!
35//! (Originally "bedrock-as-air", GPU.4: bedrock was dropped entirely,
36//! which left cliff faces transparent to the sky.)
37//!
38//! This is `O(textured voxels)` work; not on the render hot path.
39
40#![allow(
41 clippy::cast_sign_loss,
42 clippy::cast_possible_truncation,
43 clippy::cast_possible_wrap,
44 clippy::many_single_char_names,
45 clippy::missing_panics_doc,
46 clippy::verbose_bit_mask
47)]
48
49use roxlap_formats::vxl::Vxl;
50
51/// Z-extent of every voxlap column — matches `roxlap_formats`'
52/// private `MAXZDIM` (`voxlap5.h:10`). Re-declared here so this
53/// module stays a pure consumer of the public `Vxl` surface.
54pub const CHUNK_Z: u32 = 256;
55
56/// Historic sentinel BGRA for bedrock voxels — kept exported so
57/// callers that want voxlap-CPU bedrock parity can render their own
58/// pass. **Not used by the default GPU decompressor**: the
59/// "bedrock-as-air" refactor (GPU.4 prereq) skips bedrock entirely.
60pub const BEDROCK_RGB: u32 = 0x0040_4040;
61
62/// CPU-decompressed chunk ready to upload to the GPU. Each field
63/// maps onto one storage buffer in GPU.3+; for GPU.2 the buffers
64/// also serve the read-back validator.
65#[derive(Debug, Clone)]
66pub struct ChunkUpload {
67 /// XY extent of the chunk in voxels — typically `roxlap-scene`'s
68 /// `CHUNK_SIZE_XY = 128`. Same as `Vxl::vsid`.
69 pub vsid: u32,
70 /// 1 bit per voxel, packed little-endian within each u32.
71 /// `bit(x, y, z) = (occupancy[i >> 5] >> (i & 31)) & 1`
72 /// where `i = x + y*vsid + z*vsid*vsid`.
73 pub occupancy: Vec<u32>,
74 /// `vsid*vsid + 1` entries. Column `(x, y)`'s colours live at
75 /// `colors[offsets[x + y*vsid] .. offsets[x + y*vsid + 1]]`,
76 /// in ascending-z order across all solid voxels of that column.
77 pub color_offsets: Vec<u32>,
78 /// Packed BGRA u32 per solid voxel (textured + bedrock).
79 pub colors: Vec<u32>,
80 /// GPU.11 — the full mip ladder, finest (mip-0) first. `mips[0]`
81 /// is identical to the [`Self::occupancy`] / [`Self::color_offsets`]
82 /// / [`Self::colors`] fields above (which the older single-chunk
83 /// and single-grid GPU paths still read directly). The scene
84 /// path concatenates every mip per slot; the shader marches the
85 /// mip its LOD picker selects. Always at least one entry; count
86 /// is [`gpu_mip_count`]`(vsid)`.
87 pub mips: Vec<MipUpload>,
88}
89
90/// Number of u32 words per column in the occupancy bitmap
91/// (`CHUNK_Z` bits packed 32-per-word). With `CHUNK_Z = 256` this is
92/// exactly 8 — the rank-count loop in the GPU shader runs in 8
93/// iterations max.
94pub const OCC_WORDS_PER_COLUMN: u32 = CHUNK_Z / 32;
95
96/// GPU.11 — number of mip levels [`decompress_chunk`] builds per
97/// chunk (capped by the chunk's own `vsid` / `CHUNK_Z` halving).
98/// Matches the CPU demo's `OpticastSettings::mip_levels = 6`, so the
99/// GPU mip ladder reaches the same ray-depth as the CPU path
100/// (`mip_scan_dist · 2⁵`). The per-mip relative-offset tables in
101/// [`crate::scene::GridStaticMeta`] are sized to this.
102pub const GPU_MAX_MIPS: u32 = 6;
103
104/// GPU.11 — how many mip levels a chunk of side `vsid` actually
105/// yields under [`GPU_MAX_MIPS`]. Mirrors the stopping rule in
106/// [`Vxl::generate_mips`] (`src_vsid > 1 && src_z > 1 && n < max`)
107/// so the upload, the per-slot stride math in [`crate::scene`], and
108/// the shader all agree on the level count for a given `vsid`.
109/// Always `>= 1` (mip-0).
110#[must_use]
111pub fn gpu_mip_count(vsid: u32) -> u32 {
112 let mut n = 1u32;
113 let mut v = vsid;
114 let mut z = CHUNK_Z as i32;
115 while v > 1 && z > 1 && n < GPU_MAX_MIPS {
116 v >>= 1;
117 z >>= 1;
118 n += 1;
119 }
120 n
121}
122
123/// GPU.11 — number of occupancy u32 words per column at a given mip
124/// (`(CHUNK_Z >> mip)` bits packed 32-per-word, min 1). Mip-0 is
125/// [`OCC_WORDS_PER_COLUMN`] (= 8).
126#[must_use]
127pub fn occ_words_per_column_for_mip(mip: u32) -> u32 {
128 (CHUNK_Z >> mip).div_ceil(32).max(1)
129}
130
131/// CA perf — a chunk's SOLID voxel z-extent (inclusive, in-chunk
132/// `0..CHUNK_Z`) from its mip-0 solid bitmap, or `None` for an
133/// all-air chunk. Feeds the per-grid `vox_z_lo/hi` marcher clamp in
134/// [`crate::scene::GridStaticMeta`]. One pass over the bitmap words
135/// (columns are z-major, so a word's z window is
136/// `(word % words_per_col) * 32`).
137#[must_use]
138pub fn solid_z_extent(mip0: &MipUpload) -> Option<(u32, u32)> {
139 let wpc = mip0.occ_words_per_col;
140 let mut lo = u32::MAX;
141 let mut hi = 0u32;
142 let mut any = false;
143 for (i, &w) in mip0.solid_occupancy.iter().enumerate() {
144 if w == 0 {
145 continue;
146 }
147 any = true;
148 let zbase = (i as u32 % wpc) * 32;
149 lo = lo.min(zbase + w.trailing_zeros());
150 hi = hi.max(zbase + 31 - w.leading_zeros());
151 }
152 any.then_some((lo, hi))
153}
154
155/// GPU.11 — one mip level of a chunk in the GPU upload shape. Mip-N
156/// has `(vsid >> mip)²` columns spanning z = 0..`CHUNK_Z >> mip`.
157///
158/// `color_offsets` are **absolute within the chunk's whole colour
159/// block** (cumulative across mips): mip-0's run [0..n0], mip-1's
160/// run [n0..n0+n1], etc. So `colors` of all mips concatenated in
161/// level order index directly via `chunk_colors_base + offset +
162/// rank` — the same formula the shader already uses for mip-0,
163/// independent of which mip is being read.
164#[derive(Debug, Clone)]
165pub struct MipUpload {
166 /// XY column extent at this mip = `vsid >> mip`.
167 pub vsid: u32,
168 /// Z-extent at this mip = `CHUNK_Z >> mip`.
169 pub cz: u32,
170 /// Occupancy words per column at this mip = `cz.div_ceil(32)`.
171 pub occ_words_per_col: u32,
172 /// `vsid² * occ_words_per_col` packed **textured** occupancy bits
173 /// (one set bit per voxel that has an explicit colour). The shader
174 /// rank-counts these for the colour lookup.
175 pub occupancy: Vec<u32>,
176 /// Same shape as `occupancy`, but one set bit per **solid** voxel
177 /// — textured surfaces *and* the implicit bedrock interior below
178 /// them. The marcher hit-tests against this so vertical
179 /// wall/cliff faces are opaque; bedrock hits inherit the colour of
180 /// the textured surface above them. (Fixes the "cliff face shows
181 /// sky" bedrock-as-air artifact.)
182 pub solid_occupancy: Vec<u32>,
183 /// `vsid² + 1` cumulative-within-chunk colour offsets.
184 pub color_offsets: Vec<u32>,
185 /// This mip's packed BGRA colours (ascending z within a column,
186 /// columns in `x + y*vsid` order).
187 pub colors: Vec<u32>,
188}
189
190impl ChunkUpload {
191 /// Helper for tests / debug — looks up the colour at `(x, y, z)`
192 /// if solid, else `None`. CPU-side mirror of what the GPU shader
193 /// computes.
194 #[must_use]
195 pub fn voxel_at(&self, x: u32, y: u32, z: u32) -> Option<u32> {
196 if x >= self.vsid || y >= self.vsid || z >= CHUNK_Z {
197 return None;
198 }
199 let col_idx = (x + y * self.vsid) as usize;
200 let col_word_base = col_idx * OCC_WORDS_PER_COLUMN as usize;
201 let z_word = (z / 32) as usize;
202 let z_bit = z & 31;
203 let bit = (self.occupancy[col_word_base + z_word] >> z_bit) & 1;
204 if bit == 0 {
205 return None;
206 }
207 // Rank-count solid voxels at z' < z in the same column —
208 // popcount of `z_word` full words + masked partial.
209 let mut rank = 0u32;
210 for w in 0..z_word {
211 rank += self.occupancy[col_word_base + w].count_ones();
212 }
213 let mask = if z_bit == 0 {
214 0u32
215 } else {
216 (1u32 << z_bit) - 1
217 };
218 rank += (self.occupancy[col_word_base + z_word] & mask).count_ones();
219
220 let base = self.color_offsets[col_idx];
221 Some(self.colors[(base + rank) as usize])
222 }
223}
224
225/// Decompress a `Vxl` chunk into the GPU upload shape, building the
226/// full mip ladder ([`gpu_mip_count`]`(vsid)` levels). Caller
227/// guarantees `vxl` is shaped as a roxlap-scene chunk (`vsid`
228/// square). If `vxl` already carries at least that many mips (the
229/// common scene path — the bake generates 6), they are read
230/// directly; otherwise the chunk is cloned and re-mipped so the
231/// upload always carries a deterministic, vsid-uniform level count.
232///
233/// `mips[0]` is the legacy mip-0 data, also mirrored into the
234/// top-level [`ChunkUpload`] fields for the older single-chunk /
235/// single-grid paths.
236#[must_use]
237pub fn decompress_chunk(vxl: &Vxl) -> ChunkUpload {
238 let vsid = vxl.vsid;
239 let target = gpu_mip_count(vsid);
240
241 // Ensure `target` mips are available without mutating the
242 // caller's borrow. The terrain + streaming bake already builds
243 // 6, so the fast path takes the existing tables (no clone).
244 let owned;
245 let src: &Vxl = if vxl.mip_count() >= target {
246 vxl
247 } else {
248 let mut c = vxl.clone();
249 c.generate_mips(GPU_MAX_MIPS);
250 owned = c;
251 &owned
252 };
253
254 let mut mips: Vec<MipUpload> = Vec::with_capacity(target as usize);
255 let mut color_base = 0u32;
256 for m in 0..target {
257 let mip = decompress_mip(src, m, color_base);
258 color_base = *mip.color_offsets.last().expect("offsets non-empty");
259 mips.push(mip);
260 }
261
262 let m0 = &mips[0];
263 ChunkUpload {
264 vsid,
265 occupancy: m0.occupancy.clone(),
266 color_offsets: m0.color_offsets.clone(),
267 colors: m0.colors.clone(),
268 mips,
269 }
270}
271
272/// Decompress a single mip level `mip` of `src` into a [`MipUpload`].
273/// `color_base` is the cumulative colour count of all finer mips
274/// (mips `< mip`) so this level's `color_offsets` stay absolute
275/// within the chunk's whole colour block.
276#[must_use]
277fn decompress_mip(src: &Vxl, mip: u32, color_base: u32) -> MipUpload {
278 let vsid = src.vsid >> mip;
279 let cz = CHUNK_Z >> mip;
280 let occ_words_per_col = occ_words_per_column_for_mip(mip);
281 let vsid_usize = vsid as usize;
282 let n_cols = vsid_usize * vsid_usize;
283 let n_occ_words = n_cols * (occ_words_per_col as usize);
284
285 let mut occupancy = vec![0u32; n_occ_words];
286 let mut solid_occupancy = vec![0u32; n_occ_words];
287 let mut color_offsets = vec![0u32; n_cols + 1];
288 let mut colors: Vec<u32> = Vec::with_capacity(n_cols * 4);
289
290 for y in 0..vsid {
291 for x in 0..vsid {
292 let col_idx = (y as usize) * vsid_usize + (x as usize);
293 color_offsets[col_idx] =
294 color_base + u32::try_from(colors.len()).expect("colours fit in u32");
295
296 let slab = src.column_data_for_mip(mip, col_idx);
297 decompress_column(
298 slab,
299 x,
300 y,
301 vsid,
302 cz,
303 occ_words_per_col,
304 &mut occupancy,
305 &mut solid_occupancy,
306 &mut colors,
307 );
308 }
309 }
310 color_offsets[n_cols] = color_base + u32::try_from(colors.len()).expect("colours fit in u32");
311
312 MipUpload {
313 vsid,
314 cz,
315 occ_words_per_col,
316 occupancy,
317 solid_occupancy,
318 color_offsets,
319 colors,
320 }
321}
322
323/// Walk one column's slab chain, producing two bitmaps + the colour
324/// list:
325/// * `occupancy` — one bit per **textured** voxel (has an explicit
326/// colour); pushed into `colors` in ascending z. The shader
327/// rank-counts these for the colour lookup.
328/// * `solid_occupancy` — one bit per **solid** voxel: textured
329/// surfaces *and* the implicit bedrock interior below them. The
330/// marcher hit-tests this, so cliff/wall faces are opaque.
331///
332/// Bedrock (solid but uncoloured) is marked solid only *after* the
333/// column's first real textured surface, so the `empty_chunk_vxl`
334/// all-zero placeholder columns stay fully air (no spurious black
335/// floor/ceiling). `cz` is the column z-extent at this mip; the runs
336/// from [`expand_solid_runs`] already exclude overhang air gaps.
337#[allow(clippy::too_many_arguments)]
338pub(crate) fn decompress_column(
339 slab: &[u8],
340 x: u32,
341 y: u32,
342 vsid: u32,
343 cz: u32,
344 occ_words_per_col: u32,
345 occupancy: &mut [u32],
346 solid_occupancy: &mut [u32],
347 colors: &mut Vec<u32>,
348) {
349 let vsid_usize = vsid as usize;
350 let runs = expand_solid_runs(slab, cz);
351 let ranges = build_color_ranges(slab);
352
353 let col_idx = (x as usize) + (y as usize) * vsid_usize;
354 let col_word_base = col_idx * (occ_words_per_col as usize);
355 // Once a column has a real textured surface, everything solid
356 // below it is bedrock to fill (opaque). Before the first surface
357 // the run voxels are placeholder/air — leave them clear.
358 let mut have_surface = false;
359
360 let mut range_cursor = 0usize;
361 for (top, bot) in runs {
362 for z in top..bot {
363 while range_cursor < ranges.len() && z >= ranges[range_cursor].z_end {
364 range_cursor += 1;
365 }
366 let in_range = range_cursor < ranges.len() && z >= ranges[range_cursor].z_start;
367 // A textured voxel has a non-zero RGB inside a colour
368 // range. `empty_chunk_vxl`'s placeholder keeps RGB 0
369 // ([0,0,0,0]); treat it as untextured so unbaked columns
370 // don't paint a black surface.
371 let mut rgb = 0u32;
372 if in_range {
373 let off = ((z - ranges[range_cursor].z_start) as usize) * 4;
374 let bytes = &ranges[range_cursor].colours[off..off + 4];
375 rgb = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
376 }
377 let z_word = (z as usize) / 32;
378 let z_bit = (z as u32) & 31;
379
380 if in_range && (rgb & 0x00ff_ffff) != 0 {
381 // Textured surface voxel: solid + coloured.
382 occupancy[col_word_base + z_word] |= 1u32 << z_bit;
383 solid_occupancy[col_word_base + z_word] |= 1u32 << z_bit;
384 colors.push(rgb);
385 have_surface = true;
386 } else if !in_range && have_surface {
387 // GENUINE bedrock — uncoloured solid below a surface
388 // (no colour range covers it). Mark solid; it inherits
389 // the surface colour above at render time. A voxel that
390 // IS in a colour range but reads RGB 0 is the
391 // `empty_chunk_vxl` placeholder (the column's z=255
392 // air filler) — leave it air, else floating objects
393 // grow a spurious floor plane below them.
394 solid_occupancy[col_word_base + z_word] |= 1u32 << z_bit;
395 }
396 }
397 }
398}
399
400/// Port of `expandrle` (voxlap5.c:4131) but emitting `(top, bot)`
401/// pairs as half-open ranges instead of the in-place `uind` layout.
402/// Solid for `z ∈ [top, bot)`. Last run's `bot` is always `cz` (the
403/// column's z-extent at this mip — matches the voxlap "implicit
404/// bedrock below" assumption, halved per mip level).
405fn expand_solid_runs(slab: &[u8], cz: u32) -> Vec<(i32, i32)> {
406 // Worst case = MAXZDIM/2 alternating solid/air runs (mip-0 bound;
407 // coarser mips use fewer entries).
408 let mut uind = [0i32; (CHUNK_Z as usize) + 2];
409 uind[0] = i32::from(slab[1]);
410 let mut i = 2usize;
411 let mut v = 0usize;
412 while slab[v] != 0 {
413 v += usize::from(slab[v]) * 4;
414 if slab[v + 3] >= slab[v + 1] {
415 continue;
416 }
417 uind[i - 1] = i32::from(slab[v + 3]);
418 uind[i] = i32::from(slab[v + 1]);
419 i += 2;
420 }
421 uind[i - 1] = cz as i32;
422
423 let n_runs = i / 2;
424 let mut runs = Vec::with_capacity(n_runs);
425 for k in 0..n_runs {
426 runs.push((uind[2 * k], uind[2 * k + 1]));
427 }
428 runs
429}
430
431/// One colour-record range = colours for voxels at `z ∈ [z_start, z_end)`.
432struct ColorRange<'s> {
433 z_start: i32,
434 z_end: i32,
435 colours: &'s [u8],
436}
437
438/// Build the per-column colour lookup table — port of voxlap's
439/// `compilerle` colour-table loop (voxlap5.c:4163-4174) + the
440/// matching ceiling-colour walk. Mirrors `roxlap-formats`'
441/// private `build_color_table` field-for-field.
442fn build_color_ranges(slab: &[u8]) -> Vec<ColorRange<'_>> {
443 let mut ranges: Vec<ColorRange<'_>> = Vec::new();
444 let mut v = 0usize;
445 loop {
446 let z_start = i32::from(slab[v + 1]);
447 let z1c = i32::from(slab[v + 2]);
448 let z_end = z1c + 1;
449 let n_voxels = usize::try_from((z_end - z_start).max(0)).expect("non-negative");
450 let off = v + 4;
451 ranges.push(ColorRange {
452 z_start,
453 z_end,
454 colours: &slab[off..off + n_voxels * 4],
455 });
456
457 let nextptr = slab[v];
458 if nextptr == 0 {
459 break;
460 }
461 let prev_z1 = z_start;
462 let prev_z1c = z1c;
463 let prev_nextptr = i32::from(nextptr);
464 v += usize::from(nextptr) * 4;
465
466 // Ceiling colour list for the NEW slab — stored in the tail
467 // of the previous slab's bytes, between its floor colours
468 // and the next slab's header.
469 let ze = i32::from(slab[v + 3]);
470 let ceil_z_start = ze + prev_z1c - prev_z1 - prev_nextptr + 2;
471 let ceil_z_end = ze;
472 let ceil_n = usize::try_from((ceil_z_end - ceil_z_start).max(0)).expect("non-negative");
473 let ceil_start = v - ceil_n * 4;
474 ranges.push(ColorRange {
475 z_start: ceil_z_start,
476 z_end: ceil_z_end,
477 colours: &slab[ceil_start..v],
478 });
479 }
480 ranges
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486 use roxlap_formats::vxl::Vxl;
487
488 /// Build a tiny `Vxl` (4×4 columns) where every column is a
489 /// single-slab "one floor voxel at z=100" with colour
490 /// `0xAARRGGBB = 0x80ff_8000` (red-orange). Used as the
491 /// canonical fixture for both CPU and GPU round-trip tests.
492 pub(crate) fn fixture_one_voxel_per_column() -> Vxl {
493 let vsid: u32 = 4;
494 let n_cols = (vsid as usize) * (vsid as usize);
495 let mut data = Vec::with_capacity(n_cols * 8);
496 let mut column_offset = Vec::with_capacity(n_cols + 1);
497 // 0x80ff_8000 little-endian bytes = [0x00, 0x80, 0xff, 0x80]
498 // = [B=0x00, G=0x80, R=0xff, A=0x80 (neutral brightness)].
499 // Alpha=0x80 keeps the fixture non-empty under the
500 // alpha-zero placeholder filter in `decompress_column`.
501 let bgra = [0x00, 0x80, 0xff, 0x80];
502 for _ in 0..n_cols {
503 column_offset.push(u32::try_from(data.len()).expect("offset fits"));
504 data.extend_from_slice(&[0, 100, 100, 0]); // nextptr=0, z1=100, z1c=100, z0=0
505 data.extend_from_slice(&bgra);
506 }
507 column_offset.push(u32::try_from(data.len()).expect("offset fits"));
508
509 Vxl {
510 vsid,
511 ipo: [0.0; 3],
512 ist: [1.0, 0.0, 0.0],
513 ihe: [0.0, 0.0, 1.0],
514 ifo: [0.0, 1.0, 0.0],
515 data: data.into_boxed_slice(),
516 column_offset: column_offset.into_boxed_slice(),
517 mip_base_offsets: Box::new([0, n_cols + 1]),
518 vbit: Box::new([]),
519 vbiti: 0,
520 }
521 }
522
523 #[test]
524 fn fixture_textured_voxel_carries_slab_colour() {
525 let vxl = fixture_one_voxel_per_column();
526 let chunk = decompress_chunk(&vxl);
527 assert_eq!(chunk.voxel_at(1, 2, 100), Some(0x80ff_8000));
528 }
529
530 #[test]
531 fn fixture_air_above_textured_is_empty() {
532 let vxl = fixture_one_voxel_per_column();
533 let chunk = decompress_chunk(&vxl);
534 for z in 0..100 {
535 assert_eq!(chunk.voxel_at(1, 2, z), None, "z={z} expected air");
536 }
537 }
538
539 #[test]
540 fn fixture_below_textured_is_air_after_bedrock_strip() {
541 // Bedrock-as-air refactor (GPU.4 prereq): z>z1c is no
542 // longer reported as solid by the GPU decompressor.
543 let vxl = fixture_one_voxel_per_column();
544 let chunk = decompress_chunk(&vxl);
545 for z in 101..CHUNK_Z {
546 assert_eq!(
547 chunk.voxel_at(1, 2, z),
548 None,
549 "z={z} expected air (bedrock stripped)"
550 );
551 }
552 }
553
554 #[test]
555 fn only_textured_voxels_are_marked_solid() {
556 let vxl = fixture_one_voxel_per_column();
557 let chunk = decompress_chunk(&vxl);
558 // 1 textured voxel per column.
559 let solid: u32 = chunk.occupancy.iter().map(|w| w.count_ones()).sum();
560 let expected = chunk.vsid * chunk.vsid;
561 assert_eq!(solid, expected);
562 }
563
564 // ---- GPU.11 mip-ladder tests ------------------------------------
565
566 #[test]
567 fn gpu_mip_count_matches_generate_mips() {
568 // vsid=128 chunk supports the full GPU_MAX_MIPS=6.
569 assert_eq!(gpu_mip_count(128), 6);
570 // Small vsid caps the ladder where halving hits 1.
571 assert_eq!(gpu_mip_count(4), 3); // 4 -> 2 -> 1
572 assert_eq!(gpu_mip_count(2), 2); // 2 -> 1
573 assert_eq!(gpu_mip_count(1), 1);
574 }
575
576 #[test]
577 fn occ_words_per_column_halves_with_z() {
578 assert_eq!(occ_words_per_column_for_mip(0), 8); // 256/32
579 assert_eq!(occ_words_per_column_for_mip(1), 4); // 128/32
580 assert_eq!(occ_words_per_column_for_mip(2), 2); // 64/32
581 assert_eq!(occ_words_per_column_for_mip(3), 1); // 32/32
582 assert_eq!(occ_words_per_column_for_mip(4), 1); // 16 -> min 1
583 assert_eq!(occ_words_per_column_for_mip(5), 1); // 8 -> min 1
584 }
585
586 #[test]
587 fn mip0_mirrors_legacy_top_level_fields() {
588 let vxl = fixture_one_voxel_per_column();
589 let chunk = decompress_chunk(&vxl);
590 assert!(chunk.mips.len() >= 2, "fixture should build a mip ladder");
591 let m0 = &chunk.mips[0];
592 assert_eq!(m0.vsid, 4);
593 assert_eq!(m0.cz, CHUNK_Z);
594 assert_eq!(m0.occ_words_per_col, OCC_WORDS_PER_COLUMN);
595 assert_eq!(m0.occupancy, chunk.occupancy, "mip-0 occupancy == legacy");
596 assert_eq!(m0.colors, chunk.colors, "mip-0 colours == legacy");
597 assert_eq!(
598 m0.color_offsets, chunk.color_offsets,
599 "mip-0 offsets == legacy"
600 );
601 assert_eq!(m0.color_offsets[0], 0, "mip-0 starts at colour 0");
602 }
603
604 #[test]
605 fn each_mip_popcount_equals_color_count() {
606 // The 1:1 occupancy-bit ↔ colour invariant must hold at every
607 // mip level (the shader's rank-count colour lookup relies on
608 // it). The fixture's clone+generate_mips path exercises the
609 // coarse levels.
610 let vxl = fixture_one_voxel_per_column();
611 let chunk = decompress_chunk(&vxl);
612 assert_eq!(chunk.mips.len() as u32, gpu_mip_count(4));
613 for (m, mip) in chunk.mips.iter().enumerate() {
614 let solid: u32 = mip.occupancy.iter().map(|w| w.count_ones()).sum();
615 let in_mip = mip.colors.len() as u32;
616 assert_eq!(
617 solid, in_mip,
618 "mip {m}: {solid} solid bits but {in_mip} colours",
619 );
620 assert_eq!(mip.vsid, 4 >> m as u32);
621 assert_eq!(mip.cz, CHUNK_Z >> m as u32);
622 assert_eq!(
623 mip.occ_words_per_col,
624 occ_words_per_column_for_mip(m as u32)
625 );
626 assert_eq!(
627 mip.occupancy.len() as u32,
628 mip.vsid * mip.vsid * mip.occ_words_per_col,
629 );
630 assert_eq!(mip.color_offsets.len() as u32, mip.vsid * mip.vsid + 1);
631 }
632 }
633
634 #[test]
635 fn solid_occupancy_fills_bedrock_below_surface() {
636 // The cliff-face fix: every mip's `solid_occupancy` is solid
637 // from the textured surface down through the bedrock interior,
638 // while `occupancy` (textured) marks only the surface.
639 let vxl = fixture_one_voxel_per_column(); // textured at z=100
640 let chunk = decompress_chunk(&vxl);
641 let m0 = &chunk.mips[0];
642 let base = 0usize; // column (0,0)
643 let bit = |buf: &[u32], z: u32| (buf[base + (z / 32) as usize] >> (z & 31)) & 1 == 1;
644
645 assert!(bit(&m0.occupancy, 100), "surface textured");
646 assert!(bit(&m0.solid_occupancy, 100), "surface solid");
647 assert!(!bit(&m0.occupancy, 150), "bedrock is not textured");
648 assert!(
649 bit(&m0.solid_occupancy, 150),
650 "bedrock below surface is solid"
651 );
652 assert!(bit(&m0.solid_occupancy, 255), "bedrock fills to the bottom");
653 assert!(
654 !bit(&m0.solid_occupancy, 50),
655 "air above the surface stays air"
656 );
657 // The textured popcount still equals the colour count.
658 let solid: u32 = m0.solid_occupancy.iter().map(|w| w.count_ones()).sum();
659 let tex: u32 = m0.occupancy.iter().map(|w| w.count_ones()).sum();
660 assert!(solid > tex, "solid (surface + bedrock) exceeds textured");
661 }
662
663 #[test]
664 fn color_offsets_are_absolute_and_monotonic_across_mips() {
665 let vxl = fixture_one_voxel_per_column();
666 let chunk = decompress_chunk(&vxl);
667 let mut prev_end = 0u32;
668 for (m, mip) in chunk.mips.iter().enumerate() {
669 // Within a mip, offsets are non-decreasing.
670 for w in mip.color_offsets.windows(2) {
671 assert!(w[0] <= w[1], "mip {m} offsets not monotonic");
672 }
673 // First offset continues where the previous mip's colours
674 // ended (cumulative within the chunk's whole colour block).
675 assert_eq!(
676 mip.color_offsets[0], prev_end,
677 "mip {m} colour base not contiguous",
678 );
679 // Trailing sentinel == base + this mip's colour count.
680 assert_eq!(
681 *mip.color_offsets.last().unwrap(),
682 prev_end + mip.colors.len() as u32,
683 );
684 prev_end = *mip.color_offsets.last().unwrap();
685 }
686 }
687
688 #[test]
689 fn color_offsets_partition_colours_correctly() {
690 let vxl = fixture_one_voxel_per_column();
691 let chunk = decompress_chunk(&vxl);
692 let n_cols = (chunk.vsid * chunk.vsid) as usize;
693 assert_eq!(chunk.color_offsets.len(), n_cols + 1);
694 assert_eq!(chunk.color_offsets[0], 0);
695 // Bedrock is stripped — only the 1 textured voxel/column
696 // ends up in colours.
697 let per_col = 1;
698 for i in 0..=n_cols {
699 assert_eq!(
700 chunk.color_offsets[i],
701 u32::try_from(i).expect("test fixture small") * per_col,
702 );
703 }
704 assert_eq!(
705 *chunk.color_offsets.last().unwrap() as usize,
706 chunk.colors.len()
707 );
708 }
709
710 /// CA follow-up — the GPU shadow march's empty-block skip jumps 8³
711 /// cell boxes whose mip-(m+3) SOLID bit is clear, so every solid
712 /// mip level must be a **superset** of its children: a clear
713 /// parent bit must imply all 8 child bits clear, or the skip could
714 /// jump over a real occluder. Adjacent-level supersets compose to
715 /// any span. Checked over fixtures with terrain, thin plates with
716 /// real air gaps, carved caves and isolated pillars.
717 #[test]
718 fn solid_mips_are_child_supersets() {
719 use roxlap_formats::color::VoxColor;
720 let c = VoxColor(0x80_60_60_60);
721 let fixtures: Vec<(&str, Vxl)> = vec![
722 (
723 "hilly terrain",
724 Vxl::from_dense(64, |x, y, z| {
725 let surf = 20 + ((x / 3 + y / 5) % 17);
726 (z >= surf).then_some(c)
727 }),
728 ),
729 (
730 "plates with air gaps",
731 Vxl::from_dense(64, |_, _, z| matches!(z, 33 | 100 | 101 | 200).then_some(c)),
732 ),
733 (
734 "carved + pillars",
735 Vxl::from_dense(64, |x, y, z| {
736 let solid = z >= 40;
737 let cave = {
738 let (dx, dy, dz) = (x as i32 - 32, y as i32 - 32, z as i32 - 60);
739 dx * dx + dy * dy + dz * dz <= 15 * 15
740 };
741 let pillar = x % 23 == 1 && y % 19 == 2 && z >= 10;
742 ((solid && !cave) || pillar).then_some(c)
743 }),
744 ),
745 ];
746 let bit = |m: &MipUpload, x: u32, y: u32, z: u32| -> bool {
747 let w = (x + y * m.vsid) * m.occ_words_per_col + (z >> 5);
748 (m.solid_occupancy[w as usize] >> (z & 31)) & 1 != 0
749 };
750 for (name, vxl) in &fixtures {
751 let up = decompress_chunk(vxl);
752 for l in 0..up.mips.len() - 1 {
753 let (fine, coarse) = (&up.mips[l], &up.mips[l + 1]);
754 for y in 0..coarse.vsid {
755 for x in 0..coarse.vsid {
756 for z in 0..coarse.cz {
757 if bit(coarse, x, y, z) {
758 continue;
759 }
760 for d in 0..8u32 {
761 let (fx, fy, fz) =
762 (2 * x + (d & 1), 2 * y + ((d >> 1) & 1), 2 * z + (d >> 2));
763 if fx < fine.vsid && fy < fine.vsid && fz < fine.cz {
764 assert!(
765 !bit(fine, fx, fy, fz),
766 "{name}: mip {} clear at ({x},{y},{z}) but child \
767 ({fx},{fy},{fz}) solid at mip {l}",
768 l + 1,
769 );
770 }
771 }
772 }
773 }
774 }
775 }
776 }
777 }
778}