Skip to main content

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