Skip to main content

roxlap_scene/
render.rs

1//! Scene-level rendering — drives `roxlap_core::opticast::opticast`
2//! across the grids of a [`Scene`].
3//!
4//! Two entry points:
5//!
6//! - [`render_scene_composed`] (recommended for multi-grid scenes):
7//!   per grid, allocates a temporary framebuffer + zbuffer, runs
8//!   opticast into the temp, then merges into the shared output via
9//!   per-pixel min-z. Correctly composites overlapping grid output.
10//! - [`render_scene`] (single-grid trusting caller): writes every
11//!   grid directly into the shared rasterizer. For single-grid
12//!   scenes this matches a direct opticast call byte-for-byte; for
13//!   multi-grid it's last-grid-wins (sky writes from grid B
14//!   overwrite grid A's hits). Useful for tests / single-grid
15//!   sanity checks.
16//!
17//! ## S4B.2.e: Approach B multi-chunk dispatch
18//!
19//! Both APIs route per-grid rendering through
20//! [`crate::Grid::chunk_xy_backing`] → [`roxlap_core::ChunkGrid`] →
21//! [`roxlap_core::GridView::from_chunk_grid`] → `opticast`.
22//! `opticast`'s prelude looks up the camera's chunk via
23//! [`roxlap_core::GridView::chunk_at_xy`]; the grouscan column-step
24//! swaps the active per-chunk `(slab_buf, column_offsets)` when
25//! rays cross a chunk-XY boundary. The combined-world stitch
26//! (Approach C, S4.0..S4.2) is no longer in the render path — the
27//! lighting bake still uses it until S4B.4 lands a per-chunk bake.
28//!
29//! Per-grid rotation (S5) and per-grid LOD (S6) plug in at the
30//! same dispatch point: rotate the world camera into grid-local
31//! before the chunk-grid lookup, then dispatch coarse / fine /
32//! billboard based on grid-camera distance.
33
34// `fb` / `zb` (framebuffer / zbuffer) and the `_fb` / `_zb` suffixes
35// throughout this module are voxlap-canonical pairs — drilling them
36// apart with longer names just hurts readability.
37#![allow(clippy::similar_names)]
38
39use glam::DVec3;
40use roxlap_core::dda::{render_dda_parallel, CpuLights, CpuPointLight, DdaEnv};
41use roxlap_core::opticast::OpticastSettings;
42use roxlap_core::sky::Sky;
43use roxlap_core::Camera;
44use roxlap_formats::color::Rgb;
45use roxlap_formats::material::MaterialTable;
46
47use crate::billboard::{self, BillboardCache, DEFAULT_RESOLUTION as BILLBOARD_RESOLUTION};
48use crate::chunks;
49use crate::lod::Lod;
50use crate::occluder::SceneOccluder;
51use crate::{GridId, GridTransform, Scene, CHUNK_SIZE_XY};
52use roxlap_core::{CompositeOccluder, WorldOccluder, WorldShadowCtx};
53use std::collections::HashMap;
54
55/// Sentinel colour stamped into a `render_sky = false` grid's
56/// temporary framebuffer wherever the rasterizer would have drawn
57/// sky. After opticast, [`render_scene_composed`] walks the temp
58/// buffer and resets `temp_zb` to [`f32::INFINITY`] for any pixel
59/// still carrying this value — those pixels then always lose
60/// [`compose_into`]'s min-z test and the underlying grid's sky
61/// (or another grid's hit) wins.
62///
63/// Alpha byte is `0x00`. Voxlap voxel slabs carry an alpha-encoded
64/// shade in `[0x00, 0x80]`, but a `0x00` alpha **with this exact
65/// RGB pattern** is exceedingly unlikely to occur on a real hit
66/// (the lit-voxel path produces alpha ≥ 0x40 in practice). Bit
67/// pattern is also visually distinct (cyan-ish neon) if anything
68/// ever leaks through to the screen, making the bug obvious.
69const SKY_MASK_SENTINEL: u32 = 0x00_DE_AD_BE;
70
71/// CPU fog + per-face shading config for the DDA backend, passed by
72/// value into the scene render entry points (replaces the old
73/// `&mut ScratchPool` parameter the voxlap path threaded fog through).
74///
75/// `max_scan_dist <= 0` disables fog (no distance blend). Otherwise the
76/// DDA renderer linearly ramps a hit's colour toward [`Self::color`]
77/// over `max_scan_dist` voxels. `side_shades` darkens each of the six
78/// voxel faces — `[x-, x+, y-, y+, z-, z+]`.
79#[derive(Debug, Clone, Copy, Default)]
80pub struct CpuFog {
81    /// Low-24-bit RGB fog colour.
82    pub color: u32,
83    /// Distance (voxels) at which fog is fully opaque; `<= 0` ⇒ fog OFF.
84    pub max_scan_dist: i32,
85    /// Per-face brightness reduction `[x-, x+, y-, y+, z-, z+]`.
86    pub side_shades: [i8; 6],
87}
88
89/// Project a world-space [`Camera`] into a grid's local frame:
90/// translate by `-transform.origin`, then apply
91/// `transform.rotation.inverse()` to the position and the
92/// orthonormal basis (`right` / `down` / `forward`).
93///
94/// Identity rotation collapses to pure translation, byte-identical
95/// to the pre-S5 path (`DQuat::IDENTITY * v == v`). For a rotated
96/// grid the rasterizer still sees an axis-aligned chunk grid —
97/// rotation is invisible below this layer per PORTING-SCENE.md § S5.
98///
99/// The basis is rotated as a free vector (no translation
100/// component); position is rotated about the grid origin.
101fn world_camera_to_grid_local(camera: &Camera, transform: &GridTransform) -> Camera {
102    let inv = transform.rotation.inverse();
103    let world_offset = DVec3::from_array(camera.pos) - transform.origin;
104    let local_pos = inv * world_offset;
105    let local_right = inv * DVec3::from_array(camera.right);
106    let local_down = inv * DVec3::from_array(camera.down);
107    let local_forward = inv * DVec3::from_array(camera.forward);
108    Camera {
109        pos: local_pos.to_array(),
110        right: local_right.to_array(),
111        down: local_down.to_array(),
112        forward: local_forward.to_array(),
113    }
114}
115
116/// CPU.1 — transform world-space dynamic lights into a grid's local frame
117/// (the same translate + inverse-rotation as [`world_camera_to_grid_local`]):
118/// point positions are points (origin-relative + inverse-rotated); the sun
119/// direction is a vector (inverse-rotated only). Point lights land in `scratch`
120/// so the returned [`CpuLights`] can borrow them for the grid's render.
121///
122/// PF.7 (C4) — `grid_sphere` is the grid's world-space bounding sphere
123/// `(centre, radius)`: a light whose reach-sphere can't touch it (with
124/// slack for the shadow-bias sample offset) is dropped BEFORE the
125/// transform, so the per-hit light loop never sees it. Conservative ⇒
126/// byte-identical (a dropped light's `point_falloff` would be 0 at every
127/// reachable sample anyway). `None` skips the cull.
128fn grid_local_lights<'a>(
129    world: &CpuLights<'_>,
130    transform: &GridTransform,
131    scratch: &'a mut Vec<CpuPointLight>,
132    grid_sphere: Option<(DVec3, f64)>,
133) -> CpuLights<'a> {
134    scratch.clear();
135    if !world.enabled {
136        return CpuLights::default();
137    }
138    let inv = transform.rotation.inverse();
139    #[allow(clippy::cast_possible_truncation)]
140    let sun_dir = if world.sun {
141        let d = inv
142            * DVec3::new(
143                f64::from(world.sun_dir[0]),
144                f64::from(world.sun_dir[1]),
145                f64::from(world.sun_dir[2]),
146            );
147        [d.x as f32, d.y as f32, d.z as f32]
148    } else {
149        [0.0; 3]
150    };
151    // Shade samples sit on voxel surfaces inside the bounding sphere,
152    // nudged up to `shadow_bias` along the normal — expand by that plus
153    // a unit of float slack.
154    let cull_slack = f64::from(world.shadow_bias) + 1.0;
155    for p in world.points {
156        if let Some((centre, radius)) = grid_sphere {
157            let lp = DVec3::new(
158                f64::from(p.pos[0]),
159                f64::from(p.pos[1]),
160                f64::from(p.pos[2]),
161            );
162            if (lp - centre).length() > f64::from(p.radius) + radius + cull_slack {
163                continue;
164            }
165        }
166        let lp = inv
167            * (DVec3::new(
168                f64::from(p.pos[0]),
169                f64::from(p.pos[1]),
170                f64::from(p.pos[2]),
171            ) - transform.origin);
172        // SL — the cone axis is a vector: inverse-rotate only (no origin).
173        let sd = inv
174            * DVec3::new(
175                f64::from(p.spot_dir[0]),
176                f64::from(p.spot_dir[1]),
177                f64::from(p.spot_dir[2]),
178            );
179        #[allow(clippy::cast_possible_truncation)]
180        scratch.push(CpuPointLight {
181            pos: [lp.x as f32, lp.y as f32, lp.z as f32],
182            color: p.color,
183            intensity: p.intensity,
184            radius: p.radius,
185            casts_shadow: p.casts_shadow,
186            spot_dir: [sd.x as f32, sd.y as f32, sd.z as f32],
187            cos_inner: p.cos_inner,
188            cos_outer: p.cos_outer,
189        });
190    }
191    CpuLights {
192        enabled: true,
193        sun: world.sun,
194        sun_dir,
195        sun_color: world.sun_color,
196        sun_intensity: world.sun_intensity,
197        sun_casts_shadow: world.sun_casts_shadow,
198        points: scratch.as_slice(),
199        ambient: world.ambient,
200        bands: world.bands,
201        shadow_tint: world.shadow_tint,
202        // CPU.2 — shadows: the rig is world-space here; shadow distances are
203        // grid-uniform (no scaling), so they carry through unchanged.
204        shadow_strength: world.shadow_strength,
205        shadow_bias: world.shadow_bias,
206        shadow_max_dist: world.shadow_max_dist,
207    }
208}
209
210/// Outcome of a [`render_scene`] / [`render_scene_composed`] call.
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum RenderOutcome {
213    /// At least one grid produced a render.
214    Rendered {
215        /// Number of grids that were drawn.
216        grids_drawn: usize,
217    },
218    /// No grid rendered — the scene was empty (no populated grids).
219    Empty,
220}
221
222/// Render every grid in `scene` directly into `(fb, zb)` — no
223/// per-grid temp buffer, no compose merge. For multi-grid scenes
224/// this is last-grid-wins (later grids' opticast writes overwrite
225/// earlier grids' pixels indiscriminately, including sky), so it's
226/// only correct for single-grid scenes.
227///
228/// Use this when you have one grid and want the byte-stable
229/// PR.3: pick the cheapest `GridView` constructor that matches the
230/// grid's chunk layout.
231///
232/// Trivial-single-chunk grids (1 chunk at index `(0, 0, 0)`) bypass
233/// the multi-chunk rasterizer path: `GridView::from_single_vxl`
234/// leaves `chunk_grid = None`, so `phase_after_delete_kept_presync`
235/// takes the cheaper single-chunk branch instead of doing
236/// `chunk_at_xyz` + IVec2-equality + `Option::is_some` per
237/// column-step. Markers / pickups / small ships qualify.
238///
239/// Multi-chunk grids (ground, larger ships) fall through to
240/// `from_chunk_grid` with the supplied `ChunkGrid`.
241fn single_chunk_fast_path<'a>(
242    backing: &'a chunks::ChunkXyBacking<'a>,
243    cg: &'a roxlap_core::ChunkGrid<'a>,
244) -> roxlap_core::GridView<'a> {
245    if backing.chunks_x == 1
246        && backing.chunks_y == 1
247        && backing.chunks_z == 1
248        && backing.origin_chunk_xy == [0, 0]
249        && backing.origin_chunk_z == 0
250    {
251        // chunk_xyz_backing populates each `Vec<Option<GridView>>`
252        // slot via `GridView::from_single_vxl`, which leaves
253        // `chunk_grid = None`. Reuse that directly.
254        if let Some(single) = backing.chunks[0] {
255            return single;
256        }
257    }
258    roxlap_core::GridView::from_chunk_grid(cg, CHUNK_SIZE_XY)
259}
260
261/// matches-direct-opticast property — the test suite uses it as a
262/// sanity check that the combined-world stitch + render harness
263/// doesn't drift vs. a raw `opticast` call.
264///
265/// Caller pre-fills `fb` with the desired sky colour and `zb` with
266/// any value (typically `0.0` matching the per-chunk renderer's
267/// convention or `f32::INFINITY` for compose-friendly init); the
268/// rasterizer overwrites both per pixel that gets a hit.
269#[allow(clippy::too_many_arguments)]
270pub fn render_scene(
271    fb: &mut [u32],
272    zb: &mut [f32],
273    pitch_pixels: usize,
274    width: u32,
275    height: u32,
276    fog: CpuFog,
277    scene: &mut Scene,
278    camera: &Camera,
279    settings: &OpticastSettings,
280    sky: Option<&Sky>,
281) -> RenderOutcome {
282    debug_assert_eq!(fb.len(), zb.len());
283    let pixel_count = (width as usize) * (height as usize);
284    debug_assert_eq!(fb.len(), pixel_count);
285
286    let mut grids_drawn = 0usize;
287    for (_id, grid) in scene.grids_mut() {
288        // S4B.2.e: Approach B render path. World → grid-local
289        // camera transform doesn't need a voxel-offset adjustment
290        // anymore — Approach B's chunks live at their signed
291        // (chx, chy) indices and `chunk_at_xy` handles negative-
292        // index lookups natively.
293        //
294        // S5.0: per-grid arbitrary rotation. The local camera is
295        // built by `world_camera_to_grid_local` — translation +
296        // inverse-rotation of the basis. Identity rotation keeps
297        // this byte-identical to the pre-S5 translate-only form.
298        // DDA.7: refresh the cross-frame brick cache (needs `&mut grid`)
299        // before borrowing the grid immutably for `backing`.
300        let dda_mip = grid.ensure_dda_bricks(0);
301        let Some(backing) = grid.chunk_xyz_backing() else {
302            // Empty grid (no populated chz=0 chunks) — skip.
303            continue;
304        };
305        let local_cam = world_camera_to_grid_local(camera, &grid.transform);
306        let cg = roxlap_core::ChunkGrid {
307            chunks: &backing.chunks,
308            origin_chunk_xy: backing.origin_chunk_xy,
309            origin_chunk_z: backing.origin_chunk_z,
310            chunks_x: backing.chunks_x,
311            chunks_y: backing.chunks_y,
312            chunks_z: backing.chunks_z,
313        };
314        let grid_view = single_chunk_fast_path(&backing, &cg);
315        // DDA backend. The direct path doesn't pre-fill, so seed sky
316        // (black) + far depth here — DDA leaves misses untouched.
317        for px in fb.iter_mut() {
318            *px = 0;
319        }
320        for d in zb.iter_mut() {
321            *d = f32::INFINITY;
322        }
323        let fog_on = fog.max_scan_dist > 0;
324        #[allow(clippy::cast_precision_loss)]
325        let env = DdaEnv {
326            sky,
327            fog_color: if fog_on { fog.color } else { 0 },
328            fog_max_dist: if fog_on {
329                fog.max_scan_dist.max(1) as f32
330            } else {
331                0.0
332            },
333            side_shades: fog.side_shades,
334            // The direct (non-composed) path is opaque-only; terrain
335            // materials flow through render_scene_composed_with_materials.
336            materials: None,
337            terrain_materials: &[],
338            // The direct path is unlit (lighting flows through the composed
339            // path); keep it on the baked-byte shade.
340            lights: CpuLights::default(),
341            world_shadow: None,
342        };
343        render_dda_parallel(
344            &local_cam,
345            settings,
346            grid_view,
347            fb,
348            zb,
349            pitch_pixels,
350            &env,
351            &grid.dda_brick_cache,
352            dda_mip,
353        );
354        grids_drawn += 1;
355    }
356    if grids_drawn == 0 {
357        RenderOutcome::Empty
358    } else {
359        RenderOutcome::Rendered { grids_drawn }
360    }
361}
362
363/// Per-pixel "min-z wins" merge of `(temp_fb, temp_zb)` into
364/// `(shared_fb, shared_zb)`.
365///
366/// Voxlap's z-buffer convention: `z` = perpendicular distance from
367/// camera; **smaller `z` = closer to camera**. This helper picks
368/// the closer pixel per slot. Sky pixels emerge with a large `z`
369/// (`scratch.skycast.dist`, set to `gxmax` or `i32::MAX` per
370/// `phase_startsky`) so they always lose to any hit's finite
371/// distance.
372///
373/// `temp_fb` / `temp_zb` are read-only inputs; both must have the
374/// same length as `shared_fb` / `shared_zb` (debug-asserted).
375pub fn compose_into(
376    shared_fb: &mut [u32],
377    shared_zb: &mut [f32],
378    temp_fb: &[u32],
379    temp_zb: &[f32],
380) {
381    debug_assert_eq!(shared_fb.len(), shared_zb.len());
382    debug_assert_eq!(shared_fb.len(), temp_fb.len());
383    debug_assert_eq!(shared_fb.len(), temp_zb.len());
384    for i in 0..shared_fb.len() {
385        if temp_zb[i] < shared_zb[i] {
386            shared_fb[i] = temp_fb[i];
387            shared_zb[i] = temp_zb[i];
388        }
389    }
390}
391
392/// Half-open screen rectangle `[x0, x1) × [y0, y1)` a grid's
393/// projection is confined to — the scissor [`render_scene_composed`]
394/// uses to render and compose each grid only within its screen
395/// footprint instead of over the whole frame.
396#[derive(Clone, Copy, Debug)]
397struct ScreenRect {
398    x0: u32,
399    x1: u32,
400    y0: u32,
401    y1: u32,
402}
403
404impl ScreenRect {
405    fn is_empty(self) -> bool {
406        self.x0 >= self.x1 || self.y0 >= self.y1
407    }
408}
409
410/// Project a world-space bounding sphere `(centre, radius)` to a
411/// conservative screen rectangle under opticast's pinhole — focal `hz`,
412/// principal point `(hx, hy)`, ray for pixel `(px, py)` being
413/// `(px-hx)·right + (py-hy)·down + hz·forward` (camera_math). Returns:
414///
415/// - `Some(rect)` clamped to the viewport when the sphere is safely in
416///   front of the camera. The rect may be **empty** (sphere off to one
417///   side) → the grid can't appear, so the caller skips it entirely.
418/// - `None` when the camera is inside or near the sphere (forward-depth
419///   `z ≤ radius`), where a finite screen bound is unsafe → the caller
420///   must render the grid full-frame.
421///
422/// Conservative on purpose (never clips a pixel the full render would
423/// touch): the projected radius uses the over-estimate `hz·R/(z−R)`
424/// (exact is `hz·R/√(z²−R²)`) and pads by `anginc + 1`, matching the
425/// projection's `anginc` viewport padding.
426fn project_sphere_to_screen(
427    camera: &Camera,
428    centre: DVec3,
429    radius: f64,
430    settings: &OpticastSettings,
431) -> Option<ScreenRect> {
432    let d = centre - DVec3::from_array(camera.pos);
433    let z = d.dot(DVec3::from_array(camera.forward));
434    if z <= radius {
435        return None; // camera inside / in front of the sphere shell
436    }
437    let x = d.dot(DVec3::from_array(camera.right));
438    let y = d.dot(DVec3::from_array(camera.down));
439    let (hx, hy, hz) = (
440        f64::from(settings.hx),
441        f64::from(settings.hy),
442        f64::from(settings.hz),
443    );
444    let sr = hz * radius / (z - radius); // over-estimated screen radius
445    let sx = hx + x / z * hz;
446    let sy = hy + y / z * hz;
447    let pad = f64::from(settings.anginc) + 1.0;
448    let (xres, yres) = (f64::from(settings.xres), f64::from(settings.yres));
449    let clamp = |v: f64, hi: f64| v.clamp(0.0, hi);
450    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
451    Some(ScreenRect {
452        x0: clamp((sx - sr - pad).floor(), xres) as u32,
453        x1: clamp((sx + sr + pad).ceil(), xres) as u32,
454        y0: clamp((sy - sr - pad).floor(), yres) as u32,
455        y1: clamp((sy + sr + pad).ceil(), yres) as u32,
456    })
457}
458
459/// Fill each `rect` row of a `u32` buffer (row stride `pitch`) with
460/// `val` — the scissored analogue of `slice.fill(val)`.
461fn fill_rect_u32(buf: &mut [u32], pitch: usize, rect: ScreenRect, val: u32) {
462    for y in rect.y0..rect.y1 {
463        let row = y as usize * pitch;
464        buf[row + rect.x0 as usize..row + rect.x1 as usize].fill(val);
465    }
466}
467
468/// Fill each `rect` row of an `f32` buffer (row stride `pitch`) with `val`.
469fn fill_rect_f32(buf: &mut [f32], pitch: usize, rect: ScreenRect, val: f32) {
470    for y in rect.y0..rect.y1 {
471        let row = y as usize * pitch;
472        buf[row + rect.x0 as usize..row + rect.x1 as usize].fill(val);
473    }
474}
475
476/// Min-z compose `temp_*` into `fb`/`zb` over `rect` only — the
477/// scissored analogue of [`compose_into`]. A `temp` pixel wins where its
478/// `z` is strictly smaller than the destination's.
479///
480/// PF.7 (C6) — rayon rows: a memory-bandwidth loop repeated per grid per
481/// frame; rows are disjoint (`par_chunks_mut` of both destinations),
482/// sources read-only. Bit-identical.
483fn compose_rect(
484    fb: &mut [u32],
485    zb: &mut [f32],
486    temp_fb: &[u32],
487    temp_zb: &[f32],
488    pitch: usize,
489    rect: ScreenRect,
490) {
491    use rayon::prelude::*;
492    let (y0, y1) = (rect.y0 as usize, rect.y1 as usize);
493    let (x0, x1) = (rect.x0 as usize, rect.x1 as usize);
494    if y0 >= y1 {
495        return;
496    }
497    // The last row may be short of a full `pitch` when the buffer is
498    // exactly `width*height` — clamp the slice end.
499    let end = (y1 * pitch).min(fb.len());
500    fb[y0 * pitch..end]
501        .par_chunks_mut(pitch)
502        .zip(zb[y0 * pitch..end].par_chunks_mut(pitch))
503        .enumerate()
504        .for_each(|(dy, (frow, zrow))| {
505            let row = (y0 + dy) * pitch;
506            for x in x0..x1 {
507                if temp_zb[row + x] < zrow[x] {
508                    zrow[x] = temp_zb[row + x];
509                    frow[x] = temp_fb[row + x];
510                }
511            }
512        });
513}
514
515/// PF.7 (C6) — reusable scratch for the composed scene render: the
516/// per-grid temp framebuffer/z-buffer pair (was two full-frame `vec!`
517/// allocations + initialising writes per call — ≈7.4 MB at 720p, ×16
518/// under 4×SSAA), the per-grid light scratch, and the phase-A mip map.
519/// Own one per renderer and pass it to
520/// [`render_scene_composed_with_materials_scratch`]; the buffers grow to
521/// the frame size on first use and are reused verbatim afterwards (every
522/// pixel the render reads is filled per grid first, so no per-frame
523/// clear is needed).
524#[derive(Default)]
525pub struct SceneRenderScratch {
526    temp_fb: Vec<u32>,
527    temp_zb: Vec<f32>,
528    lights: Vec<CpuPointLight>,
529    eff_mips: HashMap<GridId, u32>,
530}
531
532/// Render every grid in `scene` with per-grid temporary buffers +
533/// z-buffer composition. The canonical multi-grid scene render
534/// path.
535///
536/// Algorithm:
537/// 1. Caller pre-fills `fb` with the desired sky colour and `zb`
538///    with [`f32::INFINITY`] (so any rendered pixel wins the
539///    initial composition).
540/// 2. For each grid, allocate a temporary `(temp_fb, temp_zb)` of
541///    the same size, pre-fill them with sky / `INFINITY`, and run
542///    `opticast` into them via a `ScalarRasterizer` over the
543///    temporary buffers AND the grid's combined-world view (S4.0).
544/// 3. Merge the temporary buffers into the shared `(fb, zb)` via
545///    [`compose_into`] — closer pixels (smaller `z`) win.
546///
547/// Pixel correctness across overlapping grids: sky pixels emerge
548/// with `z` = `gxmax` / `i32::MAX` (a very large value), so they
549/// always lose to any hit. Hits compete on actual perpendicular
550/// distance — the closer grid's surface is what gets composited.
551///
552/// `pitch_pixels` is the framebuffer's row stride in pixels (×4 for
553/// bytes). `width` × `height` must equal `fb.len()` /
554/// `zb.len()`. `sky` is the optional textured sky resource the
555/// rasterizer threads through to `phase_startsky`; `None` ⇒ solid
556/// `pool.skycast` fill.
557///
558/// **Heap allocation per call:** two `Vec` allocations per grid (a
559/// temp framebuffer and zbuffer). For repeated frame rendering an
560/// owned scratch struct that pre-allocates these is the obvious
561/// optimisation; deferred until profiling shows it matters.
562#[allow(clippy::too_many_arguments)]
563pub fn render_scene_composed(
564    fb: &mut [u32],
565    zb: &mut [f32],
566    pitch_pixels: usize,
567    width: u32,
568    height: u32,
569    fog: CpuFog,
570    scene: &mut Scene,
571    camera: &Camera,
572    settings: &OpticastSettings,
573    sky_color: u32,
574    sky: Option<&Sky>,
575) -> RenderOutcome {
576    render_scene_composed_scissored(
577        fb,
578        zb,
579        pitch_pixels,
580        width,
581        height,
582        fog,
583        scene,
584        camera,
585        settings,
586        sky_color,
587        sky,
588        true,
589        None,
590        &[],
591        CpuLights::default(),
592        None,
593        &mut SceneRenderScratch::default(),
594    )
595}
596
597/// [`render_scene_composed`] with TV terrain materials: `materials` is the
598/// global palette and `terrain_materials` the colour→material map; together
599/// they make matching-colour terrain voxels translucent (front-to-back
600/// composited). An empty map / `None` palette renders identically to
601/// [`render_scene_composed`].
602#[allow(clippy::too_many_arguments)]
603pub fn render_scene_composed_with_materials(
604    fb: &mut [u32],
605    zb: &mut [f32],
606    pitch_pixels: usize,
607    width: u32,
608    height: u32,
609    fog: CpuFog,
610    scene: &mut Scene,
611    camera: &Camera,
612    settings: &OpticastSettings,
613    sky_color: u32,
614    sky: Option<&Sky>,
615    materials: Option<&MaterialTable>,
616    terrain_materials: &[(Rgb, u8)],
617    lights: CpuLights<'_>,
618    // XS.2 — sprite-cast shadow occluder (so sprites darken terrain). `None` ⇒
619    // grids-only shadows.
620    sprite_occluder: Option<&dyn WorldOccluder>,
621) -> RenderOutcome {
622    render_scene_composed_scissored(
623        fb,
624        zb,
625        pitch_pixels,
626        width,
627        height,
628        fog,
629        scene,
630        camera,
631        settings,
632        sky_color,
633        sky,
634        true,
635        materials,
636        terrain_materials,
637        lights,
638        sprite_occluder,
639        &mut SceneRenderScratch::default(),
640    )
641}
642
643/// [`render_scene_composed_with_materials`] with a caller-owned
644/// [`SceneRenderScratch`] (PF.7) — the per-frame render path: the temp
645/// buffer pair and per-grid scratch are reused across frames instead of
646/// re-allocated per call.
647#[allow(clippy::too_many_arguments)]
648pub fn render_scene_composed_with_materials_scratch(
649    fb: &mut [u32],
650    zb: &mut [f32],
651    pitch_pixels: usize,
652    width: u32,
653    height: u32,
654    fog: CpuFog,
655    scene: &mut Scene,
656    camera: &Camera,
657    settings: &OpticastSettings,
658    sky_color: u32,
659    sky: Option<&Sky>,
660    materials: Option<&MaterialTable>,
661    terrain_materials: &[(Rgb, u8)],
662    lights: CpuLights<'_>,
663    sprite_occluder: Option<&dyn WorldOccluder>,
664    scratch: &mut SceneRenderScratch,
665) -> RenderOutcome {
666    render_scene_composed_scissored(
667        fb,
668        zb,
669        pitch_pixels,
670        width,
671        height,
672        fog,
673        scene,
674        camera,
675        settings,
676        sky_color,
677        sky,
678        true,
679        materials,
680        terrain_materials,
681        lights,
682        sprite_occluder,
683        scratch,
684    )
685}
686
687/// Backing implementation of [`render_scene_composed`] with the
688/// per-grid screen-AABB scissor toggleable. `scissor = true` is the
689/// production path; the regression test renders the same scene with
690/// `false` (full-frame per grid, the pre-scissor behaviour) and asserts
691/// the framebuffer is byte-identical — the scissor must be a pure
692/// speed-up, never change a pixel.
693#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
694fn render_scene_composed_scissored(
695    fb: &mut [u32],
696    zb: &mut [f32],
697    pitch_pixels: usize,
698    width: u32,
699    height: u32,
700    fog: CpuFog,
701    scene: &mut Scene,
702    camera: &Camera,
703    settings: &OpticastSettings,
704    sky_color: u32,
705    sky: Option<&Sky>,
706    scissor: bool,
707    materials: Option<&MaterialTable>,
708    terrain_materials: &[(Rgb, u8)],
709    // CPU.1 — world-space dynamic lights, transformed per grid in the loop.
710    lights: CpuLights<'_>,
711    // XS.2 — world-space occluder for sprite volumes (so sprites cast shadows
712    // onto terrain). Composited with the per-frame grid occluder. `None` ⇒
713    // grids only.
714    sprite_occluder: Option<&dyn WorldOccluder>,
715    // PF.7 — caller-owned reusable buffers (see [`SceneRenderScratch`]).
716    scratch: &mut SceneRenderScratch,
717) -> RenderOutcome {
718    debug_assert_eq!(fb.len(), zb.len());
719    let pixel_count = (width as usize) * (height as usize);
720    debug_assert_eq!(fb.len(), pixel_count);
721
722    let mut grids_drawn = 0usize;
723    // PF.7 (C6) — size (don't clear) the temp pair: every pixel the
724    // render reads inside a grid's rect is `fill_rect_*`-initialised for
725    // that grid first, and `compose_rect` reads only within the rect, so
726    // stale contents outside are never observed. This removes two
727    // full-frame allocations AND their initialising writes per call.
728    let scratch = &mut *scratch;
729    scratch.temp_fb.resize(pixel_count, 0);
730    scratch.temp_zb.resize(pixel_count, f32::INFINITY);
731    let temp_fb = &mut scratch.temp_fb[..pixel_count];
732    let temp_zb = &mut scratch.temp_zb[..pixel_count];
733
734    // XS.1 — phase A (`&mut`): materialise the per-frame caches the render
735    // reads — DDA brick caches (Near/Mid) and Far-tier billboard impostors —
736    // and record each grid's effective DDA mip. Hoisting these out of the
737    // render loop lets phase B run over `&Scene` immutably, so the cross-grid
738    // shadow occluder (which also borrows the scene) can coexist with it.
739    let cam_world = DVec3::from_array(camera.pos);
740    let eff_mips = &mut scratch.eff_mips;
741    eff_mips.clear();
742    for (id, grid) in scene.grids_mut() {
743        let lod = grid.select_lod(cam_world);
744        if lod == Lod::Far {
745            if !grid.chunks.is_empty() && grid.billboards.is_none() {
746                let cache = BillboardCache::build(grid, BILLBOARD_RESOLUTION);
747                grid.billboards = Some(cache);
748            }
749            continue; // Far blits an impostor; no brick cache / mip needed.
750        }
751        let req = match lod {
752            Lod::Mid => grid
753                .lod_thresholds
754                .mid_mip_levels
755                .map_or(0, |n| n.saturating_sub(1)),
756            Lod::Near | Lod::Far => 0,
757        };
758        eff_mips.insert(id, grid.ensure_dda_bricks(req));
759    }
760
761    // Reborrow immutably for phase B + the shadow occluder.
762    let scene: &Scene = scene;
763
764    // XS.1 — cross-grid hard shadows: build the world-space scene occluder
765    // once when shadows are actually active (a caster flagged + non-zero
766    // strength), so the shadow ray at a terrain hit tests every grid, not
767    // just the one it hit. `None` ⇒ the single-grid `SamplerShadow` path.
768    let shadows_on = lights.enabled
769        && lights.shadow_strength > 0.0
770        && (lights.sun_casts_shadow || lights.points.iter().any(|p| p.casts_shadow));
771    let grid_occ = shadows_on
772        .then(|| SceneOccluder::build(scene))
773        .filter(|o| !o.is_empty());
774    // XS.2 — combine the grid occluder with the sprite occluder (sprites cast
775    // onto terrain). `composite_store` backs the borrow when both are present.
776    let composite_store;
777    let active_occluder: Option<&dyn WorldOccluder> = if shadows_on {
778        match (grid_occ.as_ref(), sprite_occluder) {
779            (Some(g), Some(s)) => {
780                composite_store = CompositeOccluder { a: g, b: s };
781                Some(&composite_store)
782            }
783            (Some(g), None) => Some(g),
784            (None, Some(s)) => Some(s),
785            (None, None) => None,
786        }
787    } else {
788        None
789    };
790
791    for (grid_id, grid) in scene.grids() {
792        // S6.0/S6.1: per-grid LOD tier dispatch. The picker keys
793        // off the grid's `lod_thresholds` and the world-space
794        // camera. Default thresholds are `always_near` so every
795        // grid lands on `Lod::Near` and the framebuffer stays
796        // byte-identical to the pre-S6 path.
797        //
798        // S6.1: `Mid` applies the grid's `mid_mip_levels` /
799        // `mid_mip_scan_dist` overrides (if `Some`) on top of the
800        // base settings, biasing the grid into coarser mips. With
801        // both `None`, Mid renders identically to Near (graceful
802        // degrade — callers opt into the Mid plumbing via
803        // `LodThresholds::from_radius_with_mid_mip`).
804        //
805        // S6.3: `Far` skips the opticast path entirely — render
806        // dispatches into the billboard impostor blit (below). The
807        // LOD enum is computed before `chunk_xyz_backing` because
808        // the Far branch needs `&mut grid` for the lazy cache
809        // populate, which conflicts with the `&grid` lifetime
810        // backing's tied to.
811        let lod = grid.select_lod(DVec3::from_array(camera.pos));
812
813        if lod == Lod::Far {
814            // S6.3: Far-tier billboard blit. The impostor cache was built in
815            // phase A (above); this immutable pass only reads it.
816            //
817            // Empty grids have nothing to impostor; skip.
818            if grid.chunks.is_empty() {
819                continue;
820            }
821            // Grid bounds + world-space centre. Rotation preserves
822            // length, so `bounds.radius` is the world-space radius.
823            let bounds = billboard::grid_bounds(grid);
824            let centre_world = grid.transform.origin + grid.transform.rotation * bounds.centre;
825            // Query direction = unit vector from grid centre TO
826            // camera, in grid-local space (snapshots' `view_dir`s
827            // live in that frame).
828            let cam_pos = DVec3::from_array(camera.pos);
829            let centre_to_cam_world = cam_pos - centre_world;
830            let ctc_len = centre_to_cam_world.length();
831            if !ctc_len.is_finite() || ctc_len < 1e-9 {
832                // Camera essentially at grid centre — pick_nearest
833                // is ill-defined. Skip; a future frame at a
834                // resolvable pose will render normally.
835                continue;
836            }
837            let query_dir_world = centre_to_cam_world / ctc_len;
838            let query_dir_local = grid.transform.rotation.inverse() * query_dir_world;
839            // Cache was populated in phase A for non-empty Far grids; if it's
840            // somehow absent, skip (a future frame re-enters Far and builds).
841            let Some(cache) = grid.billboards.as_ref() else {
842                continue;
843            };
844            // pick_nearest only returns None for empty caches; the phase-A
845            // build produced a 26-snapshot cache so this resolves.
846            let Some(snapshot) = cache.pick_nearest(query_dir_local) else {
847                continue;
848            };
849            billboard::billboard_blit_into(
850                fb,
851                zb,
852                pitch_pixels,
853                width,
854                height,
855                snapshot,
856                centre_world,
857                bounds.radius,
858                camera,
859                settings,
860            );
861            grids_drawn += 1;
862            continue;
863        }
864
865        // S4B.2.e: Approach B render path. See `render_scene`'s
866        // body for the camera transform + ChunkGrid construction
867        // commentary; the only difference is this writes to
868        // (temp_fb, temp_zb) and composes via `compose_into`.
869        // S5.0: per-grid rotation flows via the shared helper.
870        //
871        // DDA.7: refresh the cross-frame brick cache (needs `&mut grid`)
872        // before the immutable `backing` borrow. Render mip by LOD tier:
873        // Near = full detail, Mid = coarser (clamped to built mips).
874        // Mid tier: coarsen by the grid's `mid_mip_levels` override
875        // (a level count → uniform DDA mip `n-1`). No override ⇒ mip
876        // 0, i.e. byte-identical to Near (the override is opt-in).
877        // Effective DDA mip: the brick cache was ensured in phase A; reuse the
878        // mip it resolved (Near/Mid grids are recorded; default 0 otherwise).
879        let dda_eff_mip = eff_mips.get(&grid_id).copied().unwrap_or(0);
880        let Some(backing) = grid.chunk_xyz_backing() else {
881            continue;
882        };
883
884        // Out-of-range early-out: skip the per-grid opticast pass
885        // when the grid's bounding sphere is entirely beyond
886        // `max_scan_dist`. Each opticast call walks ~width*height
887        // rays even when no ray reaches a voxel, so far-away marker
888        // pillars / pickups otherwise cost ~9 ms each at the bench
889        // pose. Safe: if the closest point of the sphere is past
890        // max_scan_dist, no ray can possibly reach the grid, so
891        // dropping the opticast pass is byte-identical.
892        //
893        // `grid_bounds` walks `grid.chunks.keys()`; for the ground's
894        // ~1024 chunks it costs ~10 µs amortised against the ~50 ms
895        // it might save by culling 4-of-5 markers in the live demo.
896        let bounds = billboard::grid_bounds(grid);
897        let centre_world = grid.transform.origin + grid.transform.rotation * bounds.centre;
898        let cam_pos = DVec3::from_array(camera.pos);
899        let dist_to_centre = (centre_world - cam_pos).length();
900        if dist_to_centre - bounds.radius > f64::from(settings.max_scan_dist) {
901            continue;
902        }
903
904        // Per-grid screen-space scissor: confine this grid's opticast +
905        // temp reset + compose to the true screen rect its projection
906        // spans, and skip the grid entirely when it projects fully
907        // off-screen on either axis. `project_sphere_to_screen` is
908        // conservative (over-estimates the footprint), so the rendered
909        // pixels stay byte-identical to the full-frame path — only the
910        // work shrinks.
911        //
912        // PF.13 (C7) — the horizontal extent now clips the render too.
913        // The historical full-width-only constraint guarded the deleted
914        // voxlap radar's column-indexed `angstart` (never reset per
915        // grid, so x-clipping read stale entries at extreme poses); the
916        // DDA renderer's pixels are fully independent, so the x band is
917        // as safe as the long-proven y band. Small grids (markers,
918        // pickups, ships) stop paying full-width rows of render + fill
919        // + compose. `None` (camera inside/near the sphere) renders
920        // full-frame; `scissor = false` disables it all for the
921        // byte-identity regression test.
922        let full_rect = ScreenRect {
923            x0: 0,
924            x1: width,
925            y0: 0,
926            y1: height,
927        };
928        let rect = if scissor {
929            match project_sphere_to_screen(camera, centre_world, bounds.radius, settings) {
930                // Off-screen on either axis → the grid can't appear.
931                Some(r) if r.is_empty() => continue,
932                Some(r) => r,
933                None => full_rect,
934            }
935        } else {
936            full_rect
937        };
938
939        // S5.2-followup: per-grid sky opt-out. Grids with
940        // `render_sky = false` (e.g. a rotating ship) must not
941        // contribute sky pixels — the grid-local sky lookup
942        // rotates with the grid and visibly fights the world's
943        // sky during compose. Implementation: stamp a sentinel
944        // colour into temp_fb everywhere the rasterizer would
945        // paint sky, then walk the buffer post-opticast and
946        // mark sentinel pixels as `INFINITY` in temp_zb so
947        // [`compose_into`]'s min-z test always drops them.
948        let owns_sky = grid.render_sky;
949        let local_sky_color = if owns_sky {
950            sky_color
951        } else {
952            SKY_MASK_SENTINEL
953        };
954
955        // Reset temp to sky / INFINITY so each grid starts fresh —
956        // only within the grid's screen rect (opticast writes nothing
957        // outside it, and the rect-limited compose reads nothing there).
958        fill_rect_u32(temp_fb, pitch_pixels, rect, local_sky_color);
959        fill_rect_f32(temp_zb, pitch_pixels, rect, f32::INFINITY);
960
961        let local_cam = world_camera_to_grid_local(camera, &grid.transform);
962        let cg = roxlap_core::ChunkGrid {
963            chunks: &backing.chunks,
964            origin_chunk_xy: backing.origin_chunk_xy,
965            origin_chunk_z: backing.origin_chunk_z,
966            chunks_x: backing.chunks_x,
967            chunks_y: backing.chunks_y,
968            chunks_z: backing.chunks_z,
969        };
970        let grid_view = single_chunk_fast_path(&backing, &cg);
971
972        // Build the per-grid settings by layering three opt-in
973        // overrides on top of the caller's `settings`:
974        //
975        //   1. (S6.1) `lod_thresholds.mid_mip_levels` /
976        //      `mid_mip_scan_dist` — applied iff `lod == Mid`.
977        //      Biases the grid into coarser mips via the existing
978        //      multi-mip path. None ⇒ Mid degrades to Near's
979        //      settings (graceful).
980        //   2. (S5.2-followup) `Grid::mip_levels_override` — global
981        //      per-grid cap applied at ALL tiers. Preserves the
982        //      ship anti-axis-aligned-beam workaround through Mid
983        //      tier (so a rotating ship pinned at mip-0 stays at
984        //      mip-0 even when distant).
985        //
986        // Layer order: Mid overrides first, then global cap. Both
987        // mip_levels overrides are clamped to `[1, base.mip_levels]`
988        // since the base is the maximum the renderer can use
989        // (chunk's `chunk_mips`-min logic inside scalar_rasterizer
990        // applies further per-chunk).
991        let per_grid_settings;
992        let active_settings = {
993            let base_mip_levels = settings.mip_levels;
994            let base_mip_scan = settings.mip_scan_dist;
995            let lod_mip_levels = match lod {
996                Lod::Mid => grid.lod_thresholds.mid_mip_levels,
997                Lod::Near | Lod::Far => None,
998            };
999            let lod_mip_scan = match lod {
1000                Lod::Mid => grid.lod_thresholds.mid_mip_scan_dist,
1001                Lod::Near | Lod::Far => None,
1002            };
1003            let global_mip_cap = grid.mip_levels_override;
1004            let needs_override =
1005                lod_mip_levels.is_some() || lod_mip_scan.is_some() || global_mip_cap.is_some();
1006            if needs_override {
1007                // Resolve mip_levels: start with base, apply LOD
1008                // override (clamped to base), then apply global cap.
1009                let mut mip_levels =
1010                    lod_mip_levels.map_or(base_mip_levels, |n| n.clamp(1, base_mip_levels));
1011                if let Some(cap) = global_mip_cap {
1012                    mip_levels = mip_levels.min(cap.clamp(1, base_mip_levels));
1013                }
1014                // Resolve mip_scan_dist: LOD override clamps to
1015                // `min(base, override)` — the override only makes
1016                // transitions kick in CLOSER, never farther. The
1017                // renderer floors at 4 internally so we don't
1018                // bottom-clamp here.
1019                let mip_scan_dist = lod_mip_scan.map_or(base_mip_scan, |d| base_mip_scan.min(d));
1020                per_grid_settings = OpticastSettings {
1021                    mip_levels,
1022                    mip_scan_dist,
1023                    ..*settings
1024                };
1025                &per_grid_settings
1026            } else {
1027                settings
1028            }
1029        };
1030
1031        // PF.13 (C7) — 2D scissor: restrict the render to the grid's
1032        // true screen rect. The y strip is the long-proven path; the x
1033        // band joins it now that the radar-era `angstart` fragility is
1034        // gone (see the rect computation above). Byte-identical to the
1035        // full frame when the rect is `0..width × 0..height`.
1036        let scissored = (*active_settings)
1037            .with_y_range(rect.y0, rect.y1)
1038            .with_x_range(rect.x0, rect.x1);
1039        // DDA backend. temp_fb / temp_zb are already pre-filled with
1040        // sky / INFINITY for this grid's rect, so a miss with no
1041        // textured sky yields the correct solid sky.
1042        //
1043        // Fog is config-driven: on iff the caller set `max_scan_dist > 0`
1044        // in `fog`. Off → no blend, so exact-colour tests and unfogged
1045        // hosts are unaffected. Linear ramp toward the configured fog
1046        // colour over `max_scan_dist`. Sky texture is suppressed for
1047        // `!owns_sky` grids so the textured-sky branch doesn't bypass
1048        // the sentinel.
1049        let fog_on = fog.max_scan_dist > 0;
1050        // CPU.1 — transform the world lights into this grid's local frame
1051        // (the reused point scratch lives for the grid's render below).
1052        // PF.7 — lights that can't reach the grid's bounding sphere are
1053        // culled (`bounds`/`centre_world` computed for the distance cull
1054        // above).
1055        let local_lights = grid_local_lights(
1056            &lights,
1057            &grid.transform,
1058            &mut scratch.lights,
1059            Some((centre_world, bounds.radius)),
1060        );
1061        // XS.1 — cross-grid shadows: hand the shade the scene-wide occluder
1062        // plus this grid's local→world transform, so a grid-local shadow ray
1063        // is lifted to world space and tested against every grid. `cols[i]`
1064        // is the world image of grid-local axis `i` (the rotation's columns).
1065        let world_shadow = active_occluder.map(|occ| {
1066            let r = grid.transform.rotation;
1067            let col = |v: DVec3| {
1068                let w = r * v;
1069                [w.x as f32, w.y as f32, w.z as f32]
1070            };
1071            let o = grid.transform.origin;
1072            WorldShadowCtx {
1073                occluder: occ,
1074                origin: [o.x as f32, o.y as f32, o.z as f32],
1075                cols: [col(DVec3::X), col(DVec3::Y), col(DVec3::Z)],
1076            }
1077        });
1078        #[allow(clippy::cast_precision_loss)]
1079        let env = DdaEnv {
1080            sky: if owns_sky { sky } else { None },
1081            fog_color: if fog_on { fog.color } else { 0 },
1082            fog_max_dist: if fog_on {
1083                fog.max_scan_dist.max(1) as f32
1084            } else {
1085                0.0
1086            },
1087            side_shades: fog.side_shades,
1088            materials,
1089            terrain_materials,
1090            lights: local_lights,
1091            world_shadow,
1092        };
1093        // Effective render mip + brick cache were prepared above
1094        // (DDA.6 uniform per-grid mip, DDA.7 cross-frame cache).
1095        render_dda_parallel(
1096            &local_cam,
1097            &scissored,
1098            grid_view,
1099            temp_fb,
1100            temp_zb,
1101            pitch_pixels,
1102            &env,
1103            &grid.dda_brick_cache,
1104            dda_eff_mip,
1105        );
1106
1107        if !owns_sky {
1108            // Mask sentinel pixels so compose drops them — only within
1109            // the grid's rect (opticast wrote nothing outside it).
1110            for y in rect.y0..rect.y1 {
1111                let row = y as usize * pitch_pixels;
1112                for i in row + rect.x0 as usize..row + rect.x1 as usize {
1113                    if temp_fb[i] == SKY_MASK_SENTINEL {
1114                        temp_zb[i] = f32::INFINITY;
1115                    }
1116                }
1117            }
1118        }
1119
1120        compose_rect(fb, zb, temp_fb, temp_zb, pitch_pixels, rect);
1121        grids_drawn += 1;
1122    }
1123
1124    if grids_drawn == 0 {
1125        RenderOutcome::Empty
1126    } else {
1127        RenderOutcome::Rendered { grids_drawn }
1128    }
1129}
1130
1131#[cfg(test)]
1132#[allow(clippy::float_cmp)]
1133mod tests {
1134    use super::*;
1135    use crate::{GridTransform, Scene, CHUNK_SIZE_XY};
1136    use glam::{DVec3, IVec3};
1137    use roxlap_core::opticast::OpticastSettings;
1138    use roxlap_core::{Camera, Engine};
1139    use roxlap_formats::color::VoxColor;
1140
1141    const XRES: u32 = 320;
1142    const YRES: u32 = 200;
1143
1144    /// Build a single-grid scene at the given world origin with a
1145    /// recognisable shape inside its chunk (0, 0, 0): a 16-voxel
1146    /// box plus a 6-radius sphere. Returns `(scene, grid_id)`.
1147    fn build_one_grid_scene(world_origin: DVec3) -> (Scene, crate::GridId) {
1148        let mut scene = Scene::new();
1149        let id = scene.add_grid(GridTransform::at(world_origin));
1150        let grid = scene.grid_mut(id).unwrap();
1151        // Box covering [40..56]³ in chunk-local coords.
1152        grid.set_rect(
1153            IVec3::new(40, 40, 40),
1154            IVec3::new(55, 55, 55),
1155            Some(VoxColor(0x80_88_88_88)),
1156        );
1157        // Sphere at (80, 80, 80) radius 6.
1158        grid.set_sphere(IVec3::new(80, 80, 80), 6, Some(VoxColor(0x80_22_aa_22)));
1159        (scene, id)
1160    }
1161
1162    fn camera_at(pos: [f64; 3]) -> Camera {
1163        // Look +y axis; voxlap z-down convention. Right-handed:
1164        // right × down == forward.
1165        Camera {
1166            pos,
1167            right: [-1.0, 0.0, 0.0],
1168            down: [0.0, 0.0, 1.0],
1169            forward: [0.0, 1.0, 0.0],
1170        }
1171    }
1172
1173    /// Spin up an engine + framebuffers ready for one `render_scene`
1174    /// pass. `_pool_vsid` is retained for call-site compatibility but
1175    /// the DDA backend needs no pre-sized scratch pool.
1176    fn render_setup(_pool_vsid: u32) -> (Engine, Vec<u32>, Vec<f32>) {
1177        let engine = Engine::new();
1178        let sky = engine.sky_color();
1179        let pixel_count = (XRES as usize) * (YRES as usize);
1180        let framebuffer = vec![sky; pixel_count];
1181        let zbuffer = vec![0.0f32; pixel_count];
1182        (engine, framebuffer, zbuffer)
1183    }
1184
1185    /// Render `scene` via [`render_scene`] (single-grid no-compose
1186    /// path) and return the resulting framebuffer.
1187    fn render_via_scene(scene: &mut Scene, camera: &Camera) -> Vec<u32> {
1188        let (_engine, mut fb, mut zb) = render_setup(CHUNK_SIZE_XY);
1189        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1190        let outcome = render_scene(
1191            &mut fb,
1192            &mut zb,
1193            XRES as usize,
1194            XRES,
1195            YRES,
1196            CpuFog::default(),
1197            scene,
1198            camera,
1199            &settings,
1200            None,
1201        );
1202        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1203        fb
1204    }
1205
1206    /// XS.1 — cross-grid hard shadows: a block in grid **B** casts a sun
1207    /// shadow onto the floor of grid **A**. Renders the two-grid scene with
1208    /// the sun shadow-casting vs not; the shadow only exists if the shadow
1209    /// ray from A's floor crossed into B, so the shadowed render must be
1210    /// strictly (and non-trivially) darker.
1211    #[test]
1212    fn cross_grid_sun_shadow_darkens_other_grid() {
1213        // Grid A: a wide floor at world z∈[60,62]. Grid B (same origin): a
1214        // 10-tall block at x∈[50,60]. Sun grazes from +x and above, so B's
1215        // shadow lands on A's floor at x≈[40,50] — visible to a straight-down
1216        // camera (B itself occludes only x∈[50,60]).
1217        let mut scene = Scene::new();
1218        let a = scene.add_grid(GridTransform::at(DVec3::ZERO));
1219        scene.grid_mut(a).unwrap().set_rect(
1220            IVec3::new(30, 30, 60),
1221            IVec3::new(90, 90, 62),
1222            Some(VoxColor(0x80_88_88_88)),
1223        );
1224        let b = scene.add_grid(GridTransform::at(DVec3::ZERO));
1225        scene.grid_mut(b).unwrap().set_rect(
1226            IVec3::new(50, 50, 40),
1227            IVec3::new(60, 60, 50),
1228            Some(VoxColor(0x80_60_60_60)),
1229        );
1230
1231        // Straight-down camera over the floor (voxlap z-down ⇒ forward +z).
1232        let cam = Camera {
1233            pos: [55.0, 55.0, 6.0],
1234            right: [1.0, 0.0, 0.0],
1235            down: [0.0, 1.0, 0.0],
1236            forward: [0.0, 0.0, 1.0],
1237        };
1238        let inv = 1.0f32 / 2.0f32.sqrt();
1239        let base = CpuLights {
1240            enabled: true,
1241            sun: true,
1242            sun_dir: [inv, 0.0, -inv], // to-sun: +x and up
1243            sun_color: [1.0; 3],
1244            sun_intensity: 1.0,
1245            ambient: [0.3; 3],
1246            shadow_strength: 0.85,
1247            shadow_bias: 1.5,
1248            shadow_max_dist: 128.0,
1249            ..CpuLights::default()
1250        };
1251        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1252        let mut sum_lum = |lights: CpuLights| -> u64 {
1253            let n = (XRES as usize) * (YRES as usize);
1254            let mut fb = vec![0u32; n];
1255            let mut zb = vec![f32::INFINITY; n];
1256            render_scene_composed_scissored(
1257                &mut fb,
1258                &mut zb,
1259                XRES as usize,
1260                XRES,
1261                YRES,
1262                CpuFog::default(),
1263                &mut scene,
1264                &cam,
1265                &settings,
1266                0x0011_2233,
1267                None,
1268                false,
1269                None,
1270                &[],
1271                lights,
1272                None,
1273                &mut SceneRenderScratch::default(),
1274            );
1275            fb.iter()
1276                .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
1277                .sum()
1278        };
1279        let lit = sum_lum(CpuLights {
1280            sun_casts_shadow: false,
1281            ..base
1282        });
1283        let shadowed = sum_lum(CpuLights {
1284            sun_casts_shadow: true,
1285            ..base
1286        });
1287        assert!(
1288            shadowed < lit,
1289            "B's shadow must darken A's floor: shadowed={shadowed} lit={lit}"
1290        );
1291        assert!(
1292            (lit - shadowed) * 200 > lit,
1293            "cross-grid shadow should remove >0.5% of total luminance: lit={lit} shadowed={shadowed}"
1294        );
1295    }
1296
1297    // ---- S5.0: world_camera_to_grid_local helper ----
1298
1299    /// Identity rotation: pos translates by `-origin`; basis is
1300    /// untouched. This is the byte-identical-to-pre-S5 contract.
1301    #[test]
1302    fn world_camera_to_grid_local_identity_rotation_translates_pos_only() {
1303        let camera = Camera {
1304            pos: [110.0, 220.0, 330.0],
1305            right: [1.0, 0.0, 0.0],
1306            down: [0.0, 0.0, 1.0],
1307            forward: [0.0, 1.0, 0.0],
1308        };
1309        let transform = GridTransform::at(DVec3::new(100.0, 200.0, 300.0));
1310        let local = super::world_camera_to_grid_local(&camera, &transform);
1311        // Basis must be bit-for-bit unchanged for the identity case.
1312        assert_eq!(local.right, camera.right);
1313        assert_eq!(local.down, camera.down);
1314        assert_eq!(local.forward, camera.forward);
1315        // Pos translates by `-origin`.
1316        for (got, want) in local.pos.iter().zip([10.0, 20.0, 30.0].iter()) {
1317            assert!((got - want).abs() < 1e-12, "pos got={got} want={want}");
1318        }
1319    }
1320
1321    /// 90° rotation about +Z: grid-local `+x` aligns with world `+y`.
1322    /// World camera at `(0, 10, 0)` looking world `+y` lives in
1323    /// grid-local at `(10, 0, 0)` looking grid-local `+x`.
1324    #[test]
1325    fn world_camera_to_grid_local_90deg_z_rotates_basis_and_pos() {
1326        use glam::DQuat;
1327        let camera = Camera {
1328            pos: [0.0, 10.0, 0.0],
1329            right: [1.0, 0.0, 0.0],
1330            down: [0.0, 0.0, 1.0],
1331            forward: [0.0, 1.0, 0.0],
1332        };
1333        let transform = GridTransform {
1334            origin: DVec3::ZERO,
1335            rotation: DQuat::from_rotation_z(std::f64::consts::FRAC_PI_2),
1336        };
1337        let local = super::world_camera_to_grid_local(&camera, &transform);
1338        // World +y == grid-local +x.
1339        let approx_eq =
1340            |a: [f64; 3], b: [f64; 3]| a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-9);
1341        assert!(
1342            approx_eq(local.pos, [10.0, 0.0, 0.0]),
1343            "pos={:?} expected ~(10, 0, 0)",
1344            local.pos
1345        );
1346        // World +x (right) maps to grid-local -y.
1347        assert!(
1348            approx_eq(local.right, [0.0, -1.0, 0.0]),
1349            "right={:?} expected ~(0, -1, 0)",
1350            local.right
1351        );
1352        // World +z (down) is unchanged — it's the rotation axis.
1353        assert!(
1354            approx_eq(local.down, [0.0, 0.0, 1.0]),
1355            "down={:?} expected ~(0, 0, 1)",
1356            local.down
1357        );
1358        // World +y (forward) maps to grid-local +x.
1359        assert!(
1360            approx_eq(local.forward, [1.0, 0.0, 0.0]),
1361            "forward={:?} expected ~(1, 0, 0)",
1362            local.forward
1363        );
1364    }
1365
1366    /// Basis orthonormality + handedness both survive the
1367    /// inverse-rotation transform. Property: any unit-quaternion
1368    /// conjugation preserves the input basis's orthonormality AND
1369    /// its handedness (rotations are orientation-preserving).
1370    #[test]
1371    fn world_camera_to_grid_local_preserves_basis_orthonormality() {
1372        use glam::DQuat;
1373        // Right-handed voxlap basis (`right × down == forward`):
1374        // looking +y, right = -x makes the cross product land on +y.
1375        let camera = Camera {
1376            pos: [3.0, -5.0, 7.0],
1377            right: [-1.0, 0.0, 0.0],
1378            down: [0.0, 0.0, 1.0],
1379            forward: [0.0, 1.0, 0.0],
1380        };
1381        let transform = GridTransform {
1382            origin: DVec3::new(1.0, 2.0, 3.0),
1383            rotation: DQuat::from_axis_angle(glam::DVec3::new(0.3, 0.8, 0.5).normalize(), 0.7),
1384        };
1385        let local = super::world_camera_to_grid_local(&camera, &transform);
1386        let r = DVec3::from_array(local.right);
1387        let d = DVec3::from_array(local.down);
1388        let f = DVec3::from_array(local.forward);
1389        // Norms ≈ 1.
1390        for v in [r, d, f] {
1391            assert!(
1392                (v.length_squared() - 1.0).abs() < 1e-12,
1393                "basis vec {v:?} not unit length"
1394            );
1395        }
1396        // Orthogonality.
1397        assert!(r.dot(d).abs() < 1e-12, "right·down = {}", r.dot(d));
1398        assert!(r.dot(f).abs() < 1e-12, "right·forward = {}", r.dot(f));
1399        assert!(d.dot(f).abs() < 1e-12, "down·forward = {}", d.dot(f));
1400        // Right-handed: right × down == forward (voxlap convention).
1401        let cross = r.cross(d);
1402        assert!(
1403            (cross - f).length() < 1e-12,
1404            "right×down={cross:?} forward={f:?}"
1405        );
1406    }
1407
1408    // ---- S5.1: rotated-grid render correctness ----
1409
1410    /// Build a single-grid scene at the given transform with a
1411    /// marker box near one corner of chunk (0, 0, 0). Returns the
1412    /// scene and the marker colour. Picking a single chunk + small
1413    /// box keeps the test compact while still exercising the gline
1414    /// + grouscan path through the rotated frame.
1415    fn build_one_grid_marker_scene(transform: GridTransform) -> (Scene, crate::GridId, u32) {
1416        let mut scene = Scene::new();
1417        let id = scene.add_grid(transform);
1418        let grid = scene.grid_mut(id).unwrap();
1419        // Bright marker box at chunk-local (40..56, 40..56, 40..56).
1420        grid.set_rect(
1421            IVec3::new(40, 40, 40),
1422            IVec3::new(55, 55, 55),
1423            Some(VoxColor(0x80_55_aa_22)), // distinctive green
1424        );
1425        (scene, id, 0x80_55_aa_22)
1426    }
1427
1428    /// Pin S5.1's central equivalence: rotating both the grid and the
1429    /// camera by the SAME rotation around the grid's origin must
1430    /// leave the rendered framebuffer unchanged — the grid-local
1431    /// camera pose collapses to the same values in both scenarios.
1432    ///
1433    /// We use `DQuat::from_xyzw(0.0, 0.0, 1.0, 0.0)`, the
1434    /// 180°-around-Z unit quaternion. This rotation acts on vectors
1435    /// as `(x, y, z) → (-x, -y, z)`, which only multiplies f64
1436    /// components by 0 or ±1 — bit-exact under glam's standard quat
1437    /// conjugation formula. Other angles (e.g. 90°) would introduce
1438    /// sub-1e-15 noise from sin/cos, breaking byte-identity at
1439    /// chunk / voxel boundaries.
1440    #[test]
1441    fn s5_1_180deg_z_rotated_grid_byte_identical_to_axis_aligned() {
1442        use glam::DQuat;
1443        // Right-handed voxlap basis (right × down == forward).
1444        let axis_aligned_camera = Camera {
1445            pos: [40.0, -20.0, 50.0],
1446            right: [-1.0, 0.0, 0.0],
1447            down: [0.0, 0.0, 1.0],
1448            forward: [0.0, 1.0, 0.0],
1449        };
1450        // R_z(180°): (x, y, z) → (-x, -y, z).
1451        let rotated_camera = Camera {
1452            pos: [-40.0, 20.0, 50.0],
1453            right: [1.0, 0.0, 0.0],
1454            down: [0.0, 0.0, 1.0],
1455            forward: [0.0, -1.0, 0.0],
1456        };
1457        // Sanity: prove the exact-arithmetic rotation lands on the
1458        // baseline. If glam ever changes its quat*vec formula in a
1459        // way that loses exactness here, the next two assertions
1460        // catch it before the framebuffer comparison.
1461        let q = DQuat::from_xyzw(0.0, 0.0, 1.0, 0.0);
1462        let rot_pos = q * DVec3::from_array(axis_aligned_camera.pos);
1463        let rot_fwd = q * DVec3::from_array(axis_aligned_camera.forward);
1464        assert_eq!(rot_pos.to_array(), rotated_camera.pos);
1465        assert_eq!(rot_fwd.to_array(), rotated_camera.forward);
1466
1467        let (mut scene_a, _, _) = build_one_grid_marker_scene(GridTransform::identity());
1468        let fb_a = render_via_scene(&mut scene_a, &axis_aligned_camera);
1469
1470        let (mut scene_b, _, _) = build_one_grid_marker_scene(GridTransform {
1471            origin: DVec3::ZERO,
1472            rotation: q,
1473        });
1474        let fb_b = render_via_scene(&mut scene_b, &rotated_camera);
1475
1476        assert_eq!(
1477            fb_a, fb_b,
1478            "rotating both grid and camera by R about the grid origin must leave the framebuffer unchanged"
1479        );
1480    }
1481
1482    /// 45° smoke test: rotated grid renders to something non-trivial
1483    /// without panicking. No equivalence assertion (45° quat math is
1484    /// approximate at f64 level; that path is exercised structurally,
1485    /// not bit-exactly). Camera is placed at a fixed world pose where
1486    /// — under the rotation — the marker box stays inside the view
1487    /// frustum.
1488    #[test]
1489    fn s5_1_45deg_z_rotated_grid_renders_marker() {
1490        use glam::DQuat;
1491        let rotation = DQuat::from_rotation_z(std::f64::consts::FRAC_PI_4);
1492        let (mut scene, _, marker) = build_one_grid_marker_scene(GridTransform {
1493            origin: DVec3::ZERO,
1494            rotation,
1495        });
1496
1497        // World position of the marker's centre. Grid-local
1498        // (47.5, 47.5, 47.5) → world `rotation * (47.5, 47.5, 47.5)`.
1499        // R_z(45°): (47.5, 47.5, 47.5) → (0, 67.18, 47.5) (the x/y
1500        // components combine into a single +y vector at √2 * 47.5).
1501        let marker_world = rotation * DVec3::new(47.5, 47.5, 47.5);
1502        // Camera 80 units south of the marker on the world Y axis,
1503        // looking +y at the same z. RH basis.
1504        let camera = Camera {
1505            pos: [marker_world.x, marker_world.y - 80.0, marker_world.z],
1506            right: [-1.0, 0.0, 0.0],
1507            down: [0.0, 0.0, 1.0],
1508            forward: [0.0, 1.0, 0.0],
1509        };
1510
1511        let (_engine, mut fb, mut zb) = render_setup(CHUNK_SIZE_XY);
1512        let fog = CpuFog::default();
1513        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1514        let outcome = render_scene(
1515            &mut fb,
1516            &mut zb,
1517            XRES as usize,
1518            XRES,
1519            YRES,
1520            fog,
1521            &mut scene,
1522            &camera,
1523            &settings,
1524            None,
1525        );
1526        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1527        let marker_count = fb.iter().filter(|&&p| p == marker).count();
1528        assert!(
1529            marker_count > 50,
1530            "45°-rotated marker box should be visible — got {marker_count} marker pixels"
1531        );
1532    }
1533
1534    // ---- S5.2-followup: per-grid render_sky opt-out ----
1535
1536    /// Two-grid scene where grid B sits behind grid A along +y;
1537    /// grid A is opaque only in the centre of the framebuffer, so
1538    /// the camera's view through grid A is mostly "ray miss". When
1539    /// `A.render_sky = false`, the pixels around A's silhouette
1540    /// must remain whatever grid B (or the shared pre-fill)
1541    /// painted — NOT A's grid-local sky colour. This pins the
1542    /// sentinel-mask path: without it, A's sky would write into
1543    /// the composed framebuffer wherever its sky-z happened to win
1544    /// the min-z race with B's sky-z.
1545    #[test]
1546    fn render_sky_false_drops_grid_sky_pixels() {
1547        use crate::{GridId, GridTransform};
1548
1549        // Grid B (far, sky owner) — a wide floor of distinct
1550        // colour spanning chunk-local x/y so most rays land on it.
1551        let mut scene = Scene::new();
1552        let _b_id: GridId = scene.add_grid(GridTransform::at(DVec3::new(0.0, 600.0, 0.0)));
1553        // Find grid B's id (HashMap iteration; we only just added
1554        // one grid, so its id is whichever the iterator yields).
1555        let b_id = scene.grids().next().unwrap().0;
1556        scene.grid_mut(b_id).unwrap().set_rect(
1557            IVec3::new(0, 0, 100),
1558            IVec3::new(127, 127, 110),
1559            Some(VoxColor(0x80_22_88_22)), // green floor
1560        );
1561
1562        // Grid A (near, sky disabled) — a SMALL marker box that
1563        // covers only a fraction of the screen. Most pixels of A's
1564        // local render are sky.
1565        let a_id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
1566        scene.grid_mut(a_id).unwrap().set_rect(
1567            IVec3::new(60, 60, 60),
1568            IVec3::new(67, 67, 67),
1569            Some(VoxColor(0x80_aa_22_22)), // red cube
1570        );
1571        scene.grid_mut(a_id).unwrap().render_sky = false;
1572
1573        let unique_sky: u32 = 0xFF_AB_CD_EF;
1574        let (_engine, fog, _) = make_composed_pool(CHUNK_SIZE_XY);
1575        let mut fb = vec![unique_sky; pixel_count(XRES, YRES)];
1576        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1577        let camera = camera_at([64.0, 0.0, 100.0]);
1578        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1579        let outcome = render_scene_composed(
1580            &mut fb,
1581            &mut zb,
1582            XRES as usize,
1583            XRES,
1584            YRES,
1585            fog,
1586            &mut scene,
1587            &camera,
1588            &settings,
1589            unique_sky,
1590            None,
1591        );
1592        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
1593
1594        // The sentinel must never appear in the composed output —
1595        // every sentinel pixel must have been masked out before
1596        // compose. If any leak through, the test catches it.
1597        let leaked = fb
1598            .iter()
1599            .filter(|&&p| p == super::SKY_MASK_SENTINEL)
1600            .count();
1601        assert_eq!(
1602            leaked, 0,
1603            "SKY_MASK_SENTINEL leaked into composed framebuffer ({leaked} pixels)"
1604        );
1605        // Grid A's hit (red cube) must still render — render_sky=false
1606        // only affects sky pixels, not hits.
1607        let red_count = fb.iter().filter(|&&p| p == 0x80_aa_22_22).count();
1608        assert!(
1609            red_count > 0,
1610            "red cube from sky-disabled grid A is missing — render_sky=false should only mask sky"
1611        );
1612        // Grid B's floor must be visible past grid A's silhouette
1613        // (the sky-disabled grid doesn't hide B's render).
1614        let green_count = fb.iter().filter(|&&p| p == 0x80_22_88_22).count();
1615        assert!(
1616            green_count > 0,
1617            "grid B's floor invisible — grid A's masked sky may have overwritten it"
1618        );
1619    }
1620
1621    /// Identity-rotation, single-grid scene with `render_sky = false`
1622    /// must produce a sentinel-free framebuffer. Sanity test for the
1623    /// trivial 1-grid case (no second grid to compose against).
1624    #[test]
1625    fn render_sky_false_single_grid_no_sentinel_leak() {
1626        let (mut scene, id, _) = build_one_grid_marker_scene(GridTransform::identity());
1627        scene.grid_mut(id).unwrap().render_sky = false;
1628        let unique_sky: u32 = 0xFF_12_34_56;
1629        let (_engine, fog, _) = make_composed_pool(CHUNK_SIZE_XY);
1630        let mut fb = vec![unique_sky; pixel_count(XRES, YRES)];
1631        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1632        let camera = camera_at([64.0, 0.0, 64.0]);
1633        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1634        let outcome = render_scene_composed(
1635            &mut fb,
1636            &mut zb,
1637            XRES as usize,
1638            XRES,
1639            YRES,
1640            fog,
1641            &mut scene,
1642            &camera,
1643            &settings,
1644            unique_sky,
1645            None,
1646        );
1647        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1648        let leaked = fb
1649            .iter()
1650            .filter(|&&p| p == super::SKY_MASK_SENTINEL)
1651            .count();
1652        assert_eq!(leaked, 0, "SKY_MASK_SENTINEL leaked ({leaked} pixels)");
1653        // Pixels that would have been the grid's sky now show
1654        // through to the pre-fill (unique_sky).
1655        let prefill_count = fb.iter().filter(|&&p| p == unique_sky).count();
1656        assert!(
1657            prefill_count > 0,
1658            "no pre-fill pixels survived — render_sky=false should leave non-hit pixels untouched"
1659        );
1660    }
1661
1662    // DDA.9: `render_scene_at_origin_matches_direct_opticast` and
1663    // `render_scene_translated_grid_matches_grid_local_opticast` were
1664    // removed — they asserted the scene render byte-matches voxlap
1665    // `opticast`, which no longer holds now that the scene's CPU backend
1666    // is the DDA renderer (different, intentionally non-bit-exact). The
1667    // grid-local camera transform they also exercised is covered by the
1668    // `stacked_*` / two-grid composition tests below.
1669
1670    #[test]
1671    fn empty_scene_returns_empty_outcome() {
1672        let mut scene = Scene::new();
1673        let (_engine, mut fb, mut zb) = render_setup(CHUNK_SIZE_XY);
1674        let fog = CpuFog::default();
1675        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1676        let outcome = render_scene(
1677            &mut fb,
1678            &mut zb,
1679            XRES as usize,
1680            XRES,
1681            YRES,
1682            fog,
1683            &mut scene,
1684            &camera_at([0.0, 0.0, 0.0]),
1685            &settings,
1686            None,
1687        );
1688        assert_eq!(outcome, RenderOutcome::Empty);
1689    }
1690
1691    // ---- S3.1 / S4.0: render_scene_composed + 2-grid composition ----
1692
1693    /// Build a 2-grid scene with two distinguishable boxes placed
1694    /// side-by-side in world space along the camera's right axis.
1695    /// Each grid holds one chunk (`(0, 0, 0)`) containing a single
1696    /// 16-voxel box with a uniquely-coloured surface so the
1697    /// composited framebuffer is partitionable by colour.
1698    fn build_two_grid_side_by_side() -> (Scene, u32, u32) {
1699        let mut scene = Scene::new();
1700        // Grid 0 at world (0, 200, 0): box centred chunk-local (64, 64, 100).
1701        let g0 = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
1702        scene.grid_mut(g0).unwrap().set_rect(
1703            IVec3::new(56, 56, 92),
1704            IVec3::new(71, 71, 107),
1705            Some(VoxColor(0x80_88_22_22)), // dark red
1706        );
1707        // Grid 1 at world (200, 200, 0): box centred chunk-local (64, 64, 100).
1708        let _g1 = scene.add_grid(GridTransform::at(DVec3::new(200.0, 200.0, 0.0)));
1709        // Borrow-checker dance: re-borrow grid 1 mutably.
1710        let g1_id = scene
1711            .grids()
1712            .filter(|(id, _)| *id != g0)
1713            .map(|(id, _)| id)
1714            .next()
1715            .unwrap();
1716        scene.grid_mut(g1_id).unwrap().set_rect(
1717            IVec3::new(56, 56, 92),
1718            IVec3::new(71, 71, 107),
1719            Some(VoxColor(0x80_22_22_88)), // dark blue
1720        );
1721        (scene, 0x80_88_22_22, 0x80_22_22_88)
1722    }
1723
1724    /// Engine + default (off) fog config + sky colour for the
1725    /// composed-render tests. `_pool_vsid` retained for call-site
1726    /// compatibility; the DDA backend needs no scratch pool.
1727    fn make_composed_pool(_pool_vsid: u32) -> (Engine, CpuFog, u32) {
1728        let engine = Engine::new();
1729        let sky_color = engine.sky_color();
1730        (engine, CpuFog::default(), sky_color)
1731    }
1732
1733    fn pixel_count(width: u32, height: u32) -> usize {
1734        (width as usize) * (height as usize)
1735    }
1736
1737    #[test]
1738    fn compose_into_takes_smaller_z() {
1739        let mut shared_fb = vec![0xff_ff_ff_ff_u32; 4];
1740        let mut shared_zb = vec![10.0f32; 4];
1741        let temp_fb = [0xaa_aa_aa_aa, 0x11_22_33_44, 0x55_66_77_88, 0xde_ad_be_ef];
1742        let temp_zb = [5.0f32, 20.0, 10.0, f32::INFINITY];
1743        compose_into(&mut shared_fb, &mut shared_zb, &temp_fb, &temp_zb);
1744        // i=0: 5 < 10 → take temp.
1745        assert_eq!(shared_fb[0], 0xaa_aa_aa_aa);
1746        assert_eq!(shared_zb[0], 5.0);
1747        // i=1: 20 > 10 → keep shared.
1748        assert_eq!(shared_fb[1], 0xff_ff_ff_ff);
1749        assert_eq!(shared_zb[1], 10.0);
1750        // i=2: 10 == 10 → keep shared (`<` not `<=`).
1751        assert_eq!(shared_fb[2], 0xff_ff_ff_ff);
1752        // i=3: INFINITY > 10 → keep shared.
1753        assert_eq!(shared_fb[3], 0xff_ff_ff_ff);
1754    }
1755
1756    #[test]
1757    fn render_scene_composed_two_grids_both_visible() {
1758        // Camera positioned to see both grids' boxes. Grid 0's box
1759        // at world (~64, ~264, ~100); grid 1's box at world
1760        // (~264, ~264, ~100). Camera at world (160, 100, 100)
1761        // looking +y centres both in view.
1762        let (mut scene, red, blue) = build_two_grid_side_by_side();
1763        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1764        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1765        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1766
1767        let camera = camera_at([160.0, 100.0, 100.0]);
1768        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1769        let outcome = render_scene_composed(
1770            &mut fb,
1771            &mut zb,
1772            XRES as usize,
1773            XRES,
1774            YRES,
1775            fog,
1776            &mut scene,
1777            &camera,
1778            &settings,
1779            sky_color,
1780            None,
1781        );
1782        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
1783
1784        // Both colours should appear somewhere in the framebuffer.
1785        let red_count = fb.iter().filter(|&&p| p == red).count();
1786        let blue_count = fb.iter().filter(|&&p| p == blue).count();
1787        assert!(
1788            red_count > 0,
1789            "no red pixels: grid 0 (red box) not visible after compose"
1790        );
1791        assert!(
1792            blue_count > 0,
1793            "no blue pixels: grid 1 (blue box) not visible after compose"
1794        );
1795    }
1796
1797    /// The per-grid screen scissor (vertical band + lateral/vertical
1798    /// off-screen cull + rect-limited memory passes) must be a pure
1799    /// speed-up: rendering a multi-grid scene with it on
1800    /// (`render_scene_composed`) must produce a **byte-identical**
1801    /// framebuffer to rendering each grid full-frame
1802    /// (`scissor = false`). Includes a third grid placed off the left
1803    /// edge but within scan distance, so the lateral cull (scissor on)
1804    /// vs a sky-only full render (scissor off) must still agree pixel
1805    /// for pixel.
1806    #[test]
1807    fn scissor_render_is_byte_identical_to_full_frame() {
1808        let (mut scene, red, blue) = build_two_grid_side_by_side();
1809        // Third grid far to the +x side at the camera's depth: within
1810        // max_scan_dist (so the distance cull doesn't fire) but its box
1811        // projects off the left screen edge → screen-culled with the
1812        // scissor, sky-only when rendered full-frame.
1813        let g2 = scene.add_grid(GridTransform::at(DVec3::new(700.0, 130.0, 0.0)));
1814        let g2_id = scene
1815            .grids()
1816            .map(|(id, _)| id)
1817            .max_by_key(|id| id.raw())
1818            .unwrap();
1819        let _ = g2;
1820        scene.grid_mut(g2_id).unwrap().set_rect(
1821            IVec3::new(56, 56, 92),
1822            IVec3::new(71, 71, 107),
1823            Some(VoxColor(0x80_22_88_22)), // green — must never appear (off-screen)
1824        );
1825
1826        let camera = camera_at([160.0, 100.0, 100.0]);
1827        let render = |scene: &mut Scene, scissor: bool| -> Vec<u32> {
1828            let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1829            let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1830            let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1831            let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1832            render_scene_composed_scissored(
1833                &mut fb,
1834                &mut zb,
1835                XRES as usize,
1836                XRES,
1837                YRES,
1838                fog,
1839                scene,
1840                &camera,
1841                &settings,
1842                sky_color,
1843                None,
1844                scissor,
1845                None,
1846                &[],
1847                CpuLights::default(),
1848                None,
1849                &mut SceneRenderScratch::default(),
1850            );
1851            fb
1852        };
1853
1854        let scissored = render(&mut scene, true);
1855        let full = render(&mut scene, false);
1856        assert_eq!(
1857            scissored, full,
1858            "the screen scissor changed the framebuffer — it must be a pure speed-up",
1859        );
1860        // Sanity: the scene actually drew content (not a vacuous all-sky
1861        // match), and the off-screen green grid never appears.
1862        assert!(scissored.iter().any(|&p| p == red || p == blue));
1863        assert!(
1864            !scissored.contains(&0x80_22_88_22),
1865            "off-screen grid leaked pixels",
1866        );
1867    }
1868
1869    #[test]
1870    fn render_scene_composed_grid_a_in_front_of_grid_b() {
1871        // Two grids stacked along +y so grid A (closer) occludes
1872        // grid B (farther). After composition only grid A's colour
1873        // should appear on the overlap.
1874        let mut scene = Scene::new();
1875        let g_a = scene.add_grid(GridTransform::at(DVec3::new(0.0, 50.0, 0.0)));
1876        scene.grid_mut(g_a).unwrap().set_rect(
1877            IVec3::new(56, 56, 92),
1878            IVec3::new(71, 71, 107),
1879            Some(VoxColor(0x80_aa_00_00)), // red
1880        );
1881        let _g_b = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
1882        let g_b_id = scene
1883            .grids()
1884            .filter(|(id, _)| *id != g_a)
1885            .map(|(id, _)| id)
1886            .next()
1887            .unwrap();
1888        scene.grid_mut(g_b_id).unwrap().set_rect(
1889            IVec3::new(56, 56, 92),
1890            IVec3::new(71, 71, 107),
1891            Some(VoxColor(0x80_00_00_aa)), // blue
1892        );
1893
1894        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1895        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1896        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1897
1898        // Camera at (64, -10, 100) looking +y — both boxes line up
1899        // along the camera's forward axis.
1900        let camera = camera_at([64.0, -10.0, 100.0]);
1901        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1902        let outcome = render_scene_composed(
1903            &mut fb,
1904            &mut zb,
1905            XRES as usize,
1906            XRES,
1907            YRES,
1908            fog,
1909            &mut scene,
1910            &camera,
1911            &settings,
1912            sky_color,
1913            None,
1914        );
1915        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
1916
1917        // Red (closer grid) should be visible. Blue (farther grid)
1918        // may peek around the edges but the central pixels should
1919        // be red where both boxes project.
1920        let red_count = fb.iter().filter(|&&p| p == 0x80_aa_00_00).count();
1921        assert!(
1922            red_count > 0,
1923            "expected red pixels (closer box should win z-test)"
1924        );
1925
1926        // Reverse the registration order (force grid B drawn first)
1927        // and verify that's irrelevant — composition is commutative.
1928        let mut scene2 = Scene::new();
1929        let g_b2 = scene2.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
1930        scene2.grid_mut(g_b2).unwrap().set_rect(
1931            IVec3::new(56, 56, 92),
1932            IVec3::new(71, 71, 107),
1933            Some(VoxColor(0x80_00_00_aa)),
1934        );
1935        let g_a2 = scene2.add_grid(GridTransform::at(DVec3::new(0.0, 50.0, 0.0)));
1936        scene2.grid_mut(g_a2).unwrap().set_rect(
1937            IVec3::new(56, 56, 92),
1938            IVec3::new(71, 71, 107),
1939            Some(VoxColor(0x80_aa_00_00)),
1940        );
1941
1942        let mut fb2 = vec![sky_color; pixel_count(XRES, YRES)];
1943        let mut zb2 = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1944        let outcome2 = render_scene_composed(
1945            &mut fb2,
1946            &mut zb2,
1947            XRES as usize,
1948            XRES,
1949            YRES,
1950            fog,
1951            &mut scene2,
1952            &camera,
1953            &settings,
1954            sky_color,
1955            None,
1956        );
1957        assert_eq!(outcome2, RenderOutcome::Rendered { grids_drawn: 2 });
1958        assert_eq!(
1959            fb, fb2,
1960            "composition should be order-independent — same scene in different add order should produce identical output"
1961        );
1962    }
1963
1964    // ---- S6.1: Mid-tier mip overrides ----
1965
1966    /// Build a multi-mip-friendly grid: solid floor spanning the
1967    /// whole chunk at z=100..254 + `generate_mips(3)`. This is the
1968    /// same setup `vxl_generate_mips_on_set_voxel_chunk_renders`
1969    /// uses and is known to render at `mip_levels = 3,
1970    /// mip_scan_dist = 32`.
1971    ///
1972    /// Returns `(scene, grid_id)`. The Mid test sets the camera
1973    /// inside the chunk so chunk-local rays reach the floor at
1974    /// short distances; that lets the Mid override use
1975    /// `mip_scan_dist = 16` without busting the ray budget
1976    /// (`mip_scan_dist * 2^(mip_levels-1) = 16 * 4 = 64` covers the
1977    /// distance from camera to floor).
1978    fn build_mip_visible_grid(world_origin: DVec3) -> (Scene, crate::GridId) {
1979        let mut scene = Scene::new();
1980        let id = scene.add_grid(GridTransform::at(world_origin));
1981        let grid = scene.grid_mut(id).unwrap();
1982        // Solid floor across the entire chunk at z=100..254.
1983        grid.set_rect(
1984            IVec3::new(0, 0, 100),
1985            IVec3::new(127, 127, 254),
1986            Some(VoxColor(0x80_88_88_88)),
1987        );
1988        // Build the per-chunk mip ladder so `gmipnum` can grow past 1.
1989        grid.chunk_mut(IVec3::ZERO).unwrap().generate_mips(3);
1990        (scene, id)
1991    }
1992
1993    /// Render `scene` via composed path with `mip_levels = 3,
1994    /// mip_scan_dist = 32` — same values the working
1995    /// `vxl_generate_mips_on_set_voxel_chunk_renders` test uses.
1996    /// Returns the framebuffer.
1997    fn render_with_multi_mip(scene: &mut Scene, camera: &Camera) -> Vec<u32> {
1998        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1999        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2000        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2001        let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2002        settings.mip_levels = 3;
2003        settings.mip_scan_dist = 32;
2004        let outcome = render_scene_composed(
2005            &mut fb,
2006            &mut zb,
2007            XRES as usize,
2008            XRES,
2009            YRES,
2010            fog,
2011            scene,
2012            camera,
2013            &settings,
2014            sky_color,
2015            None,
2016        );
2017        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2018        fb
2019    }
2020
2021    // DDA.9: `s6_1_mid_overrides_produce_different_framebuffer_than_near`
2022    // was removed. It encoded voxlap's mip-*transition* semantics
2023    // (mid_mip_levels=Some(1) caps in-grid mip transitions, differing
2024    // from Near's mip0→1→2 distance ramp). The DDA renderer uses a
2025    // *uniform* per-grid mip (no in-grid transition), so Some(1) → mip 0
2026    // = identical to Near. DDA mip coarsening is covered by
2027    // `roxlap_core::dda` `mip_render_is_coarse_but_complete`; the LOD-Mid
2028    // wiring by `s6_1_mid_without_overrides_byte_identical_to_near`.
2029
2030    /// Mid tier with `mid_mip_levels = None` AND
2031    /// `mid_mip_scan_dist = None` must produce a byte-identical
2032    /// framebuffer to Near. This is the graceful-degrade contract
2033    /// — callers can opt into the Mid plumbing without committing
2034    /// to a mip override and stay byte-stable.
2035    #[test]
2036    fn s6_1_mid_without_overrides_byte_identical_to_near() {
2037        let camera = camera_at([64.0, 0.0, 64.0]);
2038
2039        // Scene A: default thresholds → Near.
2040        let (mut scene_a, _) = build_mip_visible_grid(DVec3::ZERO);
2041        let fb_near = render_with_multi_mip(&mut scene_a, &camera);
2042
2043        // Scene B: thresholds force Mid but no mip overrides set.
2044        let (mut scene_b, b_id) = build_mip_visible_grid(DVec3::ZERO);
2045        scene_b.grid_mut(b_id).unwrap().lod_thresholds = crate::LodThresholds {
2046            r_near: 0.0,
2047            r_mid: f64::INFINITY,
2048            mid_mip_levels: None,
2049            mid_mip_scan_dist: None,
2050        };
2051        let lod = scene_b
2052            .grid(b_id)
2053            .unwrap()
2054            .select_lod(DVec3::from_array(camera.pos));
2055        assert_eq!(lod, Lod::Mid);
2056        let fb_mid = render_with_multi_mip(&mut scene_b, &camera);
2057
2058        // Byte-identical: Mid with no overrides degrades cleanly.
2059        assert_eq!(
2060            fb_near, fb_mid,
2061            "Mid with both overrides=None must byte-match Near"
2062        );
2063    }
2064
2065    // DDA.9: `s6_1_global_mip_cap_survives_mid_tier` was removed. It
2066    // pinned voxlap's `mip_levels_override` global cap composing with the
2067    // Mid override — the ship anti-axis-aligned-beam workaround. The DDA
2068    // renderer has no axis-aligned mip beam (honest per-cell traversal),
2069    // so the workaround / global cap is obsolete and the DDA path doesn't
2070    // consult `mip_levels_override`.
2071
2072    // ---- S6.3: Far-tier billboard blit ----
2073
2074    /// Force Far tier via `r_near = 0, r_mid = 0`: any non-zero
2075    /// camera-to-grid distance lands on `Lod::Far`. Renders a small
2076    /// grid at world (0, 200, 0) with default-radius thresholds
2077    /// turned all-Far. The composed framebuffer must contain
2078    /// non-sky pixels from the impostor blit.
2079    #[test]
2080    fn s6_3_far_tier_blits_non_sky_pixels() {
2081        let (mut scene, id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
2082        scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
2083            r_near: 0.0,
2084            r_mid: 0.0,
2085            mid_mip_levels: None,
2086            mid_mip_scan_dist: None,
2087        };
2088
2089        let camera = camera_at([64.0, 0.0, 100.0]);
2090        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2091        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2092        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2093        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2094        let outcome = render_scene_composed(
2095            &mut fb,
2096            &mut zb,
2097            XRES as usize,
2098            XRES,
2099            YRES,
2100            fog,
2101            &mut scene,
2102            &camera,
2103            &settings,
2104            sky_color,
2105            None,
2106        );
2107        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2108
2109        // Sanity: picker actually picked Far.
2110        let lod = scene
2111            .grid(id)
2112            .unwrap()
2113            .select_lod(DVec3::from_array(camera.pos));
2114        assert_eq!(lod, Lod::Far);
2115
2116        // Impostor must paint at least some non-sky pixels.
2117        let non_sky = fb.iter().filter(|&&p| p != sky_color).count();
2118        assert!(
2119            non_sky > 0,
2120            "Far-tier render produced no non-sky pixels — billboard blit not firing"
2121        );
2122    }
2123
2124    /// Lazy populate: cache starts `None`, becomes `Some` after the
2125    /// first Far render.
2126    #[test]
2127    fn s6_3_far_render_lazily_populates_cache() {
2128        let (mut scene, id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
2129        scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
2130            r_near: 0.0,
2131            r_mid: 0.0,
2132            mid_mip_levels: None,
2133            mid_mip_scan_dist: None,
2134        };
2135        assert!(scene.grid(id).unwrap().billboards.is_none());
2136
2137        let camera = camera_at([64.0, 0.0, 100.0]);
2138        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2139        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2140        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2141        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2142        let _ = render_scene_composed(
2143            &mut fb,
2144            &mut zb,
2145            XRES as usize,
2146            XRES,
2147            YRES,
2148            fog,
2149            &mut scene,
2150            &camera,
2151            &settings,
2152            sky_color,
2153            None,
2154        );
2155        let cache = scene
2156            .grid(id)
2157            .unwrap()
2158            .billboards
2159            .as_ref()
2160            .expect("Far render should have populated billboards");
2161        assert_eq!(cache.len(), 26);
2162    }
2163
2164    /// Edit invalidates the cache; a subsequent Far render rebuilds.
2165    #[test]
2166    fn s6_3_edit_invalidates_then_far_render_rebuilds() {
2167        let (mut scene, id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
2168        scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
2169            r_near: 0.0,
2170            r_mid: 0.0,
2171            mid_mip_levels: None,
2172            mid_mip_scan_dist: None,
2173        };
2174        let camera = camera_at([64.0, 0.0, 100.0]);
2175        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2176        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2177
2178        // First Far render → cache built.
2179        let mut fb1 = vec![sky_color; pixel_count(XRES, YRES)];
2180        let mut zb1 = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2181        let _ = render_scene_composed(
2182            &mut fb1,
2183            &mut zb1,
2184            XRES as usize,
2185            XRES,
2186            YRES,
2187            fog,
2188            &mut scene,
2189            &camera,
2190            &settings,
2191            sky_color,
2192            None,
2193        );
2194        assert!(scene.grid(id).unwrap().billboards.is_some());
2195
2196        // Edit invalidates.
2197        scene
2198            .grid_mut(id)
2199            .unwrap()
2200            .set_voxel(IVec3::new(70, 70, 70), Some(VoxColor(0x80_aa_aa_22)));
2201        assert!(scene.grid(id).unwrap().billboards.is_none());
2202
2203        // Second Far render rebuilds.
2204        let mut fb2 = vec![sky_color; pixel_count(XRES, YRES)];
2205        let mut zb2 = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2206        let _ = render_scene_composed(
2207            &mut fb2,
2208            &mut zb2,
2209            XRES as usize,
2210            XRES,
2211            YRES,
2212            fog,
2213            &mut scene,
2214            &camera,
2215            &settings,
2216            sky_color,
2217            None,
2218        );
2219        assert!(scene.grid(id).unwrap().billboards.is_some());
2220    }
2221
2222    /// Hybrid scene: one Near grid + one Far grid. Both must render
2223    /// visibly; the Far grid via blit, the Near grid via opticast.
2224    /// Sanity check that the two paths cohabit one
2225    /// `render_scene_composed` call.
2226    #[test]
2227    fn s6_3_near_and_far_grids_in_same_scene() {
2228        let mut scene = Scene::new();
2229        // Grid A: stays Near (default thresholds). Solid box at
2230        // world (-30..-20, 190..210, 50..70).
2231        let a_id = scene.add_grid(GridTransform::at(DVec3::new(-100.0, 200.0, 0.0)));
2232        scene.grid_mut(a_id).unwrap().set_rect(
2233            IVec3::new(70, 0, 50),
2234            IVec3::new(85, 15, 70),
2235            Some(VoxColor(0x80_22_88_22)), // green
2236        );
2237        // Grid B: forced Far. Box at world (~100, 200, 100).
2238        let b_id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 200.0, 0.0)));
2239        scene.grid_mut(b_id).unwrap().set_rect(
2240            IVec3::new(0, 0, 80),
2241            IVec3::new(20, 20, 110),
2242            Some(VoxColor(0x80_aa_22_22)), // red
2243        );
2244        scene.grid_mut(b_id).unwrap().lod_thresholds = crate::LodThresholds {
2245            r_near: 0.0,
2246            r_mid: 0.0,
2247            mid_mip_levels: None,
2248            mid_mip_scan_dist: None,
2249        };
2250
2251        let camera = camera_at([0.0, 0.0, 80.0]);
2252        // Confirm A is Near, B is Far for this pose.
2253        assert_eq!(
2254            scene
2255                .grid(a_id)
2256                .unwrap()
2257                .select_lod(DVec3::from_array(camera.pos)),
2258            Lod::Near
2259        );
2260        assert_eq!(
2261            scene
2262                .grid(b_id)
2263                .unwrap()
2264                .select_lod(DVec3::from_array(camera.pos)),
2265            Lod::Far
2266        );
2267
2268        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2269        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2270        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2271        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2272        let outcome = render_scene_composed(
2273            &mut fb,
2274            &mut zb,
2275            XRES as usize,
2276            XRES,
2277            YRES,
2278            fog,
2279            &mut scene,
2280            &camera,
2281            &settings,
2282            sky_color,
2283            None,
2284        );
2285        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
2286
2287        // Each grid should contribute visible pixels.
2288        let non_sky = fb.iter().filter(|&&p| p != sky_color).count();
2289        assert!(
2290            non_sky > 20,
2291            "hybrid scene produced too few non-sky pixels ({non_sky}); one tier may have failed"
2292        );
2293    }
2294
2295    /// Empty grid at Far tier: skipped silently (no panic, no
2296    /// allocation), `billboards` stays `None`.
2297    #[test]
2298    fn s6_3_empty_grid_at_far_is_skipped() {
2299        let mut scene = Scene::new();
2300        let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 200.0, 0.0)));
2301        scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
2302            r_near: 0.0,
2303            r_mid: 0.0,
2304            mid_mip_levels: None,
2305            mid_mip_scan_dist: None,
2306        };
2307
2308        let camera = camera_at([0.0, 0.0, 100.0]);
2309        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2310        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2311        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2312        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2313        let outcome = render_scene_composed(
2314            &mut fb,
2315            &mut zb,
2316            XRES as usize,
2317            XRES,
2318            YRES,
2319            fog,
2320            &mut scene,
2321            &camera,
2322            &settings,
2323            sky_color,
2324            None,
2325        );
2326        // No grids contributed.
2327        assert_eq!(outcome, RenderOutcome::Empty);
2328        // Cache must NOT have been built for an empty grid.
2329        assert!(scene.grid(id).unwrap().billboards.is_none());
2330        // Framebuffer unchanged.
2331        assert!(fb.iter().all(|&p| p == sky_color));
2332    }
2333
2334    // ---- S6.0: LOD picker wired but every tier falls through to Near ----
2335
2336    /// Threshold-invariance: a grid rendered with the S6 derived
2337    /// thresholds (`from_radius` of the actual bounding sphere) must
2338    /// produce a framebuffer byte-identical to the same grid with
2339    /// default `always_near` thresholds, because S6.0 takes the
2340    /// `Near` arm of the match for all three tiers. This is the
2341    /// regression test for the S6.0 contract.
2342    #[test]
2343    fn render_scene_composed_lod_threshold_invariance() {
2344        // Scene A: default thresholds (always_near).
2345        let (mut scene_a, _a_id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
2346        let cam = camera_at([64.0, 0.0, 100.0]);
2347        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2348        let mut fb_a = vec![sky_color; pixel_count(XRES, YRES)];
2349        let mut zb_a = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2350        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2351        let outcome_a = render_scene_composed(
2352            &mut fb_a,
2353            &mut zb_a,
2354            XRES as usize,
2355            XRES,
2356            YRES,
2357            fog,
2358            &mut scene_a,
2359            &cam,
2360            &settings,
2361            sky_color,
2362            None,
2363        );
2364        assert_eq!(outcome_a, RenderOutcome::Rendered { grids_drawn: 1 });
2365
2366        // Scene B: thresholds derived from the grid's bounding
2367        // radius. At this camera distance the grid lands on Mid or
2368        // Far; if S6.0 ever stops falling through to Near, this test
2369        // catches the divergence.
2370        let (mut scene_b, b_id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
2371        let radius = scene_b.grid(b_id).unwrap().bounding_radius();
2372        assert!(
2373            radius > 0.0,
2374            "bounding_radius should be > 0 for a populated grid"
2375        );
2376        scene_b.grid_mut(b_id).unwrap().lod_thresholds = crate::LodThresholds::from_radius(radius);
2377        // Sanity: the camera is far enough that the picker no longer
2378        // returns Near (otherwise the invariance test would be vacuous).
2379        let lod = scene_b
2380            .grid(b_id)
2381            .unwrap()
2382            .select_lod(DVec3::from_array(cam.pos));
2383        assert_ne!(
2384            lod,
2385            Lod::Near,
2386            "camera should land in Mid or Far for derived thresholds — got {lod:?}",
2387        );
2388
2389        let mut fb_b = vec![sky_color; pixel_count(XRES, YRES)];
2390        let mut zb_b = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2391        let outcome_b = render_scene_composed(
2392            &mut fb_b,
2393            &mut zb_b,
2394            XRES as usize,
2395            XRES,
2396            YRES,
2397            fog,
2398            &mut scene_b,
2399            &cam,
2400            &settings,
2401            sky_color,
2402            None,
2403        );
2404        assert_eq!(outcome_b, RenderOutcome::Rendered { grids_drawn: 1 });
2405
2406        // Byte-identity is the S6.0 contract — Mid/Far still take
2407        // the Near arm.
2408        assert_eq!(
2409            fb_a, fb_b,
2410            "S6.0 framebuffer must be byte-identical regardless of LOD thresholds"
2411        );
2412    }
2413
2414    #[test]
2415    fn render_scene_composed_empty_scene_returns_empty() {
2416        let mut scene = Scene::new();
2417        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2418        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2419        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2420        let camera = camera_at([0.0, 0.0, 0.0]);
2421        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2422        let outcome = render_scene_composed(
2423            &mut fb,
2424            &mut zb,
2425            XRES as usize,
2426            XRES,
2427            YRES,
2428            fog,
2429            &mut scene,
2430            &camera,
2431            &settings,
2432            sky_color,
2433            None,
2434        );
2435        assert_eq!(outcome, RenderOutcome::Empty);
2436        // fb should be unchanged (still all sky).
2437        assert!(fb.iter().all(|&p| p == sky_color));
2438    }
2439
2440    /// FNV-1a 64-bit hash. Same offset/prime as the
2441    /// `roxlap-oracle::fnv1a64` helper used by the wasm-render
2442    /// goldens; pinning a render hash here is the same flavour of
2443    /// regression catch.
2444    fn fnv1a64(data: &[u8]) -> u64 {
2445        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
2446        for &b in data {
2447            h ^= u64::from(b);
2448            h = h.wrapping_mul(0x0000_0100_0000_01b3);
2449        }
2450        h
2451    }
2452
2453    // ---- S4.0 cross-chunk smoke test ----
2454
2455    /// Two-chunk-wide grid: a recognisable shape spans the chunk
2456    /// boundary at `virtual_x = 128`. The render must not have a
2457    /// horizontal seam line at the boundary.
2458    #[test]
2459    fn render_scene_two_chunk_x_grid_no_seam() {
2460        let mut scene = Scene::new();
2461        let id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
2462        let g = scene.grid_mut(id).unwrap();
2463        // 100-voxel-tall stripe spanning x=[120..136] across the
2464        // x=128 chunk seam at z=200, y=[60..68]. After bake-free
2465        // render, every column in the stripe paints the same colour
2466        // at the same z; a seam at x=128 would show as missing
2467        // pixels in the column at virtual_x=128 / 129 / ...
2468        g.set_rect(
2469            IVec3::new(120, 60, 200),
2470            IVec3::new(136, 67, 215),
2471            Some(VoxColor(0x80_aa_55_22)),
2472        );
2473        // Sanity: ensure both chunks were materialised.
2474        assert_eq!(g.chunk_count(), 2);
2475
2476        // Render with a camera positioned to look at the stripe
2477        // straight on. Stripe at world (120..136, 260..268, 200..215).
2478        // Camera at (128, 100, 207) looking +y centres on it.
2479        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
2480        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2481        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2482        let camera = camera_at([128.0, 100.0, 207.0]);
2483        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2484        let outcome = render_scene_composed(
2485            &mut fb,
2486            &mut zb,
2487            XRES as usize,
2488            XRES,
2489            YRES,
2490            fog,
2491            &mut scene,
2492            &camera,
2493            &settings,
2494            sky_color,
2495            None,
2496        );
2497        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2498
2499        // Stripe colour should appear in roughly the centre of the
2500        // framebuffer. A chunk-edge seam would manifest as a thin
2501        // sky-coloured vertical line splitting the stripe in two.
2502        let stripe = 0x80_aa_55_22;
2503        let stripe_count = fb.iter().filter(|&&p| p == stripe).count();
2504        assert!(
2505            stripe_count > 200,
2506            "stripe rendered too few pixels ({stripe_count}) — chunks may not be stitching"
2507        );
2508
2509        // Walk the centre row left-to-right looking for a sky-pixel
2510        // gap inside a stripe run. A gap 1+ pixels wide flags a
2511        // chunk-edge seam.
2512        let centre_y = (YRES / 2) as usize;
2513        let row_start = centre_y * (XRES as usize);
2514        let row = &fb[row_start..row_start + (XRES as usize)];
2515        let mut in_stripe = false;
2516        let mut seam_gaps = 0usize;
2517        for &px in row {
2518            if px == stripe {
2519                in_stripe = true;
2520            } else if in_stripe && px == sky_color {
2521                // Stripe ended; if we re-enter it on this row that's
2522                // a seam.
2523                if row.iter().skip_while(|&&p| p != px).any(|&p| p == stripe) {
2524                    // Look ahead for any further stripe pixel.
2525                    seam_gaps += 1;
2526                }
2527                in_stripe = false;
2528            }
2529        }
2530        // We allow seam_gaps to count the legitimate "stripe ended,
2531        // didn't restart" transition once; more than that means
2532        // multiple disjoint runs on the row → seam.
2533        assert!(
2534            seam_gaps <= 1,
2535            "centre row has {seam_gaps} disjoint stripe runs — expected 1 (chunk-edge seam suspected)"
2536        );
2537    }
2538
2539    // DDA.9: the voxlap-era mip regression tests here
2540    // (`vxl_generate_mips_on_set_voxel_chunk_renders` + the byte-exact
2541    // 2-chunk opticast pin) were removed — they drove voxlap `opticast` +
2542    // `ScalarRasterizer` directly, a path no longer reachable from this
2543    // consumer crate. The DDA mip ladder + multi-mip render is covered by
2544    // `render_with_mips_present_still_renders_mip0` and the
2545    // `stacked_*_multi_mip` tests below.
2546
2547    /// Mip-0 preservation when mips are generated on the combined
2548    /// view but `mip_levels = 1` in the rasterizer's settings.
2549    /// Confirms `generate_mips` only APPENDS data — mip-0
2550    /// prefix is unchanged.
2551    #[test]
2552    fn render_with_mips_present_still_renders_mip0() {
2553        let mut scene = Scene::new();
2554        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2555        scene.grid_mut(id).unwrap().set_rect(
2556            IVec3::new(40, 40, 40),
2557            IVec3::new(55, 55, 55),
2558            Some(VoxColor(0x80_88_88_88)),
2559        );
2560        // S4B.4.a: force mip-1..mip-2 generation on the single
2561        // chunk directly (the Grid's combined-view cache API was
2562        // removed). The chunk's own Vxl::generate_mips builds its
2563        // own mip tables and the renderer happens to render through
2564        // them via Approach B's chunk_at_xy lookup.
2565        {
2566            let grid = scene.grid_mut(id).unwrap();
2567            let chunk = grid.chunks.get_mut(&IVec3::ZERO).unwrap();
2568            chunk.generate_mips(3);
2569        }
2570
2571        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2572        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2573        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2574        let camera = camera_at([64.0, 0.0, 64.0]);
2575        // mip_scan_dist huge → renderer never transitions past mip-0
2576        // so this test pins mip-0 correctness only.
2577        let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2578        settings.mip_scan_dist = 100_000;
2579        let outcome = render_scene_composed(
2580            &mut fb,
2581            &mut zb,
2582            XRES as usize,
2583            XRES,
2584            YRES,
2585            fog,
2586            &mut scene,
2587            &camera,
2588            &settings,
2589            sky_color,
2590            None,
2591        );
2592        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2593        let non_sky = fb.iter().filter(|&&p| p != sky_color).count();
2594        assert!(
2595            non_sky > 0,
2596            "render of single-grid scene with mips present rendered all-sky: mip-0 may be corrupted by generate_mips"
2597        );
2598    }
2599
2600    #[test]
2601    fn render_scene_two_chunk_x_grid_hash_is_stable() {
2602        // Frozen 2026-05-10 at S4.0 landing on x86_64.
2603        // DDA.9: re-frozen to the DDA renderer's output (was the
2604        // voxlap-opticast golden 0x215e_d66d_7359_4725).
2605        const GOLDEN: u64 = 0x492e_c4bb_718f_d7e5;
2606        // Same scene shape as `render_scene_two_chunk_x_grid_no_seam`
2607        // — kept distinct so the hash assertion doesn't share its
2608        // setup with the structural seam check.
2609        let mut scene = Scene::new();
2610        let id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
2611        scene.grid_mut(id).unwrap().set_rect(
2612            IVec3::new(120, 60, 200),
2613            IVec3::new(136, 67, 215),
2614            Some(VoxColor(0x80_aa_55_22)),
2615        );
2616        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
2617        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2618        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2619        let camera = camera_at([128.0, 100.0, 207.0]);
2620        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2621        let outcome = render_scene_composed(
2622            &mut fb,
2623            &mut zb,
2624            XRES as usize,
2625            XRES,
2626            YRES,
2627            fog,
2628            &mut scene,
2629            &camera,
2630            &settings,
2631            sky_color,
2632            None,
2633        );
2634        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2635
2636        let bytes: Vec<u8> = fb.iter().flat_map(|p| p.to_ne_bytes()).collect();
2637        let hash = fnv1a64(&bytes);
2638        if GOLDEN == SENTINEL {
2639            // First-run capture mode — print the hash so the
2640            // developer can paste it into GOLDEN above.
2641            eprintln!("render_scene_two_chunk_x_grid_hash_is_stable: capture hash = 0x{hash:016x}");
2642            panic!("GOLDEN is the SENTINEL placeholder — paste 0x{hash:016x} into GOLDEN above");
2643        }
2644        assert_eq!(
2645            hash, GOLDEN,
2646            "2-chunk render hash drifted: expected 0x{GOLDEN:016x}, got 0x{hash:016x}"
2647        );
2648    }
2649
2650    /// Sentinel for first-run hash capture in
2651    /// [`render_scene_two_chunk_x_grid_hash_is_stable`]. Replace
2652    /// `GOLDEN`'s definition with the printed value once captured.
2653    const SENTINEL: u64 = 0xDEAD_BEEF_DEAD_BEEF;
2654
2655    /// S4B.6.c: stacked-grid scaffold — camera in chz=1 (= world
2656    /// z=256..511) of a 2-chunk-tall grid should render its own
2657    /// chunk's terrain. Verifies cf seed + slab-byte reads + chunk-
2658    /// XY swaps all use world-z consistently.
2659    ///
2660    /// Cross-chunk look-down (= camera in chz=0 sees terrain in
2661    /// chz=1) needs cf z range extension at air-gap-lookup time;
2662    /// that's a follow-up to S4B.6.c.
2663    #[test]
2664    fn stacked_two_chunk_z_camera_in_chz1_sees_own_chunk_floor() {
2665        let mut scene = Scene::new();
2666        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2667        let g = scene.grid_mut(id).unwrap();
2668        // chz=0: all-air (materialised so chunk_xyz_backing enumerates).
2669        g.ensure_chunk(IVec3::new(0, 0, 0));
2670        // chz=1: floor at local z=50 (= world z=306).
2671        g.set_rect(
2672            IVec3::new(60, 60, 306),
2673            IVec3::new(72, 72, 310),
2674            Some(VoxColor(0x80_33_66_99)),
2675        );
2676        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
2677
2678        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
2679        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2680        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2681        // Camera at world (66, 66, 280) — directly above the
2682        // floor at world z=306. Look STRAIGHT DOWN (z increases =
2683        // down in voxlap z-down).
2684        let camera = Camera {
2685            pos: [66.0, 66.0, 280.0],
2686            right: [1.0, 0.0, 0.0],
2687            down: [0.0, 1.0, 0.0],
2688            forward: [0.0, 0.0, 1.0],
2689        };
2690        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2691        let outcome = render_scene_composed(
2692            &mut fb,
2693            &mut zb,
2694            XRES as usize,
2695            XRES,
2696            YRES,
2697            fog,
2698            &mut scene,
2699            &camera,
2700            &settings,
2701            sky_color,
2702            None,
2703        );
2704        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2705        let floor_count = fb.iter().filter(|&&p| p == 0x80_33_66_99).count();
2706        assert!(
2707            floor_count > 100,
2708            "camera at chz=1 with floor in same chunk should see it — got {floor_count} floor pixels"
2709        );
2710    }
2711
2712    /// S4B.6.e: cross-chunk look-down. Camera in chz=0's all-air
2713    /// chunk should see chz=1's floor below it. This was deferred
2714    /// from S4B.6.c because the cf seed's z range capped at the
2715    /// camera-chunk's bedrock (world z=255); S4B.6.e extends the
2716    /// air-gap walk in `camera_chunk_air_gap` to step into the
2717    /// next chunk down when the camera's column is all-air-bedrock,
2718    /// and the rasterizer routes state.column / slab_buf to the
2719    /// chunk holding the real floor via `seed_chunk_z`.
2720    #[test]
2721    fn stacked_two_chunk_z_camera_in_chz0_sees_chz1_floor() {
2722        let mut scene = Scene::new();
2723        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2724        let g = scene.grid_mut(id).unwrap();
2725        // chz=0: all-air. Materialised so chunk_xyz_backing
2726        // enumerates it.
2727        g.ensure_chunk(IVec3::new(0, 0, 0));
2728        // chz=1: floor at world z=306..310 (= local z=50..54).
2729        g.set_rect(
2730            IVec3::new(60, 60, 306),
2731            IVec3::new(72, 72, 310),
2732            Some(VoxColor(0x80_77_aa_44)),
2733        );
2734        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
2735
2736        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
2737        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2738        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2739        // Camera at world (66, 66, 100) — in chz=0's all-air
2740        // chunk. Look STRAIGHT DOWN (z+) toward chz=1's floor at
2741        // world z=306.
2742        let camera = Camera {
2743            pos: [66.0, 66.0, 100.0],
2744            right: [1.0, 0.0, 0.0],
2745            down: [0.0, 1.0, 0.0],
2746            forward: [0.0, 0.0, 1.0],
2747        };
2748        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2749        let outcome = render_scene_composed(
2750            &mut fb,
2751            &mut zb,
2752            XRES as usize,
2753            XRES,
2754            YRES,
2755            fog,
2756            &mut scene,
2757            &camera,
2758            &settings,
2759            sky_color,
2760            None,
2761        );
2762        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2763        let floor_count = fb.iter().filter(|&&p| p == 0x80_77_aa_44).count();
2764        assert!(
2765            floor_count > 50,
2766            "camera in chz=0 air-gap should see chz=1 floor via cross-chunk look-down — got {floor_count} floor pixels"
2767        );
2768    }
2769
2770    /// S4B.6.l KNOWN LIMITATION → RESOLVED by VC.5 (2026-05-31).
2771    /// Camera at chz=0 with all-air-bedrock at the camera's own
2772    /// XY column (seed_chz=1 via cross-chunk look-down). A DIFFERENT
2773    /// XY column has chz=0 content (= a distant mountain entirely
2774    /// inside chz=0). Pre-VC.5 the chunk-XY swap read chz=1 chunks
2775    /// across the DDA, so the chz=0 mountain was invisible. VC.5's
2776    /// multi-chz column-step install stitches every chz layer at the
2777    /// new XY column; the chz=0 mountain renders correctly.
2778    ///
2779    /// VC.0 pin (2026-05-31): re-enabled (was `#[ignore]`'d). VC.5
2780    /// flipped it from failing (mountain_chz0 = 0) to passing.
2781    #[test]
2782    fn stacked_chz0_distant_mountain_visible_from_chz0_camera() {
2783        let mut scene = Scene::new();
2784        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2785        let g = scene.grid_mut(id).unwrap();
2786        // chz=0 mountain at a column DISTANT from the camera —
2787        // entirely in chz=0 (world z=100..200), so chz=1 at the
2788        // same XY is all-air-bedrock.
2789        g.set_rect(
2790            IVec3::new(100, 100, 100),
2791            IVec3::new(124, 124, 200),
2792            Some(VoxColor(0x80_aa_55_22)), // distinct brown
2793        );
2794        // chz=1 hills filling the floor at world z=336..360 across
2795        // the chunk EXCEPT a hole around the mountain XY (so the
2796        // mountain doesn't sit on a green tower).
2797        g.set_rect(
2798            IVec3::new(0, 0, 336),
2799            IVec3::new(128, 128, 360),
2800            Some(VoxColor(0x80_22_88_44)),
2801        );
2802        g.set_rect(IVec3::new(100, 100, 336), IVec3::new(124, 124, 360), None);
2803        // Materialise chz=0 + chz=1 (chz=0 has the mountain; chz=1
2804        // has the hills).
2805        assert!(g.chunk(IVec3::new(0, 0, 0)).is_some());
2806        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
2807
2808        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2809        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2810        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2811        // Camera at (40, 40, 60) — chz=0 air, FAR from the mountain
2812        // XY (100..124, 100..124). Yaw=π/4 (look toward +x+y =
2813        // mountain direction), pitch=0.72 rad (≈ 41° down) so the
2814        // ray bisecting the screen aims at the chz=0 mountain centre
2815        // ≈ (112, 112, 150).
2816        let camera = Camera::from_yaw_pitch([40.0, 40.0, 60.0], std::f64::consts::FRAC_PI_4, 0.72);
2817        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2818        let outcome = render_scene_composed(
2819            &mut fb,
2820            &mut zb,
2821            XRES as usize,
2822            XRES,
2823            YRES,
2824            fog,
2825            &mut scene,
2826            &camera,
2827            &settings,
2828            sky_color,
2829            None,
2830        );
2831        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2832        let mountain_count = fb.iter().filter(|&&p| p == 0x80_aa_55_22).count();
2833        let hill_count = fb.iter().filter(|&&p| p == 0x80_22_88_44).count();
2834        eprintln!("chz0-distant-mountain: mountain_chz0={mountain_count} hill_chz1={hill_count}");
2835        // chz=1 hills are reachable via seed-time cross-chunk
2836        // look-down.
2837        assert!(
2838            hill_count > 50,
2839            "expected chz=1 hills via cross-chunk look-down — got {hill_count}"
2840        );
2841        // The proper-fix assertion: chz=0 distant mountain SHOULD be
2842        // visible. Currently fails — pins the limitation.
2843        assert!(
2844            mountain_count > 50,
2845            "expected chz=0 distant mountain visible — got {mountain_count} (S4B.6.l limitation)"
2846        );
2847    }
2848
2849    /// S4B.6.h: mid-render chunk-Z handoff. Camera column has
2850    /// content in chz=0 (= a mountain at the camera's XY) so
2851    /// seed-time cross-chunk look-down does NOT fire — seed_chz=0.
2852    /// As rays DDA across the scene, they visit XY columns where
2853    /// chz=0 is all-air-bedrock. Mid-render handoff should swap
2854    /// state to chz=1's column at those XY positions and reveal
2855    /// hill content sitting under the camera's chz=0 layer.
2856    ///
2857    /// This is the "tall mountains breaching chunk-Z boundary"
2858    /// case the demo aims for.
2859    #[test]
2860    fn mid_render_handoff_reveals_chz1_hills_under_mountain_camera() {
2861        let mut scene = Scene::new();
2862        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2863        let g = scene.grid_mut(id).unwrap();
2864        // chz=0: a small "mountain peak" at the camera's XY.
2865        // Mountain at world z=150..200 — solid block.
2866        g.set_rect(
2867            IVec3::new(60, 60, 150),
2868            IVec3::new(72, 72, 200),
2869            Some(VoxColor(0x80_88_44_22)), // brown mountain
2870        );
2871        // chz=1: hills at world z=336..360 across the WHOLE chunk
2872        // (so DDA rays hit them when chz=0 is air).
2873        g.set_rect(
2874            IVec3::new(0, 0, 336),
2875            IVec3::new(128, 128, 360),
2876            Some(VoxColor(0x80_22_88_44)), // green hills
2877        );
2878        // Carve a hole in chz=1's hill at the mountain's footprint
2879        // so the mountain doesn't appear to "float" on green.
2880        g.set_rect(IVec3::new(60, 60, 336), IVec3::new(72, 72, 360), None);
2881        assert!(g.chunk(IVec3::new(0, 0, 0)).is_some());
2882        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
2883
2884        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
2885        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2886        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2887        // Camera at world (66, 66, 100) — directly above the
2888        // mountain peak (at z=150). Camera column has the
2889        // mountain in chz=0. Look straight down.
2890        let camera = Camera {
2891            pos: [66.0, 66.0, 100.0],
2892            right: [1.0, 0.0, 0.0],
2893            down: [0.0, 1.0, 0.0],
2894            forward: [0.0, 0.0, 1.0],
2895        };
2896        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2897        let outcome = render_scene_composed(
2898            &mut fb,
2899            &mut zb,
2900            XRES as usize,
2901            XRES,
2902            YRES,
2903            fog,
2904            &mut scene,
2905            &camera,
2906            &settings,
2907            sky_color,
2908            None,
2909        );
2910        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2911        let mountain_count = fb.iter().filter(|&&p| p == 0x80_88_44_22).count();
2912        let hill_count = fb.iter().filter(|&&p| p == 0x80_22_88_44).count();
2913        // Verify the hills render at approximately the correct
2914        // world-z by sampling the z-buffer at hill pixels. Camera
2915        // at z=100 looking straight down; hills at world z=336.
2916        // Expected depth = 236 for directly-below pixels. If
2917        // state.z1 stays stuck at the mountain peak's z=150 the
2918        // hills would render with depth ≈ 50 → orders of magnitude
2919        // off.
2920        let mut hill_depths: Vec<f32> = fb
2921            .iter()
2922            .zip(zb.iter())
2923            .filter_map(|(&p, &d)| if p == 0x80_22_88_44 { Some(d) } else { None })
2924            .collect();
2925        hill_depths.sort_by(|a, b| a.partial_cmp(b).unwrap());
2926        let median_hill_depth = hill_depths[hill_depths.len() / 2];
2927        eprintln!(
2928            "mid-render handoff: mountain={mountain_count} hill={hill_count} median_hill_depth={median_hill_depth:.1}"
2929        );
2930        assert!(
2931            mountain_count > 50,
2932            "should see mountain peak via chz=0 — got {mountain_count} mountain pixels"
2933        );
2934        assert!(
2935            hill_count > 50,
2936            "should see chz=1 hills via mid-render handoff — got {hill_count} hill pixels"
2937        );
2938        assert!(
2939            (median_hill_depth - 236.0).abs() < 80.0,
2940            "hill median depth should be ≈236 (camera→z=336); got {median_hill_depth:.1} — state.z1 may be stale at the mountain peak's z"
2941        );
2942    }
2943
2944    /// S4B.6.g: cross-chunk look-down under multi-mip. Same scene
2945    /// as `stacked_two_chunk_z_camera_in_chz0_sees_chz1_floor` but
2946    /// with `mip_levels=2, mip_scan_dist=16` so the rasterizer
2947    /// transitions to mip-1 well within the chz=1 terrain. Locks in
2948    /// the slab_z_at mip-N offset fix (= `chunk_world_z_base >>
2949    /// gmipcnt`). Pre-fix produced a green / brown "wall in a circle
2950    /// around the camera" because mip-1 rendered the floor at
2951    /// world-z ≈ 178 instead of 306.
2952    #[test]
2953    fn stacked_two_chunk_z_camera_in_chz0_sees_chz1_floor_multi_mip() {
2954        let mut scene = Scene::new();
2955        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2956        let g = scene.grid_mut(id).unwrap();
2957        g.ensure_chunk(IVec3::new(0, 0, 0));
2958        g.set_rect(
2959            IVec3::new(60, 60, 306),
2960            IVec3::new(72, 72, 310),
2961            Some(VoxColor(0x80_77_aa_44)),
2962        );
2963        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
2964
2965        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
2966        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2967        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2968        let camera = Camera {
2969            pos: [66.0, 66.0, 100.0],
2970            right: [1.0, 0.0, 0.0],
2971            down: [0.0, 1.0, 0.0],
2972            forward: [0.0, 0.0, 1.0],
2973        };
2974        let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2975        settings.mip_levels = 2;
2976        settings.mip_scan_dist = 16;
2977        let outcome = render_scene_composed(
2978            &mut fb,
2979            &mut zb,
2980            XRES as usize,
2981            XRES,
2982            YRES,
2983            fog,
2984            &mut scene,
2985            &camera,
2986            &settings,
2987            sky_color,
2988            None,
2989        );
2990        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2991        let floor_count = fb.iter().filter(|&&p| p == 0x80_77_aa_44).count();
2992        assert!(
2993            floor_count > 50,
2994            "multi-mip cross-chunk look-down should still see chz=1 floor — got {floor_count} floor pixels"
2995        );
2996    }
2997
2998    /// S4B.6.d: 3-chunk-tall stack stresses the widened gylookup
2999    /// (`(chunks_z * 512) >> mip + 4` per mip). Pre-S4B.6.d, gylookup
3000    /// was hardcoded at `(512 >> mip) + 4`, which would OOB or alias
3001    /// for any z > 511. This test renders a floor at world z=562
3002    /// (= chz=2, local z=50) with the camera at world z=540, looking
3003    /// straight down. Multi-mip is on so we exercise the mip slide
3004    /// path in `phase_remiporend` that scales `advance` by chunks_z.
3005    #[test]
3006    fn stacked_three_chunk_z_camera_in_chz2_sees_own_chunk_floor_multi_mip() {
3007        let mut scene = Scene::new();
3008        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3009        let g = scene.grid_mut(id).unwrap();
3010        // Materialise chz=0 + chz=1 so chunk_xyz_backing enumerates
3011        // the full stack.
3012        g.ensure_chunk(IVec3::new(0, 0, 0));
3013        g.ensure_chunk(IVec3::new(0, 0, 1));
3014        // chz=2: floor at world z=562..566 (= local z=50..54).
3015        g.set_rect(
3016            IVec3::new(60, 60, 562),
3017            IVec3::new(72, 72, 566),
3018            Some(VoxColor(0x80_aa_55_22)),
3019        );
3020        assert!(g.chunk(IVec3::new(0, 0, 2)).is_some());
3021
3022        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
3023        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3024        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3025        let camera = Camera {
3026            pos: [66.0, 66.0, 540.0],
3027            right: [1.0, 0.0, 0.0],
3028            down: [0.0, 1.0, 0.0],
3029            forward: [0.0, 0.0, 1.0],
3030        };
3031        // Multi-mip on to exercise the gylookup-slide path.
3032        let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3033        settings.mip_levels = 2;
3034        settings.mip_scan_dist = 16;
3035        let outcome = render_scene_composed(
3036            &mut fb,
3037            &mut zb,
3038            XRES as usize,
3039            XRES,
3040            YRES,
3041            fog,
3042            &mut scene,
3043            &camera,
3044            &settings,
3045            sky_color,
3046            None,
3047        );
3048        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
3049        let floor_count = fb.iter().filter(|&&p| p == 0x80_aa_55_22).count();
3050        assert!(
3051            floor_count > 100,
3052            "camera at chz=2 with floor in same chunk should see it — got {floor_count} floor pixels"
3053        );
3054    }
3055
3056    // ---- S7.4: render integration with streaming ----
3057
3058    /// Floor-stamping generator for S7.4 render tests. Produces a
3059    /// 10-voxel-thick floor at the bottom of every chunk it
3060    /// generates (chunk-local `z = 230..239`, all xy). Visible as
3061    /// a green stripe along the bottom of the framebuffer when
3062    /// the camera looks +y across populated chunks.
3063    #[derive(Debug)]
3064    struct FloorGenerator;
3065
3066    impl crate::ChunkGenerator for FloorGenerator {
3067        fn generate(&self, _chunk_idx: IVec3) -> roxlap_formats::vxl::Vxl {
3068            // Lean on `Grid::ensure_chunk` for the empty-chunk
3069            // builder, then carve a floor via `set_rect`. Detach
3070            // the chunk from the temporary grid and return it.
3071            let mut tmp = crate::Grid::new(GridTransform::identity());
3072            tmp.ensure_chunk(IVec3::ZERO);
3073            let mut vxl = tmp.chunks.remove(&IVec3::ZERO).unwrap();
3074            #[allow(clippy::cast_possible_wrap)]
3075            roxlap_formats::edit::set_rect(
3076                &mut vxl,
3077                glam::IVec3::new(0, 0, 230).into(),
3078                glam::IVec3::new((CHUNK_SIZE_XY - 1) as i32, (CHUNK_SIZE_XY - 1) as i32, 239)
3079                    .into(),
3080                Some(VoxColor(0x80_22_aa_22)),
3081            );
3082            vxl
3083        }
3084    }
3085
3086    #[test]
3087    fn render_scene_composed_unpumped_streaming_grid_renders_all_sky() {
3088        // S7.4(a): a grid with a generator + active stream radius
3089        // but no pump_streaming call has zero chunks. The render
3090        // walks the grid (chunk_xyz_backing returns None for an
3091        // empty chunk map → grid is skipped), framebuffer stays
3092        // sky.
3093        use std::sync::Arc;
3094        let mut scene = Scene::new();
3095        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3096        let g = scene.grid_mut(id).unwrap();
3097        g.set_generator(Some(Arc::new(FloorGenerator)));
3098        g.stream_radius = crate::StreamRadius::new(300.0, 600.0);
3099        assert!(g.chunks.is_empty(), "no pump yet → no chunks");
3100
3101        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
3102        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3103        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3104        // Camera at (64, -100, 200) looking +y so it would see
3105        // chunks ahead once they exist.
3106        let camera = camera_at([64.0, -100.0, 200.0]);
3107        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3108        let _ = render_scene_composed(
3109            &mut fb,
3110            &mut zb,
3111            XRES as usize,
3112            XRES,
3113            YRES,
3114            fog,
3115            &mut scene,
3116            &camera,
3117            &settings,
3118            sky_color,
3119            None,
3120        );
3121        // Empty grid path skips opticast → framebuffer untouched.
3122        assert!(
3123            fb.iter().all(|&p| p == sky_color),
3124            "unpumped streaming grid must render as all sky"
3125        );
3126    }
3127
3128    #[test]
3129    fn render_scene_composed_picks_up_streamed_chunks_after_sync_pump() {
3130        // S7.4(a): once the streaming pump installs chunks, the
3131        // next render shows them. Using pump_streaming_sync for
3132        // deterministic timing — pump_streaming (async) lands
3133        // the same way modulo a frame of latency.
3134        use std::sync::Arc;
3135        let mut scene = Scene::new();
3136        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3137        let g = scene.grid_mut(id).unwrap();
3138        g.set_generator(Some(Arc::new(FloorGenerator)));
3139        // Cover chunks ahead of the camera (y=0, y=128, y=256).
3140        g.stream_radius = crate::StreamRadius::new(300.0, 600.0);
3141
3142        // Render BEFORE pump: zero floor pixels.
3143        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
3144        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3145        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3146        let camera = camera_at([64.0, -100.0, 200.0]);
3147        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3148        let _ = render_scene_composed(
3149            &mut fb,
3150            &mut zb,
3151            XRES as usize,
3152            XRES,
3153            YRES,
3154            fog,
3155            &mut scene,
3156            &camera,
3157            &settings,
3158            sky_color,
3159            None,
3160        );
3161        let pre_floor = fb.iter().filter(|&&p| p == 0x80_22_aa_22).count();
3162        assert_eq!(pre_floor, 0, "pre-pump frame has no streamed chunks");
3163
3164        // Pump synchronously — `world_pos` matches the camera so
3165        // chunks ahead of it (within r_active = 300) stream in.
3166        scene.pump_streaming_sync(DVec3::new(64.0, -100.0, 200.0));
3167        let g = scene.grid(id).unwrap();
3168        assert!(
3169            !g.chunks.is_empty(),
3170            "pump should have streamed at least one chunk"
3171        );
3172
3173        // Render AFTER pump: the floor should now be visible. Reset
3174        // the framebuffer to sky first.
3175        fb.iter_mut().for_each(|p| *p = sky_color);
3176        zb.iter_mut().for_each(|z| *z = f32::INFINITY);
3177        let outcome = render_scene_composed(
3178            &mut fb,
3179            &mut zb,
3180            XRES as usize,
3181            XRES,
3182            YRES,
3183            fog,
3184            &mut scene,
3185            &camera,
3186            &settings,
3187            sky_color,
3188            None,
3189        );
3190        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
3191        let post_floor = fb.iter().filter(|&&p| p == 0x80_22_aa_22).count();
3192        assert!(
3193            post_floor > 100,
3194            "post-pump frame should show the streamed floor — got {post_floor} green pixels"
3195        );
3196    }
3197
3198    #[test]
3199    fn render_scene_composed_partial_streaming_renders_pending_chunks_as_air() {
3200        // S7.4(a): mixed state — some r_active chunks are
3201        // materialised, others are still pending (not in
3202        // `chunks`). The render must treat pending chunks as
3203        // implicit-air. Verified by stamping one chunk via the
3204        // generator + skipping the others, then confirming the
3205        // framebuffer has fewer floor pixels than the
3206        // fully-pumped baseline.
3207        use std::sync::Arc;
3208        let mut scene = Scene::new();
3209        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3210        let g = scene.grid_mut(id).unwrap();
3211        g.set_generator(Some(Arc::new(FloorGenerator)));
3212        // r_active must be set so the later pump_streaming_sync
3213        // sanity-check actually streams more chunks in.
3214        g.stream_radius = crate::StreamRadius::new(400.0, 800.0);
3215
3216        // Materialise ONLY chunk (0, 0, 0) manually via the
3217        // sync helper — leave (0, 1, 0), (0, 2, 0) absent.
3218        let installed = g.ensure_chunk_generated(IVec3::ZERO);
3219        assert!(installed, "manual install of one chunk");
3220        assert_eq!(g.chunks.len(), 1);
3221        // Make sure (0, 1, 0), (0, 2, 0) are NOT present.
3222        assert!(g.chunk(IVec3::new(0, 1, 0)).is_none());
3223        assert!(g.chunk(IVec3::new(0, 2, 0)).is_none());
3224
3225        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
3226        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3227        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3228        // Camera inside chunk (0, 0, 0); looking +y means the
3229        // floor of (0, 0, 0) gets rendered until the ray walks
3230        // off the chunk into implicit-air space at y=128. No
3231        // floor pixels past that distance.
3232        let camera = camera_at([64.0, 32.0, 200.0]);
3233        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3234        let _ = render_scene_composed(
3235            &mut fb,
3236            &mut zb,
3237            XRES as usize,
3238            XRES,
3239            YRES,
3240            fog,
3241            &mut scene,
3242            &camera,
3243            &settings,
3244            sky_color,
3245            None,
3246        );
3247        let floor_pixels = fb.iter().filter(|&&p| p == 0x80_22_aa_22).count();
3248        // Visible floor inside chunk (0,0,0); pending neighbours
3249        // contribute nothing. The number isn't pinned exactly —
3250        // it just needs to be non-zero (we have content) and
3251        // less than what a fully-streamed scene would produce.
3252        assert!(
3253            floor_pixels > 0,
3254            "should see at least some floor from the loaded chunk"
3255        );
3256        // Sanity: stream the missing chunks; verify the floor
3257        // pixel count goes up.
3258        scene.pump_streaming_sync(DVec3::new(64.0, 32.0, 200.0));
3259        assert!(scene.grid(id).unwrap().chunk_count() >= 2);
3260        fb.iter_mut().for_each(|p| *p = sky_color);
3261        zb.iter_mut().for_each(|z| *z = f32::INFINITY);
3262        let _ = render_scene_composed(
3263            &mut fb,
3264            &mut zb,
3265            XRES as usize,
3266            XRES,
3267            YRES,
3268            fog,
3269            &mut scene,
3270            &camera,
3271            &settings,
3272            sky_color,
3273            None,
3274        );
3275        let floor_pixels_full = fb.iter().filter(|&&p| p == 0x80_22_aa_22).count();
3276        assert!(
3277            floor_pixels_full > floor_pixels,
3278            "fully-streamed scene should show more floor than partial: \
3279             partial={floor_pixels} full={floor_pixels_full}"
3280        );
3281    }
3282}