Skip to main content

roxlap_scene/
billboard.rs

1//! Billboard impostor cache — S6.2 of `PORTING-SCENE.md` § S6.
2//!
3//! At `Lod::Far` distance the per-grid rasterizer is too expensive
4//! and produces sub-pixel detail no one will ever see. The
5//! billboard cache replaces it with N pre-rendered orthographic-ish
6//! snapshots from canonical viewpoints arranged on a sphere; the
7//! S6.3 blit path picks the snapshot whose direction most closely
8//! matches the current camera and stamps it into the framebuffer
9//! as a screen-aligned quad with per-pixel depth.
10//!
11//! S6.2 lands the cache infrastructure ONLY — types + viewpoint
12//! generation + lazy population API + edit-time invalidation. The
13//! blit path (consuming the snapshots) is S6.3.
14//!
15//! ## Viewpoint set
16//!
17//! [`canonical_viewpoints`] returns 26 unit vectors covering the
18//! sphere octants:
19//!
20//! - **6 face viewpoints**: `±x`, `±y`, `±z` — axis-aligned.
21//! - **12 edge viewpoints**: e.g. `(1, 1, 0) / √2` — between two
22//!   adjacent faces.
23//! - **8 corner viewpoints**: `(1, 1, 1) / √3` — diagonal.
24//!
25//! The set is hand-tuned to PORTING-SCENE.md § S6's "26 is a
26//! reasonable starting point" recommendation; can grow to a
27//! denser Fibonacci sphere later if angular gaps prove visible.
28//!
29//! ## Snapshot camera
30//!
31//! For each unit viewpoint `v`, the snapshot camera lives in
32//! **grid-local space** at:
33//! ```text
34//! pos     = grid_center_local + v * D
35//! forward = -v
36//! ```
37//! where `D = 8 * bounding_radius` (well past the Far threshold).
38//! The basis right / down is constructed via Gram-Schmidt from an
39//! arbitrary "up reference" — `(0, 0, 1)` unless the viewpoint is
40//! near-parallel, in which case `(1, 0, 0)`. Final basis satisfies
41//! voxlap's right-handed convention `right × down == forward`.
42//!
43//! The projection is perspective with a large focal length so it
44//! approximates orthographic: `hz = N * D / (2 * R)`. At `D = 8R`
45//! this is `hz = 4N` — a very narrow FOV, rays diverge by at most
46//! `atan(1/8) ≈ 7°` across the framebuffer, which is "ortho
47//! enough" for impostor purposes.
48//!
49//! S6.2's `mip_levels = 1` keeps the snapshot rendering simple.
50//! Multi-mip and bigger resolutions are a S6.4 polish concern.
51
52use glam::{DVec3, IVec3};
53use roxlap_core::opticast::{opticast, OpticastOutcome, OpticastSettings};
54use roxlap_core::rasterizer::ScratchPool;
55use roxlap_core::scalar_rasterizer::ScalarRasterizer;
56use roxlap_core::Camera;
57
58use crate::{Grid, CHUNK_SIZE_XY, CHUNK_SIZE_Z};
59
60/// Per-grid bounding metadata in grid-local space — computed once
61/// per render dispatch by [`grid_local_centre_and_radius`].
62/// Exposed so S6.3's `render_scene_composed` Far arm can share the
63/// centre/radius pair across the cache lookup AND the blit
64/// projection without recomputing.
65#[derive(Debug, Clone, Copy)]
66pub struct GridLocalBounds {
67    pub centre: DVec3,
68    pub radius: f64,
69}
70
71/// See [`super::grid_local_centre_and_radius`] for the internal
72/// version. The pub re-export keeps render.rs out of the
73/// internal-helper namespace.
74#[must_use]
75pub fn grid_bounds(grid: &Grid) -> GridLocalBounds {
76    let (centre, radius) = grid_local_centre_and_radius(grid);
77    GridLocalBounds { centre, radius }
78}
79
80/// Distance multiple of `bounding_radius` at which the snapshot
81/// camera is placed. 8× is "narrow enough FOV to look orthographic
82/// without busting numerical precision". Documented above; not a
83/// runtime knob in S6.2 (S6.4 polish can expose it).
84const CAMERA_DISTANCE_FACTOR: f64 = 8.0;
85
86/// Default per-snapshot framebuffer resolution. 128 × 128 keeps
87/// memory budget per grid at `26 × 128² × (4 colour + 4 depth) =
88/// ~3.4 MB` — acceptable for a handful of ships in a scene.
89/// Configurable via [`BillboardCache::with_resolution`].
90pub const DEFAULT_RESOLUTION: u32 = 128;
91
92/// Sentinel colour stamped into a snapshot's framebuffer wherever
93/// opticast would have drawn sky. The S6.3 blit detects sentinel
94/// pixels and skips them so the snapshot's sky shows through to
95/// whatever else is in the shared `(fb, zb)` (sky panorama, ground
96/// from another grid, etc.).
97///
98/// Picked as `0x00_00_00_00` because:
99/// - Alpha byte `0x00` is unused by voxlap's lit-voxel path
100///   (`set_side_shades` produces values in `[0x40, 0x80]`), so a
101///   real hit never accidentally collides with the sentinel.
102/// - All-zero is trivially fast to check and to bulk-fill.
103pub const SKY_SENTINEL: u32 = 0x00_00_00_00;
104
105/// One pre-rendered orthographic-ish view of a grid from a fixed
106/// direction. The depth buffer carries grid-local Euclidean
107/// distance (voxlap's z-buffer convention: smaller = closer); S6.3
108/// uses it to z-compose the billboard with other grids' rendered
109/// pixels.
110#[derive(Debug, Clone)]
111pub struct BillboardSnapshot {
112    /// Unit vector from the grid centre TO the viewpoint, in
113    /// grid-local space. The snapshot camera was at
114    /// `centre + view_dir * D` looking back.
115    pub view_dir: DVec3,
116    /// Snapshot framebuffer width and height in pixels (square in
117    /// S6.2 but the struct supports rectangular for future work).
118    pub width: u32,
119    /// See [`Self::width`].
120    pub height: u32,
121    /// RGBA framebuffer. Sky pixels carry the build's `sky_color`.
122    pub color: Vec<u32>,
123    /// Per-pixel grid-local distance (voxlap's z-buffer convention:
124    /// smaller = closer). Sky pixels carry [`f32::INFINITY`].
125    pub depth: Vec<f32>,
126}
127
128/// Per-grid lazy cache of 26 [`BillboardSnapshot`]s indexed by the
129/// 26 [`canonical_viewpoints`] directions.
130///
131/// Construction modes:
132/// - [`Self::new_empty`]: allocate an empty cache, populate later
133///   via [`Self::build`].
134/// - [`Self::build`]: render all 26 snapshots in one call. Use this
135///   from the render-time lazy path: when a grid first lands on
136///   `Lod::Far`, the S6.3 dispatch checks
137///   [`Grid::billboards`]; if `None`, calls `build` and stores.
138#[derive(Debug, Clone)]
139pub struct BillboardCache {
140    /// Snapshot resolution in pixels (square). Pinned at build
141    /// time; rebuilds construct a fresh `BillboardCache` rather
142    /// than resizing in place.
143    pub resolution: u32,
144    /// 26 snapshots, indexed in the same order as
145    /// [`canonical_viewpoints`]. Empty (`Vec::new`) iff this cache
146    /// is uninitialised.
147    pub snapshots: Vec<BillboardSnapshot>,
148}
149
150impl BillboardCache {
151    /// Allocate an empty cache. `snapshots` is empty; future
152    /// [`Self::build`] populates it. Cheap — no allocations beyond
153    /// the empty `Vec` header.
154    #[must_use]
155    pub fn new_empty(resolution: u32) -> Self {
156        Self {
157            resolution,
158            snapshots: Vec::new(),
159        }
160    }
161
162    /// Number of snapshots populated. `0` for an empty cache,
163    /// `26` after [`Self::build`].
164    #[must_use]
165    pub fn len(&self) -> usize {
166        self.snapshots.len()
167    }
168
169    /// `true` iff this cache has not yet been populated (no
170    /// snapshots stored).
171    #[must_use]
172    pub fn is_empty(&self) -> bool {
173        self.snapshots.is_empty()
174    }
175
176    /// Render all 26 viewpoint snapshots of `grid` into a fresh
177    /// cache.
178    ///
179    /// Cost: `O(26 × resolution² × grid_render_cost)`. For
180    /// `resolution = 128` and a small ship grid this is roughly
181    /// equivalent to a single full-frame render. Intended to be
182    /// called once per grid-edit cycle (caches are invalidated on
183    /// edits via [`Grid::set_voxel`] et al.).
184    ///
185    /// An empty grid (no populated chunks) yields 26 all-sky
186    /// snapshots. The cache is still populated so the S6.3 blit
187    /// path doesn't keep retrying — a Far-tier empty grid's blit
188    /// is then a no-op (every pixel skipped via [`SKY_SENTINEL`]).
189    ///
190    /// **Sky-pixel detection**: the pool's skycast colour is set
191    /// to [`SKY_SENTINEL`] before each snapshot render so opticast
192    /// writes the sentinel (not a real sky colour) into pixels
193    /// rays missed. Post-render, every sentinel pixel's depth is
194    /// reset to [`f32::INFINITY`] (opticast writes a finite "sky
195    /// distance" for these pixels, which would otherwise leak
196    /// through the blit's depth check).
197    #[must_use]
198    pub fn build(grid: &Grid, resolution: u32) -> Self {
199        let viewpoints = canonical_viewpoints();
200        let mut snapshots = Vec::with_capacity(viewpoints.len());
201
202        // Grid centre + bounding radius in grid-local space.
203        let (centre, radius) = grid_local_centre_and_radius(grid);
204        // Camera distance + ray budget. `R_floor` prevents
205        // degenerate budgets for empty grids.
206        let r = radius.max(1.0);
207        let d = CAMERA_DISTANCE_FACTOR * r;
208        // Scan budget covers camera-to-far-side + a little slack
209        // for foreshortened rays past the centre.
210        let max_scan_dist = ((d + r) * 1.25).ceil().max(64.0) as i32;
211
212        // One ScratchPool shared across all 26 renders. Sized so
213        // the per-strip uurend stride fits the snapshot width.
214        let pool_vsid = CHUNK_SIZE_XY.max(resolution).max(64);
215        let mut pool = ScratchPool::new(resolution, resolution, pool_vsid);
216        // Skycast colour = SKY_SENTINEL so opticast stamps the
217        // sentinel into sky pixels (instead of a real colour the
218        // blit can't reliably tell apart from a voxel hit).
219        let sentinel_i = i32::from_ne_bytes(SKY_SENTINEL.to_ne_bytes());
220        pool.set_skycast(sentinel_i, 0);
221        pool.set_treat_z_max_as_air(true);
222
223        for view_dir in viewpoints {
224            let camera = snapshot_camera(view_dir, centre, d);
225            let mut color = vec![SKY_SENTINEL; (resolution as usize) * (resolution as usize)];
226            let mut depth = vec![f32::INFINITY; color.len()];
227
228            // Empty grid → render all-sky and move on (no chunks
229            // means `chunk_xyz_backing` returns None).
230            let outcome = if let Some(backing) = grid.chunk_xyz_backing() {
231                let cg = roxlap_core::ChunkGrid {
232                    chunks: &backing.chunks,
233                    origin_chunk_xy: backing.origin_chunk_xy,
234                    origin_chunk_z: backing.origin_chunk_z,
235                    chunks_x: backing.chunks_x,
236                    chunks_y: backing.chunks_y,
237                    chunks_z: backing.chunks_z,
238                };
239                let grid_view = roxlap_core::GridView::from_chunk_grid(&cg, CHUNK_SIZE_XY);
240                let settings = snapshot_settings(resolution, d, r, max_scan_dist);
241                let mut rasterizer =
242                    ScalarRasterizer::new(&mut color, &mut depth, resolution as usize, grid_view);
243                opticast(&mut rasterizer, &mut pool, &camera, &settings, grid_view)
244            } else {
245                // Empty grid — buffers stay at SKY_SENTINEL / INFINITY.
246                OpticastOutcome::Rendered
247            };
248            // `Rendered` and `SkippedCameraInSolid` both keep the
249            // buffers — the latter means the camera was inside
250            // solid material (impossible for our outside-the-grid
251            // camera position), in which case we get sky too.
252            let _ = outcome;
253
254            // Sentinel post-process: opticast writes a finite
255            // depth (`gxmax` / `max_scan_dist`) for sky pixels via
256            // `phase_startsky`. Reset those to INFINITY so the
257            // blit's `is_infinite()` belt-and-braces check still
258            // works alongside the colour-sentinel check.
259            for (px, z) in color.iter().zip(depth.iter_mut()) {
260                if *px == SKY_SENTINEL {
261                    *z = f32::INFINITY;
262                }
263            }
264
265            snapshots.push(BillboardSnapshot {
266                view_dir,
267                width: resolution,
268                height: resolution,
269                color,
270                depth,
271            });
272        }
273
274        Self {
275            resolution,
276            snapshots,
277        }
278    }
279
280    /// Pick the snapshot whose `view_dir` is closest to `query`
281    /// (largest dot product). Returns `None` iff the cache is
282    /// empty.
283    ///
284    /// `query` is the unit vector from the grid centre to the
285    /// current camera position in grid-local space — the same
286    /// frame the snapshot `view_dir`s live in. Caller is
287    /// responsible for normalisation.
288    ///
289    /// Tie-breaking is "first in viewpoint order"; with the 26
290    /// canonical viewpoints, ties only happen for query directions
291    /// exactly equidistant between two viewpoints, which is a
292    /// measure-zero set under f64.
293    #[must_use]
294    pub fn pick_nearest(&self, query: DVec3) -> Option<&BillboardSnapshot> {
295        if self.snapshots.is_empty() {
296            return None;
297        }
298        let mut best_idx = 0usize;
299        let mut best_dot = self.snapshots[0].view_dir.dot(query);
300        for (i, snap) in self.snapshots.iter().enumerate().skip(1) {
301            let d = snap.view_dir.dot(query);
302            if d > best_dot {
303                best_dot = d;
304                best_idx = i;
305            }
306        }
307        Some(&self.snapshots[best_idx])
308    }
309}
310
311/// 26 unit vectors covering the cube's face / edge / corner
312/// directions on the unit sphere.
313///
314/// Order is stable: 6 face → 12 edge → 8 corner. Indices are
315/// implementation details — call [`BillboardCache::pick_nearest`]
316/// for the query-driven lookup rather than indexing directly.
317///
318/// All vectors are unit length to within f64 precision (face
319/// vectors are exact; edge / corner vectors come from
320/// `normalize()` so they carry the platform's sqrt rounding —
321/// typically 1 ULP).
322#[must_use]
323pub fn canonical_viewpoints() -> Vec<DVec3> {
324    let mut out = Vec::with_capacity(26);
325
326    // 6 face directions — axis-aligned, exact unit length.
327    for &axis in &[
328        DVec3::X,
329        DVec3::NEG_X,
330        DVec3::Y,
331        DVec3::NEG_Y,
332        DVec3::Z,
333        DVec3::NEG_Z,
334    ] {
335        out.push(axis);
336    }
337
338    // 12 edge directions — (±1, ±1, 0), (±1, 0, ±1), (0, ±1, ±1)
339    // normalised to unit length (1/√2 in each non-zero component).
340    let signs = [-1.0_f64, 1.0_f64];
341    for &sa in &signs {
342        for &sb in &signs {
343            out.push(DVec3::new(sa, sb, 0.0).normalize());
344            out.push(DVec3::new(sa, 0.0, sb).normalize());
345            out.push(DVec3::new(0.0, sa, sb).normalize());
346        }
347    }
348
349    // 8 corner directions — (±1, ±1, ±1) normalised (1/√3 each).
350    for &sx in &signs {
351        for &sy in &signs {
352            for &sz in &signs {
353                out.push(DVec3::new(sx, sy, sz).normalize());
354            }
355        }
356    }
357
358    debug_assert_eq!(out.len(), 26);
359    out
360}
361
362/// Grid centre + bounding-sphere radius, in grid-local voxel
363/// coordinates. The centre is the AABB midpoint of the populated
364/// chunks (NOT the bounding-sphere centre, which would require a
365/// Welzl pass and isn't worth it for 26 fixed viewpoints).
366///
367/// Empty grid → centre is `(0, 0, 0)` and radius `0.0`. The
368/// snapshot camera still renders to all-sky in this case (no
369/// chunks → opticast skips the dispatch).
370fn grid_local_centre_and_radius(grid: &Grid) -> (DVec3, f64) {
371    if grid.chunks.is_empty() {
372        return (DVec3::ZERO, 0.0);
373    }
374    let mut lo = IVec3::splat(i32::MAX);
375    let mut hi = IVec3::splat(i32::MIN);
376    for &idx in grid.chunks.keys() {
377        lo = lo.min(idx);
378        hi = hi.max(idx);
379    }
380    let sx = f64::from(CHUNK_SIZE_XY);
381    let sz = f64::from(CHUNK_SIZE_Z);
382    let lo_v = DVec3::new(
383        f64::from(lo.x) * sx,
384        f64::from(lo.y) * sx,
385        f64::from(lo.z) * sz,
386    );
387    let hi_v = DVec3::new(
388        f64::from(hi.x + 1) * sx,
389        f64::from(hi.y + 1) * sx,
390        f64::from(hi.z + 1) * sz,
391    );
392    let centre = (lo_v + hi_v) * 0.5;
393    let half_extent = (hi_v - lo_v) * 0.5;
394    let radius = half_extent.length();
395    (centre, radius)
396}
397
398/// Build the snapshot camera for one viewpoint.
399///
400/// Positions the camera at `centre + view_dir * d` in grid-local
401/// space, looking back at the grid centre with a right-handed
402/// basis (`right × down == forward` per voxlap convention).
403fn snapshot_camera(view_dir: DVec3, centre: DVec3, d: f64) -> Camera {
404    let pos = centre + view_dir * d;
405    let forward = -view_dir;
406    // Pick an "up reference" not parallel to forward.
407    //
408    // Voxlap is z-down (positive Z = downward in world space), so
409    // the world's "up" direction is **negative** Z. Using
410    // `DVec3::NEG_Z` here gives `down = forward × right` that
411    // points toward voxlap's +Z (= screen down) — i.e. the
412    // snapshot is rendered right-side-up.
413    //
414    // Pre-2026-05-28 bug: this was `DVec3::Z`, producing
415    // `down = (0,0,-1)` for face viewpoints. The rasterizer
416    // rendered each snapshot upside-down; the blit then stamped
417    // the inverted image at the projected grid centre, making
418    // pillars appear at the wrong vertical position (visually:
419    // "billboards are taller than the voxel model").
420    //
421    // Falls back to +X when the viewpoint is near-±Z (forward
422    // nearly parallel to the up reference).
423    let up_ref = if forward.z.abs() < 0.99 {
424        DVec3::NEG_Z
425    } else {
426        DVec3::X
427    };
428    let right = forward.cross(up_ref).normalize();
429    // down = forward × right gives right × down == forward (voxlap
430    // RH convention). Verified by cross-product handedness.
431    let down = forward.cross(right);
432    Camera {
433        pos: pos.to_array(),
434        right: right.to_array(),
435        down: down.to_array(),
436        forward: forward.to_array(),
437    }
438}
439
440/// Build the [`OpticastSettings`] for one snapshot render.
441///
442/// `mip_levels = 1` keeps mip-0 only — the snapshot resolution is
443/// already coarse, deep mips would over-blur. `mip_scan_dist` is
444/// the floor (4). `max_scan_dist` is sized to cover camera-to-
445/// far-side of the grid plus a 25 % slack for foreshortened rays
446/// past the centre.
447///
448/// Projection: orthographic-ish perspective with `hz = N * D /
449/// (2 * R)`. The image scale at the grid centre lands the
450/// bounding sphere exactly at the framebuffer edges; rays
451/// diverge by `atan(R/D)` across the framebuffer (~7° at the
452/// default `D = 8R`).
453fn snapshot_settings(resolution: u32, d: f64, r: f64, max_scan_dist: i32) -> OpticastSettings {
454    let n = f64::from(resolution);
455    let half_n = (n * 0.5) as f32;
456    // hz = (N * D) / (2 * R). For D = 8R this is 4N — narrow FOV,
457    // near-orthographic. f64 → f32 cast: f32 mantissa handles
458    // values up to ~16M, comfortably above realistic 4N for the
459    // 128×128 default.
460    #[allow(clippy::cast_possible_truncation)]
461    let hz = ((n * d) / (2.0 * r)) as f32;
462    OpticastSettings {
463        xres: resolution,
464        yres: resolution,
465        y_start: 0,
466        y_end: resolution,
467        hx: half_n,
468        hy: half_n,
469        hz,
470        anginc: 1,
471        mip_levels: 1,
472        mip_scan_dist: 4,
473        max_scan_dist,
474    }
475}
476
477/// Blit one [`BillboardSnapshot`] into a `(fb, zb)` pair as a
478/// camera-aligned 2D quad — the S6.3 Far-tier render path.
479///
480/// Projection:
481/// - The grid's world centre projects to a screen pixel via the
482///   runtime camera's basis + `settings.{hx, hy, hz}`. If the
483///   centre is at or behind the camera (`depth <= 0`), this is a
484///   no-op.
485/// - The grid's bounding sphere (`radius` in grid-local voxel
486///   units, identical to world units because rotations preserve
487///   distance) projects to a screen-pixel half-extent
488///   `pixel_radius = radius * hz / depth`. The blit covers the
489///   square `[cx - pixel_radius, cx + pixel_radius]² ×
490///   [cy - pixel_radius, cy + pixel_radius]` around the projected
491///   centre.
492/// - Source-to-destination sampling is nearest-neighbour. The
493///   snapshot's resolution is fixed at build time; under-sampling
494///   when far away (snapshot_pixels >> screen_pixels) is fine for
495///   impostors. Over-sampling when close (screen_pixels >>
496///   snapshot_pixels) causes blocky scaling but Far tier is by
497///   definition past the radius-based threshold so screen_pixels
498///   should stay modest.
499///
500/// Depth: every non-sky destination pixel gets the same `z` value
501/// — the camera-to-grid-centre distance. This treats the impostor
502/// as a flat disk at the grid centre. Adequate for S6.3 minimum-
503/// viable; S6.4 polish can use per-pixel depth from
504/// `snapshot.depth` to recover internal shape.
505///
506/// Sky pixels: detected via `snapshot.depth[..].is_infinite()`.
507/// Skipped entirely (the destination pixel + zbuffer are
508/// untouched), so the underlying sky / other grids show through.
509///
510/// Compose: min-z merge against the existing `zb`. Closer-than-
511/// existing wins. Sky pixels in the snapshot don't even attempt
512/// the merge, so distant grids never wipe out a near grid's
513/// rendered pixel.
514#[allow(clippy::too_many_arguments)]
515pub fn billboard_blit_into(
516    fb: &mut [u32],
517    zb: &mut [f32],
518    pitch_pixels: usize,
519    width: u32,
520    height: u32,
521    snapshot: &BillboardSnapshot,
522    grid_world_centre: DVec3,
523    grid_world_radius: f64,
524    camera: &Camera,
525    settings: &OpticastSettings,
526) {
527    // Camera basis in world space.
528    let cam_pos = DVec3::from_array(camera.pos);
529    let forward = DVec3::from_array(camera.forward);
530    let right = DVec3::from_array(camera.right);
531    let down = DVec3::from_array(camera.down);
532    // Vector from camera to grid centre.
533    let to_centre = grid_world_centre - cam_pos;
534    // Depth along the camera forward axis. Negative = behind
535    // camera; skip (the perspective project would invert sign).
536    let depth = to_centre.dot(forward);
537    if depth <= 0.0 || !depth.is_finite() {
538        return;
539    }
540    // Off-axis offsets along right / down.
541    let x_off = to_centre.dot(right);
542    let y_off = to_centre.dot(down);
543    // Perspective scale factor at the grid centre's depth.
544    let scale = f64::from(settings.hz) / depth;
545    let cx = f64::from(settings.hx) + x_off * scale;
546    let cy = f64::from(settings.hy) + y_off * scale;
547    // Half-extent of the impostor quad in destination pixels.
548    let pixel_radius_f = grid_world_radius * scale;
549    if !pixel_radius_f.is_finite() || pixel_radius_f < 1.0 {
550        // Sub-pixel impostor — invisible at this resolution.
551        return;
552    }
553    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
554    let pixel_radius = pixel_radius_f.ceil() as i32;
555    let dst_size = pixel_radius * 2;
556    if dst_size <= 0 {
557        return;
558    }
559    // Source dims.
560    let src_w = snapshot.width as i32;
561    let src_h = snapshot.height as i32;
562    if src_w <= 0 || src_h <= 0 {
563        return;
564    }
565    // Quad's top-left destination corner. Clamped to screen at
566    // sampling time so partially-off-screen quads still render
567    // their visible portion.
568    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
569    let dst_left = (cx - pixel_radius_f) as i32;
570    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
571    let dst_top = (cy - pixel_radius_f) as i32;
572    // Z value used for every non-sky billboard pixel.
573    #[allow(clippy::cast_possible_truncation)]
574    let z = depth as f32;
575
576    let w_i = width as i32;
577    let h_i = height as i32;
578    for dy in 0..dst_size {
579        let screen_y = dst_top + dy;
580        if screen_y < 0 || screen_y >= h_i {
581            continue;
582        }
583        // Nearest-neighbour source y.
584        let sy = (dy * src_h) / dst_size;
585        let row_src_base = (sy as usize) * (src_w as usize);
586        let row_dst_base = (screen_y as usize) * pitch_pixels;
587        for dx in 0..dst_size {
588            let screen_x = dst_left + dx;
589            if screen_x < 0 || screen_x >= w_i {
590                continue;
591            }
592            let sx = (dx * src_w) / dst_size;
593            let src_idx = row_src_base + sx as usize;
594            // Sky pixels: skip. Two redundant signals — colour
595            // matches [`SKY_SENTINEL`] (opticast's skycast write)
596            // OR depth is infinite (post-build patch + empty-grid
597            // init). Either alone is sufficient; both are checked
598            // so a future refactor of build's init can drop one
599            // without breaking the blit.
600            if snapshot.color[src_idx] == SKY_SENTINEL || snapshot.depth[src_idx].is_infinite() {
601                continue;
602            }
603            let dst_idx = row_dst_base + screen_x as usize;
604            if z < zb[dst_idx] {
605                fb[dst_idx] = snapshot.color[src_idx];
606                zb[dst_idx] = z;
607            }
608        }
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::GridTransform;
616
617    // Sky pixels in built snapshots are tagged with SKY_SENTINEL
618    // (not a caller-supplied colour). The test asserts use this
619    // directly rather than re-exposing a separate constant.
620
621    #[test]
622    fn canonical_viewpoints_has_26() {
623        let v = canonical_viewpoints();
624        assert_eq!(v.len(), 26);
625    }
626
627    #[test]
628    fn canonical_viewpoints_all_unit_length() {
629        for (i, d) in canonical_viewpoints().iter().enumerate() {
630            let len = d.length();
631            assert!(
632                (len - 1.0).abs() < 1e-12,
633                "viewpoint {i}: {d:?} length={len}",
634            );
635        }
636    }
637
638    #[test]
639    fn canonical_viewpoints_all_distinct() {
640        let v = canonical_viewpoints();
641        for i in 0..v.len() {
642            for j in (i + 1)..v.len() {
643                let same = (v[i] - v[j]).length() < 1e-9;
644                assert!(!same, "viewpoint {i} and {j} are equal: {:?}", v[i]);
645            }
646        }
647    }
648
649    #[test]
650    fn canonical_viewpoints_cover_all_octants() {
651        // Among the 8 corner viewpoints, all eight (±1, ±1, ±1) sign
652        // combos should appear. Octant signature = (sign_x, sign_y, sign_z).
653        let mut octants_seen = std::collections::HashSet::new();
654        for v in canonical_viewpoints() {
655            let sig = (
656                v.x.partial_cmp(&0.0).unwrap(),
657                v.y.partial_cmp(&0.0).unwrap(),
658                v.z.partial_cmp(&0.0).unwrap(),
659            );
660            // Only collect strictly-positive-or-strictly-negative axes
661            // (no zeros) — those identify the 8 corner octants.
662            use std::cmp::Ordering::*;
663            if !matches!(sig.0, Equal) && !matches!(sig.1, Equal) && !matches!(sig.2, Equal) {
664                octants_seen.insert(sig);
665            }
666        }
667        assert_eq!(octants_seen.len(), 8);
668    }
669
670    fn build_small_grid() -> Grid {
671        // Single-chunk grid with a recognisable shape — 16-voxel
672        // box at chunk-local (50, 50, 50)..(65, 65, 65). Enough
673        // content that every viewpoint sees non-sky pixels.
674        let mut g = Grid::new(GridTransform::identity());
675        g.set_rect(
676            IVec3::new(40, 40, 40),
677            IVec3::new(80, 80, 80),
678            Some(0x80_22_aa_22),
679        );
680        g
681    }
682
683    #[test]
684    fn build_populates_26_snapshots() {
685        let grid = build_small_grid();
686        let cache = BillboardCache::build(&grid, 32);
687        assert_eq!(cache.resolution, 32);
688        assert_eq!(cache.len(), 26);
689        for (i, snap) in cache.snapshots.iter().enumerate() {
690            assert_eq!(snap.width, 32);
691            assert_eq!(snap.height, 32);
692            assert_eq!(snap.color.len(), 32 * 32);
693            assert_eq!(snap.depth.len(), 32 * 32);
694            // Each snapshot's view_dir must match the canonical
695            // viewpoint at the same index.
696            let expected = canonical_viewpoints()[i];
697            assert!(
698                (snap.view_dir - expected).length() < 1e-12,
699                "snapshot {i} view_dir mismatch",
700            );
701        }
702    }
703
704    #[test]
705    fn build_renders_some_non_sky_pixels_per_viewpoint() {
706        // Every viewpoint should hit the box. We don't pin pixel
707        // counts (mip-0 + 32×32 res + 8R distance produces 5-50
708        // hit pixels depending on viewpoint), just that every
709        // viewpoint produces at least ONE non-sky pixel — i.e.
710        // the snapshot camera correctly framed the grid.
711        let grid = build_small_grid();
712        let cache = BillboardCache::build(&grid, 32);
713        for (i, snap) in cache.snapshots.iter().enumerate() {
714            let non_sky = snap.color.iter().filter(|&&p| p != SKY_SENTINEL).count();
715            assert!(
716                non_sky > 0,
717                "snapshot {i} (view_dir={:?}) rendered all-sky",
718                snap.view_dir,
719            );
720        }
721    }
722
723    #[test]
724    fn build_empty_grid_yields_26_all_sky_snapshots() {
725        let grid = Grid::new(GridTransform::identity());
726        let cache = BillboardCache::build(&grid, 16);
727        assert_eq!(cache.len(), 26);
728        for (i, snap) in cache.snapshots.iter().enumerate() {
729            for &px in &snap.color {
730                assert_eq!(
731                    px, SKY_SENTINEL,
732                    "empty grid snapshot {i} produced non-sky pixel {px:#010x}",
733                );
734            }
735            for &z in &snap.depth {
736                assert!(z.is_infinite(), "empty grid snapshot {i} depth not INF",);
737            }
738        }
739    }
740
741    #[test]
742    fn pick_nearest_returns_face_viewpoint_for_axis_query() {
743        let grid = build_small_grid();
744        let cache = BillboardCache::build(&grid, 16);
745        // Query along +x: nearest viewpoint should be +x.
746        let snap = cache.pick_nearest(DVec3::X).expect("non-empty cache");
747        assert!(
748            (snap.view_dir - DVec3::X).length() < 1e-12,
749            "+x query picked {:?}",
750            snap.view_dir,
751        );
752        // Query along -z: nearest viewpoint should be -z.
753        let snap = cache.pick_nearest(DVec3::NEG_Z).expect("non-empty cache");
754        assert!(
755            (snap.view_dir - DVec3::NEG_Z).length() < 1e-12,
756            "-z query picked {:?}",
757            snap.view_dir,
758        );
759    }
760
761    #[test]
762    fn pick_nearest_routes_oblique_to_a_corner_viewpoint() {
763        // Query along (1, 1, 1) / √3 lands exactly on the (+, +, +)
764        // corner viewpoint; pick_nearest must return that.
765        let grid = build_small_grid();
766        let cache = BillboardCache::build(&grid, 16);
767        let query = DVec3::new(1.0, 1.0, 1.0).normalize();
768        let snap = cache.pick_nearest(query).expect("non-empty cache");
769        assert!(
770            (snap.view_dir - query).length() < 1e-9,
771            "diagonal query picked {:?}",
772            snap.view_dir,
773        );
774    }
775
776    #[test]
777    fn pick_nearest_returns_none_for_empty_cache() {
778        let cache = BillboardCache::new_empty(32);
779        assert!(cache.is_empty());
780        assert!(cache.pick_nearest(DVec3::X).is_none());
781    }
782
783    #[test]
784    fn new_empty_allocates_no_snapshots() {
785        let cache = BillboardCache::new_empty(64);
786        assert_eq!(cache.resolution, 64);
787        assert_eq!(cache.len(), 0);
788        assert!(cache.is_empty());
789    }
790}