Skip to main content

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}