Skip to main content

roxlap_core/
dda_sprite.rs

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