Skip to main content

concinnity_render/
reflection_probe.rs

1//! Reflection probe capture math: the six cube-face view-projection matrices a
2//! probe renders the scene through, plus the load-time conversion of the
3//! captured faces into the prefiltered IBL payload the environment sampler
4//! consumes. Backend-agnostic; the Metal backend drives the actual scene render
5//! into each face (see metal/probe.rs). DirectX / Vulkan can reuse this math.
6//!
7//! Face order and orientation match the engine's cube convention (the build-time
8//! `concinnity-cook::hdr` equirect resampler and core `environment_map`'s private
9//! `cube_texel_dir`):
10//!   0:+X 1:-X 2:+Y 3:-Y 4:+Z 5:-Z, with a face texel at (u,v) in [-1,1]
11//! looking along `cube_texel_dir(face, u, v)`. Each face's view-projection is
12//! built so that direction projects to NDC (u, -v) (screen-down is +v), which
13//! the orientation test pins exactly.
14
15use crate::build::environment_map as em;
16use alloc::vec;
17use alloc::vec::Vec;
18use concinnity_core::gfx::projection::perspective_rh;
19use concinnity_core::gfx::transform::mat4_mul;
20use concinnity_core::math::vec3::dot;
21use concinnity_core::math::{ceil, floor, powi, round, sqrt};
22use core::f32::consts::FRAC_PI_2;
23
24// Per-face camera basis (right, up, forward) in world space, derived so that a
25// 90-degree view down -forward reproduces `cube_texel_dir`. forward = the face
26// axis; right = d/du of the face direction; up = -d/dv.
27const FACE_BASIS: [[[f32; 3]; 3]; 6] = [
28    // [right, up, forward]
29    [[0.0, 0.0, -1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]], // 0 +X
30    [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]], // 1 -X
31    [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]], // 2 +Y
32    [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, 0.0]], // 3 -Y
33    [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],  // 4 +Z
34    [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], // 5 -Z
35];
36
37// One cube face: a 90-degree vertical field of view at a square aspect.
38fn perspective_90(near: f32, far: f32) -> [[f32; 4]; 4] {
39    perspective_rh(FRAC_PI_2, 1.0, near, far)
40}
41
42// World->view matrix for a face's (right, up, forward) basis at `eye`, looking
43// down -forward (same form as csm::look_at). Column-major arr[col][row].
44fn face_view(eye: [f32; 3], r: [f32; 3], u: [f32; 3], f: [f32; 3]) -> [[f32; 4]; 4] {
45    [
46        [r[0], u[0], -f[0], 0.0],
47        [r[1], u[1], -f[1], 0.0],
48        [r[2], u[2], -f[2], 0.0],
49        [-dot(r, eye), -dot(u, eye), dot(f, eye), 1.0],
50    ]
51}
52
53/// The view-projection for cube face `face` (0..6) captured from `eye`.
54pub fn face_view_projection(eye: [f32; 3], face: usize, near: f32, far: f32) -> [[f32; 4]; 4] {
55    let b = FACE_BASIS[face];
56    let view = face_view(eye, b[0], b[1], b[2]);
57    mat4_mul(perspective_90(near, far), view)
58}
59
60/// The world->view matrix alone for cube face `face`, captured from `eye`. The
61/// main pass needs both the combined view-projection (vertex clip transform) and
62/// the bare view matrix (some shaders reconstruct view-space data), so the probe
63/// capture builds a `ViewUniforms` from this plus `face_view_projection`.
64pub fn face_view_matrix(eye: [f32; 3], face: usize) -> [[f32; 4]; 4] {
65    let b = FACE_BASIS[face];
66    face_view(eye, b[0], b[1], b[2])
67}
68
69/// Where one reflection probe is captured from and the influence box it serves.
70/// Backend-agnostic: the renderer bakes a cube at `position` and, for a surface
71/// inside [box_min, box_max], selects this probe and parallax-corrects against the
72/// box. Derived from a declared `ReflectionProbe` asset or `auto_seed_probes`.
73#[derive(Clone, Copy, Debug, PartialEq)]
74pub struct ProbePlacement {
75    /// World-space position.
76    pub position: [f32; 3],
77    /// Lower corner of the probe's parallax box.
78    pub box_min: [f32; 3],
79    /// Upper corner of the probe's parallax box.
80    pub box_max: [f32; 3],
81}
82
83impl ProbePlacement {
84    /// Build a placement from an authored `ReflectionProbe` (capture point +
85    /// half-extents): the box is `position` plus or minus `half_extents`.
86    pub fn from_center_extents(position: [f32; 3], half_extents: [f32; 3]) -> ProbePlacement {
87        ProbePlacement {
88            position,
89            box_min: [
90                position[0] - half_extents[0],
91                position[1] - half_extents[1],
92                position[2] - half_extents[2],
93            ],
94            box_max: [
95                position[0] + half_extents[0],
96                position[1] + half_extents[1],
97                position[2] + half_extents[2],
98            ],
99        }
100    }
101}
102
103/// Tracks how far a staggered probe bake has progressed. Baking every probe on
104/// one frame stalls proportionally to the probe count; instead the renderer bakes
105/// a bounded budget per frame and walks this cursor, so the load cost is spread
106/// and unbaked probes fall back to the sky until their turn. Indices are handed
107/// out in order so the baked cube array stays aligned with the placement list.
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub struct ProbeBakeQueue {
110    total: usize,
111    next: usize,
112}
113
114impl ProbeBakeQueue {
115    /// A queue seeded with `total` probes waiting to bake.
116    pub fn new(total: usize) -> ProbeBakeQueue {
117        ProbeBakeQueue { total, next: 0 }
118    }
119
120    /// Whether any placement is still waiting to bake.
121    pub fn pending(&self) -> bool {
122        self.next < self.total
123    }
124
125    /// The next placement index to bake, advancing the cursor. `None` when done.
126    pub fn take_next(&mut self) -> Option<usize> {
127        (self.next < self.total).then(|| {
128            let i = self.next;
129            self.next += 1;
130            i
131        })
132    }
133
134    /// Abandon every remaining placement (e.g. a permanently ineligible world or
135    /// an unrecoverable bake error). Leaves the queue not `pending`.
136    pub fn abort(&mut self) {
137        self.next = self.total;
138    }
139}
140
141/// Phase of the single in-flight asynchronous bake. Exactly one probe is baked at
142/// a time, walking three phases across frames so the render thread never blocks on
143/// the capture: `Rendering` (six faces submitted, GPU running), `Converting` (faces
144/// read back, the prefilter convolution running off the render thread), and `Idle`
145/// (nothing in flight). The renderer holds the GPU resources for the in-flight
146/// probe; this enum only names which phase it is in.
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub enum BakePhase {
149    /// Nothing to bake.
150    Idle,
151    /// Rendering the probe's cube faces.
152    Rendering,
153    /// Converting the rendered faces into the prefiltered cube.
154    Converting,
155}
156
157/// What the renderer should do this frame to advance the asynchronous bake. The
158/// renderer maps each variant to a side effect: `StartNext` builds the next
159/// placement's capture buffers + targets (no face submitted yet), `RenderFace`
160/// submits one cube face (the six are spread one-per-frame so no single frame
161/// pays the whole capture), `Readback` copies the finished faces back and kicks
162/// the off-thread convolution, `Install` uploads the convolved cube and advances
163/// the queue, `Idle` does nothing.
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165pub enum BakeAction {
166    /// Nothing to do this frame.
167    Idle,
168    /// Begin the next queued probe.
169    StartNext,
170    /// Render one cube face.
171    RenderFace,
172    /// Install the converted cube into the probe pool.
173    Install,
174    /// Read the rendered faces back for conversion.
175    Readback,
176}
177
178/// Decide the next action for the single-in-flight asynchronous bake. Pure so the
179/// transition table is locked by unit tests without a GPU:
180///
181/// - while `Rendering`, submit the next cube face while `more_faces` remain
182///   (one per frame); once all six are submitted, do nothing until the GPU
183///   completion flag `done` is set, then `Readback`;
184/// - while `Converting`, do nothing until the off-thread `payload_ready`, then
185///   `Install`;
186/// - while `Idle`, start the next placement only when one is `queue_pending`
187///   and the world is `eligible` to bake this frame (bindless plus geometry
188///   present plus a non-empty cull); otherwise stay `Idle`.
189///
190/// The invariants this guarantees: never read faces back before all six are
191/// submitted and the GPU signals completion, never install before the convolution
192/// finishes, and never begin a second bake while one is already in flight.
193pub fn next_bake_action(
194    phase: BakePhase,
195    done: bool,
196    payload_ready: bool,
197    queue_pending: bool,
198    eligible: bool,
199    more_faces: bool,
200) -> BakeAction {
201    match phase {
202        BakePhase::Rendering => {
203            if more_faces {
204                BakeAction::RenderFace
205            } else if done {
206                BakeAction::Readback
207            } else {
208                BakeAction::Idle
209            }
210        }
211        BakePhase::Converting => {
212            if payload_ready {
213                BakeAction::Install
214            } else {
215                BakeAction::Idle
216            }
217        }
218        BakePhase::Idle => {
219            if queue_pending && eligible {
220                BakeAction::StartNext
221            } else {
222                BakeAction::Idle
223            }
224        }
225    }
226}
227
228// Largest number of probes auto-seed places. Bounds the bake cost + probe-cube
229// memory for an un-authored world. Must not exceed the renderer's per-frame probe
230// bind limit (`crate::uniforms::MAX_PROBES`); asserted by
231// `auto_seed_budget_fits_max_probes` in the metal uniforms tests.
232pub(crate) const AUTO_SEED_BUDGET: usize = 8;
233
234// Horizontal size (metres) each auto-seeded cell aims to cover -- roughly a large
235// room / courtyard, so a probe stays locally accurate across its cell. A 24 m
236// square scene tiles into the same 2x2 grid the original auto-seed produced.
237const AUTO_SEED_CELL_TARGET: f32 = 12.0;
238
239// Voxels along the longest horizontal axis when probing for enclosed interior space;
240// the voxel size derives from it. Bounds the detection cost (the grid is also capped
241// per axis). Fine enough that a wall a metre or two thick still seals a room.
242const INTERIOR_VOXELS_LONG_AXIS: usize = 48;
243// Hard cap on voxels per axis so a huge scene cannot blow up the grid.
244const INTERIOR_MAX_DIM: usize = 128;
245// An empty voxel is "interior" only if at least this many of its six axis directions
246// hit solid geometry before leaving the scene. Five captures a sealed or
247// single-doorway room (floor + ceiling + three walls) and a fully walled courtyard
248// (floor + four walls), while open ground (floor only) and simple overhangs stay
249// exterior.
250const INTERIOR_MIN_ENCLOSED: u8 = 5;
251// Smallest interior region (in voxels) that earns a probe, so a one-voxel pocket
252// wedged between props is not mistaken for a room.
253const INTERIOR_MIN_CLUSTER: usize = 4;
254// Smallest interior region (in metres, on every axis) that earns a probe. The
255// voxel floor above scales with the scene, so in a small scene a solid prop's
256// own hollow clears it: a surface voxeliser cannot tell a sealed box from a
257// room, and a `ProceduralMesh` box is exactly a sealed box. This is the scale
258// test that can -- a room is a space a camera can stand in. Without it, a world
259// whose only mesh is a crate spends a probe (and a full cube bake) capturing the
260// inside of that crate, and every reflector with no probe of its own inherits
261// it.
262const INTERIOR_MIN_ROOM_SPAN: f32 = 2.0;
263
264// Pick grid dimensions `nx * nz <= budget` that stay close to the requested counts
265// (so the grid keeps the scene's horizontal aspect). Scales both down by a common
266// factor when over budget, then trims the larger dimension for any rounding spill.
267fn fit_grid(nx: usize, nz: usize, budget: usize) -> (usize, usize) {
268    let (mut nx, mut nz) = (nx.max(1), nz.max(1));
269    if nx * nz > budget {
270        let scale = sqrt(budget as f32 / (nx * nz) as f32);
271        nx = (round(nx as f32 * scale) as usize).max(1);
272        nz = (round(nz as f32 * scale) as usize).max(1);
273        while nx * nz > budget {
274            if nx >= nz {
275                nx -= 1;
276            } else {
277                nz -= 1;
278            }
279        }
280    }
281    (nx.max(1), nz.max(1))
282}
283
284// Whether a point lies inside any of the occupancy boxes (object world AABBs).
285fn point_inside_any(p: [f32; 3], occupancy: &[([f32; 3], [f32; 3])]) -> bool {
286    occupancy.iter().any(|(mn, mx)| {
287        p[0] >= mn[0]
288            && p[0] <= mx[0]
289            && p[1] >= mn[1]
290            && p[1] <= mx[1]
291            && p[2] >= mn[2]
292            && p[2] <= mx[2]
293    })
294}
295
296// Choose a capture point inside the cell `[x0,x1] x [z0,z1]` (at the given eye
297// height) that does not sit inside scene geometry. Prefers the cell centre; if that
298// is occupied (e.g. inside a wall or building footprint at eye height), tries a few
299// vantage points toward the cell's quarters and takes the first open one. Returns
300// the centre unchanged when every candidate is occupied, so the result is never
301// worse than the un-nudged grid.
302fn open_capture_point(
303    center: [f32; 3],
304    x0: f32,
305    x1: f32,
306    z0: f32,
307    z1: f32,
308    occupancy: &[([f32; 3], [f32; 3])],
309) -> [f32; 3] {
310    if !point_inside_any(center, occupancy) {
311        return center;
312    }
313    let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
314    for (fx, fz) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
315        let p = [lerp(x0, x1, fx), center[1], lerp(z0, z1, fz)];
316        if !point_inside_any(p, occupancy) {
317            return p;
318        }
319    }
320    center
321}
322
323// The interior-detection voxel grid for a scene's bounds: one cube voxel size for
324// every axis (so enclosure rays step uniformly), sized so the longest horizontal axis
325// gets `INTERIOR_VOXELS_LONG_AXIS` voxels, each axis count capped at `INTERIOR_MAX_DIM`.
326// Returns `(voxel_size, nx, ny, nz)`, or `None` for a degenerate (zero-area / zero-
327// height) scene.
328fn interior_voxel_grid(
329    aabb_min: [f32; 3],
330    aabb_max: [f32; 3],
331) -> Option<(f32, usize, usize, usize)> {
332    let extent = [
333        aabb_max[0] - aabb_min[0],
334        aabb_max[1] - aabb_min[1],
335        aabb_max[2] - aabb_min[2],
336    ];
337    let long = extent[0].max(extent[2]);
338    if long <= 0.0 || extent[1] <= 0.0 {
339        return None;
340    }
341    let vs = (long / INTERIOR_VOXELS_LONG_AXIS as f32).max(0.25);
342    let dim = |e: f32| (ceil(e / vs) as usize).clamp(1, INTERIOR_MAX_DIM);
343    Some((vs, dim(extent[0]), dim(extent[1]), dim(extent[2])))
344}
345
346// Mark every voxel any object AABB overlaps as solid (rasterise each box into the grid,
347// clamped to bounds). Coarse: a watertight single mesh's AABB fills its own interior,
348// so this cannot see the hollow -- `solid_from_triangles` is the per-surface answer.
349fn solid_from_aabbs(
350    aabb_min: [f32; 3],
351    vs: f32,
352    nx: usize,
353    ny: usize,
354    nz: usize,
355    occupancy: &[([f32; 3], [f32; 3])],
356) -> Vec<bool> {
357    let idx = |x: usize, y: usize, z: usize| (z * ny + y) * nx + x;
358    let mut solid = vec![false; nx * ny * nz];
359    let to_vx =
360        |v: f32, origin: f32, hi: usize| (floor((v - origin) / vs).max(0.0) as usize).min(hi);
361    for (mn, mx) in occupancy {
362        let x0 = to_vx(mn[0], aabb_min[0], nx - 1);
363        let x1 = to_vx(mx[0], aabb_min[0], nx - 1);
364        let y0 = to_vx(mn[1], aabb_min[1], ny - 1);
365        let y1 = to_vx(mx[1], aabb_min[1], ny - 1);
366        let z0 = to_vx(mn[2], aabb_min[2], nz - 1);
367        let z1 = to_vx(mx[2], aabb_min[2], nz - 1);
368        for z in z0..=z1 {
369            for y in y0..=y1 {
370                for x in x0..=x1 {
371                    solid[idx(x, y, z)] = true;
372                }
373            }
374        }
375    }
376    solid
377}
378
379// Triangle / axis-aligned-box overlap by the separating-axis theorem (the
380// Akenine-Moller "tribox" test, in its plain 13-axis form). `box_c` is the voxel
381// centre, `box_h` its half-extent, `tri` a world-space triangle. The 13 candidate
382// axes are the 3 box face normals, the triangle face normal, and the 9 edge x
383// box-axis cross products; the pair is separated on an axis when the triangle's
384// projection interval and the box's `[-r, r]` do not overlap. A degenerate (zero)
385// axis projects everything to 0 and never separates, which is the correct no-op.
386fn tri_box_overlap(box_c: [f32; 3], box_h: [f32; 3], tri: &[[f32; 3]; 3]) -> bool {
387    let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
388    let dot = |a: [f32; 3], b: [f32; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
389    let cross = |a: [f32; 3], b: [f32; 3]| {
390        [
391            a[1] * b[2] - a[2] * b[1],
392            a[2] * b[0] - a[0] * b[2],
393            a[0] * b[1] - a[1] * b[0],
394        ]
395    };
396    // Triangle in box-local space (box centred at the origin).
397    let v = [sub(tri[0], box_c), sub(tri[1], box_c), sub(tri[2], box_c)];
398    let edges = [sub(v[1], v[0]), sub(v[2], v[1]), sub(v[0], v[2])];
399    let box_axes = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
400
401    let separated = |l: [f32; 3]| -> bool {
402        let r = box_h[0] * l[0].abs() + box_h[1] * l[1].abs() + box_h[2] * l[2].abs();
403        let p0 = dot(l, v[0]);
404        let p1 = dot(l, v[1]);
405        let p2 = dot(l, v[2]);
406        p0.min(p1).min(p2) > r || p0.max(p1).max(p2) < -r
407    };
408
409    for a in box_axes {
410        if separated(a) {
411            return false;
412        }
413    }
414    for e in edges {
415        for a in box_axes {
416            if separated(cross(e, a)) {
417                return false;
418            }
419        }
420    }
421    !separated(cross(edges[0], edges[1]))
422}
423
424// Surface-voxelise the scene triangles: mark every voxel any triangle actually passes
425// through (`tri_box_overlap`). Unlike the AABB rasteriser this leaves the hollow
426// interior of a watertight single mesh empty -- exactly the case AABB occupancy cannot
427// see -- so the enclosure sweep can find the room. Triangles are world-space; each is
428// tested only against the voxels in its own (clamped) AABB.
429fn solid_from_triangles(
430    aabb_min: [f32; 3],
431    vs: f32,
432    nx: usize,
433    ny: usize,
434    nz: usize,
435    triangles: &[[[f32; 3]; 3]],
436) -> Vec<bool> {
437    let idx = |x: usize, y: usize, z: usize| (z * ny + y) * nx + x;
438    let mut solid = vec![false; nx * ny * nz];
439    // Conservative voxelisation: inflate the test cell by a small voxel-relative margin
440    // so a triangle lying exactly on a voxel boundary (an axis-aligned wall coplanar
441    // with the grid) is still counted -- the exact SAT would FP-miss it and leave a gap
442    // that lets the enclosure sweep leak. The margin is far below one voxel, so it never
443    // reaches an interior cell.
444    let half = [vs * 0.5 + vs * 1e-3; 3];
445    let to_vx =
446        |v: f32, origin: f32, hi: usize| (floor((v - origin) / vs).max(0.0) as usize).min(hi);
447    for tri in triangles {
448        let mut tmn = tri[0];
449        let mut tmx = tri[0];
450        for vtx in &tri[1..] {
451            for a in 0..3 {
452                tmn[a] = tmn[a].min(vtx[a]);
453                tmx[a] = tmx[a].max(vtx[a]);
454            }
455        }
456        if !tmn.iter().chain(tmx.iter()).all(|c| c.is_finite()) {
457            continue;
458        }
459        let x0 = to_vx(tmn[0], aabb_min[0], nx - 1);
460        let x1 = to_vx(tmx[0], aabb_min[0], nx - 1);
461        let y0 = to_vx(tmn[1], aabb_min[1], ny - 1);
462        let y1 = to_vx(tmx[1], aabb_min[1], ny - 1);
463        let z0 = to_vx(tmn[2], aabb_min[2], nz - 1);
464        let z1 = to_vx(tmx[2], aabb_min[2], nz - 1);
465        for z in z0..=z1 {
466            for y in y0..=y1 {
467                for x in x0..=x1 {
468                    let i = idx(x, y, z);
469                    if solid[i] {
470                        continue;
471                    }
472                    let c = [
473                        aabb_min[0] + (x as f32 + 0.5) * vs,
474                        aabb_min[1] + (y as f32 + 0.5) * vs,
475                        aabb_min[2] + (z as f32 + 0.5) * vs,
476                    ];
477                    if tri_box_overlap(c, half, tri) {
478                        solid[i] = true;
479                    }
480                }
481            }
482        }
483    }
484    solid
485}
486
487// Place a probe inside each enclosed interior region (a room or walled courtyard) given
488// a precomputed solid-voxel grid. For every empty voxel counts how many of the six axis
489// directions hit solid geometry before leaving the bounds (six O(voxels) sweeps); treats
490// the well-enclosed empty voxels (`INTERIOR_MIN_ENCLOSED`) as interior; groups them into
491// 6-connected regions; and drops one probe at the centre of each region big enough to be
492// a room (`INTERIOR_MIN_CLUSTER`), largest first, up to `budget`. Returns empty for an
493// open scene (everything reachable from the sky / sides). The `solid` grid comes from
494// object AABBs (`seed_interior_probes`) or surface-voxelised triangles
495// (`seed_interior_probes_tris`); everything from here on is identical.
496fn interior_probes_from_solid(
497    aabb_min: [f32; 3],
498    vs: f32,
499    nx: usize,
500    ny: usize,
501    nz: usize,
502    solid: &[bool],
503    budget: usize,
504) -> Vec<ProbePlacement> {
505    let n = nx * ny * nz;
506    let idx = |x: usize, y: usize, z: usize| (z * ny + y) * nx + x;
507
508    // For each empty voxel, count axis directions that hit solid before the grid
509    // edge, via six linear sweeps (each marks "a solid lies ahead in this direction").
510    let mut enclosed = vec![0u8; n];
511    for z in 0..nz {
512        for y in 0..ny {
513            let (mut fwd, mut bwd) = (false, false);
514            for x in (0..nx).rev() {
515                let i = idx(x, y, z);
516                if solid[i] {
517                    fwd = true;
518                } else if fwd {
519                    enclosed[i] += 1;
520                }
521            }
522            for x in 0..nx {
523                let i = idx(x, y, z);
524                if solid[i] {
525                    bwd = true;
526                } else if bwd {
527                    enclosed[i] += 1;
528                }
529            }
530        }
531    }
532    for z in 0..nz {
533        for x in 0..nx {
534            let (mut fwd, mut bwd) = (false, false);
535            for y in (0..ny).rev() {
536                let i = idx(x, y, z);
537                if solid[i] {
538                    fwd = true;
539                } else if fwd {
540                    enclosed[i] += 1;
541                }
542            }
543            for y in 0..ny {
544                let i = idx(x, y, z);
545                if solid[i] {
546                    bwd = true;
547                } else if bwd {
548                    enclosed[i] += 1;
549                }
550            }
551        }
552    }
553    for y in 0..ny {
554        for x in 0..nx {
555            let (mut fwd, mut bwd) = (false, false);
556            for z in (0..nz).rev() {
557                let i = idx(x, y, z);
558                if solid[i] {
559                    fwd = true;
560                } else if fwd {
561                    enclosed[i] += 1;
562                }
563            }
564            for z in 0..nz {
565                let i = idx(x, y, z);
566                if solid[i] {
567                    bwd = true;
568                } else if bwd {
569                    enclosed[i] += 1;
570                }
571            }
572        }
573    }
574
575    let is_interior = |i: usize| !solid[i] && enclosed[i] >= INTERIOR_MIN_ENCLOSED;
576
577    // Group interior voxels into 6-connected regions (rooms).
578    let mut label = vec![usize::MAX; n];
579    let mut clusters: Vec<Vec<usize>> = Vec::new();
580    let mut stack: Vec<usize> = Vec::new();
581    for start in 0..n {
582        if !is_interior(start) || label[start] != usize::MAX {
583            continue;
584        }
585        let cid = clusters.len();
586        let mut members = Vec::new();
587        label[start] = cid;
588        stack.push(start);
589        while let Some(i) = stack.pop() {
590            members.push(i);
591            let z = i / (nx * ny);
592            let y = (i / nx) % ny;
593            let x = i % nx;
594            let neighbours = [
595                (x > 0).then(|| i - 1),
596                (x + 1 < nx).then_some(i + 1),
597                (y > 0).then(|| i - nx),
598                (y + 1 < ny).then_some(i + nx),
599                (z > 0).then(|| i - nx * ny),
600                (z + 1 < nz).then_some(i + nx * ny),
601            ];
602            for j in neighbours.into_iter().flatten() {
603                if is_interior(j) && label[j] == usize::MAX {
604                    label[j] = cid;
605                    stack.push(j);
606                }
607            }
608        }
609        clusters.push(members);
610    }
611
612    let voxel_center = |i: usize| {
613        let z = i / (nx * ny);
614        let y = (i / nx) % ny;
615        let x = i % nx;
616        [
617            aabb_min[0] + (x as f32 + 0.5) * vs,
618            aabb_min[1] + (y as f32 + 0.5) * vs,
619            aabb_min[2] + (z as f32 + 0.5) * vs,
620        ]
621    };
622    // World-space span of a region, corner to corner across its voxels.
623    let cluster_span = |members: &[usize]| {
624        let mut lo = [f32::MAX; 3];
625        let mut hi = [f32::MIN; 3];
626        for &i in members {
627            let c = voxel_center(i);
628            for a in 0..3 {
629                lo[a] = lo[a].min(c[a] - vs * 0.5);
630                hi[a] = hi[a].max(c[a] + vs * 0.5);
631            }
632        }
633        [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]
634    };
635
636    // Largest rooms first; skip voxel noise and anything too small to stand in;
637    // one probe per room up to budget.
638    clusters.retain(|c| {
639        c.len() >= INTERIOR_MIN_CLUSTER
640            && cluster_span(c).iter().all(|d| *d >= INTERIOR_MIN_ROOM_SPAN)
641    });
642    clusters.sort_by_key(|c| core::cmp::Reverse(c.len()));
643    clusters.truncate(budget);
644    clusters
645        .iter()
646        .map(|members| {
647            // Capture point: the member voxel nearest the region centroid (always an
648            // interior voxel, so never inside geometry).
649            let inv = 1.0 / members.len() as f32;
650            let mut centroid = [0.0f32; 3];
651            for &i in members {
652                let c = voxel_center(i);
653                for a in 0..3 {
654                    centroid[a] += c[a] * inv;
655                }
656            }
657            let dist2 = |c: [f32; 3]| {
658                powi(c[0] - centroid[0], 2)
659                    + powi(c[1] - centroid[1], 2)
660                    + powi(c[2] - centroid[2], 2)
661            };
662            let position = members
663                .iter()
664                .map(|&i| voxel_center(i))
665                .min_by(|a, b| dist2(*a).total_cmp(&dist2(*b)))
666                .unwrap_or(centroid);
667            // Influence box: the region's voxel bounds, reaching half a voxel out to
668            // the enclosing walls.
669            let mut box_min = [f32::MAX; 3];
670            let mut box_max = [f32::MIN; 3];
671            for &i in members {
672                let c = voxel_center(i);
673                for a in 0..3 {
674                    box_min[a] = box_min[a].min(c[a] - vs * 0.5);
675                    box_max[a] = box_max[a].max(c[a] + vs * 0.5);
676                }
677            }
678            ProbePlacement {
679                position,
680                box_min,
681                box_max,
682            }
683        })
684        .collect()
685}
686
687// Interior probes from object AABB occupancy (the coarse path): a watertight single
688// mesh's AABB fills its own interior, so only enclosures built from SEPARATE meshes
689// (walls / floor / ceiling / props -- the common case) are detected.
690fn seed_interior_probes(
691    aabb_min: [f32; 3],
692    aabb_max: [f32; 3],
693    occupancy: &[([f32; 3], [f32; 3])],
694    budget: usize,
695) -> Vec<ProbePlacement> {
696    if budget == 0 || occupancy.is_empty() {
697        return Vec::new();
698    }
699    let (vs, nx, ny, nz) = match interior_voxel_grid(aabb_min, aabb_max) {
700        Some(g) => g,
701        None => return Vec::new(),
702    };
703    let solid = solid_from_aabbs(aabb_min, vs, nx, ny, nz, occupancy);
704    interior_probes_from_solid(aabb_min, vs, nx, ny, nz, &solid, budget)
705}
706
707// Interior probes from surface-voxelised triangles (the fine path): marks only the
708// voxels a triangle actually passes through, so a watertight single mesh reads as a
709// hollow shell and its interior room is detected -- the case AABB occupancy misses.
710fn seed_interior_probes_tris(
711    aabb_min: [f32; 3],
712    aabb_max: [f32; 3],
713    triangles: &[[[f32; 3]; 3]],
714    budget: usize,
715) -> Vec<ProbePlacement> {
716    if budget == 0 || triangles.is_empty() {
717        return Vec::new();
718    }
719    let (vs, nx, ny, nz) = match interior_voxel_grid(aabb_min, aabb_max) {
720        Some(g) => g,
721        None => return Vec::new(),
722    };
723    let solid = solid_from_triangles(aabb_min, vs, nx, ny, nz, triangles);
724    interior_probes_from_solid(aabb_min, vs, nx, ny, nz, &solid, budget)
725}
726
727// Half-height a flat reflector contributes to the scene bounds. A water surface
728// or a glass pane is a plane, so it has no thickness of its own; without this a
729// world whose only reflector is a pool would fold to a zero-height AABB and every
730// grid probe would get a zero-height influence box, which nothing is ever inside.
731// Standing height, matching `INTERIOR_MIN_ROOM_SPAN`.
732const REFLECTOR_BOUNDS_HALF_HEIGHT: f32 = 2.0;
733
734/// The world-space AABB an auto-seed pass should count for a flat reflector: a
735/// water surface or a glass pane.
736///
737/// A reflector is not a draw object, so it never reaches `fold_world_bounds`
738/// through the scene geometry. A pool or a pane wider than every mesh would
739/// otherwise sit outside the probe grid entirely, leaving `probe_set_specular` to
740/// hand it whatever its no-coverage fallback picked -- which is how a 28 m pool
741/// ended up reflecting the inside of the 1.4 m crate floating over it.
742///
743/// `half_extents` is per-axis and is expected to be zero on the reflector's flat
744/// axis; every axis is widened to at least `REFLECTOR_BOUNDS_HALF_HEIGHT` so the
745/// result is a real volume rather than a plane.
746pub fn reflector_bounds(centre: [f32; 3], half_extents: [f32; 3]) -> ([f32; 3], [f32; 3]) {
747    let half = |a: usize| half_extents[a].abs().max(REFLECTOR_BOUNDS_HALF_HEIGHT);
748    (
749        [
750            centre[0] - half(0),
751            centre[1] - half(1),
752            centre[2] - half(2),
753        ],
754        [
755            centre[0] + half(0),
756            centre[1] + half(1),
757            centre[2] + half(2),
758        ],
759    )
760}
761
762/// Auto-seed probes from the scene bounds, used when a world declares no
763/// `ReflectionProbe`. Convenience wrapper over `auto_seed_probes_with_geometry` with no
764/// triangle geometry: interior detection uses object AABB occupancy (the coarse path).
765pub fn auto_seed_probes(
766    aabb_min: [f32; 3],
767    aabb_max: [f32; 3],
768    occupancy: &[([f32; 3], [f32; 3])],
769) -> Vec<ProbePlacement> {
770    auto_seed_probes_with_geometry(aabb_min, aabb_max, occupancy, &[])
771}
772
773/// Auto-seed probes from the scene bounds. First places a probe INSIDE each enclosed
774/// interior region (a room / walled courtyard, where a local capture is most valuable),
775/// then fills the remaining `AUTO_SEED_BUDGET` with a `seed_grid_probes` grid for broad /
776/// open coverage. An open scene finds no interiors, so it falls straight through to the
777/// grid (unchanged); a fully-enclosed scene is mostly rooms; a mixed scene gets both,
778/// cross-faded by the partition-of-unity blend. When `triangles` is non-empty, interior
779/// detection surface-voxelises them (so a watertight single mesh's hollow is found);
780/// when empty, it falls back to the coarse AABB `occupancy`. The grid fill always uses
781/// the AABB `occupancy` for its open-vantage capture-point nudge. Approximate --
782/// authored probes give per-space control -- but better than one global cube. Returns
783/// empty for a degenerate (non-finite or zero-area) scene.
784pub fn auto_seed_probes_with_geometry(
785    aabb_min: [f32; 3],
786    aabb_max: [f32; 3],
787    occupancy: &[([f32; 3], [f32; 3])],
788    triangles: &[[[f32; 3]; 3]],
789) -> Vec<ProbePlacement> {
790    let finite = aabb_min
791        .iter()
792        .chain(aabb_max.iter())
793        .all(|c| c.is_finite());
794    if !finite || aabb_max[0] <= aabb_min[0] || aabb_max[2] <= aabb_min[2] {
795        return Vec::new();
796    }
797    let mut out = if triangles.is_empty() {
798        seed_interior_probes(aabb_min, aabb_max, occupancy, AUTO_SEED_BUDGET)
799    } else {
800        seed_interior_probes_tris(aabb_min, aabb_max, triangles, AUTO_SEED_BUDGET)
801    };
802    let remaining = AUTO_SEED_BUDGET.saturating_sub(out.len());
803    if remaining > 0 {
804        out.extend(seed_grid_probes(aabb_min, aabb_max, occupancy, remaining));
805    }
806    out
807}
808
809// The grid half of auto-seed: tile the scene's horizontal extent into at most
810// `budget` cells (sized to `AUTO_SEED_CELL_TARGET`, shaped to the aspect via
811// `fit_grid`), each owning its full-height column as the influence box, capture point
812// = the cell centre nudged to an open vantage at eye height (`occupancy` = the scene's
813// object AABBs) so a probe is not captured from inside a wall.
814fn seed_grid_probes(
815    aabb_min: [f32; 3],
816    aabb_max: [f32; 3],
817    occupancy: &[([f32; 3], [f32; 3])],
818    budget: usize,
819) -> Vec<ProbePlacement> {
820    if budget == 0 {
821        return Vec::new();
822    }
823    let dx = aabb_max[0] - aabb_min[0];
824    let dz = aabb_max[2] - aabb_min[2];
825    let nx_raw = ceil(dx / AUTO_SEED_CELL_TARGET).max(1.0) as usize;
826    let nz_raw = ceil(dz / AUTO_SEED_CELL_TARGET).max(1.0) as usize;
827    let (nx, nz) = fit_grid(nx_raw, nz_raw, budget);
828
829    let y_eye = probe_eye_point(aabb_min, aabb_max)[1];
830    let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
831    let mut out = Vec::with_capacity(nx * nz);
832    for ix in 0..nx {
833        for iz in 0..nz {
834            let x0 = lerp(aabb_min[0], aabb_max[0], ix as f32 / nx as f32);
835            let x1 = lerp(aabb_min[0], aabb_max[0], (ix + 1) as f32 / nx as f32);
836            let z0 = lerp(aabb_min[2], aabb_max[2], iz as f32 / nz as f32);
837            let z1 = lerp(aabb_min[2], aabb_max[2], (iz + 1) as f32 / nz as f32);
838            let center = [(x0 + x1) * 0.5, y_eye, (z0 + z1) * 0.5];
839            out.push(ProbePlacement {
840                position: open_capture_point(center, x0, x1, z0, z1, occupancy),
841                box_min: [x0, aabb_min[1], z0],
842                box_max: [x1, aabb_max[1], z1],
843            });
844        }
845    }
846    out
847}
848
849/// Union the world-space AABBs of every scene object into one bounds, skipping
850/// any box with a non-finite corner (a degenerate / sentinel AABB). Returns
851/// `None` for an empty scene. The probe eye is then `probe_eye_point` of this.
852pub fn fold_world_bounds(
853    boxes: impl IntoIterator<Item = ([f32; 3], [f32; 3])>,
854) -> Option<([f32; 3], [f32; 3])> {
855    let mut acc: Option<([f32; 3], [f32; 3])> = None;
856    for (mn, mx) in boxes {
857        if !mn.iter().chain(mx.iter()).all(|c| c.is_finite()) {
858            continue;
859        }
860        match &mut acc {
861            None => acc = Some((mn, mx)),
862            Some((amn, amx)) => {
863                for i in 0..3 {
864                    amn[i] = amn[i].min(mn[i]);
865                    amx[i] = amx[i].max(mx[i]);
866                }
867            }
868        }
869    }
870    acc
871}
872
873/// Convert six captured cube faces (each `face_size*face_size` RGBA `f32`, row
874/// major, in the FACE_BASIS order) into the serialised `ENVM` payload the
875/// environment sampler consumes: a cosine-convolved irradiance cube + a GGX
876/// prefilter mip chain. Reuses the exact build-time convolutions (including the
877/// firefly clamp), so a scene-captured probe and an imported HDR produce
878/// byte-compatible payloads that flow through the same `upload_environment_map`.
879/// `scheduler` runs each convolution's independent output rows; the engine hands
880/// in its job pool.
881pub fn build_probe_payload<S: em::RowScheduler>(
882    scheduler: &S,
883    faces: &[Vec<f32>; 6],
884    face_size: u32,
885    irradiance_face: u32,
886    prefilter_samples: u32,
887    prefilter_clamp: f32,
888) -> Vec<u8> {
889    let mips = em::max_mip_count(face_size);
890    let irradiance = em::CubeBake::irradiance(
891        faces,
892        face_size,
893        irradiance_face,
894        em::DEFAULT_IRRADIANCE_PHI_SAMPLES,
895        em::DEFAULT_IRRADIANCE_THETA_SAMPLES,
896    )
897    .bake(scheduler);
898    // A probe cube is sampled only by the specular term (never drawn as a skybox), so
899    // clamp mip 0 too: it suppresses a lone blown highlight aliasing into a bright
900    // square on a near-mirror surface that falls back to the probe (SSR/RT miss).
901    let mut prefilter = Vec::with_capacity(mips as usize);
902    prefilter.push(em::prefilter_mip0(faces, face_size, prefilter_clamp, true));
903    for mip in 1..mips {
904        prefilter.push(
905            em::CubeBake::ggx(
906                faces,
907                face_size,
908                face_size >> mip,
909                em::prefilter_roughness(mip, mips),
910                prefilter_samples,
911                prefilter_clamp,
912            )
913            .bake(scheduler),
914        );
915    }
916    em::serialise_payload(irradiance_face, face_size, mips, &irradiance, &prefilter)
917}
918
919// Pick the eye point a single scene probe captures from: the horizontal centre
920// of the scene bounds, raised to eye height above the floor. A probe serves a
921// volume rather than a viewpoint, so centring it degrades most gracefully as a
922// first-person camera roams (the captured cube is still parallax-locked to this
923// point until box parallax correction lands). Kept pure so an authored probe
924// position can later replace this heuristic. `aabb_min`/`aabb_max` are the world
925// bounds; +Y is up.
926pub(crate) fn probe_eye_point(aabb_min: [f32; 3], aabb_max: [f32; 3]) -> [f32; 3] {
927    const EYE_HEIGHT: f32 = 1.7;
928    let cx = 0.5 * (aabb_min[0] + aabb_max[0]);
929    let cz = 0.5 * (aabb_min[2] + aabb_max[2]);
930    let floor = aabb_min[1];
931    let ceil = aabb_max[1];
932    // Eye height off the floor, but never above the scene's own mid-height (so a
933    // scene shorter than a person still places the probe inside its bounds).
934    let y = (floor + EYE_HEIGHT).min(0.5 * (floor + ceil)).max(floor);
935    [cx, y, cz]
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941
942    // The engine's cube convention (mirrors core `cube_texel_dir`): a face texel
943    // at (u, v) in [-1, 1] looks along this world direction.
944    fn cube_texel_dir(face: usize, u: f32, v: f32) -> [f32; 3] {
945        match face {
946            0 => [1.0, -v, -u],
947            1 => [-1.0, -v, u],
948            2 => [u, 1.0, v],
949            3 => [u, -1.0, -v],
950            4 => [u, -v, 1.0],
951            5 => [-u, -v, -1.0],
952            _ => unreachable!(),
953        }
954    }
955
956    fn project(vp: [[f32; 4]; 4], p: [f32; 3]) -> (f32, f32, f32) {
957        let mut c = [0.0f32; 4];
958        let pv = [p[0], p[1], p[2], 1.0];
959        for row in 0..4 {
960            for k in 0..4 {
961                c[row] += vp[k][row] * pv[k];
962            }
963        }
964        (c[0] / c[3], c[1] / c[3], c[3])
965    }
966
967    // Each face's view-projection must map cube_texel_dir(face, u, v) to NDC
968    // (u, -v): screen-right is +u, screen-down is +v, exactly the layout the
969    // readback stores and the prefilter samples. A flipped or rotated face
970    // would break this and is the classic cube-capture bug.
971    #[test]
972    fn face_view_projection_matches_cube_convention() {
973        let eye = [3.0, -1.5, 2.0];
974        let samples = [
975            (0.0f32, 0.0f32),
976            (0.5, 0.0),
977            (0.0, 0.5),
978            (-0.6, 0.3),
979            (0.7, -0.4),
980        ];
981        for face in 0..6 {
982            let vp = face_view_projection(eye, face, 0.05, 100.0);
983            for &(u, v) in &samples {
984                let d = cube_texel_dir(face, u, v);
985                let p = [eye[0] + d[0], eye[1] + d[1], eye[2] + d[2]];
986                let (nx, ny, w) = project(vp, p);
987                assert!(
988                    w > 0.0,
989                    "face {face} sample ({u},{v}) behind camera (w={w})"
990                );
991                assert!(
992                    (nx - u).abs() < 1e-4 && (ny - (-v)).abs() < 1e-4,
993                    "face {face} ({u},{v}) -> ndc ({nx},{ny}), expected ({u},{})",
994                    -v
995                );
996            }
997        }
998    }
999
1000    #[test]
1001    fn build_probe_payload_round_trips() {
1002        // Six small solid faces convolve into a valid ENVM payload that
1003        // deserialises with the requested sizes.
1004        let face = 8usize;
1005        let faces: [Vec<f32>; 6] = core::array::from_fn(|f| {
1006            let mut v = vec![0.0f32; face * face * 4];
1007            for px in v.chunks_exact_mut(4) {
1008                px[0] = f as f32 * 0.1;
1009                px[1] = 0.2;
1010                px[2] = 0.3;
1011                px[3] = 1.0;
1012            }
1013            v
1014        });
1015        let bytes = build_probe_payload(&em::Serial, &faces, face as u32, 8, 16, 12.0);
1016        let view = crate::build::environment_map::deserialise(&bytes).expect("deserialise");
1017        assert_eq!(view.prefilter_face, 8);
1018        assert_eq!(view.irradiance_face, 8);
1019        assert!(view.prefilter_mip_bytes.len() >= 2);
1020    }
1021
1022    #[test]
1023    fn probe_eye_point_centres_at_eye_height() {
1024        // A tall scene: probe sits at the horizontal centre, eye height off the
1025        // floor.
1026        let eye = probe_eye_point([-10.0, 0.0, -4.0], [6.0, 30.0, 12.0]);
1027        assert!((eye[0] - (-2.0)).abs() < 1e-6, "x not centred: {}", eye[0]);
1028        assert!((eye[2] - 4.0).abs() < 1e-6, "z not centred: {}", eye[2]);
1029        assert!((eye[1] - 1.7).abs() < 1e-6, "y not eye height: {}", eye[1]);
1030    }
1031
1032    #[test]
1033    fn probe_eye_point_clamps_to_a_flat_scene() {
1034        // A scene shorter than a person clamps the probe inside its own bounds.
1035        let eye = probe_eye_point([0.0, 0.0, 0.0], [2.0, 1.0, 2.0]);
1036        assert!(
1037            eye[1] >= 0.0 && eye[1] <= 1.0,
1038            "y escaped bounds: {}",
1039            eye[1]
1040        );
1041    }
1042
1043    #[test]
1044    fn face_view_matrix_composes_to_face_vp() {
1045        // The exposed bare view matrix, pre-multiplied by the 90-degree
1046        // projection, must reproduce `face_view_projection` exactly (the probe
1047        // capture builds ViewUniforms from the two separately).
1048        let eye = [1.0, 2.0, -3.0];
1049        for face in 0..6 {
1050            let vp = face_view_projection(eye, face, 0.1, 50.0);
1051            let comp = mat4_mul(perspective_90(0.1, 50.0), face_view_matrix(eye, face));
1052            for c in 0..4 {
1053                for r in 0..4 {
1054                    assert!(
1055                        (vp[c][r] - comp[c][r]).abs() < 1e-5,
1056                        "face {face} [{c}][{r}] mismatch"
1057                    );
1058                }
1059            }
1060        }
1061    }
1062
1063    #[test]
1064    fn placement_from_center_extents_builds_box() {
1065        let p = ProbePlacement::from_center_extents([1.0, 2.0, 3.0], [4.0, 5.0, 6.0]);
1066        assert_eq!(p.box_min, [-3.0, -3.0, -3.0]);
1067        assert_eq!(p.box_max, [5.0, 7.0, 9.0]);
1068        assert_eq!(p.position, [1.0, 2.0, 3.0]);
1069    }
1070
1071    // Union of every probe's influence box.
1072    fn probe_union(probes: &[ProbePlacement]) -> ([f32; 3], [f32; 3]) {
1073        let mn = probes.iter().fold([f32::MAX; 3], |a, p| {
1074            core::array::from_fn(|i| a[i].min(p.box_min[i]))
1075        });
1076        let mx = probes.iter().fold([f32::MIN; 3], |a, p| {
1077            core::array::from_fn(|i| a[i].max(p.box_max[i]))
1078        });
1079        (mn, mx)
1080    }
1081
1082    #[test]
1083    fn auto_seed_probes_tiles_the_scene() {
1084        // A 20 m square scene seeds a 2x2 grid whose boxes tile the full extent.
1085        let probes = auto_seed_probes([-10.0, 0.0, -10.0], [10.0, 6.0, 10.0], &[]);
1086        assert_eq!(probes.len(), 4);
1087        let (union_min, union_max) = probe_union(&probes);
1088        assert_eq!(union_min, [-10.0, 0.0, -10.0]);
1089        assert_eq!(union_max, [10.0, 6.0, 10.0]);
1090        // Degenerate scenes seed nothing.
1091        assert!(auto_seed_probes([0.0; 3], [0.0; 3], &[]).is_empty());
1092        assert!(auto_seed_probes([f32::NAN, 0.0, 0.0], [1.0, 1.0, 1.0], &[]).is_empty());
1093    }
1094
1095    #[test]
1096    fn auto_seed_scales_count_to_scene_size_and_aspect() {
1097        // A small scene (under one cell each way) seeds a single probe, not a
1098        // redundant 2x2.
1099        let small = auto_seed_probes([-3.0, 0.0, -3.0], [3.0, 3.0, 3.0], &[]);
1100        assert_eq!(small.len(), 1);
1101        // An elongated scene gets an elongated grid (more cells along the long
1102        // axis), and the boxes always tile the full extent.
1103        let long = auto_seed_probes([0.0, 0.0, 0.0], [96.0, 4.0, 12.0], &[]);
1104        assert!(long.len() > 1 && long.len() <= AUTO_SEED_BUDGET);
1105        let nx = long.iter().filter(|p| p.box_min[2] == 0.0).count();
1106        let nz = long.len() / nx;
1107        assert!(nx > nz, "long axis (x) should have more cells: {nx}x{nz}");
1108        let (mn, mx) = probe_union(&long);
1109        assert_eq!(mn, [0.0, 0.0, 0.0]);
1110        assert_eq!(mx, [96.0, 4.0, 12.0]);
1111        // A large scene is capped at the budget.
1112        let big = auto_seed_probes([0.0, 0.0, 0.0], [500.0, 4.0, 500.0], &[]);
1113        assert!(big.len() <= AUTO_SEED_BUDGET);
1114    }
1115
1116    #[test]
1117    fn auto_seed_nudges_capture_point_out_of_geometry() {
1118        // One probe (small scene), with a wall-like box covering the cell centre at
1119        // eye height. The capture point must move out of it but stay in the box.
1120        let occ = [([-1.0, 0.0, -1.0], [1.0, 5.0, 1.0])];
1121        let probes = auto_seed_probes([-3.0, 0.0, -3.0], [3.0, 3.0, 3.0], &occ);
1122        assert_eq!(probes.len(), 1);
1123        let p = probes[0].position;
1124        assert!(
1125            !point_inside_any(p, &occ),
1126            "capture point {p:?} still inside the occupancy box"
1127        );
1128        // Still within the probe's influence box.
1129        assert!(p[0] >= probes[0].box_min[0] && p[0] <= probes[0].box_max[0]);
1130        assert!(p[2] >= probes[0].box_min[2] && p[2] <= probes[0].box_max[2]);
1131        // When every candidate is occupied, the centre is kept (never drops a probe).
1132        let everywhere = [([-100.0, -100.0, -100.0], [100.0, 100.0, 100.0])];
1133        let trapped = auto_seed_probes([-3.0, 0.0, -3.0], [3.0, 3.0, 3.0], &everywhere);
1134        assert_eq!(trapped.len(), 1);
1135    }
1136
1137    #[test]
1138    fn fit_grid_respects_budget_and_aspect() {
1139        assert_eq!(fit_grid(1, 1, 8), (1, 1));
1140        assert_eq!(fit_grid(2, 2, 8), (2, 2)); // within budget, unchanged
1141        let (nx, nz) = fit_grid(9, 2, 8); // over budget, x-heavy
1142        assert!(nx * nz <= 8 && nx > nz);
1143        let (nx, nz) = fit_grid(20, 20, 8); // far over budget, square
1144        assert!(nx * nz <= 8 && nx >= 1 && nz >= 1);
1145    }
1146
1147    // The six wall slabs (thickness 1 m) of an axis-aligned room with the given
1148    // interior bounds, as object occupancy AABBs.
1149    fn box_room(min: [f32; 3], max: [f32; 3]) -> Vec<([f32; 3], [f32; 3])> {
1150        let [x0, y0, z0] = min;
1151        let [x1, y1, z1] = max;
1152        vec![
1153            ([x0, y0 - 1.0, z0], [x1, y0, z1]), // floor
1154            ([x0, y1, z0], [x1, y1 + 1.0, z1]), // ceiling
1155            ([x0 - 1.0, y0, z0], [x0, y1, z1]), // -x wall
1156            ([x1, y0, z0], [x1 + 1.0, y1, z1]), // +x wall
1157            ([x0, y0, z0 - 1.0], [x1, y1, z0]), // -z wall
1158            ([x0, y0, z1], [x1, y1, z1 + 1.0]), // +z wall
1159        ]
1160    }
1161
1162    #[test]
1163    fn seed_interior_probes_finds_a_sealed_room() {
1164        // A 10x6x10 room inside a larger scene: the only enclosed space is its
1165        // interior, so exactly one probe lands inside it.
1166        let room = box_room([0.0, 0.0, 0.0], [10.0, 6.0, 10.0]);
1167        let probes = seed_interior_probes([-3.0, -3.0, -3.0], [13.0, 9.0, 13.0], &room, 8);
1168        assert_eq!(probes.len(), 1, "one room -> one interior probe");
1169        let p = probes[0].position;
1170        assert!(
1171            p[0] > 0.0 && p[0] < 10.0 && p[1] > 0.0 && p[1] < 6.0 && p[2] > 0.0 && p[2] < 10.0,
1172            "probe {p:?} should sit inside the room"
1173        );
1174        // The influence box covers (most of) the room interior.
1175        assert!(probes[0].box_min[0] < 2.0 && probes[0].box_max[0] > 8.0);
1176    }
1177
1178    #[test]
1179    fn reflector_bounds_covers_the_surface_and_has_volume() {
1180        // A 28 m square pool centred at the origin: the box spans its full extent
1181        // horizontally and is a real volume vertically, so grid probes seeded over
1182        // it get influence boxes a point can be inside.
1183        let (mn, mx) = reflector_bounds([0.0, 0.0, 0.0], [14.0, 0.0, 14.0]);
1184        assert_eq!(mn[0], -14.0);
1185        assert_eq!(mx[2], 14.0);
1186        assert!(
1187            mx[1] - mn[1] > 0.0,
1188            "flat axis is inflated, not left at zero"
1189        );
1190
1191        // Off-origin, and an axis already larger than the floor is left alone.
1192        let (mn, mx) = reflector_bounds([5.0, 3.0, -2.0], [0.5, 6.0, 0.5]);
1193        assert_eq!(mn[1], -3.0);
1194        assert_eq!(mx[1], 9.0);
1195        assert!(mn[0] < 5.0 && mx[0] > 5.0);
1196
1197        // Folding it in is what widens a scene whose only mesh is a small crate.
1198        let crate_aabb = ([-0.7, 0.6, -0.7], [0.7, 2.0, 0.7]);
1199        let pool = reflector_bounds([0.0, 0.0, 0.0], [14.0, 0.0, 14.0]);
1200        let (mn, mx) = fold_world_bounds([crate_aabb, pool]).expect("finite bounds");
1201        assert_eq!((mn[0], mx[0]), (-14.0, 14.0));
1202        assert_eq!((mn[2], mx[2]), (-14.0, 14.0));
1203    }
1204
1205    #[test]
1206    fn seed_interior_probes_ignores_a_prop_sized_hollow() {
1207        // The `examples/cube.rs` case: the world's only mesh is a 1.4 m
1208        // `ProceduralMesh` box. A surface voxeliser cannot tell it from a room, so
1209        // the enclosure sweep finds its inside; the span guard is what rejects it,
1210        // and without it the box's lit interior became the reflection every
1211        // uncovered surface in the world inherited.
1212        let crate_tris = box_mesh_tris([-0.7, 0.6, -0.7], [0.7, 2.0, 0.7]);
1213        let probes = seed_interior_probes_tris([-0.8, 0.5, -0.8], [0.8, 2.1, 0.8], &crate_tris, 8);
1214        assert!(
1215            probes.is_empty(),
1216            "a prop-sized hollow is not a room: {probes:?}"
1217        );
1218
1219        // Scaled up past the standing-room span, the same shape does earn one, so
1220        // the guard is a size test and not a blanket rejection of small scenes.
1221        let room_tris = box_mesh_tris([-2.5, 0.0, -2.5], [2.5, 3.0, 2.5]);
1222        let probes = seed_interior_probes_tris([-3.0, -0.5, -3.0], [3.0, 3.5, 3.0], &room_tris, 8);
1223        assert_eq!(
1224            probes.len(),
1225            1,
1226            "a standing-height room still earns a probe"
1227        );
1228    }
1229
1230    #[test]
1231    fn seed_interior_probes_ignores_an_open_scene() {
1232        // A ground slab plus two free-standing pillars: nothing encloses a volume, so
1233        // no interior probe is seeded (the caller falls back to the grid).
1234        let open = vec![
1235            ([-20.0, -1.0, -20.0], [20.0, 0.0, 20.0]), // ground
1236            ([-5.0, 0.0, -5.0], [-3.0, 6.0, -3.0]),    // pillar
1237            ([3.0, 0.0, 3.0], [5.0, 6.0, 5.0]),        // pillar
1238        ];
1239        let probes = seed_interior_probes([-20.0, -1.0, -20.0], [20.0, 8.0, 20.0], &open, 8);
1240        assert!(
1241            probes.is_empty(),
1242            "open scene seeds no interior probes: {probes:?}"
1243        );
1244        // No occupancy at all is likewise empty.
1245        assert!(seed_interior_probes([0.0, 0.0, 0.0], [10.0, 5.0, 10.0], &[], 8).is_empty());
1246    }
1247
1248    #[test]
1249    fn auto_seed_places_a_room_probe_then_grid() {
1250        // A room inside the scene -> at least one probe inside it, plus grid fill for
1251        // the open remainder, all within budget.
1252        let room = box_room([0.0, 0.0, 0.0], [10.0, 6.0, 10.0]);
1253        let probes = auto_seed_probes([-12.0, -3.0, -12.0], [22.0, 9.0, 22.0], &room);
1254        assert!(!probes.is_empty() && probes.len() <= AUTO_SEED_BUDGET);
1255        let inside_room = probes.iter().any(|p| {
1256            let q = p.position;
1257            q[0] > 0.0 && q[0] < 10.0 && q[1] > 0.0 && q[1] < 6.0 && q[2] > 0.0 && q[2] < 10.0
1258        });
1259        assert!(
1260            inside_room,
1261            "auto-seed should drop a probe inside the room: {probes:?}"
1262        );
1263    }
1264
1265    // 12 triangles (2 per face) of the closed axis-aligned box [min, max]: a watertight
1266    // single mesh whose AABB is the whole box, but whose triangles only cover the shell.
1267    fn box_mesh_tris(min: [f32; 3], max: [f32; 3]) -> Vec<[[f32; 3]; 3]> {
1268        let [x0, y0, z0] = min;
1269        let [x1, y1, z1] = max;
1270        let c = [
1271            [x0, y0, z0],
1272            [x1, y0, z0],
1273            [x1, y1, z0],
1274            [x0, y1, z0],
1275            [x0, y0, z1],
1276            [x1, y0, z1],
1277            [x1, y1, z1],
1278            [x0, y1, z1],
1279        ];
1280        // Each face as four corner indices (a,b,c,d) -> triangles (a,b,c) + (a,c,d).
1281        let quads = [
1282            [0, 1, 2, 3], // -z
1283            [4, 5, 6, 7], // +z
1284            [0, 3, 7, 4], // -x
1285            [1, 2, 6, 5], // +x
1286            [0, 1, 5, 4], // -y
1287            [3, 2, 6, 7], // +y
1288        ];
1289        let mut tris = Vec::with_capacity(12);
1290        for q in quads {
1291            tris.push([c[q[0]], c[q[1]], c[q[2]]]);
1292            tris.push([c[q[0]], c[q[2]], c[q[3]]]);
1293        }
1294        tris
1295    }
1296
1297    #[test]
1298    fn tri_box_overlap_detects_intersection_and_separation() {
1299        let h = [0.5, 0.5, 0.5];
1300        // A triangle straddling the origin overlaps a unit box centred there.
1301        let through = [[-1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1302        assert!(tri_box_overlap([0.0, 0.0, 0.0], h, &through));
1303        // Separated from a box well off to the side (a box face-axis separates).
1304        assert!(!tri_box_overlap([10.0, 0.0, 0.0], h, &through));
1305        // A horizontal triangle high above is separated by the triangle's own normal
1306        // axis (the box sits entirely on one side of the triangle's plane).
1307        let above = [[-1.0, 5.0, -1.0], [3.0, 5.0, -1.0], [0.0, 5.0, 3.0]];
1308        assert!(!tri_box_overlap([0.0, 0.0, 0.0], h, &above));
1309        // A tiny triangle fully inside the box overlaps it.
1310        let inside = [[-0.1, 0.0, 0.0], [0.1, 0.0, 0.0], [0.0, 0.1, 0.0]];
1311        assert!(tri_box_overlap([0.0, 0.0, 0.0], h, &inside));
1312    }
1313
1314    #[test]
1315    fn surface_voxels_leave_a_watertight_mesh_hollow() {
1316        // The exact case AABB occupancy misses: a room modelled as ONE watertight mesh.
1317        // Its single AABB fills the interior (no room found), but surface-voxelising its
1318        // triangles leaves the interior empty so the enclosure sweep finds the room.
1319        let scene_min = [-3.0, -3.0, -3.0];
1320        let scene_max = [13.0, 9.0, 13.0];
1321        let room_aabb = vec![([0.0, 0.0, 0.0], [10.0, 6.0, 10.0])];
1322        let room_tris = box_mesh_tris([0.0, 0.0, 0.0], [10.0, 6.0, 10.0]);
1323
1324        // AABB occupancy: the solid box has no hollow, so no interior probe.
1325        let from_aabb = seed_interior_probes(scene_min, scene_max, &room_aabb, 8);
1326        assert!(
1327            from_aabb.is_empty(),
1328            "a watertight mesh's AABB hides its interior: {from_aabb:?}"
1329        );
1330
1331        // Triangle occupancy: the shell is hollow, so one probe lands inside the room.
1332        let from_tris = seed_interior_probes_tris(scene_min, scene_max, &room_tris, 8);
1333        assert_eq!(from_tris.len(), 1, "the hollow interior earns one probe");
1334        let p = from_tris[0].position;
1335        assert!(
1336            p[0] > 0.0 && p[0] < 10.0 && p[1] > 0.0 && p[1] < 6.0 && p[2] > 0.0 && p[2] < 10.0,
1337            "probe {p:?} should sit inside the watertight room"
1338        );
1339
1340        // The public geometry entry routes through the surface-voxel path.
1341        let auto = auto_seed_probes_with_geometry(scene_min, scene_max, &room_aabb, &room_tris);
1342        assert!(
1343            auto.iter().any(|q| {
1344                let q = q.position;
1345                q[0] > 0.0 && q[0] < 10.0 && q[1] > 0.0 && q[1] < 6.0 && q[2] > 0.0 && q[2] < 10.0
1346            }),
1347            "auto-seed with geometry drops a probe inside the room: {auto:?}"
1348        );
1349        // Empty geometry is exactly the coarse AABB path (back-compatible delegation).
1350        assert_eq!(
1351            auto_seed_probes_with_geometry(scene_min, scene_max, &room_aabb, &[]).len(),
1352            auto_seed_probes(scene_min, scene_max, &room_aabb).len(),
1353        );
1354    }
1355
1356    #[test]
1357    fn fold_world_bounds_unions_and_skips_nonfinite() {
1358        let boxes = [
1359            ([0.0, 0.0, 0.0], [1.0, 2.0, 1.0]),
1360            ([-3.0, 1.0, -1.0], [0.5, 4.0, 2.0]),
1361            ([f32::NAN, 0.0, 0.0], [1.0, 1.0, 1.0]), // degenerate: skipped
1362        ];
1363        let (mn, mx) = fold_world_bounds(boxes).expect("non-empty");
1364        assert_eq!(mn, [-3.0, 0.0, -1.0]);
1365        assert_eq!(mx, [1.0, 4.0, 2.0]);
1366        assert!(fold_world_bounds(core::iter::empty()).is_none());
1367    }
1368
1369    #[test]
1370    fn face_centres_look_down_their_axis() {
1371        // The centre texel of each face projects to the NDC origin.
1372        let eye = [0.0, 0.0, 0.0];
1373        for face in 0..6 {
1374            let vp = face_view_projection(eye, face, 0.05, 100.0);
1375            let d = cube_texel_dir(face, 0.0, 0.0);
1376            let (nx, ny, w) = project(vp, d);
1377            assert!(w > 0.0);
1378            assert!(
1379                nx.abs() < 1e-5 && ny.abs() < 1e-5,
1380                "face {face} centre off-origin"
1381            );
1382        }
1383    }
1384
1385    #[test]
1386    fn bake_queue_hands_out_indices_in_order() {
1387        let mut q = ProbeBakeQueue::new(3);
1388        assert!(q.pending());
1389        assert_eq!(q.take_next(), Some(0));
1390        assert_eq!(q.take_next(), Some(1));
1391        assert!(q.pending());
1392        assert_eq!(q.take_next(), Some(2));
1393        assert!(!q.pending());
1394        assert_eq!(q.take_next(), None);
1395    }
1396
1397    #[test]
1398    fn bake_queue_empty_is_never_pending() {
1399        let mut q = ProbeBakeQueue::new(0);
1400        assert!(!q.pending());
1401        assert_eq!(q.take_next(), None);
1402    }
1403
1404    #[test]
1405    fn bake_queue_abort_skips_the_remainder() {
1406        let mut q = ProbeBakeQueue::new(4);
1407        assert_eq!(q.take_next(), Some(0));
1408        q.abort();
1409        assert!(!q.pending());
1410        assert_eq!(q.take_next(), None);
1411    }
1412
1413    #[test]
1414    fn bake_action_idle_starts_only_when_pending_and_eligible() {
1415        // Idle with work waiting and the world able to bake -> begin.
1416        assert_eq!(
1417            next_bake_action(BakePhase::Idle, false, false, true, true, false),
1418            BakeAction::StartNext
1419        );
1420        // Idle but nothing queued -> stay put.
1421        assert_eq!(
1422            next_bake_action(BakePhase::Idle, false, false, false, true, false),
1423            BakeAction::Idle
1424        );
1425        // Idle with work queued but the world not eligible this frame (e.g. the
1426        // cull is still empty while geometry streams in) -> wait, do not start.
1427        assert_eq!(
1428            next_bake_action(BakePhase::Idle, false, false, true, false, false),
1429            BakeAction::Idle
1430        );
1431    }
1432
1433    #[test]
1434    fn bake_action_rendering_submits_faces_before_waiting_for_completion() {
1435        // While faces remain, submit the next one (one per frame) -- even if `done`
1436        // somehow read true, more_faces takes precedence so the capture finishes.
1437        assert_eq!(
1438            next_bake_action(BakePhase::Rendering, false, false, true, true, true),
1439            BakeAction::RenderFace
1440        );
1441        // All six submitted, GPU not done yet -> wait.
1442        assert_eq!(
1443            next_bake_action(BakePhase::Rendering, false, false, true, true, false),
1444            BakeAction::Idle
1445        );
1446        // All six submitted and the GPU completion flag set -> read them back.
1447        assert_eq!(
1448            next_bake_action(BakePhase::Rendering, true, false, true, true, false),
1449            BakeAction::Readback
1450        );
1451    }
1452
1453    #[test]
1454    fn bake_action_converting_waits_for_offthread_payload() {
1455        // The off-thread convolution gates the install: never upload early.
1456        assert_eq!(
1457            next_bake_action(BakePhase::Converting, true, false, true, true, false),
1458            BakeAction::Idle
1459        );
1460        assert_eq!(
1461            next_bake_action(BakePhase::Converting, true, true, false, true, false),
1462            BakeAction::Install
1463        );
1464    }
1465
1466    #[test]
1467    fn bake_action_never_starts_a_second_bake_while_one_is_in_flight() {
1468        // With a probe rendering or converting, a pending queue must not trigger a
1469        // second StartNext regardless of eligibility -- one bake in flight at a time.
1470        for phase in [BakePhase::Rendering, BakePhase::Converting] {
1471            assert_ne!(
1472                next_bake_action(phase, false, false, true, true, false),
1473                BakeAction::StartNext
1474            );
1475            assert_ne!(
1476                next_bake_action(phase, false, false, true, true, true),
1477                BakeAction::StartNext
1478            );
1479        }
1480    }
1481}