Skip to main content

roxlap_gpu/
sprite_model.rs

1//! GPU.10 — KV6 sprite as a DDA-marchable voxel model.
2//!
3//! Unlike the GPU.9 splatter (one thread per voxel, screen-space
4//! squares, overdraw + atomic contention), a sprite model is a small
5//! voxel volume the precise ray-DDA marches one ray per pixel —
6//! crisp, correct occlusion, no overdraw. This is the GPU.10.0 single
7//! sprite; instancing + tiling + LOD come in later sub-substages.
8//!
9//! The volume reuses the chunk occupancy/colour scheme but sized to
10//! the KV6 bbox: per-column occupancy bitmask (`occ_words_per_col`
11//! u32s, `CHUNK_Z`-style 32-bits-per-word), a flat colour array in
12//! ascending-z order per column, and a `color_offsets` prefix table.
13//! The shader finds a voxel's colour by `offset[col] + popcount(bits
14//! below z)`, so colours MUST be ascending-z (we sort per column).
15
16#![allow(
17    clippy::cast_precision_loss,
18    clippy::cast_possible_truncation,
19    clippy::cast_possible_wrap,
20    clippy::cast_sign_loss,
21    clippy::many_single_char_names,
22    clippy::similar_names
23)]
24
25use bytemuck::{Pod, Zeroable};
26use roxlap_formats::kv6::Kv6;
27use roxlap_formats::sprite::Sprite;
28
29/// CPU-built voxel volume for one KV6 model.
30#[derive(Debug, Clone)]
31pub struct SpriteModel {
32    /// Voxel extent `(mx, my, mz)`.
33    pub dims: [u32; 3],
34    /// `ceil(mz / 32)` — u32 words of occupancy per (x, y) column.
35    pub occ_words_per_col: u32,
36    /// KV6 pivot in model-local voxel space.
37    pub pivot: [f32; 3],
38    /// Per-column occupancy bitmask, `mx * my * occ_words_per_col`.
39    pub occupancy: Vec<u32>,
40    /// Voxel colours, ascending z within each column.
41    pub colors: Vec<u32>,
42    /// Per-voxel surface-normal index (`Kv6::Voxel::dir`, 0..256),
43    /// parallel to [`colors`](Self::colors). The GPU sprite shader uses
44    /// it to index the per-instance `kv6colmul` lighting table, matching
45    /// the CPU rasteriser's normal-based shading.
46    pub dirs: Vec<u32>,
47    /// Prefix sums: `color_offsets[col]` is the first colour index of
48    /// column `col`; length `mx * my + 1`.
49    pub color_offsets: Vec<u32>,
50    /// World-space size of one voxel of this model (GPU.10.4 LOD): 1.0
51    /// at mip-0, doubling each [`SpriteModel::downsample`]. The shader
52    /// divides the local ray by this so a coarse voxel spans the right
53    /// world extent and the march `t` stays in world units.
54    pub voxel_world_size: f32,
55}
56
57/// Build the DDA volume from a KV6. Columns are packed in
58/// `x + y*mx` order; each column's voxels are sorted ascending by z
59/// so the shader's popcount-rank colour lookup is correct.
60///
61/// # Panics
62/// If the KV6's `ylen` counters disagree with `voxels.len()` (a
63/// malformed model).
64#[must_use]
65pub fn build_sprite_model(kv6: &Kv6) -> SpriteModel {
66    let (mx, my, mz) = (kv6.xsiz, kv6.ysiz, kv6.zsiz);
67    let occ_words_per_col = mz.div_ceil(32).max(1);
68    let cols = (mx * my) as usize;
69
70    let mut occupancy = vec![0u32; cols * occ_words_per_col as usize];
71    let mut color_offsets = vec![0u32; cols + 1];
72    let mut colors: Vec<u32> = Vec::with_capacity(kv6.voxels.len());
73    let mut dirs: Vec<u32> = Vec::with_capacity(kv6.voxels.len());
74
75    // Pass 1 — consume voxels in KV6 storage order (x-outer / y-inner)
76    // into per-column buckets keyed by `col = x + y*mx`. Each entry is
77    // `(z, colour, normal-dir)`.
78    let mut buckets: Vec<Vec<(u16, u32, u8)>> = vec![Vec::new(); cols];
79    let mut voxel_iter = kv6.voxels.iter();
80    for x in 0..mx {
81        for y in 0..my {
82            let col = (x + y * mx) as usize;
83            let count = kv6.ylen[x as usize][y as usize];
84            for _ in 0..count {
85                let v = voxel_iter.next().expect("KV6 ylen / voxels.len mismatch");
86                buckets[col].push((v.z, v.col, v.dir));
87            }
88        }
89    }
90
91    // Pass 2 — emit in COLUMN-INDEX order so `color_offsets` is a true
92    // monotonic prefix sum (the shader indexes by `col` either way, but
93    // structural edits / mip rebuilds rely on monotonic offsets). Each
94    // column's voxels sorted ascending z for the popcount-rank lookup.
95    for (col, bucket) in buckets.iter_mut().enumerate() {
96        color_offsets[col] = colors.len() as u32;
97        bucket.sort_by_key(|(z, _, _)| *z);
98        for &(z, col_rgba, dir) in bucket.iter() {
99            let z = u32::from(z);
100            let base = col * occ_words_per_col as usize + (z >> 5) as usize;
101            occupancy[base] |= 1u32 << (z & 31);
102            colors.push(col_rgba);
103            dirs.push(u32::from(dir));
104        }
105    }
106    color_offsets[cols] = colors.len() as u32;
107
108    SpriteModel {
109        dims: [mx, my, mz],
110        occ_words_per_col,
111        pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
112        occupancy,
113        color_offsets,
114        colors,
115        dirs,
116        voxel_world_size: 1.0,
117    }
118}
119
120/// Per-instance transform consumed by the model-DDA shader: the
121/// inverse model→world rotation (so a world ray can be brought into
122/// model-local space) plus the instance's world position. Stored as
123/// three padded columns for std140/std430 (`mat3x3` 16-byte columns).
124#[repr(C)]
125#[derive(Clone, Copy, Pod, Zeroable, Debug)]
126pub struct SpriteInstanceTransform {
127    /// Inverse of `[s | h | f]`, column-major, each column padded to
128    /// `vec4`. `inv_rot * v = c0*v.x + c1*v.y + c2*v.z`.
129    pub inv_rot: [[f32; 4]; 3],
130    /// Instance world position (the KV6 pivot maps here).
131    pub pos: [f32; 3],
132    _pad: f32,
133}
134
135impl SpriteInstanceTransform {
136    /// Build from a sprite pose. `s/h/f` are the model→world basis
137    /// columns; we invert them so the shader can map world→local.
138    #[must_use]
139    pub fn from_sprite(sprite: &Sprite) -> Self {
140        let inv = mat3_inverse([sprite.s, sprite.h, sprite.f]);
141        Self {
142            inv_rot: [
143                [inv[0][0], inv[0][1], inv[0][2], 0.0],
144                [inv[1][0], inv[1][1], inv[1][2], 0.0],
145                [inv[2][0], inv[2][1], inv[2][2], 0.0],
146            ],
147            pos: sprite.p,
148            _pad: 0.0,
149        }
150    }
151}
152
153/// A registry of sprite models. Instances reference a model by
154/// `model_id`, which is a **LOD chain** id: each chain holds one or
155/// more concrete mip levels (finest first; GPU.10.4), and the renderer
156/// picks the level per instance by distance. Identical KV6s are added
157/// once and shared by many instances. **Copy-on-modify**:
158/// [`Self::fork`] deep-copies a chain so edits to the fork leave the
159/// parent (and its instances) intact.
160#[derive(Debug, Clone, Default)]
161pub struct SpriteModelRegistry {
162    /// Concrete mip-level volumes (the GPU buffers concatenate these).
163    entries: Vec<SpriteModel>,
164    /// `chains[model_id]` = entry ids, finest (mip-0) first.
165    chains: Vec<Vec<u32>>,
166}
167
168impl SpriteModelRegistry {
169    #[must_use]
170    pub fn new() -> Self {
171        Self::default()
172    }
173
174    fn push_entry(&mut self, model: SpriteModel) -> u32 {
175        let id = self.entries.len() as u32;
176        self.entries.push(model);
177        id
178    }
179
180    /// Register a single-level (no-LOD) model; returns its `model_id`.
181    pub fn add(&mut self, model: SpriteModel) -> u32 {
182        let e = self.push_entry(model);
183        let id = self.chains.len() as u32;
184        self.chains.push(vec![e]);
185        id
186    }
187
188    /// Register a model with up to `max_levels` LOD mips (each a 2×
189    /// [`SpriteModel::downsample`] of the previous; stops early once a
190    /// level collapses to 1³). Returns its `model_id`.
191    pub fn add_lod(&mut self, model: SpriteModel, max_levels: u32) -> u32 {
192        let mut levels = vec![self.push_entry(model.clone())];
193        let mut cur = model;
194        for _ in 1..max_levels.max(1) {
195            if cur.dims == [1, 1, 1] {
196                break;
197            }
198            cur = cur.downsample();
199            levels.push(self.push_entry(cur.clone()));
200        }
201        let id = self.chains.len() as u32;
202        self.chains.push(levels);
203        id
204    }
205
206    /// Copy-on-modify: deep-copy every level of chain `parent` into new
207    /// entries + a new chain, and return its `model_id`. The fork owns
208    /// independent voxel data, so mutating it does not affect the
209    /// parent or any instance still pointing at it.
210    ///
211    /// # Panics
212    /// If `parent` is not a registered `model_id`.
213    pub fn fork(&mut self, parent: u32) -> u32 {
214        let src = self.chains[parent as usize].clone();
215        let levels: Vec<u32> = src
216            .iter()
217            .map(|&e| {
218                let copy = self.entries[e as usize].clone();
219                self.push_entry(copy)
220            })
221            .collect();
222        let id = self.chains.len() as u32;
223        self.chains.push(levels);
224        id
225    }
226
227    /// The finest (mip-0) model of chain `id`.
228    #[must_use]
229    pub fn model(&self, id: u32) -> &SpriteModel {
230        &self.entries[self.chains[id as usize][0] as usize]
231    }
232
233    /// Mutable access to the finest (mip-0) model for editing — the
234    /// copy-on-modify entry point (typically on a [`Self::fork`]).
235    /// After a *structural* edit (occupancy/dims), call
236    /// [`Self::rebuild_lod`] so the coarser mips match; a pure recolour
237    /// can use [`Self::recolor_chain`] instead.
238    pub fn model_mut(&mut self, id: u32) -> &mut SpriteModel {
239        let e = self.chains[id as usize][0] as usize;
240        &mut self.entries[e]
241    }
242
243    /// Recolour every LOD level of chain `id` (so a forked tint shows
244    /// at all distances).
245    pub fn recolor_chain(&mut self, id: u32, f: impl Fn(u32) -> u32 + Copy) {
246        for li in 0..self.chains[id as usize].len() {
247            let e = self.chains[id as usize][li] as usize;
248            self.entries[e].recolor(f);
249        }
250    }
251
252    /// Regenerate chain `id`'s coarser mip levels from its (possibly
253    /// just-edited) mip-0. Run after a structural edit via
254    /// [`Self::model_mut`] so the LOD ladder stays consistent. No-op
255    /// for a single-level (no-LOD) chain.
256    pub fn rebuild_lod(&mut self, id: u32) {
257        let levels = self.chains[id as usize].clone();
258        if levels.len() <= 1 {
259            return;
260        }
261        let mut cur = self.entries[levels[0] as usize].clone();
262        for &e in &levels[1..] {
263            cur = cur.downsample();
264            self.entries[e as usize] = cur.clone();
265        }
266    }
267
268    /// Number of LOD chains (distinct `model_id`s).
269    #[must_use]
270    pub fn len(&self) -> usize {
271        self.chains.len()
272    }
273
274    #[must_use]
275    pub fn is_empty(&self) -> bool {
276        self.chains.is_empty()
277    }
278}
279
280impl SpriteModel {
281    /// Recolour every voxel via `f(old_rgba) -> new_rgba`. Structure
282    /// (occupancy / offsets) is untouched, so this is a cheap in-place
283    /// edit — handy on a [`SpriteModelRegistry::fork`] to make a tinted
284    /// variant. For structural edits, mutate the public occupancy /
285    /// colours / dims directly (via `model_mut`) then rebuild the LOD.
286    pub fn recolor(&mut self, f: impl Fn(u32) -> u32) {
287        for c in &mut self.colors {
288            *c = f(*c);
289        }
290    }
291
292    /// GPU.12 — structural edit of a single voxel within the model's
293    /// existing bounds. `Some(rgba)` sets/replaces the voxel at
294    /// `(x, y, z)`; `None` clears it. Maintains the ascending-z colour
295    /// invariant by inserting/removing at the voxel's popcount rank and
296    /// shifting the affected columns' `color_offsets`. Returns `true`
297    /// if the model changed. Out-of-bounds coordinates are ignored
298    /// (returns `false`) — growing `dims` is a separate concern.
299    ///
300    /// After editing, call [`SpriteModelRegistry::rebuild_lod`] to
301    /// refresh coarser mips, then re-upload via `set_sprite_instances`.
302    pub fn set_voxel(&mut self, x: u32, y: u32, z: u32, color: Option<u32>) -> bool {
303        if x >= self.dims[0] || y >= self.dims[1] || z >= self.dims[2] {
304            return false;
305        }
306        let owpc = self.occ_words_per_col as usize;
307        let cols = (self.dims[0] * self.dims[1]) as usize;
308        let col = (x + y * self.dims[0]) as usize;
309        let base = col * owpc;
310        let zw = (z >> 5) as usize;
311        let zb = z & 31;
312
313        // Rank = solid voxels strictly below z in this column.
314        let mut rank = 0usize;
315        for w in 0..zw {
316            rank += self.occupancy[base + w].count_ones() as usize;
317        }
318        let below_mask = if zb > 0 { (1u32 << zb) - 1 } else { 0 };
319        rank += (self.occupancy[base + zw] & below_mask).count_ones() as usize;
320        let idx = self.color_offsets[col] as usize + rank;
321        let was_set = (self.occupancy[base + zw] >> zb) & 1 == 1;
322
323        if let Some(rgba) = color {
324            if was_set {
325                self.colors[idx] = rgba; // replace in place (keeps dir)
326            } else {
327                self.occupancy[base + zw] |= 1u32 << zb;
328                self.colors.insert(idx, rgba);
329                // No normal supplied by this API — default to dir 0 (the
330                // sole caller, the carve hotkey, only ever clears).
331                self.dirs.insert(idx, 0);
332                for c in &mut self.color_offsets[col + 1..=cols] {
333                    *c += 1;
334                }
335            }
336            true
337        } else {
338            if !was_set {
339                return false;
340            }
341            self.occupancy[base + zw] &= !(1u32 << zb);
342            self.colors.remove(idx);
343            self.dirs.remove(idx);
344            for c in &mut self.color_offsets[col + 1..=cols] {
345                *c -= 1;
346            }
347            true
348        }
349    }
350
351    /// Radius of a bounding sphere centred at the instance position
352    /// (the pivot maps there): the farthest bbox corner from the
353    /// pivot. Used for frustum culling. Assumes a unit basis; scaled
354    /// instances would multiply this by their max basis length.
355    #[must_use]
356    pub fn bound_radius(&self) -> f32 {
357        let mut r2 = 0.0_f32;
358        for &cx in &[0.0, self.dims[0] as f32] {
359            for &cy in &[0.0, self.dims[1] as f32] {
360                for &cz in &[0.0, self.dims[2] as f32] {
361                    let d = [cx - self.pivot[0], cy - self.pivot[1], cz - self.pivot[2]];
362                    r2 = r2.max(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
363                }
364            }
365        }
366        r2.sqrt()
367    }
368
369    /// GPU.10.4 — 2× voxel downsample for the next LOD level. A coarse
370    /// voxel is solid if any of its 2×2×2 fine voxels is, coloured by
371    /// their per-channel average. Dims/pivot halve and
372    /// `voxel_world_size` doubles, so the coarse model occupies the
373    /// same world box at half the resolution (origin-corner aligned).
374    #[must_use]
375    #[allow(clippy::manual_checked_ops)] // `n > 0` guards 4 divisions, not one checked_div
376    pub fn downsample(&self) -> SpriteModel {
377        let [fx, fy, fz] = self.dims;
378        let fidx = |x: u32, y: u32, z: u32| (x + y * fx + z * fx * fy) as usize;
379
380        // Reconstruct dense fine voxels (solid flag + colour + normal).
381        let mut solid = vec![false; (fx * fy * fz) as usize];
382        let mut fine = vec![0u32; (fx * fy * fz) as usize];
383        let mut fine_dir = vec![0u32; (fx * fy * fz) as usize];
384        for x in 0..fx {
385            for y in 0..fy {
386                let col = (x + y * fx) as usize;
387                let base = col * self.occ_words_per_col as usize;
388                let off = self.color_offsets[col] as usize;
389                let mut seen = 0usize;
390                for z in 0..fz {
391                    let w = base + (z >> 5) as usize;
392                    if (self.occupancy[w] >> (z & 31)) & 1 == 1 {
393                        fine[fidx(x, y, z)] = self.colors[off + seen];
394                        fine_dir[fidx(x, y, z)] = self.dirs[off + seen];
395                        solid[fidx(x, y, z)] = true;
396                        seen += 1;
397                    }
398                }
399            }
400        }
401
402        let nx = fx.div_ceil(2).max(1);
403        let ny = fy.div_ceil(2).max(1);
404        let nz = fz.div_ceil(2).max(1);
405        let owpc = nz.div_ceil(32).max(1);
406        let cols = (nx * ny) as usize;
407        let mut occupancy = vec![0u32; cols * owpc as usize];
408        let mut color_offsets = vec![0u32; cols + 1];
409        let mut colors: Vec<u32> = Vec::new();
410        let mut dirs: Vec<u32> = Vec::new();
411
412        // Emit in column-index order (`ccol = cx + cy*nx`), cy outer,
413        // so `color_offsets` is a monotonic prefix sum like build's.
414        for cy in 0..ny {
415            for cx in 0..nx {
416                let ccol = (cx + cy * nx) as usize;
417                color_offsets[ccol] = colors.len() as u32;
418                for cz in 0..nz {
419                    let (mut a, mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32, 0u32);
420                    // Normals don't average meaningfully — keep the first
421                    // solid child's `dir` as the coarse voxel's normal.
422                    let mut rep_dir = 0u32;
423                    for dz in 0..2 {
424                        for dy in 0..2 {
425                            for dx in 0..2 {
426                                let (x, y, z) = (2 * cx + dx, 2 * cy + dy, 2 * cz + dz);
427                                if x < fx && y < fy && z < fz && solid[fidx(x, y, z)] {
428                                    let c = fine[fidx(x, y, z)];
429                                    if n == 0 {
430                                        rep_dir = fine_dir[fidx(x, y, z)];
431                                    }
432                                    a += (c >> 24) & 0xff;
433                                    r += (c >> 16) & 0xff;
434                                    g += (c >> 8) & 0xff;
435                                    b += c & 0xff;
436                                    n += 1;
437                                }
438                            }
439                        }
440                    }
441                    if n > 0 {
442                        let avg = ((a / n) << 24) | ((r / n) << 16) | ((g / n) << 8) | (b / n);
443                        let base = ccol * owpc as usize + (cz >> 5) as usize;
444                        occupancy[base] |= 1u32 << (cz & 31);
445                        colors.push(avg);
446                        dirs.push(rep_dir);
447                    }
448                }
449            }
450        }
451        color_offsets[cols] = colors.len() as u32;
452
453        SpriteModel {
454            dims: [nx, ny, nz],
455            occ_words_per_col: owpc,
456            pivot: [
457                self.pivot[0] * 0.5,
458                self.pivot[1] * 0.5,
459                self.pivot[2] * 0.5,
460            ],
461            occupancy,
462            colors,
463            dirs,
464            color_offsets,
465            voxel_world_size: self.voxel_world_size * 2.0,
466        }
467    }
468}
469
470/// View frustum for CPU instance culling, in world space. Built each
471/// frame from the world camera. `half_w`/`half_h` are the tangents of
472/// the half-FOV (so the side planes are `|x| <= half_w * z` etc. in
473/// camera space).
474#[derive(Clone, Copy, Debug)]
475pub struct ViewFrustum {
476    pub pos: [f32; 3],
477    pub right: [f32; 3],
478    pub down: [f32; 3],
479    pub forward: [f32; 3],
480    pub half_w: f32,
481    pub half_h: f32,
482    pub far: f32,
483}
484
485/// CPU cull record: the GPU instance + its world bounding sphere.
486/// Not `Copy` — carries a boxed 256-entry `kv6colmul` table.
487#[derive(Clone)]
488struct CullInstance {
489    /// Instance transform + a placeholder `model_id`; the cull
490    /// overwrites `model_id` with the distance-chosen LOD entry.
491    gpu: SpriteInstanceGpu,
492    /// LOD chain this instance draws (the user-facing `model_id`).
493    chain_id: u32,
494    center: [f32; 3],
495    radius: f32,
496    /// voxlap `kv6colmul[256]` — per-surface-normal colour modulation
497    /// for this instance's pose + lighting. Defaults to identity
498    /// (`0x0100` in every channel lane → unshaded) until the facade sets
499    /// it via [`SpriteRegistryResident::set_instance_colmul`]. Packed
500    /// into the `colmul` GPU buffer (in visible order) each frame.
501    colmul: Box<[u64; 256]>,
502}
503
504/// Identity `kv6colmul` table: every channel lane = `0x0100`, so the
505/// shader's `(rgb[c] << 8) * 0x0100 >> 16 == rgb[c]` — i.e. no shading.
506fn identity_colmul() -> Box<[u64; 256]> {
507    const LANE: u64 = 0x0100;
508    let w = LANE | (LANE << 16) | (LANE << 32) | (LANE << 48);
509    Box::new([w; 256])
510}
511
512fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
513    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
514}
515
516/// One sprite instance: a model reference + world pose.
517#[derive(Debug, Clone, Copy)]
518pub struct SpriteInstance {
519    pub model_id: u32,
520    pub transform: SpriteInstanceTransform,
521}
522
523/// GPU per-model metadata: where this model's data starts in the
524/// shared registry buffers + its dims/pivot. Mirrors `ModelMeta` in
525/// the shader (std430, 48 bytes).
526#[repr(C)]
527#[derive(Clone, Copy, Pod, Zeroable, Debug)]
528struct SpriteModelMeta {
529    occupancy_offset: u32,
530    colors_offset: u32,
531    color_offsets_offset: u32,
532    occ_words_per_col: u32,
533    dims: [u32; 3],
534    _pad0: u32,
535    pivot: [f32; 3],
536    /// GPU.10.4 — world size of one voxel of this (mip) entry.
537    voxel_world_size: f32,
538}
539
540/// GPU per-instance record. Mirrors `Instance` in the shader (std430,
541/// 64 bytes): inverse rotation columns + position + model id.
542#[repr(C)]
543#[derive(Clone, Copy, Pod, Zeroable, Debug)]
544struct SpriteInstanceGpu {
545    inv_rot0: [f32; 4],
546    inv_rot1: [f32; 4],
547    inv_rot2: [f32; 4],
548    pos: [f32; 3],
549    model_id: u32,
550}
551
552/// Invert a 3×3 matrix given as basis columns `[c0, c1, c2]`,
553/// returning the inverse as columns. For an orthonormal basis this is
554/// the transpose; the general path covers rotation + non-unit scale.
555#[must_use]
556fn mat3_inverse(cols: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
557    let [a, b, c] = cols; // columns
558                          // Determinant via scalar triple product a · (b × c).
559    let cross = |u: [f32; 3], v: [f32; 3]| {
560        [
561            u[1] * v[2] - u[2] * v[1],
562            u[2] * v[0] - u[0] * v[2],
563            u[0] * v[1] - u[1] * v[0],
564        ]
565    };
566    let bc = cross(b, c);
567    let ca = cross(c, a);
568    let ab = cross(a, b);
569    let det = a[0] * bc[0] + a[1] * bc[1] + a[2] * bc[2];
570    let inv_det = if det.abs() < 1e-12 { 0.0 } else { 1.0 / det };
571    // Inverse rows are (b×c, c×a, a×b)/det; return as columns of the
572    // inverse, i.e. transpose of those rows.
573    [
574        [bc[0] * inv_det, ca[0] * inv_det, ab[0] * inv_det],
575        [bc[1] * inv_det, ca[1] * inv_det, ab[1] * inv_det],
576        [bc[2] * inv_det, ca[2] * inv_det, ab[2] * inv_det],
577    ]
578}
579
580/// GPU-resident registry + instances: every model's occupancy /
581/// colours / offsets concatenated into shared storage buffers, a
582/// per-model metadata table, and a capacity-sized instance buffer
583/// rewritten each frame with the frustum-visible subset (GPU.10.2).
584/// One bind group serves all models (same approach as the multi-grid
585/// scene).
586pub struct SpriteRegistryResident {
587    pub occupancy: wgpu::Buffer,
588    pub colors: wgpu::Buffer,
589    /// Per-voxel surface-normal index, concatenated across models in the
590    /// same layout as [`colors`](Self::colors). The shader indexes the
591    /// per-instance `kv6colmul` table by it.
592    pub dirs: wgpu::Buffer,
593    pub color_offsets: wgpu::Buffer,
594    pub model_meta: wgpu::Buffer,
595    /// Holds up to `instance_capacity` instances; the visible subset
596    /// is packed into `[0, count)` each frame by [`Self::cull_bin_upload`].
597    pub instances: wgpu::Buffer,
598    pub instance_capacity: u32,
599    /// Per-visible-instance `kv6colmul[256]` tables, packed in the same
600    /// order as the `instances` buffer each frame (two u32 per u64
601    /// entry: lanes 0|1 then 2|3). Sized `instance_capacity * 256 * 2`
602    /// u32; rewritten by [`Self::cull_bin_upload`].
603    pub colmul: wgpu::Buffer,
604    colmul_cap: u32,
605    /// GPU.10.3 — per-tile `(offset, count)` into `tile_instances`,
606    /// flat `2 * tiles_x * tiles_y` u32s. Grown to fit the screen.
607    pub tile_ranges: wgpu::Buffer,
608    tile_ranges_cap: u32,
609    /// GPU.10.3 — flat list of visible-instance indices grouped by
610    /// tile. Grown to fit the per-frame total.
611    pub tile_instances: wgpu::Buffer,
612    tile_instances_cap: u32,
613    /// CPU cull records (full set), with precomputed bounding spheres.
614    cull: Vec<CullInstance>,
615    /// GPU.10.4 — LOD chains: `chains[chain_id]` = entry ids, finest
616    /// first. The cull picks a level by distance and writes its entry
617    /// id into the packed instance's `model_id`.
618    chains: Vec<Vec<u32>>,
619}
620
621impl SpriteRegistryResident {
622    /// Concatenate `registry`'s models into shared buffers and prepare
623    /// `instances` for per-frame culling. Model-relative indices stay
624    /// as built; the shader adds each model's base offset from the
625    /// metadata table.
626    #[must_use]
627    pub fn upload(
628        device: &wgpu::Device,
629        registry: &SpriteModelRegistry,
630        instances: &[SpriteInstance],
631    ) -> Self {
632        let mut all_occ: Vec<u32> = Vec::new();
633        let mut all_colors: Vec<u32> = Vec::new();
634        let mut all_dirs: Vec<u32> = Vec::new();
635        let mut all_offsets: Vec<u32> = Vec::new();
636        let mut meta: Vec<SpriteModelMeta> = Vec::with_capacity(registry.entries.len());
637
638        // One meta + concatenated data per concrete (mip-level) entry.
639        // `dirs` parallels `colors` (same offsets/ranks).
640        for m in &registry.entries {
641            meta.push(SpriteModelMeta {
642                occupancy_offset: all_occ.len() as u32,
643                colors_offset: all_colors.len() as u32,
644                color_offsets_offset: all_offsets.len() as u32,
645                occ_words_per_col: m.occ_words_per_col,
646                dims: m.dims,
647                _pad0: 0,
648                pivot: m.pivot,
649                voxel_world_size: m.voxel_world_size,
650            });
651            all_occ.extend_from_slice(&m.occupancy);
652            all_colors.extend_from_slice(&m.colors);
653            all_dirs.extend_from_slice(&m.dirs);
654            all_offsets.extend_from_slice(&m.color_offsets);
655        }
656
657        // Per-instance cull records: sphere centred at the instance
658        // position, radius from the chain's finest (mip-0) model.
659        // `colmul` starts at identity (unshaded) until the facade sets
660        // per-instance lighting via `set_instance_colmul`.
661        let cull: Vec<CullInstance> = instances
662            .iter()
663            .map(|i| CullInstance {
664                gpu: SpriteInstanceGpu {
665                    inv_rot0: i.transform.inv_rot[0],
666                    inv_rot1: i.transform.inv_rot[1],
667                    inv_rot2: i.transform.inv_rot[2],
668                    pos: i.transform.pos,
669                    model_id: i.model_id, // placeholder; cull rewrites
670                },
671                chain_id: i.model_id,
672                center: i.transform.pos,
673                radius: registry.model(i.model_id).bound_radius(),
674                colmul: identity_colmul(),
675            })
676            .collect();
677
678        // Capacity buffer (COPY_DST so cull can rewrite it each frame),
679        // seeded with the full set so frame 0 is valid pre-cull.
680        let seed: Vec<SpriteInstanceGpu> = cull.iter().map(|c| c.gpu).collect();
681        let instances_buf = {
682            use wgpu::util::DeviceExt;
683            let one = [SpriteInstanceGpu::zeroed()];
684            let src: &[SpriteInstanceGpu] = if seed.is_empty() { &one } else { &seed };
685            device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
686                label: Some("roxlap-gpu sprite_reg.instances"),
687                contents: bytemuck::cast_slice(src),
688                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
689            })
690        };
691
692        let tile_ranges = storage_dst_u32(device, "roxlap-gpu sprite_reg.tile_ranges", 1);
693        let tile_instances = storage_dst_u32(device, "roxlap-gpu sprite_reg.tile_instances", 1);
694        // colmul: 256 entries × 2 u32 per visible instance. Sized to the
695        // full instance set (worst case all visible); rewritten per frame.
696        let colmul_cap = (cull.len() as u32).max(1) * 256 * 2;
697        let colmul = storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", colmul_cap);
698        Self {
699            occupancy: storage_u32(device, "roxlap-gpu sprite_reg.occupancy", &all_occ),
700            colors: storage_u32(device, "roxlap-gpu sprite_reg.colors", &all_colors),
701            dirs: storage_u32(device, "roxlap-gpu sprite_reg.dirs", &all_dirs),
702            color_offsets: storage_u32(device, "roxlap-gpu sprite_reg.color_offsets", &all_offsets),
703            model_meta: storage_pod(device, "roxlap-gpu sprite_reg.model_meta", &meta),
704            instances: instances_buf,
705            instance_capacity: cull.len() as u32,
706            colmul,
707            colmul_cap,
708            tile_ranges,
709            tile_ranges_cap: 1,
710            tile_instances,
711            tile_instances_cap: 1,
712            cull,
713            chains: registry.chains.clone(),
714        }
715    }
716
717    /// Set the per-instance `kv6colmul[256]` lighting tables (voxlap's
718    /// `update_reflects` output), in the same order/length as the
719    /// instances passed to [`Self::upload`]. The next
720    /// [`Self::cull_bin_upload`] packs the visible subset to the GPU.
721    /// Instances beyond `tables.len()` keep their previous tables.
722    pub fn set_instance_colmul(&mut self, tables: &[[u64; 256]]) {
723        for (ci, t) in self.cull.iter_mut().zip(tables) {
724            ci.colmul.copy_from_slice(t);
725        }
726    }
727
728    /// Refresh instance poses in place from `instances` — for animated
729    /// sprites (e.g. KFA limbs re-posed each frame) — **without** any
730    /// model-volume re-upload. `instances` must match the set passed to
731    /// [`Self::upload`] in length + order; each keeps its `model_id`
732    /// (LOD chain) so only the transform + cull centre change. No GPU
733    /// write happens here: the next [`Self::cull_bin_upload`] re-uploads
734    /// the packed visible subset, as it already does every frame.
735    pub fn update_transforms(&mut self, instances: &[SpriteInstance]) {
736        debug_assert_eq!(
737            instances.len(),
738            self.cull.len(),
739            "update_transforms instance count must match upload"
740        );
741        for (ci, inst) in self.cull.iter_mut().zip(instances) {
742            ci.gpu.inv_rot0 = inst.transform.inv_rot[0];
743            ci.gpu.inv_rot1 = inst.transform.inv_rot[1];
744            ci.gpu.inv_rot2 = inst.transform.inv_rot[2];
745            ci.gpu.pos = inst.transform.pos;
746            // Bounding sphere follows the pivot; radius/chain unchanged.
747            ci.center = inst.transform.pos;
748        }
749    }
750
751    /// GPU.10.3 — frustum-cull, pack the visible subset into the
752    /// instance buffer, then bin those instances into screen tiles:
753    /// project each visible bounding sphere to a screen AABB and append
754    /// its (visible) index to every overlapped tile. Uploads the
755    /// instance buffer + `tile_ranges` (per-tile offset/count) +
756    /// `tile_instances` (flat grouped indices), growing the tile
757    /// buffers as needed. Returns `(visible_count, tiles_x, tiles_y)`.
758    #[allow(clippy::too_many_arguments)]
759    pub fn cull_bin_upload(
760        &mut self,
761        device: &wgpu::Device,
762        queue: &wgpu::Queue,
763        f: &ViewFrustum,
764        screen_w: u32,
765        screen_h: u32,
766        tile_size: u32,
767        lod_px: f32,
768    ) -> (u32, u32, u32) {
769        let tiles_x = screen_w.div_ceil(tile_size).max(1);
770        let tiles_y = screen_h.div_ceil(tile_size).max(1);
771        let n_tiles = (tiles_x * tiles_y) as usize;
772
773        let nw = (1.0 + f.half_w * f.half_w).sqrt();
774        let nh = (1.0 + f.half_h * f.half_h).sqrt();
775        let cx = screen_w as f32 * 0.5;
776        let cy = screen_h as f32 * 0.5;
777        let px_per_world = cx / f.half_w; // isotropic: == cy/half_h
778        let ts = tile_size as f32;
779        let tx_max = tiles_x as i32 - 1;
780        let ty_max = tiles_y as i32 - 1;
781
782        let mut visible: Vec<SpriteInstanceGpu> = Vec::with_capacity(self.cull.len());
783        // Per-visible tile AABB (tx0, tx1, ty0, ty1) for the bin pass.
784        let mut boxes: Vec<[i32; 4]> = Vec::with_capacity(self.cull.len());
785        // Per-visible kv6colmul tables, flattened to two u32 per u64
786        // entry (lanes 0|1, then 2|3), packed in visible order so the
787        // shader indexes `colmul[inst_idx*512 + dir*2 + {0,1}]`.
788        let mut visible_colmul: Vec<u32> = Vec::with_capacity(self.cull.len() * 512);
789        let mut counts = vec![0u32; n_tiles];
790
791        for ci in &self.cull {
792            let rel = [
793                ci.center[0] - f.pos[0],
794                ci.center[1] - f.pos[1],
795                ci.center[2] - f.pos[2],
796            ];
797            let z = dot3(rel, f.forward);
798            let r = ci.radius;
799            if z + r < 0.0 || z - r > f.far {
800                continue; // behind / beyond far
801            }
802            let x = dot3(rel, f.right);
803            if (x - f.half_w * z) > r * nw || (-x - f.half_w * z) > r * nw {
804                continue; // right / left
805            }
806            let y = dot3(rel, f.down);
807            if (y - f.half_h * z) > r * nh || (-y - f.half_h * z) > r * nh {
808                continue; // bottom / top
809            }
810
811            // Visible: project the sphere to a screen AABB → tile range.
812            let (tx0, tx1, ty0, ty1) = if z > 1e-3 {
813                let sx = cx + (x / z) * px_per_world;
814                let sy = cy + (y / z) * px_per_world;
815                let sr = (r / z) * px_per_world;
816                (
817                    (((sx - sr) / ts).floor() as i32).clamp(0, tx_max),
818                    (((sx + sr) / ts).floor() as i32).clamp(0, tx_max),
819                    (((sy - sr) / ts).floor() as i32).clamp(0, ty_max),
820                    (((sy + sr) / ts).floor() as i32).clamp(0, ty_max),
821                )
822            } else {
823                // Sphere crosses the camera plane — cover all tiles.
824                (0, tx_max, 0, ty_max)
825            };
826            // GPU.10.4 — pick the LOD level by projected voxel size:
827            // choose the coarsest level whose voxel still covers at
828            // least `lod_px` screen pixels, i.e. step up once a mip-0
829            // voxel would be smaller than that. `lod_px = 1` is the
830            // natural "don't go sub-pixel" threshold; larger values
831            // force LOD in closer (tuning/inspection).
832            let chain = &self.chains[ci.chain_id as usize];
833            let level = if z > 1e-3 && chain.len() > 1 {
834                let voxel_px = px_per_world / z; // mip-0 voxel screen size
835                ((lod_px / voxel_px).log2().ceil().max(0.0) as usize).min(chain.len() - 1)
836            } else {
837                0
838            };
839            let mut g = ci.gpu;
840            g.model_id = chain[level];
841            visible.push(g);
842            boxes.push([tx0, tx1, ty0, ty1]);
843            for &w in ci.colmul.iter() {
844                visible_colmul.push((w & 0xffff_ffff) as u32);
845                visible_colmul.push((w >> 32) as u32);
846            }
847            for ty in ty0..=ty1 {
848                for tx in tx0..=tx1 {
849                    counts[(ty * tiles_x as i32 + tx) as usize] += 1;
850                }
851            }
852        }
853
854        if visible.is_empty() {
855            return (0, tiles_x, tiles_y);
856        }
857
858        // Prefix-sum counts → per-tile offsets; build the flat grouped
859        // index list.
860        let mut tile_ranges = vec![0u32; n_tiles * 2];
861        let mut running = 0u32;
862        for t in 0..n_tiles {
863            tile_ranges[2 * t] = running; // offset
864            tile_ranges[2 * t + 1] = counts[t]; // count
865            running += counts[t];
866        }
867        let total = running as usize;
868        let mut tile_instances = vec![0u32; total.max(1)];
869        let mut cursor: Vec<u32> = (0..n_tiles).map(|t| tile_ranges[2 * t]).collect();
870        for (vis_idx, b) in boxes.iter().enumerate() {
871            for ty in b[2]..=b[3] {
872                for tx in b[0]..=b[1] {
873                    let t = (ty * tiles_x as i32 + tx) as usize;
874                    tile_instances[cursor[t] as usize] = vis_idx as u32;
875                    cursor[t] += 1;
876                }
877            }
878        }
879
880        // Upload: instances + (grown) tile buffers. Grow a tile buffer
881        // only when this frame needs more than its capacity (wgpu has
882        // no Clone on Buffer, so we replace the field in place).
883        queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(&visible));
884        let need_ranges = tile_ranges.len() as u32;
885        if need_ranges > self.tile_ranges_cap {
886            self.tile_ranges_cap = need_ranges.next_power_of_two();
887            self.tile_ranges = storage_dst_u32(
888                device,
889                "roxlap-gpu sprite_reg.tile_ranges",
890                self.tile_ranges_cap,
891            );
892        }
893        let need_inst = tile_instances.len() as u32;
894        if need_inst > self.tile_instances_cap {
895            self.tile_instances_cap = need_inst.next_power_of_two();
896            self.tile_instances = storage_dst_u32(
897                device,
898                "roxlap-gpu sprite_reg.tile_instances",
899                self.tile_instances_cap,
900            );
901        }
902        queue.write_buffer(&self.tile_ranges, 0, bytemuck::cast_slice(&tile_ranges));
903        queue.write_buffer(
904            &self.tile_instances,
905            0,
906            bytemuck::cast_slice(&tile_instances),
907        );
908        let need_colmul = visible_colmul.len() as u32;
909        if need_colmul > self.colmul_cap {
910            self.colmul_cap = need_colmul.next_power_of_two();
911            self.colmul = storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", self.colmul_cap);
912        }
913        queue.write_buffer(&self.colmul, 0, bytemuck::cast_slice(&visible_colmul));
914
915        (visible.len() as u32, tiles_x, tiles_y)
916    }
917}
918
919/// Create a STORAGE buffer of u32s; pads empty input (wgpu rejects
920/// zero-sized storage bindings).
921fn storage_u32(device: &wgpu::Device, label: &str, data: &[u32]) -> wgpu::Buffer {
922    use wgpu::util::DeviceExt;
923    let bytes: &[u8] = if data.is_empty() {
924        bytemuck::cast_slice(&[0u32])
925    } else {
926        bytemuck::cast_slice(data)
927    };
928    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
929        label: Some(label),
930        contents: bytes,
931        usage: wgpu::BufferUsages::STORAGE,
932    })
933}
934
935/// Create an uninitialised `STORAGE | COPY_DST` `u32` buffer of `cap`
936/// words (≥1). Written each frame via `queue.write_buffer`.
937fn storage_dst_u32(device: &wgpu::Device, label: &str, cap: u32) -> wgpu::Buffer {
938    device.create_buffer(&wgpu::BufferDescriptor {
939        label: Some(label),
940        size: u64::from(cap.max(1)) * 4,
941        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
942        mapped_at_creation: false,
943    })
944}
945
946/// Create a STORAGE buffer of Pod records; pads empty input with one
947/// zeroed `T`.
948fn storage_pod<T: Pod + Zeroable>(device: &wgpu::Device, label: &str, data: &[T]) -> wgpu::Buffer {
949    use wgpu::util::DeviceExt;
950    let one = [T::zeroed()];
951    let src: &[T] = if data.is_empty() { &one } else { data };
952    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
953        label: Some(label),
954        contents: bytemuck::cast_slice(src),
955        usage: wgpu::BufferUsages::STORAGE,
956    })
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962    use roxlap_formats::kv6::{Kv6, Voxel};
963
964    /// 2×1 kv6: column (0,0) has voxels at z=5 (red) and z=1 (green)
965    /// stored OUT of z-order; column (1,0) has one voxel at z=3.
966    fn kv6_unsorted() -> Kv6 {
967        let mk = |z, col| Voxel {
968            col,
969            z,
970            vis: 0,
971            dir: 0,
972        };
973        Kv6 {
974            xsiz: 2,
975            ysiz: 1,
976            zsiz: 8,
977            xpiv: 0.0,
978            ypiv: 0.0,
979            zpiv: 0.0,
980            voxels: vec![mk(5, 0xAA), mk(1, 0xBB), mk(3, 0xCC)],
981            xlen: vec![2, 1],
982            ylen: vec![vec![2], vec![1]],
983            palette: None,
984        }
985    }
986
987    #[test]
988    fn occupancy_bits_set_at_voxel_z() {
989        let m = build_sprite_model(&kv6_unsorted());
990        assert_eq!(m.dims, [2, 1, 8]);
991        assert_eq!(m.occ_words_per_col, 1); // ceil(8/32)
992                                            // col 0: bits 1 and 5; col 1: bit 3.
993        assert_eq!(m.occupancy[0], (1 << 1) | (1 << 5));
994        assert_eq!(m.occupancy[1], 1 << 3);
995    }
996
997    #[test]
998    fn colors_are_ascending_z_for_rank_lookup() {
999        let m = build_sprite_model(&kv6_unsorted());
1000        // col 0 sorted ascending z ⇒ z=1 (green 0xBB) before z=5 (0xAA).
1001        assert_eq!(m.color_offsets, vec![0, 2, 3]);
1002        assert_eq!(&m.colors, &[0xBB, 0xAA, 0xCC]);
1003    }
1004
1005    #[test]
1006    fn identity_basis_inverts_to_identity() {
1007        let inv = mat3_inverse([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
1008        assert_eq!(inv, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
1009    }
1010
1011    #[test]
1012    fn fork_is_independent_of_parent() {
1013        let mut reg = SpriteModelRegistry::new();
1014        let base = reg.add(build_sprite_model(&kv6_unsorted()));
1015        let forked = reg.fork(base);
1016        assert_ne!(base, forked);
1017        // Recolour only the fork.
1018        reg.model_mut(forked).recolor(|_| 0x11);
1019        // Parent colours untouched; fork fully overwritten.
1020        assert_eq!(&reg.model(base).colors, &[0xBB, 0xAA, 0xCC]);
1021        assert_eq!(&reg.model(forked).colors, &[0x11, 0x11, 0x11]);
1022    }
1023
1024    #[test]
1025    fn registry_gpu_structs_have_expected_sizes() {
1026        assert_eq!(std::mem::size_of::<SpriteModelMeta>(), 48);
1027        assert_eq!(std::mem::size_of::<SpriteInstanceGpu>(), 64);
1028    }
1029
1030    #[test]
1031    fn add_lod_builds_halving_mip_chain() {
1032        let mut reg = SpriteModelRegistry::new();
1033        // 8×8×8 single voxel-filled column model would be ideal, but
1034        // kv6_unsorted is 2×1×8 → mips: 2×1×8 → 1×1×4 → 1×1×2 → 1×1×1.
1035        let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
1036        let m0 = reg.model(id);
1037        assert_eq!(m0.dims, [2, 1, 8]);
1038        assert!((m0.voxel_world_size - 1.0).abs() < 1e-6);
1039    }
1040
1041    /// kv6 from explicit voxels, ordered x-major/y-inner to match
1042    /// `build_sprite_model`'s column walk.
1043    fn kv6_from(xsiz: u32, ysiz: u32, zsiz: u32, voxels: &[(u32, u32, u16, u32)]) -> Kv6 {
1044        let mut ylen = vec![vec![0u16; ysiz as usize]; xsiz as usize];
1045        let mut flat = Vec::new();
1046        for x in 0..xsiz {
1047            for y in 0..ysiz {
1048                let mut col: Vec<(u16, u32)> = voxels
1049                    .iter()
1050                    .filter(|(vx, vy, _, _)| *vx == x && *vy == y)
1051                    .map(|(_, _, z, c)| (*z, *c))
1052                    .collect();
1053                col.sort_by_key(|(z, _)| *z);
1054                ylen[x as usize][y as usize] = col.len() as u16;
1055                for (z, c) in col {
1056                    flat.push(Voxel {
1057                        col: c,
1058                        z,
1059                        vis: 0,
1060                        dir: 0,
1061                    });
1062                }
1063            }
1064        }
1065        let xlen = ylen
1066            .iter()
1067            .map(|c| c.iter().map(|&v| u32::from(v)).sum())
1068            .collect();
1069        Kv6 {
1070            xsiz,
1071            ysiz,
1072            zsiz,
1073            xpiv: 0.0,
1074            ypiv: 0.0,
1075            zpiv: 0.0,
1076            voxels: flat,
1077            xlen,
1078            ylen,
1079            palette: None,
1080        }
1081    }
1082
1083    fn offsets_consistent(m: &SpriteModel) -> bool {
1084        let cols = (m.dims[0] * m.dims[1]) as usize;
1085        if m.color_offsets.len() != cols + 1 {
1086            return false;
1087        }
1088        // Monotonic non-decreasing + last == colors.len + each column's
1089        // span == its solid-voxel count.
1090        for w in m.color_offsets.windows(2) {
1091            if w[1] < w[0] {
1092                return false;
1093            }
1094        }
1095        m.color_offsets[cols] as usize == m.colors.len()
1096    }
1097
1098    #[test]
1099    fn carve_two_layers_keeps_offsets_consistent() {
1100        // Mirror the demo's carve: columns with voxels at varied z,
1101        // some sharing z=0/z=1, some not.
1102        let kv6 = kv6_from(
1103            3,
1104            2,
1105            8,
1106            &[
1107                (0, 0, 0, 0xA0),
1108                (0, 0, 1, 0xA1),
1109                (0, 0, 5, 0xA5),
1110                (1, 0, 1, 0xB1),
1111                (2, 1, 0, 0xC0),
1112                (2, 1, 3, 0xC3),
1113            ],
1114        );
1115        let mut m = build_sprite_model(&kv6);
1116        assert!(offsets_consistent(&m));
1117        for z in 0..2u32 {
1118            for y in 0..m.dims[1] {
1119                for x in 0..m.dims[0] {
1120                    m.set_voxel(x, y, z, None);
1121                }
1122            }
1123            assert!(offsets_consistent(&m), "inconsistent after carving z={z}");
1124            // downsample must not panic on the carved model.
1125            let _ = m.downsample();
1126        }
1127    }
1128
1129    #[test]
1130    fn set_voxel_inserts_replaces_and_clears() {
1131        // col 0 starts with z=1 (0xBB), z=5 (0xAA); col 1 with z=3 (0xCC).
1132        let mut m = build_sprite_model(&kv6_unsorted());
1133
1134        // Insert z=3 into col 0 (between z=1 and z=5) → rank 1.
1135        assert!(m.set_voxel(0, 0, 3, Some(0x55)));
1136        assert_eq!(m.occupancy[0], (1 << 1) | (1 << 3) | (1 << 5));
1137        // col 0 colours ascending z: 0xBB(z1), 0x55(z3), 0xAA(z5).
1138        assert_eq!(m.color_offsets, vec![0, 3, 4]);
1139        assert_eq!(&m.colors, &[0xBB, 0x55, 0xAA, 0xCC]);
1140
1141        // Replace z=3 in place (no offset shift).
1142        assert!(m.set_voxel(0, 0, 3, Some(0x66)));
1143        assert_eq!(&m.colors, &[0xBB, 0x66, 0xAA, 0xCC]);
1144        assert_eq!(m.color_offsets, vec![0, 3, 4]);
1145
1146        // Clear z=1 (rank 0) from col 0.
1147        assert!(m.set_voxel(0, 0, 1, None));
1148        assert_eq!(m.occupancy[0], (1 << 3) | (1 << 5));
1149        assert_eq!(m.color_offsets, vec![0, 2, 3]);
1150        assert_eq!(&m.colors, &[0x66, 0xAA, 0xCC]);
1151
1152        // No-ops: clear an empty voxel, edit out of bounds.
1153        assert!(!m.set_voxel(0, 0, 2, None));
1154        assert!(!m.set_voxel(9, 0, 0, Some(1)));
1155    }
1156
1157    #[test]
1158    fn rebuild_lod_refreshes_coarse_levels_from_mip0() {
1159        let mut reg = SpriteModelRegistry::new();
1160        let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 3);
1161        // Recolour mip-0 only via model_mut, then rebuild the ladder.
1162        reg.model_mut(id).recolor(|_| 0x0000_2000);
1163        reg.rebuild_lod(id);
1164        // The mip-1 average of all-0x2000 voxels is still 0x2000.
1165        let lvl1_entry = reg.chains[id as usize][1] as usize;
1166        assert!(reg.entries[lvl1_entry]
1167            .colors
1168            .iter()
1169            .all(|&c| c == 0x0000_2000));
1170    }
1171}