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/// GPU.11 — one mip level of a chunk in the GPU upload shape. Mip-N
132/// has `(vsid >> mip)²` columns spanning z = 0..`CHUNK_Z >> mip`.
133///
134/// `color_offsets` are **absolute within the chunk's whole colour
135/// block** (cumulative across mips): mip-0's run [0..n0], mip-1's
136/// run [n0..n0+n1], etc. So `colors` of all mips concatenated in
137/// level order index directly via `chunk_colors_base + offset +
138/// rank` — the same formula the shader already uses for mip-0,
139/// independent of which mip is being read.
140#[derive(Debug, Clone)]
141pub struct MipUpload {
142 /// XY column extent at this mip = `vsid >> mip`.
143 pub vsid: u32,
144 /// Z-extent at this mip = `CHUNK_Z >> mip`.
145 pub cz: u32,
146 /// Occupancy words per column at this mip = `cz.div_ceil(32)`.
147 pub occ_words_per_col: u32,
148 /// `vsid² * occ_words_per_col` packed **textured** occupancy bits
149 /// (one set bit per voxel that has an explicit colour). The shader
150 /// rank-counts these for the colour lookup.
151 pub occupancy: Vec<u32>,
152 /// Same shape as `occupancy`, but one set bit per **solid** voxel
153 /// — textured surfaces *and* the implicit bedrock interior below
154 /// them. The marcher hit-tests against this so vertical
155 /// wall/cliff faces are opaque; bedrock hits inherit the colour of
156 /// the textured surface above them. (Fixes the "cliff face shows
157 /// sky" bedrock-as-air artifact.)
158 pub solid_occupancy: Vec<u32>,
159 /// `vsid² + 1` cumulative-within-chunk colour offsets.
160 pub color_offsets: Vec<u32>,
161 /// This mip's packed BGRA colours (ascending z within a column,
162 /// columns in `x + y*vsid` order).
163 pub colors: Vec<u32>,
164}
165
166impl ChunkUpload {
167 /// Helper for tests / debug — looks up the colour at `(x, y, z)`
168 /// if solid, else `None`. CPU-side mirror of what the GPU shader
169 /// computes.
170 #[must_use]
171 pub fn voxel_at(&self, x: u32, y: u32, z: u32) -> Option<u32> {
172 if x >= self.vsid || y >= self.vsid || z >= CHUNK_Z {
173 return None;
174 }
175 let col_idx = (x + y * self.vsid) as usize;
176 let col_word_base = col_idx * OCC_WORDS_PER_COLUMN as usize;
177 let z_word = (z / 32) as usize;
178 let z_bit = z & 31;
179 let bit = (self.occupancy[col_word_base + z_word] >> z_bit) & 1;
180 if bit == 0 {
181 return None;
182 }
183 // Rank-count solid voxels at z' < z in the same column —
184 // popcount of `z_word` full words + masked partial.
185 let mut rank = 0u32;
186 for w in 0..z_word {
187 rank += self.occupancy[col_word_base + w].count_ones();
188 }
189 let mask = if z_bit == 0 {
190 0u32
191 } else {
192 (1u32 << z_bit) - 1
193 };
194 rank += (self.occupancy[col_word_base + z_word] & mask).count_ones();
195
196 let base = self.color_offsets[col_idx];
197 Some(self.colors[(base + rank) as usize])
198 }
199}
200
201/// Decompress a `Vxl` chunk into the GPU upload shape, building the
202/// full mip ladder ([`gpu_mip_count`]`(vsid)` levels). Caller
203/// guarantees `vxl` is shaped as a roxlap-scene chunk (`vsid`
204/// square). If `vxl` already carries at least that many mips (the
205/// common scene path — the bake generates 6), they are read
206/// directly; otherwise the chunk is cloned and re-mipped so the
207/// upload always carries a deterministic, vsid-uniform level count.
208///
209/// `mips[0]` is the legacy mip-0 data, also mirrored into the
210/// top-level [`ChunkUpload`] fields for the older single-chunk /
211/// single-grid paths.
212#[must_use]
213pub fn decompress_chunk(vxl: &Vxl) -> ChunkUpload {
214 let vsid = vxl.vsid;
215 let target = gpu_mip_count(vsid);
216
217 // Ensure `target` mips are available without mutating the
218 // caller's borrow. The terrain + streaming bake already builds
219 // 6, so the fast path takes the existing tables (no clone).
220 let owned;
221 let src: &Vxl = if vxl.mip_count() >= target {
222 vxl
223 } else {
224 let mut c = vxl.clone();
225 c.generate_mips(GPU_MAX_MIPS);
226 owned = c;
227 &owned
228 };
229
230 let mut mips: Vec<MipUpload> = Vec::with_capacity(target as usize);
231 let mut color_base = 0u32;
232 for m in 0..target {
233 let mip = decompress_mip(src, m, color_base);
234 color_base = *mip.color_offsets.last().expect("offsets non-empty");
235 mips.push(mip);
236 }
237
238 let m0 = &mips[0];
239 ChunkUpload {
240 vsid,
241 occupancy: m0.occupancy.clone(),
242 color_offsets: m0.color_offsets.clone(),
243 colors: m0.colors.clone(),
244 mips,
245 }
246}
247
248/// Decompress a single mip level `mip` of `src` into a [`MipUpload`].
249/// `color_base` is the cumulative colour count of all finer mips
250/// (mips `< mip`) so this level's `color_offsets` stay absolute
251/// within the chunk's whole colour block.
252#[must_use]
253fn decompress_mip(src: &Vxl, mip: u32, color_base: u32) -> MipUpload {
254 let vsid = src.vsid >> mip;
255 let cz = CHUNK_Z >> mip;
256 let occ_words_per_col = occ_words_per_column_for_mip(mip);
257 let vsid_usize = vsid as usize;
258 let n_cols = vsid_usize * vsid_usize;
259 let n_occ_words = n_cols * (occ_words_per_col as usize);
260
261 let mut occupancy = vec![0u32; n_occ_words];
262 let mut solid_occupancy = vec![0u32; n_occ_words];
263 let mut color_offsets = vec![0u32; n_cols + 1];
264 let mut colors: Vec<u32> = Vec::with_capacity(n_cols * 4);
265
266 for y in 0..vsid {
267 for x in 0..vsid {
268 let col_idx = (y as usize) * vsid_usize + (x as usize);
269 color_offsets[col_idx] =
270 color_base + u32::try_from(colors.len()).expect("colours fit in u32");
271
272 let slab = src.column_data_for_mip(mip, col_idx);
273 decompress_column(
274 slab,
275 x,
276 y,
277 vsid,
278 cz,
279 occ_words_per_col,
280 &mut occupancy,
281 &mut solid_occupancy,
282 &mut colors,
283 );
284 }
285 }
286 color_offsets[n_cols] = color_base + u32::try_from(colors.len()).expect("colours fit in u32");
287
288 MipUpload {
289 vsid,
290 cz,
291 occ_words_per_col,
292 occupancy,
293 solid_occupancy,
294 color_offsets,
295 colors,
296 }
297}
298
299/// Walk one column's slab chain, producing two bitmaps + the colour
300/// list:
301/// * `occupancy` — one bit per **textured** voxel (has an explicit
302/// colour); pushed into `colors` in ascending z. The shader
303/// rank-counts these for the colour lookup.
304/// * `solid_occupancy` — one bit per **solid** voxel: textured
305/// surfaces *and* the implicit bedrock interior below them. The
306/// marcher hit-tests this, so cliff/wall faces are opaque.
307///
308/// Bedrock (solid but uncoloured) is marked solid only *after* the
309/// column's first real textured surface, so the `empty_chunk_vxl`
310/// all-zero placeholder columns stay fully air (no spurious black
311/// floor/ceiling). `cz` is the column z-extent at this mip; the runs
312/// from [`expand_solid_runs`] already exclude overhang air gaps.
313#[allow(clippy::too_many_arguments)]
314fn decompress_column(
315 slab: &[u8],
316 x: u32,
317 y: u32,
318 vsid: u32,
319 cz: u32,
320 occ_words_per_col: u32,
321 occupancy: &mut [u32],
322 solid_occupancy: &mut [u32],
323 colors: &mut Vec<u32>,
324) {
325 let vsid_usize = vsid as usize;
326 let runs = expand_solid_runs(slab, cz);
327 let ranges = build_color_ranges(slab);
328
329 let col_idx = (x as usize) + (y as usize) * vsid_usize;
330 let col_word_base = col_idx * (occ_words_per_col as usize);
331 // Once a column has a real textured surface, everything solid
332 // below it is bedrock to fill (opaque). Before the first surface
333 // the run voxels are placeholder/air — leave them clear.
334 let mut have_surface = false;
335
336 let mut range_cursor = 0usize;
337 for (top, bot) in runs {
338 for z in top..bot {
339 while range_cursor < ranges.len() && z >= ranges[range_cursor].z_end {
340 range_cursor += 1;
341 }
342 let in_range = range_cursor < ranges.len() && z >= ranges[range_cursor].z_start;
343 // A textured voxel has a non-zero RGB inside a colour
344 // range. `empty_chunk_vxl`'s placeholder keeps RGB 0
345 // ([0,0,0,0]); treat it as untextured so unbaked columns
346 // don't paint a black surface.
347 let mut rgb = 0u32;
348 if in_range {
349 let off = ((z - ranges[range_cursor].z_start) as usize) * 4;
350 let bytes = &ranges[range_cursor].colours[off..off + 4];
351 rgb = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
352 }
353 let z_word = (z as usize) / 32;
354 let z_bit = (z as u32) & 31;
355
356 if in_range && (rgb & 0x00ff_ffff) != 0 {
357 // Textured surface voxel: solid + coloured.
358 occupancy[col_word_base + z_word] |= 1u32 << z_bit;
359 solid_occupancy[col_word_base + z_word] |= 1u32 << z_bit;
360 colors.push(rgb);
361 have_surface = true;
362 } else if !in_range && have_surface {
363 // GENUINE bedrock — uncoloured solid below a surface
364 // (no colour range covers it). Mark solid; it inherits
365 // the surface colour above at render time. A voxel that
366 // IS in a colour range but reads RGB 0 is the
367 // `empty_chunk_vxl` placeholder (the column's z=255
368 // air filler) — leave it air, else floating objects
369 // grow a spurious floor plane below them.
370 solid_occupancy[col_word_base + z_word] |= 1u32 << z_bit;
371 }
372 }
373 }
374}
375
376/// Port of `expandrle` (voxlap5.c:4131) but emitting `(top, bot)`
377/// pairs as half-open ranges instead of the in-place `uind` layout.
378/// Solid for `z ∈ [top, bot)`. Last run's `bot` is always `cz` (the
379/// column's z-extent at this mip — matches the voxlap "implicit
380/// bedrock below" assumption, halved per mip level).
381fn expand_solid_runs(slab: &[u8], cz: u32) -> Vec<(i32, i32)> {
382 // Worst case = MAXZDIM/2 alternating solid/air runs (mip-0 bound;
383 // coarser mips use fewer entries).
384 let mut uind = [0i32; (CHUNK_Z as usize) + 2];
385 uind[0] = i32::from(slab[1]);
386 let mut i = 2usize;
387 let mut v = 0usize;
388 while slab[v] != 0 {
389 v += usize::from(slab[v]) * 4;
390 if slab[v + 3] >= slab[v + 1] {
391 continue;
392 }
393 uind[i - 1] = i32::from(slab[v + 3]);
394 uind[i] = i32::from(slab[v + 1]);
395 i += 2;
396 }
397 uind[i - 1] = cz as i32;
398
399 let n_runs = i / 2;
400 let mut runs = Vec::with_capacity(n_runs);
401 for k in 0..n_runs {
402 runs.push((uind[2 * k], uind[2 * k + 1]));
403 }
404 runs
405}
406
407/// One colour-record range = colours for voxels at `z ∈ [z_start, z_end)`.
408struct ColorRange<'s> {
409 z_start: i32,
410 z_end: i32,
411 colours: &'s [u8],
412}
413
414/// Build the per-column colour lookup table — port of voxlap's
415/// `compilerle` colour-table loop (voxlap5.c:4163-4174) + the
416/// matching ceiling-colour walk. Mirrors `roxlap-formats`'
417/// private `build_color_table` field-for-field.
418fn build_color_ranges(slab: &[u8]) -> Vec<ColorRange<'_>> {
419 let mut ranges: Vec<ColorRange<'_>> = Vec::new();
420 let mut v = 0usize;
421 loop {
422 let z_start = i32::from(slab[v + 1]);
423 let z1c = i32::from(slab[v + 2]);
424 let z_end = z1c + 1;
425 let n_voxels = usize::try_from((z_end - z_start).max(0)).expect("non-negative");
426 let off = v + 4;
427 ranges.push(ColorRange {
428 z_start,
429 z_end,
430 colours: &slab[off..off + n_voxels * 4],
431 });
432
433 let nextptr = slab[v];
434 if nextptr == 0 {
435 break;
436 }
437 let prev_z1 = z_start;
438 let prev_z1c = z1c;
439 let prev_nextptr = i32::from(nextptr);
440 v += usize::from(nextptr) * 4;
441
442 // Ceiling colour list for the NEW slab — stored in the tail
443 // of the previous slab's bytes, between its floor colours
444 // and the next slab's header.
445 let ze = i32::from(slab[v + 3]);
446 let ceil_z_start = ze + prev_z1c - prev_z1 - prev_nextptr + 2;
447 let ceil_z_end = ze;
448 let ceil_n = usize::try_from((ceil_z_end - ceil_z_start).max(0)).expect("non-negative");
449 let ceil_start = v - ceil_n * 4;
450 ranges.push(ColorRange {
451 z_start: ceil_z_start,
452 z_end: ceil_z_end,
453 colours: &slab[ceil_start..v],
454 });
455 }
456 ranges
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use roxlap_formats::vxl::Vxl;
463
464 /// Build a tiny `Vxl` (4×4 columns) where every column is a
465 /// single-slab "one floor voxel at z=100" with colour
466 /// `0xAARRGGBB = 0x80ff_8000` (red-orange). Used as the
467 /// canonical fixture for both CPU and GPU round-trip tests.
468 pub(crate) fn fixture_one_voxel_per_column() -> Vxl {
469 let vsid: u32 = 4;
470 let n_cols = (vsid as usize) * (vsid as usize);
471 let mut data = Vec::with_capacity(n_cols * 8);
472 let mut column_offset = Vec::with_capacity(n_cols + 1);
473 // 0x80ff_8000 little-endian bytes = [0x00, 0x80, 0xff, 0x80]
474 // = [B=0x00, G=0x80, R=0xff, A=0x80 (neutral brightness)].
475 // Alpha=0x80 keeps the fixture non-empty under the
476 // alpha-zero placeholder filter in `decompress_column`.
477 let bgra = [0x00, 0x80, 0xff, 0x80];
478 for _ in 0..n_cols {
479 column_offset.push(u32::try_from(data.len()).expect("offset fits"));
480 data.extend_from_slice(&[0, 100, 100, 0]); // nextptr=0, z1=100, z1c=100, z0=0
481 data.extend_from_slice(&bgra);
482 }
483 column_offset.push(u32::try_from(data.len()).expect("offset fits"));
484
485 Vxl {
486 vsid,
487 ipo: [0.0; 3],
488 ist: [1.0, 0.0, 0.0],
489 ihe: [0.0, 0.0, 1.0],
490 ifo: [0.0, 1.0, 0.0],
491 data: data.into_boxed_slice(),
492 column_offset: column_offset.into_boxed_slice(),
493 mip_base_offsets: Box::new([0, n_cols + 1]),
494 vbit: Box::new([]),
495 vbiti: 0,
496 }
497 }
498
499 #[test]
500 fn fixture_textured_voxel_carries_slab_colour() {
501 let vxl = fixture_one_voxel_per_column();
502 let chunk = decompress_chunk(&vxl);
503 assert_eq!(chunk.voxel_at(1, 2, 100), Some(0x80ff_8000));
504 }
505
506 #[test]
507 fn fixture_air_above_textured_is_empty() {
508 let vxl = fixture_one_voxel_per_column();
509 let chunk = decompress_chunk(&vxl);
510 for z in 0..100 {
511 assert_eq!(chunk.voxel_at(1, 2, z), None, "z={z} expected air");
512 }
513 }
514
515 #[test]
516 fn fixture_below_textured_is_air_after_bedrock_strip() {
517 // Bedrock-as-air refactor (GPU.4 prereq): z>z1c is no
518 // longer reported as solid by the GPU decompressor.
519 let vxl = fixture_one_voxel_per_column();
520 let chunk = decompress_chunk(&vxl);
521 for z in 101..CHUNK_Z {
522 assert_eq!(
523 chunk.voxel_at(1, 2, z),
524 None,
525 "z={z} expected air (bedrock stripped)"
526 );
527 }
528 }
529
530 #[test]
531 fn only_textured_voxels_are_marked_solid() {
532 let vxl = fixture_one_voxel_per_column();
533 let chunk = decompress_chunk(&vxl);
534 // 1 textured voxel per column.
535 let solid: u32 = chunk.occupancy.iter().map(|w| w.count_ones()).sum();
536 let expected = chunk.vsid * chunk.vsid;
537 assert_eq!(solid, expected);
538 }
539
540 // ---- GPU.11 mip-ladder tests ------------------------------------
541
542 #[test]
543 fn gpu_mip_count_matches_generate_mips() {
544 // vsid=128 chunk supports the full GPU_MAX_MIPS=6.
545 assert_eq!(gpu_mip_count(128), 6);
546 // Small vsid caps the ladder where halving hits 1.
547 assert_eq!(gpu_mip_count(4), 3); // 4 -> 2 -> 1
548 assert_eq!(gpu_mip_count(2), 2); // 2 -> 1
549 assert_eq!(gpu_mip_count(1), 1);
550 }
551
552 #[test]
553 fn occ_words_per_column_halves_with_z() {
554 assert_eq!(occ_words_per_column_for_mip(0), 8); // 256/32
555 assert_eq!(occ_words_per_column_for_mip(1), 4); // 128/32
556 assert_eq!(occ_words_per_column_for_mip(2), 2); // 64/32
557 assert_eq!(occ_words_per_column_for_mip(3), 1); // 32/32
558 assert_eq!(occ_words_per_column_for_mip(4), 1); // 16 -> min 1
559 assert_eq!(occ_words_per_column_for_mip(5), 1); // 8 -> min 1
560 }
561
562 #[test]
563 fn mip0_mirrors_legacy_top_level_fields() {
564 let vxl = fixture_one_voxel_per_column();
565 let chunk = decompress_chunk(&vxl);
566 assert!(chunk.mips.len() >= 2, "fixture should build a mip ladder");
567 let m0 = &chunk.mips[0];
568 assert_eq!(m0.vsid, 4);
569 assert_eq!(m0.cz, CHUNK_Z);
570 assert_eq!(m0.occ_words_per_col, OCC_WORDS_PER_COLUMN);
571 assert_eq!(m0.occupancy, chunk.occupancy, "mip-0 occupancy == legacy");
572 assert_eq!(m0.colors, chunk.colors, "mip-0 colours == legacy");
573 assert_eq!(
574 m0.color_offsets, chunk.color_offsets,
575 "mip-0 offsets == legacy"
576 );
577 assert_eq!(m0.color_offsets[0], 0, "mip-0 starts at colour 0");
578 }
579
580 #[test]
581 fn each_mip_popcount_equals_color_count() {
582 // The 1:1 occupancy-bit ↔ colour invariant must hold at every
583 // mip level (the shader's rank-count colour lookup relies on
584 // it). The fixture's clone+generate_mips path exercises the
585 // coarse levels.
586 let vxl = fixture_one_voxel_per_column();
587 let chunk = decompress_chunk(&vxl);
588 assert_eq!(chunk.mips.len() as u32, gpu_mip_count(4));
589 for (m, mip) in chunk.mips.iter().enumerate() {
590 let solid: u32 = mip.occupancy.iter().map(|w| w.count_ones()).sum();
591 let in_mip = mip.colors.len() as u32;
592 assert_eq!(
593 solid, in_mip,
594 "mip {m}: {solid} solid bits but {in_mip} colours",
595 );
596 assert_eq!(mip.vsid, 4 >> m as u32);
597 assert_eq!(mip.cz, CHUNK_Z >> m as u32);
598 assert_eq!(
599 mip.occ_words_per_col,
600 occ_words_per_column_for_mip(m as u32)
601 );
602 assert_eq!(
603 mip.occupancy.len() as u32,
604 mip.vsid * mip.vsid * mip.occ_words_per_col,
605 );
606 assert_eq!(mip.color_offsets.len() as u32, mip.vsid * mip.vsid + 1);
607 }
608 }
609
610 #[test]
611 fn solid_occupancy_fills_bedrock_below_surface() {
612 // The cliff-face fix: every mip's `solid_occupancy` is solid
613 // from the textured surface down through the bedrock interior,
614 // while `occupancy` (textured) marks only the surface.
615 let vxl = fixture_one_voxel_per_column(); // textured at z=100
616 let chunk = decompress_chunk(&vxl);
617 let m0 = &chunk.mips[0];
618 let base = 0usize; // column (0,0)
619 let bit = |buf: &[u32], z: u32| (buf[base + (z / 32) as usize] >> (z & 31)) & 1 == 1;
620
621 assert!(bit(&m0.occupancy, 100), "surface textured");
622 assert!(bit(&m0.solid_occupancy, 100), "surface solid");
623 assert!(!bit(&m0.occupancy, 150), "bedrock is not textured");
624 assert!(
625 bit(&m0.solid_occupancy, 150),
626 "bedrock below surface is solid"
627 );
628 assert!(bit(&m0.solid_occupancy, 255), "bedrock fills to the bottom");
629 assert!(
630 !bit(&m0.solid_occupancy, 50),
631 "air above the surface stays air"
632 );
633 // The textured popcount still equals the colour count.
634 let solid: u32 = m0.solid_occupancy.iter().map(|w| w.count_ones()).sum();
635 let tex: u32 = m0.occupancy.iter().map(|w| w.count_ones()).sum();
636 assert!(solid > tex, "solid (surface + bedrock) exceeds textured");
637 }
638
639 #[test]
640 fn color_offsets_are_absolute_and_monotonic_across_mips() {
641 let vxl = fixture_one_voxel_per_column();
642 let chunk = decompress_chunk(&vxl);
643 let mut prev_end = 0u32;
644 for (m, mip) in chunk.mips.iter().enumerate() {
645 // Within a mip, offsets are non-decreasing.
646 for w in mip.color_offsets.windows(2) {
647 assert!(w[0] <= w[1], "mip {m} offsets not monotonic");
648 }
649 // First offset continues where the previous mip's colours
650 // ended (cumulative within the chunk's whole colour block).
651 assert_eq!(
652 mip.color_offsets[0], prev_end,
653 "mip {m} colour base not contiguous",
654 );
655 // Trailing sentinel == base + this mip's colour count.
656 assert_eq!(
657 *mip.color_offsets.last().unwrap(),
658 prev_end + mip.colors.len() as u32,
659 );
660 prev_end = *mip.color_offsets.last().unwrap();
661 }
662 }
663
664 #[test]
665 fn color_offsets_partition_colours_correctly() {
666 let vxl = fixture_one_voxel_per_column();
667 let chunk = decompress_chunk(&vxl);
668 let n_cols = (chunk.vsid * chunk.vsid) as usize;
669 assert_eq!(chunk.color_offsets.len(), n_cols + 1);
670 assert_eq!(chunk.color_offsets[0], 0);
671 // Bedrock is stripped — only the 1 textured voxel/column
672 // ends up in colours.
673 let per_col = 1;
674 for i in 0..=n_cols {
675 assert_eq!(
676 chunk.color_offsets[i],
677 u32::try_from(i).expect("test fixture small") * per_col,
678 );
679 }
680 assert_eq!(
681 *chunk.color_offsets.last().unwrap() as usize,
682 chunk.colors.len()
683 );
684 }
685}