Skip to main content

roxlap_core/
dda_sprite.rs

1//! Clean-room KV6 sprite raycaster for the DDA backend (Substage
2//! DDA.8).
3//!
4//! Renders KV6 sprites by **per-pixel ray casting**: for every screen
5//! pixel the sprite covers, transform the camera ray into the sprite's
6//! local voxel space, 3D-DDA through the KV6, and depth-composite the
7//! first solid voxel against the shared z-buffer. Clean-room (no voxlap
8//! code), the sprite counterpart to the terrain renderer in
9//! [`crate::dda`].
10//!
11//! **Depth parity.** Transforming the ray by the inverse sprite basis
12//! leaves the ray parameter unchanged in world units — a hit at local
13//! parameter `t` is at world point `cam.pos + dir·t` — so the
14//! perpendicular depth is `t · (dir·forward)`, exactly the convention
15//! [`crate::dda`] writes for terrain. Sprites therefore occlude and are
16//! occluded by DDA terrain correctly.
17//!
18//! Shading reads the KV6 voxel's baked brightness byte (high byte of
19//! the packed colour) via `crate::dda::shade` — the clean-room
20//! brightness model, not voxlap's `dir`-LUT reflection shading.
21
22use roxlap_formats::kv6::Kv6;
23use roxlap_formats::material::{material_for_color, BlendMode, MaterialTable};
24use roxlap_formats::sprite::{
25    Sprite, SPRITE_FLAG_INVISIBLE, SPRITE_FLAG_LIGHT_AMBIENT_ONLY, SPRITE_FLAG_LIGHT_WORLD_UP,
26    SPRITE_FLAG_NO_Z,
27};
28use roxlap_formats::voxel_clip::{DecodedClip, VoxelFrame};
29use roxlap_formats::Rgb;
30
31use std::sync::Arc;
32
33use crate::camera_math::CameraState;
34use crate::dda::{
35    dda_setup, emissive_shade, intersect_aabb, min_axis, pixel_ray, shade, shade_dynamic,
36    CpuLights, ShadowTester, WorldOccluder, WorldShadow, WorldShadowCtx,
37};
38use crate::opticast::OpticastSettings;
39use crate::raster_target::RasterTarget;
40
41/// Near-plane parameter: voxels nearer than this (camera-forward) are
42/// dropped, keeping the pinhole divide finite.
43const NEAR_Z: f32 = 1.0;
44
45/// Force a packed voxel colour to full brightness for the flat-lit
46/// clean-room sprite path. KV6 / voxel-clip colours carry voxlap's
47/// `dir`/shading slot in the high byte (some `0x80`, some `0x00`), not
48/// the 0..128 brightness [`shade`] expects, so a raw value can render
49/// black; we render every sprite voxel at its authored RGB.
50#[inline]
51fn full_bright(col: u32) -> u32 {
52    (col & 0x00ff_ffff) | 0x8000_0000
53}
54
55/// Dense occupancy + colour grid for one sprite frame, plus its pivot —
56/// the decoded form the per-pixel raycaster marches. Built once from a
57/// [`Kv6`] ([`SpriteDense::from_kv6`]) or a voxel-clip [`VoxelFrame`]
58/// ([`SpriteDense::from_voxel_frame`]); the latter lets an animated clip
59/// cache every frame's grid up front instead of rebuilding per frame.
60///
61/// Both sources store only **surface** voxels (a from-air ray's first
62/// hit is the visible surface), so the grid is the visible hull.
63#[derive(Clone)]
64pub struct SpriteDense {
65    dims: [i32; 3],
66    occ: Vec<bool>,
67    col: Vec<u32>,
68    /// Per-voxel material id (TV stage), parallel to [`col`](Self::col) /
69    /// [`occ`](Self::occ) (same dense index). **Empty** means every voxel
70    /// uses the draw-time uniform material (the TV.1 path); a non-empty
71    /// array gives mixed-material models (opaque frame + glass, TV.3). Only
72    /// consulted on the [`draw_sprite_dense_shaded`] accumulate path.
73    mat: Vec<u8>,
74    pivot: [f32; 3],
75    /// World units per voxel (the clip's `voxel_world_size`; `1.0`
76    /// for kv6 models). The draw + occluder entries scale the
77    /// instance basis by it, mirroring the GPU shader's
78    /// `(p - pivot) * voxel_world_size` — CPU/GPU parity for scaled
79    /// clips.
80    voxel_world_size: f32,
81}
82
83impl SpriteDense {
84    /// Decode a [`Kv6`]'s surface-voxel run tables into a dense grid.
85    #[must_use]
86    #[allow(clippy::cast_possible_wrap)]
87    pub fn from_kv6(kv6: &Kv6) -> Self {
88        let dims = [kv6.xsiz as i32, kv6.ysiz as i32, kv6.zsiz as i32];
89        let n = (dims[0].max(0) * dims[1].max(0) * dims[2].max(0)) as usize;
90        let mut occ = vec![false; n];
91        let mut col = vec![0u32; n];
92        let mut vi = 0usize;
93        for x in 0..kv6.xsiz as usize {
94            for y in 0..kv6.ysiz as usize {
95                let cnt = usize::from(kv6.ylen[x][y]);
96                for _ in 0..cnt {
97                    let v = kv6.voxels[vi];
98                    vi += 1;
99                    let z = i32::from(v.z);
100                    if z >= 0 && z < dims[2] {
101                        let idx = ((x as i32 * dims[1] + y as i32) * dims[2] + z) as usize;
102                        occ[idx] = true;
103                        col[idx] = full_bright(v.col);
104                    }
105                }
106            }
107        }
108        Self {
109            dims,
110            occ,
111            col,
112            mat: Vec::new(),
113            pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
114            voxel_world_size: 1.0,
115        }
116    }
117
118    /// Like [`from_kv6`](Self::from_kv6) but classifies each voxel into a
119    /// material id by colour (TV.3 mixed models) via `material_map`
120    /// (`(rgb, material_id)` pairs; see
121    /// [`material_for_color`]).
122    /// The resulting per-voxel `mat` array is consulted by the
123    /// [`draw_sprite_dense_shaded`] accumulate path. An empty map yields the
124    /// same all-opaque (uniform) result as `from_kv6`.
125    #[must_use]
126    #[allow(clippy::cast_possible_wrap)]
127    pub fn from_kv6_with_materials(kv6: &Kv6, material_map: &[(Rgb, u8)]) -> Self {
128        let mut dense = Self::from_kv6(kv6);
129        if !material_map.is_empty() {
130            let n = dense.col.len();
131            let mut mat = vec![0u8; n];
132            for (idx, slot) in mat.iter_mut().enumerate() {
133                if dense.occ[idx] {
134                    *slot = material_for_color(material_map, dense.col[idx]);
135                }
136            }
137            dense.mat = mat;
138        }
139        dense
140    }
141
142    /// Decode a voxel-clip [`VoxelFrame`] (dense-column layout) into the
143    /// dense grid, given the clip's `dims` + `pivot`. The frame's columns
144    /// are `col = x + y*dims[0]`, each a per-column occupancy bitmask with
145    /// an ascending-z colour run — walked here into the raycaster's
146    /// `(x·my + y)·mz + z` grid.
147    #[must_use]
148    #[allow(clippy::cast_possible_wrap)]
149    pub fn from_voxel_frame(frame: &VoxelFrame, dims: [u32; 3], pivot: [f32; 3]) -> Self {
150        let (mx, my, mz) = (dims[0], dims[1], dims[2]);
151        let owpc = mz.div_ceil(32).max(1) as usize;
152        let n = (mx * my * mz) as usize;
153        let mut occ = vec![false; n];
154        let mut col = vec![0u32; n];
155        for col_idx in 0..(mx * my) as usize {
156            let x = col_idx as u32 % mx;
157            let y = col_idx as u32 / mx;
158            let run_start = frame.color_offsets[col_idx] as usize;
159            let mut k = 0usize;
160            for z in 0..mz {
161                let word = frame.occupancy[col_idx * owpc + (z >> 5) as usize];
162                if (word >> (z & 31)) & 1 != 0 {
163                    let idx = (((x * my + y) * mz) + z) as usize;
164                    occ[idx] = true;
165                    col[idx] = full_bright(frame.colors[run_start + k]);
166                    k += 1;
167                }
168            }
169        }
170        Self {
171            dims: [mx as i32, my as i32, mz as i32],
172            occ,
173            col,
174            mat: Vec::new(),
175            pivot,
176            voxel_world_size: 1.0,
177        }
178    }
179
180    /// Set the render scale (world units per voxel) — the clip's
181    /// `voxel_world_size`. Chainable; the default is `1.0`.
182    #[must_use]
183    pub fn with_voxel_world_size(mut self, vws: f32) -> Self {
184        self.voxel_world_size = vws;
185        self
186    }
187
188    /// Like [`from_voxel_frame`](Self::from_voxel_frame) but classifies each
189    /// voxel into a material id by colour (TV.3 mixed models) via
190    /// `material_map` — the clip analogue of
191    /// [`from_kv6_with_materials`](Self::from_kv6_with_materials). An empty
192    /// map yields the same all-opaque (uniform) result as `from_voxel_frame`.
193    #[must_use]
194    pub fn from_voxel_frame_with_materials(
195        frame: &VoxelFrame,
196        dims: [u32; 3],
197        pivot: [f32; 3],
198        material_map: &[(Rgb, u8)],
199    ) -> Self {
200        let mut dense = Self::from_voxel_frame(frame, dims, pivot);
201        if !material_map.is_empty() {
202            let n = dense.col.len();
203            let mut mat = vec![0u8; n];
204            for (idx, slot) in mat.iter_mut().enumerate() {
205                if dense.occ[idx] {
206                    *slot = material_for_color(material_map, dense.col[idx]);
207                }
208            }
209            dense.mat = mat;
210        }
211        dense
212    }
213
214    #[inline]
215    #[allow(clippy::cast_sign_loss)]
216    fn idx_of(&self, c: [i32; 3]) -> usize {
217        ((c[0] * self.dims[1] + c[1]) * self.dims[2] + c[2]) as usize
218    }
219
220    #[inline]
221    fn at(&self, c: [i32; 3]) -> Option<u32> {
222        let idx = self.idx_of(c);
223        self.occ[idx].then(|| self.col[idx])
224    }
225}
226
227/// Inverse of the column-matrix `[s | h | f]` (the sprite basis), or
228/// `None` if degenerate. Maps a world delta into local voxel space.
229fn invert_basis(s: [f32; 3], h: [f32; 3], f: [f32; 3]) -> Option<[[f32; 3]; 3]> {
230    let det = s[0] * (h[1] * f[2] - f[1] * h[2]) - h[0] * (s[1] * f[2] - f[1] * s[2])
231        + f[0] * (s[1] * h[2] - h[1] * s[2]);
232    if det.abs() < 1e-12 {
233        return None;
234    }
235    let inv = 1.0 / det;
236    Some([
237        [
238            (h[1] * f[2] - f[1] * h[2]) * inv,
239            -(h[0] * f[2] - f[0] * h[2]) * inv,
240            (h[0] * f[1] - f[0] * h[1]) * inv,
241        ],
242        [
243            -(s[1] * f[2] - f[1] * s[2]) * inv,
244            (s[0] * f[2] - f[0] * s[2]) * inv,
245            -(s[0] * f[1] - f[0] * s[1]) * inv,
246        ],
247        [
248            (s[1] * h[2] - h[1] * s[2]) * inv,
249            -(s[0] * h[2] - h[0] * s[2]) * inv,
250            (s[0] * h[1] - h[0] * s[1]) * inv,
251        ],
252    ])
253}
254
255#[inline]
256fn mat_apply(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
257    [
258        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
259        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
260        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
261    ]
262}
263
264/// Cast one ray (already in the sprite's local voxel space) into the
265/// dense KV6 and return `(colour, t)` of the first solid voxel — `t` is
266/// the world-units ray parameter (shared with the world ray).
267#[allow(clippy::cast_possible_truncation)]
268/// First solid voxel along the local ray. Returns `(color, t, normal_local,
269/// cell)`: `normal_local` is the **model-local** face normal of the hit
270/// (points back toward the ray; zero for the entry voxel, no face crossed) —
271/// the caller rotates it to world for dynamic lighting (DL.7); `cell` is the
272/// hit voxel for the flat-per-voxel world centre.
273fn cast_local(
274    dense: &SpriteDense,
275    origin: [f32; 3],
276    dir: [f32; 3],
277) -> Option<(u32, f32, [f32; 3], [i32; 3])> {
278    #[allow(clippy::cast_precision_loss)]
279    let hi = [
280        dense.dims[0] as f32,
281        dense.dims[1] as f32,
282        dense.dims[2] as f32,
283    ];
284    let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
285    let start = t0 + 1e-4;
286    let p = [
287        origin[0] + dir[0] * start,
288        origin[1] + dir[1] * start,
289        origin[2] + dir[2] * start,
290    ];
291    let mut cell = [
292        (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
293        (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
294        (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
295    ];
296    let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
297    let mut t_curr = t0;
298    // Face crossed to reach the current cell (model-local normal). The entry
299    // voxel (solid at t0, no step yet) has none → zero normal.
300    let mut normal = [0.0f32; 3];
301    let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
302    for _ in 0..max_steps {
303        if cell[0] < 0
304            || cell[0] >= dense.dims[0]
305            || cell[1] < 0
306            || cell[1] >= dense.dims[1]
307            || cell[2] < 0
308            || cell[2] >= dense.dims[2]
309            || t_curr > t1
310        {
311            return None;
312        }
313        if let Some(color) = dense.at(cell) {
314            return Some((color, t_curr, normal, cell));
315        }
316        let axis = min_axis(t_max);
317        t_curr = t_max[axis];
318        cell[axis] += step[axis];
319        t_max[axis] += t_delta[axis];
320        normal = [0.0; 3];
321        normal[axis] = -(step[axis] as f32);
322    }
323    None
324}
325
326/// XS.2 — one sprite volume in the scene shadow occluder: its decoded dense
327/// voxels + world pose, with the cached inverse instance basis for
328/// world→sprite-local transforms. PF.8 — the dense grid is shared
329/// (`Arc`) with the draw path's per-model cache instead of deep-cloned
330/// per occluder rebuild.
331struct SpriteOccEntry {
332    dense: Arc<SpriteDense>,
333    pos: [f32; 3],
334    pivot: [f32; 3],
335    minv: [[f32; 3]; 3],
336}
337
338/// XS.2 — a [`WorldOccluder`] over sprite volumes, so **sprites cast** hard
339/// shadows onto terrain and each other (and so a sprite-receive query also
340/// sees other sprites). Owns the decoded [`SpriteDense`] grids; populate with
341/// [`Self::push`].
342///
343/// A world-space shadow ray is transformed into each sprite's local frame and
344/// the dense occupancy is DDA-marched. Assumes orthonormal unit instance bases
345/// (as the sprite draw does); a non-uniform scale would skew the `max_t`
346/// distance bound. Empty ⇒ casts nothing.
347#[derive(Default)]
348pub struct SpriteOccluder {
349    entries: Vec<SpriteOccEntry>,
350}
351
352impl SpriteOccluder {
353    /// An empty occluder (casts nothing). Add sprite volumes with
354    /// [`push`](Self::push).
355    #[must_use]
356    pub fn new() -> Self {
357        Self::default()
358    }
359
360    /// Whether the occluder holds any sprite volumes.
361    #[must_use]
362    pub fn is_empty(&self) -> bool {
363        self.entries.is_empty()
364    }
365
366    /// Add a decoded sprite volume at a world pose (`pos` = world pivot;
367    /// `s`/`h`/`f` = model→world basis columns, the same pose the draw uses).
368    /// A degenerate (non-invertible) basis is skipped. PF.8 — takes the
369    /// dense grid by `Arc` (an occluder rebuild shares the draw path's
370    /// cached decodes instead of re-densifying every caster per frame).
371    pub fn push(
372        &mut self,
373        dense: Arc<SpriteDense>,
374        pos: [f32; 3],
375        s: [f32; 3],
376        h: [f32; 3],
377        f: [f32; 3],
378    ) {
379        // Same clip render scale as the draw entry (see
380        // `draw_sprite_dense_shaded`) — shadows must match the pixels.
381        let sc = dense.voxel_world_size;
382        let (s, h, f) = (bb_scale3(s, sc), bb_scale3(h, sc), bb_scale3(f, sc));
383        let Some(minv) = invert_basis(s, h, f) else {
384            return;
385        };
386        let pivot = dense.pivot;
387        self.entries.push(SpriteOccEntry {
388            dense,
389            pos,
390            pivot,
391            minv,
392        });
393    }
394}
395
396impl WorldOccluder for SpriteOccluder {
397    fn occluded_world(&self, origin: [f64; 3], dir: [f32; 3], max_t: f32) -> bool {
398        self.entries
399            .iter()
400            .any(|e| sprite_entry_occluded(e, origin, dir, max_t))
401    }
402}
403
404/// March one sprite entry's dense occupancy along a world-space ray; `true` if
405/// a solid voxel blocks it within `max_t` world units.
406#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
407/// Scale a basis column by the clip render scale.
408fn bb_scale3(v: [f32; 3], k: f32) -> [f32; 3] {
409    [v[0] * k, v[1] * k, v[2] * k]
410}
411
412fn sprite_entry_occluded(e: &SpriteOccEntry, ow: [f64; 3], dw: [f32; 3], max_t: f32) -> bool {
413    // World → sprite-local voxel space (same transform as the draw). SC.2 —
414    // the origin subtraction runs in f64 (both are large world coords, so this
415    // avoids catastrophic cancellation) then narrows to f32 for the local
416    // basis apply; the sprite frame's coords are small.
417    #[allow(clippy::cast_possible_truncation)]
418    let rel = [
419        (ow[0] - f64::from(e.pos[0])) as f32,
420        (ow[1] - f64::from(e.pos[1])) as f32,
421        (ow[2] - f64::from(e.pos[2])) as f32,
422    ];
423    let ol = mat_apply(&e.minv, rel);
424    let origin = [ol[0] + e.pivot[0], ol[1] + e.pivot[1], ol[2] + e.pivot[2]];
425    let dir = mat_apply(&e.minv, dw);
426
427    let hi = [
428        e.dense.dims[0] as f32,
429        e.dense.dims[1] as f32,
430        e.dense.dims[2] as f32,
431    ];
432    let Some((t0, t1)) = intersect_aabb(origin, dir, [0.0; 3], hi) else {
433        return false;
434    };
435    let t_enter = t0.max(0.0);
436    let t_exit = t1.min(max_t);
437    if t_enter > t_exit {
438        return false;
439    }
440    let start = t_enter + 1e-4;
441    let p = [
442        origin[0] + dir[0] * start,
443        origin[1] + dir[1] * start,
444        origin[2] + dir[2] * start,
445    ];
446    let mut cell = [
447        (p[0].floor() as i32).clamp(0, e.dense.dims[0] - 1),
448        (p[1].floor() as i32).clamp(0, e.dense.dims[1] - 1),
449        (p[2].floor() as i32).clamp(0, e.dense.dims[2] - 1),
450    ];
451    let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
452    let mut t_curr = t_enter;
453    let max_steps = (e.dense.dims[0] + e.dense.dims[1] + e.dense.dims[2]) as usize + 8;
454    for _ in 0..max_steps {
455        if cell[0] < 0
456            || cell[0] >= e.dense.dims[0]
457            || cell[1] < 0
458            || cell[1] >= e.dense.dims[1]
459            || cell[2] < 0
460            || cell[2] >= e.dense.dims[2]
461            || t_curr > t_exit
462        {
463            return false;
464        }
465        if e.dense.occ[e.dense.idx_of(cell)] {
466            return true;
467        }
468        let a = min_axis(t_max);
469        t_curr = t_max[a];
470        cell[a] += step[a];
471        t_max[a] += t_delta[a];
472    }
473    false
474}
475
476/// Material context for a translucent sprite draw (TV stage): the global
477/// [`MaterialTable`] plus this instance's uniform material id and per-frame
478/// alpha multiplier. Passed (as `Some`) to [`draw_sprite_dense_shaded`] /
479/// [`ClipFlipbook::draw_frame_shaded`] to enable front-to-back
480/// accumulate-and-continue compositing; `None` (or an all-opaque effective
481/// material) takes the existing first-hit opaque path byte-for-byte.
482#[derive(Clone, Copy)]
483pub struct SpriteShade<'a> {
484    /// Global voxel-material palette (per-voxel id → opacity + blend mode).
485    pub materials: &'a MaterialTable,
486    /// Uniform material id for every voxel of this sprite whose dense
487    /// per-voxel `mat` array is empty (the TV.1 whole-sprite material).
488    pub material: u8,
489    /// Per-instance opacity multiplier (`255` = unscaled), so an effect can
490    /// fade out by cheap per-frame updates without re-uploading the volume.
491    pub alpha_mul: u8,
492    /// Per-instance RGB colour tint, packed `0x00RRGGBB` — each rendered
493    /// voxel's colour is multiplied by it. `0x00FF_FFFF` (white) is a no-op.
494    pub tint: u32,
495    /// DL.7 — world-space dynamic lights. When `enabled`, the opaque hit is
496    /// lit (sun + point lights + cel + ramp, flat per voxel) instead of the
497    /// baked `shade`. `CpuLights::default()` (disabled) ⇒ unchanged.
498    pub lights: CpuLights<'a>,
499    /// XS.2 — world-space scene occluder for **sprites receiving** hard
500    /// shadows: a lit sprite voxel marches a shadow ray (world space) against
501    /// this and is darkened where terrain / other sprites block the caster.
502    /// `None` (the default) ⇒ unshadowed sprites (the pre-XS.2 look).
503    pub shadow: Option<&'a dyn WorldOccluder>,
504}
505
506/// Accumulated front-to-back composite for one ray through a sprite.
507struct LayerAccum {
508    /// Premultiplied accumulated colour, channels in `0..=~1` (additive may
509    /// exceed 1; clamped at pack time).
510    rgb: [f32; 3],
511    /// Remaining transmittance (starts 1.0, decays through `AlphaBlend`).
512    trans: f32,
513    /// The opaque/background hit that terminated the march, if any: its
514    /// already-shaded packed colour + world-ray parameter `t`. `None` if the
515    /// ray exited (or fully attenuated) without an opaque voxel — then the
516    /// background is whatever the framebuffer already holds (terrain/sky).
517    opaque: Option<(u32, f32)>,
518}
519
520/// Per-instance RGB tint: multiply `color`'s RGB by `tint`'s (both packed,
521/// `tint`'s channels normalised by 255), preserving `color`'s high byte. White
522/// tint (`0x00FF_FFFF`) returns `color` unchanged. Mirrors the GPU `apply_tint`.
523#[inline]
524fn tint_packed(color: u32, tint: u32) -> u32 {
525    if tint & 0x00FF_FFFF == 0x00FF_FFFF {
526        return color;
527    }
528    let mul = |shift: u32| {
529        let c = (color >> shift) & 0xff;
530        let t = (tint >> shift) & 0xff;
531        ((c * t) / 255) & 0xff
532    };
533    (color & 0xff00_0000) | (mul(16) << 16) | (mul(8) << 8) | mul(0)
534}
535
536/// Unpack a packed `0x..RRGGBB` colour to linear-ish `0..1` float channels
537/// (RGB only; the high byte is ignored here — sprite voxels are flat-lit).
538#[inline]
539fn rgb_to_f32(c: u32) -> [f32; 3] {
540    [
541        ((c >> 16) & 0xff) as f32 / 255.0,
542        ((c >> 8) & 0xff) as f32 / 255.0,
543        (c & 0xff) as f32 / 255.0,
544    ]
545}
546
547/// Repack `0..1` float channels (clamped) into `0x80RRGGBB` — the
548/// full-brightness packing the flat-lit sprite path writes.
549#[inline]
550#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
551fn f32_to_rgb(c: [f32; 3]) -> u32 {
552    let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
553    0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
554}
555
556/// roxlap world up — voxlap z-down, so up is `-z`. Used by
557/// [`SpriteLightMode::WorldUp`] as a fixed shading normal.
558const SPRITE_WORLD_UP: [f32; 3] = [0.0, 0.0, -1.0];
559
560/// Per-instance billboard lighting mode (BB.2b), decoded from the sprite
561/// `flags` (bits 6/7). Controls the surface normal / direct-light handling at
562/// the sprite shade site so a camera-facing billboard needn't suffer the
563/// camera-dependent N·L of its (camera-tracking) face normal.
564#[derive(Clone, Copy, PartialEq, Eq)]
565pub enum SpriteLightMode {
566    /// The DDA hit-face normal (default; today's DL.7 look).
567    FaceNormal,
568    /// A fixed world-up normal (stable directional shading).
569    WorldUp,
570    /// Ambient only — no sun / point-light direct term (flat cutout).
571    AmbientOnly,
572    /// Full-bright / emissive — the voxel colour at full intensity, ignoring
573    /// lighting (glows: fire, spell auras). Encoded as **both** flag bits set.
574    FullBright,
575}
576
577impl SpriteLightMode {
578    /// Decode the mode from a sprite instance's `flags` word (bits
579    /// 6/7 — `SPRITE_FLAG_LIGHT_WORLD_UP` /
580    /// `SPRITE_FLAG_LIGHT_AMBIENT_ONLY`). Neither bit ⇒
581    /// [`FaceNormal`](Self::FaceNormal) (the default); both ⇒
582    /// [`FullBright`](Self::FullBright).
583    #[must_use]
584    pub fn from_flags(flags: u32) -> Self {
585        let world_up = flags & SPRITE_FLAG_LIGHT_WORLD_UP != 0;
586        let ambient_only = flags & SPRITE_FLAG_LIGHT_AMBIENT_ONLY != 0;
587        match (ambient_only, world_up) {
588            (true, true) => Self::FullBright, // both bits set
589            (true, false) => Self::AmbientOnly,
590            (false, true) => Self::WorldUp,
591            (false, false) => Self::FaceNormal,
592        }
593    }
594}
595
596/// Shade a sprite voxel under a [`SpriteLightMode`] (BB.2b): `FaceNormal` is
597/// the plain [`shade_dynamic`]; `WorldUp` swaps in a fixed world-up normal;
598/// `AmbientOnly` drops the sun + point lights (and stylization) for a flat
599/// ambient term. Shared by both sprite shade sites (opaque + translucent).
600fn shade_dynamic_mode(
601    mode: SpriteLightMode,
602    albedo: [f32; 3],
603    n_world: [f32; 3],
604    center: [f32; 3],
605    lights: &CpuLights<'_>,
606    tester: Option<&mut dyn ShadowTester>,
607) -> u32 {
608    match mode {
609        SpriteLightMode::FaceNormal => shade_dynamic(albedo, 1.0, n_world, center, lights, tester),
610        SpriteLightMode::WorldUp => {
611            shade_dynamic(albedo, 1.0, SPRITE_WORLD_UP, center, lights, tester)
612        }
613        SpriteLightMode::AmbientOnly => {
614            let mut amb = *lights;
615            amb.sun = false;
616            amb.points = &[];
617            amb.bands = 0; // smooth ambient (no cel ramp toward shadow_tint)
618            shade_dynamic(albedo, 1.0, n_world, center, &amb, None)
619        }
620        // Emissive: the voxel colour at full intensity, ignoring the rig.
621        SpriteLightMode::FullBright => f32_to_rgb(albedo),
622    }
623}
624
625/// Cast one ray (in sprite-local voxel space) accumulating translucent
626/// voxels front-to-back until an opaque voxel, transmittance exhaustion, or
627/// the `max_t` cutoff (the terrain depth, so the march stops at geometry it
628/// can't see past). `fwd_dot = dir·camera-forward` converts the ray
629/// parameter to perpendicular depth. Returns `None` if the ray contributes
630/// nothing (missed the box, or every voxel was clipped / behind terrain).
631#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
632fn cast_local_layers(
633    dense: &SpriteDense,
634    origin: [f32; 3],
635    dir: [f32; 3],
636    fwd_dot: f32,
637    max_t: f32,
638    shade_ctx: SpriteShade,
639    // Instance basis (s,h,f) + world position — only used to light each layer
640    // (rotate the model-local face normal + voxel centre to world). Ignored
641    // when the rig is disabled (then every layer is the baked `shade`).
642    s: [f32; 3],
643    h: [f32; 3],
644    f: [f32; 3],
645    pos: [f32; 3],
646    light_mode: SpriteLightMode,
647) -> Option<LayerAccum> {
648    #[allow(clippy::cast_precision_loss)]
649    let hi = [
650        dense.dims[0] as f32,
651        dense.dims[1] as f32,
652        dense.dims[2] as f32,
653    ];
654    let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
655    let start = t0 + 1e-4;
656    let p = [
657        origin[0] + dir[0] * start,
658        origin[1] + dir[1] * start,
659        origin[2] + dir[2] * start,
660    ];
661    let mut cell = [
662        (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
663        (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
664        (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
665    ];
666    let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
667    let mut t_curr = t0;
668    let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
669
670    let mut acc = LayerAccum {
671        rgb: [0.0; 3],
672        trans: 1.0,
673        opaque: None,
674    };
675    let mut touched = false;
676    // Per-span compositing: a translucent voxel contributes one alpha layer
677    // only when the ray *enters* a contiguous solid run (the previous cell
678    // was air). Without this, a ray clipping the shared boundary between two
679    // adjacent surface voxels passes through both and double-composites a thin
680    // strip — the model reads as "diced" by a voxel grid. Treating each solid
681    // run as one surface makes a wall contribute exactly one alpha regardless
682    // of how many of its voxels the ray grazes. A run is also re-entered on a
683    // material change (TV.3: two adjacent translucent materials each count),
684    // and an opaque voxel stops the ray on every cell (the opaque core of a
685    // mixed model).
686    let mut prev_solid = false;
687    let mut prev_mat = 0u8;
688    // Local ray length per ray-parameter unit — converts a cell's `t` span to
689    // its path length in voxel units for the `Volumetric` Beer–Lambert weight.
690    let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
691    // Model-local face normal of the cell currently being shaded (the face the
692    // ray crossed to enter it); zero for the AABB-entry voxel, matching
693    // `cast_local`. Updated after each step.
694    let mut normal = [0.0f32; 3];
695
696    // XS.0/XS.2 — per-layer dynamic lighting (mirror of the opaque path):
697    // rotate the model-local face normal + voxel centre to world via the
698    // instance basis and call `shade_dynamic` (flat per voxel). Disabled rig ⇒
699    // baked `shade` (byte-identical). XS.2 — when a scene occluder is present
700    // the layer also receives hard shadows (world-space query, identity ctx).
701    let lights = shade_ctx.lights;
702    let tint = shade_ctx.tint;
703    let mut tester = shade_ctx.shadow.map(|occ| WorldShadow {
704        ctx: WorldShadowCtx::identity(occ),
705    });
706    let mut shade_layer = |idx: usize, cell: [i32; 3], n_local: [f32; 3], emissive: u8| -> u32 {
707        // EV — an emissive layer outranks both the baked and the dynamic
708        // shade (albedo × emissive factor only; the per-instance tint still
709        // applies), mirroring the terrain hit order. `emissive == 0` keeps
710        // the pre-EV branches byte-identical.
711        if emissive > 0 {
712            return tint_packed(emissive_shade(dense.col[idx], emissive), tint);
713        }
714        if !lights.enabled {
715            return tint_packed(shade(dense.col[idx], 0), tint);
716        }
717        let to_world = |v: [f32; 3]| {
718            [
719                v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
720                v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
721                v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
722            ]
723        };
724        let n_world = to_world(n_local);
725        let rel = [
726            cell[0] as f32 + 0.5 - dense.pivot[0],
727            cell[1] as f32 + 0.5 - dense.pivot[1],
728            cell[2] as f32 + 0.5 - dense.pivot[2],
729        ];
730        let wc = to_world(rel);
731        let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
732        let albedo = [
733            ((dense.col[idx] >> 16) & 0xff) as f32 / 255.0,
734            ((dense.col[idx] >> 8) & 0xff) as f32 / 255.0,
735            (dense.col[idx] & 0xff) as f32 / 255.0,
736        ];
737        let t = tester.as_mut().map(|t| t as &mut dyn ShadowTester);
738        tint_packed(
739            shade_dynamic_mode(light_mode, albedo, n_world, center, &lights, t),
740            tint,
741        )
742    };
743
744    for _ in 0..max_steps {
745        if cell[0] < 0
746            || cell[0] >= dense.dims[0]
747            || cell[1] < 0
748            || cell[1] >= dense.dims[1]
749            || cell[2] < 0
750            || cell[2] >= dense.dims[2]
751            || t_curr > t1
752        {
753            break;
754        }
755        // Stop at the terrain depth: everything past it is occluded, and the
756        // already-drawn framebuffer pixel becomes the background.
757        let depth = t_curr * fwd_dot;
758        if depth >= max_t {
759            break;
760        }
761        // Exit `t` of the current cell — the next boundary crossing. Its span
762        // from `t_curr` is the ray's path through this cell (Volumetric).
763        let exit_axis = min_axis(t_max);
764        let t_exit = t_max[exit_axis];
765        let idx = dense.idx_of(cell);
766        let solid_here = dense.occ[idx];
767        if solid_here && depth >= NEAR_Z {
768            let mat_id = if dense.mat.is_empty() {
769                shade_ctx.material
770            } else {
771                dense.mat[idx]
772            };
773            let m = shade_ctx.materials.get(mat_id);
774            if m.is_opaque() {
775                acc.opaque = Some((shade_layer(idx, cell, normal, m.emissive), t_curr));
776                touched = true;
777                break;
778            }
779            let a = f32::from(m.alpha) / 255.0 * (f32::from(shade_ctx.alpha_mul) / 255.0);
780            if m.mode == BlendMode::Volumetric {
781                // Per-cell Beer–Lambert: opacity weighted by traversed length
782                // (in voxel units), so a thin sliver contributes ≈0 and a
783                // filled volume thickens smoothly with depth. Always occludes.
784                let seg_len = (t_exit - t_curr).max(0.0) * dir_len;
785                let eff_a = 1.0 - (1.0 - a).powf(seg_len);
786                let lit = rgb_to_f32(shade_layer(idx, cell, normal, m.emissive));
787                acc.rgb[0] += acc.trans * eff_a * lit[0];
788                acc.rgb[1] += acc.trans * eff_a * lit[1];
789                acc.rgb[2] += acc.trans * eff_a * lit[2];
790                acc.trans *= 1.0 - eff_a;
791                touched = true;
792                prev_mat = mat_id;
793                if acc.trans < 1.0 / 256.0 {
794                    break;
795                }
796            } else if !prev_solid || mat_id != prev_mat {
797                // AlphaBlend / Additive: one alpha layer per solid-run entry or
798                // material change (thickness-independent — shells, glass).
799                let lit = rgb_to_f32(shade_layer(idx, cell, normal, m.emissive));
800                acc.rgb[0] += acc.trans * a * lit[0];
801                acc.rgb[1] += acc.trans * a * lit[1];
802                acc.rgb[2] += acc.trans * a * lit[2];
803                if m.mode == BlendMode::AlphaBlend {
804                    acc.trans *= 1.0 - a; // Additive glow does not occlude.
805                }
806                touched = true;
807                prev_mat = mat_id;
808                if acc.trans < 1.0 / 256.0 {
809                    break;
810                }
811            }
812        }
813        prev_solid = solid_here;
814        t_curr = t_exit;
815        cell[exit_axis] += step[exit_axis];
816        t_max[exit_axis] += t_delta[exit_axis];
817        normal = [0.0; 3];
818        normal[exit_axis] = -(step[exit_axis] as f32);
819    }
820
821    touched.then_some(acc)
822}
823
824/// Draw one KV6 [`Sprite`] into `(fb, zb)` by per-pixel ray casting,
825/// depth-compositing against whatever the terrain pass already wrote.
826/// Returns the number of pixels written.
827///
828/// `cam` / `settings` are the **same** per-frame projection the DDA
829/// terrain pass used (build via [`crate::camera_math::derive`]), so
830/// sprite and terrain share one pinhole and z convention. `pitch_pixels`
831/// is the framebuffer row stride. Honours `SPRITE_FLAG_INVISIBLE`
832/// (skip) and `SPRITE_FLAG_NO_Z` (write without the depth test).
833#[allow(
834    clippy::too_many_arguments,
835    clippy::cast_possible_truncation,
836    clippy::cast_sign_loss
837)]
838#[must_use]
839pub fn draw_sprite_dda(
840    fb: &mut [u32],
841    zb: &mut [f32],
842    pitch_pixels: usize,
843    width: u32,
844    height: u32,
845    cam: &CameraState,
846    settings: &OpticastSettings,
847    sprite: &Sprite,
848) -> u32 {
849    if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
850        return 0;
851    }
852    draw_sprite_dda_shaded(
853        fb,
854        zb,
855        pitch_pixels,
856        width,
857        height,
858        cam,
859        settings,
860        sprite,
861        None,
862    )
863}
864
865/// Draw one KV6 [`Sprite`], optionally with a translucent material (TV
866/// stage) — the [`draw_sprite_dense_shaded`] counterpart of
867/// [`draw_sprite_dda`]. `shade_ctx == None` (or an opaque effective
868/// material) renders the sprite opaque, byte-for-byte unchanged.
869#[allow(clippy::too_many_arguments)]
870#[must_use]
871pub fn draw_sprite_dda_shaded(
872    fb: &mut [u32],
873    zb: &mut [f32],
874    pitch_pixels: usize,
875    width: u32,
876    height: u32,
877    cam: &CameraState,
878    settings: &OpticastSettings,
879    sprite: &Sprite,
880    shade_ctx: Option<SpriteShade>,
881) -> u32 {
882    if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
883        return 0;
884    }
885    // Decodes the KV6 to a dense grid each call (the per-frame cost an
886    // animated clip avoids via [`ClipFlipbook`]'s cached grids). A non-empty
887    // `material_map` classifies voxels into per-voxel materials (TV.3 mixed
888    // models); an empty one is the plain uniform-material decode.
889    let dense = if sprite.material_map.is_empty() {
890        SpriteDense::from_kv6(&sprite.kv6)
891    } else {
892        SpriteDense::from_kv6_with_materials(&sprite.kv6, &sprite.material_map)
893    };
894    draw_sprite_dense_shaded(
895        fb,
896        zb,
897        pitch_pixels,
898        width,
899        height,
900        cam,
901        settings,
902        &dense,
903        sprite.p,
904        sprite.s,
905        sprite.h,
906        sprite.f,
907        sprite.flags,
908        shade_ctx,
909    )
910}
911
912/// Draw a pre-decoded [`SpriteDense`] at a world pose — the generalised
913/// core of [`draw_sprite_dda`], shared by the KV6 path and animated
914/// [`ClipFlipbook`] frames. `pos` is the world pivot; `s`/`h`/`f` are the
915/// model→world basis columns (local +x/+y/+z); `flags` honours
916/// [`SPRITE_FLAG_INVISIBLE`] / [`SPRITE_FLAG_NO_Z`]. Returns pixels written.
917///
918/// Fully opaque (the existing first-hit path). For translucent sprites use
919/// [`draw_sprite_dense_shaded`].
920#[allow(clippy::too_many_arguments)]
921#[must_use]
922pub fn draw_sprite_dense(
923    fb: &mut [u32],
924    zb: &mut [f32],
925    pitch_pixels: usize,
926    width: u32,
927    height: u32,
928    cam: &CameraState,
929    settings: &OpticastSettings,
930    dense: &SpriteDense,
931    pos: [f32; 3],
932    s: [f32; 3],
933    h: [f32; 3],
934    f: [f32; 3],
935    flags: u32,
936) -> u32 {
937    draw_sprite_dense_shaded(
938        fb,
939        zb,
940        pitch_pixels,
941        width,
942        height,
943        cam,
944        settings,
945        dense,
946        pos,
947        s,
948        h,
949        f,
950        flags,
951        None,
952    )
953}
954
955/// Draw a pre-decoded [`SpriteDense`] at a world pose, optionally with a
956/// translucent material (TV stage). `shade_ctx`:
957/// - `None`, or a `Some` whose effective material is opaque ⇒ the existing
958///   first-hit, depth-tested opaque path, **byte-for-byte unchanged**.
959/// - `Some` with a translucent uniform material (or a non-empty per-voxel
960///   `mat` array) ⇒ front-to-back accumulate-and-continue: each ray marches
961///   through the sprite compositing `AlphaBlend`/`Additive` layers over what
962///   lies behind (terrain/sky already in the framebuffer, or an opaque voxel
963///   of the model). Opaque voxels write the model surface depth; purely
964///   translucent pixels composite over the framebuffer without touching the
965///   z-buffer (they do not occlude). See `PORTING-TRANSPARENCY.md`.
966#[allow(
967    clippy::too_many_arguments,
968    clippy::cast_possible_truncation,
969    clippy::cast_sign_loss
970)]
971#[must_use]
972pub fn draw_sprite_dense_shaded(
973    fb: &mut [u32],
974    zb: &mut [f32],
975    pitch_pixels: usize,
976    width: u32,
977    height: u32,
978    cam: &CameraState,
979    settings: &OpticastSettings,
980    dense: &SpriteDense,
981    pos: [f32; 3],
982    s: [f32; 3],
983    h: [f32; 3],
984    f: [f32; 3],
985    flags: u32,
986    shade_ctx: Option<SpriteShade>,
987) -> u32 {
988    if flags & SPRITE_FLAG_INVISIBLE != 0 || dense.occ.is_empty() {
989        return 0;
990    }
991    // Clip render scale: `basis · ((v − pivot) · vws)` ≡
992    // `(basis · vws) · (v − pivot)`, so scaling the basis once here
993    // reproduces the GPU shader's per-voxel `* voxel_world_size`
994    // without touching any transform below. Exact no-op at 1.0.
995    let sc = dense.voxel_world_size;
996    let (s, h, f) = (bb_scale3(s, sc), bb_scale3(h, sc), bb_scale3(f, sc));
997    let Some(minv) = invert_basis(s, h, f) else {
998        return 0;
999    };
1000    let pivot = dense.pivot;
1001    let no_z = flags & SPRITE_FLAG_NO_Z != 0;
1002    // BB.2b — per-instance billboard lighting mode (flags bits 6/7).
1003    let light_mode = SpriteLightMode::from_flags(flags);
1004
1005    // Screen bounding box from the 8 corners of the local voxel box.
1006    let Some(rect) = project_screen_rect(dense, pos, s, h, f, cam, settings, width, height) else {
1007        return 0;
1008    };
1009
1010    // Per-sprite gate: a sprite whose effective material is opaque (the
1011    // common case, and every sprite while no translucent material is
1012    // defined) takes the original loop unchanged — so the opaque world stays
1013    // bit-identical. Only a genuinely translucent sprite runs the accumulate
1014    // loop.
1015    let layers =
1016        shade_ctx.filter(|s| !dense.mat.is_empty() || !s.materials.get(s.material).is_opaque());
1017
1018    debug_assert_eq!(fb.len(), zb.len());
1019    let target = RasterTarget::new(fb, zb);
1020    // PF.8 — one row of the sprite's screen rect; rows are disjoint, every
1021    // pixel reads/writes only its own index, so rows parallelise safely
1022    // (the terrain DDA's `RasterTarget` band contract).
1023    let draw_row = |py: u32| -> u32 {
1024        let mut written = 0u32;
1025        let row = py as usize * pitch_pixels;
1026        for px in rect.0..rect.2 {
1027            let (origin, dir) = pixel_ray(cam, settings, px, py);
1028            // World ray → sprite-local voxel space.
1029            let rel = [origin[0] - pos[0], origin[1] - pos[1], origin[2] - pos[2]];
1030            let ol = mat_apply(&minv, rel);
1031            let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
1032            let dir_local = mat_apply(&minv, dir);
1033            let fwd_dot =
1034                dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
1035            let idx = row + px as usize;
1036
1037            if let Some(shade_ctx) = layers {
1038                // ---- translucent: accumulate front-to-back ----
1039                if fwd_dot <= 1e-6 {
1040                    continue;
1041                }
1042                // Terrain/opaque depth cutoff (perpendicular distance, the
1043                // same units `cast_local_layers` compares `t_curr·fwd_dot`
1044                // against — NOT a ray parameter, since the CPU `dir` is
1045                // unnormalised). SAFETY: idx in rect ⊂ (width,height).
1046                let max_t = if no_z {
1047                    f32::INFINITY
1048                } else {
1049                    unsafe { target.read_depth(idx) }
1050                };
1051                let Some(acc) = cast_local_layers(
1052                    dense,
1053                    origin_local,
1054                    dir_local,
1055                    fwd_dot,
1056                    max_t,
1057                    shade_ctx,
1058                    s,
1059                    h,
1060                    f,
1061                    pos,
1062                    light_mode,
1063                ) else {
1064                    continue;
1065                };
1066                // SAFETY: idx in bounds; single-threaded writer.
1067                let wrote = unsafe {
1068                    match acc.opaque {
1069                        Some((bg_color, t)) => {
1070                            // Opaque model surface behind the translucent
1071                            // layers: composite over it, write surface depth.
1072                            let bg = rgb_to_f32(bg_color);
1073                            let out = f32_to_rgb([
1074                                acc.rgb[0] + acc.trans * bg[0],
1075                                acc.rgb[1] + acc.trans * bg[1],
1076                                acc.rgb[2] + acc.trans * bg[2],
1077                            ]);
1078                            let depth = t * fwd_dot;
1079                            if no_z {
1080                                target.write_color(idx, out);
1081                                target.write_depth(idx, depth);
1082                                true
1083                            } else {
1084                                target.z_test_write(idx, out, depth)
1085                            }
1086                        }
1087                        None => {
1088                            // Ray exited (or fully attenuated) with no opaque
1089                            // model voxel: composite over the framebuffer
1090                            // (terrain/sky). Translucent layers do not occlude,
1091                            // so the z-buffer is left untouched.
1092                            let bg = rgb_to_f32(target.read_color(idx));
1093                            let out = f32_to_rgb([
1094                                acc.rgb[0] + acc.trans * bg[0],
1095                                acc.rgb[1] + acc.trans * bg[1],
1096                                acc.rgb[2] + acc.trans * bg[2],
1097                            ]);
1098                            target.write_color(idx, out);
1099                            true
1100                        }
1101                    }
1102                };
1103                written += u32::from(wrote);
1104            } else {
1105                // ---- opaque: first-hit path ----
1106                let Some((color, t, n_local, cell)) = cast_local(dense, origin_local, dir_local)
1107                else {
1108                    continue;
1109                };
1110                let depth = t * fwd_dot;
1111                if depth < NEAR_Z {
1112                    continue;
1113                }
1114                // DL.7 — dynamic lighting when a rig is active (sun + point
1115                // lights + cel + ramp, flat per voxel); else the baked `shade`
1116                // (byte-identical). The model-local face normal + voxel centre
1117                // are rotated into world space via the instance basis (s,h,f).
1118                let dl = shade_ctx.map_or(CpuLights::default(), |s| s.lights);
1119                // EV — sprite emissive: the hit voxel's material (per-voxel
1120                // id, or the whole-sprite uniform id) outranks both the lit
1121                // and baked paths, mirroring the terrain hit order. No
1122                // shade_ctx / emissive 0 ⇒ the pre-EV branches, byte-identical.
1123                let emissive = shade_ctx.map_or(0, |sc| {
1124                    let mat_id = if dense.mat.is_empty() {
1125                        sc.material
1126                    } else {
1127                        dense.mat[dense.idx_of(cell)]
1128                    };
1129                    sc.materials.get(mat_id).emissive
1130                });
1131                let lit = if emissive > 0 {
1132                    emissive_shade(color, emissive)
1133                } else if dl.enabled {
1134                    let to_world = |v: [f32; 3]| {
1135                        [
1136                            v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
1137                            v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
1138                            v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
1139                        ]
1140                    };
1141                    let n_world = to_world(n_local);
1142                    let rel = [
1143                        cell[0] as f32 + 0.5 - pivot[0],
1144                        cell[1] as f32 + 0.5 - pivot[1],
1145                        cell[2] as f32 + 0.5 - pivot[2],
1146                    ];
1147                    let wc = to_world(rel);
1148                    let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
1149                    let albedo = [
1150                        ((color >> 16) & 0xff) as f32 / 255.0,
1151                        ((color >> 8) & 0xff) as f32 / 255.0,
1152                        (color & 0xff) as f32 / 255.0,
1153                    ];
1154                    // XS.2 — sprite receives shadows: a world-space query
1155                    // against the scene occluder (sprite shading is already in
1156                    // world space, so an identity transform). `None` ⇒ unshadowed.
1157                    let mut ws = shade_ctx.and_then(|s| s.shadow).map(|occ| WorldShadow {
1158                        ctx: WorldShadowCtx::identity(occ),
1159                    });
1160                    let tester = ws.as_mut().map(|t| t as &mut dyn ShadowTester);
1161                    shade_dynamic_mode(light_mode, albedo, n_world, center, &dl, tester)
1162                } else {
1163                    shade(color, 0)
1164                };
1165                // Per-instance RGB tint (white ⇒ no-op).
1166                let lit = tint_packed(lit, shade_ctx.map_or(0x00FF_FFFF, |s| s.tint));
1167                // SAFETY: idx in-bounds for the rect within (width, height);
1168                // single-threaded writer.
1169                let wrote = unsafe {
1170                    if no_z {
1171                        target.write_color(idx, lit);
1172                        target.write_depth(idx, depth);
1173                        true
1174                    } else {
1175                        target.z_test_write(idx, lit, depth)
1176                    }
1177                };
1178                written += u32::from(wrote);
1179            }
1180        }
1181        written
1182    };
1183    // PF.8 — the sprite pass was entirely single-threaded after the
1184    // rayon-parallel terrain; large footprints (a close-up character,
1185    // a big translucent volume) now use the same worker pool. Small
1186    // rects stay serial — per-sprite rayon overhead would dominate.
1187    let rows = rect.3.saturating_sub(rect.1) as usize;
1188    let cols = rect.2.saturating_sub(rect.0) as usize;
1189    const SPRITE_PAR_MIN_PIXELS: usize = 64 * 64;
1190    if rows >= 2 && rows * cols >= SPRITE_PAR_MIN_PIXELS {
1191        use rayon::prelude::*;
1192        (rect.1..rect.3).into_par_iter().map(draw_row).sum()
1193    } else {
1194        (rect.1..rect.3).map(draw_row).sum()
1195    }
1196}
1197
1198/// Project the sprite's local voxel AABB to a clamped screen rectangle
1199/// `(x0, y0, x1, y1)` (half-open). `None` if it can't appear; falls back
1200/// to the full viewport when the box straddles the near plane (rare).
1201#[allow(
1202    clippy::cast_possible_truncation,
1203    clippy::cast_sign_loss,
1204    clippy::cast_precision_loss
1205)]
1206fn project_screen_rect(
1207    dense: &SpriteDense,
1208    pos: [f32; 3],
1209    s: [f32; 3],
1210    h: [f32; 3],
1211    f: [f32; 3],
1212    cam: &CameraState,
1213    settings: &OpticastSettings,
1214    width: u32,
1215    height: u32,
1216) -> Option<(u32, u32, u32, u32)> {
1217    let (xs, ys, zs) = (
1218        dense.dims[0] as f32,
1219        dense.dims[1] as f32,
1220        dense.dims[2] as f32,
1221    );
1222    let (xp, yp, zp) = (dense.pivot[0], dense.pivot[1], dense.pivot[2]);
1223    let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
1224    let mut all_front = true;
1225    for &cx in &[0.0, xs] {
1226        for &cy in &[0.0, ys] {
1227            for &cz in &[0.0, zs] {
1228                // Local → world via the sprite basis about the pivot.
1229                let lx = cx - xp;
1230                let ly = cy - yp;
1231                let lz = cz - zp;
1232                let world = [
1233                    pos[0] + lx * s[0] + ly * h[0] + lz * f[0],
1234                    pos[1] + lx * s[1] + ly * h[1] + lz * f[1],
1235                    pos[2] + lx * s[2] + ly * h[2] + lz * f[2],
1236                ];
1237                let rel = [
1238                    world[0] - cam.pos[0],
1239                    world[1] - cam.pos[1],
1240                    world[2] - cam.pos[2],
1241                ];
1242                let cz_cam =
1243                    rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
1244                if cz_cam < NEAR_Z {
1245                    all_front = false;
1246                    continue;
1247                }
1248                let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
1249                let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
1250                let sx = settings.hx + cx_cam / cz_cam * settings.hz;
1251                let sy = settings.hy + cy_cam / cz_cam * settings.hz;
1252                x0 = x0.min(sx);
1253                y0 = y0.min(sy);
1254                x1 = x1.max(sx);
1255                y1 = y1.max(sy);
1256            }
1257        }
1258    }
1259    let (w, h) = (width as f32, height as f32);
1260    let (rx0, ry0, rx1, ry1) = if all_front {
1261        (
1262            (x0 - 1.0).max(0.0),
1263            (y0 - 1.0).max(0.0),
1264            (x1 + 1.0).min(w),
1265            (y1 + 1.0).min(h),
1266        )
1267    } else {
1268        // Straddles the near plane → scan the whole viewport.
1269        (0.0, 0.0, w, h)
1270    };
1271    if rx0 >= rx1 || ry0 >= ry1 {
1272        return None;
1273    }
1274    Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
1275}
1276
1277/// CPU-side decoded animated voxel clip: every frame's [`SpriteDense`]
1278/// is cached at construction, so per-frame playback is a grid **select**
1279/// — not the per-frame voxel-volume decode [`draw_sprite_dda`] pays each
1280/// call. The CPU counterpart to the GPU flipbook (VCL.2). Build once from
1281/// a [`DecodedClip`], then [`draw_frame`](ClipFlipbook::draw_frame) the
1282/// active frame each render.
1283pub struct ClipFlipbook {
1284    /// PF.8 — `Arc` per frame so the shadow occluder shares the cached
1285    /// decode instead of deep-cloning the current frame per rebuild.
1286    frames: Vec<Arc<SpriteDense>>,
1287}
1288
1289impl ClipFlipbook {
1290    /// An empty flipbook (no frames) — a tombstone for a removed clip;
1291    /// [`draw_frame`](Self::draw_frame) always draws nothing.
1292    #[must_use]
1293    pub fn empty() -> Self {
1294        Self { frames: Vec::new() }
1295    }
1296
1297    /// Decode + cache every frame of `clip` (one [`SpriteDense`] each).
1298    #[must_use]
1299    pub fn from_decoded(clip: &DecodedClip) -> Self {
1300        Self::from_decoded_with_materials(clip, &[])
1301    }
1302
1303    /// Like [`from_decoded`](Self::from_decoded) but classifies every frame's
1304    /// voxels into per-voxel material ids by colour (TV.3 mixed models) via
1305    /// `material_map` — the clip analogue of
1306    /// [`SpriteDense::from_kv6_with_materials`]. An empty map yields the same
1307    /// all-opaque result as `from_decoded`.
1308    #[must_use]
1309    pub fn from_decoded_with_materials(clip: &DecodedClip, material_map: &[(Rgb, u8)]) -> Self {
1310        let frames = clip
1311            .frames
1312            .iter()
1313            .map(|frame| {
1314                Arc::new(
1315                    SpriteDense::from_voxel_frame_with_materials(
1316                        frame,
1317                        clip.dims,
1318                        clip.pivot,
1319                        material_map,
1320                    )
1321                    .with_voxel_world_size(clip.voxel_world_size),
1322                )
1323            })
1324            .collect();
1325        Self { frames }
1326    }
1327
1328    /// Number of cached frames. `0` for a tombstone from
1329    /// [`empty`](Self::empty).
1330    #[must_use]
1331    pub fn frame_count(&self) -> usize {
1332        self.frames.len()
1333    }
1334
1335    /// Borrow frame `frame`'s cached dense grid, if in range.
1336    #[must_use]
1337    pub fn frame(&self, frame: usize) -> Option<&SpriteDense> {
1338        self.frames.get(frame).map(Arc::as_ref)
1339    }
1340
1341    /// Share frame `frame`'s cached dense grid (PF.8) — a cheap refcount
1342    /// clone for the shadow occluder (was a deep clone per rebuild).
1343    #[must_use]
1344    pub fn frame_arc(&self, frame: usize) -> Option<Arc<SpriteDense>> {
1345        self.frames.get(frame).cloned()
1346    }
1347
1348    /// Replace one frame's cached dense grid in place — the CPU side of an
1349    /// editor's single-frame edit (no re-decode of the other frames).
1350    /// Returns `false` if `frame` is out of range.
1351    pub fn set_frame(&mut self, frame: usize, dense: SpriteDense) -> bool {
1352        match self.frames.get_mut(frame) {
1353            Some(slot) => {
1354                *slot = Arc::new(dense);
1355                true
1356            }
1357            None => false,
1358        }
1359    }
1360
1361    /// Draw frame `frame` at a world pose via [`draw_sprite_dense`] —
1362    /// `pos` is the world pivot, `s`/`h`/`f` the model→world basis columns.
1363    /// Returns pixels written (0 if `frame` is out of range).
1364    #[allow(clippy::too_many_arguments)]
1365    #[must_use]
1366    pub fn draw_frame(
1367        &self,
1368        fb: &mut [u32],
1369        zb: &mut [f32],
1370        pitch_pixels: usize,
1371        width: u32,
1372        height: u32,
1373        cam: &CameraState,
1374        settings: &OpticastSettings,
1375        frame: usize,
1376        pos: [f32; 3],
1377        s: [f32; 3],
1378        h: [f32; 3],
1379        f: [f32; 3],
1380        flags: u32,
1381    ) -> u32 {
1382        self.draw_frame_shaded(
1383            fb,
1384            zb,
1385            pitch_pixels,
1386            width,
1387            height,
1388            cam,
1389            settings,
1390            frame,
1391            pos,
1392            s,
1393            h,
1394            f,
1395            flags,
1396            None,
1397        )
1398    }
1399
1400    /// Draw frame `frame`, optionally with a translucent material (TV stage)
1401    /// — the [`draw_sprite_dense_shaded`] counterpart of
1402    /// [`draw_frame`](Self::draw_frame). `shade_ctx == None` (or an opaque
1403    /// effective material) renders the frame opaque, unchanged.
1404    #[allow(clippy::too_many_arguments)]
1405    #[must_use]
1406    pub fn draw_frame_shaded(
1407        &self,
1408        fb: &mut [u32],
1409        zb: &mut [f32],
1410        pitch_pixels: usize,
1411        width: u32,
1412        height: u32,
1413        cam: &CameraState,
1414        settings: &OpticastSettings,
1415        frame: usize,
1416        pos: [f32; 3],
1417        s: [f32; 3],
1418        h: [f32; 3],
1419        f: [f32; 3],
1420        flags: u32,
1421        shade_ctx: Option<SpriteShade>,
1422    ) -> u32 {
1423        let Some(dense) = self.frames.get(frame) else {
1424            return 0;
1425        };
1426        draw_sprite_dense_shaded(
1427            fb,
1428            zb,
1429            pitch_pixels,
1430            width,
1431            height,
1432            cam,
1433            settings,
1434            dense,
1435            pos,
1436            s,
1437            h,
1438            f,
1439            flags,
1440            shade_ctx,
1441        )
1442    }
1443}
1444
1445#[cfg(test)]
1446mod tests {
1447    use super::*;
1448    use crate::camera_math;
1449    use crate::Camera;
1450    use roxlap_formats::kv6::Kv6;
1451    use roxlap_formats::material::{Material, MaterialTable};
1452    use roxlap_formats::VoxColor;
1453
1454    /// BB.2b — `WorldUp` lights a side-facing billboard as if it faced world
1455    /// up (the sun directly overhead); `AmbientOnly` drops the sun term.
1456    #[test]
1457    fn sprite_light_mode_world_up_and_ambient_only() {
1458        let lights = CpuLights {
1459            enabled: true,
1460            sun: true,
1461            sun_dir: [0.0, 0.0, -1.0], // toward the sun (world up, z-down)
1462            sun_color: [1.0, 1.0, 1.0],
1463            sun_intensity: 1.0,
1464            sun_casts_shadow: false,
1465            points: &[],
1466            ambient: [0.2, 0.2, 0.2],
1467            bands: 0,
1468            shadow_tint: [0.0; 3],
1469            shadow_strength: 0.0,
1470            shadow_bias: 0.0,
1471            shadow_max_dist: 0.0,
1472        };
1473        let a = [1.0, 1.0, 1.0];
1474        let c = [0.0, 0.0, 0.0];
1475        let g = |packed: u32| (packed >> 8) & 0xff; // green channel
1476        let up_n = [0.0, 0.0, -1.0];
1477        let side_n = [1.0, 0.0, 0.0];
1478        let face_up = g(shade_dynamic_mode(
1479            SpriteLightMode::FaceNormal,
1480            a,
1481            up_n,
1482            c,
1483            &lights,
1484            None,
1485        ));
1486        let face_side = g(shade_dynamic_mode(
1487            SpriteLightMode::FaceNormal,
1488            a,
1489            side_n,
1490            c,
1491            &lights,
1492            None,
1493        ));
1494        let amb = g(shade_dynamic_mode(
1495            SpriteLightMode::AmbientOnly,
1496            a,
1497            up_n,
1498            c,
1499            &lights,
1500            None,
1501        ));
1502        let world_up = g(shade_dynamic_mode(
1503            SpriteLightMode::WorldUp,
1504            a,
1505            side_n,
1506            c,
1507            &lights,
1508            None,
1509        ));
1510        assert!(
1511            face_up > face_side,
1512            "a sun-facing face is brighter than a side face"
1513        );
1514        assert!(amb < face_up, "ambient-only drops the sun term");
1515        assert_eq!(
1516            world_up, face_up,
1517            "world-up shades a side-facing billboard as if it faced up"
1518        );
1519        let full = g(shade_dynamic_mode(
1520            SpriteLightMode::FullBright,
1521            a,
1522            side_n,
1523            c,
1524            &lights,
1525            None,
1526        ));
1527        // Full-bright = the albedo at full intensity, ignoring the rig (so a
1528        // glow isn't dimmed by ambient like AmbientOnly is).
1529        assert_eq!(full, 255, "full-bright emits the colour at full intensity");
1530        assert!(full > amb, "full-bright glow is brighter than ambient-only");
1531    }
1532
1533    /// DL.7 — `cast_local` reports the hit's model-local face normal (used to
1534    /// light sprites/clips). A ray crossing air then a solid block via the
1535    /// z face gets a back-facing (-z) normal; the entry voxel (immediately
1536    /// solid) gets a zero normal.
1537    #[test]
1538    fn cast_local_reports_face_normal() {
1539        // Solid only at z >= 4 (air below), full in x/y.
1540        let kv6 = Kv6::from_fn(8, 8, 8, |_, _, z| {
1541            (z >= 4).then_some(VoxColor(0x80_C0_40_20))
1542        });
1543        let dense = SpriteDense::from_kv6(&kv6);
1544        // Ray from below, travelling +z: air (z<4) then the block's top face.
1545        let (_c, _t, n, cell) =
1546            cast_local(&dense, [4.0, 4.0, -5.0], [0.0, 0.0, 1.0]).expect("ray hits the block");
1547        assert_eq!(cell[2], 4, "first solid voxel is the z=4 surface");
1548        assert!(
1549            n[2] < -0.5 && n[0].abs() < 1e-6 && n[1].abs() < 1e-6,
1550            "z-crossing face normal points back toward the ray (-z): {n:?}",
1551        );
1552    }
1553    use roxlap_formats::sprite::Sprite;
1554    use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
1555
1556    fn settings(w: u32, h: u32) -> OpticastSettings {
1557        OpticastSettings::for_oracle_framebuffer(w, h)
1558    }
1559
1560    /// Camera at the origin looking down +y at a sprite ahead.
1561    fn cam_looking_y() -> Camera {
1562        Camera {
1563            pos: [0.0, 0.0, 0.0],
1564            right: [1.0, 0.0, 0.0],
1565            down: [0.0, 0.0, 1.0],
1566            forward: [0.0, 1.0, 0.0],
1567        }
1568    }
1569
1570    /// PS.1 — the CPU sprite DDA honours a **scaled** basis (voxlap
1571    /// heritage: `s/h/f` magnitude = scale). The same cube drawn with
1572    /// 2× / 0.5× columns covers roughly 4× / 0.25× the pixels of the
1573    /// unit pose — the parity the particle system's scale-over-life
1574    /// relies on. Loose bounds: perspective (the scaled cube's front
1575    /// face sits nearer/farther) skews the exact ratio.
1576    #[test]
1577    fn scaled_basis_scales_drawn_extent() {
1578        let kv6 = Kv6::from_fn(8, 8, 8, |_, _, _| Some(VoxColor(0x80_C0_40_20)));
1579        let (w, h) = (64u32, 64u32);
1580        let n = (w * h) as usize;
1581        let cam = cam_looking_y();
1582        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1583        let cfg = settings(w, h);
1584
1585        let px_at = |k: f32| -> u32 {
1586            let mut sprite = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1587            for a in 0..3 {
1588                sprite.s[a] *= k;
1589                sprite.h[a] *= k;
1590                sprite.f[a] *= k;
1591            }
1592            let mut fb = vec![0u32; n];
1593            let mut zb = vec![f32::INFINITY; n];
1594            draw_sprite_dda(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, &sprite)
1595        };
1596
1597        let (unit, double, half) = (px_at(1.0), px_at(2.0), px_at(0.5));
1598        assert!(unit > 0, "unit-scale cube must draw ({unit} px)");
1599        let r2 = f64::from(double) / f64::from(unit);
1600        let rh = f64::from(half) / f64::from(unit);
1601        assert!(
1602            (3.0..8.0).contains(&r2),
1603            "2× scale should roughly quadruple coverage: {unit} → {double} px (×{r2:.2})"
1604        );
1605        assert!(
1606            (0.08..0.5).contains(&rh),
1607            "0.5× scale should roughly quarter coverage: {unit} → {half} px (×{rh:.2})"
1608        );
1609    }
1610
1611    /// Build a [`VoxelFrame`] from a dense `fill(x,y,z) -> Option<color>`.
1612    fn clip_frame(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1613        let owpc = dims[2].div_ceil(32).max(1) as usize;
1614        let cols = (dims[0] * dims[1]) as usize;
1615        let mut occupancy = vec![0u32; cols * owpc];
1616        let mut color_offsets = vec![0u32; cols + 1];
1617        let mut colors = Vec::new();
1618        for y in 0..dims[1] {
1619            for x in 0..dims[0] {
1620                let col = (x + y * dims[0]) as usize;
1621                color_offsets[col] = colors.len() as u32;
1622                for z in 0..dims[2] {
1623                    if let Some(c) = fill(x, y, z) {
1624                        occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1625                        colors.push(c);
1626                    }
1627                }
1628            }
1629        }
1630        color_offsets[cols] = colors.len() as u32;
1631        VoxelFrame {
1632            occupancy,
1633            colors,
1634            color_offsets,
1635        }
1636    }
1637
1638    /// A cached [`ClipFlipbook`] draws distinct frames distinctly — the
1639    /// CPU flipbook select. Frame 0 fills the bottom half (red), frame 1
1640    /// the top half (green); rendered at the same pose they cover
1641    /// different screen pixels in different colours.
1642    #[test]
1643    fn clip_flipbook_frames_render_differently() {
1644        let dims = [8u32, 8, 8];
1645        let f0 = clip_frame(dims, |_x, _y, z| (z < 4).then_some(0x00FF_0000)); // red, low z
1646        let f1 = clip_frame(dims, |_x, _y, z| (z >= 4).then_some(0x0000_FF00)); // green, high z
1647        let clip = VoxelClip::from_frames(
1648            dims,
1649            [4.0, 4.0, 4.0],
1650            1.0,
1651            LoopMode::Loop,
1652            &[f0, f1],
1653            &[],
1654            33,
1655            0,
1656        );
1657        let decoded = clip.decode().expect("decode");
1658        let book = ClipFlipbook::from_decoded(&decoded);
1659        assert_eq!(book.frame_count(), 2);
1660        assert!(book.frame(0).is_some() && book.frame(2).is_none());
1661
1662        let (w, h) = (64u32, 64u32);
1663        let n = (w * h) as usize;
1664        let cam = cam_looking_y();
1665        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1666        let cfg = settings(w, h);
1667        let pose = [0.0, 40.0, 0.0];
1668        let (s, hh, f) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
1669
1670        let render = |frame: usize| -> Vec<u32> {
1671            let mut fb = vec![0u32; n];
1672            let mut zb = vec![f32::INFINITY; n];
1673            let wrote = book.draw_frame(
1674                &mut fb, &mut zb, w as usize, w, h, &cs, &cfg, frame, pose, s, hh, f, 0,
1675            );
1676            assert!(wrote > 0, "frame {frame} should draw some pixels");
1677            fb
1678        };
1679        let fb0 = render(0);
1680        let fb1 = render(1);
1681        assert_ne!(fb0, fb1, "distinct frames must render distinct pixels");
1682        // Each frame shows its own channel: red present in frame 0, green
1683        // present in frame 1.
1684        assert!(fb0.iter().any(|&p| (p & 0x00FF_0000) != 0));
1685        assert!(fb1.iter().any(|&p| (p & 0x0000_FF00) != 0));
1686        // Out-of-range frame draws nothing.
1687        let mut fb = vec![0u32; n];
1688        let mut zb = vec![f32::INFINITY; n];
1689        assert_eq!(
1690            book.draw_frame(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, 9, pose, s, hh, f, 0),
1691            0
1692        );
1693    }
1694
1695    #[test]
1696    fn clip_flipbook_set_frame_replaces_one_frame() {
1697        // The single-frame edit primitive: replace frame 0's dense with
1698        // frame 1's content, in place. Out-of-range → false.
1699        let dims = [8u32, 8, 8];
1700        let f0 = clip_frame(dims, |_, _, z| (z < 4).then_some(0x00FF_0000)); // red
1701        let f1 = clip_frame(dims, |_, _, z| (z >= 4).then_some(0x0000_FF00)); // green
1702        let clip =
1703            VoxelClip::from_frames(dims, [4.0; 3], 1.0, LoopMode::Loop, &[f0, f1], &[], 33, 0);
1704        let decoded = clip.decode().unwrap();
1705        let mut book = ClipFlipbook::from_decoded(&decoded);
1706
1707        let (w, h) = (64u32, 64u32);
1708        let n = (w * h) as usize;
1709        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1710        let cfg = settings(w, h);
1711        let render0 = |b: &ClipFlipbook| -> Vec<u32> {
1712            let mut fb = vec![0u32; n];
1713            let mut zb = vec![f32::INFINITY; n];
1714            let _ = b.draw_frame(
1715                &mut fb,
1716                &mut zb,
1717                w as usize,
1718                w,
1719                h,
1720                &cs,
1721                &cfg,
1722                0,
1723                [0.0, 40.0, 0.0],
1724                [1.0, 0.0, 0.0],
1725                [0.0, 1.0, 0.0],
1726                [0.0, 0.0, 1.0],
1727                0,
1728            );
1729            fb
1730        };
1731
1732        let before = render0(&book);
1733        assert!(
1734            before.iter().any(|&p| (p & 0x00FF_0000) != 0),
1735            "frame 0 is red"
1736        );
1737
1738        // Replace frame 0 with frame 1's dense.
1739        let replacement = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1740        assert!(book.set_frame(0, replacement));
1741        let extra = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1742        assert!(!book.set_frame(9, extra), "out-of-range set_frame is false");
1743
1744        let after = render0(&book);
1745        assert!(
1746            after.iter().any(|&p| (p & 0x0000_FF00) != 0),
1747            "frame 0 now green"
1748        );
1749        assert_ne!(before, after);
1750    }
1751
1752    /// A solid cube sprite in front of the camera is drawn, with the
1753    /// cube colour (shaded) and a sensible centre depth.
1754    #[test]
1755    fn cube_sprite_renders() {
1756        let kv6 = Kv6::solid_cube(8, VoxColor(0x80_C0_40_20));
1757        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1758        let (w, h) = (64u32, 64u32);
1759        let n = (w * h) as usize;
1760        let mut fb = vec![0u32; n];
1761        let mut zb = vec![f32::INFINITY; n];
1762        let cam = cam_looking_y();
1763        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1764        let wrote = draw_sprite_dda(
1765            &mut fb,
1766            &mut zb,
1767            w as usize,
1768            w,
1769            h,
1770            &cs,
1771            &settings(w, h),
1772            &sprite,
1773        );
1774
1775        assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1776        let centre = (h / 2 * w + w / 2) as usize;
1777        assert_eq!(
1778            fb[centre] & 0x00ff_ffff,
1779            0x00_C0_40_20,
1780            "got {:08x}",
1781            fb[centre]
1782        );
1783        // Pivot at world y=40, cube spans y in [36,44] → near face ~36.
1784        assert!(
1785            (zb[centre] - 36.0).abs() < 3.0,
1786            "centre depth {} not ≈ 36",
1787            zb[centre]
1788        );
1789    }
1790
1791    /// A KV6 whose voxel colours store a `0x00` high byte (voxlap's
1792    /// unused `dir` slot, e.g. `sprite_meltsphere.kv6`) must still
1793    /// render its authored RGB, not black — the brightness byte is
1794    /// normalised to full on decode.
1795    #[test]
1796    fn zero_high_byte_sprite_not_black() {
1797        let kv6 = Kv6::solid_cube(8, VoxColor(0x00_C0_40_20));
1798        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1799        let (w, h) = (64u32, 64u32);
1800        let n = (w * h) as usize;
1801        let mut fb = vec![0u32; n];
1802        let mut zb = vec![f32::INFINITY; n];
1803        let cam = cam_looking_y();
1804        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1805        let wrote = draw_sprite_dda(
1806            &mut fb,
1807            &mut zb,
1808            w as usize,
1809            w,
1810            h,
1811            &cs,
1812            &settings(w, h),
1813            &sprite,
1814        );
1815        assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1816        let centre = (h / 2 * w + w / 2) as usize;
1817        assert_eq!(
1818            fb[centre] & 0x00ff_ffff,
1819            0x00_C0_40_20,
1820            "zero-high-byte sprite rendered as {:08x} (black bug)",
1821            fb[centre]
1822        );
1823    }
1824
1825    /// A sprite occludes / is occluded by the z-buffer: a nearer
1826    /// pre-filled depth blocks the sprite; a farther one lets it win.
1827    #[test]
1828    fn sprite_respects_zbuffer() {
1829        let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1830        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1831        let (w, h) = (32u32, 32u32);
1832        let n = (w * h) as usize;
1833        let cam = cam_looking_y();
1834        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1835        let centre = (h / 2 * w + w / 2) as usize;
1836
1837        // Terrain in front (depth 10 < ~36) → sprite blocked at centre.
1838        let mut fb = vec![0u32; n];
1839        let mut zb = vec![f32::INFINITY; n];
1840        fb[centre] = 0x80_11_22_33;
1841        zb[centre] = 10.0;
1842        let _ = draw_sprite_dda(
1843            &mut fb,
1844            &mut zb,
1845            w as usize,
1846            w,
1847            h,
1848            &cs,
1849            &settings(w, h),
1850            &sprite,
1851        );
1852        assert_eq!(
1853            fb[centre], 0x80_11_22_33,
1854            "near terrain must occlude sprite"
1855        );
1856
1857        // Terrain behind (depth 100) → sprite wins.
1858        let mut fb2 = vec![0u32; n];
1859        let mut zb2 = vec![f32::INFINITY; n];
1860        fb2[centre] = 0x80_11_22_33;
1861        zb2[centre] = 100.0;
1862        let _ = draw_sprite_dda(
1863            &mut fb2,
1864            &mut zb2,
1865            w as usize,
1866            w,
1867            h,
1868            &cs,
1869            &settings(w, h),
1870            &sprite,
1871        );
1872        assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
1873        assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
1874    }
1875
1876    /// The covered screen rect (min/max px,py) of whatever the sprite
1877    /// painted — used to compare an axis-aligned vs a rotated pose.
1878    fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
1879        let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
1880        for py in 0..h {
1881            for px in 0..w {
1882                if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
1883                    x0 = x0.min(px);
1884                    y0 = y0.min(py);
1885                    x1 = x1.max(px);
1886                    y1 = y1.max(py);
1887                }
1888            }
1889        }
1890        (x0, y0, x1, y1)
1891    }
1892
1893    /// A non-cube box drawn axis-aligned vs. drawn with a per-instance
1894    /// transform that swaps its long axis onto the screen's other axis
1895    /// flips the silhouette's aspect ratio. Pins that the `s/h/f` basis
1896    /// (the path `DynSpriteTransform` feeds) actually reorients the model.
1897    #[test]
1898    fn posed_basis_reorients_silhouette() {
1899        // Wide-in-local-x, short-in-local-z box → appears wide on screen
1900        // (screen-x = world-x via `right`, screen-y = world-z via `down`).
1901        let kv6 = Kv6::solid_box(16, 4, 4, VoxColor(0x80_C0_40_20));
1902        let (w, h) = (64u32, 64u32);
1903        let n = (w * h) as usize;
1904        let cam = cam_looking_y();
1905        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1906
1907        // Axis-aligned: wide silhouette.
1908        let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1909        let mut fb = vec![0u32; n];
1910        let mut zb = vec![f32::INFINITY; n];
1911        let _ = draw_sprite_dda(
1912            &mut fb,
1913            &mut zb,
1914            w as usize,
1915            w,
1916            h,
1917            &cs,
1918            &settings(w, h),
1919            &aa,
1920        );
1921        let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
1922        let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
1923        assert!(
1924            aa_wide > 4,
1925            "axis-aligned box should be wider than tall (got w-h={aa_wide})"
1926        );
1927
1928        // Posed: map local +x onto world +z and local +z onto world +x
1929        // (det = -1 ≠ 0). Same box now reads tall on screen.
1930        let mut posed = aa.clone();
1931        posed.s = [0.0, 0.0, 1.0]; // local +x ↦ world +z (screen down)
1932        posed.h = [0.0, 1.0, 0.0]; // local +y ↦ world +y (depth)
1933        posed.f = [1.0, 0.0, 0.0]; // local +z ↦ world +x (screen right)
1934        let mut fb2 = vec![0u32; n];
1935        let mut zb2 = vec![f32::INFINITY; n];
1936        let _ = draw_sprite_dda(
1937            &mut fb2,
1938            &mut zb2,
1939            w as usize,
1940            w,
1941            h,
1942            &cs,
1943            &settings(w, h),
1944            &posed,
1945        );
1946        let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
1947        let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
1948        assert!(
1949            posed_tall > 4,
1950            "posed box should be taller than wide (got h-w={posed_tall})"
1951        );
1952    }
1953
1954    /// A degenerate (singular) basis — `det == 0` — makes the sprite
1955    /// silently skip rather than panic (the `DynSpriteTransform` guard).
1956    #[test]
1957    fn degenerate_basis_draws_nothing() {
1958        let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1959        let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1960        sprite.f = sprite.s; // two equal columns → det 0
1961        let (w, h) = (32u32, 32u32);
1962        let n = (w * h) as usize;
1963        let mut fb = vec![0u32; n];
1964        let mut zb = vec![f32::INFINITY; n];
1965        let cam = cam_looking_y();
1966        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1967        let wrote = draw_sprite_dda(
1968            &mut fb,
1969            &mut zb,
1970            w as usize,
1971            w,
1972            h,
1973            &cs,
1974            &settings(w, h),
1975            &sprite,
1976        );
1977        assert_eq!(wrote, 0, "singular basis must skip, not panic");
1978    }
1979
1980    /// An invisible sprite draws nothing.
1981    #[test]
1982    fn invisible_sprite_skipped() {
1983        let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1984        let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1985        sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
1986        let (w, h) = (32u32, 32u32);
1987        let n = (w * h) as usize;
1988        let mut fb = vec![0u32; n];
1989        let mut zb = vec![f32::INFINITY; n];
1990        let cam = cam_looking_y();
1991        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1992        let wrote = draw_sprite_dda(
1993            &mut fb,
1994            &mut zb,
1995            w as usize,
1996            w,
1997            h,
1998            &cs,
1999            &settings(w, h),
2000            &sprite,
2001        );
2002        assert_eq!(wrote, 0);
2003    }
2004
2005    // ---------- TV.1a: translucent accumulate-and-continue path ----------
2006
2007    /// Draw a uniform-material 8³ cube (RGB `0xC0_40_20`) at world y=40 over
2008    /// a `bg`-filled framebuffer with z-buffer `zb_v`, using palette id 1 =
2009    /// `mat`. Returns `(centre_pixel, full_framebuffer)`.
2010    fn draw_cube_shaded(mat: Material, alpha_mul: u8, bg: u32, zb_v: f32) -> (u32, Vec<u32>) {
2011        let mut table = MaterialTable::new();
2012        table.set(1, mat);
2013        let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
2014        let (w, h) = (64u32, 64u32);
2015        let n = (w * h) as usize;
2016        let mut fb = vec![bg; n];
2017        let mut zb = vec![zb_v; n];
2018        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2019        let sh = SpriteShade {
2020            materials: &table,
2021            lights: CpuLights::default(),
2022            material: 1,
2023            alpha_mul,
2024            tint: 0x00FF_FFFF,
2025            shadow: None,
2026        };
2027        let _ = draw_sprite_dense_shaded(
2028            &mut fb,
2029            &mut zb,
2030            w as usize,
2031            w,
2032            h,
2033            &cs,
2034            &settings(w, h),
2035            &dense,
2036            [0.0, 40.0, 0.0],
2037            [1.0, 0.0, 0.0],
2038            [0.0, 1.0, 0.0],
2039            [0.0, 0.0, 1.0],
2040            0,
2041            Some(sh),
2042        );
2043        (fb[(h / 2 * w + w / 2) as usize], fb)
2044    }
2045
2046    /// An additive sprite over a dark background brightens it (glow) — and
2047    /// never darkens any channel below the background.
2048    #[test]
2049    fn additive_sprite_brightens_background() {
2050        let bg = 0x80_20_20_20;
2051        let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, f32::INFINITY);
2052        let (cr, cg, cb) = ((centre >> 16) & 0xff, (centre >> 8) & 0xff, centre & 0xff);
2053        assert!(
2054            cr > 0x20 && cg > 0x20 && cb >= 0x20,
2055            "centre {centre:08x} should be brighter than bg"
2056        );
2057        // Red channel (sprite 0xC0) lifts the most.
2058        assert!(
2059            cr >= cg && cr >= cb,
2060            "additive of a red-dominant cube stays red-dominant"
2061        );
2062    }
2063
2064    /// An alpha-blend sprite composites *between* the background and its own
2065    /// colour — neither equal to the bare background nor the opaque colour.
2066    #[test]
2067    fn alpha_blend_sprite_between_bg_and_color() {
2068        let bg = 0x80_20_20_20;
2069        let (centre, _) = draw_cube_shaded(Material::alpha_blend(128), 255, bg, f32::INFINITY);
2070        let cr = (centre >> 16) & 0xff;
2071        assert!(
2072            cr > 0x20,
2073            "blended red must rise above bg 0x20 (got {cr:02x})"
2074        );
2075        assert!(
2076            cr < 0xC0,
2077            "blended red must stay below opaque 0xC0 (got {cr:02x})"
2078        );
2079        // Distinct from both endpoints.
2080        assert_ne!(centre & 0x00ff_ffff, bg & 0x00ff_ffff);
2081        assert_ne!(centre & 0x00ff_ffff, 0x00_C0_40_20);
2082    }
2083
2084    /// The per-instance `alpha_mul` scales opacity: a lower multiplier keeps
2085    /// more of the background (less of the sprite colour).
2086    #[test]
2087    fn alpha_mul_scales_opacity() {
2088        let bg = 0x80_20_20_20;
2089        let (full, _) = draw_cube_shaded(Material::alpha_blend(255), 255, bg, f32::INFINITY);
2090        let (faded, _) = draw_cube_shaded(Material::alpha_blend(255), 64, bg, f32::INFINITY);
2091        let r_full = (full >> 16) & 0xff;
2092        let r_faded = (faded >> 16) & 0xff;
2093        // Both lift red above bg, but the faded one stays closer to bg.
2094        assert!(
2095            r_full > r_faded,
2096            "alpha_mul=255 ({r_full:02x}) more opaque than 64 ({r_faded:02x})"
2097        );
2098        assert!(r_faded > 0x20, "even faded lifts above bg");
2099    }
2100
2101    // ---------- EV: sprite emissive ----------
2102
2103    /// Like [`draw_cube_shaded`] but with an explicit light rig and a black
2104    /// background, so the emissive tests can prove the branch outranks
2105    /// dynamic lighting. Returns the centre pixel.
2106    fn draw_cube_lit(mat: Material, lights: CpuLights) -> u32 {
2107        let mut table = MaterialTable::new();
2108        table.set(1, mat);
2109        let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
2110        let (w, h) = (64u32, 64u32);
2111        let n = (w * h) as usize;
2112        let mut fb = vec![0u32; n];
2113        let mut zb = vec![f32::INFINITY; n];
2114        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2115        let sh = SpriteShade {
2116            materials: &table,
2117            lights,
2118            material: 1,
2119            alpha_mul: 255,
2120            tint: 0x00FF_FFFF,
2121            shadow: None,
2122        };
2123        let _ = draw_sprite_dense_shaded(
2124            &mut fb,
2125            &mut zb,
2126            w as usize,
2127            w,
2128            h,
2129            &cs,
2130            &settings(w, h),
2131            &dense,
2132            [0.0, 40.0, 0.0],
2133            [1.0, 0.0, 0.0],
2134            [0.0, 1.0, 0.0],
2135            [0.0, 0.0, 1.0],
2136            0,
2137            Some(sh),
2138        );
2139        fb[(h / 2 * w + w / 2) as usize]
2140    }
2141
2142    /// EV — an **opaque emissive** sprite voxel renders at its albedo × the
2143    /// emissive factor, independent of the dynamic rig: a zero-ambient rig
2144    /// blacks out a plain cube but leaves the glowing one untouched, and the
2145    /// value equals the terrain ladder's [`emissive_shade`] exactly — CPU
2146    /// sprite/terrain parity by construction.
2147    #[test]
2148    fn sprite_emissive_ignores_lighting() {
2149        // Zero rig: enabled, no sun, zero ambient — a plain cube goes black.
2150        let dark = CpuLights {
2151            enabled: true,
2152            ambient: [0.0; 3],
2153            ..CpuLights::default()
2154        };
2155        let plain = draw_cube_lit(Material::OPAQUE, dark);
2156        assert_eq!(
2157            plain & 0x00ff_ffff,
2158            0,
2159            "zero rig must black out a plain cube: {plain:#010x}"
2160        );
2161
2162        let expect = emissive_shade(0x80_C0_40_20, 255) & 0x00ff_ffff;
2163        let glow = draw_cube_lit(Material::OPAQUE.with_emissive(255), dark);
2164        assert_eq!(
2165            glow & 0x00ff_ffff,
2166            expect,
2167            "emissive must outrank the rig: {glow:#010x} vs {expect:#010x}"
2168        );
2169
2170        // Rig disabled ⇒ the same value: emissive is rig-invariant.
2171        let unlit = draw_cube_lit(Material::OPAQUE.with_emissive(255), CpuLights::default());
2172        assert_eq!(unlit & 0x00ff_ffff, expect, "emissive is rig-invariant");
2173    }
2174
2175    /// EV — an **AlphaBlend emissive** sprite glows *through* its
2176    /// translucency: over a black background the composited layer is
2177    /// brighter than the same non-emissive glass.
2178    #[test]
2179    fn sprite_emissive_glows_through_alpha_blend() {
2180        let (plain, _) =
2181            draw_cube_shaded(Material::alpha_blend(128), 255, 0x8000_0000, f32::INFINITY);
2182        let (glow, _) = draw_cube_shaded(
2183            Material::alpha_blend(128).with_emissive(255),
2184            255,
2185            0x8000_0000,
2186            f32::INFINITY,
2187        );
2188        let r = |p: u32| (p >> 16) & 0xff;
2189        assert!(
2190            r(glow) > r(plain),
2191            "emissive glass must beat plain glass over black: {glow:#010x} vs {plain:#010x}"
2192        );
2193    }
2194
2195    /// A `SpriteShade` whose effective material is **opaque** (id 0) renders
2196    /// byte-for-byte identically to the plain opaque path — the per-sprite
2197    /// gate that keeps the opaque world unchanged.
2198    #[test]
2199    fn opaque_shade_ctx_matches_plain_path() {
2200        let table = MaterialTable::new();
2201        let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
2202        let (w, h) = (64u32, 64u32);
2203        let n = (w * h) as usize;
2204        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2205        let pose = (
2206            [0.0, 40.0, 0.0],
2207            [1.0, 0.0, 0.0],
2208            [0.0, 1.0, 0.0],
2209            [0.0, 0.0, 1.0],
2210        );
2211
2212        let mut fb_plain = vec![0u32; n];
2213        let mut zb_plain = vec![f32::INFINITY; n];
2214        let _ = draw_sprite_dense(
2215            &mut fb_plain,
2216            &mut zb_plain,
2217            w as usize,
2218            w,
2219            h,
2220            &cs,
2221            &settings(w, h),
2222            &dense,
2223            pose.0,
2224            pose.1,
2225            pose.2,
2226            pose.3,
2227            0,
2228        );
2229
2230        let mut fb_sh = vec![0u32; n];
2231        let mut zb_sh = vec![f32::INFINITY; n];
2232        let sh = SpriteShade {
2233            materials: &table,
2234            lights: CpuLights::default(),
2235            material: 0, // opaque
2236            alpha_mul: 255,
2237            tint: 0x00FF_FFFF,
2238            shadow: None,
2239        };
2240        let _ = draw_sprite_dense_shaded(
2241            &mut fb_sh,
2242            &mut zb_sh,
2243            w as usize,
2244            w,
2245            h,
2246            &cs,
2247            &settings(w, h),
2248            &dense,
2249            pose.0,
2250            pose.1,
2251            pose.2,
2252            pose.3,
2253            0,
2254            Some(sh),
2255        );
2256
2257        assert_eq!(
2258            fb_plain, fb_sh,
2259            "opaque shade-ctx must match the plain path bit-for-bit"
2260        );
2261        assert_eq!(zb_plain, zb_sh, "opaque shade-ctx z-buffer must match too");
2262    }
2263
2264    /// A translucent (additive) sprite behind nearer terrain is occluded:
2265    /// the front depth (~36) is past the z-buffer cutoff (5), so the march
2266    /// stops before contributing and the background pixel is untouched.
2267    #[test]
2268    fn translucent_sprite_occluded_by_near_terrain() {
2269        let bg = 0x80_20_20_20;
2270        let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, 5.0);
2271        assert_eq!(
2272            centre, bg,
2273            "near terrain (z=5) must occlude the sprite at y≈36"
2274        );
2275    }
2276
2277    /// Per-span compositing: a translucent voxel contributes one alpha layer
2278    /// per contiguous solid run, so a 2-voxel-thick slab composites the same
2279    /// as a 1-voxel-thick one (adjacent voxels are not double-counted). This
2280    /// is the fix for the voxel-grid striping where a ray clipping a shared
2281    /// voxel boundary passed through two cells of one wall.
2282    #[test]
2283    fn per_span_thickness_independent() {
2284        fn centre(ysiz: u32) -> u32 {
2285            let mut table = MaterialTable::new();
2286            table.set(1, Material::alpha_blend(128));
2287            let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, ysiz, 8, VoxColor(0x80_C0_40_20)));
2288            let (w, h) = (64u32, 64u32);
2289            let n = (w * h) as usize;
2290            let mut fb = vec![0x80_10_10_10u32; n];
2291            let mut zb = vec![f32::INFINITY; n];
2292            let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2293            let sh = SpriteShade {
2294                materials: &table,
2295                lights: CpuLights::default(),
2296                material: 1,
2297                alpha_mul: 255,
2298                tint: 0x00FF_FFFF,
2299                shadow: None,
2300            };
2301            let _ = draw_sprite_dense_shaded(
2302                &mut fb,
2303                &mut zb,
2304                w as usize,
2305                w,
2306                h,
2307                &cs,
2308                &settings(w, h),
2309                &dense,
2310                [0.0, 40.0, 0.0],
2311                [1.0, 0.0, 0.0],
2312                [0.0, 1.0, 0.0],
2313                [0.0, 0.0, 1.0],
2314                0,
2315                Some(sh),
2316            );
2317            fb[(h / 2 * w + w / 2) as usize] & 0x00ff_ffff
2318        }
2319        // A 2-deep box is solid through (surface-only of a 2-thick box is both
2320        // y-layers); per-span treats the straight-through ray's two adjacent
2321        // voxels as one surface → identical to the 1-deep slab.
2322        assert_eq!(
2323            centre(1),
2324            centre(2),
2325            "per-span: a 2-thick slab must match a 1-thick one (no double-count)"
2326        );
2327    }
2328
2329    /// Volumetric (Beer–Lambert) is the thickness-*dependent* counterpart of
2330    /// per-span: a deeper **filled** volume absorbs more, so its centre pixel
2331    /// sits closer to the volume colour (less background shows through) than a
2332    /// shallow one — the opposite of `per_span_thickness_independent`.
2333    #[test]
2334    fn volumetric_thickness_deepens_opacity() {
2335        // Centre-pixel red channel of a filled red box `depth` voxels deep,
2336        // Volumetric material, over a dark background.
2337        fn red_at(depth: u32) -> u32 {
2338            let mut table = MaterialTable::new();
2339            table.set(1, Material::volumetric(128));
2340            // FILLED box (every cell solid, interior kept) so the ray actually
2341            // traverses `depth` absorbing voxels. `from_fn` would cull the
2342            // interior to a hollow shell (front+back faces only) — no genuine
2343            // depth accumulation — so use `from_fn_keep_interior`.
2344            let kv6 = Kv6::from_fn_keep_interior(
2345                8,
2346                depth,
2347                8,
2348                |_, _, _| Some(VoxColor(0x80_C0_20_20)),
2349                |_| true,
2350            );
2351            let dense = SpriteDense::from_kv6(&kv6);
2352            let (w, h) = (64u32, 64u32);
2353            let n = (w * h) as usize;
2354            let mut fb = vec![0x80_10_10_10u32; n];
2355            let mut zb = vec![f32::INFINITY; n];
2356            let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2357            let sh = SpriteShade {
2358                materials: &table,
2359                lights: CpuLights::default(),
2360                material: 1,
2361                alpha_mul: 255,
2362                tint: 0x00FF_FFFF,
2363                shadow: None,
2364            };
2365            let _ = draw_sprite_dense_shaded(
2366                &mut fb,
2367                &mut zb,
2368                w as usize,
2369                w,
2370                h,
2371                &cs,
2372                &settings(w, h),
2373                &dense,
2374                [0.0, 40.0, 0.0],
2375                [1.0, 0.0, 0.0],
2376                [0.0, 1.0, 0.0],
2377                [0.0, 0.0, 1.0],
2378                0,
2379                Some(sh),
2380            );
2381            (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2382        }
2383        let shallow = red_at(1);
2384        let deep = red_at(12);
2385        // Both lift red above the 0x10 background; the deeper volume absorbs
2386        // more of its own colour in, so its red is higher (more opaque).
2387        assert!(
2388            shallow > 0x10,
2389            "even a 1-deep volume tints (got {shallow:02x})"
2390        );
2391        assert!(
2392            deep > shallow,
2393            "deeper Volumetric volume is more opaque: deep {deep:02x} > shallow {shallow:02x}"
2394        );
2395    }
2396
2397    /// CPU/GPU parity for scaled clips: rendering a dense volume with
2398    /// `voxel_world_size = k` at a unit basis must be pixel-identical
2399    /// to rendering it with `voxel_world_size = 1` at a `k`-scaled
2400    /// basis — the two factorings of `basis · ((v − pivot) · k)`. The
2401    /// occluder must agree so shadows match the pixels.
2402    #[test]
2403    fn voxel_world_size_matches_scaled_basis() {
2404        use crate::dda::WorldOccluder;
2405        let kv6 = Kv6::solid_cube(8, VoxColor(0x80_40_C0_40));
2406        let scaled = SpriteDense::from_kv6(&kv6).with_voxel_world_size(2.0);
2407        let unit = SpriteDense::from_kv6(&kv6);
2408
2409        let (w, h) = (64u32, 64u32);
2410        let n = (w * h) as usize;
2411        let draw = |dense: &SpriteDense, basis: f32| {
2412            let mut fb = vec![0x80_10_10_10u32; n];
2413            let mut zb = vec![f32::INFINITY; n];
2414            let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2415            let written = draw_sprite_dense(
2416                &mut fb,
2417                &mut zb,
2418                w as usize,
2419                w,
2420                h,
2421                &cs,
2422                &settings(w, h),
2423                dense,
2424                [0.0, 40.0, 0.0],
2425                [basis, 0.0, 0.0],
2426                [0.0, basis, 0.0],
2427                [0.0, 0.0, basis],
2428                0,
2429            );
2430            assert!(written > 0, "sprite must be visible");
2431            fb
2432        };
2433        assert_eq!(
2434            draw(&scaled, 1.0),
2435            draw(&unit, 2.0),
2436            "vws=2 @ unit basis == vws=1 @ 2x basis, pixel for pixel"
2437        );
2438
2439        let occludes = |dense: &SpriteDense, basis: f32, x: f64| {
2440            let mut occ = SpriteOccluder::new();
2441            occ.push(
2442                Arc::new(dense.clone()),
2443                [0.0, 40.0, 0.0],
2444                [basis, 0.0, 0.0],
2445                [0.0, basis, 0.0],
2446                [0.0, 0.0, basis],
2447            );
2448            occ.occluded_world([x, 0.0, 0.0], [0.0, 1.0, 0.0], 100.0)
2449        };
2450        // x = 6: inside the DOUBLED half-extent (8) but outside the
2451        // unit one (4) — blocked only under the 2x scale, from either
2452        // factoring.
2453        assert!(occludes(&scaled, 1.0, 6.0));
2454        assert!(occludes(&unit, 2.0, 6.0));
2455        assert!(!occludes(&unit, 1.0, 6.0), "unscaled cube ends at 4");
2456    }
2457
2458    /// XS.2 — the sprite occluder reports a world ray blocked by a sprite
2459    /// volume (the mechanism by which sprites **cast** shadows, and **receive**
2460    /// them from each other). A ray through the cube is occluded; one well to
2461    /// the side is not.
2462    #[test]
2463    fn sprite_occluder_blocks_ray_through_volume() {
2464        use crate::dda::WorldOccluder;
2465        // 8³ cube, identity pose at world origin; `from_fn` centres the pivot
2466        // (4,4,4), so the cube spans world ≈ [-4, 4]³.
2467        let dense = Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(
2468            8,
2469            VoxColor(0x80_FF_FF_FF),
2470        )));
2471        let mut occ = SpriteOccluder::new();
2472        occ.push(
2473            dense,
2474            [0.0, 0.0, 0.0],
2475            [1.0, 0.0, 0.0],
2476            [0.0, 1.0, 0.0],
2477            [0.0, 0.0, 1.0],
2478        );
2479        assert!(!occ.is_empty());
2480        // Up the z-axis through the cube centre (world x=y=0 ⇒ local 4,4).
2481        assert!(
2482            occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2483            "a ray through the cube must be occluded"
2484        );
2485        // Far to the side: never enters the cube's AABB.
2486        assert!(
2487            !occ.occluded_world([50.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2488            "a ray missing the cube must not be occluded"
2489        );
2490        // Beyond max_t: the cube is at ~50 units, cap at 10 ⇒ unreached.
2491        assert!(
2492            !occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 10.0),
2493            "max_t shorter than the distance to the cube ⇒ unoccluded"
2494        );
2495    }
2496
2497    /// XS.2 — a sprite **receives** a hard shadow: a blocker volume between the
2498    /// drawn sprite and the sun darkens it. The blocker lives only in the
2499    /// occluder (it isn't drawn), so the framebuffer difference is purely the
2500    /// shadow it casts on the visible sprite.
2501    #[test]
2502    fn sprite_receives_hard_shadow() {
2503        // Drawn target: a voxel sphere at world (0,40,0). A sphere (unlike a
2504        // face-on cube, whose visible face is the normal-less AABB-entry voxel)
2505        // gives real per-voxel normals, so its −y hemisphere is genuinely
2506        // sunlit (to-sun = −y, toward the camera). A blocker cube at (0,25,0)
2507        // between sphere and sun shadows that lit hemisphere.
2508        let target = SpriteDense::from_kv6(&Kv6::from_fn(16, 16, 16, |x, y, z| {
2509            let (dx, dy, dz) = (x as i32 - 8, y as i32 - 8, z as i32 - 8);
2510            (dx * dx + dy * dy + dz * dz <= 49).then_some(VoxColor(0x80_C0_C0_C0))
2511        }));
2512        let mut occ = SpriteOccluder::new();
2513        occ.push(
2514            Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(
2515                8,
2516                VoxColor(0x80_FF_FF_FF),
2517            ))),
2518            [0.0, 25.0, 0.0],
2519            [1.0, 0.0, 0.0],
2520            [0.0, 1.0, 0.0],
2521            [0.0, 0.0, 1.0],
2522        );
2523        let table = MaterialTable::new();
2524        let base = CpuLights {
2525            enabled: true,
2526            sun: true,
2527            sun_dir: [0.0, -1.0, 0.0], // to-sun: −y (toward the camera)
2528            sun_color: [1.0; 3],
2529            sun_intensity: 1.0,
2530            sun_casts_shadow: true,
2531            ambient: [0.3; 3],
2532            shadow_strength: 0.85,
2533            shadow_bias: 1.5,
2534            shadow_max_dist: 128.0,
2535            ..CpuLights::default()
2536        };
2537        let (w, h) = (64u32, 64u32);
2538        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2539        let sum_lum = |shadow: Option<&dyn crate::dda::WorldOccluder>| -> u64 {
2540            let n = (w * h) as usize;
2541            let mut fb = vec![0u32; n];
2542            let mut zb = vec![f32::INFINITY; n];
2543            let sh = SpriteShade {
2544                materials: &table,
2545                lights: base,
2546                material: 0,
2547                alpha_mul: 255,
2548                tint: 0x00FF_FFFF,
2549                shadow,
2550            };
2551            let _ = draw_sprite_dense_shaded(
2552                &mut fb,
2553                &mut zb,
2554                w as usize,
2555                w,
2556                h,
2557                &cs,
2558                &settings(w, h),
2559                &target,
2560                [0.0, 40.0, 0.0],
2561                [1.0, 0.0, 0.0],
2562                [0.0, 1.0, 0.0],
2563                [0.0, 0.0, 1.0],
2564                0,
2565                Some(sh),
2566            );
2567            fb.iter()
2568                .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
2569                .sum()
2570        };
2571        let lit = sum_lum(None);
2572        let shadowed = sum_lum(Some(&occ));
2573        assert!(
2574            shadowed < lit,
2575            "the blocker must shadow the drawn sprite: shadowed={shadowed} lit={lit}"
2576        );
2577    }
2578
2579    /// Per-instance RGB tint multiplies the sprite's colour: a red tint on a
2580    /// white cube zeroes green+blue and keeps red; a white tint is a no-op.
2581    #[test]
2582    fn sprite_rgb_tint_recolours() {
2583        let table = MaterialTable::new();
2584        let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF)));
2585        let (w, h) = (64u32, 64u32);
2586        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2587        let centre = |tint: u32| -> u32 {
2588            let n = (w * h) as usize;
2589            let mut fb = vec![0u32; n];
2590            let mut zb = vec![f32::INFINITY; n];
2591            let sh = SpriteShade {
2592                materials: &table,
2593                lights: CpuLights::default(),
2594                material: 0,
2595                alpha_mul: 255,
2596                tint,
2597                shadow: None,
2598            };
2599            let _ = draw_sprite_dense_shaded(
2600                &mut fb,
2601                &mut zb,
2602                w as usize,
2603                w,
2604                h,
2605                &cs,
2606                &settings(w, h),
2607                &dense,
2608                [0.0, 40.0, 0.0],
2609                [1.0, 0.0, 0.0],
2610                [0.0, 1.0, 0.0],
2611                [0.0, 0.0, 1.0],
2612                0,
2613                Some(sh),
2614            );
2615            fb[(h / 2 * w + w / 2) as usize]
2616        };
2617        let r = |p: u32| (p >> 16) & 0xff;
2618        let g = |p: u32| (p >> 8) & 0xff;
2619        let b = |p: u32| p & 0xff;
2620        let white = centre(0x00FF_FFFF);
2621        let red = centre(0x00FF_0000);
2622        assert!(
2623            g(white) > 180 && b(white) > 180 && r(white) > 180,
2624            "white tint must be a no-op: {white:#08x}"
2625        );
2626        assert!(
2627            r(red) > 180 && g(red) < 20 && b(red) < 20,
2628            "red tint zeroes green/blue, keeps red: {red:#08x}"
2629        );
2630    }
2631
2632    /// XS.0 — translucent sprite layers are now **lit** (dynamic-lighting rig),
2633    /// not flat-baked: with the rig enabled at a dim ambient (sun off), the
2634    /// accumulated layer colour is darker than the disabled (baked, full-
2635    /// brightness) path. Pins that `cast_local_layers` runs `shade_dynamic`.
2636    #[test]
2637    fn translucent_sprite_layers_are_lit() {
2638        fn center_red(lights: CpuLights) -> u32 {
2639            let mut table = MaterialTable::new();
2640            table.set(1, Material::alpha_blend(160));
2641            let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, 8, 8, VoxColor(0x80_E0_30_30)));
2642            let (w, h) = (64u32, 64u32);
2643            let n = (w * h) as usize;
2644            let mut fb = vec![0x80_10_10_10u32; n];
2645            let mut zb = vec![f32::INFINITY; n];
2646            let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2647            let sh = SpriteShade {
2648                materials: &table,
2649                lights,
2650                material: 1,
2651                alpha_mul: 255,
2652                tint: 0x00FF_FFFF,
2653                shadow: None,
2654            };
2655            let _ = draw_sprite_dense_shaded(
2656                &mut fb,
2657                &mut zb,
2658                w as usize,
2659                w,
2660                h,
2661                &cs,
2662                &settings(w, h),
2663                &dense,
2664                [0.0, 40.0, 0.0],
2665                [1.0, 0.0, 0.0],
2666                [0.0, 1.0, 0.0],
2667                [0.0, 0.0, 1.0],
2668                0,
2669                Some(sh),
2670            );
2671            (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2672        }
2673        let baked = center_red(CpuLights::default()); // disabled ⇒ full-brightness baked
2674        let dim = center_red(CpuLights {
2675            enabled: true,
2676            ambient: [0.3; 3], // sun off, dim ambient ⇒ the layer should darken
2677            ..CpuLights::default()
2678        });
2679        assert!(
2680            dim < baked,
2681            "lit translucent layer must respond to the rig (dim ambient darkens): dim={dim:#x} baked={baked:#x}",
2682        );
2683    }
2684
2685    /// The demo scenario: an **opaque** backdrop sprite drawn first, then a
2686    /// **translucent** sprite in front of it sharing the buffer. The glass
2687    /// must composite over the backdrop colour (tint it), not leave it
2688    /// unchanged. Pins the CPU opaque-then-translucent interaction.
2689    #[test]
2690    fn translucent_sprite_tints_opaque_sprite_behind() {
2691        let mut table = MaterialTable::new();
2692        table.set(1, Material::alpha_blend(128));
2693        let (w, h) = (64u32, 64u32);
2694        let n = (w * h) as usize;
2695        let mut fb = vec![0x80_10_20_40u32; n]; // flat sky
2696        let mut zb = vec![f32::INFINITY; n];
2697        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2698        let cfg = settings(w, h);
2699        let id = [1.0, 0.0, 0.0];
2700        let up = [0.0, 1.0, 0.0];
2701        let fw = [0.0, 0.0, 1.0];
2702        let centre = (h / 2 * w + w / 2) as usize;
2703
2704        // Opaque red backdrop (material 0), far.
2705        let backdrop = SpriteDense::from_kv6(&Kv6::solid_cube(12, VoxColor(0x80_FF_00_00)));
2706        let sh_op = SpriteShade {
2707            materials: &table,
2708            lights: CpuLights::default(),
2709            material: 0,
2710            alpha_mul: 255,
2711            tint: 0x00FF_FFFF,
2712            shadow: None,
2713        };
2714        let _ = draw_sprite_dense_shaded(
2715            &mut fb,
2716            &mut zb,
2717            w as usize,
2718            w,
2719            h,
2720            &cs,
2721            &cfg,
2722            &backdrop,
2723            [0.0, 80.0, 0.0],
2724            id,
2725            up,
2726            fw,
2727            0,
2728            Some(sh_op),
2729        );
2730        let after_backdrop = fb[centre];
2731        assert_eq!(
2732            after_backdrop & 0x00ff_ffff,
2733            0x00FF_0000,
2734            "backdrop red must be drawn first"
2735        );
2736
2737        // Cyan glass (material 1), nearer + overlapping.
2738        let glass = SpriteDense::from_kv6(&Kv6::solid_cube(12, VoxColor(0x80_00_FF_FF)));
2739        let sh_gl = SpriteShade {
2740            materials: &table,
2741            lights: CpuLights::default(),
2742            material: 1,
2743            alpha_mul: 255,
2744            tint: 0x00FF_FFFF,
2745            shadow: None,
2746        };
2747        let wrote = draw_sprite_dense_shaded(
2748            &mut fb,
2749            &mut zb,
2750            w as usize,
2751            w,
2752            h,
2753            &cs,
2754            &cfg,
2755            &glass,
2756            [0.0, 40.0, 0.0],
2757            id,
2758            up,
2759            fw,
2760            0,
2761            Some(sh_gl),
2762        );
2763        let _ = wrote;
2764        let after_glass = fb[centre];
2765        assert_ne!(
2766            after_glass, after_backdrop,
2767            "glass must tint the backdrop (composite over it)"
2768        );
2769        // Cyan over red: red channel drops, blue/green rise.
2770        assert!(
2771            (after_glass >> 16) & 0xff < 0xFF,
2772            "glass should reduce the backdrop's red (got {after_glass:08x})"
2773        );
2774    }
2775
2776    /// TV.3: `from_kv6_with_materials` classifies voxels into per-voxel
2777    /// material ids by colour — mapped colour → its id, unmapped → 0.
2778    #[test]
2779    fn from_kv6_with_materials_classifies_by_color() {
2780        let col = VoxColor(0x80_AA_BB_CC);
2781        let kv6 = Kv6::solid_cube(6, col);
2782        let dense = SpriteDense::from_kv6_with_materials(&kv6, &[(Rgb(0x00AA_BBCC), 2)]);
2783        assert_eq!(
2784            dense.mat.len(),
2785            dense.col.len(),
2786            "per-voxel mat array sized"
2787        );
2788        let mut solids = 0;
2789        for idx in 0..dense.occ.len() {
2790            if dense.occ[idx] {
2791                assert_eq!(dense.mat[idx], 2, "mapped colour → material 2");
2792                solids += 1;
2793            }
2794        }
2795        assert!(solids > 0, "cube has solid voxels");
2796        // A map that doesn't include the cube's colour → all opaque (0).
2797        let dense0 = SpriteDense::from_kv6_with_materials(&kv6, &[(Rgb(0x0012_3456), 5)]);
2798        assert!(
2799            dense0.mat.iter().all(|&m| m == 0),
2800            "unmapped colour → material 0"
2801        );
2802    }
2803
2804    /// TV.3: a model whose every voxel maps to the *same* material id renders
2805    /// identically to drawing it with that material as the instance's uniform
2806    /// material — the per-voxel path reduces to the uniform path when
2807    /// homogeneous (and overrides the instance's `material`).
2808    #[test]
2809    fn per_voxel_material_matches_uniform_when_homogeneous() {
2810        let mut table = MaterialTable::new();
2811        table.set(1, Material::alpha_blend(120));
2812        let col = VoxColor(0x80_30_A0_F0);
2813        let kv6 = Kv6::solid_cube(10, col);
2814        let (w, h) = (64u32, 64u32);
2815        let n = (w * h) as usize;
2816        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2817        let cfg = settings(w, h);
2818        let (pos, s, hh, f) = (
2819            [0.0, 40.0, 0.0],
2820            [1.0, 0.0, 0.0],
2821            [0.0, 1.0, 0.0],
2822            [0.0, 0.0, 1.0],
2823        );
2824        let render = |dense: &SpriteDense, material: u8| -> Vec<u32> {
2825            let mut fb = vec![0x80_10_10_10u32; n];
2826            let mut zb = vec![f32::INFINITY; n];
2827            let sh = SpriteShade {
2828                materials: &table,
2829                lights: CpuLights::default(),
2830                material,
2831                alpha_mul: 255,
2832                tint: 0x00FF_FFFF,
2833                shadow: None,
2834            };
2835            let _ = draw_sprite_dense_shaded(
2836                &mut fb,
2837                &mut zb,
2838                w as usize,
2839                w,
2840                h,
2841                &cs,
2842                &cfg,
2843                dense,
2844                pos,
2845                s,
2846                hh,
2847                f,
2848                0,
2849                Some(sh),
2850            );
2851            fb
2852        };
2853        // Per-voxel: every voxel → material 1; instance's uniform material is 0
2854        // (opaque) but the per-voxel id overrides it.
2855        let pv = render(
2856            &SpriteDense::from_kv6_with_materials(&kv6, &[(col.rgb_part(), 1)]),
2857            0,
2858        );
2859        // Uniform: no per-voxel data, instance material 1.
2860        let un = render(&SpriteDense::from_kv6(&kv6), 1);
2861        assert_eq!(pv, un, "homogeneous per-voxel material == uniform material");
2862        // And it's actually translucent (differs from the bare background).
2863        let centre = (h / 2 * w + w / 2) as usize;
2864        assert_ne!(pv[centre] & 0x00ff_ffff, 0x0010_1010, "translucent, not bg");
2865    }
2866
2867    /// TV.3 (clip wiring): a [`ClipFlipbook`] built with a colour→material map
2868    /// carries per-voxel materials on every cached frame (the clip analogue of
2869    /// `from_kv6_with_materials`); an empty map leaves them all-opaque, so the
2870    /// flipbook is byte-identical to `from_decoded`.
2871    #[test]
2872    fn clip_flipbook_with_materials_classifies_every_frame() {
2873        let dims = [6u32, 6, 6];
2874        let glass = Rgb(0x00AA_BBCC);
2875        let glass_lit = 0x80AA_BBCC;
2876        // Two distinct frames, both filled with the glass colour.
2877        let f0 = clip_frame(dims, |_x, _y, z| (z < 3).then_some(glass_lit));
2878        let f1 = clip_frame(dims, |_x, _y, z| (z >= 3).then_some(glass_lit));
2879        let clip = VoxelClip::from_frames(
2880            dims,
2881            [3.0, 3.0, 3.0],
2882            1.0,
2883            LoopMode::Loop,
2884            &[f0, f1],
2885            &[],
2886            33,
2887            0,
2888        );
2889        let decoded = clip.decode().expect("decode");
2890
2891        let book = ClipFlipbook::from_decoded_with_materials(&decoded, &[(glass, 2)]);
2892        assert_eq!(book.frame_count(), 2);
2893        for fr in 0..2 {
2894            let dense = book.frame(fr).expect("frame in range");
2895            assert_eq!(dense.mat.len(), dense.col.len(), "frame {fr} mat sized");
2896            let mut solids = 0;
2897            for idx in 0..dense.occ.len() {
2898                if dense.occ[idx] {
2899                    assert_eq!(dense.mat[idx], 2, "frame {fr}: glass → material 2");
2900                    solids += 1;
2901                }
2902            }
2903            assert!(solids > 0, "frame {fr} has solid voxels");
2904        }
2905
2906        // An empty map ⇒ no per-voxel materials, identical to `from_decoded`.
2907        let plain = ClipFlipbook::from_decoded(&decoded);
2908        let plain_mat = ClipFlipbook::from_decoded_with_materials(&decoded, &[]);
2909        for fr in 0..2 {
2910            assert!(plain.frame(fr).unwrap().mat.is_empty());
2911            assert!(plain_mat.frame(fr).unwrap().mat.is_empty());
2912        }
2913    }
2914}