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::material::material_for_color;
28use roxlap_formats::sprite::Sprite;
29use roxlap_formats::voxel_clip::{DecodedClip, VoxelFrame};
30
31/// CPU-built voxel volume for one KV6 model.
32#[derive(Debug, Clone)]
33pub struct SpriteModel {
34    /// Voxel extent `(mx, my, mz)`.
35    pub dims: [u32; 3],
36    /// `ceil(mz / 32)` — u32 words of occupancy per (x, y) column.
37    pub occ_words_per_col: u32,
38    /// KV6 pivot in model-local voxel space.
39    pub pivot: [f32; 3],
40    /// Per-column occupancy bitmask, `mx * my * occ_words_per_col`.
41    pub occupancy: Vec<u32>,
42    /// Voxel colours, ascending z within each column.
43    pub colors: Vec<u32>,
44    /// Per-voxel surface-normal index (`Kv6::Voxel::dir`, 0..256),
45    /// parallel to [`colors`](Self::colors). The GPU sprite shader uses
46    /// it to index the per-instance `kv6colmul` lighting table, matching
47    /// the CPU rasteriser's normal-based shading.
48    pub dirs: Vec<u32>,
49    /// Prefix sums: `color_offsets[col]` is the first colour index of
50    /// column `col`; length `mx * my + 1`.
51    pub color_offsets: Vec<u32>,
52    /// Per-voxel material id (TV.3), parallel to [`colors`](Self::colors).
53    /// **Empty** means the model has no per-voxel materials — every voxel
54    /// uses the instance's uniform material (the TV.1/TV.2 path). A non-empty
55    /// array gives mixed-material models (opaque frame + glass). Built by
56    /// [`build_sprite_model_with_materials`].
57    pub materials: Vec<u8>,
58    /// World-space size of one voxel of this model (GPU.10.4 LOD): 1.0
59    /// at mip-0, doubling each [`SpriteModel::downsample`]. The shader
60    /// divides the local ray by this so a coarse voxel spans the right
61    /// world extent and the march `t` stays in world units.
62    pub voxel_world_size: f32,
63}
64
65/// Build the DDA volume from a KV6. Columns are packed in
66/// `x + y*mx` order; each column's voxels are sorted ascending by z
67/// so the shader's popcount-rank colour lookup is correct.
68///
69/// # Panics
70/// If the KV6's `ylen` counters disagree with `voxels.len()` (a
71/// malformed model).
72#[must_use]
73pub fn build_sprite_model(kv6: &Kv6) -> SpriteModel {
74    build_sprite_model_inner(kv6, &[])
75}
76
77/// Build the DDA volume from a KV6, classifying each voxel into a per-voxel
78/// **material id** by colour (TV.3 mixed models) via `material_map`
79/// (`(rgb, material_id)` pairs; see
80/// [`material_for_color`](roxlap_formats::material::material_for_color)).
81/// An empty map produces a model with no per-voxel materials (identical to
82/// [`build_sprite_model`]).
83///
84/// # Panics
85/// As [`build_sprite_model`].
86#[must_use]
87pub fn build_sprite_model_with_materials(kv6: &Kv6, material_map: &[(u32, u8)]) -> SpriteModel {
88    build_sprite_model_inner(kv6, material_map)
89}
90
91fn build_sprite_model_inner(kv6: &Kv6, material_map: &[(u32, u8)]) -> SpriteModel {
92    let (mx, my, mz) = (kv6.xsiz, kv6.ysiz, kv6.zsiz);
93    let occ_words_per_col = mz.div_ceil(32).max(1);
94    let cols = (mx * my) as usize;
95    let want_mats = !material_map.is_empty();
96
97    let mut occupancy = vec![0u32; cols * occ_words_per_col as usize];
98    let mut color_offsets = vec![0u32; cols + 1];
99    let mut colors: Vec<u32> = Vec::with_capacity(kv6.voxels.len());
100    let mut dirs: Vec<u32> = Vec::with_capacity(kv6.voxels.len());
101    let mut materials: Vec<u8> = if want_mats {
102        Vec::with_capacity(kv6.voxels.len())
103    } else {
104        Vec::new()
105    };
106
107    // Pass 1 — consume voxels in KV6 storage order (x-outer / y-inner)
108    // into per-column buckets keyed by `col = x + y*mx`. Each entry is
109    // `(z, colour, normal-dir)`.
110    let mut buckets: Vec<Vec<(u16, u32, u8)>> = vec![Vec::new(); cols];
111    let mut voxel_iter = kv6.voxels.iter();
112    for x in 0..mx {
113        for y in 0..my {
114            let col = (x + y * mx) as usize;
115            let count = kv6.ylen[x as usize][y as usize];
116            for _ in 0..count {
117                let v = voxel_iter.next().expect("KV6 ylen / voxels.len mismatch");
118                buckets[col].push((v.z, v.col, v.dir));
119            }
120        }
121    }
122
123    // Pass 2 — emit in COLUMN-INDEX order so `color_offsets` is a true
124    // monotonic prefix sum (the shader indexes by `col` either way, but
125    // structural edits / mip rebuilds rely on monotonic offsets). Each
126    // column's voxels sorted ascending z for the popcount-rank lookup.
127    for (col, bucket) in buckets.iter_mut().enumerate() {
128        color_offsets[col] = colors.len() as u32;
129        bucket.sort_by_key(|(z, _, _)| *z);
130        for &(z, col_rgba, dir) in bucket.iter() {
131            let z = u32::from(z);
132            let base = col * occ_words_per_col as usize + (z >> 5) as usize;
133            occupancy[base] |= 1u32 << (z & 31);
134            colors.push(col_rgba);
135            dirs.push(u32::from(dir));
136            if want_mats {
137                materials.push(material_for_color(material_map, col_rgba));
138            }
139        }
140    }
141    color_offsets[cols] = colors.len() as u32;
142
143    SpriteModel {
144        dims: [mx, my, mz],
145        occ_words_per_col,
146        pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
147        occupancy,
148        color_offsets,
149        colors,
150        dirs,
151        materials,
152        voxel_world_size: 1.0,
153    }
154}
155
156/// Build a [`SpriteModel`] directly from a decoded voxel-clip frame
157/// (VCL.2). The [`VoxelFrame`] dense-column layout is byte-for-byte the
158/// [`SpriteModel`] layout that [`build_sprite_model`] produces, so this is
159/// a field move — no per-column bucket-sort. `dirs` is the frame's
160/// surface-normal LUT indices (from [`DecodedClip::dirs`]), parallel to
161/// `frame.colors`.
162///
163/// # Panics
164/// In debug, if `dirs.len() != frame.colors.len()` or the field shapes
165/// don't match `dims` (the same invariants [`build_sprite_model`] upholds).
166#[must_use]
167pub fn sprite_model_from_voxel_frame(
168    frame: &VoxelFrame,
169    dirs: &[u32],
170    dims: [u32; 3],
171    pivot: [f32; 3],
172    voxel_world_size: f32,
173) -> SpriteModel {
174    sprite_model_from_voxel_frame_with_materials(frame, dirs, dims, pivot, voxel_world_size, &[])
175}
176
177/// Like [`sprite_model_from_voxel_frame`] but classifies each voxel into a
178/// per-voxel **material id** by colour (TV.3 mixed models) via `material_map`
179/// (`(rgb, material_id)` pairs). An empty map produces a model with no
180/// per-voxel materials (identical to [`sprite_model_from_voxel_frame`]).
181///
182/// # Panics
183/// As [`sprite_model_from_voxel_frame`].
184#[must_use]
185pub fn sprite_model_from_voxel_frame_with_materials(
186    frame: &VoxelFrame,
187    dirs: &[u32],
188    dims: [u32; 3],
189    pivot: [f32; 3],
190    voxel_world_size: f32,
191    material_map: &[(u32, u8)],
192) -> SpriteModel {
193    let occ_words_per_col = dims[2].div_ceil(32).max(1);
194    let cols = (dims[0] * dims[1]) as usize;
195    debug_assert_eq!(frame.occupancy.len(), cols * occ_words_per_col as usize);
196    debug_assert_eq!(frame.color_offsets.len(), cols + 1);
197    debug_assert_eq!(dirs.len(), frame.colors.len());
198    // Per-voxel materials are parallel to `colors` (popcount-rank order), so
199    // classify the frame's colour run directly — no re-index needed.
200    let materials: Vec<u8> = if material_map.is_empty() {
201        Vec::new()
202    } else {
203        frame
204            .colors
205            .iter()
206            .map(|&c| material_for_color(material_map, c))
207            .collect()
208    };
209    SpriteModel {
210        dims,
211        occ_words_per_col,
212        pivot,
213        occupancy: frame.occupancy.clone(),
214        colors: frame.colors.clone(),
215        dirs: dirs.to_vec(),
216        color_offsets: frame.color_offsets.clone(),
217        materials,
218        voxel_world_size,
219    }
220}
221
222/// Build the [`SpriteModel`] for frame `frame` of a decoded clip — the
223/// per-frame model uploaded into a flipbook chain (VCL.2).
224///
225/// # Panics
226/// If `frame` is out of range, or the frame fails the layout invariants.
227#[must_use]
228pub fn sprite_model_from_clip_frame(clip: &DecodedClip, frame: usize) -> SpriteModel {
229    sprite_model_from_clip_frame_with_materials(clip, frame, &[])
230}
231
232/// Like [`sprite_model_from_clip_frame`] but classifies the frame's voxels
233/// into per-voxel material ids by colour (TV.3 mixed models) via
234/// `material_map`. An empty map is identical to [`sprite_model_from_clip_frame`].
235///
236/// # Panics
237/// If `frame` is out of range, or the frame fails the layout invariants.
238#[must_use]
239pub fn sprite_model_from_clip_frame_with_materials(
240    clip: &DecodedClip,
241    frame: usize,
242    material_map: &[(u32, u8)],
243) -> SpriteModel {
244    sprite_model_from_voxel_frame_with_materials(
245        &clip.frames[frame],
246        &clip.dirs[frame],
247        clip.dims,
248        clip.pivot,
249        clip.voxel_world_size,
250        material_map,
251    )
252}
253
254/// Per-instance transform consumed by the model-DDA shader: the
255/// inverse model→world rotation (so a world ray can be brought into
256/// model-local space) plus the instance's world position. Stored as
257/// three padded columns for std140/std430 (`mat3x3` 16-byte columns).
258#[repr(C)]
259#[derive(Clone, Copy, Pod, Zeroable, Debug)]
260pub struct SpriteInstanceTransform {
261    /// Inverse of `[s | h | f]`, column-major, each column padded to
262    /// `vec4`. `inv_rot * v = c0*v.x + c1*v.y + c2*v.z`.
263    pub inv_rot: [[f32; 4]; 3],
264    /// Instance world position (the KV6 pivot maps here).
265    pub pos: [f32; 3],
266    _pad: f32,
267}
268
269impl SpriteInstanceTransform {
270    /// Build from a sprite pose. `s/h/f` are the model→world basis
271    /// columns; we invert them so the shader can map world→local.
272    #[must_use]
273    pub fn from_sprite(sprite: &Sprite) -> Self {
274        let inv = mat3_inverse([sprite.s, sprite.h, sprite.f]);
275        Self {
276            inv_rot: [
277                [inv[0][0], inv[0][1], inv[0][2], 0.0],
278                [inv[1][0], inv[1][1], inv[1][2], 0.0],
279                [inv[2][0], inv[2][1], inv[2][2], 0.0],
280            ],
281            pos: sprite.p,
282            _pad: 0.0,
283        }
284    }
285}
286
287/// A registry of sprite models. Instances reference a model by
288/// `model_id`, which is a **LOD chain** id: each chain holds one or
289/// more concrete mip levels (finest first; GPU.10.4), and the renderer
290/// picks the level per instance by distance. Identical KV6s are added
291/// once and shared by many instances. **Copy-on-modify**:
292/// [`Self::fork`] deep-copies a chain so edits to the fork leave the
293/// parent (and its instances) intact.
294#[derive(Debug, Clone, Default)]
295pub struct SpriteModelRegistry {
296    /// Concrete mip-level volumes (the GPU buffers concatenate these).
297    entries: Vec<SpriteModel>,
298    /// `chains[model_id]` = entry ids, finest (mip-0) first.
299    chains: Vec<Vec<u32>>,
300}
301
302impl SpriteModelRegistry {
303    #[must_use]
304    pub fn new() -> Self {
305        Self::default()
306    }
307
308    fn push_entry(&mut self, model: SpriteModel) -> u32 {
309        let id = self.entries.len() as u32;
310        self.entries.push(model);
311        id
312    }
313
314    /// Register a single-level (no-LOD) model; returns its `model_id`.
315    pub fn add(&mut self, model: SpriteModel) -> u32 {
316        let e = self.push_entry(model);
317        let id = self.chains.len() as u32;
318        self.chains.push(vec![e]);
319        id
320    }
321
322    /// Register a model with up to `max_levels` LOD mips (each a 2×
323    /// [`SpriteModel::downsample`] of the previous; stops early once a
324    /// level collapses to 1³). Returns its `model_id`.
325    pub fn add_lod(&mut self, model: SpriteModel, max_levels: u32) -> u32 {
326        let mut levels = vec![self.push_entry(model.clone())];
327        let mut cur = model;
328        for _ in 1..max_levels.max(1) {
329            if cur.dims == [1, 1, 1] {
330                break;
331            }
332            cur = cur.downsample();
333            levels.push(self.push_entry(cur.clone()));
334        }
335        let id = self.chains.len() as u32;
336        self.chains.push(levels);
337        id
338    }
339
340    /// Copy-on-modify: deep-copy every level of chain `parent` into new
341    /// entries + a new chain, and return its `model_id`. The fork owns
342    /// independent voxel data, so mutating it does not affect the
343    /// parent or any instance still pointing at it.
344    ///
345    /// # Panics
346    /// If `parent` is not a registered `model_id`.
347    pub fn fork(&mut self, parent: u32) -> u32 {
348        let src = self.chains[parent as usize].clone();
349        let levels: Vec<u32> = src
350            .iter()
351            .map(|&e| {
352                let copy = self.entries[e as usize].clone();
353                self.push_entry(copy)
354            })
355            .collect();
356        let id = self.chains.len() as u32;
357        self.chains.push(levels);
358        id
359    }
360
361    /// The finest (mip-0) model of chain `id`.
362    #[must_use]
363    pub fn model(&self, id: u32) -> &SpriteModel {
364        &self.entries[self.chains[id as usize][0] as usize]
365    }
366
367    /// Like [`Self::model`] but returns `None` for an out-of-range or
368    /// tombstoned (emptied) chain instead of panicking — the guarded form
369    /// for public primitives handed an arbitrary `chain_id`.
370    #[must_use]
371    pub fn model_checked(&self, id: u32) -> Option<&SpriteModel> {
372        let entry = *self.chains.get(id as usize)?.first()?;
373        self.entries.get(entry as usize)
374    }
375
376    /// Mutable access to the finest (mip-0) model for editing — the
377    /// copy-on-modify entry point (typically on a [`Self::fork`]).
378    /// After a *structural* edit (occupancy/dims), call
379    /// [`Self::rebuild_lod`] so the coarser mips match; a pure recolour
380    /// can use [`Self::recolor_chain`] instead.
381    pub fn model_mut(&mut self, id: u32) -> &mut SpriteModel {
382        let e = self.chains[id as usize][0] as usize;
383        &mut self.entries[e]
384    }
385
386    /// Recolour every LOD level of chain `id` (so a forked tint shows
387    /// at all distances).
388    pub fn recolor_chain(&mut self, id: u32, f: impl Fn(u32) -> u32 + Copy) {
389        for li in 0..self.chains[id as usize].len() {
390            let e = self.chains[id as usize][li] as usize;
391            self.entries[e].recolor(f);
392        }
393    }
394
395    /// Regenerate chain `id`'s coarser mip levels from its (possibly
396    /// just-edited) mip-0. Run after a structural edit via
397    /// [`Self::model_mut`] so the LOD ladder stays consistent. No-op
398    /// for a single-level (no-LOD) chain.
399    pub fn rebuild_lod(&mut self, id: u32) {
400        let levels = self.chains[id as usize].clone();
401        if levels.len() <= 1 {
402            return;
403        }
404        let mut cur = self.entries[levels[0] as usize].clone();
405        for &e in &levels[1..] {
406            cur = cur.downsample();
407            self.entries[e as usize] = cur.clone();
408        }
409    }
410
411    /// Free chain `chain_id`'s voxel data **in place**: replace each of
412    /// its LOD entries with [`SpriteModel::empty`] and clear the chain.
413    /// Entry ids and every other `model_id` are **preserved** (the chain
414    /// becomes empty, its entries become placeholders), so no id remap is
415    /// needed and the resident registry's entry alignment stays intact.
416    ///
417    /// This is safe to pair with the resident side because
418    /// [`SpriteRegistryResident::remove_model`] tombstones the same
419    /// entries (`dead[e]`) and [`compact`](SpriteRegistryResident::compact)
420    /// reads only live entries — so the resident never touches the empty
421    /// placeholders left here. Call `remove_model` (resident) **before**
422    /// this so those tombstones are set. No-op if `chain_id` is out of
423    /// range or already removed.
424    pub fn remove(&mut self, chain_id: u32) {
425        let Some(entries) = self.chains.get(chain_id as usize) else {
426            return;
427        };
428        // Clone the small id list so we can mutate `entries` while iterating.
429        let entries = entries.clone();
430        for e in entries {
431            self.entries[e as usize] = SpriteModel::empty();
432        }
433        self.chains[chain_id as usize] = Vec::new(); // tombstone (slot kept)
434    }
435
436    /// Whether `chain_id` is a live (registered, not [`removed`](Self::remove))
437    /// model. `false` for an out-of-range id or a tombstoned chain.
438    #[must_use]
439    pub fn is_live(&self, chain_id: u32) -> bool {
440        self.chains
441            .get(chain_id as usize)
442            .is_some_and(|c| !c.is_empty())
443    }
444
445    /// Number of LOD chains (distinct `model_id`s). Counts tombstoned
446    /// (removed) chains too — ids are never reused, so this is also the
447    /// next id that [`Self::add`] / [`Self::add_lod`] will mint.
448    #[must_use]
449    pub fn len(&self) -> usize {
450        self.chains.len()
451    }
452
453    #[must_use]
454    pub fn is_empty(&self) -> bool {
455        self.chains.is_empty()
456    }
457}
458
459impl SpriteModel {
460    /// An empty (zero-voxel, zero-extent) placeholder model. Used by
461    /// [`SpriteModelRegistry::remove`] to free a removed chain's voxel
462    /// data while keeping its entry slot, so ids stay stable. Carries no
463    /// occupancy/colours; `color_offsets` is the single-element prefix
464    /// `[0]` (`cols + 1` with `cols == 0`), keeping the structural
465    /// invariant intact for any code that inspects it.
466    #[must_use]
467    pub fn empty() -> Self {
468        Self {
469            dims: [0, 0, 0],
470            occ_words_per_col: 1,
471            pivot: [0.0, 0.0, 0.0],
472            occupancy: Vec::new(),
473            colors: Vec::new(),
474            dirs: Vec::new(),
475            color_offsets: vec![0],
476            materials: Vec::new(),
477            voxel_world_size: 1.0,
478        }
479    }
480
481    /// Recolour every voxel via `f(old_rgba) -> new_rgba`. Structure
482    /// (occupancy / offsets) is untouched, so this is a cheap in-place
483    /// edit — handy on a [`SpriteModelRegistry::fork`] to make a tinted
484    /// variant. For structural edits, mutate the public occupancy /
485    /// colours / dims directly (via `model_mut`) then rebuild the LOD.
486    pub fn recolor(&mut self, f: impl Fn(u32) -> u32) {
487        for c in &mut self.colors {
488            *c = f(*c);
489        }
490    }
491
492    /// GPU.12 — structural edit of a single voxel within the model's
493    /// existing bounds. `Some(rgba)` sets/replaces the voxel at
494    /// `(x, y, z)`; `None` clears it. Maintains the ascending-z colour
495    /// invariant by inserting/removing at the voxel's popcount rank and
496    /// shifting the affected columns' `color_offsets`. Returns `true`
497    /// if the model changed. Out-of-bounds coordinates are ignored
498    /// (returns `false`) — growing `dims` is a separate concern.
499    ///
500    /// After editing, call [`SpriteModelRegistry::rebuild_lod`] to
501    /// refresh coarser mips, then re-upload via `set_sprite_instances`.
502    pub fn set_voxel(&mut self, x: u32, y: u32, z: u32, color: Option<u32>) -> bool {
503        if x >= self.dims[0] || y >= self.dims[1] || z >= self.dims[2] {
504            return false;
505        }
506        let owpc = self.occ_words_per_col as usize;
507        let cols = (self.dims[0] * self.dims[1]) as usize;
508        let col = (x + y * self.dims[0]) as usize;
509        let base = col * owpc;
510        let zw = (z >> 5) as usize;
511        let zb = z & 31;
512
513        // Rank = solid voxels strictly below z in this column.
514        let mut rank = 0usize;
515        for w in 0..zw {
516            rank += self.occupancy[base + w].count_ones() as usize;
517        }
518        let below_mask = if zb > 0 { (1u32 << zb) - 1 } else { 0 };
519        rank += (self.occupancy[base + zw] & below_mask).count_ones() as usize;
520        let idx = self.color_offsets[col] as usize + rank;
521        let was_set = (self.occupancy[base + zw] >> zb) & 1 == 1;
522
523        if let Some(rgba) = color {
524            if was_set {
525                self.colors[idx] = rgba; // replace in place (keeps dir)
526            } else {
527                self.occupancy[base + zw] |= 1u32 << zb;
528                self.colors.insert(idx, rgba);
529                // No normal supplied by this API — default to dir 0 (the
530                // sole caller, the carve hotkey, only ever clears).
531                self.dirs.insert(idx, 0);
532                if !self.materials.is_empty() {
533                    self.materials.insert(idx, 0); // new voxel → opaque material
534                }
535                for c in &mut self.color_offsets[col + 1..=cols] {
536                    *c += 1;
537                }
538            }
539            true
540        } else {
541            if !was_set {
542                return false;
543            }
544            self.occupancy[base + zw] &= !(1u32 << zb);
545            self.colors.remove(idx);
546            self.dirs.remove(idx);
547            if !self.materials.is_empty() {
548                self.materials.remove(idx);
549            }
550            for c in &mut self.color_offsets[col + 1..=cols] {
551                *c -= 1;
552            }
553            true
554        }
555    }
556
557    /// Radius of a bounding sphere centred at the instance position
558    /// (the pivot maps there): the farthest bbox corner from the
559    /// pivot. Used for frustum culling. Assumes a unit basis; scaled
560    /// instances would multiply this by their max basis length.
561    #[must_use]
562    pub fn bound_radius(&self) -> f32 {
563        let mut r2 = 0.0_f32;
564        for &cx in &[0.0, self.dims[0] as f32] {
565            for &cy in &[0.0, self.dims[1] as f32] {
566                for &cz in &[0.0, self.dims[2] as f32] {
567                    let d = [cx - self.pivot[0], cy - self.pivot[1], cz - self.pivot[2]];
568                    r2 = r2.max(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
569                }
570            }
571        }
572        r2.sqrt()
573    }
574
575    /// GPU.10.4 — 2× voxel downsample for the next LOD level. A coarse
576    /// voxel is solid if any of its 2×2×2 fine voxels is, coloured by
577    /// their per-channel average. Dims/pivot halve and
578    /// `voxel_world_size` doubles, so the coarse model occupies the
579    /// same world box at half the resolution (origin-corner aligned).
580    #[must_use]
581    #[allow(clippy::manual_checked_ops)] // `n > 0` guards 4 divisions, not one checked_div
582    pub fn downsample(&self) -> SpriteModel {
583        let [fx, fy, fz] = self.dims;
584        let fidx = |x: u32, y: u32, z: u32| (x + y * fx + z * fx * fy) as usize;
585
586        // Reconstruct dense fine voxels (solid flag + colour + normal + TV
587        // material).
588        let has_mats = !self.materials.is_empty();
589        let mut solid = vec![false; (fx * fy * fz) as usize];
590        let mut fine = vec![0u32; (fx * fy * fz) as usize];
591        let mut fine_dir = vec![0u32; (fx * fy * fz) as usize];
592        let mut fine_mat = vec![0u8; (fx * fy * fz) as usize];
593        for x in 0..fx {
594            for y in 0..fy {
595                let col = (x + y * fx) as usize;
596                let base = col * self.occ_words_per_col as usize;
597                let off = self.color_offsets[col] as usize;
598                let mut seen = 0usize;
599                for z in 0..fz {
600                    let w = base + (z >> 5) as usize;
601                    if (self.occupancy[w] >> (z & 31)) & 1 == 1 {
602                        fine[fidx(x, y, z)] = self.colors[off + seen];
603                        fine_dir[fidx(x, y, z)] = self.dirs[off + seen];
604                        if has_mats {
605                            fine_mat[fidx(x, y, z)] = self.materials[off + seen];
606                        }
607                        solid[fidx(x, y, z)] = true;
608                        seen += 1;
609                    }
610                }
611            }
612        }
613
614        let nx = fx.div_ceil(2).max(1);
615        let ny = fy.div_ceil(2).max(1);
616        let nz = fz.div_ceil(2).max(1);
617        let owpc = nz.div_ceil(32).max(1);
618        let cols = (nx * ny) as usize;
619        let mut occupancy = vec![0u32; cols * owpc as usize];
620        let mut color_offsets = vec![0u32; cols + 1];
621        let mut colors: Vec<u32> = Vec::new();
622        let mut dirs: Vec<u32> = Vec::new();
623        let mut materials: Vec<u8> = Vec::new();
624
625        // Emit in column-index order (`ccol = cx + cy*nx`), cy outer,
626        // so `color_offsets` is a monotonic prefix sum like build's.
627        for cy in 0..ny {
628            for cx in 0..nx {
629                let ccol = (cx + cy * nx) as usize;
630                color_offsets[ccol] = colors.len() as u32;
631                for cz in 0..nz {
632                    let (mut a, mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32, 0u32);
633                    // Normals + materials don't average meaningfully — keep
634                    // the first solid child's `dir` / material for the coarse
635                    // voxel.
636                    let mut rep_dir = 0u32;
637                    let mut rep_mat = 0u8;
638                    for dz in 0..2 {
639                        for dy in 0..2 {
640                            for dx in 0..2 {
641                                let (x, y, z) = (2 * cx + dx, 2 * cy + dy, 2 * cz + dz);
642                                if x < fx && y < fy && z < fz && solid[fidx(x, y, z)] {
643                                    let c = fine[fidx(x, y, z)];
644                                    if n == 0 {
645                                        rep_dir = fine_dir[fidx(x, y, z)];
646                                        rep_mat = fine_mat[fidx(x, y, z)];
647                                    }
648                                    a += (c >> 24) & 0xff;
649                                    r += (c >> 16) & 0xff;
650                                    g += (c >> 8) & 0xff;
651                                    b += c & 0xff;
652                                    n += 1;
653                                }
654                            }
655                        }
656                    }
657                    if n > 0 {
658                        let avg = ((a / n) << 24) | ((r / n) << 16) | ((g / n) << 8) | (b / n);
659                        let base = ccol * owpc as usize + (cz >> 5) as usize;
660                        occupancy[base] |= 1u32 << (cz & 31);
661                        colors.push(avg);
662                        dirs.push(rep_dir);
663                        if has_mats {
664                            materials.push(rep_mat);
665                        }
666                    }
667                }
668            }
669        }
670        color_offsets[cols] = colors.len() as u32;
671
672        SpriteModel {
673            dims: [nx, ny, nz],
674            occ_words_per_col: owpc,
675            pivot: [
676                self.pivot[0] * 0.5,
677                self.pivot[1] * 0.5,
678                self.pivot[2] * 0.5,
679            ],
680            occupancy,
681            colors,
682            dirs,
683            color_offsets,
684            materials,
685            voxel_world_size: self.voxel_world_size * 2.0,
686        }
687    }
688}
689
690/// View frustum for CPU instance culling, in world space. Built each
691/// frame from the world camera. `half_w`/`half_h` are the tangents of
692/// the half-FOV (so the side planes are `|x| <= half_w * z` etc. in
693/// camera space).
694#[derive(Clone, Copy, Debug)]
695pub struct ViewFrustum {
696    pub pos: [f32; 3],
697    pub right: [f32; 3],
698    pub down: [f32; 3],
699    pub forward: [f32; 3],
700    pub half_w: f32,
701    pub half_h: f32,
702    pub far: f32,
703}
704
705/// CPU cull record: the GPU instance + its world bounding sphere.
706/// Not `Copy` — carries a boxed 256-entry `kv6colmul` table.
707#[derive(Clone)]
708struct CullInstance {
709    /// Instance transform + a placeholder `model_id`; the cull
710    /// overwrites `model_id` with the distance-chosen LOD entry.
711    gpu: SpriteInstanceGpu,
712    /// LOD chain this instance draws (the user-facing `model_id`).
713    chain_id: u32,
714    center: [f32; 3],
715    radius: f32,
716    /// voxlap `kv6colmul[256]` — per-surface-normal colour modulation
717    /// for this instance's pose + lighting. Defaults to identity
718    /// (`0x0100` in every channel lane → unshaded) until the facade sets
719    /// it via [`SpriteRegistryResident::set_instance_colmul`]. Packed
720    /// into the `colmul` GPU buffer (in visible order) each frame.
721    colmul: Box<[u64; 256]>,
722}
723
724/// Identity `kv6colmul` table: every channel lane = `0x0100`, so the
725/// shader's `(rgb[c] << 8) * 0x0100 >> 16 == rgb[c]` — i.e. no shading.
726fn identity_colmul() -> Box<[u64; 256]> {
727    const LANE: u64 = 0x0100;
728    let w = LANE | (LANE << 16) | (LANE << 32) | (LANE << 48);
729    Box::new([w; 256])
730}
731
732fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
733    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
734}
735
736/// Build one CPU cull record from a user [`SpriteInstance`]: pack the
737/// transform, seed the bounding sphere from the chain's finest model, and
738/// start `colmul` at identity. Shared by the full
739/// [`SpriteRegistryResident::upload`] and the incremental
740/// [`SpriteRegistryResident::append_instances`].
741fn make_cull(registry: &SpriteModelRegistry, i: &SpriteInstance) -> CullInstance {
742    CullInstance {
743        gpu: SpriteInstanceGpu {
744            inv_rot0: i.transform.inv_rot[0],
745            inv_rot1: i.transform.inv_rot[1],
746            inv_rot2: i.transform.inv_rot[2],
747            pos: i.transform.pos,
748            model_id: i.model_id, // placeholder; cull rewrites per frame
749            material: u32::from(i.material),
750            alpha_mul: f32::from(i.alpha_mul) / 255.0,
751            flags: i.flags,
752            tint: i.tint,
753        },
754        chain_id: i.model_id,
755        center: i.transform.pos,
756        radius: registry.model(i.model_id).bound_radius(),
757        colmul: identity_colmul(),
758    }
759}
760
761/// Allocate the `instances` capacity buffer (`STORAGE | COPY_DST`) sized
762/// for `cap` records (≥1). Left uninitialised — `cull_bin_upload`
763/// rewrites it (offset 0) each frame, and `append_instances` seeds the
764/// live records after a grow.
765fn instances_buffer(device: &wgpu::Device, cap: u32) -> wgpu::Buffer {
766    device.create_buffer(&wgpu::BufferDescriptor {
767        label: Some("roxlap-gpu sprite_reg.instances"),
768        size: u64::from(cap.max(1)) * std::mem::size_of::<SpriteInstanceGpu>() as u64,
769        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
770        mapped_at_creation: false,
771    })
772}
773
774/// One sprite instance: a model reference + world pose.
775#[derive(Debug, Clone, Copy)]
776pub struct SpriteInstance {
777    pub model_id: u32,
778    pub transform: SpriteInstanceTransform,
779    /// Voxel-material id (TV stage): indexes the renderer's global material
780    /// palette for this instance's opacity + blend mode. `0` (the default)
781    /// is opaque, so an unset instance renders unchanged.
782    pub material: u8,
783    /// Per-instance alpha multiplier (TV stage), `0..=255` (`255` =
784    /// unscaled, the default).
785    pub alpha_mul: u8,
786    /// XS.4 — sprite shadow flags (`roxlap_formats::sprite` bits 4/5:
787    /// `NO_SHADOW_CAST` / `NO_SHADOW_RECEIVE`). `0` (default) ⇒ casts +
788    /// receives. Only honoured when the device is sprite-shadow capable.
789    pub flags: u32,
790    /// Per-instance RGB tint, packed `0x00RRGGBB` (white `0x00FF_FFFF` = no-op).
791    pub tint: u32,
792}
793
794impl SpriteInstance {
795    /// A model reference + pose with the default opaque material
796    /// (`material = 0`, `alpha_mul = 255`), shadows on (`flags = 0`), and no
797    /// tint (`0x00FF_FFFF`).
798    #[must_use]
799    pub fn new(model_id: u32, transform: SpriteInstanceTransform) -> Self {
800        Self {
801            model_id,
802            transform,
803            material: 0,
804            alpha_mul: 255,
805            flags: 0,
806            tint: 0x00FF_FFFF,
807        }
808    }
809}
810
811/// GPU per-model metadata: where this model's data starts in the
812/// shared registry buffers + its dims/pivot. Mirrors `ModelMeta` in
813/// the shader (std430, 48 bytes).
814#[repr(C)]
815#[derive(Clone, Copy, Pod, Zeroable, Debug)]
816struct SpriteModelMeta {
817    occupancy_offset: u32,
818    colors_offset: u32,
819    color_offsets_offset: u32,
820    occ_words_per_col: u32,
821    dims: [u32; 3],
822    /// TV.3 — 1 if this model has per-voxel materials (`materials_vox` is
823    /// populated for it); 0 ⇒ use the instance's uniform material.
824    has_vox_materials: u32,
825    pivot: [f32; 3],
826    /// GPU.10.4 — world size of one voxel of this (mip) entry.
827    voxel_world_size: f32,
828}
829
830/// GPU per-instance record. Mirrors `Instance` in the shader (std430,
831/// 80 bytes): inverse rotation columns + position + model id + the TV
832/// material id and per-instance alpha multiplier.
833#[repr(C)]
834#[derive(Clone, Copy, Pod, Zeroable, Debug)]
835struct SpriteInstanceGpu {
836    inv_rot0: [f32; 4],
837    inv_rot1: [f32; 4],
838    inv_rot2: [f32; 4],
839    pos: [f32; 3],
840    model_id: u32,
841    /// TV: material id into the global palette (binding 12).
842    material: u32,
843    /// TV: per-instance alpha multiplier, normalised to `0..=1`.
844    alpha_mul: f32,
845    /// XS.4 — sprite shadow flags (mirror of `roxlap_formats::sprite` bits 4/5):
846    /// bit4 = NO_SHADOW_CAST, bit5 = NO_SHADOW_RECEIVE. `0` ⇒ casts + receives.
847    flags: u32,
848    /// Per-instance RGB tint, packed `0x00RRGGBB` (white `0x00FF_FFFF` = no-op).
849    tint: u32,
850}
851
852/// Invert a 3×3 matrix given as basis columns `[c0, c1, c2]`,
853/// returning the inverse as columns. For an orthonormal basis this is
854/// the transpose; the general path covers rotation + non-unit scale.
855#[must_use]
856fn mat3_inverse(cols: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
857    let [a, b, c] = cols; // columns
858                          // Determinant via scalar triple product a · (b × c).
859    let cross = |u: [f32; 3], v: [f32; 3]| {
860        [
861            u[1] * v[2] - u[2] * v[1],
862            u[2] * v[0] - u[0] * v[2],
863            u[0] * v[1] - u[1] * v[0],
864        ]
865    };
866    let bc = cross(b, c);
867    let ca = cross(c, a);
868    let ab = cross(a, b);
869    let det = a[0] * bc[0] + a[1] * bc[1] + a[2] * bc[2];
870    let inv_det = if det.abs() < 1e-12 { 0.0 } else { 1.0 / det };
871    // Inverse rows are (b×c, c×a, a×b)/det; return as columns of the
872    // inverse, i.e. transpose of those rows.
873    [
874        [bc[0] * inv_det, ca[0] * inv_det, ab[0] * inv_det],
875        [bc[1] * inv_det, ca[1] * inv_det, ab[1] * inv_det],
876        [bc[2] * inv_det, ca[2] * inv_det, ab[2] * inv_det],
877    ]
878}
879
880/// GPU-resident registry + instances: every model's occupancy /
881/// colours / offsets concatenated into shared storage buffers, a
882/// per-model metadata table, and a capacity-sized instance buffer
883/// rewritten each frame with the frustum-visible subset (GPU.10.2).
884/// One bind group serves all models (same approach as the multi-grid
885/// scene).
886pub struct SpriteRegistryResident {
887    pub occupancy: wgpu::Buffer,
888    pub colors: wgpu::Buffer,
889    /// Per-voxel surface-normal index, concatenated across models in the
890    /// same layout as [`colors`](Self::colors). The shader indexes the
891    /// per-instance `kv6colmul` table by it.
892    pub dirs: wgpu::Buffer,
893    /// Per-voxel material id (TV.3), same layout as [`colors`](Self::colors)
894    /// (one u32 per voxel). `0` for models without per-voxel materials; the
895    /// per-model `has_vox_materials` flag in `model_meta` says whether to use
896    /// it (else the shader falls back to the instance's uniform material).
897    pub materials_vox: wgpu::Buffer,
898    pub color_offsets: wgpu::Buffer,
899    pub model_meta: wgpu::Buffer,
900    /// Holds up to `instance_capacity` instances; the visible subset
901    /// is packed into `[0, count)` each frame by [`Self::cull_bin_upload`].
902    pub instances: wgpu::Buffer,
903    pub instance_capacity: u32,
904    /// Per-visible-instance `kv6colmul[256]` tables, packed in the same
905    /// order as the `instances` buffer each frame (two u32 per u64
906    /// entry: lanes 0|1 then 2|3). Sized `instance_capacity * 256 * 2`
907    /// u32; rewritten by [`Self::cull_bin_upload`].
908    pub colmul: wgpu::Buffer,
909    colmul_cap: u32,
910    /// GPU.10.3 — per-tile `(offset, count)` into `tile_instances`,
911    /// flat `2 * tiles_x * tiles_y` u32s. Grown to fit the screen.
912    pub tile_ranges: wgpu::Buffer,
913    tile_ranges_cap: u32,
914    /// GPU.10.3 — flat list of visible-instance indices grouped by
915    /// tile. Grown to fit the per-frame total.
916    pub tile_instances: wgpu::Buffer,
917    tile_instances_cap: u32,
918    /// CPU cull records (full set), with precomputed bounding spheres.
919    cull: Vec<CullInstance>,
920    /// GPU.10.4 — LOD chains: `chains[chain_id]` = entry ids, finest
921    /// first. The cull picks a level by distance and writes its entry
922    /// id into the packed instance's `model_id`.
923    chains: Vec<Vec<u32>>,
924    /// GPU.12 incremental — CPU mirror of the GPU `model_meta` table, one
925    /// per concrete entry. [`Self::update_model`] reads the fixed
926    /// occupancy/color_offsets bases from here and rewrites the changed
927    /// `colors_offset` on a relocation.
928    meta: Vec<SpriteModelMeta>,
929    /// GPU.12 incremental — per-entry placement of `colors`/`dirs` in the
930    /// shared buffers (drives both; same offsets/ranks). Lets an edit
931    /// re-upload one model's data without touching the others.
932    colors_alloc: ColorsAllocator,
933    /// Per-entry word length of the dims-fixed `occupancy` and
934    /// `color_offsets` arrays, kept so [`Self::update_model`] can assert a
935    /// carve never changed dims (which would invalidate the in-place
936    /// writes — growing dims is out of scope, handled by a full re-upload).
937    occ_lens: Vec<u32>,
938    coloff_lens: Vec<u32>,
939    /// Used / allocated words of the tightly-concatenated `occupancy`
940    /// buffer. `add_model` bump-appends at `occ_used`; when it would pass
941    /// `occ_cap` the buffer is grown (with slack) and rebuilt from the
942    /// registry. (`colors`/`dirs` track theirs in [`ColorsAllocator`].)
943    occ_used: u32,
944    occ_cap: u32,
945    /// Used / allocated words of the tightly-concatenated `color_offsets`
946    /// buffer — same growth scheme as `occ_*`.
947    coloff_used: u32,
948    coloff_cap: u32,
949    /// Allocated record count of the `model_meta` buffer; `add_model`
950    /// grows it (with slack) when the entry count passes it.
951    meta_cap: u32,
952    /// Per-entry tombstone: `true` once its model was removed
953    /// ([`Self::remove_model`]). Dead entries keep their `meta` slot (so
954    /// entry ids — and the caller's `chain_id`s — stay stable) but their
955    /// colours are freed for reuse and they contribute nothing to a
956    /// repack / [`Self::compact`]. Parallel to `meta`.
957    dead: Vec<bool>,
958}
959
960/// Which tightly-concatenated registry buffer [`SpriteRegistryResident::
961/// sync_concat`] is operating on.
962#[derive(Clone, Copy)]
963enum ConcatBuf {
964    Occupancy,
965    ColorOffsets,
966}
967
968/// The model's source array for a given [`ConcatBuf`] — a free fn (not a
969/// closure) so the returned borrow keeps `m`'s lifetime.
970fn concat_data(m: &SpriteModel, which: ConcatBuf) -> &[u32] {
971    match which {
972        ConcatBuf::Occupancy => &m.occupancy,
973        ConcatBuf::ColorOffsets => &m.color_offsets,
974    }
975}
976
977impl SpriteRegistryResident {
978    /// Concatenate `registry`'s models into shared buffers and prepare
979    /// `instances` for per-frame culling. Model-relative indices stay
980    /// as built; the shader adds each model's base offset from the
981    /// metadata table.
982    #[must_use]
983    pub fn upload(
984        device: &wgpu::Device,
985        registry: &SpriteModelRegistry,
986        instances: &[SpriteInstance],
987    ) -> Self {
988        // `occupancy` + `color_offsets` are dims-fixed → tightly
989        // concatenated (never grow on a carve). `colors` + `dirs` are
990        // variable → laid out by the suballocator with per-slot slack so
991        // an incremental edit can rewrite one model in place.
992        let entry_lens: Vec<u32> = registry
993            .entries
994            .iter()
995            .map(|m| m.colors.len() as u32)
996            .collect();
997        let colors_alloc = ColorsAllocator::new(&entry_lens);
998        let cap_total = colors_alloc.cap_total();
999
1000        let mut all_occ: Vec<u32> = Vec::new();
1001        let mut all_offsets: Vec<u32> = Vec::new();
1002        let mut all_colors: Vec<u32> = vec![0; cap_total as usize];
1003        let mut all_dirs: Vec<u32> = vec![0; cap_total as usize];
1004        let mut all_materials: Vec<u32> = vec![0; cap_total as usize];
1005        let mut meta: Vec<SpriteModelMeta> = Vec::with_capacity(registry.entries.len());
1006        let mut occ_lens: Vec<u32> = Vec::with_capacity(registry.entries.len());
1007        let mut coloff_lens: Vec<u32> = Vec::with_capacity(registry.entries.len());
1008
1009        // One meta + placed data per concrete (mip-level) entry.
1010        for (e, m) in registry.entries.iter().enumerate() {
1011            let slot = colors_alloc.slot(e);
1012            meta.push(SpriteModelMeta {
1013                occupancy_offset: all_occ.len() as u32,
1014                colors_offset: slot.off,
1015                color_offsets_offset: all_offsets.len() as u32,
1016                occ_words_per_col: m.occ_words_per_col,
1017                dims: m.dims,
1018                has_vox_materials: u32::from(!m.materials.is_empty()),
1019                pivot: m.pivot,
1020                voxel_world_size: m.voxel_world_size,
1021            });
1022            occ_lens.push(m.occupancy.len() as u32);
1023            coloff_lens.push(m.color_offsets.len() as u32);
1024            all_occ.extend_from_slice(&m.occupancy);
1025            all_offsets.extend_from_slice(&m.color_offsets);
1026            let off = slot.off as usize;
1027            all_colors[off..off + m.colors.len()].copy_from_slice(&m.colors);
1028            all_dirs[off..off + m.dirs.len()].copy_from_slice(&m.dirs);
1029            for (i, &mat) in m.materials.iter().enumerate() {
1030                all_materials[off + i] = u32::from(mat);
1031            }
1032        }
1033
1034        // Per-instance cull records: sphere centred at the instance
1035        // position, radius from the chain's finest (mip-0) model.
1036        // `colmul` starts at identity (unshaded) until the facade sets
1037        // per-instance lighting via `set_instance_colmul`.
1038        let cull: Vec<CullInstance> = instances.iter().map(|i| make_cull(registry, i)).collect();
1039
1040        // Capacity buffer (COPY_DST so cull can rewrite it each frame),
1041        // seeded with the full set so frame 0 is valid pre-cull.
1042        let seed: Vec<SpriteInstanceGpu> = cull.iter().map(|c| c.gpu).collect();
1043        let instances_buf = {
1044            use wgpu::util::DeviceExt;
1045            let one = [SpriteInstanceGpu::zeroed()];
1046            let src: &[SpriteInstanceGpu] = if seed.is_empty() { &one } else { &seed };
1047            device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1048                label: Some("roxlap-gpu sprite_reg.instances"),
1049                contents: bytemuck::cast_slice(src),
1050                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
1051            })
1052        };
1053
1054        let tile_ranges = storage_dst_u32(device, "roxlap-gpu sprite_reg.tile_ranges", 1);
1055        let tile_instances = storage_dst_u32(device, "roxlap-gpu sprite_reg.tile_instances", 1);
1056        // colmul: 256 entries × 2 u32 per visible instance. Sized to the
1057        // full instance set (worst case all visible); rewritten per frame.
1058        let colmul_cap = (cull.len() as u32).max(1) * 256 * 2;
1059        let colmul = storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", colmul_cap);
1060        Self {
1061            occupancy: storage_dst_u32_cap(
1062                device,
1063                "roxlap-gpu sprite_reg.occupancy",
1064                &all_occ,
1065                all_occ.len() as u32,
1066            ),
1067            colors: storage_dst_u32_cap(
1068                device,
1069                "roxlap-gpu sprite_reg.colors",
1070                &all_colors,
1071                cap_total,
1072            ),
1073            dirs: storage_dst_u32_cap(device, "roxlap-gpu sprite_reg.dirs", &all_dirs, cap_total),
1074            materials_vox: storage_dst_u32_cap(
1075                device,
1076                "roxlap-gpu sprite_reg.materials_vox",
1077                &all_materials,
1078                cap_total,
1079            ),
1080            color_offsets: storage_dst_u32_cap(
1081                device,
1082                "roxlap-gpu sprite_reg.color_offsets",
1083                &all_offsets,
1084                all_offsets.len() as u32,
1085            ),
1086            model_meta: storage_dst_pod(device, "roxlap-gpu sprite_reg.model_meta", &meta),
1087            instances: instances_buf,
1088            instance_capacity: cull.len() as u32,
1089            colmul,
1090            colmul_cap,
1091            tile_ranges,
1092            tile_ranges_cap: 1,
1093            tile_instances,
1094            tile_instances_cap: 1,
1095            cull,
1096            chains: registry.chains.clone(),
1097            occ_used: all_occ.len() as u32,
1098            occ_cap: all_occ.len() as u32,
1099            coloff_used: all_offsets.len() as u32,
1100            coloff_cap: all_offsets.len() as u32,
1101            meta_cap: meta.len() as u32,
1102            dead: vec![false; meta.len()],
1103            meta,
1104            colors_alloc,
1105            occ_lens,
1106            coloff_lens,
1107        }
1108    }
1109
1110    /// Number of resident instances (the cull set length).
1111    #[must_use]
1112    pub fn instance_count(&self) -> usize {
1113        self.cull.len()
1114    }
1115
1116    /// Append new instances **without** re-uploading any model volume —
1117    /// the incremental counterpart to [`Self::upload`], for streaming
1118    /// spawns (asteroids, projectiles, …). Returns the index of the first
1119    /// appended instance; the block occupies `[base, base + N)`.
1120    ///
1121    /// The model volumes are untouched, so every appended instance must
1122    /// reference a `model_id` (LOD chain) that was already present in the
1123    /// `registry` passed to [`Self::upload`]. Registering a *new* model
1124    /// still requires a full [`Self::upload`] (its voxels must be laid
1125    /// into the shared buffers). `registry` here is only read for the new
1126    /// instances' bound-sphere radii and must be the resident one.
1127    ///
1128    /// The `instances` GPU buffer is only *grown* here (power-of-two,
1129    /// amortised O(1)); its contents are **not** written. [`Self::
1130    /// cull_bin_upload`] rewrites the whole visible range from `cull` every
1131    /// frame before the sprite pass reads it — exactly as for the static
1132    /// instances — so appending only needs to extend `cull` and ensure
1133    /// capacity. Writing the buffer here too caused a mid-frame
1134    /// write-while-in-flight hazard on some drivers (a stray full-screen
1135    /// flash on append). `colmul` likewise grows lazily in
1136    /// `cull_bin_upload`. After a removal the capacity is not shrunk.
1137    pub fn append_instances(
1138        &mut self,
1139        device: &wgpu::Device,
1140        registry: &SpriteModelRegistry,
1141        instances: &[SpriteInstance],
1142    ) -> u32 {
1143        let base = self.cull.len() as u32;
1144        if instances.is_empty() {
1145            return base;
1146        }
1147        for i in instances {
1148            debug_assert!(
1149                (i.model_id as usize) < self.chains.len(),
1150                "append_instances: model_id {} not resident (run upload to register new models)",
1151                i.model_id
1152            );
1153            self.cull.push(make_cull(registry, i));
1154        }
1155        let need = self.cull.len() as u32;
1156        if need > self.instance_capacity {
1157            // Grow power-of-two and recreate the buffer (the next frame's
1158            // bind group picks up the new handle). No seed write — the
1159            // per-frame cull_bin_upload populates it.
1160            self.instance_capacity = need.next_power_of_two();
1161            self.instances = instances_buffer(device, self.instance_capacity);
1162        }
1163        base
1164    }
1165
1166    /// Remove the instance at `index` by swap-remove — O(1), no GPU work
1167    /// (the next [`Self::cull_bin_upload`] repacks the visible set from
1168    /// the shrunk cull list). Capacity is retained for reuse.
1169    ///
1170    /// Returns `Some(old_last)` when a different instance was moved into
1171    /// `index` to fill the hole (its index changed from `old_last` to
1172    /// `index` — callers holding instance handles must fix up that one),
1173    /// or `None` if `index` was the last element or out of range. Because
1174    /// this reorders, any [`Self::set_instance_colmul`] table set by
1175    /// position should be re-applied after a removal.
1176    pub fn remove_instance(&mut self, index: usize) -> Option<usize> {
1177        if index >= self.cull.len() {
1178            return None;
1179        }
1180        let last = self.cull.len() - 1;
1181        self.cull.swap_remove(index);
1182        (index != last).then_some(last)
1183    }
1184
1185    /// Set the per-instance `kv6colmul[256]` lighting tables (voxlap's
1186    /// `update_reflects` output), in the same order/length as the
1187    /// instances passed to [`Self::upload`]. The next
1188    /// [`Self::cull_bin_upload`] packs the visible subset to the GPU.
1189    /// Instances beyond `tables.len()` keep their previous tables.
1190    pub fn set_instance_colmul(&mut self, tables: &[[u64; 256]]) {
1191        for (ci, t) in self.cull.iter_mut().zip(tables) {
1192            ci.colmul.copy_from_slice(t);
1193        }
1194    }
1195
1196    /// Refresh instance poses in place from `instances` — for animated
1197    /// sprites (e.g. KFA limbs re-posed each frame) — **without** any
1198    /// model-volume re-upload. `instances` must match the set passed to
1199    /// [`Self::upload`] in length + order; each keeps its `model_id`
1200    /// (LOD chain) so only the transform + cull centre change. No GPU
1201    /// write happens here: the next [`Self::cull_bin_upload`] re-uploads
1202    /// the packed visible subset, as it already does every frame.
1203    pub fn update_transforms(&mut self, instances: &[SpriteInstance]) {
1204        debug_assert_eq!(
1205            instances.len(),
1206            self.cull.len(),
1207            "update_transforms instance count must match upload"
1208        );
1209        for (ci, inst) in self.cull.iter_mut().zip(instances) {
1210            ci.gpu.inv_rot0 = inst.transform.inv_rot[0];
1211            ci.gpu.inv_rot1 = inst.transform.inv_rot[1];
1212            ci.gpu.inv_rot2 = inst.transform.inv_rot[2];
1213            ci.gpu.pos = inst.transform.pos;
1214            // TV: material id + alpha multiplier ride the same coalesced
1215            // update as the pose (set via the facade's per-instance setters).
1216            ci.gpu.material = u32::from(inst.material);
1217            ci.gpu.alpha_mul = f32::from(inst.alpha_mul) / 255.0;
1218            // XS.4 shadow flags + per-instance RGB tint also ride this flush,
1219            // so `set_dyn_instance_tint` (and any flag change) takes effect.
1220            ci.gpu.flags = inst.flags;
1221            ci.gpu.tint = inst.tint;
1222            // Bounding sphere follows the pivot; radius/chain unchanged.
1223            ci.center = inst.transform.pos;
1224        }
1225    }
1226
1227    /// Repoint instance `idx` at a different LOD chain — the per-frame
1228    /// **flipbook** step for animated voxel clips (VCL.2). The instance's
1229    /// transform / colmul are untouched; only which model's volume it
1230    /// draws changes. The new chain's volume must already be resident
1231    /// (uploaded via [`Self::add_model`] / [`Self::upload`]); `registry`
1232    /// is the one those uploads used (so the bounding radius reseeds from
1233    /// the new model). Like [`Self::update_transforms`], this is a CPU-side
1234    /// rewrite — the next [`Self::cull_bin_upload`] re-uploads the packed
1235    /// visible subset, so it costs nothing extra on the GPU. No-op if `idx`
1236    /// is out of range.
1237    ///
1238    /// All frames of a clip share the same `dims`, so a flipbook swap
1239    /// leaves the bounding radius unchanged; reseeding it anyway keeps the
1240    /// method correct for arbitrary chain swaps.
1241    pub fn set_instance_model(
1242        &mut self,
1243        registry: &SpriteModelRegistry,
1244        idx: usize,
1245        chain_id: u32,
1246    ) {
1247        // Guard `chain_id` (the `cull.get_mut` below only covers `idx`): a
1248        // public caller could pass an out-of-range / tombstoned chain, which
1249        // `registry.model` would index-panic on.
1250        let Some(radius) = registry
1251            .model_checked(chain_id)
1252            .map(SpriteModel::bound_radius)
1253        else {
1254            return;
1255        };
1256        let Some(ci) = self.cull.get_mut(idx) else {
1257            return;
1258        };
1259        ci.chain_id = chain_id;
1260        ci.gpu.model_id = chain_id; // placeholder; cull rewrites to the LOD entry
1261        ci.radius = radius;
1262    }
1263
1264    /// GPU.12 incremental — re-upload only the entries of LOD chain
1265    /// `chain_id` after an in-place edit (carve / recolour) of its model,
1266    /// **without** rebuilding the whole registry. `registry` must be the
1267    /// same registry uploaded (same entry ids), with chain `chain_id`'s
1268    /// entries already edited (`model_mut` + `rebuild_lod`).
1269    ///
1270    /// For each entry: occupancy + color_offsets are dims-fixed, so they
1271    /// are written in place; colors + dirs (variable, parallel) go through
1272    /// the suballocator — written in place when they fit the slack,
1273    /// relocated (with a `model_meta` rewrite) when they outgrow it, and
1274    /// only when the buffer tail overflows are colors/dirs grown + the
1275    /// whole registry repacked. Instances / cull / colmul are untouched
1276    /// (a carve never moves an instance or grows its bounds) — that is the
1277    /// win over [`Self::upload`].
1278    ///
1279    /// # Panics (debug)
1280    /// If an entry's dims changed (occupancy / color_offsets length), which
1281    /// the in-place path can't absorb — growing dims needs a full
1282    /// re-upload via [`Self::upload`].
1283    pub fn update_model(
1284        &mut self,
1285        device: &wgpu::Device,
1286        queue: &wgpu::Queue,
1287        registry: &SpriteModelRegistry,
1288        chain_id: u32,
1289    ) {
1290        let entries = self.chains[chain_id as usize].clone();
1291        let mut grew = false;
1292        for &e in &entries {
1293            let e = e as usize;
1294            let m = &registry.entries[e];
1295
1296            // Dims-fixed arrays: assert unchanged, then write in place.
1297            debug_assert_eq!(
1298                m.occupancy.len() as u32,
1299                self.occ_lens[e],
1300                "update_model: entry {e} occupancy length changed (dims grew?)"
1301            );
1302            debug_assert_eq!(
1303                m.color_offsets.len() as u32,
1304                self.coloff_lens[e],
1305                "update_model: entry {e} color_offsets length changed (dims grew?)"
1306            );
1307            queue.write_buffer(
1308                &self.occupancy,
1309                u64::from(self.meta[e].occupancy_offset) * 4,
1310                bytemuck::cast_slice(&m.occupancy),
1311            );
1312            queue.write_buffer(
1313                &self.color_offsets,
1314                u64::from(self.meta[e].color_offsets_offset) * 4,
1315                bytemuck::cast_slice(&m.color_offsets),
1316            );
1317
1318            // Variable colors/dirs via the suballocator.
1319            let new_len = m.colors.len() as u32;
1320            match self.colors_alloc.place(e, new_len) {
1321                Some(off) => {
1322                    queue.write_buffer(
1323                        &self.colors,
1324                        u64::from(off) * 4,
1325                        bytemuck::cast_slice(&m.colors),
1326                    );
1327                    queue.write_buffer(
1328                        &self.dirs,
1329                        u64::from(off) * 4,
1330                        bytemuck::cast_slice(&m.dirs),
1331                    );
1332                    let mats: Vec<u32> = m.materials.iter().map(|&x| u32::from(x)).collect();
1333                    queue.write_buffer(
1334                        &self.materials_vox,
1335                        u64::from(off) * 4,
1336                        bytemuck::cast_slice(&mats),
1337                    );
1338                    if self.meta[e].colors_offset != off {
1339                        // Relocated — rewrite this entry's meta record.
1340                        self.meta[e].colors_offset = off;
1341                        queue.write_buffer(
1342                            &self.model_meta,
1343                            (e * std::mem::size_of::<SpriteModelMeta>()) as u64,
1344                            bytemuck::bytes_of(&self.meta[e]),
1345                        );
1346                    }
1347                }
1348                None => grew = true,
1349            }
1350        }
1351
1352        // Buffer overflow on at least one entry → grow colors/dirs and
1353        // repack the WHOLE registry (rare; offsets for every entry move).
1354        if grew {
1355            self.grow_and_repack(device, queue, registry);
1356        }
1357    }
1358
1359    /// Grow the `colors`/`dirs` buffers and repack every entry compactly
1360    /// (with fresh slack) when an [`Self::update_model`] edit overflowed
1361    /// the buffer tail. Recreates both buffers (the next frame's bind
1362    /// group picks up the new handles) and rewrites every `model_meta`
1363    /// `colors_offset`. O(registry) but rare — logged so a growth burst
1364    /// is visible.
1365    fn grow_and_repack(
1366        &mut self,
1367        device: &wgpu::Device,
1368        queue: &wgpu::Queue,
1369        registry: &SpriteModelRegistry,
1370    ) {
1371        self.repack_colors_dirs(device, registry);
1372        // Every entry's colors_offset moved → rewrite the whole meta table.
1373        queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1374    }
1375
1376    /// Repack `colors`/`dirs` compactly (with fresh slack) from the full
1377    /// `registry`, recreating both buffers and updating every CPU
1378    /// `meta[e].colors_offset`. Does **not** touch the GPU `model_meta`
1379    /// buffer — the caller writes it ([`Self::grow_and_repack`] writes the
1380    /// whole table; [`Self::add_model`] writes it once after all entries
1381    /// are placed). O(registry) but rare — logged so a growth burst is
1382    /// visible.
1383    fn repack_colors_dirs(&mut self, device: &wgpu::Device, registry: &SpriteModelRegistry) {
1384        // Dead (removed) entries collapse to 0 length so they reclaim no
1385        // space; live entries keep their colours.
1386        let new_lens: Vec<u32> = registry
1387            .entries
1388            .iter()
1389            .enumerate()
1390            .map(|(e, m)| {
1391                if self.dead[e] {
1392                    0
1393                } else {
1394                    m.colors.len() as u32
1395                }
1396            })
1397            .collect();
1398        self.colors_alloc.repack(&new_lens);
1399        let cap_total = self.colors_alloc.cap_total();
1400
1401        let mut all_colors = vec![0u32; cap_total as usize];
1402        let mut all_dirs = vec![0u32; cap_total as usize];
1403        let mut all_materials = vec![0u32; cap_total as usize];
1404        for (e, m) in registry.entries.iter().enumerate() {
1405            if self.dead[e] {
1406                self.meta[e].colors_offset = 0;
1407                continue;
1408            }
1409            let off = self.colors_alloc.slot(e).off as usize;
1410            all_colors[off..off + m.colors.len()].copy_from_slice(&m.colors);
1411            all_dirs[off..off + m.dirs.len()].copy_from_slice(&m.dirs);
1412            for (i, &mat) in m.materials.iter().enumerate() {
1413                all_materials[off + i] = u32::from(mat);
1414            }
1415            self.meta[e].colors_offset = off as u32;
1416        }
1417        self.colors = storage_dst_u32_cap(
1418            device,
1419            "roxlap-gpu sprite_reg.colors",
1420            &all_colors,
1421            cap_total,
1422        );
1423        self.dirs = storage_dst_u32_cap(device, "roxlap-gpu sprite_reg.dirs", &all_dirs, cap_total);
1424        self.materials_vox = storage_dst_u32_cap(
1425            device,
1426            "roxlap-gpu sprite_reg.materials_vox",
1427            &all_materials,
1428            cap_total,
1429        );
1430        eprintln!(
1431            "roxlap-gpu: sprite registry colors/dirs/materials grew + repacked to {cap_total} words"
1432        );
1433    }
1434
1435    /// Append a new model (its full LOD chain) to the resident registry
1436    /// **without** re-uploading the existing models' volumes — the
1437    /// incremental counterpart to a full [`Self::upload`], for streaming
1438    /// in new geometry (unique asteroids, generated meshes).
1439    ///
1440    /// Contract (mirrors [`Self::update_model`]): the caller owns the
1441    /// `SpriteModelRegistry`, has just appended this chain to it (e.g. via
1442    /// [`SpriteModelRegistry::add_lod`]), and passes the resulting
1443    /// `chain_id`. The chain's entries must be the registry's newest (ids
1444    /// `>= ` the resident entry count) — entries are append-only.
1445    ///
1446    /// The large `colors`/`dirs`/`occupancy`/`color_offsets` buffers carry
1447    /// slack and bump-append the new entries in place; a buffer that
1448    /// overflows is grown (with slack) and rebuilt once from the registry
1449    /// (amortised O(1) per add). The small `model_meta` table is rewritten
1450    /// each call. After this, [`Self::append_instances`] can reference the
1451    /// new `chain_id`.
1452    pub fn add_model(
1453        &mut self,
1454        device: &wgpu::Device,
1455        queue: &wgpu::Queue,
1456        registry: &SpriteModelRegistry,
1457        chain_id: u32,
1458    ) {
1459        let entries = registry.chains[chain_id as usize].clone();
1460        debug_assert_eq!(
1461            chain_id as usize,
1462            self.chains.len(),
1463            "add_model: chains must be appended in order"
1464        );
1465
1466        // CPU bookkeeping: assign each new entry a tight occ/coloff offset
1467        // and an allocator slot for colors/dirs. `need_colors_grow` marks
1468        // a slot that didn't fit → a colors/dirs repack below.
1469        let mut need_colors_grow = false;
1470        for &e in &entries {
1471            let e = e as usize;
1472            debug_assert_eq!(
1473                e,
1474                self.meta.len(),
1475                "add_model: entries must be appended in order"
1476            );
1477            let m = &registry.entries[e];
1478            let occ_off = self.occ_used;
1479            let coloff_off = self.coloff_used;
1480            self.occ_used += m.occupancy.len() as u32;
1481            self.coloff_used += m.color_offsets.len() as u32;
1482            let colors_off = match self.colors_alloc.push(m.colors.len() as u32) {
1483                Some(off) => off,
1484                None => {
1485                    need_colors_grow = true;
1486                    0 // placeholder; repack assigns the real offset
1487                }
1488            };
1489            self.meta.push(SpriteModelMeta {
1490                occupancy_offset: occ_off,
1491                colors_offset: colors_off,
1492                color_offsets_offset: coloff_off,
1493                occ_words_per_col: m.occ_words_per_col,
1494                dims: m.dims,
1495                has_vox_materials: u32::from(!m.materials.is_empty()),
1496                pivot: m.pivot,
1497                voxel_world_size: m.voxel_world_size,
1498            });
1499            self.occ_lens.push(m.occupancy.len() as u32);
1500            self.coloff_lens.push(m.color_offsets.len() as u32);
1501            self.dead.push(false);
1502        }
1503        self.chains.push(entries.clone());
1504
1505        // occupancy + color_offsets: grow+rebuild on overflow, else write
1506        // the new tails in place.
1507        self.sync_concat(device, queue, registry, &entries, ConcatBuf::Occupancy);
1508        self.sync_concat(device, queue, registry, &entries, ConcatBuf::ColorOffsets);
1509
1510        // colors/dirs: repack on overflow (rebuilds both + every CPU
1511        // colors_offset), else write the new entries at their slots.
1512        if need_colors_grow {
1513            self.repack_colors_dirs(device, registry);
1514        } else {
1515            for &e in &entries {
1516                let e = e as usize;
1517                let m = &registry.entries[e];
1518                let off = u64::from(self.meta[e].colors_offset) * 4;
1519                queue.write_buffer(&self.colors, off, bytemuck::cast_slice(&m.colors));
1520                queue.write_buffer(&self.dirs, off, bytemuck::cast_slice(&m.dirs));
1521                let mats: Vec<u32> = m.materials.iter().map(|&x| u32::from(x)).collect();
1522                queue.write_buffer(&self.materials_vox, off, bytemuck::cast_slice(&mats));
1523            }
1524        }
1525
1526        // model_meta: grow the record buffer if needed, then rewrite the
1527        // whole (small) table — covers both new records and any
1528        // colors_offset relocations from a repack.
1529        let count = self.meta.len() as u32;
1530        if count > self.meta_cap {
1531            self.meta_cap = grow_records(count);
1532            self.model_meta = storage_dst_pod_cap(
1533                device,
1534                "roxlap-gpu sprite_reg.model_meta",
1535                &self.meta,
1536                self.meta_cap,
1537            );
1538        } else {
1539            queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1540        }
1541    }
1542
1543    /// Sync one tightly-concatenated buffer (`occupancy` or
1544    /// `color_offsets`) after `add_model` appended `new_entries`: if the
1545    /// used length now exceeds capacity, grow (with slack) and rebuild the
1546    /// whole buffer from the registry; otherwise write just the appended
1547    /// tails at their offsets.
1548    fn sync_concat(
1549        &mut self,
1550        device: &wgpu::Device,
1551        queue: &wgpu::Queue,
1552        registry: &SpriteModelRegistry,
1553        new_entries: &[u32],
1554        which: ConcatBuf,
1555    ) {
1556        let (used, cap) = match which {
1557            ConcatBuf::Occupancy => (self.occ_used, self.occ_cap),
1558            ConcatBuf::ColorOffsets => (self.coloff_used, self.coloff_cap),
1559        };
1560        if used > cap {
1561            let new_cap = grow_words(used);
1562            let all: Vec<u32> = registry
1563                .entries
1564                .iter()
1565                .flat_map(|m| concat_data(m, which).iter().copied())
1566                .collect();
1567            let label = match which {
1568                ConcatBuf::Occupancy => "roxlap-gpu sprite_reg.occupancy",
1569                ConcatBuf::ColorOffsets => "roxlap-gpu sprite_reg.color_offsets",
1570            };
1571            let buf = storage_dst_u32_cap(device, label, &all, new_cap);
1572            match which {
1573                ConcatBuf::Occupancy => {
1574                    self.occupancy = buf;
1575                    self.occ_cap = new_cap;
1576                }
1577                ConcatBuf::ColorOffsets => {
1578                    self.color_offsets = buf;
1579                    self.coloff_cap = new_cap;
1580                }
1581            }
1582        } else {
1583            let target = match which {
1584                ConcatBuf::Occupancy => &self.occupancy,
1585                ConcatBuf::ColorOffsets => &self.color_offsets,
1586            };
1587            for &e in new_entries {
1588                let e = e as usize;
1589                let off = match which {
1590                    ConcatBuf::Occupancy => self.meta[e].occupancy_offset,
1591                    ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset,
1592                };
1593                queue.write_buffer(
1594                    target,
1595                    u64::from(off) * 4,
1596                    bytemuck::cast_slice(concat_data(&registry.entries[e], which)),
1597                );
1598            }
1599        }
1600    }
1601
1602    /// Number of removed-but-not-yet-compacted models (tombstoned chains).
1603    /// A caller streams `add_model` / `remove_model` and calls
1604    /// [`Self::compact`] once this (relative to [`Self::live_model_count`])
1605    /// crosses a threshold.
1606    #[must_use]
1607    pub fn dead_model_count(&self) -> usize {
1608        self.chains.iter().filter(|c| c.is_empty()).count()
1609    }
1610
1611    /// Number of live (non-removed) models.
1612    #[must_use]
1613    pub fn live_model_count(&self) -> usize {
1614        self.chains.iter().filter(|c| !c.is_empty()).count()
1615    }
1616
1617    /// Remove a model (tombstone its LOD chain) — the counterpart to
1618    /// [`Self::add_model`]. O(chain length): marks the chain's entries
1619    /// dead and frees their `colors`/`dirs` slots for reuse by a later
1620    /// `add_model`. The `occupancy` / `color_offsets` holes are **not**
1621    /// reclaimed until [`Self::compact`]; entry ids (and the caller's other
1622    /// `chain_id`s) stay stable.
1623    ///
1624    /// Instances of the removed chain are **not** dropped here — they
1625    /// linger in the cull set but draw as nothing (skipped in
1626    /// [`Self::cull_bin_upload`]); the caller removes them via
1627    /// [`Self::remove_instance`] when convenient. A no-op if `chain_id` is
1628    /// out of range or already removed.
1629    pub fn remove_model(&mut self, chain_id: u32) {
1630        let Some(entries) = self.chains.get(chain_id as usize).cloned() else {
1631            return;
1632        };
1633        if entries.is_empty() {
1634            return; // already removed
1635        }
1636        for &e in &entries {
1637            let e = e as usize;
1638            self.dead[e] = true;
1639            self.colors_alloc.free(e);
1640        }
1641        self.chains[chain_id as usize] = Vec::new(); // tombstone
1642    }
1643
1644    /// Reclaim the holes left by [`Self::remove_model`]: rebuild the shared
1645    /// volume buffers from the live entries only, dropping every dead
1646    /// entry's data. Entry ids and `chain_id`s are preserved (dead entries
1647    /// keep a zero-length `meta` tombstone), so the caller's handles stay
1648    /// valid and no remap is needed.
1649    ///
1650    /// `registry` must be the resident one (entry ids 1:1, as for
1651    /// [`Self::add_model`] / [`Self::update_model`]). O(live volume) —
1652    /// call it when [`Self::dead_model_count`] is high, not every frame.
1653    pub fn compact(
1654        &mut self,
1655        device: &wgpu::Device,
1656        queue: &wgpu::Queue,
1657        registry: &SpriteModelRegistry,
1658    ) {
1659        // occupancy + color_offsets: re-pack live entries tightly, rewrite
1660        // each live entry's meta offset, zero the dead ones.
1661        self.compact_concat(device, registry, ConcatBuf::Occupancy);
1662        self.compact_concat(device, registry, ConcatBuf::ColorOffsets);
1663        // colors/dirs: the dead-aware repack already drops dead entries.
1664        self.repack_colors_dirs(device, registry);
1665        // model_meta: rewrite the (unchanged-length) table with the new
1666        // offsets. Buffer count didn't change, so no grow needed.
1667        queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1668    }
1669
1670    /// Rebuild one tightly-concatenated buffer from live entries only
1671    /// (used by [`Self::compact`]): assign each live entry a fresh tight
1672    /// offset, zero dead entries' offset, and recreate the buffer with
1673    /// slack.
1674    fn compact_concat(
1675        &mut self,
1676        device: &wgpu::Device,
1677        registry: &SpriteModelRegistry,
1678        which: ConcatBuf,
1679    ) {
1680        let mut all: Vec<u32> = Vec::new();
1681        for e in 0..self.meta.len() {
1682            if self.dead[e] {
1683                match which {
1684                    ConcatBuf::Occupancy => self.meta[e].occupancy_offset = 0,
1685                    ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = 0,
1686                }
1687                continue;
1688            }
1689            let off = all.len() as u32;
1690            match which {
1691                ConcatBuf::Occupancy => self.meta[e].occupancy_offset = off,
1692                ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = off,
1693            }
1694            all.extend_from_slice(concat_data(&registry.entries[e], which));
1695        }
1696        let used = all.len() as u32;
1697        let cap = grow_words(used);
1698        let (label, buf) = match which {
1699            ConcatBuf::Occupancy => ("roxlap-gpu sprite_reg.occupancy", &mut self.occupancy),
1700            ConcatBuf::ColorOffsets => (
1701                "roxlap-gpu sprite_reg.color_offsets",
1702                &mut self.color_offsets,
1703            ),
1704        };
1705        *buf = storage_dst_u32_cap(device, label, &all, cap);
1706        match which {
1707            ConcatBuf::Occupancy => {
1708                self.occ_used = used;
1709                self.occ_cap = cap;
1710            }
1711            ConcatBuf::ColorOffsets => {
1712                self.coloff_used = used;
1713                self.coloff_cap = cap;
1714            }
1715        }
1716    }
1717
1718    /// GPU.10.3 — frustum-cull, pack the visible subset into the
1719    /// instance buffer, then bin those instances into screen tiles:
1720    /// project each visible bounding sphere to a screen AABB and append
1721    /// its (visible) index to every overlapped tile. Uploads the
1722    /// instance buffer + `tile_ranges` (per-tile offset/count) +
1723    /// `tile_instances` (flat grouped indices), growing the tile
1724    /// buffers as needed. Returns `(visible_count, tiles_x, tiles_y)`.
1725    #[allow(clippy::too_many_arguments)]
1726    pub fn cull_bin_upload(
1727        &mut self,
1728        device: &wgpu::Device,
1729        queue: &wgpu::Queue,
1730        f: &ViewFrustum,
1731        screen_w: u32,
1732        screen_h: u32,
1733        tile_size: u32,
1734        lod_px: f32,
1735    ) -> (u32, u32, u32) {
1736        let tiles_x = screen_w.div_ceil(tile_size).max(1);
1737        let tiles_y = screen_h.div_ceil(tile_size).max(1);
1738        let n_tiles = (tiles_x * tiles_y) as usize;
1739
1740        let nw = (1.0 + f.half_w * f.half_w).sqrt();
1741        let nh = (1.0 + f.half_h * f.half_h).sqrt();
1742        let cx = screen_w as f32 * 0.5;
1743        let cy = screen_h as f32 * 0.5;
1744        let px_per_world = cx / f.half_w; // isotropic: == cy/half_h
1745        let ts = tile_size as f32;
1746        let tx_max = tiles_x as i32 - 1;
1747        let ty_max = tiles_y as i32 - 1;
1748
1749        let mut visible: Vec<SpriteInstanceGpu> = Vec::with_capacity(self.cull.len());
1750        // Per-visible tile AABB (tx0, tx1, ty0, ty1) for the bin pass.
1751        let mut boxes: Vec<[i32; 4]> = Vec::with_capacity(self.cull.len());
1752        // Per-visible kv6colmul tables, flattened to two u32 per u64
1753        // entry (lanes 0|1, then 2|3), packed in visible order so the
1754        // shader indexes `colmul[inst_idx*512 + dir*2 + {0,1}]`.
1755        let mut visible_colmul: Vec<u32> = Vec::with_capacity(self.cull.len() * 512);
1756        let mut counts = vec![0u32; n_tiles];
1757
1758        for ci in &self.cull {
1759            // Skip instances of a removed model (tombstoned chain) — they
1760            // linger in `cull` until the caller drops them, but draw as
1761            // nothing.
1762            if self.chains[ci.chain_id as usize].is_empty() {
1763                continue;
1764            }
1765            let rel = [
1766                ci.center[0] - f.pos[0],
1767                ci.center[1] - f.pos[1],
1768                ci.center[2] - f.pos[2],
1769            ];
1770            let z = dot3(rel, f.forward);
1771            let r = ci.radius;
1772            if z + r < 0.0 || z - r > f.far {
1773                continue; // behind / beyond far
1774            }
1775            let x = dot3(rel, f.right);
1776            if (x - f.half_w * z) > r * nw || (-x - f.half_w * z) > r * nw {
1777                continue; // right / left
1778            }
1779            let y = dot3(rel, f.down);
1780            if (y - f.half_h * z) > r * nh || (-y - f.half_h * z) > r * nh {
1781                continue; // bottom / top
1782            }
1783
1784            // Visible: project the sphere to a screen AABB → tile range.
1785            let (tx0, tx1, ty0, ty1) = if z > 1e-3 {
1786                let sx = cx + (x / z) * px_per_world;
1787                let sy = cy + (y / z) * px_per_world;
1788                let sr = (r / z) * px_per_world;
1789                (
1790                    (((sx - sr) / ts).floor() as i32).clamp(0, tx_max),
1791                    (((sx + sr) / ts).floor() as i32).clamp(0, tx_max),
1792                    (((sy - sr) / ts).floor() as i32).clamp(0, ty_max),
1793                    (((sy + sr) / ts).floor() as i32).clamp(0, ty_max),
1794                )
1795            } else {
1796                (0, tx_max, 0, ty_max)
1797            };
1798            // GPU.10.4 — pick the LOD level by projected voxel size:
1799            // choose the coarsest level whose voxel still covers at
1800            // least `lod_px` screen pixels, i.e. step up once a mip-0
1801            // voxel would be smaller than that. `lod_px = 1` is the
1802            // natural "don't go sub-pixel" threshold; larger values
1803            // force LOD in closer (tuning/inspection).
1804            let chain = &self.chains[ci.chain_id as usize];
1805            let level = if z > 1e-3 && chain.len() > 1 {
1806                let voxel_px = px_per_world / z; // mip-0 voxel screen size
1807                ((lod_px / voxel_px).log2().ceil().max(0.0) as usize).min(chain.len() - 1)
1808            } else {
1809                0
1810            };
1811            let mut g = ci.gpu;
1812            g.model_id = chain[level];
1813            visible.push(g);
1814            boxes.push([tx0, tx1, ty0, ty1]);
1815            for &w in ci.colmul.iter() {
1816                visible_colmul.push((w & 0xffff_ffff) as u32);
1817                visible_colmul.push((w >> 32) as u32);
1818            }
1819            for ty in ty0..=ty1 {
1820                for tx in tx0..=tx1 {
1821                    counts[(ty * tiles_x as i32 + tx) as usize] += 1;
1822                }
1823            }
1824        }
1825
1826        if visible.is_empty() {
1827            return (0, tiles_x, tiles_y);
1828        }
1829
1830        // Prefix-sum counts → per-tile offsets; build the flat grouped
1831        // index list.
1832        let mut tile_ranges = vec![0u32; n_tiles * 2];
1833        let mut running = 0u32;
1834        for t in 0..n_tiles {
1835            tile_ranges[2 * t] = running; // offset
1836            tile_ranges[2 * t + 1] = counts[t]; // count
1837            running += counts[t];
1838        }
1839        let total = running as usize;
1840        let mut tile_instances = vec![0u32; total.max(1)];
1841        let mut cursor: Vec<u32> = (0..n_tiles).map(|t| tile_ranges[2 * t]).collect();
1842        for (vis_idx, b) in boxes.iter().enumerate() {
1843            for ty in b[2]..=b[3] {
1844                for tx in b[0]..=b[1] {
1845                    let t = (ty * tiles_x as i32 + tx) as usize;
1846                    tile_instances[cursor[t] as usize] = vis_idx as u32;
1847                    cursor[t] += 1;
1848                }
1849            }
1850        }
1851
1852        // Upload: instances + (grown) tile buffers. Grow a tile buffer
1853        // only when this frame needs more than its capacity (wgpu has
1854        // no Clone on Buffer, so we replace the field in place).
1855        queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(&visible));
1856        let need_ranges = tile_ranges.len() as u32;
1857        if need_ranges > self.tile_ranges_cap {
1858            self.tile_ranges_cap = need_ranges.next_power_of_two();
1859            self.tile_ranges = storage_dst_u32(
1860                device,
1861                "roxlap-gpu sprite_reg.tile_ranges",
1862                self.tile_ranges_cap,
1863            );
1864        }
1865        let need_inst = tile_instances.len() as u32;
1866        if need_inst > self.tile_instances_cap {
1867            self.tile_instances_cap = need_inst.next_power_of_two();
1868            self.tile_instances = storage_dst_u32(
1869                device,
1870                "roxlap-gpu sprite_reg.tile_instances",
1871                self.tile_instances_cap,
1872            );
1873        }
1874        queue.write_buffer(&self.tile_ranges, 0, bytemuck::cast_slice(&tile_ranges));
1875        queue.write_buffer(
1876            &self.tile_instances,
1877            0,
1878            bytemuck::cast_slice(&tile_instances),
1879        );
1880        let need_colmul = visible_colmul.len() as u32;
1881        if need_colmul > self.colmul_cap {
1882            self.colmul_cap = need_colmul.next_power_of_two();
1883            self.colmul = storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", self.colmul_cap);
1884        }
1885        queue.write_buffer(&self.colmul, 0, bytemuck::cast_slice(&visible_colmul));
1886
1887        (visible.len() as u32, tiles_x, tiles_y)
1888    }
1889}
1890
1891/// GPU.12 incremental — per-entry placement of one model's `colors`
1892/// (and the parallel `dirs`) within the shared registry buffers: a
1893/// `[off, off+cap)` word window holding `len` live words. `cap >= len`
1894/// gives slack so a carve that *grows* the surface-voxel count can be
1895/// rewritten in place without relocating.
1896#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1897struct ColorSlot {
1898    off: u32,
1899    cap: u32,
1900    len: u32,
1901}
1902
1903/// First-fit suballocator over the parallel `colors`/`dirs` buffers
1904/// (same offsets/ranks → one allocator drives both). Each registry
1905/// entry owns a [`ColorSlot`]; growth past a slot's `cap` relocates it
1906/// (freeing the old block) via the free list or a bump tail, and only
1907/// when the tail would exceed `cap_total` does the caller grow + repack
1908/// the whole buffer. Pure (no GPU) so it unit-tests on its own.
1909#[derive(Debug, Default)]
1910struct ColorsAllocator {
1911    /// Per-entry slot, indexed by entry id.
1912    slots: Vec<ColorSlot>,
1913    /// Freed `(off, cap)` blocks available for first-fit reuse.
1914    free: Vec<(u32, u32)>,
1915    /// Next bump-allocation position (words).
1916    tail: u32,
1917    /// Total buffer capacity in words.
1918    cap_total: u32,
1919}
1920
1921/// Slack-padded capacity for a `len`-word array: +25% + 16 words, so a
1922/// few extra surface voxels from a carve fit without relocating.
1923fn slot_cap(len: u32) -> u32 {
1924    len + len / 4 + 16
1925}
1926
1927/// Slack capacity (words) for a grown concatenated buffer: +50% + 256, so
1928/// a burst of `add_model` calls bump-appends rather than re-growing every
1929/// time. Matches [`ColorsAllocator`]'s `cap_total` headroom.
1930fn grow_words(used: u32) -> u32 {
1931    used + used / 2 + 256
1932}
1933
1934/// Slack capacity (records) for a grown `model_meta` buffer: +50% + 8.
1935fn grow_records(count: u32) -> u32 {
1936    count + count / 2 + 8
1937}
1938
1939impl ColorsAllocator {
1940    /// Lay every entry out contiguously (with per-slot slack) and add a
1941    /// global tail headroom so early growth bump-allocates rather than
1942    /// repacks.
1943    fn new(entry_lens: &[u32]) -> Self {
1944        let mut a = Self::default();
1945        a.repack(entry_lens);
1946        a
1947    }
1948
1949    fn slot(&self, entry: usize) -> ColorSlot {
1950        self.slots[entry]
1951    }
1952
1953    fn cap_total(&self) -> u32 {
1954        self.cap_total
1955    }
1956
1957    /// Repack ALL entries compactly to fit `new_lens`, resetting the
1958    /// free list + tail and choosing a fresh `cap_total` with headroom.
1959    /// Used at initial build and on a buffer grow.
1960    fn repack(&mut self, new_lens: &[u32]) {
1961        self.free.clear();
1962        let mut off = 0u32;
1963        let mut slots = Vec::with_capacity(new_lens.len());
1964        for &len in new_lens {
1965            // A 0-length (dead / removed) entry takes no space — keeps a
1966            // tombstone slot so entry ids stay positional.
1967            let cap = if len == 0 { 0 } else { slot_cap(len) };
1968            slots.push(ColorSlot { off, cap, len });
1969            off += cap;
1970        }
1971        self.slots = slots;
1972        self.tail = off;
1973        // Global headroom: +50% + 256 words.
1974        self.cap_total = off + off / 2 + 256;
1975    }
1976
1977    /// Place `new_len` words for `entry`. Returns `Some(off)` with the
1978    /// (possibly relocated) slot offset, or `None` if the buffer must
1979    /// grow + repack. On relocation the old block is pushed to the free
1980    /// list; an in-place fit returns the unchanged offset.
1981    fn place(&mut self, entry: usize, new_len: u32) -> Option<u32> {
1982        let cur = self.slots[entry];
1983        if new_len <= cur.cap {
1984            self.slots[entry] = ColorSlot {
1985                len: new_len,
1986                ..cur
1987            };
1988            return Some(cur.off);
1989        }
1990        let old = (cur.off, cur.cap);
1991        // First-fit a freed block big enough for the live data.
1992        if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
1993            let (off, cap) = self.free.remove(i);
1994            self.free.push(old);
1995            self.slots[entry] = ColorSlot {
1996                off,
1997                cap,
1998                len: new_len,
1999            };
2000            return Some(off);
2001        }
2002        // Bump the tail if there's room.
2003        let want = slot_cap(new_len);
2004        if self.tail + want <= self.cap_total {
2005            let off = self.tail;
2006            self.tail += want;
2007            self.free.push(old);
2008            self.slots[entry] = ColorSlot {
2009                off,
2010                cap: want,
2011                len: new_len,
2012            };
2013            return Some(off);
2014        }
2015        None
2016    }
2017
2018    /// Append a slot for a brand-new entry of `new_len` words (used by
2019    /// [`SpriteRegistryResident::add_model`]). Returns `Some(off)` placed
2020    /// via the free list or the bump tail, or `None` if the buffer must
2021    /// grow + repack — in which case **no** slot is pushed (the caller's
2022    /// repack rebuilds every slot from scratch).
2023    fn push(&mut self, new_len: u32) -> Option<u32> {
2024        if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
2025            let (off, cap) = self.free.remove(i);
2026            self.slots.push(ColorSlot {
2027                off,
2028                cap,
2029                len: new_len,
2030            });
2031            return Some(off);
2032        }
2033        let want = slot_cap(new_len);
2034        if self.tail + want <= self.cap_total {
2035            let off = self.tail;
2036            self.tail += want;
2037            self.slots.push(ColorSlot {
2038                off,
2039                cap: want,
2040                len: new_len,
2041            });
2042            return Some(off);
2043        }
2044        None
2045    }
2046
2047    /// Free `entry`'s slot back to the pool ([`SpriteRegistryResident::
2048    /// remove_model`]). Its `(off, cap)` block joins the free list for
2049    /// first-fit reuse by a later [`Self::push`]; the slot is zeroed so a
2050    /// repack treats it as a 0-length tombstone.
2051    fn free(&mut self, entry: usize) {
2052        let s = self.slots[entry];
2053        if s.cap > 0 {
2054            self.free.push((s.off, s.cap));
2055        }
2056        self.slots[entry] = ColorSlot {
2057            off: 0,
2058            cap: 0,
2059            len: 0,
2060        };
2061    }
2062}
2063
2064/// Create a STORAGE buffer of u32s; pads empty input (wgpu rejects
2065/// zero-sized storage bindings).
2066#[allow(dead_code)]
2067fn storage_u32(device: &wgpu::Device, label: &str, data: &[u32]) -> wgpu::Buffer {
2068    use wgpu::util::DeviceExt;
2069    let bytes: &[u8] = if data.is_empty() {
2070        bytemuck::cast_slice(&[0u32])
2071    } else {
2072        bytemuck::cast_slice(data)
2073    };
2074    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2075        label: Some(label),
2076        contents: bytes,
2077        usage: wgpu::BufferUsages::STORAGE,
2078    })
2079}
2080
2081/// Create an uninitialised `STORAGE | COPY_DST` `u32` buffer of `cap`
2082/// words (≥1). Written each frame via `queue.write_buffer`.
2083fn storage_dst_u32(device: &wgpu::Device, label: &str, cap: u32) -> wgpu::Buffer {
2084    device.create_buffer(&wgpu::BufferDescriptor {
2085        label: Some(label),
2086        size: u64::from(cap.max(1)) * 4,
2087        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2088        mapped_at_creation: false,
2089    })
2090}
2091
2092/// Create a `STORAGE | COPY_DST` `u32` buffer of `cap` words (≥ data
2093/// length, ≥ 1), initialised with `data` at offset 0 and the tail left
2094/// zeroed. Unlike [`storage_u32`] (STORAGE-only, exact-size) this both
2095/// reserves spare capacity and is `COPY_DST`, so the incremental
2096/// [`SpriteRegistryResident::update_model`] can `write_buffer` a growing
2097/// `colors`/`dirs` array in place. Filled via `mapped_at_creation` so no
2098/// queue is needed at upload time.
2099fn storage_dst_u32_cap(device: &wgpu::Device, label: &str, data: &[u32], cap: u32) -> wgpu::Buffer {
2100    let cap = cap.max(data.len() as u32).max(1);
2101    let buf = device.create_buffer(&wgpu::BufferDescriptor {
2102        label: Some(label),
2103        size: u64::from(cap) * 4,
2104        usage: wgpu::BufferUsages::STORAGE
2105            | wgpu::BufferUsages::COPY_DST
2106            | wgpu::BufferUsages::COPY_SRC,
2107        mapped_at_creation: true,
2108    });
2109    if !data.is_empty() {
2110        buf.slice(..(data.len() as u64 * 4))
2111            .get_mapped_range_mut()
2112            .copy_from_slice(bytemuck::cast_slice(data));
2113    }
2114    buf.unmap();
2115    buf
2116}
2117
2118/// Create a `STORAGE | COPY_DST` buffer of Pod records, exact-size
2119/// (≥ 1, zero-padded), so individual records can be rewritten in place
2120/// by [`SpriteRegistryResident::update_model`] on a relocation. The
2121/// record *count* never changes on an incremental edit (no model is
2122/// added/removed), so no slack is needed here.
2123fn storage_dst_pod<T: Pod + Zeroable>(
2124    device: &wgpu::Device,
2125    label: &str,
2126    data: &[T],
2127) -> wgpu::Buffer {
2128    let one = [T::zeroed()];
2129    let src: &[T] = if data.is_empty() { &one } else { data };
2130    let buf = device.create_buffer(&wgpu::BufferDescriptor {
2131        label: Some(label),
2132        size: std::mem::size_of_val(src) as u64,
2133        usage: wgpu::BufferUsages::STORAGE
2134            | wgpu::BufferUsages::COPY_DST
2135            | wgpu::BufferUsages::COPY_SRC,
2136        mapped_at_creation: true,
2137    });
2138    buf.slice(..)
2139        .get_mapped_range_mut()
2140        .copy_from_slice(bytemuck::cast_slice(src));
2141    buf.unmap();
2142    buf
2143}
2144
2145/// Create a `STORAGE | COPY_DST` Pod buffer holding `cap` records
2146/// (≥ `data.len()`, ≥ 1), initialised with `data` at record 0 and the
2147/// tail zeroed. The slack lets [`SpriteRegistryResident::add_model`] grow
2148/// the `model_meta` table without re-growing on every add.
2149fn storage_dst_pod_cap<T: Pod + Zeroable>(
2150    device: &wgpu::Device,
2151    label: &str,
2152    data: &[T],
2153    cap: u32,
2154) -> wgpu::Buffer {
2155    let rec = std::mem::size_of::<T>() as u64;
2156    let cap = u64::from(cap.max(data.len() as u32).max(1));
2157    let buf = device.create_buffer(&wgpu::BufferDescriptor {
2158        label: Some(label),
2159        size: cap * rec,
2160        usage: wgpu::BufferUsages::STORAGE
2161            | wgpu::BufferUsages::COPY_DST
2162            | wgpu::BufferUsages::COPY_SRC,
2163        mapped_at_creation: true,
2164    });
2165    if !data.is_empty() {
2166        buf.slice(..(data.len() as u64 * rec))
2167            .get_mapped_range_mut()
2168            .copy_from_slice(bytemuck::cast_slice(data));
2169    }
2170    buf.unmap();
2171    buf
2172}
2173
2174/// Create a STORAGE buffer of Pod records; pads empty input with one
2175/// zeroed `T`.
2176#[allow(dead_code)]
2177fn storage_pod<T: Pod + Zeroable>(device: &wgpu::Device, label: &str, data: &[T]) -> wgpu::Buffer {
2178    use wgpu::util::DeviceExt;
2179    let one = [T::zeroed()];
2180    let src: &[T] = if data.is_empty() { &one } else { data };
2181    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2182        label: Some(label),
2183        contents: bytemuck::cast_slice(src),
2184        usage: wgpu::BufferUsages::STORAGE,
2185    })
2186}
2187
2188#[cfg(test)]
2189mod tests {
2190    use super::*;
2191    use roxlap_formats::kv6::{Kv6, Voxel};
2192
2193    /// 2×1 kv6: column (0,0) has voxels at z=5 (red) and z=1 (green)
2194    /// stored OUT of z-order; column (1,0) has one voxel at z=3.
2195    fn kv6_unsorted() -> Kv6 {
2196        let mk = |z, col| Voxel {
2197            col,
2198            z,
2199            vis: 0,
2200            dir: 0,
2201        };
2202        Kv6 {
2203            xsiz: 2,
2204            ysiz: 1,
2205            zsiz: 8,
2206            xpiv: 0.0,
2207            ypiv: 0.0,
2208            zpiv: 0.0,
2209            voxels: vec![mk(5, 0xAA), mk(1, 0xBB), mk(3, 0xCC)],
2210            xlen: vec![2, 1],
2211            ylen: vec![vec![2], vec![1]],
2212            palette: None,
2213        }
2214    }
2215
2216    #[test]
2217    fn occupancy_bits_set_at_voxel_z() {
2218        let m = build_sprite_model(&kv6_unsorted());
2219        assert_eq!(m.dims, [2, 1, 8]);
2220        assert_eq!(m.occ_words_per_col, 1); // ceil(8/32)
2221                                            // col 0: bits 1 and 5; col 1: bit 3.
2222        assert_eq!(m.occupancy[0], (1 << 1) | (1 << 5));
2223        assert_eq!(m.occupancy[1], 1 << 3);
2224    }
2225
2226    #[test]
2227    fn colors_are_ascending_z_for_rank_lookup() {
2228        let m = build_sprite_model(&kv6_unsorted());
2229        // col 0 sorted ascending z ⇒ z=1 (green 0xBB) before z=5 (0xAA).
2230        assert_eq!(m.color_offsets, vec![0, 2, 3]);
2231        assert_eq!(&m.colors, &[0xBB, 0xAA, 0xCC]);
2232    }
2233
2234    #[test]
2235    fn identity_basis_inverts_to_identity() {
2236        let inv = mat3_inverse([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
2237        assert_eq!(inv, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
2238    }
2239
2240    #[test]
2241    fn fork_is_independent_of_parent() {
2242        let mut reg = SpriteModelRegistry::new();
2243        let base = reg.add(build_sprite_model(&kv6_unsorted()));
2244        let forked = reg.fork(base);
2245        assert_ne!(base, forked);
2246        // Recolour only the fork.
2247        reg.model_mut(forked).recolor(|_| 0x11);
2248        // Parent colours untouched; fork fully overwritten.
2249        assert_eq!(&reg.model(base).colors, &[0xBB, 0xAA, 0xCC]);
2250        assert_eq!(&reg.model(forked).colors, &[0x11, 0x11, 0x11]);
2251    }
2252
2253    #[test]
2254    fn remove_frees_chain_data_keeps_ids_stable() {
2255        let mut reg = SpriteModelRegistry::new();
2256        let a = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2257        let b = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2258        let len_before = reg.len();
2259        assert!(reg.is_live(a) && reg.is_live(b));
2260
2261        reg.remove(a);
2262        // Chain `a` is tombstoned (its entries are freed to empty models;
2263        // they're unreachable via `model()` now — that's the tombstone).
2264        assert!(!reg.is_live(a));
2265        // `b` is untouched and still live; `len()` (next id) is unchanged.
2266        assert!(reg.is_live(b));
2267        assert_eq!(&reg.model(b).colors, &[0xBB, 0xAA, 0xCC]);
2268        assert_eq!(reg.len(), len_before);
2269
2270        // A later add mints a fresh id past the tombstone (no slot reuse).
2271        let c = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2272        assert_eq!(c, len_before as u32);
2273        assert!(reg.is_live(c));
2274        // `b`'s id stayed valid across the remove + add round-trip.
2275        assert_eq!(&reg.model(b).colors, &[0xBB, 0xAA, 0xCC]);
2276    }
2277
2278    #[test]
2279    fn model_checked_guards_out_of_range_and_tombstoned() {
2280        // The guard `set_instance_model` relies on: `model()` would
2281        // index-panic on these, `model_checked` returns `None`.
2282        let mut reg = SpriteModelRegistry::new();
2283        let a = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2284        assert!(reg.model_checked(a).is_some());
2285        assert!(reg.model_checked(9999).is_none(), "out of range → None");
2286        reg.remove(a);
2287        assert!(reg.model_checked(a).is_none(), "tombstoned chain → None");
2288    }
2289
2290    #[test]
2291    fn remove_is_idempotent_and_bounds_safe() {
2292        let mut reg = SpriteModelRegistry::new();
2293        let a = reg.add(build_sprite_model(&kv6_unsorted()));
2294        reg.remove(a);
2295        reg.remove(a); // already removed → no-op, no panic
2296        reg.remove(999); // out of range → no-op
2297        assert!(!reg.is_live(a));
2298        assert!(!reg.is_live(999));
2299    }
2300
2301    #[test]
2302    fn registry_gpu_structs_have_expected_sizes() {
2303        assert_eq!(std::mem::size_of::<SpriteModelMeta>(), 48);
2304        // TV — grew 64 → 80 with the per-instance material id + alpha_mul
2305        // (+ 8 bytes pad to keep the 16-byte std430 stride).
2306        assert_eq!(std::mem::size_of::<SpriteInstanceGpu>(), 80);
2307    }
2308
2309    #[test]
2310    fn add_lod_builds_halving_mip_chain() {
2311        let mut reg = SpriteModelRegistry::new();
2312        // 8×8×8 single voxel-filled column model would be ideal, but
2313        // kv6_unsorted is 2×1×8 → mips: 2×1×8 → 1×1×4 → 1×1×2 → 1×1×1.
2314        let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2315        let m0 = reg.model(id);
2316        assert_eq!(m0.dims, [2, 1, 8]);
2317        assert!((m0.voxel_world_size - 1.0).abs() < 1e-6);
2318    }
2319
2320    /// kv6 from explicit voxels, ordered x-major/y-inner to match
2321    /// `build_sprite_model`'s column walk.
2322    fn kv6_from(xsiz: u32, ysiz: u32, zsiz: u32, voxels: &[(u32, u32, u16, u32)]) -> Kv6 {
2323        let mut ylen = vec![vec![0u16; ysiz as usize]; xsiz as usize];
2324        let mut flat = Vec::new();
2325        for x in 0..xsiz {
2326            for y in 0..ysiz {
2327                let mut col: Vec<(u16, u32)> = voxels
2328                    .iter()
2329                    .filter(|(vx, vy, _, _)| *vx == x && *vy == y)
2330                    .map(|(_, _, z, c)| (*z, *c))
2331                    .collect();
2332                col.sort_by_key(|(z, _)| *z);
2333                ylen[x as usize][y as usize] = col.len() as u16;
2334                for (z, c) in col {
2335                    flat.push(Voxel {
2336                        col: c,
2337                        z,
2338                        vis: 0,
2339                        dir: 0,
2340                    });
2341                }
2342            }
2343        }
2344        let xlen = ylen
2345            .iter()
2346            .map(|c| c.iter().map(|&v| u32::from(v)).sum())
2347            .collect();
2348        Kv6 {
2349            xsiz,
2350            ysiz,
2351            zsiz,
2352            xpiv: 0.0,
2353            ypiv: 0.0,
2354            zpiv: 0.0,
2355            voxels: flat,
2356            xlen,
2357            ylen,
2358            palette: None,
2359        }
2360    }
2361
2362    fn offsets_consistent(m: &SpriteModel) -> bool {
2363        let cols = (m.dims[0] * m.dims[1]) as usize;
2364        if m.color_offsets.len() != cols + 1 {
2365            return false;
2366        }
2367        // Monotonic non-decreasing + last == colors.len + each column's
2368        // span == its solid-voxel count.
2369        for w in m.color_offsets.windows(2) {
2370            if w[1] < w[0] {
2371                return false;
2372            }
2373        }
2374        m.color_offsets[cols] as usize == m.colors.len()
2375    }
2376
2377    #[test]
2378    fn carve_two_layers_keeps_offsets_consistent() {
2379        // Mirror the demo's carve: columns with voxels at varied z,
2380        // some sharing z=0/z=1, some not.
2381        let kv6 = kv6_from(
2382            3,
2383            2,
2384            8,
2385            &[
2386                (0, 0, 0, 0xA0),
2387                (0, 0, 1, 0xA1),
2388                (0, 0, 5, 0xA5),
2389                (1, 0, 1, 0xB1),
2390                (2, 1, 0, 0xC0),
2391                (2, 1, 3, 0xC3),
2392            ],
2393        );
2394        let mut m = build_sprite_model(&kv6);
2395        assert!(offsets_consistent(&m));
2396        for z in 0..2u32 {
2397            for y in 0..m.dims[1] {
2398                for x in 0..m.dims[0] {
2399                    m.set_voxel(x, y, z, None);
2400                }
2401            }
2402            assert!(offsets_consistent(&m), "inconsistent after carving z={z}");
2403            // downsample must not panic on the carved model.
2404            let _ = m.downsample();
2405        }
2406    }
2407
2408    #[test]
2409    fn set_voxel_inserts_replaces_and_clears() {
2410        // col 0 starts with z=1 (0xBB), z=5 (0xAA); col 1 with z=3 (0xCC).
2411        let mut m = build_sprite_model(&kv6_unsorted());
2412
2413        // Insert z=3 into col 0 (between z=1 and z=5) → rank 1.
2414        assert!(m.set_voxel(0, 0, 3, Some(0x55)));
2415        assert_eq!(m.occupancy[0], (1 << 1) | (1 << 3) | (1 << 5));
2416        // col 0 colours ascending z: 0xBB(z1), 0x55(z3), 0xAA(z5).
2417        assert_eq!(m.color_offsets, vec![0, 3, 4]);
2418        assert_eq!(&m.colors, &[0xBB, 0x55, 0xAA, 0xCC]);
2419
2420        // Replace z=3 in place (no offset shift).
2421        assert!(m.set_voxel(0, 0, 3, Some(0x66)));
2422        assert_eq!(&m.colors, &[0xBB, 0x66, 0xAA, 0xCC]);
2423        assert_eq!(m.color_offsets, vec![0, 3, 4]);
2424
2425        // Clear z=1 (rank 0) from col 0.
2426        assert!(m.set_voxel(0, 0, 1, None));
2427        assert_eq!(m.occupancy[0], (1 << 3) | (1 << 5));
2428        assert_eq!(m.color_offsets, vec![0, 2, 3]);
2429        assert_eq!(&m.colors, &[0x66, 0xAA, 0xCC]);
2430
2431        // No-ops: clear an empty voxel, edit out of bounds.
2432        assert!(!m.set_voxel(0, 0, 2, None));
2433        assert!(!m.set_voxel(9, 0, 0, Some(1)));
2434    }
2435
2436    #[test]
2437    fn rebuild_lod_refreshes_coarse_levels_from_mip0() {
2438        let mut reg = SpriteModelRegistry::new();
2439        let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 3);
2440        // Recolour mip-0 only via model_mut, then rebuild the ladder.
2441        reg.model_mut(id).recolor(|_| 0x0000_2000);
2442        reg.rebuild_lod(id);
2443        // The mip-1 average of all-0x2000 voxels is still 0x2000.
2444        let lvl1_entry = reg.chains[id as usize][1] as usize;
2445        assert!(reg.entries[lvl1_entry]
2446            .colors
2447            .iter()
2448            .all(|&c| c == 0x0000_2000));
2449    }
2450
2451    // ---- GPU.12 incremental: colors/dirs suballocator -----------------
2452
2453    /// Every slot fits its data, has slack, doesn't overlap the next, and
2454    /// the buffer reserves tail headroom past the last slot.
2455    fn alloc_invariants(a: &ColorsAllocator, lens: &[u32]) {
2456        let mut prev_end = 0u32;
2457        for (e, &len) in lens.iter().enumerate() {
2458            let s = a.slot(e);
2459            assert_eq!(s.len, len, "slot {e} len");
2460            assert!(s.cap >= s.len, "slot {e} cap >= len");
2461            // In a freshly repacked layout slots are in entry order.
2462            assert!(s.off >= prev_end, "slot {e} overlaps previous");
2463            assert!(s.off + s.cap <= a.cap_total(), "slot {e} past cap_total");
2464            prev_end = s.off + s.cap;
2465        }
2466        assert!(a.cap_total() >= prev_end, "tail headroom");
2467    }
2468
2469    #[test]
2470    fn allocator_new_lays_out_with_slack_and_headroom() {
2471        let lens = [10u32, 0, 64, 7];
2472        let a = ColorsAllocator::new(&lens);
2473        alloc_invariants(&a, &lens);
2474        // Slack: a 64-word slot has cap > 64 so a small carve-grow fits.
2475        assert!(a.slot(2).cap > 64);
2476        // Headroom past the bump tail for early growth.
2477        assert!(a.cap_total() > a.slot(3).off + a.slot(3).cap);
2478    }
2479
2480    #[test]
2481    fn allocator_place_in_place_when_within_cap() {
2482        let mut a = ColorsAllocator::new(&[10, 20]);
2483        let off0 = a.slot(0).off;
2484        let cap0 = a.slot(0).cap;
2485        // Shrink: still the same slot.
2486        assert_eq!(a.place(0, 5), Some(off0));
2487        assert_eq!(a.slot(0).len, 5);
2488        assert_eq!(a.slot(0).cap, cap0);
2489        // Grow within slack: same offset, no relocation.
2490        assert_eq!(a.place(0, cap0), Some(off0));
2491        assert_eq!(a.slot(0).off, off0);
2492        assert!(a.free.is_empty(), "no relocation should free anything");
2493    }
2494
2495    #[test]
2496    fn allocator_place_relocates_to_tail_and_frees_old() {
2497        let mut a = ColorsAllocator::new(&[10, 20]);
2498        let old0 = (a.slot(0).off, a.slot(0).cap);
2499        let tail_before = a.tail;
2500        // Overgrow entry 0 past its cap → relocate to the bump tail.
2501        let new_len = a.slot(0).cap + 5;
2502        let off = a.place(0, new_len).expect("fits in headroom");
2503        assert_eq!(off, tail_before, "relocated to old tail");
2504        assert_eq!(a.slot(0).off, off);
2505        assert_eq!(a.slot(0).len, new_len);
2506        assert!(a.free.contains(&old0), "old slot freed");
2507    }
2508
2509    #[test]
2510    fn allocator_reuses_freed_block_first_fit() {
2511        // Entry 0 has a large slot; entry 1 a tiny one, so growing 1 must
2512        // relocate (it can't fit in place) and lands in 0's freed block.
2513        let mut a = ColorsAllocator::new(&[10, 2]);
2514        let old0 = (a.slot(0).off, a.slot(0).cap);
2515        // Relocate entry 0 to the tail, freeing its original block.
2516        let _ = a.place(0, a.slot(0).cap + 5).unwrap();
2517        assert!(a.free.contains(&old0));
2518        // Grow entry 1 past its (tiny) cap but ≤ the freed block's cap →
2519        // first-fit reuses that block rather than bumping the tail.
2520        let new1 = a.slot(1).cap + 1;
2521        assert!(new1 <= old0.1, "freed block big enough");
2522        let off = a.place(1, new1).expect("reuses freed block");
2523        assert_eq!(off, old0.0, "first-fit reused the freed slot offset");
2524        assert!(!a.free.contains(&old0), "freed block consumed");
2525    }
2526
2527    #[test]
2528    fn allocator_signals_grow_then_repack_restores() {
2529        let mut a = ColorsAllocator::new(&[8, 8]);
2530        // Force overflow: ask for far more than cap_total.
2531        let huge = a.cap_total() + 100;
2532        assert_eq!(a.place(0, huge), None, "overflow must signal grow");
2533        // Repack with the new lengths compacts + grows the buffer.
2534        a.repack(&[huge, 8]);
2535        alloc_invariants(&a, &[huge, 8]);
2536        assert!(a.cap_total() > huge);
2537        // After repack the entry now fits in place.
2538        assert_eq!(a.place(0, huge), Some(a.slot(0).off));
2539    }
2540
2541    /// Drive the allocator like a real carve loop (mirroring
2542    /// `update_model`): one model's colour count drifts up and down
2543    /// across many edits while two neighbours stay put. Growth is
2544    /// absorbed in place / via the free list / by the bump tail, and on
2545    /// the rare overflow we repack (as `update_model` does). After every
2546    /// edit the live `[off, off+len)` windows must stay disjoint.
2547    #[test]
2548    fn allocator_carve_loop_keeps_live_windows_disjoint() {
2549        let mut a = ColorsAllocator::new(&[40, 12, 40]);
2550        let mut lens = [40u32, 12, 40];
2551        // A deterministic up/down walk of entry 1's length, incl. a jump
2552        // that forces at least one grow+repack.
2553        let walk = [13u32, 30, 60, 18, 9, 80, 80, 25, 200, 7];
2554        let mut grew = false;
2555        for &len in &walk {
2556            lens[1] = len;
2557            // Entry 1 re-placed; on overflow, repack the whole set.
2558            if a.place(1, len).is_none() {
2559                grew = true;
2560                a.repack(&lens);
2561            } else {
2562                // Neighbours fit in place every time.
2563                assert_eq!(a.place(0, 40), Some(a.slot(0).off));
2564                assert_eq!(a.place(2, 40), Some(a.slot(2).off));
2565            }
2566            assert_eq!(a.slot(1).len, len);
2567
2568            // No two entries' live windows overlap.
2569            let mut wins: Vec<(u32, u32)> =
2570                (0..3).map(|e| (a.slot(e).off, a.slot(e).len)).collect();
2571            wins.sort_by_key(|w| w.0);
2572            for pair in wins.windows(2) {
2573                let (o0, l0) = pair[0];
2574                let (o1, _) = pair[1];
2575                assert!(o0 + l0 <= o1, "live windows overlap: {pair:?}");
2576            }
2577        }
2578        assert!(grew, "the 200-word jump should have forced a repack");
2579    }
2580
2581    // --- incremental instance path (device-backed; skips w/o adapter) ---
2582
2583    fn headless() -> Option<crate::HeadlessGpu> {
2584        match crate::HeadlessGpu::new_blocking(crate::GpuRendererSettings::default()) {
2585            Ok(h) => Some(h),
2586            Err(e) => {
2587                eprintln!("[skip] no GPU adapter reachable: {e}");
2588                None
2589            }
2590        }
2591    }
2592
2593    fn one_model_registry() -> (SpriteModelRegistry, u32) {
2594        let mut reg = SpriteModelRegistry::new();
2595        let id = reg.add(build_sprite_model(&kv6_unsorted()));
2596        (reg, id)
2597    }
2598
2599    fn inst(model_id: u32, pos: [f32; 3]) -> SpriteInstance {
2600        use roxlap_formats::sprite::Sprite;
2601        SpriteInstance::new(
2602            model_id,
2603            SpriteInstanceTransform::from_sprite(&Sprite::axis_aligned(kv6_unsorted(), pos)),
2604        )
2605    }
2606
2607    #[test]
2608    fn append_grows_count_and_capacity_pow2() {
2609        let Some(h) = headless() else { return };
2610        let (reg, m) = one_model_registry();
2611        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(m, [0.0; 3])]);
2612        assert_eq!(res.instance_count(), 1);
2613        assert_eq!(res.instance_capacity, 1);
2614
2615        // Append 4 → count 5, capacity grows to next_pow2(5) = 8.
2616        let more: Vec<_> = (1..=4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
2617        let base = res.append_instances(&h.device, &reg, &more);
2618        assert_eq!(base, 1, "first appended index follows the seed instance");
2619        assert_eq!(res.instance_count(), 5);
2620        assert_eq!(res.instance_capacity, 8, "power-of-two growth");
2621
2622        // A second append that still fits keeps the same capacity (no realloc).
2623        let base2 = res.append_instances(&h.device, &reg, &[inst(m, [9.0, 0.0, 0.0])]);
2624        assert_eq!(base2, 5);
2625        assert_eq!(res.instance_count(), 6);
2626        assert_eq!(res.instance_capacity, 8, "fits existing capacity, no grow");
2627    }
2628
2629    #[test]
2630    fn append_empty_is_noop() {
2631        let Some(h) = headless() else { return };
2632        let (reg, m) = one_model_registry();
2633        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(m, [0.0; 3])]);
2634        let base = res.append_instances(&h.device, &reg, &[]);
2635        assert_eq!(base, 1);
2636        assert_eq!(res.instance_count(), 1);
2637        assert_eq!(res.instance_capacity, 1);
2638    }
2639
2640    /// Read `words` u32s back from a GPU buffer (needs COPY_SRC).
2641    fn read_u32(h: &crate::HeadlessGpu, buf: &wgpu::Buffer, words: u64) -> Vec<u32> {
2642        let bytes = words * 4;
2643        let staging = h.device.create_buffer(&wgpu::BufferDescriptor {
2644            label: Some("readback"),
2645            size: bytes,
2646            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2647            mapped_at_creation: false,
2648        });
2649        let mut enc = h
2650            .device
2651            .create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
2652        enc.copy_buffer_to_buffer(buf, 0, &staging, 0, bytes);
2653        h.queue.submit(std::iter::once(enc.finish()));
2654        let slice = staging.slice(..);
2655        let (tx, rx) = std::sync::mpsc::channel();
2656        slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
2657        h.device.poll(wgpu::PollType::wait_indefinitely()).ok();
2658        rx.recv().unwrap().unwrap();
2659        let data = slice.get_mapped_range();
2660        let out = bytemuck::cast_slice::<u8, u32>(&data).to_vec();
2661        drop(data);
2662        staging.unmap();
2663        out
2664    }
2665
2666    /// A second distinct model so add_model has real new geometry to lay
2667    /// down (different dims + colours from `kv6_unsorted`).
2668    fn kv6_other() -> Kv6 {
2669        let mk = |z, col| Voxel {
2670            col,
2671            z,
2672            vis: 0,
2673            dir: 0,
2674        };
2675        Kv6 {
2676            xsiz: 1,
2677            ysiz: 1,
2678            zsiz: 4,
2679            xpiv: 0.0,
2680            ypiv: 0.0,
2681            zpiv: 0.0,
2682            voxels: vec![mk(0, 0x11), mk(2, 0x22)],
2683            xlen: vec![2],
2684            ylen: vec![vec![2]],
2685            palette: None,
2686        }
2687    }
2688
2689    /// add_model lays the new model's volume on the GPU at the offsets its
2690    /// meta record claims — verified by reading the shared buffers back
2691    /// and matching each entry against its source SpriteModel.
2692    #[test]
2693    fn add_model_uploads_new_volume_incrementally() {
2694        let Some(h) = headless() else { return };
2695
2696        // Residency starts with model A only.
2697        let mut reg = SpriteModelRegistry::new();
2698        let a = reg.add(build_sprite_model(&kv6_unsorted()));
2699        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(a, [0.0; 3])]);
2700        assert_eq!(res.chains.len(), 1);
2701        let entries_before = res.meta.len();
2702
2703        // Append model B (single-level) to the registry, then sync it.
2704        let b = reg.add(build_sprite_model(&kv6_other()));
2705        res.add_model(&h.device, &h.queue, &reg, b);
2706        assert_eq!(res.chains.len(), 2);
2707        assert_eq!(res.meta.len(), entries_before + 1, "one new entry");
2708
2709        // Read the shared buffers back and check EVERY entry's data sits
2710        // where its meta record points — both the pre-existing A and the
2711        // newly streamed B.
2712        let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
2713        let coloff = read_u32(&h, &res.color_offsets, u64::from(res.coloff_cap));
2714        let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
2715        for (e, m) in reg.entries.iter().enumerate() {
2716            let meta = res.meta[e];
2717            let oo = meta.occupancy_offset as usize;
2718            assert_eq!(
2719                &occ[oo..oo + m.occupancy.len()],
2720                &m.occupancy[..],
2721                "occ entry {e}"
2722            );
2723            let co = meta.color_offsets_offset as usize;
2724            assert_eq!(
2725                &coloff[co..co + m.color_offsets.len()],
2726                &m.color_offsets[..],
2727                "color_offsets entry {e}"
2728            );
2729            let cc = meta.colors_offset as usize;
2730            assert_eq!(
2731                &cols[cc..cc + m.colors.len()],
2732                &m.colors[..],
2733                "colors entry {e}"
2734            );
2735        }
2736
2737        // And an instance of the freshly-added model can now be appended.
2738        let base = res.append_instances(&h.device, &reg, &[inst(b, [5.0, 0.0, 0.0])]);
2739        assert_eq!(base, 1);
2740        assert_eq!(res.instance_count(), 2);
2741    }
2742
2743    /// Adding many small models forces the volume buffers to grow + rebuild
2744    /// at least once; every entry must still read back correctly across the
2745    /// grow boundary.
2746    #[test]
2747    fn add_model_survives_buffer_growth() {
2748        let Some(h) = headless() else { return };
2749        let mut reg = SpriteModelRegistry::new();
2750        let a = reg.add(build_sprite_model(&kv6_unsorted()));
2751        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(a, [0.0; 3])]);
2752        let occ_cap0 = res.occ_cap;
2753
2754        // 40 adds — occupancy starts exact-sized (cap == used), so the very
2755        // first add overflows and grows; later ones ride the slack.
2756        for _ in 0..40 {
2757            let id = reg.add(build_sprite_model(&kv6_other()));
2758            res.add_model(&h.device, &h.queue, &reg, id);
2759        }
2760        assert_eq!(res.chains.len(), 41);
2761        assert!(res.occ_cap > occ_cap0, "occupancy buffer grew");
2762
2763        let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
2764        let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
2765        for (e, m) in reg.entries.iter().enumerate() {
2766            let meta = res.meta[e];
2767            let oo = meta.occupancy_offset as usize;
2768            assert_eq!(
2769                &occ[oo..oo + m.occupancy.len()],
2770                &m.occupancy[..],
2771                "occ entry {e}"
2772            );
2773            let cc = meta.colors_offset as usize;
2774            assert_eq!(
2775                &cols[cc..cc + m.colors.len()],
2776                &m.colors[..],
2777                "colors entry {e}"
2778            );
2779        }
2780    }
2781
2782    /// VCL.2 — a decoded voxel clip's frames register as a flipbook of LOD
2783    /// chains, and `set_instance_model` flips which frame an instance
2784    /// draws. The cull state it updates is exactly what
2785    /// `cull_bin_upload` packs into the GPU instance buffer each frame, so
2786    /// TV.3 (clip wiring): `sprite_model_from_clip_frame_with_materials`
2787    /// classifies a clip frame's voxels into a per-voxel `materials` array
2788    /// (parallel to `colors`) by colour; an empty map leaves it empty (the
2789    /// all-opaque clip), identical to `sprite_model_from_clip_frame`.
2790    #[test]
2791    fn clip_frame_with_materials_classifies_by_color() {
2792        use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
2793
2794        let dims = [1u32, 1, 4];
2795        let owpc = dims[2].div_ceil(32).max(1) as usize; // 1
2796        let glass = 0x80AA_BBCC;
2797        let stone = 0x8011_2233;
2798        let frame = VoxelFrame {
2799            occupancy: {
2800                let mut occ = vec![0u32; owpc];
2801                occ[0] |= (1 << 0) | (1 << 1);
2802                occ
2803            },
2804            colors: vec![stone, glass], // ascending z: z=0 stone, z=1 glass
2805            color_offsets: vec![0, 2],
2806        };
2807        let clip = VoxelClip::from_frames(
2808            dims,
2809            [0.5, 0.5, 2.0],
2810            1.0,
2811            LoopMode::Loop,
2812            &[frame],
2813            &[],
2814            33,
2815            0,
2816        );
2817        let decoded = clip.decode().expect("decode");
2818
2819        // Map only the glass colour → material 2; stone stays opaque (0).
2820        let m = sprite_model_from_clip_frame_with_materials(&decoded, 0, &[(0x00AA_BBCC, 2)]);
2821        assert_eq!(
2822            m.materials.len(),
2823            m.colors.len(),
2824            "materials parallel to colors"
2825        );
2826        // `colors` is in popcount-rank (ascending z) order: stone then glass.
2827        assert_eq!(
2828            m.materials,
2829            vec![0u8, 2u8],
2830            "stone opaque, glass material 2"
2831        );
2832
2833        // Empty map ⇒ no per-voxel materials, identical to the plain builder.
2834        let plain = sprite_model_from_clip_frame(&decoded, 0);
2835        let plain_mat = sprite_model_from_clip_frame_with_materials(&decoded, 0, &[]);
2836        assert!(plain.materials.is_empty());
2837        assert!(plain_mat.materials.is_empty());
2838        assert_eq!(plain.colors, plain_mat.colors);
2839    }
2840
2841    /// TV.3 (streaming-clip refresh path): `build_sprite_model_with_materials`
2842    /// — the builder behind `GpuBackend::update_sprite_model_with_materials`,
2843    /// which a streaming clip re-runs each frame — classifies a kv6's voxels
2844    /// into a per-voxel `materials` array (popcount-rank order) by colour.
2845    #[test]
2846    fn build_with_materials_classifies_by_color() {
2847        let glass = 0x80AA_BBCC;
2848        let stone = 0x8011_2233;
2849        // One column (x=0,y=0), two voxels: z=0 stone, z=1 glass.
2850        let kv6 = kv6_from(1, 1, 4, &[(0, 0, 0, stone), (0, 0, 1, glass)]);
2851
2852        let m = build_sprite_model_with_materials(&kv6, &[(0x00AA_BBCC, 2)]);
2853        assert_eq!(
2854            m.materials.len(),
2855            m.colors.len(),
2856            "materials parallel to colors"
2857        );
2858        assert_eq!(
2859            m.materials,
2860            vec![0u8, 2u8],
2861            "stone opaque, glass material 2"
2862        );
2863
2864        // Empty map ⇒ no per-voxel materials, identical to `build_sprite_model`.
2865        let plain = build_sprite_model(&kv6);
2866        let plain_mat = build_sprite_model_with_materials(&kv6, &[]);
2867        assert!(plain.materials.is_empty());
2868        assert!(plain_mat.materials.is_empty());
2869        assert_eq!(plain.colors, plain_mat.colors);
2870    }
2871
2872    /// flipping `chain_id` redirects the rendered instance to the new
2873    /// frame's resident volume.
2874    #[test]
2875    fn voxel_clip_flipbook_set_instance_model() {
2876        use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
2877        let Some(h) = headless() else { return };
2878
2879        // Two distinct frames of a 1×1×4 clip: frame 0 has a voxel at z=0;
2880        // frame 1 adds z=1 — different occupancy + a longer colour run.
2881        let dims = [1u32, 1, 4];
2882        let owpc = dims[2].div_ceil(32).max(1) as usize; // 1
2883        let mk_frame = |zs: &[u32], cols: &[u32]| -> VoxelFrame {
2884            let mut occ = vec![0u32; owpc];
2885            for &z in zs {
2886                occ[(z >> 5) as usize] |= 1u32 << (z & 31);
2887            }
2888            VoxelFrame {
2889                occupancy: occ,
2890                colors: cols.to_vec(),
2891                color_offsets: vec![0, cols.len() as u32],
2892            }
2893        };
2894        let f0 = mk_frame(&[0], &[0x8011_2233]);
2895        let f1 = mk_frame(&[0, 1], &[0x8011_2233, 0x80AA_BBCC]);
2896        let clip = VoxelClip::from_frames(
2897            dims,
2898            [0.5, 0.5, 2.0],
2899            1.0,
2900            LoopMode::Loop,
2901            &[f0, f1],
2902            &[],
2903            33,
2904            0,
2905        );
2906        let decoded = clip.decode().expect("decode");
2907
2908        // Each frame → a single-level chain; both volumes resident + distinct.
2909        let mut reg = SpriteModelRegistry::new();
2910        let c0 = reg.add(sprite_model_from_clip_frame(&decoded, 0));
2911        let c1 = reg.add(sprite_model_from_clip_frame(&decoded, 1));
2912        assert_eq!(reg.model(c0).colors.len(), 1);
2913        assert_eq!(reg.model(c1).colors.len(), 2);
2914
2915        // One instance, in front of the test frustum, drawing frame 0.
2916        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(c0, [0.0, 0.0, 5.0])]);
2917        assert_eq!(res.cull[0].chain_id, c0);
2918
2919        // Flip to frame 1: the cull now draws chain c1 (radius reseeded).
2920        res.set_instance_model(&reg, 0, c1);
2921        assert_eq!(res.cull[0].chain_id, c1);
2922        assert_eq!(res.cull[0].radius, reg.model(c1).bound_radius());
2923
2924        // The next cull packs the new chain into the GPU instance buffer
2925        // (visible, no panic).
2926        let f = test_frustum();
2927        let (visible, _, _) = res.cull_bin_upload(&h.device, &h.queue, &f, 64, 64, 16, 1.0);
2928        assert_eq!(visible, 1);
2929
2930        // …and back to frame 0.
2931        res.set_instance_model(&reg, 0, c0);
2932        assert_eq!(res.cull[0].chain_id, c0);
2933
2934        // Out-of-range index is a safe no-op.
2935        res.set_instance_model(&reg, 99, c1);
2936        assert_eq!(res.cull[0].chain_id, c0);
2937    }
2938
2939    fn test_frustum() -> ViewFrustum {
2940        ViewFrustum {
2941            pos: [0.0, 0.0, 0.0],
2942            right: [1.0, 0.0, 0.0],
2943            down: [0.0, 1.0, 0.0],
2944            forward: [0.0, 0.0, 1.0],
2945            half_w: 1.0,
2946            half_h: 1.0,
2947            far: 10_000.0,
2948        }
2949    }
2950
2951    #[test]
2952    fn remove_model_tombstones_frees_and_reuses() {
2953        let Some(h) = headless() else { return };
2954        // Residency with models A and B, one instance each.
2955        let mut reg = SpriteModelRegistry::new();
2956        let a = reg.add(build_sprite_model(&kv6_unsorted()));
2957        let b = reg.add(build_sprite_model(&kv6_other()));
2958        let mut res = SpriteRegistryResident::upload(
2959            &h.device,
2960            &reg,
2961            &[inst(a, [0.0; 3]), inst(b, [1.0, 0.0, 0.0])],
2962        );
2963        assert_eq!(res.live_model_count(), 2);
2964        assert_eq!(res.dead_model_count(), 0);
2965
2966        // Remove B → tombstoned, its colours freed into the pool.
2967        res.remove_model(b);
2968        assert_eq!(res.live_model_count(), 1);
2969        assert_eq!(res.dead_model_count(), 1);
2970        assert_eq!(res.dead.iter().filter(|&&d| d).count(), 1, "one entry dead");
2971        assert!(!res.colors_alloc.free.is_empty(), "B's colour slot freed");
2972
2973        // Adding C reuses the freed slot (free-list first-fit).
2974        let c = reg.add(build_sprite_model(&kv6_other()));
2975        res.add_model(&h.device, &h.queue, &reg, c);
2976        assert_eq!(res.live_model_count(), 2);
2977
2978        // A and C read back correctly; B is dead (skipped).
2979        let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
2980        for e in [a as usize, c as usize] {
2981            let m = &reg.entries[e];
2982            let cc = res.meta[e].colors_offset as usize;
2983            assert_eq!(
2984                &cols[cc..cc + m.colors.len()],
2985                &m.colors[..],
2986                "colors entry {e}"
2987            );
2988        }
2989
2990        // The lingering instance of removed B is skipped without panic.
2991        let f = test_frustum();
2992        let _ = res.cull_bin_upload(&h.device, &h.queue, &f, 64, 64, 16, 1.0);
2993    }
2994
2995    #[test]
2996    fn compact_reclaims_holes_keeps_ids_stable() {
2997        let Some(h) = headless() else { return };
2998        let mut reg = SpriteModelRegistry::new();
2999        let a = reg.add(build_sprite_model(&kv6_unsorted()));
3000        let b = reg.add(build_sprite_model(&kv6_other()));
3001        let c = reg.add(build_sprite_model(&kv6_other()));
3002        let mut res = SpriteRegistryResident::upload(
3003            &h.device,
3004            &reg,
3005            &[inst(a, [0.0; 3]), inst(b, [1.0; 3]), inst(c, [2.0; 3])],
3006        );
3007        let occ_used_full = res.occ_used;
3008
3009        // Remove the middle model, then compact.
3010        res.remove_model(b);
3011        res.compact(&h.device, &h.queue, &reg);
3012
3013        // Holes reclaimed: occupancy now only covers A + C.
3014        let live_occ: u32 = [a, c]
3015            .iter()
3016            .map(|&e| reg.entries[e as usize].occupancy.len() as u32)
3017            .sum();
3018        assert_eq!(res.occ_used, live_occ);
3019        assert!(res.occ_used < occ_used_full, "compaction shrank occupancy");
3020        // Dead entry keeps a zeroed tombstone; ids unchanged.
3021        assert_eq!(res.meta[b as usize].occupancy_offset, 0);
3022        assert_eq!(res.live_model_count(), 2);
3023        assert_eq!(res.dead_model_count(), 1);
3024
3025        // Live entries read back correctly at their new offsets.
3026        let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3027        let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3028        for &e in &[a as usize, c as usize] {
3029            let m = &reg.entries[e];
3030            let oo = res.meta[e].occupancy_offset as usize;
3031            assert_eq!(
3032                &occ[oo..oo + m.occupancy.len()],
3033                &m.occupancy[..],
3034                "occ {e}"
3035            );
3036            let cc = res.meta[e].colors_offset as usize;
3037            assert_eq!(&cols[cc..cc + m.colors.len()], &m.colors[..], "cols {e}");
3038        }
3039
3040        // Chain ids still valid: C's chain still resolves; B's is empty.
3041        assert!(!res.chains[c as usize].is_empty());
3042        assert!(res.chains[b as usize].is_empty());
3043    }
3044
3045    #[test]
3046    fn remove_swap_semantics_and_capacity_retained() {
3047        let Some(h) = headless() else { return };
3048        let (reg, m) = one_model_registry();
3049        let seed: Vec<_> = (0..4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
3050        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &seed);
3051        assert_eq!(res.instance_count(), 4);
3052        let cap = res.instance_capacity;
3053
3054        // Remove a middle element → the previous last (idx 3) moved into it.
3055        assert_eq!(res.remove_instance(1), Some(3));
3056        assert_eq!(res.instance_count(), 3);
3057
3058        // Remove the current last (idx 2) → nothing moved.
3059        assert_eq!(res.remove_instance(2), None);
3060        assert_eq!(res.instance_count(), 2);
3061
3062        // Out of range → None.
3063        assert_eq!(res.remove_instance(99), None);
3064        assert_eq!(res.instance_count(), 2);
3065
3066        // Capacity is retained for reuse (no shrink).
3067        assert_eq!(res.instance_capacity, cap);
3068    }
3069}