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/// CA.4 — one clipped grid's cutaway volume for the sprite cull: the
775/// **footprint rule**. An instance whose origin, mapped into the grid's
776/// local voxel frame, lands inside the grid's XY chunk footprint with
777/// `z < z_clip` is hidden this frame — dropped from the visible set,
778/// which also stops it casting sprite shadows (the shadow pass marches
779/// the culled visible set). Instances outside the footprint are never
780/// affected. Built per frame by `SceneRenderer::render_scene` from the
781/// per-grid world transforms + the resident chunk AABBs.
782#[derive(Clone, Copy, PartialEq)]
783pub struct SpriteCutawayClip {
784    /// Grid world origin.
785    pub origin: [f32; 3],
786    /// World→local rotation rows: `local[i] = dot(inv_rows[i], w - origin)`
787    /// (= the grid's local→world rotation columns, reused as rows).
788    pub inv_rows: [[f32; 3]; 3],
789    /// `1 / voxel_world_size` — world offsets → voxel coords.
790    pub inv_vws: f32,
791    /// XY footprint `[lo, hi)` of the grid's resident chunks, voxel coords.
792    pub xy_lo: [f32; 2],
793    /// See [`Self::xy_lo`].
794    pub xy_hi: [f32; 2],
795    /// The clip plane ([`crate::GridWorldTransform::z_clip`]), voxel z.
796    pub z_clip: f32,
797}
798
799impl SpriteCutawayClip {
800    /// Whether the footprint rule hides a world-space point.
801    fn hides(&self, p: [f32; 3]) -> bool {
802        let rel = [
803            p[0] - self.origin[0],
804            p[1] - self.origin[1],
805            p[2] - self.origin[2],
806        ];
807        let x = dot3(self.inv_rows[0], rel) * self.inv_vws;
808        if x < self.xy_lo[0] || x >= self.xy_hi[0] {
809            return false;
810        }
811        let y = dot3(self.inv_rows[1], rel) * self.inv_vws;
812        if y < self.xy_lo[1] || y >= self.xy_hi[1] {
813            return false;
814        }
815        dot3(self.inv_rows[2], rel) * self.inv_vws < self.z_clip
816    }
817
818    /// Bitwise field dump feeding the [`CullKey`] clip fingerprint.
819    fn key_bits(&self) -> [u32; 18] {
820        let b = |v: f32| v.to_bits();
821        [
822            b(self.origin[0]),
823            b(self.origin[1]),
824            b(self.origin[2]),
825            b(self.inv_rows[0][0]),
826            b(self.inv_rows[0][1]),
827            b(self.inv_rows[0][2]),
828            b(self.inv_rows[1][0]),
829            b(self.inv_rows[1][1]),
830            b(self.inv_rows[1][2]),
831            b(self.inv_rows[2][0]),
832            b(self.inv_rows[2][1]),
833            b(self.inv_rows[2][2]),
834            b(self.inv_vws),
835            b(self.xy_lo[0]),
836            b(self.xy_lo[1]),
837            b(self.xy_hi[0]),
838            b(self.xy_hi[1]),
839            b(self.z_clip),
840        ]
841    }
842}
843
844/// CA.4 — FNV-1a fold of the frame's clip volumes for the [`CullKey`]:
845/// keeps the key `Copy` and allocation-free on the PF.10 skip path (a
846/// per-frame `Vec` of bit dumps would allocate just to compare-and-die
847/// on the very path whose purpose is skipping work). Seeded per-clip
848/// with the count so `[]` vs `[identity-ish]` can't alias trivially; a
849/// 64-bit collision mis-SKIPS one cull for one frame at worst.
850fn clips_fingerprint(clips: &[SpriteCutawayClip]) -> u64 {
851    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
852    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
853    let mut h = FNV_OFFSET ^ clips.len() as u64;
854    for c in clips {
855        for w in c.key_bits() {
856            h = (h ^ u64::from(w)).wrapping_mul(FNV_PRIME);
857        }
858    }
859    h
860}
861
862/// PF.10 — everything `cull_bin_upload`'s result depends on besides the
863/// registry contents (float fields compared bitwise). Paired with the
864/// "registry changed" invalidation (`last_cull = None` in every mutating
865/// method): when the key matches the previous frame's, the cull, the
866/// binning, and all four buffer uploads are skipped — the buffers already
867/// hold exactly this frame's data. CA.4 adds the cutaway-clip volumes
868/// (as an FNV-1a fingerprint — see [`clips_fingerprint`]) — moving a
869/// clip plane changes the visible set, so it must invalidate the cache
870/// like a camera move.
871#[derive(Clone, Copy, PartialEq)]
872struct CullKey {
873    frustum: [u32; 15],
874    screen: [u32; 4],
875    clips_fp: u64,
876    /// FW.4 — the fog-of-war mask version. Bumps when the mask changes,
877    /// so a sprite whose cell crossed the visible/hidden boundary re-culls
878    /// (the fog hide test below reads the current mask).
879    fog_version: u64,
880}
881
882impl CullKey {
883    fn new(
884        f: &ViewFrustum,
885        screen_w: u32,
886        screen_h: u32,
887        tile_size: u32,
888        lod_px: f32,
889        clips: &[SpriteCutawayClip],
890        fog_version: u64,
891    ) -> Self {
892        let b = |v: f32| v.to_bits();
893        Self {
894            fog_version,
895            frustum: [
896                b(f.pos[0]),
897                b(f.pos[1]),
898                b(f.pos[2]),
899                b(f.right[0]),
900                b(f.right[1]),
901                b(f.right[2]),
902                b(f.down[0]),
903                b(f.down[1]),
904                b(f.down[2]),
905                b(f.forward[0]),
906                b(f.forward[1]),
907                b(f.forward[2]),
908                b(f.half_w),
909                b(f.half_h),
910                b(f.far),
911            ],
912            screen: [screen_w, screen_h, tile_size, lod_px.to_bits()],
913            clips_fp: clips_fingerprint(clips),
914        }
915    }
916}
917
918/// PF.10 — reusable cull/bin workspace (was 6+ fresh `Vec`s per frame).
919#[derive(Default)]
920struct CullScratch {
921    visible: Vec<SpriteInstanceGpu>,
922    boxes: Vec<[i32; 4]>,
923    colmul: Vec<u32>,
924    counts: Vec<u32>,
925    tile_ranges: Vec<u32>,
926    tile_instances: Vec<u32>,
927    cursor: Vec<u32>,
928}
929
930/// Build one CPU cull record from a user [`SpriteInstance`]: pack the
931/// transform, seed the bounding sphere from the chain's finest model, and
932/// start `colmul` at identity. Shared by the full
933/// [`SpriteRegistryResident::upload`] and the incremental
934/// [`SpriteRegistryResident::append_instances`].
935fn make_cull(registry: &SpriteModelRegistry, i: &SpriteInstance) -> CullInstance {
936    let model_radius = registry.model(i.model_id).bound_radius();
937    CullInstance {
938        gpu: SpriteInstanceGpu {
939            inv_rot0: i.transform.inv_rot[0],
940            inv_rot1: i.transform.inv_rot[1],
941            inv_rot2: i.transform.inv_rot[2],
942            pos: i.transform.pos,
943            model_id: i.model_id, // placeholder; cull rewrites per frame
944            material: u32::from(i.material),
945            alpha_mul: f32::from(i.alpha_mul) / 255.0,
946            flags: i.flags,
947            tint: i.tint,
948        },
949        chain_id: i.model_id,
950        center: i.transform.pos,
951        radius: model_radius * i.transform.max_scale,
952        model_radius,
953        max_scale: i.transform.max_scale,
954        colmul: identity_colmul(),
955    }
956}
957
958/// Allocate the `instances` capacity buffer (`STORAGE | COPY_DST`) sized
959/// for `cap` records (≥1). Left uninitialised — `cull_bin_upload`
960/// rewrites it (offset 0) each frame, and `append_instances` seeds the
961/// live records after a grow.
962fn instances_buffer(device: &wgpu::Device, cap: u32) -> wgpu::Buffer {
963    device.create_buffer(&wgpu::BufferDescriptor {
964        label: Some("roxlap-gpu sprite_reg.instances"),
965        size: u64::from(cap.max(1)) * std::mem::size_of::<SpriteInstanceGpu>() as u64,
966        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
967        mapped_at_creation: false,
968    })
969}
970
971/// One sprite instance: a model reference + world pose.
972#[derive(Debug, Clone, Copy)]
973pub struct SpriteInstance {
974    /// LOD-chain id from [`SpriteModelRegistry::add`] / `add_lod` —
975    /// which model this instance draws. The per-frame cull substitutes
976    /// the distance-picked concrete mip entry.
977    pub model_id: u32,
978    /// World pose: inverse model→world rotation/scale + position (see
979    /// [`SpriteInstanceTransform::from_sprite`]).
980    pub transform: SpriteInstanceTransform,
981    /// Voxel-material id (TV stage): indexes the renderer's global material
982    /// palette for this instance's opacity + blend mode. `0` (the default)
983    /// is opaque, so an unset instance renders unchanged.
984    pub material: u8,
985    /// Per-instance alpha multiplier (TV stage), `0..=255` (`255` =
986    /// unscaled, the default).
987    pub alpha_mul: u8,
988    /// XS.4 — sprite shadow flags (`roxlap_formats::sprite` bits 4/5:
989    /// `NO_SHADOW_CAST` / `NO_SHADOW_RECEIVE`). `0` (default) ⇒ casts +
990    /// receives. Only honoured when the device is sprite-shadow capable.
991    pub flags: u32,
992    /// Per-instance RGB tint, packed `0x00RRGGBB` (white `0x00FF_FFFF` = no-op).
993    pub tint: u32,
994}
995
996impl SpriteInstance {
997    /// A model reference + pose with the default opaque material
998    /// (`material = 0`, `alpha_mul = 255`), shadows on (`flags = 0`), and no
999    /// tint (`0x00FF_FFFF`).
1000    #[must_use]
1001    pub fn new(model_id: u32, transform: SpriteInstanceTransform) -> Self {
1002        Self {
1003            model_id,
1004            transform,
1005            material: 0,
1006            alpha_mul: 255,
1007            flags: 0,
1008            tint: 0x00FF_FFFF,
1009        }
1010    }
1011}
1012
1013/// GPU per-model metadata: where this model's data starts in the
1014/// shared registry buffers + its dims/pivot. Mirrors `ModelMeta` in
1015/// the shader (std430, 48 bytes).
1016#[repr(C)]
1017#[derive(Clone, Copy, Pod, Zeroable, Debug)]
1018struct SpriteModelMeta {
1019    occupancy_offset: u32,
1020    colors_offset: u32,
1021    color_offsets_offset: u32,
1022    occ_words_per_col: u32,
1023    dims: [u32; 3],
1024    /// TV.3 — 1 if this model has per-voxel materials (`materials_vox` is
1025    /// populated for it); 0 ⇒ use the instance's uniform material.
1026    has_vox_materials: u32,
1027    pivot: [f32; 3],
1028    /// GPU.10.4 — world size of one voxel of this (mip) entry.
1029    voxel_world_size: f32,
1030}
1031
1032/// GPU per-instance record. Mirrors `Instance` in the shader (std430,
1033/// 80 bytes): inverse rotation columns + position + model id + the TV
1034/// material id and per-instance alpha multiplier.
1035#[repr(C)]
1036#[derive(Clone, Copy, Pod, Zeroable, Debug)]
1037struct SpriteInstanceGpu {
1038    inv_rot0: [f32; 4],
1039    inv_rot1: [f32; 4],
1040    inv_rot2: [f32; 4],
1041    pos: [f32; 3],
1042    model_id: u32,
1043    /// TV: material id into the global palette (binding 12).
1044    material: u32,
1045    /// TV: per-instance alpha multiplier, normalised to `0..=1`.
1046    alpha_mul: f32,
1047    /// XS.4 — sprite shadow flags (mirror of `roxlap_formats::sprite` bits 4/5):
1048    /// bit4 = NO_SHADOW_CAST, bit5 = NO_SHADOW_RECEIVE. `0` ⇒ casts + receives.
1049    flags: u32,
1050    /// Per-instance RGB tint, packed `0x00RRGGBB` (white `0x00FF_FFFF` = no-op).
1051    tint: u32,
1052}
1053
1054/// Invert a 3×3 matrix given as basis columns `[c0, c1, c2]`,
1055/// returning the inverse as columns. For an orthonormal basis this is
1056/// the transpose; the general path covers rotation + non-unit scale.
1057#[must_use]
1058fn mat3_inverse(cols: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
1059    let [a, b, c] = cols; // columns
1060                          // Determinant via scalar triple product a · (b × c).
1061    let cross = |u: [f32; 3], v: [f32; 3]| {
1062        [
1063            u[1] * v[2] - u[2] * v[1],
1064            u[2] * v[0] - u[0] * v[2],
1065            u[0] * v[1] - u[1] * v[0],
1066        ]
1067    };
1068    let bc = cross(b, c);
1069    let ca = cross(c, a);
1070    let ab = cross(a, b);
1071    let det = a[0] * bc[0] + a[1] * bc[1] + a[2] * bc[2];
1072    let inv_det = if det.abs() < 1e-12 { 0.0 } else { 1.0 / det };
1073    // Inverse rows are (b×c, c×a, a×b)/det; return as columns of the
1074    // inverse, i.e. transpose of those rows.
1075    [
1076        [bc[0] * inv_det, ca[0] * inv_det, ab[0] * inv_det],
1077        [bc[1] * inv_det, ca[1] * inv_det, ab[1] * inv_det],
1078        [bc[2] * inv_det, ca[2] * inv_det, ab[2] * inv_det],
1079    ]
1080}
1081
1082/// GPU-resident registry + instances: every model's occupancy /
1083/// colours / offsets concatenated into shared storage buffers, a
1084/// per-model metadata table, and a capacity-sized instance buffer
1085/// rewritten each frame with the frustum-visible subset (GPU.10.2).
1086/// One bind group serves all models (same approach as the multi-grid
1087/// scene).
1088pub struct SpriteRegistryResident {
1089    /// Concatenated per-model occupancy bitmaps (1 bit per voxel,
1090    /// 32 per u32 word, z innermost within a column); each model's
1091    /// region starts at its `model_meta` `occupancy_offset`.
1092    pub occupancy: wgpu::Buffer,
1093    /// Concatenated packed voxel colours, one u32 per solid voxel
1094    /// (blue bits 0-7, green 8-15, red 16-23; the high byte is carried
1095    /// through but unread — sprite shading comes from the per-instance
1096    /// `kv6colmul` table). Rank-indexed via [`Self::color_offsets`].
1097    pub colors: wgpu::Buffer,
1098    /// Per-voxel surface-normal index, concatenated across models in the
1099    /// same layout as [`colors`](Self::colors). The shader indexes the
1100    /// per-instance `kv6colmul` table by it.
1101    pub dirs: wgpu::Buffer,
1102    /// Per-voxel material id (TV.3), same layout as [`colors`](Self::colors)
1103    /// (one u32 per voxel). `0` for models without per-voxel materials; the
1104    /// per-model `has_vox_materials` flag in `model_meta` says whether to use
1105    /// it (else the shader falls back to the instance's uniform material).
1106    pub materials_vox: wgpu::Buffer,
1107    /// Concatenated per-model `cols + 1` prefix tables: column
1108    /// `(x, y)`'s colours span
1109    /// `colors[offsets[col] .. offsets[col + 1]]` (offsets are local
1110    /// to the model's colour block).
1111    pub color_offsets: wgpu::Buffer,
1112    /// Per-model metadata table (std430, 48 B each): buffer offsets,
1113    /// dims, pivot, per-voxel-materials flag, and the mip entry's
1114    /// `voxel_world_size`. Indexed by the instance's culled `model_id`.
1115    pub model_meta: wgpu::Buffer,
1116    /// Holds up to `instance_capacity` instances; the visible subset
1117    /// is packed into `[0, count)` each frame by [`Self::cull_bin_upload`].
1118    pub instances: wgpu::Buffer,
1119    /// Allocation size of [`Self::instances`] in records (grown
1120    /// power-of-2-style by `append_instances`); the per-frame visible
1121    /// count is at most this.
1122    pub instance_capacity: u32,
1123    /// Per-visible-instance `kv6colmul[256]` tables, packed in the same
1124    /// order as the `instances` buffer each frame (two u32 per u64
1125    /// entry: lanes 0|1 then 2|3). Sized `instance_capacity * 256 * 2`
1126    /// u32; rewritten by [`Self::cull_bin_upload`].
1127    pub colmul: wgpu::Buffer,
1128    colmul_cap: u32,
1129    /// GPU.10.3 — per-tile `(offset, count)` into `tile_instances`,
1130    /// flat `2 * tiles_x * tiles_y` u32s. Grown to fit the screen.
1131    pub tile_ranges: wgpu::Buffer,
1132    tile_ranges_cap: u32,
1133    /// GPU.10.3 — flat list of visible-instance indices grouped by
1134    /// tile. Grown to fit the per-frame total.
1135    pub tile_instances: wgpu::Buffer,
1136    tile_instances_cap: u32,
1137    /// CPU cull records (full set), with precomputed bounding spheres.
1138    cull: Vec<CullInstance>,
1139    /// GPU.10.4 — LOD chains: `chains[chain_id]` = entry ids, finest
1140    /// first. The cull picks a level by distance and writes its entry
1141    /// id into the packed instance's `model_id`.
1142    chains: Vec<Vec<u32>>,
1143    /// GPU.12 incremental — CPU mirror of the GPU `model_meta` table, one
1144    /// per concrete entry. [`Self::update_model`] reads the fixed
1145    /// occupancy/color_offsets bases from here and rewrites the changed
1146    /// `colors_offset` on a relocation.
1147    meta: Vec<SpriteModelMeta>,
1148    /// GPU.12 incremental — per-entry placement of `colors`/`dirs` in the
1149    /// shared buffers (drives both; same offsets/ranks). Lets an edit
1150    /// re-upload one model's data without touching the others.
1151    colors_alloc: ColorsAllocator,
1152    /// PF.10 — the (frustum, screen) key + result of the last
1153    /// `cull_bin_upload`; `None` after any registry mutation. A matching
1154    /// key skips the whole cull/bin/upload (buffers already current).
1155    last_cull: Option<(CullKey, (u32, u32, u32))>,
1156    /// PF.10 — true once ANY per-instance colmul table was set. While
1157    /// false every table is identity, so the 2 KiB-per-visible-instance
1158    /// rebuild + upload is skipped; the buffer is identity-filled lazily
1159    /// instead (`colmul_identity`).
1160    any_colmul: bool,
1161    /// PF.10 — whether the whole `colmul` buffer currently holds the
1162    /// identity pattern (reset on growth).
1163    colmul_identity: bool,
1164    /// PF.10 — reusable cull/bin workspace.
1165    scratch: CullScratch,
1166    /// Per-entry word length of the dims-fixed `occupancy` and
1167    /// `color_offsets` arrays, kept so [`Self::update_model`] can assert a
1168    /// carve never changed dims (which would invalidate the in-place
1169    /// writes — growing dims is out of scope, handled by a full re-upload).
1170    occ_lens: Vec<u32>,
1171    coloff_lens: Vec<u32>,
1172    /// Used / allocated words of the tightly-concatenated `occupancy`
1173    /// buffer. `add_model` bump-appends at `occ_used`; when it would pass
1174    /// `occ_cap` the buffer is grown (with slack) and rebuilt from the
1175    /// registry. (`colors`/`dirs` track theirs in [`ColorsAllocator`].)
1176    occ_used: u32,
1177    occ_cap: u32,
1178    /// Used / allocated words of the tightly-concatenated `color_offsets`
1179    /// buffer — same growth scheme as `occ_*`.
1180    coloff_used: u32,
1181    coloff_cap: u32,
1182    /// Allocated record count of the `model_meta` buffer; `add_model`
1183    /// grows it (with slack) when the entry count passes it.
1184    meta_cap: u32,
1185    /// Per-entry tombstone: `true` once its model was removed
1186    /// ([`Self::remove_model`]). Dead entries keep their `meta` slot (so
1187    /// entry ids — and the caller's `chain_id`s — stay stable) but their
1188    /// colours are freed for reuse and they contribute nothing to a
1189    /// repack / [`Self::compact`]. Parallel to `meta`.
1190    dead: Vec<bool>,
1191}
1192
1193/// Which tightly-concatenated registry buffer [`SpriteRegistryResident::
1194/// sync_concat`] is operating on.
1195#[derive(Clone, Copy)]
1196enum ConcatBuf {
1197    Occupancy,
1198    ColorOffsets,
1199}
1200
1201/// The model's source array for a given [`ConcatBuf`] — a free fn (not a
1202/// closure) so the returned borrow keeps `m`'s lifetime.
1203fn concat_data(m: &SpriteModel, which: ConcatBuf) -> &[u32] {
1204    match which {
1205        ConcatBuf::Occupancy => &m.occupancy,
1206        ConcatBuf::ColorOffsets => &m.color_offsets,
1207    }
1208}
1209
1210impl SpriteRegistryResident {
1211    /// Concatenate `registry`'s models into shared buffers and prepare
1212    /// `instances` for per-frame culling. Model-relative indices stay
1213    /// as built; the shader adds each model's base offset from the
1214    /// metadata table.
1215    #[must_use]
1216    pub fn upload(
1217        device: &wgpu::Device,
1218        registry: &SpriteModelRegistry,
1219        instances: &[SpriteInstance],
1220    ) -> Self {
1221        // `occupancy` + `color_offsets` are dims-fixed → tightly
1222        // concatenated (never grow on a carve). `colors` + `dirs` are
1223        // variable → laid out by the suballocator with per-slot slack so
1224        // an incremental edit can rewrite one model in place.
1225        let entry_lens: Vec<u32> = registry
1226            .entries
1227            .iter()
1228            .map(|m| m.colors.len() as u32)
1229            .collect();
1230        let colors_alloc = ColorsAllocator::new(&entry_lens);
1231        let cap_total = colors_alloc.cap_total();
1232
1233        let mut all_occ: Vec<u32> = Vec::new();
1234        let mut all_offsets: Vec<u32> = Vec::new();
1235        let mut all_colors: Vec<u32> = vec![0; cap_total as usize];
1236        let mut all_dirs: Vec<u32> = vec![0; cap_total as usize];
1237        let mut all_materials: Vec<u32> = vec![0; cap_total as usize];
1238        let mut meta: Vec<SpriteModelMeta> = Vec::with_capacity(registry.entries.len());
1239        let mut occ_lens: Vec<u32> = Vec::with_capacity(registry.entries.len());
1240        let mut coloff_lens: Vec<u32> = Vec::with_capacity(registry.entries.len());
1241
1242        // One meta + placed data per concrete (mip-level) entry.
1243        for (e, m) in registry.entries.iter().enumerate() {
1244            let slot = colors_alloc.slot(e);
1245            meta.push(SpriteModelMeta {
1246                occupancy_offset: all_occ.len() as u32,
1247                colors_offset: slot.off,
1248                color_offsets_offset: all_offsets.len() as u32,
1249                occ_words_per_col: m.occ_words_per_col,
1250                dims: m.dims,
1251                has_vox_materials: u32::from(!m.materials.is_empty()),
1252                pivot: m.pivot,
1253                voxel_world_size: m.voxel_world_size,
1254            });
1255            occ_lens.push(m.occupancy.len() as u32);
1256            coloff_lens.push(m.color_offsets.len() as u32);
1257            all_occ.extend_from_slice(&m.occupancy);
1258            all_offsets.extend_from_slice(&m.color_offsets);
1259            let off = slot.off as usize;
1260            all_colors[off..off + m.colors.len()].copy_from_slice(&m.colors);
1261            all_dirs[off..off + m.dirs.len()].copy_from_slice(&m.dirs);
1262            for (i, &mat) in m.materials.iter().enumerate() {
1263                all_materials[off + i] = u32::from(mat);
1264            }
1265        }
1266
1267        // Per-instance cull records: sphere centred at the instance
1268        // position, radius from the chain's finest (mip-0) model.
1269        // `colmul` starts at identity (unshaded) until the facade sets
1270        // per-instance lighting via `set_instance_colmul`.
1271        let cull: Vec<CullInstance> = instances.iter().map(|i| make_cull(registry, i)).collect();
1272
1273        // Capacity buffer (COPY_DST so cull can rewrite it each frame),
1274        // seeded with the full set so frame 0 is valid pre-cull.
1275        let seed: Vec<SpriteInstanceGpu> = cull.iter().map(|c| c.gpu).collect();
1276        let instances_buf = {
1277            use wgpu::util::DeviceExt;
1278            let one = [SpriteInstanceGpu::zeroed()];
1279            let src: &[SpriteInstanceGpu] = if seed.is_empty() { &one } else { &seed };
1280            device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1281                label: Some("roxlap-gpu sprite_reg.instances"),
1282                contents: bytemuck::cast_slice(src),
1283                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
1284            })
1285        };
1286
1287        let tile_ranges = storage_dst_u32(device, "roxlap-gpu sprite_reg.tile_ranges", 1);
1288        let tile_instances = storage_dst_u32(device, "roxlap-gpu sprite_reg.tile_instances", 1);
1289        // colmul: 256 entries × 2 u32 per visible instance. Sized to the
1290        // full instance set (worst case all visible); rewritten per frame.
1291        let colmul_cap = (cull.len() as u32).max(1) * 256 * 2;
1292        let colmul = storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", colmul_cap);
1293        Self {
1294            occupancy: storage_dst_u32_cap(
1295                device,
1296                "roxlap-gpu sprite_reg.occupancy",
1297                &all_occ,
1298                all_occ.len() as u32,
1299            ),
1300            colors: storage_dst_u32_cap(
1301                device,
1302                "roxlap-gpu sprite_reg.colors",
1303                &all_colors,
1304                cap_total,
1305            ),
1306            dirs: storage_dst_u32_cap(device, "roxlap-gpu sprite_reg.dirs", &all_dirs, cap_total),
1307            materials_vox: storage_dst_u32_cap(
1308                device,
1309                "roxlap-gpu sprite_reg.materials_vox",
1310                &all_materials,
1311                cap_total,
1312            ),
1313            color_offsets: storage_dst_u32_cap(
1314                device,
1315                "roxlap-gpu sprite_reg.color_offsets",
1316                &all_offsets,
1317                all_offsets.len() as u32,
1318            ),
1319            model_meta: storage_dst_pod(device, "roxlap-gpu sprite_reg.model_meta", &meta),
1320            instances: instances_buf,
1321            instance_capacity: cull.len() as u32,
1322            colmul,
1323            colmul_cap,
1324            tile_ranges,
1325            tile_ranges_cap: 1,
1326            tile_instances,
1327            tile_instances_cap: 1,
1328            cull,
1329            chains: registry.chains.clone(),
1330            last_cull: None,
1331            any_colmul: false,
1332            colmul_identity: false,
1333            scratch: CullScratch::default(),
1334            occ_used: all_occ.len() as u32,
1335            occ_cap: all_occ.len() as u32,
1336            coloff_used: all_offsets.len() as u32,
1337            coloff_cap: all_offsets.len() as u32,
1338            meta_cap: meta.len() as u32,
1339            dead: vec![false; meta.len()],
1340            meta,
1341            colors_alloc,
1342            occ_lens,
1343            coloff_lens,
1344        }
1345    }
1346
1347    /// Number of resident instances (the cull set length).
1348    #[must_use]
1349    pub fn instance_count(&self) -> usize {
1350        self.cull.len()
1351    }
1352
1353    /// Append new instances **without** re-uploading any model volume —
1354    /// the incremental counterpart to [`Self::upload`], for streaming
1355    /// spawns (asteroids, projectiles, …). Returns the index of the first
1356    /// appended instance; the block occupies `[base, base + N)`.
1357    ///
1358    /// The model volumes are untouched, so every appended instance must
1359    /// reference a `model_id` (LOD chain) that was already present in the
1360    /// `registry` passed to [`Self::upload`]. Registering a *new* model
1361    /// still requires a full [`Self::upload`] (its voxels must be laid
1362    /// into the shared buffers). `registry` here is only read for the new
1363    /// instances' bound-sphere radii and must be the resident one.
1364    ///
1365    /// The `instances` GPU buffer is only *grown* here (power-of-two,
1366    /// amortised O(1)); its contents are **not** written. [`Self::
1367    /// cull_bin_upload`] rewrites the whole visible range from `cull` every
1368    /// frame before the sprite pass reads it — exactly as for the static
1369    /// instances — so appending only needs to extend `cull` and ensure
1370    /// capacity. Writing the buffer here too caused a mid-frame
1371    /// write-while-in-flight hazard on some drivers (a stray full-screen
1372    /// flash on append). `colmul` likewise grows lazily in
1373    /// `cull_bin_upload`. After a removal the capacity is not shrunk.
1374    pub fn append_instances(
1375        &mut self,
1376        device: &wgpu::Device,
1377        registry: &SpriteModelRegistry,
1378        instances: &[SpriteInstance],
1379    ) -> u32 {
1380        let base = self.cull.len() as u32;
1381        if instances.is_empty() {
1382            return base;
1383        }
1384        self.last_cull = None; // PF.10 — instance set changed
1385        for i in instances {
1386            debug_assert!(
1387                (i.model_id as usize) < self.chains.len(),
1388                "append_instances: model_id {} not resident (run upload to register new models)",
1389                i.model_id
1390            );
1391            self.cull.push(make_cull(registry, i));
1392        }
1393        let need = self.cull.len() as u32;
1394        if need > self.instance_capacity {
1395            // Grow power-of-two and recreate the buffer (the next frame's
1396            // bind group picks up the new handle). No seed write — the
1397            // per-frame cull_bin_upload populates it.
1398            self.instance_capacity = need.next_power_of_two();
1399            self.instances = instances_buffer(device, self.instance_capacity);
1400        }
1401        base
1402    }
1403
1404    /// Remove the instance at `index` by swap-remove — O(1), no GPU work
1405    /// (the next [`Self::cull_bin_upload`] repacks the visible set from
1406    /// the shrunk cull list). Capacity is retained for reuse.
1407    ///
1408    /// Returns `Some(old_last)` when a different instance was moved into
1409    /// `index` to fill the hole (its index changed from `old_last` to
1410    /// `index` — callers holding instance handles must fix up that one),
1411    /// or `None` if `index` was the last element or out of range. Because
1412    /// this reorders, any [`Self::set_instance_colmul`] table set by
1413    /// position should be re-applied after a removal.
1414    pub fn remove_instance(&mut self, index: usize) -> Option<usize> {
1415        if index >= self.cull.len() {
1416            return None;
1417        }
1418        self.last_cull = None; // PF.10 — instance set changed
1419        let last = self.cull.len() - 1;
1420        self.cull.swap_remove(index);
1421        (index != last).then_some(last)
1422    }
1423
1424    /// Set the per-instance `kv6colmul[256]` lighting tables (voxlap's
1425    /// `update_reflects` output), in the same order/length as the
1426    /// instances passed to [`Self::upload`]. The next
1427    /// [`Self::cull_bin_upload`] packs the visible subset to the GPU.
1428    /// Instances beyond `tables.len()` keep their previous tables.
1429    pub fn set_instance_colmul(&mut self, tables: &[[u64; 256]]) {
1430        // PF.10 — leaves the identity fast path for good: from here on the
1431        // per-visible tables are rebuilt + uploaded each cull.
1432        self.any_colmul = true;
1433        self.last_cull = None;
1434        for (ci, t) in self.cull.iter_mut().zip(tables) {
1435            ci.colmul.copy_from_slice(t);
1436        }
1437    }
1438
1439    /// Refresh instance poses in place from `instances` — for animated
1440    /// sprites (e.g. KFA limbs re-posed each frame) — **without** any
1441    /// model-volume re-upload. `instances` must match the set passed to
1442    /// [`Self::upload`] in length + order; each keeps its `model_id`
1443    /// (LOD chain) so only the transform + cull centre change. No GPU
1444    /// write happens here: the next [`Self::cull_bin_upload`] re-uploads
1445    /// the packed visible subset, as it already does every frame.
1446    pub fn update_transforms(&mut self, instances: &[SpriteInstance]) {
1447        debug_assert_eq!(
1448            instances.len(),
1449            self.cull.len(),
1450            "update_transforms instance count must match upload"
1451        );
1452        self.last_cull = None; // PF.10 — poses changed
1453        for (ci, inst) in self.cull.iter_mut().zip(instances) {
1454            ci.gpu.inv_rot0 = inst.transform.inv_rot[0];
1455            ci.gpu.inv_rot1 = inst.transform.inv_rot[1];
1456            ci.gpu.inv_rot2 = inst.transform.inv_rot[2];
1457            ci.gpu.pos = inst.transform.pos;
1458            // TV: material id + alpha multiplier ride the same coalesced
1459            // update as the pose (set via the facade's per-instance setters).
1460            ci.gpu.material = u32::from(inst.material);
1461            ci.gpu.alpha_mul = f32::from(inst.alpha_mul) / 255.0;
1462            // XS.4 shadow flags + per-instance RGB tint also ride this flush,
1463            // so `set_dyn_instance_tint` (and any flag change) takes effect.
1464            ci.gpu.flags = inst.flags;
1465            ci.gpu.tint = inst.tint;
1466            // Bounding sphere follows the pivot and rescales with the
1467            // pose's longest basis column (PS.1 — scaled particles
1468            // must not under-cull); the chain is unchanged.
1469            ci.center = inst.transform.pos;
1470            ci.max_scale = inst.transform.max_scale;
1471            ci.radius = ci.model_radius * inst.transform.max_scale;
1472        }
1473    }
1474
1475    /// Repoint instance `idx` at a different LOD chain — the per-frame
1476    /// **flipbook** step for animated voxel clips (VCL.2). The instance's
1477    /// transform / colmul are untouched; only which model's volume it
1478    /// draws changes. The new chain's volume must already be resident
1479    /// (uploaded via [`Self::add_model`] / [`Self::upload`]); `registry`
1480    /// is the one those uploads used (so the bounding radius reseeds from
1481    /// the new model). Like [`Self::update_transforms`], this is a CPU-side
1482    /// rewrite — the next [`Self::cull_bin_upload`] re-uploads the packed
1483    /// visible subset, so it costs nothing extra on the GPU. No-op if `idx`
1484    /// is out of range.
1485    ///
1486    /// All frames of a clip share the same `dims`, so a flipbook swap
1487    /// leaves the bounding radius unchanged; reseeding it anyway keeps the
1488    /// method correct for arbitrary chain swaps.
1489    pub fn set_instance_model(
1490        &mut self,
1491        registry: &SpriteModelRegistry,
1492        idx: usize,
1493        chain_id: u32,
1494    ) {
1495        self.last_cull = None; // PF.10 — model binding changed
1496                               // Guard `chain_id` (the `cull.get_mut` below only covers `idx`): a
1497                               // public caller could pass an out-of-range / tombstoned chain, which
1498                               // `registry.model` would index-panic on.
1499        let Some(model_radius) = registry
1500            .model_checked(chain_id)
1501            .map(SpriteModel::bound_radius)
1502        else {
1503            return;
1504        };
1505        let Some(ci) = self.cull.get_mut(idx) else {
1506            return;
1507        };
1508        ci.chain_id = chain_id;
1509        ci.gpu.model_id = chain_id; // placeholder; cull rewrites to the LOD entry
1510        ci.model_radius = model_radius;
1511        ci.radius = model_radius * ci.max_scale;
1512    }
1513
1514    /// GPU.12 incremental — re-upload only the entries of LOD chain
1515    /// `chain_id` after an in-place edit (carve / recolour) of its model,
1516    /// **without** rebuilding the whole registry. `registry` must be the
1517    /// same registry uploaded (same entry ids), with chain `chain_id`'s
1518    /// entries already edited (`model_mut` + `rebuild_lod`).
1519    ///
1520    /// For each entry: occupancy + color_offsets are dims-fixed, so they
1521    /// are written in place; colors + dirs (variable, parallel) go through
1522    /// the suballocator — written in place when they fit the slack,
1523    /// relocated (with a `model_meta` rewrite) when they outgrow it, and
1524    /// only when the buffer tail overflows are colors/dirs grown + the
1525    /// whole registry repacked. Instances / cull / colmul are untouched
1526    /// (a carve never moves an instance or grows its bounds) — that is the
1527    /// win over [`Self::upload`].
1528    ///
1529    /// # Panics (debug)
1530    /// If an entry's dims changed (occupancy / color_offsets length), which
1531    /// the in-place path can't absorb — growing dims needs a full
1532    /// re-upload via [`Self::upload`].
1533    pub fn update_model(
1534        &mut self,
1535        device: &wgpu::Device,
1536        queue: &wgpu::Queue,
1537        registry: &SpriteModelRegistry,
1538        chain_id: u32,
1539    ) {
1540        self.last_cull = None; // PF.10 — model volume changed
1541        let entries = self.chains[chain_id as usize].clone();
1542        let mut grew = false;
1543        for &e in &entries {
1544            let e = e as usize;
1545            let m = &registry.entries[e];
1546
1547            // Dims-fixed arrays: assert unchanged, then write in place.
1548            debug_assert_eq!(
1549                m.occupancy.len() as u32,
1550                self.occ_lens[e],
1551                "update_model: entry {e} occupancy length changed (dims grew?)"
1552            );
1553            debug_assert_eq!(
1554                m.color_offsets.len() as u32,
1555                self.coloff_lens[e],
1556                "update_model: entry {e} color_offsets length changed (dims grew?)"
1557            );
1558            queue.write_buffer(
1559                &self.occupancy,
1560                u64::from(self.meta[e].occupancy_offset) * 4,
1561                bytemuck::cast_slice(&m.occupancy),
1562            );
1563            queue.write_buffer(
1564                &self.color_offsets,
1565                u64::from(self.meta[e].color_offsets_offset) * 4,
1566                bytemuck::cast_slice(&m.color_offsets),
1567            );
1568
1569            // Variable colors/dirs via the suballocator.
1570            let new_len = m.colors.len() as u32;
1571            match self.colors_alloc.place(e, new_len) {
1572                Some(off) => {
1573                    queue.write_buffer(
1574                        &self.colors,
1575                        u64::from(off) * 4,
1576                        bytemuck::cast_slice(&m.colors),
1577                    );
1578                    queue.write_buffer(
1579                        &self.dirs,
1580                        u64::from(off) * 4,
1581                        bytemuck::cast_slice(&m.dirs),
1582                    );
1583                    let mats: Vec<u32> = m.materials.iter().map(|&x| u32::from(x)).collect();
1584                    queue.write_buffer(
1585                        &self.materials_vox,
1586                        u64::from(off) * 4,
1587                        bytemuck::cast_slice(&mats),
1588                    );
1589                    if self.meta[e].colors_offset != off {
1590                        // Relocated — rewrite this entry's meta record.
1591                        self.meta[e].colors_offset = off;
1592                        queue.write_buffer(
1593                            &self.model_meta,
1594                            (e * std::mem::size_of::<SpriteModelMeta>()) as u64,
1595                            bytemuck::bytes_of(&self.meta[e]),
1596                        );
1597                    }
1598                }
1599                None => grew = true,
1600            }
1601        }
1602
1603        // Buffer overflow on at least one entry → grow colors/dirs and
1604        // repack the WHOLE registry (rare; offsets for every entry move).
1605        if grew {
1606            self.grow_and_repack(device, queue, registry);
1607        }
1608    }
1609
1610    /// Grow the `colors`/`dirs` buffers and repack every entry compactly
1611    /// (with fresh slack) when an [`Self::update_model`] edit overflowed
1612    /// the buffer tail. Recreates both buffers (the next frame's bind
1613    /// group picks up the new handles) and rewrites every `model_meta`
1614    /// `colors_offset`. O(registry) but rare — logged so a growth burst
1615    /// is visible.
1616    fn grow_and_repack(
1617        &mut self,
1618        device: &wgpu::Device,
1619        queue: &wgpu::Queue,
1620        registry: &SpriteModelRegistry,
1621    ) {
1622        self.repack_colors_dirs(device, registry);
1623        // Every entry's colors_offset moved → rewrite the whole meta table.
1624        queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1625    }
1626
1627    /// Repack `colors`/`dirs` compactly (with fresh slack) from the full
1628    /// `registry`, recreating both buffers and updating every CPU
1629    /// `meta[e].colors_offset`. Does **not** touch the GPU `model_meta`
1630    /// buffer — the caller writes it ([`Self::grow_and_repack`] writes the
1631    /// whole table; [`Self::add_model`] writes it once after all entries
1632    /// are placed). O(registry) but rare — logged so a growth burst is
1633    /// visible.
1634    fn repack_colors_dirs(&mut self, device: &wgpu::Device, registry: &SpriteModelRegistry) {
1635        // Dead (removed) entries collapse to 0 length so they reclaim no
1636        // space; live entries keep their colours.
1637        let new_lens: Vec<u32> = registry
1638            .entries
1639            .iter()
1640            .enumerate()
1641            .map(|(e, m)| {
1642                if self.dead[e] {
1643                    0
1644                } else {
1645                    m.colors.len() as u32
1646                }
1647            })
1648            .collect();
1649        self.colors_alloc.repack(&new_lens);
1650        let cap_total = self.colors_alloc.cap_total();
1651
1652        let mut all_colors = vec![0u32; cap_total as usize];
1653        let mut all_dirs = vec![0u32; cap_total as usize];
1654        let mut all_materials = vec![0u32; cap_total as usize];
1655        for (e, m) in registry.entries.iter().enumerate() {
1656            if self.dead[e] {
1657                self.meta[e].colors_offset = 0;
1658                continue;
1659            }
1660            let off = self.colors_alloc.slot(e).off as usize;
1661            all_colors[off..off + m.colors.len()].copy_from_slice(&m.colors);
1662            all_dirs[off..off + m.dirs.len()].copy_from_slice(&m.dirs);
1663            for (i, &mat) in m.materials.iter().enumerate() {
1664                all_materials[off + i] = u32::from(mat);
1665            }
1666            self.meta[e].colors_offset = off as u32;
1667        }
1668        self.colors = storage_dst_u32_cap(
1669            device,
1670            "roxlap-gpu sprite_reg.colors",
1671            &all_colors,
1672            cap_total,
1673        );
1674        self.dirs = storage_dst_u32_cap(device, "roxlap-gpu sprite_reg.dirs", &all_dirs, cap_total);
1675        self.materials_vox = storage_dst_u32_cap(
1676            device,
1677            "roxlap-gpu sprite_reg.materials_vox",
1678            &all_materials,
1679            cap_total,
1680        );
1681        eprintln!(
1682            "roxlap-gpu: sprite registry colors/dirs/materials grew + repacked to {cap_total} words"
1683        );
1684    }
1685
1686    /// Append a new model (its full LOD chain) to the resident registry
1687    /// **without** re-uploading the existing models' volumes — the
1688    /// incremental counterpart to a full [`Self::upload`], for streaming
1689    /// in new geometry (unique asteroids, generated meshes).
1690    ///
1691    /// Contract (mirrors [`Self::update_model`]): the caller owns the
1692    /// `SpriteModelRegistry`, has just appended this chain to it (e.g. via
1693    /// [`SpriteModelRegistry::add_lod`]), and passes the resulting
1694    /// `chain_id`. The chain's entries must be the registry's newest (ids
1695    /// `>= ` the resident entry count) — entries are append-only.
1696    ///
1697    /// The large `colors`/`dirs`/`occupancy`/`color_offsets` buffers carry
1698    /// slack and bump-append the new entries in place; a buffer that
1699    /// overflows is grown (with slack) and rebuilt once from the registry
1700    /// (amortised O(1) per add). The small `model_meta` table is rewritten
1701    /// each call. After this, [`Self::append_instances`] can reference the
1702    /// new `chain_id`.
1703    pub fn add_model(
1704        &mut self,
1705        device: &wgpu::Device,
1706        queue: &wgpu::Queue,
1707        registry: &SpriteModelRegistry,
1708        chain_id: u32,
1709    ) {
1710        self.last_cull = None; // PF.10 — chain set changed
1711        let entries = registry.chains[chain_id as usize].clone();
1712        debug_assert_eq!(
1713            chain_id as usize,
1714            self.chains.len(),
1715            "add_model: chains must be appended in order"
1716        );
1717
1718        // CPU bookkeeping: assign each new entry a tight occ/coloff offset
1719        // and an allocator slot for colors/dirs. `need_colors_grow` marks
1720        // a slot that didn't fit → a colors/dirs repack below.
1721        let mut need_colors_grow = false;
1722        for &e in &entries {
1723            let e = e as usize;
1724            debug_assert_eq!(
1725                e,
1726                self.meta.len(),
1727                "add_model: entries must be appended in order"
1728            );
1729            let m = &registry.entries[e];
1730            let occ_off = self.occ_used;
1731            let coloff_off = self.coloff_used;
1732            self.occ_used += m.occupancy.len() as u32;
1733            self.coloff_used += m.color_offsets.len() as u32;
1734            let colors_off = match self.colors_alloc.push(m.colors.len() as u32) {
1735                Some(off) => off,
1736                None => {
1737                    need_colors_grow = true;
1738                    0 // placeholder; repack assigns the real offset
1739                }
1740            };
1741            self.meta.push(SpriteModelMeta {
1742                occupancy_offset: occ_off,
1743                colors_offset: colors_off,
1744                color_offsets_offset: coloff_off,
1745                occ_words_per_col: m.occ_words_per_col,
1746                dims: m.dims,
1747                has_vox_materials: u32::from(!m.materials.is_empty()),
1748                pivot: m.pivot,
1749                voxel_world_size: m.voxel_world_size,
1750            });
1751            self.occ_lens.push(m.occupancy.len() as u32);
1752            self.coloff_lens.push(m.color_offsets.len() as u32);
1753            self.dead.push(false);
1754        }
1755        self.chains.push(entries.clone());
1756
1757        // occupancy + color_offsets: grow+rebuild on overflow, else write
1758        // the new tails in place.
1759        self.sync_concat(device, queue, registry, &entries, ConcatBuf::Occupancy);
1760        self.sync_concat(device, queue, registry, &entries, ConcatBuf::ColorOffsets);
1761
1762        // colors/dirs: repack on overflow (rebuilds both + every CPU
1763        // colors_offset), else write the new entries at their slots.
1764        if need_colors_grow {
1765            self.repack_colors_dirs(device, registry);
1766        } else {
1767            for &e in &entries {
1768                let e = e as usize;
1769                let m = &registry.entries[e];
1770                let off = u64::from(self.meta[e].colors_offset) * 4;
1771                queue.write_buffer(&self.colors, off, bytemuck::cast_slice(&m.colors));
1772                queue.write_buffer(&self.dirs, off, bytemuck::cast_slice(&m.dirs));
1773                let mats: Vec<u32> = m.materials.iter().map(|&x| u32::from(x)).collect();
1774                queue.write_buffer(&self.materials_vox, off, bytemuck::cast_slice(&mats));
1775            }
1776        }
1777
1778        // model_meta: grow the record buffer if needed, then rewrite the
1779        // whole (small) table — covers both new records and any
1780        // colors_offset relocations from a repack.
1781        let count = self.meta.len() as u32;
1782        if count > self.meta_cap {
1783            self.meta_cap = grow_records(count);
1784            self.model_meta = storage_dst_pod_cap(
1785                device,
1786                "roxlap-gpu sprite_reg.model_meta",
1787                &self.meta,
1788                self.meta_cap,
1789            );
1790        } else {
1791            queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1792        }
1793    }
1794
1795    /// Sync one tightly-concatenated buffer (`occupancy` or
1796    /// `color_offsets`) after `add_model` appended `new_entries`: if the
1797    /// used length now exceeds capacity, grow (with slack) and rebuild the
1798    /// whole buffer from the registry; otherwise write just the appended
1799    /// tails at their offsets.
1800    fn sync_concat(
1801        &mut self,
1802        device: &wgpu::Device,
1803        queue: &wgpu::Queue,
1804        registry: &SpriteModelRegistry,
1805        new_entries: &[u32],
1806        which: ConcatBuf,
1807    ) {
1808        let (used, cap) = match which {
1809            ConcatBuf::Occupancy => (self.occ_used, self.occ_cap),
1810            ConcatBuf::ColorOffsets => (self.coloff_used, self.coloff_cap),
1811        };
1812        if used > cap {
1813            // The bump layout overflowed: rebuild through the COMPACTOR,
1814            // which re-packs live entries tightly AND rewrites their
1815            // meta offsets (`add_model` re-uploads the whole model_meta
1816            // table right after this, so the recomputed offsets reach
1817            // the GPU for free) — and reclaiming tombstone holes may
1818            // absorb the growth outright.
1819            //
1820            // The previous code here rebuilt tightly but kept the STALE
1821            // bump offsets in `meta`: after any `remove_model` hole,
1822            // every live model behind it read its volume at a shifted
1823            // offset — permanent "black stripe" corruption, repaired
1824            // per-model only by an `update_model` rewrite. Root-caused
1825            // by the roxlap-game-demo author (0.27.0).
1826            self.compact_concat(device, registry, which);
1827        } else {
1828            let target = match which {
1829                ConcatBuf::Occupancy => &self.occupancy,
1830                ConcatBuf::ColorOffsets => &self.color_offsets,
1831            };
1832            for &e in new_entries {
1833                let e = e as usize;
1834                let off = match which {
1835                    ConcatBuf::Occupancy => self.meta[e].occupancy_offset,
1836                    ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset,
1837                };
1838                queue.write_buffer(
1839                    target,
1840                    u64::from(off) * 4,
1841                    bytemuck::cast_slice(concat_data(&registry.entries[e], which)),
1842                );
1843            }
1844        }
1845    }
1846
1847    /// Number of removed-but-not-yet-compacted models (tombstoned chains).
1848    /// A caller streams `add_model` / `remove_model` and calls
1849    /// [`Self::compact`] once this (relative to [`Self::live_model_count`])
1850    /// crosses a threshold.
1851    #[must_use]
1852    pub fn dead_model_count(&self) -> usize {
1853        self.chains.iter().filter(|c| c.is_empty()).count()
1854    }
1855
1856    /// Number of live (non-removed) models.
1857    #[must_use]
1858    pub fn live_model_count(&self) -> usize {
1859        self.chains.iter().filter(|c| !c.is_empty()).count()
1860    }
1861
1862    /// Remove a model (tombstone its LOD chain) — the counterpart to
1863    /// [`Self::add_model`]. O(chain length): marks the chain's entries
1864    /// dead and frees their `colors`/`dirs` slots for reuse by a later
1865    /// `add_model`. The `occupancy` / `color_offsets` holes are **not**
1866    /// reclaimed until [`Self::compact`]; entry ids (and the caller's other
1867    /// `chain_id`s) stay stable.
1868    ///
1869    /// Instances of the removed chain are **not** dropped here — they
1870    /// linger in the cull set but draw as nothing (skipped in
1871    /// [`Self::cull_bin_upload`]); the caller removes them via
1872    /// [`Self::remove_instance`] when convenient. A no-op if `chain_id` is
1873    /// out of range or already removed.
1874    pub fn remove_model(&mut self, chain_id: u32) {
1875        let Some(entries) = self.chains.get(chain_id as usize).cloned() else {
1876            return;
1877        };
1878        if entries.is_empty() {
1879            return; // already removed
1880        }
1881        self.last_cull = None; // PF.10 — tombstone changes visibility
1882        for &e in &entries {
1883            let e = e as usize;
1884            self.dead[e] = true;
1885            self.colors_alloc.free(e);
1886        }
1887        self.chains[chain_id as usize] = Vec::new(); // tombstone
1888    }
1889
1890    /// Reclaim the holes left by [`Self::remove_model`]: rebuild the shared
1891    /// volume buffers from the live entries only, dropping every dead
1892    /// entry's data. Entry ids and `chain_id`s are preserved (dead entries
1893    /// keep a zero-length `meta` tombstone), so the caller's handles stay
1894    /// valid and no remap is needed.
1895    ///
1896    /// `registry` must be the resident one (entry ids 1:1, as for
1897    /// [`Self::add_model`] / [`Self::update_model`]). O(live volume) —
1898    /// call it when [`Self::dead_model_count`] is high, not every frame.
1899    pub fn compact(
1900        &mut self,
1901        device: &wgpu::Device,
1902        queue: &wgpu::Queue,
1903        registry: &SpriteModelRegistry,
1904    ) {
1905        self.last_cull = None; // PF.10 — entry ids / chains renumbered
1906                               // occupancy + color_offsets: re-pack live entries tightly, rewrite
1907                               // each live entry's meta offset, zero the dead ones.
1908        self.compact_concat(device, registry, ConcatBuf::Occupancy);
1909        self.compact_concat(device, registry, ConcatBuf::ColorOffsets);
1910        // colors/dirs: the dead-aware repack already drops dead entries.
1911        self.repack_colors_dirs(device, registry);
1912        // model_meta: rewrite the (unchanged-length) table with the new
1913        // offsets. Buffer count didn't change, so no grow needed.
1914        queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1915    }
1916
1917    /// Rebuild one tightly-concatenated buffer from live entries only
1918    /// (used by [`Self::compact`]): assign each live entry a fresh tight
1919    /// offset, zero dead entries' offset, and recreate the buffer with
1920    /// slack.
1921    fn compact_concat(
1922        &mut self,
1923        device: &wgpu::Device,
1924        registry: &SpriteModelRegistry,
1925        which: ConcatBuf,
1926    ) {
1927        let mut all: Vec<u32> = Vec::new();
1928        for e in 0..self.meta.len() {
1929            if self.dead[e] {
1930                match which {
1931                    ConcatBuf::Occupancy => self.meta[e].occupancy_offset = 0,
1932                    ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = 0,
1933                }
1934                continue;
1935            }
1936            let off = all.len() as u32;
1937            match which {
1938                ConcatBuf::Occupancy => self.meta[e].occupancy_offset = off,
1939                ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = off,
1940            }
1941            all.extend_from_slice(concat_data(&registry.entries[e], which));
1942        }
1943        let used = all.len() as u32;
1944        let cap = grow_words(used);
1945        let (label, buf) = match which {
1946            ConcatBuf::Occupancy => ("roxlap-gpu sprite_reg.occupancy", &mut self.occupancy),
1947            ConcatBuf::ColorOffsets => (
1948                "roxlap-gpu sprite_reg.color_offsets",
1949                &mut self.color_offsets,
1950            ),
1951        };
1952        *buf = storage_dst_u32_cap(device, label, &all, cap);
1953        match which {
1954            ConcatBuf::Occupancy => {
1955                self.occ_used = used;
1956                self.occ_cap = cap;
1957            }
1958            ConcatBuf::ColorOffsets => {
1959                self.coloff_used = used;
1960                self.coloff_cap = cap;
1961            }
1962        }
1963    }
1964
1965    /// GPU.10.3 — frustum-cull, pack the visible subset into the
1966    /// instance buffer, then bin those instances into screen tiles:
1967    /// project each visible bounding sphere to a screen AABB and append
1968    /// its (visible) index to every overlapped tile. Uploads the
1969    /// instance buffer + `tile_ranges` (per-tile offset/count) +
1970    /// `tile_instances` (flat grouped indices), growing the tile
1971    /// buffers as needed. Returns `(visible_count, tiles_x, tiles_y)`.
1972    #[allow(clippy::too_many_arguments)]
1973    pub fn cull_bin_upload(
1974        &mut self,
1975        device: &wgpu::Device,
1976        queue: &wgpu::Queue,
1977        f: &ViewFrustum,
1978        screen_w: u32,
1979        screen_h: u32,
1980        tile_size: u32,
1981        lod_px: f32,
1982        // CA.4 — per-frame cutaway volumes; an instance inside any of
1983        // them is hidden (footprint rule). Empty ⇒ pre-CA behaviour.
1984        clips: &[SpriteCutawayClip],
1985        // FW.4 — fog-of-war hide test on an instance's world centre
1986        // (decision 8): `Some(f)` where `f(center)` is true hides the
1987        // sprite (Memory / Unseen cell). `None` ⇒ no fog. `fog_version`
1988        // keys the cull skip cache so a mask change re-culls.
1989        fog_hidden: Option<&dyn Fn([f32; 3]) -> bool>,
1990        fog_version: u64,
1991    ) -> (u32, u32, u32) {
1992        let tiles_x = screen_w.div_ceil(tile_size).max(1);
1993        let tiles_y = screen_h.div_ceil(tile_size).max(1);
1994        let n_tiles = (tiles_x * tiles_y) as usize;
1995
1996        // PF.10 — nothing changed since the last cull (same registry
1997        // state, same view, same screen): the four buffers already hold
1998        // exactly this frame's data — skip the whole cull/bin/upload.
1999        let key = CullKey::new(f, screen_w, screen_h, tile_size, lod_px, clips, fog_version);
2000        if let Some((k, res)) = self.last_cull {
2001            if k == key {
2002                return res;
2003            }
2004        }
2005
2006        let nw = (1.0 + f.half_w * f.half_w).sqrt();
2007        let nh = (1.0 + f.half_h * f.half_h).sqrt();
2008        let cx = screen_w as f32 * 0.5;
2009        let cy = screen_h as f32 * 0.5;
2010        let px_per_world = cx / f.half_w; // isotropic: == cy/half_h
2011        let ts = tile_size as f32;
2012        let tx_max = tiles_x as i32 - 1;
2013        let ty_max = tiles_y as i32 - 1;
2014
2015        // PF.10 — reused workspace (was 6+ fresh Vecs per frame).
2016        let scratch = &mut self.scratch;
2017        let visible = &mut scratch.visible;
2018        visible.clear();
2019        // Per-visible tile AABB (tx0, tx1, ty0, ty1) for the bin pass.
2020        let boxes = &mut scratch.boxes;
2021        boxes.clear();
2022        // Per-visible kv6colmul tables, flattened to two u32 per u64
2023        // entry (lanes 0|1, then 2|3), packed in visible order so the
2024        // shader indexes `colmul[inst_idx*512 + dir*2 + {0,1}]`. PF.10 —
2025        // built ONLY once a non-identity table exists (`any_colmul`);
2026        // until then the buffer holds a lazily-written identity fill and
2027        // the ~2 KiB-per-visible-instance rebuild + upload is skipped.
2028        let visible_colmul = &mut scratch.colmul;
2029        visible_colmul.clear();
2030        let counts = &mut scratch.counts;
2031        counts.clear();
2032        counts.resize(n_tiles, 0u32);
2033        let pack_colmul = self.any_colmul;
2034
2035        for ci in &self.cull {
2036            // Skip instances of a removed model (tombstoned chain) — they
2037            // linger in `cull` until the caller drops them, but draw as
2038            // nothing.
2039            if self.chains[ci.chain_id as usize].is_empty() {
2040                continue;
2041            }
2042            // CA.4 — cutaway footprint rule: hidden instances drop out
2043            // of the visible set (and with it, sprite shadow casting).
2044            if clips.iter().any(|c| c.hides(ci.center)) {
2045                continue;
2046            }
2047            // FW.4 — fog-of-war hide: a sprite over a Memory / Unseen cell
2048            // of the fog grid is not currently known to be there, so it
2049            // drops out of the visible set too (decision 8).
2050            if fog_hidden.is_some_and(|f| f(ci.center)) {
2051                continue;
2052            }
2053            let rel = [
2054                ci.center[0] - f.pos[0],
2055                ci.center[1] - f.pos[1],
2056                ci.center[2] - f.pos[2],
2057            ];
2058            let z = dot3(rel, f.forward);
2059            let r = ci.radius;
2060            if z + r < 0.0 || z - r > f.far {
2061                continue; // behind / beyond far
2062            }
2063            let x = dot3(rel, f.right);
2064            if (x - f.half_w * z) > r * nw || (-x - f.half_w * z) > r * nw {
2065                continue; // right / left
2066            }
2067            let y = dot3(rel, f.down);
2068            if (y - f.half_h * z) > r * nh || (-y - f.half_h * z) > r * nh {
2069                continue; // bottom / top
2070            }
2071
2072            // Visible: project the sphere to a screen AABB → tile range.
2073            let (tx0, tx1, ty0, ty1) = if z > 1e-3 {
2074                let sx = cx + (x / z) * px_per_world;
2075                let sy = cy + (y / z) * px_per_world;
2076                let sr = (r / z) * px_per_world;
2077                (
2078                    (((sx - sr) / ts).floor() as i32).clamp(0, tx_max),
2079                    (((sx + sr) / ts).floor() as i32).clamp(0, tx_max),
2080                    (((sy - sr) / ts).floor() as i32).clamp(0, ty_max),
2081                    (((sy + sr) / ts).floor() as i32).clamp(0, ty_max),
2082                )
2083            } else {
2084                (0, tx_max, 0, ty_max)
2085            };
2086            // GPU.10.4 — pick the LOD level by projected voxel size:
2087            // choose the coarsest level whose voxel still covers at
2088            // least `lod_px` screen pixels, i.e. step up once a mip-0
2089            // voxel would be smaller than that. `lod_px = 1` is the
2090            // natural "don't go sub-pixel" threshold; larger values
2091            // force LOD in closer (tuning/inspection).
2092            let chain = &self.chains[ci.chain_id as usize];
2093            let level = if z > 1e-3 && chain.len() > 1 {
2094                // Mip-0 voxel screen size; a scaled instance's voxels
2095                // are `max_scale`× larger in world, so it holds the
2096                // fine mip proportionally longer (PS.1).
2097                let voxel_px = px_per_world * ci.max_scale / z;
2098                ((lod_px / voxel_px).log2().ceil().max(0.0) as usize).min(chain.len() - 1)
2099            } else {
2100                0
2101            };
2102            let mut g = ci.gpu;
2103            g.model_id = chain[level];
2104            visible.push(g);
2105            boxes.push([tx0, tx1, ty0, ty1]);
2106            if pack_colmul {
2107                for &w in ci.colmul.iter() {
2108                    visible_colmul.push((w & 0xffff_ffff) as u32);
2109                    visible_colmul.push((w >> 32) as u32);
2110                }
2111            }
2112            for ty in ty0..=ty1 {
2113                for tx in tx0..=tx1 {
2114                    counts[(ty * tiles_x as i32 + tx) as usize] += 1;
2115                }
2116            }
2117        }
2118
2119        if visible.is_empty() {
2120            let res = (0, tiles_x, tiles_y);
2121            self.last_cull = Some((key, res));
2122            return res;
2123        }
2124
2125        // Prefix-sum counts → per-tile offsets; build the flat grouped
2126        // index list.
2127        let tile_ranges = &mut scratch.tile_ranges;
2128        tile_ranges.clear();
2129        tile_ranges.resize(n_tiles * 2, 0u32);
2130        let mut running = 0u32;
2131        for t in 0..n_tiles {
2132            tile_ranges[2 * t] = running; // offset
2133            tile_ranges[2 * t + 1] = counts[t]; // count
2134            running += counts[t];
2135        }
2136        let total = running as usize;
2137        let tile_instances = &mut scratch.tile_instances;
2138        tile_instances.clear();
2139        tile_instances.resize(total.max(1), 0u32);
2140        let cursor = &mut scratch.cursor;
2141        cursor.clear();
2142        cursor.extend((0..n_tiles).map(|t| tile_ranges[2 * t]));
2143        for (vis_idx, b) in boxes.iter().enumerate() {
2144            for ty in b[2]..=b[3] {
2145                for tx in b[0]..=b[1] {
2146                    let t = (ty * tiles_x as i32 + tx) as usize;
2147                    tile_instances[cursor[t] as usize] = vis_idx as u32;
2148                    cursor[t] += 1;
2149                }
2150            }
2151        }
2152
2153        // Upload: instances + (grown) tile buffers. Grow a tile buffer
2154        // only when this frame needs more than its capacity (wgpu has
2155        // no Clone on Buffer, so we replace the field in place).
2156        queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(visible));
2157        let need_ranges = tile_ranges.len() as u32;
2158        if need_ranges > self.tile_ranges_cap {
2159            self.tile_ranges_cap = need_ranges.next_power_of_two();
2160            self.tile_ranges = storage_dst_u32(
2161                device,
2162                "roxlap-gpu sprite_reg.tile_ranges",
2163                self.tile_ranges_cap,
2164            );
2165        }
2166        let need_inst = tile_instances.len() as u32;
2167        if need_inst > self.tile_instances_cap {
2168            self.tile_instances_cap = need_inst.next_power_of_two();
2169            self.tile_instances = storage_dst_u32(
2170                device,
2171                "roxlap-gpu sprite_reg.tile_instances",
2172                self.tile_instances_cap,
2173            );
2174        }
2175        queue.write_buffer(&self.tile_ranges, 0, bytemuck::cast_slice(tile_ranges));
2176        queue.write_buffer(
2177            &self.tile_instances,
2178            0,
2179            bytemuck::cast_slice(tile_instances),
2180        );
2181        if pack_colmul {
2182            let need_colmul = visible_colmul.len() as u32;
2183            if need_colmul > self.colmul_cap {
2184                self.colmul_cap = need_colmul.next_power_of_two();
2185                self.colmul =
2186                    storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", self.colmul_cap);
2187                self.colmul_identity = false;
2188            }
2189            queue.write_buffer(&self.colmul, 0, bytemuck::cast_slice(visible_colmul));
2190        } else {
2191            // PF.10 — identity fast path: every table is identity, so the
2192            // buffer content is a constant repeating pattern. (Re)fill it
2193            // only on first use / growth; per-frame upload skipped.
2194            let need_colmul = visible.len() as u32 * 512;
2195            if need_colmul > self.colmul_cap {
2196                self.colmul_cap = need_colmul.next_power_of_two();
2197                self.colmul =
2198                    storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", self.colmul_cap);
2199                self.colmul_identity = false;
2200            }
2201            if !self.colmul_identity {
2202                let w = identity_colmul()[0];
2203                let (lo, hi) = ((w & 0xffff_ffff) as u32, (w >> 32) as u32);
2204                let fill: Vec<u32> = (0..self.colmul_cap)
2205                    .map(|i| if i & 1 == 0 { lo } else { hi })
2206                    .collect();
2207                queue.write_buffer(&self.colmul, 0, bytemuck::cast_slice(&fill));
2208                self.colmul_identity = true;
2209            }
2210        }
2211
2212        let res = (visible.len() as u32, tiles_x, tiles_y);
2213        self.last_cull = Some((key, res));
2214        res
2215    }
2216}
2217
2218/// GPU.12 incremental — per-entry placement of one model's `colors`
2219/// (and the parallel `dirs`) within the shared registry buffers: a
2220/// `[off, off+cap)` word window holding `len` live words. `cap >= len`
2221/// gives slack so a carve that *grows* the surface-voxel count can be
2222/// rewritten in place without relocating.
2223#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2224struct ColorSlot {
2225    off: u32,
2226    cap: u32,
2227    len: u32,
2228}
2229
2230/// First-fit suballocator over the parallel `colors`/`dirs` buffers
2231/// (same offsets/ranks → one allocator drives both). Each registry
2232/// entry owns a [`ColorSlot`]; growth past a slot's `cap` relocates it
2233/// (freeing the old block) via the free list or a bump tail, and only
2234/// when the tail would exceed `cap_total` does the caller grow + repack
2235/// the whole buffer. Pure (no GPU) so it unit-tests on its own.
2236#[derive(Debug, Default)]
2237struct ColorsAllocator {
2238    /// Per-entry slot, indexed by entry id.
2239    slots: Vec<ColorSlot>,
2240    /// Freed `(off, cap)` blocks available for first-fit reuse.
2241    free: Vec<(u32, u32)>,
2242    /// Next bump-allocation position (words).
2243    tail: u32,
2244    /// Total buffer capacity in words.
2245    cap_total: u32,
2246}
2247
2248/// Slack-padded capacity for a `len`-word array: +25% + 16 words, so a
2249/// few extra surface voxels from a carve fit without relocating.
2250fn slot_cap(len: u32) -> u32 {
2251    len + len / 4 + 16
2252}
2253
2254/// Slack capacity (words) for a grown concatenated buffer: +50% + 256, so
2255/// a burst of `add_model` calls bump-appends rather than re-growing every
2256/// time. Matches [`ColorsAllocator`]'s `cap_total` headroom.
2257fn grow_words(used: u32) -> u32 {
2258    used + used / 2 + 256
2259}
2260
2261/// Slack capacity (records) for a grown `model_meta` buffer: +50% + 8.
2262fn grow_records(count: u32) -> u32 {
2263    count + count / 2 + 8
2264}
2265
2266impl ColorsAllocator {
2267    /// Lay every entry out contiguously (with per-slot slack) and add a
2268    /// global tail headroom so early growth bump-allocates rather than
2269    /// repacks.
2270    fn new(entry_lens: &[u32]) -> Self {
2271        let mut a = Self::default();
2272        a.repack(entry_lens);
2273        a
2274    }
2275
2276    fn slot(&self, entry: usize) -> ColorSlot {
2277        self.slots[entry]
2278    }
2279
2280    fn cap_total(&self) -> u32 {
2281        self.cap_total
2282    }
2283
2284    /// Repack ALL entries compactly to fit `new_lens`, resetting the
2285    /// free list + tail and choosing a fresh `cap_total` with headroom.
2286    /// Used at initial build and on a buffer grow.
2287    fn repack(&mut self, new_lens: &[u32]) {
2288        self.free.clear();
2289        let mut off = 0u32;
2290        let mut slots = Vec::with_capacity(new_lens.len());
2291        for &len in new_lens {
2292            // A 0-length (dead / removed) entry takes no space — keeps a
2293            // tombstone slot so entry ids stay positional.
2294            let cap = if len == 0 { 0 } else { slot_cap(len) };
2295            slots.push(ColorSlot { off, cap, len });
2296            off += cap;
2297        }
2298        self.slots = slots;
2299        self.tail = off;
2300        // Global headroom: +50% + 256 words.
2301        self.cap_total = off + off / 2 + 256;
2302    }
2303
2304    /// Place `new_len` words for `entry`. Returns `Some(off)` with the
2305    /// (possibly relocated) slot offset, or `None` if the buffer must
2306    /// grow + repack. On relocation the old block is pushed to the free
2307    /// list; an in-place fit returns the unchanged offset.
2308    fn place(&mut self, entry: usize, new_len: u32) -> Option<u32> {
2309        let cur = self.slots[entry];
2310        if new_len <= cur.cap {
2311            self.slots[entry] = ColorSlot {
2312                len: new_len,
2313                ..cur
2314            };
2315            return Some(cur.off);
2316        }
2317        let old = (cur.off, cur.cap);
2318        // First-fit a freed block big enough for the live data.
2319        if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
2320            let (off, cap) = self.free.remove(i);
2321            self.free.push(old);
2322            self.slots[entry] = ColorSlot {
2323                off,
2324                cap,
2325                len: new_len,
2326            };
2327            return Some(off);
2328        }
2329        // Bump the tail if there's room.
2330        let want = slot_cap(new_len);
2331        if self.tail + want <= self.cap_total {
2332            let off = self.tail;
2333            self.tail += want;
2334            self.free.push(old);
2335            self.slots[entry] = ColorSlot {
2336                off,
2337                cap: want,
2338                len: new_len,
2339            };
2340            return Some(off);
2341        }
2342        None
2343    }
2344
2345    /// Append a slot for a brand-new entry of `new_len` words (used by
2346    /// [`SpriteRegistryResident::add_model`]). Returns `Some(off)` placed
2347    /// via the free list or the bump tail, or `None` if the buffer must
2348    /// grow + repack — in which case **no** slot is pushed (the caller's
2349    /// repack rebuilds every slot from scratch).
2350    fn push(&mut self, new_len: u32) -> Option<u32> {
2351        if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
2352            let (off, cap) = self.free.remove(i);
2353            self.slots.push(ColorSlot {
2354                off,
2355                cap,
2356                len: new_len,
2357            });
2358            return Some(off);
2359        }
2360        let want = slot_cap(new_len);
2361        if self.tail + want <= self.cap_total {
2362            let off = self.tail;
2363            self.tail += want;
2364            self.slots.push(ColorSlot {
2365                off,
2366                cap: want,
2367                len: new_len,
2368            });
2369            return Some(off);
2370        }
2371        None
2372    }
2373
2374    /// Free `entry`'s slot back to the pool ([`SpriteRegistryResident::
2375    /// remove_model`]). Its `(off, cap)` block joins the free list for
2376    /// first-fit reuse by a later [`Self::push`]; the slot is zeroed so a
2377    /// repack treats it as a 0-length tombstone.
2378    fn free(&mut self, entry: usize) {
2379        let s = self.slots[entry];
2380        if s.cap > 0 {
2381            self.free.push((s.off, s.cap));
2382        }
2383        self.slots[entry] = ColorSlot {
2384            off: 0,
2385            cap: 0,
2386            len: 0,
2387        };
2388    }
2389}
2390
2391/// Create a STORAGE buffer of u32s; pads empty input (wgpu rejects
2392/// zero-sized storage bindings).
2393#[allow(dead_code)]
2394fn storage_u32(device: &wgpu::Device, label: &str, data: &[u32]) -> wgpu::Buffer {
2395    use wgpu::util::DeviceExt;
2396    let bytes: &[u8] = if data.is_empty() {
2397        bytemuck::cast_slice(&[0u32])
2398    } else {
2399        bytemuck::cast_slice(data)
2400    };
2401    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2402        label: Some(label),
2403        contents: bytes,
2404        usage: wgpu::BufferUsages::STORAGE,
2405    })
2406}
2407
2408/// Create an uninitialised `STORAGE | COPY_DST` `u32` buffer of `cap`
2409/// words (≥1). Written each frame via `queue.write_buffer`.
2410fn storage_dst_u32(device: &wgpu::Device, label: &str, cap: u32) -> wgpu::Buffer {
2411    device.create_buffer(&wgpu::BufferDescriptor {
2412        label: Some(label),
2413        size: u64::from(cap.max(1)) * 4,
2414        // COPY_SRC so test/debug harnesses can read the contents back
2415        // (PF.10's cull gate does); free at runtime.
2416        usage: wgpu::BufferUsages::STORAGE
2417            | wgpu::BufferUsages::COPY_DST
2418            | wgpu::BufferUsages::COPY_SRC,
2419        mapped_at_creation: false,
2420    })
2421}
2422
2423/// Create a `STORAGE | COPY_DST` `u32` buffer of `cap` words (≥ data
2424/// length, ≥ 1), initialised with `data` at offset 0 and the tail left
2425/// zeroed. Unlike [`storage_u32`] (STORAGE-only, exact-size) this both
2426/// reserves spare capacity and is `COPY_DST`, so the incremental
2427/// [`SpriteRegistryResident::update_model`] can `write_buffer` a growing
2428/// `colors`/`dirs` array in place. Filled via `mapped_at_creation` so no
2429/// queue is needed at upload time.
2430fn storage_dst_u32_cap(device: &wgpu::Device, label: &str, data: &[u32], cap: u32) -> wgpu::Buffer {
2431    let cap = cap.max(data.len() as u32).max(1);
2432    let buf = device.create_buffer(&wgpu::BufferDescriptor {
2433        label: Some(label),
2434        size: u64::from(cap) * 4,
2435        usage: wgpu::BufferUsages::STORAGE
2436            | wgpu::BufferUsages::COPY_DST
2437            | wgpu::BufferUsages::COPY_SRC,
2438        mapped_at_creation: true,
2439    });
2440    if !data.is_empty() {
2441        buf.slice(..(data.len() as u64 * 4))
2442            .get_mapped_range_mut()
2443            .copy_from_slice(bytemuck::cast_slice(data));
2444    }
2445    buf.unmap();
2446    buf
2447}
2448
2449/// Create a `STORAGE | COPY_DST` buffer of Pod records, exact-size
2450/// (≥ 1, zero-padded), so individual records can be rewritten in place
2451/// by [`SpriteRegistryResident::update_model`] on a relocation. The
2452/// record *count* never changes on an incremental edit (no model is
2453/// added/removed), so no slack is needed here.
2454fn storage_dst_pod<T: Pod + Zeroable>(
2455    device: &wgpu::Device,
2456    label: &str,
2457    data: &[T],
2458) -> wgpu::Buffer {
2459    let one = [T::zeroed()];
2460    let src: &[T] = if data.is_empty() { &one } else { data };
2461    let buf = device.create_buffer(&wgpu::BufferDescriptor {
2462        label: Some(label),
2463        size: std::mem::size_of_val(src) as u64,
2464        usage: wgpu::BufferUsages::STORAGE
2465            | wgpu::BufferUsages::COPY_DST
2466            | wgpu::BufferUsages::COPY_SRC,
2467        mapped_at_creation: true,
2468    });
2469    buf.slice(..)
2470        .get_mapped_range_mut()
2471        .copy_from_slice(bytemuck::cast_slice(src));
2472    buf.unmap();
2473    buf
2474}
2475
2476/// Create a `STORAGE | COPY_DST` Pod buffer holding `cap` records
2477/// (≥ `data.len()`, ≥ 1), initialised with `data` at record 0 and the
2478/// tail zeroed. The slack lets [`SpriteRegistryResident::add_model`] grow
2479/// the `model_meta` table without re-growing on every add.
2480fn storage_dst_pod_cap<T: Pod + Zeroable>(
2481    device: &wgpu::Device,
2482    label: &str,
2483    data: &[T],
2484    cap: u32,
2485) -> wgpu::Buffer {
2486    let rec = std::mem::size_of::<T>() as u64;
2487    let cap = u64::from(cap.max(data.len() as u32).max(1));
2488    let buf = device.create_buffer(&wgpu::BufferDescriptor {
2489        label: Some(label),
2490        size: cap * rec,
2491        usage: wgpu::BufferUsages::STORAGE
2492            | wgpu::BufferUsages::COPY_DST
2493            | wgpu::BufferUsages::COPY_SRC,
2494        mapped_at_creation: true,
2495    });
2496    if !data.is_empty() {
2497        buf.slice(..(data.len() as u64 * rec))
2498            .get_mapped_range_mut()
2499            .copy_from_slice(bytemuck::cast_slice(data));
2500    }
2501    buf.unmap();
2502    buf
2503}
2504
2505/// Create a STORAGE buffer of Pod records; pads empty input with one
2506/// zeroed `T`.
2507#[allow(dead_code)]
2508fn storage_pod<T: Pod + Zeroable>(device: &wgpu::Device, label: &str, data: &[T]) -> wgpu::Buffer {
2509    use wgpu::util::DeviceExt;
2510    let one = [T::zeroed()];
2511    let src: &[T] = if data.is_empty() { &one } else { data };
2512    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2513        label: Some(label),
2514        contents: bytemuck::cast_slice(src),
2515        usage: wgpu::BufferUsages::STORAGE,
2516    })
2517}
2518
2519#[cfg(test)]
2520mod tests {
2521    use super::*;
2522    use roxlap_formats::kv6::{Kv6, Voxel};
2523
2524    /// CA.4 — the footprint rule's point test: inside-footprint +
2525    /// above-plane hides; below the plane or outside the XY footprint
2526    /// never does; rotated / scaled grids test in THEIR voxel frame.
2527    #[test]
2528    fn cutaway_clip_footprint_rule() {
2529        let clip = SpriteCutawayClip {
2530            origin: [10.0, 0.0, 0.0],
2531            inv_rows: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
2532            inv_vws: 1.0,
2533            xy_lo: [0.0, 0.0],
2534            xy_hi: [128.0, 128.0],
2535            z_clip: 120.0,
2536        };
2537        assert!(clip.hides([20.0, 5.0, 50.0]), "inside + above plane");
2538        assert!(!clip.hides([20.0, 5.0, 150.0]), "below the plane");
2539        assert!(!clip.hides([20.0, 5.0, 120.0]), "on the plane = visible");
2540        assert!(!clip.hides([300.0, 5.0, 50.0]), "outside the footprint");
2541        assert!(!clip.hides([5.0, 5.0, 50.0]), "x < origin: outside");
2542
2543        // 90°-about-z grid (local x = world y, local y = −world x): the
2544        // footprint follows the ROTATED frame, not the world axes.
2545        let rot = SpriteCutawayClip {
2546            inv_rows: [[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
2547            ..clip
2548        };
2549        assert!(
2550            rot.hides([-20.0, 40.0, 50.0]),
2551            "local (40, 30): inside the rotated footprint"
2552        );
2553        assert!(
2554            !rot.hides([20.0, 40.0, 50.0]),
2555            "local (40, -10): outside the rotated footprint"
2556        );
2557        // A scaled grid (vws = 0.5 ⇒ inv_vws = 2): world offsets double
2558        // in voxel coords, so world x = 74 → voxel x = 128 (outside).
2559        let scaled = SpriteCutawayClip {
2560            inv_vws: 2.0,
2561            ..clip
2562        };
2563        assert!(scaled.hides([40.0, 5.0, 50.0]), "voxel (60, 10, 100) < 120");
2564        assert!(!scaled.hides([74.0, 5.0, 50.0]), "voxel x = 128: outside");
2565    }
2566
2567    /// 2×1 kv6: column (0,0) has voxels at z=5 (red) and z=1 (green)
2568    /// stored OUT of z-order; column (1,0) has one voxel at z=3.
2569    fn kv6_unsorted() -> Kv6 {
2570        let mk = |z, col| Voxel {
2571            col,
2572            z,
2573            vis: 0,
2574            dir: 0,
2575        };
2576        Kv6 {
2577            xsiz: 2,
2578            ysiz: 1,
2579            zsiz: 8,
2580            xpiv: 0.0,
2581            ypiv: 0.0,
2582            zpiv: 0.0,
2583            voxels: vec![mk(5, 0xAA), mk(1, 0xBB), mk(3, 0xCC)],
2584            xlen: vec![2, 1],
2585            ylen: vec![vec![2], vec![1]],
2586            palette: None,
2587        }
2588    }
2589
2590    #[test]
2591    fn occupancy_bits_set_at_voxel_z() {
2592        let m = build_sprite_model(&kv6_unsorted());
2593        assert_eq!(m.dims, [2, 1, 8]);
2594        assert_eq!(m.occ_words_per_col, 1); // ceil(8/32)
2595                                            // col 0: bits 1 and 5; col 1: bit 3.
2596        assert_eq!(m.occupancy[0], (1 << 1) | (1 << 5));
2597        assert_eq!(m.occupancy[1], 1 << 3);
2598    }
2599
2600    #[test]
2601    fn colors_are_ascending_z_for_rank_lookup() {
2602        let m = build_sprite_model(&kv6_unsorted());
2603        // col 0 sorted ascending z ⇒ z=1 (green 0xBB) before z=5 (0xAA).
2604        assert_eq!(m.color_offsets, vec![0, 2, 3]);
2605        assert_eq!(&m.colors, &[0xBB, 0xAA, 0xCC]);
2606    }
2607
2608    #[test]
2609    fn identity_basis_inverts_to_identity() {
2610        let inv = mat3_inverse([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
2611        assert_eq!(inv, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
2612    }
2613
2614    #[test]
2615    fn fork_is_independent_of_parent() {
2616        let mut reg = SpriteModelRegistry::new();
2617        let base = reg.add(build_sprite_model(&kv6_unsorted()));
2618        let forked = reg.fork(base);
2619        assert_ne!(base, forked);
2620        // Recolour only the fork.
2621        reg.model_mut(forked).recolor(|_| 0x11);
2622        // Parent colours untouched; fork fully overwritten.
2623        assert_eq!(&reg.model(base).colors, &[0xBB, 0xAA, 0xCC]);
2624        assert_eq!(&reg.model(forked).colors, &[0x11, 0x11, 0x11]);
2625    }
2626
2627    #[test]
2628    fn remove_frees_chain_data_keeps_ids_stable() {
2629        let mut reg = SpriteModelRegistry::new();
2630        let a = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2631        let b = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2632        let len_before = reg.len();
2633        assert!(reg.is_live(a) && reg.is_live(b));
2634
2635        reg.remove(a);
2636        // Chain `a` is tombstoned (its entries are freed to empty models;
2637        // they're unreachable via `model()` now — that's the tombstone).
2638        assert!(!reg.is_live(a));
2639        // `b` is untouched and still live; `len()` (next id) is unchanged.
2640        assert!(reg.is_live(b));
2641        assert_eq!(&reg.model(b).colors, &[0xBB, 0xAA, 0xCC]);
2642        assert_eq!(reg.len(), len_before);
2643
2644        // A later add mints a fresh id past the tombstone (no slot reuse).
2645        let c = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2646        assert_eq!(c, len_before as u32);
2647        assert!(reg.is_live(c));
2648        // `b`'s id stayed valid across the remove + add round-trip.
2649        assert_eq!(&reg.model(b).colors, &[0xBB, 0xAA, 0xCC]);
2650    }
2651
2652    #[test]
2653    fn model_checked_guards_out_of_range_and_tombstoned() {
2654        // The guard `set_instance_model` relies on: `model()` would
2655        // index-panic on these, `model_checked` returns `None`.
2656        let mut reg = SpriteModelRegistry::new();
2657        let a = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2658        assert!(reg.model_checked(a).is_some());
2659        assert!(reg.model_checked(9999).is_none(), "out of range → None");
2660        reg.remove(a);
2661        assert!(reg.model_checked(a).is_none(), "tombstoned chain → None");
2662    }
2663
2664    #[test]
2665    fn remove_is_idempotent_and_bounds_safe() {
2666        let mut reg = SpriteModelRegistry::new();
2667        let a = reg.add(build_sprite_model(&kv6_unsorted()));
2668        reg.remove(a);
2669        reg.remove(a); // already removed → no-op, no panic
2670        reg.remove(999); // out of range → no-op
2671        assert!(!reg.is_live(a));
2672        assert!(!reg.is_live(999));
2673    }
2674
2675    #[test]
2676    fn registry_gpu_structs_have_expected_sizes() {
2677        assert_eq!(std::mem::size_of::<SpriteModelMeta>(), 48);
2678        // TV — grew 64 → 80 with the per-instance material id + alpha_mul
2679        // (+ 8 bytes pad to keep the 16-byte std430 stride).
2680        assert_eq!(std::mem::size_of::<SpriteInstanceGpu>(), 80);
2681    }
2682
2683    #[test]
2684    fn add_lod_builds_halving_mip_chain() {
2685        let mut reg = SpriteModelRegistry::new();
2686        // 8×8×8 single voxel-filled column model would be ideal, but
2687        // kv6_unsorted is 2×1×8 → mips: 2×1×8 → 1×1×4 → 1×1×2 → 1×1×1.
2688        let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2689        let m0 = reg.model(id);
2690        assert_eq!(m0.dims, [2, 1, 8]);
2691        assert!((m0.voxel_world_size - 1.0).abs() < 1e-6);
2692    }
2693
2694    /// kv6 from explicit voxels, ordered x-major/y-inner to match
2695    /// `build_sprite_model`'s column walk.
2696    fn kv6_from(xsiz: u32, ysiz: u32, zsiz: u32, voxels: &[(u32, u32, u16, u32)]) -> Kv6 {
2697        let mut ylen = vec![vec![0u16; ysiz as usize]; xsiz as usize];
2698        let mut flat = Vec::new();
2699        for x in 0..xsiz {
2700            for y in 0..ysiz {
2701                let mut col: Vec<(u16, u32)> = voxels
2702                    .iter()
2703                    .filter(|(vx, vy, _, _)| *vx == x && *vy == y)
2704                    .map(|(_, _, z, c)| (*z, *c))
2705                    .collect();
2706                col.sort_by_key(|(z, _)| *z);
2707                ylen[x as usize][y as usize] = col.len() as u16;
2708                for (z, c) in col {
2709                    flat.push(Voxel {
2710                        col: c,
2711                        z,
2712                        vis: 0,
2713                        dir: 0,
2714                    });
2715                }
2716            }
2717        }
2718        let xlen = ylen
2719            .iter()
2720            .map(|c| c.iter().map(|&v| u32::from(v)).sum())
2721            .collect();
2722        Kv6 {
2723            xsiz,
2724            ysiz,
2725            zsiz,
2726            xpiv: 0.0,
2727            ypiv: 0.0,
2728            zpiv: 0.0,
2729            voxels: flat,
2730            xlen,
2731            ylen,
2732            palette: None,
2733        }
2734    }
2735
2736    fn offsets_consistent(m: &SpriteModel) -> bool {
2737        let cols = (m.dims[0] * m.dims[1]) as usize;
2738        if m.color_offsets.len() != cols + 1 {
2739            return false;
2740        }
2741        // Monotonic non-decreasing + last == colors.len + each column's
2742        // span == its solid-voxel count.
2743        for w in m.color_offsets.windows(2) {
2744            if w[1] < w[0] {
2745                return false;
2746            }
2747        }
2748        m.color_offsets[cols] as usize == m.colors.len()
2749    }
2750
2751    #[test]
2752    fn carve_two_layers_keeps_offsets_consistent() {
2753        // Mirror the demo's carve: columns with voxels at varied z,
2754        // some sharing z=0/z=1, some not.
2755        let kv6 = kv6_from(
2756            3,
2757            2,
2758            8,
2759            &[
2760                (0, 0, 0, 0xA0),
2761                (0, 0, 1, 0xA1),
2762                (0, 0, 5, 0xA5),
2763                (1, 0, 1, 0xB1),
2764                (2, 1, 0, 0xC0),
2765                (2, 1, 3, 0xC3),
2766            ],
2767        );
2768        let mut m = build_sprite_model(&kv6);
2769        assert!(offsets_consistent(&m));
2770        for z in 0..2u32 {
2771            for y in 0..m.dims[1] {
2772                for x in 0..m.dims[0] {
2773                    m.set_voxel(x, y, z, None);
2774                }
2775            }
2776            assert!(offsets_consistent(&m), "inconsistent after carving z={z}");
2777            // downsample must not panic on the carved model.
2778            let _ = m.downsample();
2779        }
2780    }
2781
2782    #[test]
2783    fn set_voxel_inserts_replaces_and_clears() {
2784        // col 0 starts with z=1 (0xBB), z=5 (0xAA); col 1 with z=3 (0xCC).
2785        let mut m = build_sprite_model(&kv6_unsorted());
2786
2787        // Insert z=3 into col 0 (between z=1 and z=5) → rank 1.
2788        assert!(m.set_voxel(0, 0, 3, Some(0x55)));
2789        assert_eq!(m.occupancy[0], (1 << 1) | (1 << 3) | (1 << 5));
2790        // col 0 colours ascending z: 0xBB(z1), 0x55(z3), 0xAA(z5).
2791        assert_eq!(m.color_offsets, vec![0, 3, 4]);
2792        assert_eq!(&m.colors, &[0xBB, 0x55, 0xAA, 0xCC]);
2793
2794        // Replace z=3 in place (no offset shift).
2795        assert!(m.set_voxel(0, 0, 3, Some(0x66)));
2796        assert_eq!(&m.colors, &[0xBB, 0x66, 0xAA, 0xCC]);
2797        assert_eq!(m.color_offsets, vec![0, 3, 4]);
2798
2799        // Clear z=1 (rank 0) from col 0.
2800        assert!(m.set_voxel(0, 0, 1, None));
2801        assert_eq!(m.occupancy[0], (1 << 3) | (1 << 5));
2802        assert_eq!(m.color_offsets, vec![0, 2, 3]);
2803        assert_eq!(&m.colors, &[0x66, 0xAA, 0xCC]);
2804
2805        // No-ops: clear an empty voxel, edit out of bounds.
2806        assert!(!m.set_voxel(0, 0, 2, None));
2807        assert!(!m.set_voxel(9, 0, 0, Some(1)));
2808    }
2809
2810    #[test]
2811    fn rebuild_lod_refreshes_coarse_levels_from_mip0() {
2812        let mut reg = SpriteModelRegistry::new();
2813        let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 3);
2814        // Recolour mip-0 only via model_mut, then rebuild the ladder.
2815        reg.model_mut(id).recolor(|_| 0x0000_2000);
2816        reg.rebuild_lod(id);
2817        // The mip-1 average of all-0x2000 voxels is still 0x2000.
2818        let lvl1_entry = reg.chains[id as usize][1] as usize;
2819        assert!(reg.entries[lvl1_entry]
2820            .colors
2821            .iter()
2822            .all(|&c| c == 0x0000_2000));
2823    }
2824
2825    // ---- GPU.12 incremental: colors/dirs suballocator -----------------
2826
2827    /// Every slot fits its data, has slack, doesn't overlap the next, and
2828    /// the buffer reserves tail headroom past the last slot.
2829    fn alloc_invariants(a: &ColorsAllocator, lens: &[u32]) {
2830        let mut prev_end = 0u32;
2831        for (e, &len) in lens.iter().enumerate() {
2832            let s = a.slot(e);
2833            assert_eq!(s.len, len, "slot {e} len");
2834            assert!(s.cap >= s.len, "slot {e} cap >= len");
2835            // In a freshly repacked layout slots are in entry order.
2836            assert!(s.off >= prev_end, "slot {e} overlaps previous");
2837            assert!(s.off + s.cap <= a.cap_total(), "slot {e} past cap_total");
2838            prev_end = s.off + s.cap;
2839        }
2840        assert!(a.cap_total() >= prev_end, "tail headroom");
2841    }
2842
2843    #[test]
2844    fn allocator_new_lays_out_with_slack_and_headroom() {
2845        let lens = [10u32, 0, 64, 7];
2846        let a = ColorsAllocator::new(&lens);
2847        alloc_invariants(&a, &lens);
2848        // Slack: a 64-word slot has cap > 64 so a small carve-grow fits.
2849        assert!(a.slot(2).cap > 64);
2850        // Headroom past the bump tail for early growth.
2851        assert!(a.cap_total() > a.slot(3).off + a.slot(3).cap);
2852    }
2853
2854    #[test]
2855    fn allocator_place_in_place_when_within_cap() {
2856        let mut a = ColorsAllocator::new(&[10, 20]);
2857        let off0 = a.slot(0).off;
2858        let cap0 = a.slot(0).cap;
2859        // Shrink: still the same slot.
2860        assert_eq!(a.place(0, 5), Some(off0));
2861        assert_eq!(a.slot(0).len, 5);
2862        assert_eq!(a.slot(0).cap, cap0);
2863        // Grow within slack: same offset, no relocation.
2864        assert_eq!(a.place(0, cap0), Some(off0));
2865        assert_eq!(a.slot(0).off, off0);
2866        assert!(a.free.is_empty(), "no relocation should free anything");
2867    }
2868
2869    #[test]
2870    fn allocator_place_relocates_to_tail_and_frees_old() {
2871        let mut a = ColorsAllocator::new(&[10, 20]);
2872        let old0 = (a.slot(0).off, a.slot(0).cap);
2873        let tail_before = a.tail;
2874        // Overgrow entry 0 past its cap → relocate to the bump tail.
2875        let new_len = a.slot(0).cap + 5;
2876        let off = a.place(0, new_len).expect("fits in headroom");
2877        assert_eq!(off, tail_before, "relocated to old tail");
2878        assert_eq!(a.slot(0).off, off);
2879        assert_eq!(a.slot(0).len, new_len);
2880        assert!(a.free.contains(&old0), "old slot freed");
2881    }
2882
2883    #[test]
2884    fn allocator_reuses_freed_block_first_fit() {
2885        // Entry 0 has a large slot; entry 1 a tiny one, so growing 1 must
2886        // relocate (it can't fit in place) and lands in 0's freed block.
2887        let mut a = ColorsAllocator::new(&[10, 2]);
2888        let old0 = (a.slot(0).off, a.slot(0).cap);
2889        // Relocate entry 0 to the tail, freeing its original block.
2890        let _ = a.place(0, a.slot(0).cap + 5).unwrap();
2891        assert!(a.free.contains(&old0));
2892        // Grow entry 1 past its (tiny) cap but ≤ the freed block's cap →
2893        // first-fit reuses that block rather than bumping the tail.
2894        let new1 = a.slot(1).cap + 1;
2895        assert!(new1 <= old0.1, "freed block big enough");
2896        let off = a.place(1, new1).expect("reuses freed block");
2897        assert_eq!(off, old0.0, "first-fit reused the freed slot offset");
2898        assert!(!a.free.contains(&old0), "freed block consumed");
2899    }
2900
2901    #[test]
2902    fn allocator_signals_grow_then_repack_restores() {
2903        let mut a = ColorsAllocator::new(&[8, 8]);
2904        // Force overflow: ask for far more than cap_total.
2905        let huge = a.cap_total() + 100;
2906        assert_eq!(a.place(0, huge), None, "overflow must signal grow");
2907        // Repack with the new lengths compacts + grows the buffer.
2908        a.repack(&[huge, 8]);
2909        alloc_invariants(&a, &[huge, 8]);
2910        assert!(a.cap_total() > huge);
2911        // After repack the entry now fits in place.
2912        assert_eq!(a.place(0, huge), Some(a.slot(0).off));
2913    }
2914
2915    /// Drive the allocator like a real carve loop (mirroring
2916    /// `update_model`): one model's colour count drifts up and down
2917    /// across many edits while two neighbours stay put. Growth is
2918    /// absorbed in place / via the free list / by the bump tail, and on
2919    /// the rare overflow we repack (as `update_model` does). After every
2920    /// edit the live `[off, off+len)` windows must stay disjoint.
2921    #[test]
2922    fn allocator_carve_loop_keeps_live_windows_disjoint() {
2923        let mut a = ColorsAllocator::new(&[40, 12, 40]);
2924        let mut lens = [40u32, 12, 40];
2925        // A deterministic up/down walk of entry 1's length, incl. a jump
2926        // that forces at least one grow+repack.
2927        let walk = [13u32, 30, 60, 18, 9, 80, 80, 25, 200, 7];
2928        let mut grew = false;
2929        for &len in &walk {
2930            lens[1] = len;
2931            // Entry 1 re-placed; on overflow, repack the whole set.
2932            if a.place(1, len).is_none() {
2933                grew = true;
2934                a.repack(&lens);
2935            } else {
2936                // Neighbours fit in place every time.
2937                assert_eq!(a.place(0, 40), Some(a.slot(0).off));
2938                assert_eq!(a.place(2, 40), Some(a.slot(2).off));
2939            }
2940            assert_eq!(a.slot(1).len, len);
2941
2942            // No two entries' live windows overlap.
2943            let mut wins: Vec<(u32, u32)> =
2944                (0..3).map(|e| (a.slot(e).off, a.slot(e).len)).collect();
2945            wins.sort_by_key(|w| w.0);
2946            for pair in wins.windows(2) {
2947                let (o0, l0) = pair[0];
2948                let (o1, _) = pair[1];
2949                assert!(o0 + l0 <= o1, "live windows overlap: {pair:?}");
2950            }
2951        }
2952        assert!(grew, "the 200-word jump should have forced a repack");
2953    }
2954
2955    // --- incremental instance path (device-backed; skips w/o adapter) ---
2956
2957    fn headless() -> Option<crate::HeadlessGpu> {
2958        match crate::HeadlessGpu::new_blocking(crate::GpuRendererSettings::default()) {
2959            Ok(h) => Some(h),
2960            Err(e) => {
2961                eprintln!("[skip] no GPU adapter reachable: {e}");
2962                None
2963            }
2964        }
2965    }
2966
2967    fn one_model_registry() -> (SpriteModelRegistry, u32) {
2968        let mut reg = SpriteModelRegistry::new();
2969        let id = reg.add(build_sprite_model(&kv6_unsorted()));
2970        (reg, id)
2971    }
2972
2973    fn inst(model_id: u32, pos: [f32; 3]) -> SpriteInstance {
2974        use roxlap_formats::sprite::Sprite;
2975        SpriteInstance::new(
2976            model_id,
2977            SpriteInstanceTransform::from_sprite(&Sprite::axis_aligned(kv6_unsorted(), pos)),
2978        )
2979    }
2980
2981    /// PS.1 — a scaled basis grows the cull sphere with the pose: the
2982    /// transform keeps the longest basis column, and `make_cull` seeds
2983    /// `radius = model.bound_radius() × max_scale`, so scaled-up
2984    /// instances (particles) no longer under-cull at screen edges.
2985    #[test]
2986    fn scaled_basis_scales_cull_radius() {
2987        use roxlap_formats::sprite::Sprite;
2988
2989        let mut reg = SpriteModelRegistry::new();
2990        let chain = reg.add(build_sprite_model(&kv6_unsorted()));
2991        let model_r = reg.model(chain).bound_radius();
2992
2993        let scaled = |k: f32, pos: [f32; 3]| {
2994            let mut s = Sprite::axis_aligned(kv6_unsorted(), pos);
2995            for a in 0..3 {
2996                s.s[a] *= k;
2997                s.h[a] *= k;
2998                s.f[a] *= k;
2999            }
3000            SpriteInstanceTransform::from_sprite(&s)
3001        };
3002
3003        // Unit basis: max_scale 1, radius = the model's (float-exact).
3004        let unit = inst(chain, [0.0; 3]);
3005        assert_eq!(unit.transform.max_scale, 1.0);
3006        assert_eq!(make_cull(&reg, &unit).radius, model_r);
3007
3008        // 2× uniform scale doubles both.
3009        let xf2 = scaled(2.0, [0.0; 3]);
3010        assert!((xf2.max_scale - 2.0).abs() < 1e-6);
3011        let big = SpriteInstance::new(chain, xf2);
3012        assert!((make_cull(&reg, &big).radius - 2.0 * model_r).abs() < 1e-4);
3013
3014        // Anisotropic scale takes the longest column.
3015        let mut s = Sprite::axis_aligned(kv6_unsorted(), [0.0; 3]);
3016        for a in 0..3 {
3017            s.h[a] *= 3.0;
3018            s.f[a] *= 0.5;
3019        }
3020        let xf = SpriteInstanceTransform::from_sprite(&s);
3021        assert!((xf.max_scale - 3.0).abs() < 1e-6);
3022    }
3023
3024    #[test]
3025    fn append_grows_count_and_capacity_pow2() {
3026        let Some(h) = headless() else { return };
3027        let (reg, m) = one_model_registry();
3028        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(m, [0.0; 3])]);
3029        assert_eq!(res.instance_count(), 1);
3030        assert_eq!(res.instance_capacity, 1);
3031
3032        // Append 4 → count 5, capacity grows to next_pow2(5) = 8.
3033        let more: Vec<_> = (1..=4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
3034        let base = res.append_instances(&h.device, &reg, &more);
3035        assert_eq!(base, 1, "first appended index follows the seed instance");
3036        assert_eq!(res.instance_count(), 5);
3037        assert_eq!(res.instance_capacity, 8, "power-of-two growth");
3038
3039        // A second append that still fits keeps the same capacity (no realloc).
3040        let base2 = res.append_instances(&h.device, &reg, &[inst(m, [9.0, 0.0, 0.0])]);
3041        assert_eq!(base2, 5);
3042        assert_eq!(res.instance_count(), 6);
3043        assert_eq!(res.instance_capacity, 8, "fits existing capacity, no grow");
3044    }
3045
3046    #[test]
3047    fn append_empty_is_noop() {
3048        let Some(h) = headless() else { return };
3049        let (reg, m) = one_model_registry();
3050        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(m, [0.0; 3])]);
3051        let base = res.append_instances(&h.device, &reg, &[]);
3052        assert_eq!(base, 1);
3053        assert_eq!(res.instance_count(), 1);
3054        assert_eq!(res.instance_capacity, 1);
3055    }
3056
3057    /// Read `words` u32s back from a GPU buffer (needs COPY_SRC).
3058    fn read_u32(h: &crate::HeadlessGpu, buf: &wgpu::Buffer, words: u64) -> Vec<u32> {
3059        let bytes = words * 4;
3060        let staging = h.device.create_buffer(&wgpu::BufferDescriptor {
3061            label: Some("readback"),
3062            size: bytes,
3063            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
3064            mapped_at_creation: false,
3065        });
3066        let mut enc = h
3067            .device
3068            .create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
3069        enc.copy_buffer_to_buffer(buf, 0, &staging, 0, bytes);
3070        h.queue.submit(std::iter::once(enc.finish()));
3071        let slice = staging.slice(..);
3072        let (tx, rx) = std::sync::mpsc::channel();
3073        slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
3074        h.device.poll(wgpu::PollType::wait_indefinitely()).ok();
3075        rx.recv().unwrap().unwrap();
3076        let data = slice.get_mapped_range();
3077        let out = bytemuck::cast_slice::<u8, u32>(&data).to_vec();
3078        drop(data);
3079        staging.unmap();
3080        out
3081    }
3082
3083    /// A second distinct model so add_model has real new geometry to lay
3084    /// down (different dims + colours from `kv6_unsorted`).
3085    fn kv6_other() -> Kv6 {
3086        let mk = |z, col| Voxel {
3087            col,
3088            z,
3089            vis: 0,
3090            dir: 0,
3091        };
3092        Kv6 {
3093            xsiz: 1,
3094            ysiz: 1,
3095            zsiz: 4,
3096            xpiv: 0.0,
3097            ypiv: 0.0,
3098            zpiv: 0.0,
3099            voxels: vec![mk(0, 0x11), mk(2, 0x22)],
3100            xlen: vec![2],
3101            ylen: vec![vec![2]],
3102            palette: None,
3103        }
3104    }
3105
3106    /// add_model lays the new model's volume on the GPU at the offsets its
3107    /// meta record claims — verified by reading the shared buffers back
3108    /// and matching each entry against its source SpriteModel.
3109    #[test]
3110    fn add_model_uploads_new_volume_incrementally() {
3111        let Some(h) = headless() else { return };
3112
3113        // Residency starts with model A only.
3114        let mut reg = SpriteModelRegistry::new();
3115        let a = reg.add(build_sprite_model(&kv6_unsorted()));
3116        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(a, [0.0; 3])]);
3117        assert_eq!(res.chains.len(), 1);
3118        let entries_before = res.meta.len();
3119
3120        // Append model B (single-level) to the registry, then sync it.
3121        let b = reg.add(build_sprite_model(&kv6_other()));
3122        res.add_model(&h.device, &h.queue, &reg, b);
3123        assert_eq!(res.chains.len(), 2);
3124        assert_eq!(res.meta.len(), entries_before + 1, "one new entry");
3125
3126        // Read the shared buffers back and check EVERY entry's data sits
3127        // where its meta record points — both the pre-existing A and the
3128        // newly streamed B.
3129        let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3130        let coloff = read_u32(&h, &res.color_offsets, u64::from(res.coloff_cap));
3131        let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3132        for (e, m) in reg.entries.iter().enumerate() {
3133            let meta = res.meta[e];
3134            let oo = meta.occupancy_offset as usize;
3135            assert_eq!(
3136                &occ[oo..oo + m.occupancy.len()],
3137                &m.occupancy[..],
3138                "occ entry {e}"
3139            );
3140            let co = meta.color_offsets_offset as usize;
3141            assert_eq!(
3142                &coloff[co..co + m.color_offsets.len()],
3143                &m.color_offsets[..],
3144                "color_offsets entry {e}"
3145            );
3146            let cc = meta.colors_offset as usize;
3147            assert_eq!(
3148                &cols[cc..cc + m.colors.len()],
3149                &m.colors[..],
3150                "colors entry {e}"
3151            );
3152        }
3153
3154        // And an instance of the freshly-added model can now be appended.
3155        let base = res.append_instances(&h.device, &reg, &[inst(b, [5.0, 0.0, 0.0])]);
3156        assert_eq!(base, 1);
3157        assert_eq!(res.instance_count(), 2);
3158    }
3159
3160    /// Adding many small models forces the volume buffers to grow + rebuild
3161    /// at least once; every entry must still read back correctly across the
3162    /// grow boundary.
3163    #[test]
3164    fn add_model_survives_buffer_growth() {
3165        let Some(h) = headless() else { return };
3166        let mut reg = SpriteModelRegistry::new();
3167        let a = reg.add(build_sprite_model(&kv6_unsorted()));
3168        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(a, [0.0; 3])]);
3169        let occ_cap0 = res.occ_cap;
3170
3171        // 40 adds — occupancy starts exact-sized (cap == used), so the very
3172        // first add overflows and grows; later ones ride the slack.
3173        for _ in 0..40 {
3174            let id = reg.add(build_sprite_model(&kv6_other()));
3175            res.add_model(&h.device, &h.queue, &reg, id);
3176        }
3177        assert_eq!(res.chains.len(), 41);
3178        assert!(res.occ_cap > occ_cap0, "occupancy buffer grew");
3179
3180        let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3181        let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3182        for (e, m) in reg.entries.iter().enumerate() {
3183            let meta = res.meta[e];
3184            let oo = meta.occupancy_offset as usize;
3185            assert_eq!(
3186                &occ[oo..oo + m.occupancy.len()],
3187                &m.occupancy[..],
3188                "occ entry {e}"
3189            );
3190            let cc = meta.colors_offset as usize;
3191            assert_eq!(
3192                &cols[cc..cc + m.colors.len()],
3193                &m.colors[..],
3194                "colors entry {e}"
3195            );
3196        }
3197    }
3198
3199    /// Regression (downstream report, 0.27.0): a `remove_model` hole
3200    /// followed by an occupancy-overflow `add_model` desynced every live
3201    /// entry behind the hole — the grow path rebuilt the buffer tightly
3202    /// (tombstoned entries contribute nothing) but kept the STALE bump
3203    /// offsets in `meta`, so those models read shifted occupancy words
3204    /// ("black stripe planes") until an `update_model` happened to
3205    /// rewrite them at the stale offset. The overflow path now routes
3206    /// through `compact_concat`, which recomputes the offsets it
3207    /// uploads.
3208    #[test]
3209    fn growth_after_remove_keeps_offsets_in_sync() {
3210        let Some(h) = headless() else { return };
3211        let mut reg = SpriteModelRegistry::new();
3212        // Three models; the middle one becomes the hole.
3213        let a = reg.add(build_sprite_model(&kv6_unsorted()));
3214        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(a, [0.0; 3])]);
3215        let b = reg.add(build_sprite_model(&kv6_other()));
3216        res.add_model(&h.device, &h.queue, &reg, b);
3217        let c = reg.add(build_sprite_model(&kv6_other()));
3218        res.add_model(&h.device, &h.queue, &reg, c);
3219        let _ = c;
3220
3221        // Tombstone the middle chain: resident hole + zero-length
3222        // registry entry (exactly the facade's remove path).
3223        res.remove_model(b);
3224        reg.remove(b);
3225
3226        // Keep adding until occupancy overflows — the grow/rebuild path.
3227        let cap_before = res.occ_cap;
3228        let mut guard = 0;
3229        while res.occ_cap == cap_before {
3230            let id = reg.add(build_sprite_model(&kv6_other()));
3231            res.add_model(&h.device, &h.queue, &reg, id);
3232            guard += 1;
3233            assert!(guard < 10_000, "growth never triggered");
3234        }
3235
3236        // Every LIVE entry's meta offset must point at its actual data
3237        // in the rebuilt buffers.
3238        let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3239        let coloff = read_u32(&h, &res.color_offsets, u64::from(res.coloff_cap));
3240        for (e, m) in reg.entries.iter().enumerate() {
3241            if res.dead[e] {
3242                continue;
3243            }
3244            let meta = res.meta[e];
3245            let oo = meta.occupancy_offset as usize;
3246            assert_eq!(
3247                &occ[oo..oo + m.occupancy.len()],
3248                &m.occupancy[..],
3249                "occ entry {e} reads at its meta offset"
3250            );
3251            let co = meta.color_offsets_offset as usize;
3252            assert_eq!(
3253                &coloff[co..co + m.color_offsets.len()],
3254                &m.color_offsets[..],
3255                "color_offsets entry {e}"
3256            );
3257        }
3258    }
3259
3260    /// VCL.2 — a decoded voxel clip's frames register as a flipbook of LOD
3261    /// chains, and `set_instance_model` flips which frame an instance
3262    /// draws. The cull state it updates is exactly what
3263    /// `cull_bin_upload` packs into the GPU instance buffer each frame, so
3264    /// TV.3 (clip wiring): `sprite_model_from_clip_frame_with_materials`
3265    /// classifies a clip frame's voxels into a per-voxel `materials` array
3266    /// (parallel to `colors`) by colour; an empty map leaves it empty (the
3267    /// all-opaque clip), identical to `sprite_model_from_clip_frame`.
3268    #[test]
3269    fn clip_frame_with_materials_classifies_by_color() {
3270        use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
3271
3272        let dims = [1u32, 1, 4];
3273        let owpc = dims[2].div_ceil(32).max(1) as usize; // 1
3274        let glass = 0x80AA_BBCC;
3275        let stone = 0x8011_2233;
3276        let frame = VoxelFrame {
3277            occupancy: {
3278                let mut occ = vec![0u32; owpc];
3279                occ[0] |= (1 << 0) | (1 << 1);
3280                occ
3281            },
3282            colors: vec![stone, glass], // ascending z: z=0 stone, z=1 glass
3283            color_offsets: vec![0, 2],
3284        };
3285        let clip = VoxelClip::from_frames(
3286            dims,
3287            [0.5, 0.5, 2.0],
3288            1.0,
3289            LoopMode::Loop,
3290            &[frame],
3291            &[],
3292            33,
3293            0,
3294        );
3295        let decoded = clip.decode().expect("decode");
3296
3297        // Map only the glass colour → material 2; stone stays opaque (0).
3298        let m = sprite_model_from_clip_frame_with_materials(&decoded, 0, &[(Rgb(0x00AA_BBCC), 2)]);
3299        assert_eq!(
3300            m.materials.len(),
3301            m.colors.len(),
3302            "materials parallel to colors"
3303        );
3304        // `colors` is in popcount-rank (ascending z) order: stone then glass.
3305        assert_eq!(
3306            m.materials,
3307            vec![0u8, 2u8],
3308            "stone opaque, glass material 2"
3309        );
3310
3311        // Empty map ⇒ no per-voxel materials, identical to the plain builder.
3312        let plain = sprite_model_from_clip_frame(&decoded, 0);
3313        let plain_mat = sprite_model_from_clip_frame_with_materials(&decoded, 0, &[]);
3314        assert!(plain.materials.is_empty());
3315        assert!(plain_mat.materials.is_empty());
3316        assert_eq!(plain.colors, plain_mat.colors);
3317    }
3318
3319    /// TV.3 (streaming-clip refresh path): `build_sprite_model_with_materials`
3320    /// — the builder behind `GpuBackend::update_sprite_model_with_materials`,
3321    /// which a streaming clip re-runs each frame — classifies a kv6's voxels
3322    /// into a per-voxel `materials` array (popcount-rank order) by colour.
3323    #[test]
3324    fn build_with_materials_classifies_by_color() {
3325        let glass = 0x80AA_BBCC;
3326        let stone = 0x8011_2233;
3327        // One column (x=0,y=0), two voxels: z=0 stone, z=1 glass.
3328        let kv6 = kv6_from(1, 1, 4, &[(0, 0, 0, stone), (0, 0, 1, glass)]);
3329
3330        let m = build_sprite_model_with_materials(&kv6, &[(Rgb(0x00AA_BBCC), 2)]);
3331        assert_eq!(
3332            m.materials.len(),
3333            m.colors.len(),
3334            "materials parallel to colors"
3335        );
3336        assert_eq!(
3337            m.materials,
3338            vec![0u8, 2u8],
3339            "stone opaque, glass material 2"
3340        );
3341
3342        // Empty map ⇒ no per-voxel materials, identical to `build_sprite_model`.
3343        let plain = build_sprite_model(&kv6);
3344        let plain_mat = build_sprite_model_with_materials(&kv6, &[]);
3345        assert!(plain.materials.is_empty());
3346        assert!(plain_mat.materials.is_empty());
3347        assert_eq!(plain.colors, plain_mat.colors);
3348    }
3349
3350    /// flipping `chain_id` redirects the rendered instance to the new
3351    /// frame's resident volume.
3352    #[test]
3353    fn voxel_clip_flipbook_set_instance_model() {
3354        use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
3355        let Some(h) = headless() else { return };
3356
3357        // Two distinct frames of a 1×1×4 clip: frame 0 has a voxel at z=0;
3358        // frame 1 adds z=1 — different occupancy + a longer colour run.
3359        let dims = [1u32, 1, 4];
3360        let owpc = dims[2].div_ceil(32).max(1) as usize; // 1
3361        let mk_frame = |zs: &[u32], cols: &[u32]| -> VoxelFrame {
3362            let mut occ = vec![0u32; owpc];
3363            for &z in zs {
3364                occ[(z >> 5) as usize] |= 1u32 << (z & 31);
3365            }
3366            VoxelFrame {
3367                occupancy: occ,
3368                colors: cols.to_vec(),
3369                color_offsets: vec![0, cols.len() as u32],
3370            }
3371        };
3372        let f0 = mk_frame(&[0], &[0x8011_2233]);
3373        let f1 = mk_frame(&[0, 1], &[0x8011_2233, 0x80AA_BBCC]);
3374        let clip = VoxelClip::from_frames(
3375            dims,
3376            [0.5, 0.5, 2.0],
3377            1.0,
3378            LoopMode::Loop,
3379            &[f0, f1],
3380            &[],
3381            33,
3382            0,
3383        );
3384        let decoded = clip.decode().expect("decode");
3385
3386        // Each frame → a single-level chain; both volumes resident + distinct.
3387        let mut reg = SpriteModelRegistry::new();
3388        let c0 = reg.add(sprite_model_from_clip_frame(&decoded, 0));
3389        let c1 = reg.add(sprite_model_from_clip_frame(&decoded, 1));
3390        assert_eq!(reg.model(c0).colors.len(), 1);
3391        assert_eq!(reg.model(c1).colors.len(), 2);
3392
3393        // One instance, in front of the test frustum, drawing frame 0.
3394        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &[inst(c0, [0.0, 0.0, 5.0])]);
3395        assert_eq!(res.cull[0].chain_id, c0);
3396
3397        // Flip to frame 1: the cull now draws chain c1 (radius reseeded).
3398        res.set_instance_model(&reg, 0, c1);
3399        assert_eq!(res.cull[0].chain_id, c1);
3400        assert_eq!(res.cull[0].radius, reg.model(c1).bound_radius());
3401
3402        // The next cull packs the new chain into the GPU instance buffer
3403        // (visible, no panic).
3404        let f = test_frustum();
3405        let (visible, _, _) =
3406            res.cull_bin_upload(&h.device, &h.queue, &f, 64, 64, 16, 1.0, &[], None, 0);
3407        assert_eq!(visible, 1);
3408
3409        // …and back to frame 0.
3410        res.set_instance_model(&reg, 0, c0);
3411        assert_eq!(res.cull[0].chain_id, c0);
3412
3413        // Out-of-range index is a safe no-op.
3414        res.set_instance_model(&reg, 99, c1);
3415        assert_eq!(res.cull[0].chain_id, c0);
3416    }
3417
3418    fn test_frustum() -> ViewFrustum {
3419        ViewFrustum {
3420            pos: [0.0, 0.0, 0.0],
3421            right: [1.0, 0.0, 0.0],
3422            down: [0.0, 1.0, 0.0],
3423            forward: [0.0, 0.0, 1.0],
3424            half_w: 1.0,
3425            half_h: 1.0,
3426            far: 10_000.0,
3427        }
3428    }
3429
3430    #[test]
3431    fn remove_model_tombstones_frees_and_reuses() {
3432        let Some(h) = headless() else { return };
3433        // Residency with models A and B, one instance each.
3434        let mut reg = SpriteModelRegistry::new();
3435        let a = reg.add(build_sprite_model(&kv6_unsorted()));
3436        let b = reg.add(build_sprite_model(&kv6_other()));
3437        let mut res = SpriteRegistryResident::upload(
3438            &h.device,
3439            &reg,
3440            &[inst(a, [0.0; 3]), inst(b, [1.0, 0.0, 0.0])],
3441        );
3442        assert_eq!(res.live_model_count(), 2);
3443        assert_eq!(res.dead_model_count(), 0);
3444
3445        // Remove B → tombstoned, its colours freed into the pool.
3446        res.remove_model(b);
3447        assert_eq!(res.live_model_count(), 1);
3448        assert_eq!(res.dead_model_count(), 1);
3449        assert_eq!(res.dead.iter().filter(|&&d| d).count(), 1, "one entry dead");
3450        assert!(!res.colors_alloc.free.is_empty(), "B's colour slot freed");
3451
3452        // Adding C reuses the freed slot (free-list first-fit).
3453        let c = reg.add(build_sprite_model(&kv6_other()));
3454        res.add_model(&h.device, &h.queue, &reg, c);
3455        assert_eq!(res.live_model_count(), 2);
3456
3457        // A and C read back correctly; B is dead (skipped).
3458        let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3459        for e in [a as usize, c as usize] {
3460            let m = &reg.entries[e];
3461            let cc = res.meta[e].colors_offset as usize;
3462            assert_eq!(
3463                &cols[cc..cc + m.colors.len()],
3464                &m.colors[..],
3465                "colors entry {e}"
3466            );
3467        }
3468
3469        // The lingering instance of removed B is skipped without panic.
3470        let f = test_frustum();
3471        let _ = res.cull_bin_upload(&h.device, &h.queue, &f, 64, 64, 16, 1.0, &[], None, 0);
3472    }
3473
3474    #[test]
3475    fn compact_reclaims_holes_keeps_ids_stable() {
3476        let Some(h) = headless() else { return };
3477        let mut reg = SpriteModelRegistry::new();
3478        let a = reg.add(build_sprite_model(&kv6_unsorted()));
3479        let b = reg.add(build_sprite_model(&kv6_other()));
3480        let c = reg.add(build_sprite_model(&kv6_other()));
3481        let mut res = SpriteRegistryResident::upload(
3482            &h.device,
3483            &reg,
3484            &[inst(a, [0.0; 3]), inst(b, [1.0; 3]), inst(c, [2.0; 3])],
3485        );
3486        let occ_used_full = res.occ_used;
3487
3488        // Remove the middle model, then compact.
3489        res.remove_model(b);
3490        res.compact(&h.device, &h.queue, &reg);
3491
3492        // Holes reclaimed: occupancy now only covers A + C.
3493        let live_occ: u32 = [a, c]
3494            .iter()
3495            .map(|&e| reg.entries[e as usize].occupancy.len() as u32)
3496            .sum();
3497        assert_eq!(res.occ_used, live_occ);
3498        assert!(res.occ_used < occ_used_full, "compaction shrank occupancy");
3499        // Dead entry keeps a zeroed tombstone; ids unchanged.
3500        assert_eq!(res.meta[b as usize].occupancy_offset, 0);
3501        assert_eq!(res.live_model_count(), 2);
3502        assert_eq!(res.dead_model_count(), 1);
3503
3504        // Live entries read back correctly at their new offsets.
3505        let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3506        let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3507        for &e in &[a as usize, c as usize] {
3508            let m = &reg.entries[e];
3509            let oo = res.meta[e].occupancy_offset as usize;
3510            assert_eq!(
3511                &occ[oo..oo + m.occupancy.len()],
3512                &m.occupancy[..],
3513                "occ {e}"
3514            );
3515            let cc = res.meta[e].colors_offset as usize;
3516            assert_eq!(&cols[cc..cc + m.colors.len()], &m.colors[..], "cols {e}");
3517        }
3518
3519        // Chain ids still valid: C's chain still resolves; B's is empty.
3520        assert!(!res.chains[c as usize].is_empty());
3521        assert!(res.chains[b as usize].is_empty());
3522    }
3523
3524    #[test]
3525    fn remove_swap_semantics_and_capacity_retained() {
3526        let Some(h) = headless() else { return };
3527        let (reg, m) = one_model_registry();
3528        let seed: Vec<_> = (0..4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
3529        let mut res = SpriteRegistryResident::upload(&h.device, &reg, &seed);
3530        assert_eq!(res.instance_count(), 4);
3531        let cap = res.instance_capacity;
3532
3533        // Remove a middle element → the previous last (idx 3) moved into it.
3534        assert_eq!(res.remove_instance(1), Some(3));
3535        assert_eq!(res.instance_count(), 3);
3536
3537        // Remove the current last (idx 2) → nothing moved.
3538        assert_eq!(res.remove_instance(2), None);
3539        assert_eq!(res.instance_count(), 2);
3540
3541        // Out of range → None.
3542        assert_eq!(res.remove_instance(99), None);
3543        assert_eq!(res.instance_count(), 2);
3544
3545        // Capacity is retained for reuse (no shrink).
3546        assert_eq!(res.instance_capacity, cap);
3547    }
3548}