Skip to main content

roxlap_gpu/
sprite_model.rs

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