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