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