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