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            // The bump layout overflowed: rebuild through the COMPACTOR,
1708            // which re-packs live entries tightly AND rewrites their
1709            // meta offsets (`add_model` re-uploads the whole model_meta
1710            // table right after this, so the recomputed offsets reach
1711            // the GPU for free) — and reclaiming tombstone holes may
1712            // absorb the growth outright.
1713            //
1714            // The previous code here rebuilt tightly but kept the STALE
1715            // bump offsets in `meta`: after any `remove_model` hole,
1716            // every live model behind it read its volume at a shifted
1717            // offset — permanent "black stripe" corruption, repaired
1718            // per-model only by an `update_model` rewrite. Root-caused
1719            // by the roxlap-game-demo author (0.27.0).
1720            self.compact_concat(device, registry, which);
1721        } else {
1722            let target = match which {
1723                ConcatBuf::Occupancy => &self.occupancy,
1724                ConcatBuf::ColorOffsets => &self.color_offsets,
1725            };
1726            for &e in new_entries {
1727                let e = e as usize;
1728                let off = match which {
1729                    ConcatBuf::Occupancy => self.meta[e].occupancy_offset,
1730                    ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset,
1731                };
1732                queue.write_buffer(
1733                    target,
1734                    u64::from(off) * 4,
1735                    bytemuck::cast_slice(concat_data(&registry.entries[e], which)),
1736                );
1737            }
1738        }
1739    }
1740
1741    /// Number of removed-but-not-yet-compacted models (tombstoned chains).
1742    /// A caller streams `add_model` / `remove_model` and calls
1743    /// [`Self::compact`] once this (relative to [`Self::live_model_count`])
1744    /// crosses a threshold.
1745    #[must_use]
1746    pub fn dead_model_count(&self) -> usize {
1747        self.chains.iter().filter(|c| c.is_empty()).count()
1748    }
1749
1750    /// Number of live (non-removed) models.
1751    #[must_use]
1752    pub fn live_model_count(&self) -> usize {
1753        self.chains.iter().filter(|c| !c.is_empty()).count()
1754    }
1755
1756    /// Remove a model (tombstone its LOD chain) — the counterpart to
1757    /// [`Self::add_model`]. O(chain length): marks the chain's entries
1758    /// dead and frees their `colors`/`dirs` slots for reuse by a later
1759    /// `add_model`. The `occupancy` / `color_offsets` holes are **not**
1760    /// reclaimed until [`Self::compact`]; entry ids (and the caller's other
1761    /// `chain_id`s) stay stable.
1762    ///
1763    /// Instances of the removed chain are **not** dropped here — they
1764    /// linger in the cull set but draw as nothing (skipped in
1765    /// [`Self::cull_bin_upload`]); the caller removes them via
1766    /// [`Self::remove_instance`] when convenient. A no-op if `chain_id` is
1767    /// out of range or already removed.
1768    pub fn remove_model(&mut self, chain_id: u32) {
1769        let Some(entries) = self.chains.get(chain_id as usize).cloned() else {
1770            return;
1771        };
1772        if entries.is_empty() {
1773            return; // already removed
1774        }
1775        self.last_cull = None; // PF.10 — tombstone changes visibility
1776        for &e in &entries {
1777            let e = e as usize;
1778            self.dead[e] = true;
1779            self.colors_alloc.free(e);
1780        }
1781        self.chains[chain_id as usize] = Vec::new(); // tombstone
1782    }
1783
1784    /// Reclaim the holes left by [`Self::remove_model`]: rebuild the shared
1785    /// volume buffers from the live entries only, dropping every dead
1786    /// entry's data. Entry ids and `chain_id`s are preserved (dead entries
1787    /// keep a zero-length `meta` tombstone), so the caller's handles stay
1788    /// valid and no remap is needed.
1789    ///
1790    /// `registry` must be the resident one (entry ids 1:1, as for
1791    /// [`Self::add_model`] / [`Self::update_model`]). O(live volume) —
1792    /// call it when [`Self::dead_model_count`] is high, not every frame.
1793    pub fn compact(
1794        &mut self,
1795        device: &wgpu::Device,
1796        queue: &wgpu::Queue,
1797        registry: &SpriteModelRegistry,
1798    ) {
1799        self.last_cull = None; // PF.10 — entry ids / chains renumbered
1800                               // occupancy + color_offsets: re-pack live entries tightly, rewrite
1801                               // each live entry's meta offset, zero the dead ones.
1802        self.compact_concat(device, registry, ConcatBuf::Occupancy);
1803        self.compact_concat(device, registry, ConcatBuf::ColorOffsets);
1804        // colors/dirs: the dead-aware repack already drops dead entries.
1805        self.repack_colors_dirs(device, registry);
1806        // model_meta: rewrite the (unchanged-length) table with the new
1807        // offsets. Buffer count didn't change, so no grow needed.
1808        queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1809    }
1810
1811    /// Rebuild one tightly-concatenated buffer from live entries only
1812    /// (used by [`Self::compact`]): assign each live entry a fresh tight
1813    /// offset, zero dead entries' offset, and recreate the buffer with
1814    /// slack.
1815    fn compact_concat(
1816        &mut self,
1817        device: &wgpu::Device,
1818        registry: &SpriteModelRegistry,
1819        which: ConcatBuf,
1820    ) {
1821        let mut all: Vec<u32> = Vec::new();
1822        for e in 0..self.meta.len() {
1823            if self.dead[e] {
1824                match which {
1825                    ConcatBuf::Occupancy => self.meta[e].occupancy_offset = 0,
1826                    ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = 0,
1827                }
1828                continue;
1829            }
1830            let off = all.len() as u32;
1831            match which {
1832                ConcatBuf::Occupancy => self.meta[e].occupancy_offset = off,
1833                ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = off,
1834            }
1835            all.extend_from_slice(concat_data(&registry.entries[e], which));
1836        }
1837        let used = all.len() as u32;
1838        let cap = grow_words(used);
1839        let (label, buf) = match which {
1840            ConcatBuf::Occupancy => ("roxlap-gpu sprite_reg.occupancy", &mut self.occupancy),
1841            ConcatBuf::ColorOffsets => (
1842                "roxlap-gpu sprite_reg.color_offsets",
1843                &mut self.color_offsets,
1844            ),
1845        };
1846        *buf = storage_dst_u32_cap(device, label, &all, cap);
1847        match which {
1848            ConcatBuf::Occupancy => {
1849                self.occ_used = used;
1850                self.occ_cap = cap;
1851            }
1852            ConcatBuf::ColorOffsets => {
1853                self.coloff_used = used;
1854                self.coloff_cap = cap;
1855            }
1856        }
1857    }
1858
1859    /// GPU.10.3 — frustum-cull, pack the visible subset into the
1860    /// instance buffer, then bin those instances into screen tiles:
1861    /// project each visible bounding sphere to a screen AABB and append
1862    /// its (visible) index to every overlapped tile. Uploads the
1863    /// instance buffer + `tile_ranges` (per-tile offset/count) +
1864    /// `tile_instances` (flat grouped indices), growing the tile
1865    /// buffers as needed. Returns `(visible_count, tiles_x, tiles_y)`.
1866    #[allow(clippy::too_many_arguments)]
1867    pub fn cull_bin_upload(
1868        &mut self,
1869        device: &wgpu::Device,
1870        queue: &wgpu::Queue,
1871        f: &ViewFrustum,
1872        screen_w: u32,
1873        screen_h: u32,
1874        tile_size: u32,
1875        lod_px: f32,
1876    ) -> (u32, u32, u32) {
1877        let tiles_x = screen_w.div_ceil(tile_size).max(1);
1878        let tiles_y = screen_h.div_ceil(tile_size).max(1);
1879        let n_tiles = (tiles_x * tiles_y) as usize;
1880
1881        // PF.10 — nothing changed since the last cull (same registry
1882        // state, same view, same screen): the four buffers already hold
1883        // exactly this frame's data — skip the whole cull/bin/upload.
1884        let key = CullKey::new(f, screen_w, screen_h, tile_size, lod_px);
1885        if let Some((k, res)) = self.last_cull {
1886            if k == key {
1887                return res;
1888            }
1889        }
1890
1891        let nw = (1.0 + f.half_w * f.half_w).sqrt();
1892        let nh = (1.0 + f.half_h * f.half_h).sqrt();
1893        let cx = screen_w as f32 * 0.5;
1894        let cy = screen_h as f32 * 0.5;
1895        let px_per_world = cx / f.half_w; // isotropic: == cy/half_h
1896        let ts = tile_size as f32;
1897        let tx_max = tiles_x as i32 - 1;
1898        let ty_max = tiles_y as i32 - 1;
1899
1900        // PF.10 — reused workspace (was 6+ fresh Vecs per frame).
1901        let scratch = &mut self.scratch;
1902        let visible = &mut scratch.visible;
1903        visible.clear();
1904        // Per-visible tile AABB (tx0, tx1, ty0, ty1) for the bin pass.
1905        let boxes = &mut scratch.boxes;
1906        boxes.clear();
1907        // Per-visible kv6colmul tables, flattened to two u32 per u64
1908        // entry (lanes 0|1, then 2|3), packed in visible order so the
1909        // shader indexes `colmul[inst_idx*512 + dir*2 + {0,1}]`. PF.10 —
1910        // built ONLY once a non-identity table exists (`any_colmul`);
1911        // until then the buffer holds a lazily-written identity fill and
1912        // the ~2 KiB-per-visible-instance rebuild + upload is skipped.
1913        let visible_colmul = &mut scratch.colmul;
1914        visible_colmul.clear();
1915        let counts = &mut scratch.counts;
1916        counts.clear();
1917        counts.resize(n_tiles, 0u32);
1918        let pack_colmul = self.any_colmul;
1919
1920        for ci in &self.cull {
1921            // Skip instances of a removed model (tombstoned chain) — they
1922            // linger in `cull` until the caller drops them, but draw as
1923            // nothing.
1924            if self.chains[ci.chain_id as usize].is_empty() {
1925                continue;
1926            }
1927            let rel = [
1928                ci.center[0] - f.pos[0],
1929                ci.center[1] - f.pos[1],
1930                ci.center[2] - f.pos[2],
1931            ];
1932            let z = dot3(rel, f.forward);
1933            let r = ci.radius;
1934            if z + r < 0.0 || z - r > f.far {
1935                continue; // behind / beyond far
1936            }
1937            let x = dot3(rel, f.right);
1938            if (x - f.half_w * z) > r * nw || (-x - f.half_w * z) > r * nw {
1939                continue; // right / left
1940            }
1941            let y = dot3(rel, f.down);
1942            if (y - f.half_h * z) > r * nh || (-y - f.half_h * z) > r * nh {
1943                continue; // bottom / top
1944            }
1945
1946            // Visible: project the sphere to a screen AABB → tile range.
1947            let (tx0, tx1, ty0, ty1) = if z > 1e-3 {
1948                let sx = cx + (x / z) * px_per_world;
1949                let sy = cy + (y / z) * px_per_world;
1950                let sr = (r / z) * px_per_world;
1951                (
1952                    (((sx - sr) / ts).floor() as i32).clamp(0, tx_max),
1953                    (((sx + sr) / ts).floor() as i32).clamp(0, tx_max),
1954                    (((sy - sr) / ts).floor() as i32).clamp(0, ty_max),
1955                    (((sy + sr) / ts).floor() as i32).clamp(0, ty_max),
1956                )
1957            } else {
1958                (0, tx_max, 0, ty_max)
1959            };
1960            // GPU.10.4 — pick the LOD level by projected voxel size:
1961            // choose the coarsest level whose voxel still covers at
1962            // least `lod_px` screen pixels, i.e. step up once a mip-0
1963            // voxel would be smaller than that. `lod_px = 1` is the
1964            // natural "don't go sub-pixel" threshold; larger values
1965            // force LOD in closer (tuning/inspection).
1966            let chain = &self.chains[ci.chain_id as usize];
1967            let level = if z > 1e-3 && chain.len() > 1 {
1968                // Mip-0 voxel screen size; a scaled instance's voxels
1969                // are `max_scale`× larger in world, so it holds the
1970                // fine mip proportionally longer (PS.1).
1971                let voxel_px = px_per_world * ci.max_scale / z;
1972                ((lod_px / voxel_px).log2().ceil().max(0.0) as usize).min(chain.len() - 1)
1973            } else {
1974                0
1975            };
1976            let mut g = ci.gpu;
1977            g.model_id = chain[level];
1978            visible.push(g);
1979            boxes.push([tx0, tx1, ty0, ty1]);
1980            if pack_colmul {
1981                for &w in ci.colmul.iter() {
1982                    visible_colmul.push((w & 0xffff_ffff) as u32);
1983                    visible_colmul.push((w >> 32) as u32);
1984                }
1985            }
1986            for ty in ty0..=ty1 {
1987                for tx in tx0..=tx1 {
1988                    counts[(ty * tiles_x as i32 + tx) as usize] += 1;
1989                }
1990            }
1991        }
1992
1993        if visible.is_empty() {
1994            let res = (0, tiles_x, tiles_y);
1995            self.last_cull = Some((key, res));
1996            return res;
1997        }
1998
1999        // Prefix-sum counts → per-tile offsets; build the flat grouped
2000        // index list.
2001        let tile_ranges = &mut scratch.tile_ranges;
2002        tile_ranges.clear();
2003        tile_ranges.resize(n_tiles * 2, 0u32);
2004        let mut running = 0u32;
2005        for t in 0..n_tiles {
2006            tile_ranges[2 * t] = running; // offset
2007            tile_ranges[2 * t + 1] = counts[t]; // count
2008            running += counts[t];
2009        }
2010        let total = running as usize;
2011        let tile_instances = &mut scratch.tile_instances;
2012        tile_instances.clear();
2013        tile_instances.resize(total.max(1), 0u32);
2014        let cursor = &mut scratch.cursor;
2015        cursor.clear();
2016        cursor.extend((0..n_tiles).map(|t| tile_ranges[2 * t]));
2017        for (vis_idx, b) in boxes.iter().enumerate() {
2018            for ty in b[2]..=b[3] {
2019                for tx in b[0]..=b[1] {
2020                    let t = (ty * tiles_x as i32 + tx) as usize;
2021                    tile_instances[cursor[t] as usize] = vis_idx as u32;
2022                    cursor[t] += 1;
2023                }
2024            }
2025        }
2026
2027        // Upload: instances + (grown) tile buffers. Grow a tile buffer
2028        // only when this frame needs more than its capacity (wgpu has
2029        // no Clone on Buffer, so we replace the field in place).
2030        queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(visible));
2031        let need_ranges = tile_ranges.len() as u32;
2032        if need_ranges > self.tile_ranges_cap {
2033            self.tile_ranges_cap = need_ranges.next_power_of_two();
2034            self.tile_ranges = storage_dst_u32(
2035                device,
2036                "roxlap-gpu sprite_reg.tile_ranges",
2037                self.tile_ranges_cap,
2038            );
2039        }
2040        let need_inst = tile_instances.len() as u32;
2041        if need_inst > self.tile_instances_cap {
2042            self.tile_instances_cap = need_inst.next_power_of_two();
2043            self.tile_instances = storage_dst_u32(
2044                device,
2045                "roxlap-gpu sprite_reg.tile_instances",
2046                self.tile_instances_cap,
2047            );
2048        }
2049        queue.write_buffer(&self.tile_ranges, 0, bytemuck::cast_slice(tile_ranges));
2050        queue.write_buffer(
2051            &self.tile_instances,
2052            0,
2053            bytemuck::cast_slice(tile_instances),
2054        );
2055        if pack_colmul {
2056            let need_colmul = visible_colmul.len() as u32;
2057            if need_colmul > self.colmul_cap {
2058                self.colmul_cap = need_colmul.next_power_of_two();
2059                self.colmul =
2060                    storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", self.colmul_cap);
2061                self.colmul_identity = false;
2062            }
2063            queue.write_buffer(&self.colmul, 0, bytemuck::cast_slice(visible_colmul));
2064        } else {
2065            // PF.10 — identity fast path: every table is identity, so the
2066            // buffer content is a constant repeating pattern. (Re)fill it
2067            // only on first use / growth; per-frame upload skipped.
2068            let need_colmul = visible.len() as u32 * 512;
2069            if need_colmul > self.colmul_cap {
2070                self.colmul_cap = need_colmul.next_power_of_two();
2071                self.colmul =
2072                    storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", self.colmul_cap);
2073                self.colmul_identity = false;
2074            }
2075            if !self.colmul_identity {
2076                let w = identity_colmul()[0];
2077                let (lo, hi) = ((w & 0xffff_ffff) as u32, (w >> 32) as u32);
2078                let fill: Vec<u32> = (0..self.colmul_cap)
2079                    .map(|i| if i & 1 == 0 { lo } else { hi })
2080                    .collect();
2081                queue.write_buffer(&self.colmul, 0, bytemuck::cast_slice(&fill));
2082                self.colmul_identity = true;
2083            }
2084        }
2085
2086        let res = (visible.len() as u32, tiles_x, tiles_y);
2087        self.last_cull = Some((key, res));
2088        res
2089    }
2090}
2091
2092/// GPU.12 incremental — per-entry placement of one model's `colors`
2093/// (and the parallel `dirs`) within the shared registry buffers: a
2094/// `[off, off+cap)` word window holding `len` live words. `cap >= len`
2095/// gives slack so a carve that *grows* the surface-voxel count can be
2096/// rewritten in place without relocating.
2097#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2098struct ColorSlot {
2099    off: u32,
2100    cap: u32,
2101    len: u32,
2102}
2103
2104/// First-fit suballocator over the parallel `colors`/`dirs` buffers
2105/// (same offsets/ranks → one allocator drives both). Each registry
2106/// entry owns a [`ColorSlot`]; growth past a slot's `cap` relocates it
2107/// (freeing the old block) via the free list or a bump tail, and only
2108/// when the tail would exceed `cap_total` does the caller grow + repack
2109/// the whole buffer. Pure (no GPU) so it unit-tests on its own.
2110#[derive(Debug, Default)]
2111struct ColorsAllocator {
2112    /// Per-entry slot, indexed by entry id.
2113    slots: Vec<ColorSlot>,
2114    /// Freed `(off, cap)` blocks available for first-fit reuse.
2115    free: Vec<(u32, u32)>,
2116    /// Next bump-allocation position (words).
2117    tail: u32,
2118    /// Total buffer capacity in words.
2119    cap_total: u32,
2120}
2121
2122/// Slack-padded capacity for a `len`-word array: +25% + 16 words, so a
2123/// few extra surface voxels from a carve fit without relocating.
2124fn slot_cap(len: u32) -> u32 {
2125    len + len / 4 + 16
2126}
2127
2128/// Slack capacity (words) for a grown concatenated buffer: +50% + 256, so
2129/// a burst of `add_model` calls bump-appends rather than re-growing every
2130/// time. Matches [`ColorsAllocator`]'s `cap_total` headroom.
2131fn grow_words(used: u32) -> u32 {
2132    used + used / 2 + 256
2133}
2134
2135/// Slack capacity (records) for a grown `model_meta` buffer: +50% + 8.
2136fn grow_records(count: u32) -> u32 {
2137    count + count / 2 + 8
2138}
2139
2140impl ColorsAllocator {
2141    /// Lay every entry out contiguously (with per-slot slack) and add a
2142    /// global tail headroom so early growth bump-allocates rather than
2143    /// repacks.
2144    fn new(entry_lens: &[u32]) -> Self {
2145        let mut a = Self::default();
2146        a.repack(entry_lens);
2147        a
2148    }
2149
2150    fn slot(&self, entry: usize) -> ColorSlot {
2151        self.slots[entry]
2152    }
2153
2154    fn cap_total(&self) -> u32 {
2155        self.cap_total
2156    }
2157
2158    /// Repack ALL entries compactly to fit `new_lens`, resetting the
2159    /// free list + tail and choosing a fresh `cap_total` with headroom.
2160    /// Used at initial build and on a buffer grow.
2161    fn repack(&mut self, new_lens: &[u32]) {
2162        self.free.clear();
2163        let mut off = 0u32;
2164        let mut slots = Vec::with_capacity(new_lens.len());
2165        for &len in new_lens {
2166            // A 0-length (dead / removed) entry takes no space — keeps a
2167            // tombstone slot so entry ids stay positional.
2168            let cap = if len == 0 { 0 } else { slot_cap(len) };
2169            slots.push(ColorSlot { off, cap, len });
2170            off += cap;
2171        }
2172        self.slots = slots;
2173        self.tail = off;
2174        // Global headroom: +50% + 256 words.
2175        self.cap_total = off + off / 2 + 256;
2176    }
2177
2178    /// Place `new_len` words for `entry`. Returns `Some(off)` with the
2179    /// (possibly relocated) slot offset, or `None` if the buffer must
2180    /// grow + repack. On relocation the old block is pushed to the free
2181    /// list; an in-place fit returns the unchanged offset.
2182    fn place(&mut self, entry: usize, new_len: u32) -> Option<u32> {
2183        let cur = self.slots[entry];
2184        if new_len <= cur.cap {
2185            self.slots[entry] = ColorSlot {
2186                len: new_len,
2187                ..cur
2188            };
2189            return Some(cur.off);
2190        }
2191        let old = (cur.off, cur.cap);
2192        // First-fit a freed block big enough for the live data.
2193        if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
2194            let (off, cap) = self.free.remove(i);
2195            self.free.push(old);
2196            self.slots[entry] = ColorSlot {
2197                off,
2198                cap,
2199                len: new_len,
2200            };
2201            return Some(off);
2202        }
2203        // Bump the tail if there's room.
2204        let want = slot_cap(new_len);
2205        if self.tail + want <= self.cap_total {
2206            let off = self.tail;
2207            self.tail += want;
2208            self.free.push(old);
2209            self.slots[entry] = ColorSlot {
2210                off,
2211                cap: want,
2212                len: new_len,
2213            };
2214            return Some(off);
2215        }
2216        None
2217    }
2218
2219    /// Append a slot for a brand-new entry of `new_len` words (used by
2220    /// [`SpriteRegistryResident::add_model`]). Returns `Some(off)` placed
2221    /// via the free list or the bump tail, or `None` if the buffer must
2222    /// grow + repack — in which case **no** slot is pushed (the caller's
2223    /// repack rebuilds every slot from scratch).
2224    fn push(&mut self, new_len: u32) -> Option<u32> {
2225        if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
2226            let (off, cap) = self.free.remove(i);
2227            self.slots.push(ColorSlot {
2228                off,
2229                cap,
2230                len: new_len,
2231            });
2232            return Some(off);
2233        }
2234        let want = slot_cap(new_len);
2235        if self.tail + want <= self.cap_total {
2236            let off = self.tail;
2237            self.tail += want;
2238            self.slots.push(ColorSlot {
2239                off,
2240                cap: want,
2241                len: new_len,
2242            });
2243            return Some(off);
2244        }
2245        None
2246    }
2247
2248    /// Free `entry`'s slot back to the pool ([`SpriteRegistryResident::
2249    /// remove_model`]). Its `(off, cap)` block joins the free list for
2250    /// first-fit reuse by a later [`Self::push`]; the slot is zeroed so a
2251    /// repack treats it as a 0-length tombstone.
2252    fn free(&mut self, entry: usize) {
2253        let s = self.slots[entry];
2254        if s.cap > 0 {
2255            self.free.push((s.off, s.cap));
2256        }
2257        self.slots[entry] = ColorSlot {
2258            off: 0,
2259            cap: 0,
2260            len: 0,
2261        };
2262    }
2263}
2264
2265/// Create a STORAGE buffer of u32s; pads empty input (wgpu rejects
2266/// zero-sized storage bindings).
2267#[allow(dead_code)]
2268fn storage_u32(device: &wgpu::Device, label: &str, data: &[u32]) -> wgpu::Buffer {
2269    use wgpu::util::DeviceExt;
2270    let bytes: &[u8] = if data.is_empty() {
2271        bytemuck::cast_slice(&[0u32])
2272    } else {
2273        bytemuck::cast_slice(data)
2274    };
2275    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2276        label: Some(label),
2277        contents: bytes,
2278        usage: wgpu::BufferUsages::STORAGE,
2279    })
2280}
2281
2282/// Create an uninitialised `STORAGE | COPY_DST` `u32` buffer of `cap`
2283/// words (≥1). Written each frame via `queue.write_buffer`.
2284fn storage_dst_u32(device: &wgpu::Device, label: &str, cap: u32) -> wgpu::Buffer {
2285    device.create_buffer(&wgpu::BufferDescriptor {
2286        label: Some(label),
2287        size: u64::from(cap.max(1)) * 4,
2288        // COPY_SRC so test/debug harnesses can read the contents back
2289        // (PF.10's cull gate does); free at runtime.
2290        usage: wgpu::BufferUsages::STORAGE
2291            | wgpu::BufferUsages::COPY_DST
2292            | wgpu::BufferUsages::COPY_SRC,
2293        mapped_at_creation: false,
2294    })
2295}
2296
2297/// Create a `STORAGE | COPY_DST` `u32` buffer of `cap` words (≥ data
2298/// length, ≥ 1), initialised with `data` at offset 0 and the tail left
2299/// zeroed. Unlike [`storage_u32`] (STORAGE-only, exact-size) this both
2300/// reserves spare capacity and is `COPY_DST`, so the incremental
2301/// [`SpriteRegistryResident::update_model`] can `write_buffer` a growing
2302/// `colors`/`dirs` array in place. Filled via `mapped_at_creation` so no
2303/// queue is needed at upload time.
2304fn storage_dst_u32_cap(device: &wgpu::Device, label: &str, data: &[u32], cap: u32) -> wgpu::Buffer {
2305    let cap = cap.max(data.len() as u32).max(1);
2306    let buf = device.create_buffer(&wgpu::BufferDescriptor {
2307        label: Some(label),
2308        size: u64::from(cap) * 4,
2309        usage: wgpu::BufferUsages::STORAGE
2310            | wgpu::BufferUsages::COPY_DST
2311            | wgpu::BufferUsages::COPY_SRC,
2312        mapped_at_creation: true,
2313    });
2314    if !data.is_empty() {
2315        buf.slice(..(data.len() as u64 * 4))
2316            .get_mapped_range_mut()
2317            .copy_from_slice(bytemuck::cast_slice(data));
2318    }
2319    buf.unmap();
2320    buf
2321}
2322
2323/// Create a `STORAGE | COPY_DST` buffer of Pod records, exact-size
2324/// (≥ 1, zero-padded), so individual records can be rewritten in place
2325/// by [`SpriteRegistryResident::update_model`] on a relocation. The
2326/// record *count* never changes on an incremental edit (no model is
2327/// added/removed), so no slack is needed here.
2328fn storage_dst_pod<T: Pod + Zeroable>(
2329    device: &wgpu::Device,
2330    label: &str,
2331    data: &[T],
2332) -> wgpu::Buffer {
2333    let one = [T::zeroed()];
2334    let src: &[T] = if data.is_empty() { &one } else { data };
2335    let buf = device.create_buffer(&wgpu::BufferDescriptor {
2336        label: Some(label),
2337        size: std::mem::size_of_val(src) as u64,
2338        usage: wgpu::BufferUsages::STORAGE
2339            | wgpu::BufferUsages::COPY_DST
2340            | wgpu::BufferUsages::COPY_SRC,
2341        mapped_at_creation: true,
2342    });
2343    buf.slice(..)
2344        .get_mapped_range_mut()
2345        .copy_from_slice(bytemuck::cast_slice(src));
2346    buf.unmap();
2347    buf
2348}
2349
2350/// Create a `STORAGE | COPY_DST` Pod buffer holding `cap` records
2351/// (≥ `data.len()`, ≥ 1), initialised with `data` at record 0 and the
2352/// tail zeroed. The slack lets [`SpriteRegistryResident::add_model`] grow
2353/// the `model_meta` table without re-growing on every add.
2354fn storage_dst_pod_cap<T: Pod + Zeroable>(
2355    device: &wgpu::Device,
2356    label: &str,
2357    data: &[T],
2358    cap: u32,
2359) -> wgpu::Buffer {
2360    let rec = std::mem::size_of::<T>() as u64;
2361    let cap = u64::from(cap.max(data.len() as u32).max(1));
2362    let buf = device.create_buffer(&wgpu::BufferDescriptor {
2363        label: Some(label),
2364        size: cap * rec,
2365        usage: wgpu::BufferUsages::STORAGE
2366            | wgpu::BufferUsages::COPY_DST
2367            | wgpu::BufferUsages::COPY_SRC,
2368        mapped_at_creation: true,
2369    });
2370    if !data.is_empty() {
2371        buf.slice(..(data.len() as u64 * rec))
2372            .get_mapped_range_mut()
2373            .copy_from_slice(bytemuck::cast_slice(data));
2374    }
2375    buf.unmap();
2376    buf
2377}
2378
2379/// Create a STORAGE buffer of Pod records; pads empty input with one
2380/// zeroed `T`.
2381#[allow(dead_code)]
2382fn storage_pod<T: Pod + Zeroable>(device: &wgpu::Device, label: &str, data: &[T]) -> wgpu::Buffer {
2383    use wgpu::util::DeviceExt;
2384    let one = [T::zeroed()];
2385    let src: &[T] = if data.is_empty() { &one } else { data };
2386    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2387        label: Some(label),
2388        contents: bytemuck::cast_slice(src),
2389        usage: wgpu::BufferUsages::STORAGE,
2390    })
2391}
2392
2393#[cfg(test)]
2394mod tests {
2395    use super::*;
2396    use roxlap_formats::kv6::{Kv6, Voxel};
2397
2398    /// 2×1 kv6: column (0,0) has voxels at z=5 (red) and z=1 (green)
2399    /// stored OUT of z-order; column (1,0) has one voxel at z=3.
2400    fn kv6_unsorted() -> Kv6 {
2401        let mk = |z, col| Voxel {
2402            col,
2403            z,
2404            vis: 0,
2405            dir: 0,
2406        };
2407        Kv6 {
2408            xsiz: 2,
2409            ysiz: 1,
2410            zsiz: 8,
2411            xpiv: 0.0,
2412            ypiv: 0.0,
2413            zpiv: 0.0,
2414            voxels: vec![mk(5, 0xAA), mk(1, 0xBB), mk(3, 0xCC)],
2415            xlen: vec![2, 1],
2416            ylen: vec![vec![2], vec![1]],
2417            palette: None,
2418        }
2419    }
2420
2421    #[test]
2422    fn occupancy_bits_set_at_voxel_z() {
2423        let m = build_sprite_model(&kv6_unsorted());
2424        assert_eq!(m.dims, [2, 1, 8]);
2425        assert_eq!(m.occ_words_per_col, 1); // ceil(8/32)
2426                                            // col 0: bits 1 and 5; col 1: bit 3.
2427        assert_eq!(m.occupancy[0], (1 << 1) | (1 << 5));
2428        assert_eq!(m.occupancy[1], 1 << 3);
2429    }
2430
2431    #[test]
2432    fn colors_are_ascending_z_for_rank_lookup() {
2433        let m = build_sprite_model(&kv6_unsorted());
2434        // col 0 sorted ascending z ⇒ z=1 (green 0xBB) before z=5 (0xAA).
2435        assert_eq!(m.color_offsets, vec![0, 2, 3]);
2436        assert_eq!(&m.colors, &[0xBB, 0xAA, 0xCC]);
2437    }
2438
2439    #[test]
2440    fn identity_basis_inverts_to_identity() {
2441        let inv = mat3_inverse([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
2442        assert_eq!(inv, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
2443    }
2444
2445    #[test]
2446    fn fork_is_independent_of_parent() {
2447        let mut reg = SpriteModelRegistry::new();
2448        let base = reg.add(build_sprite_model(&kv6_unsorted()));
2449        let forked = reg.fork(base);
2450        assert_ne!(base, forked);
2451        // Recolour only the fork.
2452        reg.model_mut(forked).recolor(|_| 0x11);
2453        // Parent colours untouched; fork fully overwritten.
2454        assert_eq!(&reg.model(base).colors, &[0xBB, 0xAA, 0xCC]);
2455        assert_eq!(&reg.model(forked).colors, &[0x11, 0x11, 0x11]);
2456    }
2457
2458    #[test]
2459    fn remove_frees_chain_data_keeps_ids_stable() {
2460        let mut reg = SpriteModelRegistry::new();
2461        let a = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2462        let b = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2463        let len_before = reg.len();
2464        assert!(reg.is_live(a) && reg.is_live(b));
2465
2466        reg.remove(a);
2467        // Chain `a` is tombstoned (its entries are freed to empty models;
2468        // they're unreachable via `model()` now — that's the tombstone).
2469        assert!(!reg.is_live(a));
2470        // `b` is untouched and still live; `len()` (next id) is unchanged.
2471        assert!(reg.is_live(b));
2472        assert_eq!(&reg.model(b).colors, &[0xBB, 0xAA, 0xCC]);
2473        assert_eq!(reg.len(), len_before);
2474
2475        // A later add mints a fresh id past the tombstone (no slot reuse).
2476        let c = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2477        assert_eq!(c, len_before as u32);
2478        assert!(reg.is_live(c));
2479        // `b`'s id stayed valid across the remove + add round-trip.
2480        assert_eq!(&reg.model(b).colors, &[0xBB, 0xAA, 0xCC]);
2481    }
2482
2483    #[test]
2484    fn model_checked_guards_out_of_range_and_tombstoned() {
2485        // The guard `set_instance_model` relies on: `model()` would
2486        // index-panic on these, `model_checked` returns `None`.
2487        let mut reg = SpriteModelRegistry::new();
2488        let a = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2489        assert!(reg.model_checked(a).is_some());
2490        assert!(reg.model_checked(9999).is_none(), "out of range → None");
2491        reg.remove(a);
2492        assert!(reg.model_checked(a).is_none(), "tombstoned chain → None");
2493    }
2494
2495    #[test]
2496    fn remove_is_idempotent_and_bounds_safe() {
2497        let mut reg = SpriteModelRegistry::new();
2498        let a = reg.add(build_sprite_model(&kv6_unsorted()));
2499        reg.remove(a);
2500        reg.remove(a); // already removed → no-op, no panic
2501        reg.remove(999); // out of range → no-op
2502        assert!(!reg.is_live(a));
2503        assert!(!reg.is_live(999));
2504    }
2505
2506    #[test]
2507    fn registry_gpu_structs_have_expected_sizes() {
2508        assert_eq!(std::mem::size_of::<SpriteModelMeta>(), 48);
2509        // TV — grew 64 → 80 with the per-instance material id + alpha_mul
2510        // (+ 8 bytes pad to keep the 16-byte std430 stride).
2511        assert_eq!(std::mem::size_of::<SpriteInstanceGpu>(), 80);
2512    }
2513
2514    #[test]
2515    fn add_lod_builds_halving_mip_chain() {
2516        let mut reg = SpriteModelRegistry::new();
2517        // 8×8×8 single voxel-filled column model would be ideal, but
2518        // kv6_unsorted is 2×1×8 → mips: 2×1×8 → 1×1×4 → 1×1×2 → 1×1×1.
2519        let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2520        let m0 = reg.model(id);
2521        assert_eq!(m0.dims, [2, 1, 8]);
2522        assert!((m0.voxel_world_size - 1.0).abs() < 1e-6);
2523    }
2524
2525    /// kv6 from explicit voxels, ordered x-major/y-inner to match
2526    /// `build_sprite_model`'s column walk.
2527    fn kv6_from(xsiz: u32, ysiz: u32, zsiz: u32, voxels: &[(u32, u32, u16, u32)]) -> Kv6 {
2528        let mut ylen = vec![vec![0u16; ysiz as usize]; xsiz as usize];
2529        let mut flat = Vec::new();
2530        for x in 0..xsiz {
2531            for y in 0..ysiz {
2532                let mut col: Vec<(u16, u32)> = voxels
2533                    .iter()
2534                    .filter(|(vx, vy, _, _)| *vx == x && *vy == y)
2535                    .map(|(_, _, z, c)| (*z, *c))
2536                    .collect();
2537                col.sort_by_key(|(z, _)| *z);
2538                ylen[x as usize][y as usize] = col.len() as u16;
2539                for (z, c) in col {
2540                    flat.push(Voxel {
2541                        col: c,
2542                        z,
2543                        vis: 0,
2544                        dir: 0,
2545                    });
2546                }
2547            }
2548        }
2549        let xlen = ylen
2550            .iter()
2551            .map(|c| c.iter().map(|&v| u32::from(v)).sum())
2552            .collect();
2553        Kv6 {
2554            xsiz,
2555            ysiz,
2556            zsiz,
2557            xpiv: 0.0,
2558            ypiv: 0.0,
2559            zpiv: 0.0,
2560            voxels: flat,
2561            xlen,
2562            ylen,
2563            palette: None,
2564        }
2565    }
2566
2567    fn offsets_consistent(m: &SpriteModel) -> bool {
2568        let cols = (m.dims[0] * m.dims[1]) as usize;
2569        if m.color_offsets.len() != cols + 1 {
2570            return false;
2571        }
2572        // Monotonic non-decreasing + last == colors.len + each column's
2573        // span == its solid-voxel count.
2574        for w in m.color_offsets.windows(2) {
2575            if w[1] < w[0] {
2576                return false;
2577            }
2578        }
2579        m.color_offsets[cols] as usize == m.colors.len()
2580    }
2581
2582    #[test]
2583    fn carve_two_layers_keeps_offsets_consistent() {
2584        // Mirror the demo's carve: columns with voxels at varied z,
2585        // some sharing z=0/z=1, some not.
2586        let kv6 = kv6_from(
2587            3,
2588            2,
2589            8,
2590            &[
2591                (0, 0, 0, 0xA0),
2592                (0, 0, 1, 0xA1),
2593                (0, 0, 5, 0xA5),
2594                (1, 0, 1, 0xB1),
2595                (2, 1, 0, 0xC0),
2596                (2, 1, 3, 0xC3),
2597            ],
2598        );
2599        let mut m = build_sprite_model(&kv6);
2600        assert!(offsets_consistent(&m));
2601        for z in 0..2u32 {
2602            for y in 0..m.dims[1] {
2603                for x in 0..m.dims[0] {
2604                    m.set_voxel(x, y, z, None);
2605                }
2606            }
2607            assert!(offsets_consistent(&m), "inconsistent after carving z={z}");
2608            // downsample must not panic on the carved model.
2609            let _ = m.downsample();
2610        }
2611    }
2612
2613    #[test]
2614    fn set_voxel_inserts_replaces_and_clears() {
2615        // col 0 starts with z=1 (0xBB), z=5 (0xAA); col 1 with z=3 (0xCC).
2616        let mut m = build_sprite_model(&kv6_unsorted());
2617
2618        // Insert z=3 into col 0 (between z=1 and z=5) → rank 1.
2619        assert!(m.set_voxel(0, 0, 3, Some(0x55)));
2620        assert_eq!(m.occupancy[0], (1 << 1) | (1 << 3) | (1 << 5));
2621        // col 0 colours ascending z: 0xBB(z1), 0x55(z3), 0xAA(z5).
2622        assert_eq!(m.color_offsets, vec![0, 3, 4]);
2623        assert_eq!(&m.colors, &[0xBB, 0x55, 0xAA, 0xCC]);
2624
2625        // Replace z=3 in place (no offset shift).
2626        assert!(m.set_voxel(0, 0, 3, Some(0x66)));
2627        assert_eq!(&m.colors, &[0xBB, 0x66, 0xAA, 0xCC]);
2628        assert_eq!(m.color_offsets, vec![0, 3, 4]);
2629
2630        // Clear z=1 (rank 0) from col 0.
2631        assert!(m.set_voxel(0, 0, 1, None));
2632        assert_eq!(m.occupancy[0], (1 << 3) | (1 << 5));
2633        assert_eq!(m.color_offsets, vec![0, 2, 3]);
2634        assert_eq!(&m.colors, &[0x66, 0xAA, 0xCC]);
2635
2636        // No-ops: clear an empty voxel, edit out of bounds.
2637        assert!(!m.set_voxel(0, 0, 2, None));
2638        assert!(!m.set_voxel(9, 0, 0, Some(1)));
2639    }
2640
2641    #[test]
2642    fn rebuild_lod_refreshes_coarse_levels_from_mip0() {
2643        let mut reg = SpriteModelRegistry::new();
2644        let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 3);
2645        // Recolour mip-0 only via model_mut, then rebuild the ladder.
2646        reg.model_mut(id).recolor(|_| 0x0000_2000);
2647        reg.rebuild_lod(id);
2648        // The mip-1 average of all-0x2000 voxels is still 0x2000.
2649        let lvl1_entry = reg.chains[id as usize][1] as usize;
2650        assert!(reg.entries[lvl1_entry]
2651            .colors
2652            .iter()
2653            .all(|&c| c == 0x0000_2000));
2654    }
2655
2656    // ---- GPU.12 incremental: colors/dirs suballocator -----------------
2657
2658    /// Every slot fits its data, has slack, doesn't overlap the next, and
2659    /// the buffer reserves tail headroom past the last slot.
2660    fn alloc_invariants(a: &ColorsAllocator, lens: &[u32]) {
2661        let mut prev_end = 0u32;
2662        for (e, &len) in lens.iter().enumerate() {
2663            let s = a.slot(e);
2664            assert_eq!(s.len, len, "slot {e} len");
2665            assert!(s.cap >= s.len, "slot {e} cap >= len");
2666            // In a freshly repacked layout slots are in entry order.
2667            assert!(s.off >= prev_end, "slot {e} overlaps previous");
2668            assert!(s.off + s.cap <= a.cap_total(), "slot {e} past cap_total");
2669            prev_end = s.off + s.cap;
2670        }
2671        assert!(a.cap_total() >= prev_end, "tail headroom");
2672    }
2673
2674    #[test]
2675    fn allocator_new_lays_out_with_slack_and_headroom() {
2676        let lens = [10u32, 0, 64, 7];
2677        let a = ColorsAllocator::new(&lens);
2678        alloc_invariants(&a, &lens);
2679        // Slack: a 64-word slot has cap > 64 so a small carve-grow fits.
2680        assert!(a.slot(2).cap > 64);
2681        // Headroom past the bump tail for early growth.
2682        assert!(a.cap_total() > a.slot(3).off + a.slot(3).cap);
2683    }
2684
2685    #[test]
2686    fn allocator_place_in_place_when_within_cap() {
2687        let mut a = ColorsAllocator::new(&[10, 20]);
2688        let off0 = a.slot(0).off;
2689        let cap0 = a.slot(0).cap;
2690        // Shrink: still the same slot.
2691        assert_eq!(a.place(0, 5), Some(off0));
2692        assert_eq!(a.slot(0).len, 5);
2693        assert_eq!(a.slot(0).cap, cap0);
2694        // Grow within slack: same offset, no relocation.
2695        assert_eq!(a.place(0, cap0), Some(off0));
2696        assert_eq!(a.slot(0).off, off0);
2697        assert!(a.free.is_empty(), "no relocation should free anything");
2698    }
2699
2700    #[test]
2701    fn allocator_place_relocates_to_tail_and_frees_old() {
2702        let mut a = ColorsAllocator::new(&[10, 20]);
2703        let old0 = (a.slot(0).off, a.slot(0).cap);
2704        let tail_before = a.tail;
2705        // Overgrow entry 0 past its cap → relocate to the bump tail.
2706        let new_len = a.slot(0).cap + 5;
2707        let off = a.place(0, new_len).expect("fits in headroom");
2708        assert_eq!(off, tail_before, "relocated to old tail");
2709        assert_eq!(a.slot(0).off, off);
2710        assert_eq!(a.slot(0).len, new_len);
2711        assert!(a.free.contains(&old0), "old slot freed");
2712    }
2713
2714    #[test]
2715    fn allocator_reuses_freed_block_first_fit() {
2716        // Entry 0 has a large slot; entry 1 a tiny one, so growing 1 must
2717        // relocate (it can't fit in place) and lands in 0's freed block.
2718        let mut a = ColorsAllocator::new(&[10, 2]);
2719        let old0 = (a.slot(0).off, a.slot(0).cap);
2720        // Relocate entry 0 to the tail, freeing its original block.
2721        let _ = a.place(0, a.slot(0).cap + 5).unwrap();
2722        assert!(a.free.contains(&old0));
2723        // Grow entry 1 past its (tiny) cap but ≤ the freed block's cap →
2724        // first-fit reuses that block rather than bumping the tail.
2725        let new1 = a.slot(1).cap + 1;
2726        assert!(new1 <= old0.1, "freed block big enough");
2727        let off = a.place(1, new1).expect("reuses freed block");
2728        assert_eq!(off, old0.0, "first-fit reused the freed slot offset");
2729        assert!(!a.free.contains(&old0), "freed block consumed");
2730    }
2731
2732    #[test]
2733    fn allocator_signals_grow_then_repack_restores() {
2734        let mut a = ColorsAllocator::new(&[8, 8]);
2735        // Force overflow: ask for far more than cap_total.
2736        let huge = a.cap_total() + 100;
2737        assert_eq!(a.place(0, huge), None, "overflow must signal grow");
2738        // Repack with the new lengths compacts + grows the buffer.
2739        a.repack(&[huge, 8]);
2740        alloc_invariants(&a, &[huge, 8]);
2741        assert!(a.cap_total() > huge);
2742        // After repack the entry now fits in place.
2743        assert_eq!(a.place(0, huge), Some(a.slot(0).off));
2744    }
2745
2746    /// Drive the allocator like a real carve loop (mirroring
2747    /// `update_model`): one model's colour count drifts up and down
2748    /// across many edits while two neighbours stay put. Growth is
2749    /// absorbed in place / via the free list / by the bump tail, and on
2750    /// the rare overflow we repack (as `update_model` does). After every
2751    /// edit the live `[off, off+len)` windows must stay disjoint.
2752    #[test]
2753    fn allocator_carve_loop_keeps_live_windows_disjoint() {
2754        let mut a = ColorsAllocator::new(&[40, 12, 40]);
2755        let mut lens = [40u32, 12, 40];
2756        // A deterministic up/down walk of entry 1's length, incl. a jump
2757        // that forces at least one grow+repack.
2758        let walk = [13u32, 30, 60, 18, 9, 80, 80, 25, 200, 7];
2759        let mut grew = false;
2760        for &len in &walk {
2761            lens[1] = len;
2762            // Entry 1 re-placed; on overflow, repack the whole set.
2763            if a.place(1, len).is_none() {
2764                grew = true;
2765                a.repack(&lens);
2766            } else {
2767                // Neighbours fit in place every time.
2768                assert_eq!(a.place(0, 40), Some(a.slot(0).off));
2769                assert_eq!(a.place(2, 40), Some(a.slot(2).off));
2770            }
2771            assert_eq!(a.slot(1).len, len);
2772
2773            // No two entries' live windows overlap.
2774            let mut wins: Vec<(u32, u32)> =
2775                (0..3).map(|e| (a.slot(e).off, a.slot(e).len)).collect();
2776            wins.sort_by_key(|w| w.0);
2777            for pair in wins.windows(2) {
2778                let (o0, l0) = pair[0];
2779                let (o1, _) = pair[1];
2780                assert!(o0 + l0 <= o1, "live windows overlap: {pair:?}");
2781            }
2782        }
2783        assert!(grew, "the 200-word jump should have forced a repack");
2784    }
2785
2786    // --- incremental instance path (device-backed; skips w/o adapter) ---
2787
2788    fn headless() -> Option<crate::HeadlessGpu> {
2789        match crate::HeadlessGpu::new_blocking(crate::GpuRendererSettings::default()) {
2790            Ok(h) => Some(h),
2791            Err(e) => {
2792                eprintln!("[skip] no GPU adapter reachable: {e}");
2793                None
2794            }
2795        }
2796    }
2797
2798    fn one_model_registry() -> (SpriteModelRegistry, u32) {
2799        let mut reg = SpriteModelRegistry::new();
2800        let id = reg.add(build_sprite_model(&kv6_unsorted()));
2801        (reg, id)
2802    }
2803
2804    fn inst(model_id: u32, pos: [f32; 3]) -> SpriteInstance {
2805        use roxlap_formats::sprite::Sprite;
2806        SpriteInstance::new(
2807            model_id,
2808            SpriteInstanceTransform::from_sprite(&Sprite::axis_aligned(kv6_unsorted(), pos)),
2809        )
2810    }
2811
2812    /// PS.1 — a scaled basis grows the cull sphere with the pose: the
2813    /// transform keeps the longest basis column, and `make_cull` seeds
2814    /// `radius = model.bound_radius() × max_scale`, so scaled-up
2815    /// instances (particles) no longer under-cull at screen edges.
2816    #[test]
2817    fn scaled_basis_scales_cull_radius() {
2818        use roxlap_formats::sprite::Sprite;
2819
2820        let mut reg = SpriteModelRegistry::new();
2821        let chain = reg.add(build_sprite_model(&kv6_unsorted()));
2822        let model_r = reg.model(chain).bound_radius();
2823
2824        let scaled = |k: f32, pos: [f32; 3]| {
2825            let mut s = Sprite::axis_aligned(kv6_unsorted(), pos);
2826            for a in 0..3 {
2827                s.s[a] *= k;
2828                s.h[a] *= k;
2829                s.f[a] *= k;
2830            }
2831            SpriteInstanceTransform::from_sprite(&s)
2832        };
2833
2834        // Unit basis: max_scale 1, radius = the model's (float-exact).
2835        let unit = inst(chain, [0.0; 3]);
2836        assert_eq!(unit.transform.max_scale, 1.0);
2837        assert_eq!(make_cull(&reg, &unit).radius, model_r);
2838
2839        // 2× uniform scale doubles both.
2840        let xf2 = scaled(2.0, [0.0; 3]);
2841        assert!((xf2.max_scale - 2.0).abs() < 1e-6);
2842        let big = SpriteInstance::new(chain, xf2);
2843        assert!((make_cull(&reg, &big).radius - 2.0 * model_r).abs() < 1e-4);
2844
2845        // Anisotropic scale takes the longest column.
2846        let mut s = Sprite::axis_aligned(kv6_unsorted(), [0.0; 3]);
2847        for a in 0..3 {
2848            s.h[a] *= 3.0;
2849            s.f[a] *= 0.5;
2850        }
2851        let xf = SpriteInstanceTransform::from_sprite(&s);
2852        assert!((xf.max_scale - 3.0).abs() < 1e-6);
2853    }
2854
2855    #[test]
2856    fn append_grows_count_and_capacity_pow2() {
2857        let Some(h) = headless() else { return };
2858        let (reg, m) = one_model_registry();
2859        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(m, [0.0; 3])]);
2860        assert_eq!(res.instance_count(), 1);
2861        assert_eq!(res.instance_capacity, 1);
2862
2863        // Append 4 → count 5, capacity grows to next_pow2(5) = 8.
2864        let more: Vec<_> = (1..=4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
2865        let base = res.append_instances(&h.device, &reg, &more);
2866        assert_eq!(base, 1, "first appended index follows the seed instance");
2867        assert_eq!(res.instance_count(), 5);
2868        assert_eq!(res.instance_capacity, 8, "power-of-two growth");
2869
2870        // A second append that still fits keeps the same capacity (no realloc).
2871        let base2 = res.append_instances(&h.device, &reg, &[inst(m, [9.0, 0.0, 0.0])]);
2872        assert_eq!(base2, 5);
2873        assert_eq!(res.instance_count(), 6);
2874        assert_eq!(res.instance_capacity, 8, "fits existing capacity, no grow");
2875    }
2876
2877    #[test]
2878    fn append_empty_is_noop() {
2879        let Some(h) = headless() else { return };
2880        let (reg, m) = one_model_registry();
2881        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(m, [0.0; 3])]);
2882        let base = res.append_instances(&h.device, &reg, &[]);
2883        assert_eq!(base, 1);
2884        assert_eq!(res.instance_count(), 1);
2885        assert_eq!(res.instance_capacity, 1);
2886    }
2887
2888    /// Read `words` u32s back from a GPU buffer (needs COPY_SRC).
2889    fn read_u32(h: &crate::HeadlessGpu, buf: &wgpu::Buffer, words: u64) -> Vec<u32> {
2890        let bytes = words * 4;
2891        let staging = h.device.create_buffer(&wgpu::BufferDescriptor {
2892            label: Some("readback"),
2893            size: bytes,
2894            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2895            mapped_at_creation: false,
2896        });
2897        let mut enc = h
2898            .device
2899            .create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
2900        enc.copy_buffer_to_buffer(buf, 0, &staging, 0, bytes);
2901        h.queue.submit(std::iter::once(enc.finish()));
2902        let slice = staging.slice(..);
2903        let (tx, rx) = std::sync::mpsc::channel();
2904        slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
2905        h.device.poll(wgpu::PollType::wait_indefinitely()).ok();
2906        rx.recv().unwrap().unwrap();
2907        let data = slice.get_mapped_range();
2908        let out = bytemuck::cast_slice::<u8, u32>(&data).to_vec();
2909        drop(data);
2910        staging.unmap();
2911        out
2912    }
2913
2914    /// A second distinct model so add_model has real new geometry to lay
2915    /// down (different dims + colours from `kv6_unsorted`).
2916    fn kv6_other() -> Kv6 {
2917        let mk = |z, col| Voxel {
2918            col,
2919            z,
2920            vis: 0,
2921            dir: 0,
2922        };
2923        Kv6 {
2924            xsiz: 1,
2925            ysiz: 1,
2926            zsiz: 4,
2927            xpiv: 0.0,
2928            ypiv: 0.0,
2929            zpiv: 0.0,
2930            voxels: vec![mk(0, 0x11), mk(2, 0x22)],
2931            xlen: vec![2],
2932            ylen: vec![vec![2]],
2933            palette: None,
2934        }
2935    }
2936
2937    /// add_model lays the new model's volume on the GPU at the offsets its
2938    /// meta record claims — verified by reading the shared buffers back
2939    /// and matching each entry against its source SpriteModel.
2940    #[test]
2941    fn add_model_uploads_new_volume_incrementally() {
2942        let Some(h) = headless() else { return };
2943
2944        // Residency starts with model A only.
2945        let mut reg = SpriteModelRegistry::new();
2946        let a = reg.add(build_sprite_model(&kv6_unsorted()));
2947        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(a, [0.0; 3])]);
2948        assert_eq!(res.chains.len(), 1);
2949        let entries_before = res.meta.len();
2950
2951        // Append model B (single-level) to the registry, then sync it.
2952        let b = reg.add(build_sprite_model(&kv6_other()));
2953        res.add_model(&h.device, &h.queue, &reg, b);
2954        assert_eq!(res.chains.len(), 2);
2955        assert_eq!(res.meta.len(), entries_before + 1, "one new entry");
2956
2957        // Read the shared buffers back and check EVERY entry's data sits
2958        // where its meta record points — both the pre-existing A and the
2959        // newly streamed B.
2960        let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
2961        let coloff = read_u32(&h, &res.color_offsets, u64::from(res.coloff_cap));
2962        let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
2963        for (e, m) in reg.entries.iter().enumerate() {
2964            let meta = res.meta[e];
2965            let oo = meta.occupancy_offset as usize;
2966            assert_eq!(
2967                &occ[oo..oo + m.occupancy.len()],
2968                &m.occupancy[..],
2969                "occ entry {e}"
2970            );
2971            let co = meta.color_offsets_offset as usize;
2972            assert_eq!(
2973                &coloff[co..co + m.color_offsets.len()],
2974                &m.color_offsets[..],
2975                "color_offsets entry {e}"
2976            );
2977            let cc = meta.colors_offset as usize;
2978            assert_eq!(
2979                &cols[cc..cc + m.colors.len()],
2980                &m.colors[..],
2981                "colors entry {e}"
2982            );
2983        }
2984
2985        // And an instance of the freshly-added model can now be appended.
2986        let base = res.append_instances(&h.device, &reg, &[inst(b, [5.0, 0.0, 0.0])]);
2987        assert_eq!(base, 1);
2988        assert_eq!(res.instance_count(), 2);
2989    }
2990
2991    /// Adding many small models forces the volume buffers to grow + rebuild
2992    /// at least once; every entry must still read back correctly across the
2993    /// grow boundary.
2994    #[test]
2995    fn add_model_survives_buffer_growth() {
2996        let Some(h) = headless() else { return };
2997        let mut reg = SpriteModelRegistry::new();
2998        let a = reg.add(build_sprite_model(&kv6_unsorted()));
2999        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(a, [0.0; 3])]);
3000        let occ_cap0 = res.occ_cap;
3001
3002        // 40 adds — occupancy starts exact-sized (cap == used), so the very
3003        // first add overflows and grows; later ones ride the slack.
3004        for _ in 0..40 {
3005            let id = reg.add(build_sprite_model(&kv6_other()));
3006            res.add_model(&h.device, &h.queue, &reg, id);
3007        }
3008        assert_eq!(res.chains.len(), 41);
3009        assert!(res.occ_cap > occ_cap0, "occupancy buffer grew");
3010
3011        let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3012        let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3013        for (e, m) in reg.entries.iter().enumerate() {
3014            let meta = res.meta[e];
3015            let oo = meta.occupancy_offset as usize;
3016            assert_eq!(
3017                &occ[oo..oo + m.occupancy.len()],
3018                &m.occupancy[..],
3019                "occ entry {e}"
3020            );
3021            let cc = meta.colors_offset as usize;
3022            assert_eq!(
3023                &cols[cc..cc + m.colors.len()],
3024                &m.colors[..],
3025                "colors entry {e}"
3026            );
3027        }
3028    }
3029
3030    /// Regression (downstream report, 0.27.0): a `remove_model` hole
3031    /// followed by an occupancy-overflow `add_model` desynced every live
3032    /// entry behind the hole — the grow path rebuilt the buffer tightly
3033    /// (tombstoned entries contribute nothing) but kept the STALE bump
3034    /// offsets in `meta`, so those models read shifted occupancy words
3035    /// ("black stripe planes") until an `update_model` happened to
3036    /// rewrite them at the stale offset. The overflow path now routes
3037    /// through `compact_concat`, which recomputes the offsets it
3038    /// uploads.
3039    #[test]
3040    fn growth_after_remove_keeps_offsets_in_sync() {
3041        let Some(h) = headless() else { return };
3042        let mut reg = SpriteModelRegistry::new();
3043        // Three models; the middle one becomes the hole.
3044        let a = reg.add(build_sprite_model(&kv6_unsorted()));
3045        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(a, [0.0; 3])]);
3046        let b = reg.add(build_sprite_model(&kv6_other()));
3047        res.add_model(&h.device, &h.queue, &reg, b);
3048        let c = reg.add(build_sprite_model(&kv6_other()));
3049        res.add_model(&h.device, &h.queue, &reg, c);
3050        let _ = c;
3051
3052        // Tombstone the middle chain: resident hole + zero-length
3053        // registry entry (exactly the facade's remove path).
3054        res.remove_model(b);
3055        reg.remove(b);
3056
3057        // Keep adding until occupancy overflows — the grow/rebuild path.
3058        let cap_before = res.occ_cap;
3059        let mut guard = 0;
3060        while res.occ_cap == cap_before {
3061            let id = reg.add(build_sprite_model(&kv6_other()));
3062            res.add_model(&h.device, &h.queue, &reg, id);
3063            guard += 1;
3064            assert!(guard < 10_000, "growth never triggered");
3065        }
3066
3067        // Every LIVE entry's meta offset must point at its actual data
3068        // in the rebuilt buffers.
3069        let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3070        let coloff = read_u32(&h, &res.color_offsets, u64::from(res.coloff_cap));
3071        for (e, m) in reg.entries.iter().enumerate() {
3072            if res.dead[e] {
3073                continue;
3074            }
3075            let meta = res.meta[e];
3076            let oo = meta.occupancy_offset as usize;
3077            assert_eq!(
3078                &occ[oo..oo + m.occupancy.len()],
3079                &m.occupancy[..],
3080                "occ entry {e} reads at its meta offset"
3081            );
3082            let co = meta.color_offsets_offset as usize;
3083            assert_eq!(
3084                &coloff[co..co + m.color_offsets.len()],
3085                &m.color_offsets[..],
3086                "color_offsets entry {e}"
3087            );
3088        }
3089    }
3090
3091    /// VCL.2 — a decoded voxel clip's frames register as a flipbook of LOD
3092    /// chains, and `set_instance_model` flips which frame an instance
3093    /// draws. The cull state it updates is exactly what
3094    /// `cull_bin_upload` packs into the GPU instance buffer each frame, so
3095    /// TV.3 (clip wiring): `sprite_model_from_clip_frame_with_materials`
3096    /// classifies a clip frame's voxels into a per-voxel `materials` array
3097    /// (parallel to `colors`) by colour; an empty map leaves it empty (the
3098    /// all-opaque clip), identical to `sprite_model_from_clip_frame`.
3099    #[test]
3100    fn clip_frame_with_materials_classifies_by_color() {
3101        use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
3102
3103        let dims = [1u32, 1, 4];
3104        let owpc = dims[2].div_ceil(32).max(1) as usize; // 1
3105        let glass = 0x80AA_BBCC;
3106        let stone = 0x8011_2233;
3107        let frame = VoxelFrame {
3108            occupancy: {
3109                let mut occ = vec![0u32; owpc];
3110                occ[0] |= (1 << 0) | (1 << 1);
3111                occ
3112            },
3113            colors: vec![stone, glass], // ascending z: z=0 stone, z=1 glass
3114            color_offsets: vec![0, 2],
3115        };
3116        let clip = VoxelClip::from_frames(
3117            dims,
3118            [0.5, 0.5, 2.0],
3119            1.0,
3120            LoopMode::Loop,
3121            &[frame],
3122            &[],
3123            33,
3124            0,
3125        );
3126        let decoded = clip.decode().expect("decode");
3127
3128        // Map only the glass colour → material 2; stone stays opaque (0).
3129        let m = sprite_model_from_clip_frame_with_materials(&decoded, 0, &[(Rgb(0x00AA_BBCC), 2)]);
3130        assert_eq!(
3131            m.materials.len(),
3132            m.colors.len(),
3133            "materials parallel to colors"
3134        );
3135        // `colors` is in popcount-rank (ascending z) order: stone then glass.
3136        assert_eq!(
3137            m.materials,
3138            vec![0u8, 2u8],
3139            "stone opaque, glass material 2"
3140        );
3141
3142        // Empty map ⇒ no per-voxel materials, identical to the plain builder.
3143        let plain = sprite_model_from_clip_frame(&decoded, 0);
3144        let plain_mat = sprite_model_from_clip_frame_with_materials(&decoded, 0, &[]);
3145        assert!(plain.materials.is_empty());
3146        assert!(plain_mat.materials.is_empty());
3147        assert_eq!(plain.colors, plain_mat.colors);
3148    }
3149
3150    /// TV.3 (streaming-clip refresh path): `build_sprite_model_with_materials`
3151    /// — the builder behind `GpuBackend::update_sprite_model_with_materials`,
3152    /// which a streaming clip re-runs each frame — classifies a kv6's voxels
3153    /// into a per-voxel `materials` array (popcount-rank order) by colour.
3154    #[test]
3155    fn build_with_materials_classifies_by_color() {
3156        let glass = 0x80AA_BBCC;
3157        let stone = 0x8011_2233;
3158        // One column (x=0,y=0), two voxels: z=0 stone, z=1 glass.
3159        let kv6 = kv6_from(1, 1, 4, &[(0, 0, 0, stone), (0, 0, 1, glass)]);
3160
3161        let m = build_sprite_model_with_materials(&kv6, &[(Rgb(0x00AA_BBCC), 2)]);
3162        assert_eq!(
3163            m.materials.len(),
3164            m.colors.len(),
3165            "materials parallel to colors"
3166        );
3167        assert_eq!(
3168            m.materials,
3169            vec![0u8, 2u8],
3170            "stone opaque, glass material 2"
3171        );
3172
3173        // Empty map ⇒ no per-voxel materials, identical to `build_sprite_model`.
3174        let plain = build_sprite_model(&kv6);
3175        let plain_mat = build_sprite_model_with_materials(&kv6, &[]);
3176        assert!(plain.materials.is_empty());
3177        assert!(plain_mat.materials.is_empty());
3178        assert_eq!(plain.colors, plain_mat.colors);
3179    }
3180
3181    /// flipping `chain_id` redirects the rendered instance to the new
3182    /// frame's resident volume.
3183    #[test]
3184    fn voxel_clip_flipbook_set_instance_model() {
3185        use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
3186        let Some(h) = headless() else { return };
3187
3188        // Two distinct frames of a 1×1×4 clip: frame 0 has a voxel at z=0;
3189        // frame 1 adds z=1 — different occupancy + a longer colour run.
3190        let dims = [1u32, 1, 4];
3191        let owpc = dims[2].div_ceil(32).max(1) as usize; // 1
3192        let mk_frame = |zs: &[u32], cols: &[u32]| -> VoxelFrame {
3193            let mut occ = vec![0u32; owpc];
3194            for &z in zs {
3195                occ[(z >> 5) as usize] |= 1u32 << (z & 31);
3196            }
3197            VoxelFrame {
3198                occupancy: occ,
3199                colors: cols.to_vec(),
3200                color_offsets: vec![0, cols.len() as u32],
3201            }
3202        };
3203        let f0 = mk_frame(&[0], &[0x8011_2233]);
3204        let f1 = mk_frame(&[0, 1], &[0x8011_2233, 0x80AA_BBCC]);
3205        let clip = VoxelClip::from_frames(
3206            dims,
3207            [0.5, 0.5, 2.0],
3208            1.0,
3209            LoopMode::Loop,
3210            &[f0, f1],
3211            &[],
3212            33,
3213            0,
3214        );
3215        let decoded = clip.decode().expect("decode");
3216
3217        // Each frame → a single-level chain; both volumes resident + distinct.
3218        let mut reg = SpriteModelRegistry::new();
3219        let c0 = reg.add(sprite_model_from_clip_frame(&decoded, 0));
3220        let c1 = reg.add(sprite_model_from_clip_frame(&decoded, 1));
3221        assert_eq!(reg.model(c0).colors.len(), 1);
3222        assert_eq!(reg.model(c1).colors.len(), 2);
3223
3224        // One instance, in front of the test frustum, drawing frame 0.
3225        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(c0, [0.0, 0.0, 5.0])]);
3226        assert_eq!(res.cull[0].chain_id, c0);
3227
3228        // Flip to frame 1: the cull now draws chain c1 (radius reseeded).
3229        res.set_instance_model(&reg, 0, c1);
3230        assert_eq!(res.cull[0].chain_id, c1);
3231        assert_eq!(res.cull[0].radius, reg.model(c1).bound_radius());
3232
3233        // The next cull packs the new chain into the GPU instance buffer
3234        // (visible, no panic).
3235        let f = test_frustum();
3236        let (visible, _, _) = res.cull_bin_upload(&h.device, &h.queue, &f, 64, 64, 16, 1.0);
3237        assert_eq!(visible, 1);
3238
3239        // …and back to frame 0.
3240        res.set_instance_model(&reg, 0, c0);
3241        assert_eq!(res.cull[0].chain_id, c0);
3242
3243        // Out-of-range index is a safe no-op.
3244        res.set_instance_model(&reg, 99, c1);
3245        assert_eq!(res.cull[0].chain_id, c0);
3246    }
3247
3248    fn test_frustum() -> ViewFrustum {
3249        ViewFrustum {
3250            pos: [0.0, 0.0, 0.0],
3251            right: [1.0, 0.0, 0.0],
3252            down: [0.0, 1.0, 0.0],
3253            forward: [0.0, 0.0, 1.0],
3254            half_w: 1.0,
3255            half_h: 1.0,
3256            far: 10_000.0,
3257        }
3258    }
3259
3260    #[test]
3261    fn remove_model_tombstones_frees_and_reuses() {
3262        let Some(h) = headless() else { return };
3263        // Residency with models A and B, one instance each.
3264        let mut reg = SpriteModelRegistry::new();
3265        let a = reg.add(build_sprite_model(&kv6_unsorted()));
3266        let b = reg.add(build_sprite_model(&kv6_other()));
3267        let mut res = SpriteRegistryResident::upload(
3268            &h.device,
3269            &reg,
3270            &[inst(a, [0.0; 3]), inst(b, [1.0, 0.0, 0.0])],
3271        );
3272        assert_eq!(res.live_model_count(), 2);
3273        assert_eq!(res.dead_model_count(), 0);
3274
3275        // Remove B → tombstoned, its colours freed into the pool.
3276        res.remove_model(b);
3277        assert_eq!(res.live_model_count(), 1);
3278        assert_eq!(res.dead_model_count(), 1);
3279        assert_eq!(res.dead.iter().filter(|&&d| d).count(), 1, "one entry dead");
3280        assert!(!res.colors_alloc.free.is_empty(), "B's colour slot freed");
3281
3282        // Adding C reuses the freed slot (free-list first-fit).
3283        let c = reg.add(build_sprite_model(&kv6_other()));
3284        res.add_model(&h.device, &h.queue, &reg, c);
3285        assert_eq!(res.live_model_count(), 2);
3286
3287        // A and C read back correctly; B is dead (skipped).
3288        let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3289        for e in [a as usize, c as usize] {
3290            let m = &reg.entries[e];
3291            let cc = res.meta[e].colors_offset as usize;
3292            assert_eq!(
3293                &cols[cc..cc + m.colors.len()],
3294                &m.colors[..],
3295                "colors entry {e}"
3296            );
3297        }
3298
3299        // The lingering instance of removed B is skipped without panic.
3300        let f = test_frustum();
3301        let _ = res.cull_bin_upload(&h.device, &h.queue, &f, 64, 64, 16, 1.0);
3302    }
3303
3304    #[test]
3305    fn compact_reclaims_holes_keeps_ids_stable() {
3306        let Some(h) = headless() else { return };
3307        let mut reg = SpriteModelRegistry::new();
3308        let a = reg.add(build_sprite_model(&kv6_unsorted()));
3309        let b = reg.add(build_sprite_model(&kv6_other()));
3310        let c = reg.add(build_sprite_model(&kv6_other()));
3311        let mut res = SpriteRegistryResident::upload(
3312            &h.device,
3313            &reg,
3314            &[inst(a, [0.0; 3]), inst(b, [1.0; 3]), inst(c, [2.0; 3])],
3315        );
3316        let occ_used_full = res.occ_used;
3317
3318        // Remove the middle model, then compact.
3319        res.remove_model(b);
3320        res.compact(&h.device, &h.queue, &reg);
3321
3322        // Holes reclaimed: occupancy now only covers A + C.
3323        let live_occ: u32 = [a, c]
3324            .iter()
3325            .map(|&e| reg.entries[e as usize].occupancy.len() as u32)
3326            .sum();
3327        assert_eq!(res.occ_used, live_occ);
3328        assert!(res.occ_used < occ_used_full, "compaction shrank occupancy");
3329        // Dead entry keeps a zeroed tombstone; ids unchanged.
3330        assert_eq!(res.meta[b as usize].occupancy_offset, 0);
3331        assert_eq!(res.live_model_count(), 2);
3332        assert_eq!(res.dead_model_count(), 1);
3333
3334        // Live entries read back correctly at their new offsets.
3335        let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3336        let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3337        for &e in &[a as usize, c as usize] {
3338            let m = &reg.entries[e];
3339            let oo = res.meta[e].occupancy_offset as usize;
3340            assert_eq!(
3341                &occ[oo..oo + m.occupancy.len()],
3342                &m.occupancy[..],
3343                "occ {e}"
3344            );
3345            let cc = res.meta[e].colors_offset as usize;
3346            assert_eq!(&cols[cc..cc + m.colors.len()], &m.colors[..], "cols {e}");
3347        }
3348
3349        // Chain ids still valid: C's chain still resolves; B's is empty.
3350        assert!(!res.chains[c as usize].is_empty());
3351        assert!(res.chains[b as usize].is_empty());
3352    }
3353
3354    #[test]
3355    fn remove_swap_semantics_and_capacity_retained() {
3356        let Some(h) = headless() else { return };
3357        let (reg, m) = one_model_registry();
3358        let seed: Vec<_> = (0..4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
3359        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &seed);
3360        assert_eq!(res.instance_count(), 4);
3361        let cap = res.instance_capacity;
3362
3363        // Remove a middle element → the previous last (idx 3) moved into it.
3364        assert_eq!(res.remove_instance(1), Some(3));
3365        assert_eq!(res.instance_count(), 3);
3366
3367        // Remove the current last (idx 2) → nothing moved.
3368        assert_eq!(res.remove_instance(2), None);
3369        assert_eq!(res.instance_count(), 2);
3370
3371        // Out of range → None.
3372        assert_eq!(res.remove_instance(99), None);
3373        assert_eq!(res.instance_count(), 2);
3374
3375        // Capacity is retained for reuse (no shrink).
3376        assert_eq!(res.instance_capacity, cap);
3377    }
3378}