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