Skip to main content

roxlap_core/
dda_sprite.rs

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