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