Skip to main content

roxlap_core/
dda_sprite.rs

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