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