Skip to main content

concinnity_core/render/
planar_reflection.rs

1//! Planar reflection math: mirror a camera across a world plane and oblique-clip
2//! the projection so geometry behind the plane never leaks into the reflection.
3//!
4//! Backend-agnostic and pure (mirrors gfx/reflection_probe.rs). A reflective flat
5//! surface (water, a mirror floor) renders the scene a second time from the
6//! camera reflected across its plane; the reflective surface then samples that
7//! render projectively. This module produces the matrices that pass needs.
8//!
9//! Conventions match [`crate::gfx::projection`]: column-major storage
10//! `m[col][row]`, a right-handed view looking down -z, and a perspective
11//! projection mapping depth to [0, 1] (Metal / D3D clip space). A plane is
12//! `[nx, ny, nz, d]` with `n` unit-length, satisfying `n . p + d = 0` for points
13//! on it; `n . p + d > 0` is the side the normal points toward.
14
15use crate::gfx::transform::{Mat4, mat4_inverse, mat4_mul};
16use crate::math::sqrt;
17use alloc::vec::Vec;
18
19type Vec4 = [f32; 4];
20
21/// The engine-wide capacity ceiling for distinct reflection planes: water
22/// surfaces and glass panes combined. Each plane is a full render-resolution MSAA scene
23/// re-render, so this bounds the per-frame planar cost and the reserved mirror
24/// target VRAM; reflectors past the active budget fall back to the box-projected
25/// probe cube. This is the CAPACITY every backend sizes its mirror targets / ICB
26/// slots / resolve SRVs against, so the three `planar::MAX_PLANAR_PLANES` alias it
27/// and stay in lockstep by construction. The per-frame budget passed to
28/// `assign_planar_slots` can be lower (scaled down under a quality preset / GPU
29/// tier) but never higher.
30pub const MAX_PLANAR_PLANES: usize = 4;
31
32fn mat_vec(m: Mat4, v: Vec4) -> Vec4 {
33    let mut out = [0.0f32; 4];
34    for row in 0..4 {
35        for k in 0..4 {
36            out[row] += m[k][row] * v[k];
37        }
38    }
39    out
40}
41
42fn transpose(m: Mat4) -> Mat4 {
43    let mut out = [[0.0f32; 4]; 4];
44    for col in 0..4 {
45        for row in 0..4 {
46            out[col][row] = m[row][col];
47        }
48    }
49    out
50}
51
52fn dot4(a: Vec4, b: Vec4) -> f32 {
53    a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]
54}
55
56// Normalise a plane so its normal is unit length (scaling d to match). A zero
57// normal is returned unchanged (degenerate, callers guard separately).
58pub(crate) fn normalize_plane(plane: Vec4) -> Vec4 {
59    let len = sqrt(plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]);
60    if len < 1e-12 {
61        return plane;
62    }
63    let inv = 1.0 / len;
64    [
65        plane[0] * inv,
66        plane[1] * inv,
67        plane[2] * inv,
68        plane[3] * inv,
69    ]
70}
71
72// Householder reflection of world points across `plane` (unit normal). A point
73// p maps to p - 2 (n.p + d) n; this 4x4 applies that to homogeneous points.
74pub(crate) fn reflection_matrix(plane: Vec4) -> Mat4 {
75    let [nx, ny, nz, d] = plane;
76    [
77        [1.0 - 2.0 * nx * nx, -2.0 * ny * nx, -2.0 * nz * nx, 0.0],
78        [-2.0 * nx * ny, 1.0 - 2.0 * ny * ny, -2.0 * nz * ny, 0.0],
79        [-2.0 * nx * nz, -2.0 * ny * nz, 1.0 - 2.0 * nz * nz, 0.0],
80        [-2.0 * nx * d, -2.0 * ny * d, -2.0 * nz * d, 1.0],
81    ]
82}
83
84// Reflect a single world point across the plane (the reflected camera eye).
85pub(crate) fn reflect_point(p: [f32; 3], plane: Vec4) -> [f32; 3] {
86    let dist = plane[0] * p[0] + plane[1] * p[1] + plane[2] * p[2] + plane[3];
87    [
88        p[0] - 2.0 * dist * plane[0],
89        p[1] - 2.0 * dist * plane[1],
90        p[2] - 2.0 * dist * plane[2],
91    ]
92}
93
94// The reflected view matrix: reflect a world point across the plane, then apply
95// the camera view. Equivalent to rendering the scene from the mirrored camera.
96pub(crate) fn reflected_view(view: Mat4, plane: Vec4) -> Mat4 {
97    mat4_mul(view, reflection_matrix(plane))
98}
99
100// Transform a world-space plane into a view space. Planes transform by the
101// inverse-transpose of the world->view matrix: plane_view = (V^-1)^T . plane.
102pub(crate) fn plane_in_view(plane_world: Vec4, view: Mat4) -> Vec4 {
103    mat_vec(transpose(mat4_inverse(view)), plane_world)
104}
105
106// Oblique near-plane clipping (Lengyel) for a [0, 1]-depth perspective matrix.
107// Replaces the projection's z (depth) row so the near clip plane coincides with
108// `clip_plane` (given in the projection's view space), clipping everything on the
109// negative side of that plane. The far plane is preserved by scaling against the
110// frustum corner the plane faces.
111//
112// Derivation for this depth convention: the near plane is `z_row . p = 0`, so the
113// new z-row is `alpha * C` for the clip plane C (any alpha keeps the near plane at
114// C). Picking the far frustum corner q = inv(P) . (sgn(Cx), sgn(Cy), 1, 1) and
115// requiring it to land on the far plane (ndc.z = 1, i.e. z_row.q = w_row.q = -q.z)
116// gives alpha = -q.z / (C . q). For this projection q has the closed form below
117// (q.z = -1), so alpha = 1 / (C . q).
118pub(crate) fn oblique_projection(proj: Mat4, clip_plane: Vec4) -> Mat4 {
119    let xs = proj[0][0];
120    let ys = proj[1][1];
121    let zs = proj[2][2]; // z-row's z component
122    let zs_near = proj[3][2]; // z-row's w component (= zs * near)
123    if xs.abs() < 1e-12 || ys.abs() < 1e-12 || zs_near.abs() < 1e-12 {
124        return proj;
125    }
126
127    let sgn = |v: f32| {
128        if v > 0.0 {
129            1.0
130        } else if v < 0.0 {
131            -1.0
132        } else {
133            0.0
134        }
135    };
136    // Back-projected far frustum corner toward the clip plane.
137    let q: Vec4 = [
138        sgn(clip_plane[0]) / xs,
139        sgn(clip_plane[1]) / ys,
140        -1.0,
141        (1.0 + zs) / zs_near,
142    ];
143    let denom = dot4(clip_plane, q);
144    if denom.abs() < 1e-12 {
145        return proj;
146    }
147    let alpha = 1.0 / denom;
148
149    let mut out = proj;
150    // Replace the z (depth) row: row index 2 across all four columns.
151    out[0][2] = alpha * clip_plane[0];
152    out[1][2] = alpha * clip_plane[1];
153    out[2][2] = alpha * clip_plane[2];
154    out[3][2] = alpha * clip_plane[3];
155    out
156}
157
158/// Flip a plane so its normal points toward `point` (the kept side faces the
159/// camera). The reflection matrix is sign-invariant, but the oblique near-plane
160/// clip is not: it keeps the +n side, so the normal must face the viewer or the
161/// mirror render clips the wrong half. A no-op when `point` already lies on the
162/// +n side, which is the horizontal-water-above-camera case, so water renders
163/// identically with or without this orientation.
164pub fn orient_plane_toward(plane: Vec4, point: [f32; 3]) -> Vec4 {
165    let signed = plane[0] * point[0] + plane[1] * point[1] + plane[2] * point[2] + plane[3];
166    if signed < 0.0 {
167        [-plane[0], -plane[1], -plane[2], -plane[3]]
168    } else {
169        plane
170    }
171}
172
173/// The result of grouping a list of reflection planes into a bounded number of
174/// distinct slots: `slots[i]` is the slot a plane maps to (`None` when the budget
175/// is exhausted by earlier distinct planes, i.e. it falls back to the probe cube),
176/// and `representatives` is the deduplicated plane per slot (`representatives.len()`
177/// is the number of mirror renders the frame needs).
178pub struct PlanarAssignment {
179    /// Per-reflector plane slot, `None` when the reflector fell back to a probe.
180    pub slots: Vec<Option<usize>>,
181    /// One representative plane per assigned slot.
182    pub representatives: Vec<Vec4>,
183}
184
185/// Group near-coplanar reflection planes so each distinct plane renders one mirror
186/// pass, capped at `max_slots`. Planes are matched sign-invariantly (a plane and
187/// its flip are the same surface): two planes share a slot when their unit normals
188/// are near-parallel and their offset along the normal matches. A plane coplanar
189/// with an already-assigned slot always reuses it (even past the budget); only a
190/// NEW distinct plane beyond `max_slots` overflows to `None`. Input order sets slot
191/// priority, so callers list higher-priority planes (e.g. water) first.
192pub fn assign_planar_slots(planes: &[Vec4], max_slots: usize) -> PlanarAssignment {
193    // ~2.6 degrees of normal divergence and 0.1 world units of offset still count
194    // as the same plane: tight enough to keep separate walls distinct, loose
195    // enough to merge co-planar panes authored with slight slop.
196    const NORMAL_DOT_EPS: f32 = 0.999;
197    const OFFSET_EPS: f32 = 0.1;
198
199    let mut representatives: Vec<Vec4> = Vec::new();
200    let mut slots: Vec<Option<usize>> = Vec::with_capacity(planes.len());
201    for &raw in planes {
202        let p = normalize_plane(raw);
203        let nlen = sqrt(p[0] * p[0] + p[1] * p[1] + p[2] * p[2]);
204        if nlen < 1e-6 {
205            // Degenerate normal: no usable plane, fall back to the probe cube.
206            slots.push(None);
207            continue;
208        }
209        let mut found = None;
210        for (i, r) in representatives.iter().enumerate() {
211            let d = p[0] * r[0] + p[1] * r[1] + p[2] * r[2];
212            if d.abs() >= NORMAL_DOT_EPS {
213                // Align the representative to p's sign, then the two are the same
214                // surface iff their plane constants match.
215                let rd_aligned = if d < 0.0 { -r[3] } else { r[3] };
216                if (p[3] - rd_aligned).abs() <= OFFSET_EPS {
217                    found = Some(i);
218                    break;
219                }
220            }
221        }
222        match found {
223            Some(i) => slots.push(Some(i)),
224            None => {
225                if representatives.len() < max_slots {
226                    representatives.push(p);
227                    slots.push(Some(representatives.len() - 1));
228                } else {
229                    slots.push(None);
230                }
231            }
232        }
233    }
234    PlanarAssignment {
235        slots,
236        representatives,
237    }
238}
239
240/// Whether the transparent pass has to render its planar mirrors this frame.
241///
242/// Water prefers the mirror to the per-pixel ray trace: a water surface is a
243/// plane, so one mirrored scene render resolves it exactly, at a fraction of the
244/// cost of a ray per pixel, and the wave normal only has to perturb the lookup.
245/// A trace off the per-fragment wave normal is hypersensitive at grazing angles
246/// and lands on the probe / sky fallback wherever it misses, which reads as a
247/// chrome sheet rather than water. Glass keeps the trace (a pane shows what is
248/// genuinely behind it), so a world of glass alone still skips the re-render
249/// while ray tracing is live.
250///
251/// `has_targets` is whether any mirror target exists at all, `water_has_slot`
252/// whether a visible water surface holds one, and `rt_transparent_active`
253/// whether the pass would otherwise trace.
254pub fn planar_pass_needed(
255    has_targets: bool,
256    water_has_slot: bool,
257    rt_transparent_active: bool,
258) -> bool {
259    has_targets && (water_has_slot || !rt_transparent_active)
260}
261
262/// The matrices a planar reflection pass needs for one mirror plane.
263pub struct PlanarMatrices {
264    /// Reflected view matrix (world -> mirrored view).
265    pub view: Mat4,
266    /// Reflected view-projection with oblique near-plane clipping applied.
267    pub view_proj: Mat4,
268    /// The camera eye reflected across the plane (LOD / view-direction anchor).
269    pub eye: [f32; 3],
270}
271
272/// Build the mirror view + oblique-clipped view-projection + reflected eye for a
273/// camera (`view` / `proj` / `cam_pos`) reflecting across `plane_world`. The clip
274/// plane is nudged a hair below the surface (`clip_bias`, world units along the
275/// normal) so fragments exactly on the surface are not clipped by precision.
276pub fn planar_matrices(
277    view: Mat4,
278    proj: Mat4,
279    cam_pos: [f32; 3],
280    plane_world: Vec4,
281    clip_bias: f32,
282) -> PlanarMatrices {
283    let plane = normalize_plane(plane_world);
284    let r_view = reflected_view(view, plane);
285    // Push the clip plane slightly toward the kept (normal) side so geometry
286    // right at the waterline survives the near-plane test.
287    let clip_world = [plane[0], plane[1], plane[2], plane[3] + clip_bias];
288    let clip_view = plane_in_view(clip_world, r_view);
289    let r_proj = oblique_projection(proj, clip_view);
290    PlanarMatrices {
291        view: r_view,
292        view_proj: mat4_mul(r_proj, r_view),
293        eye: reflect_point(cam_pos, plane),
294    }
295}
296
297/// Resolve the CPU visible set for a planar mirror render: BVH-cull the cullable
298/// draw objects against the reflected-camera frustum, then append the always-draw
299/// fallback (skybox, rooms) so it appears in the reflection too. Mirrors the main
300/// camera's visible-set resolution but against the reflected frustum, so geometry
301/// visible only in the reflection (behind or beside the main camera, outside its
302/// frustum) is captured instead of reusing the main camera's set. `eye` is the
303/// reflected camera position, consulted only for the leaves' distance-based cull.
304/// `out` is cleared then refilled, so a caller can reuse one buffer across planes.
305pub fn reflected_visible_set(
306    bvh: &crate::render::bvh::Bvh,
307    reflected_frustum: &crate::gfx::frustum::Frustum,
308    eye: [f32; 3],
309    always_draw: &[u32],
310    out: &mut Vec<u32>,
311) {
312    out.clear();
313    bvh.query(reflected_frustum, eye, |idx| out.push(idx));
314    out.sort_unstable();
315    out.extend_from_slice(always_draw);
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::gfx::projection::perspective_rh;
322
323    use alloc::vec;
324    // Apply a column-major transform to a homogeneous point.
325    fn xform(m: Mat4, p: [f32; 4]) -> [f32; 4] {
326        mat_vec(m, p)
327    }
328
329    fn approx(a: f32, b: f32, eps: f32) -> bool {
330        (a - b).abs() <= eps
331    }
332
333    #[test]
334    fn reflection_is_an_involution() {
335        // Reflecting twice returns the original point (R * R = I).
336        let plane = normalize_plane([0.0, 1.0, 0.0, -2.0]); // y = 2
337        let p = [3.0, 5.0, -1.0];
338        let once = reflect_point(p, plane);
339        let twice = reflect_point(once, plane);
340        assert!(approx(twice[0], p[0], 1e-5));
341        assert!(approx(twice[1], p[1], 1e-5));
342        assert!(approx(twice[2], p[2], 1e-5));
343    }
344
345    #[test]
346    fn reflection_across_y_plane_flips_height_about_it() {
347        // y = 2 plane: a point at y=5 reflects to y=-1 (mirror about 2).
348        let plane = normalize_plane([0.0, 1.0, 0.0, -2.0]);
349        let r = reflect_point([3.0, 5.0, -1.0], plane);
350        assert!(approx(r[0], 3.0, 1e-5));
351        assert!(approx(r[1], -1.0, 1e-5));
352        assert!(approx(r[2], -1.0, 1e-5));
353    }
354
355    #[test]
356    fn reflection_matrix_matches_point_reflection() {
357        let plane = normalize_plane([0.2, 0.9, -0.3, 1.4]);
358        let m = reflection_matrix(plane);
359        let p = [1.3, -2.1, 0.7];
360        let via_matrix = xform(m, [p[0], p[1], p[2], 1.0]);
361        let via_point = reflect_point(p, plane);
362        for i in 0..3 {
363            assert!(approx(via_matrix[i], via_point[i], 1e-4), "component {i}");
364        }
365        assert!(approx(via_matrix[3], 1.0, 1e-5));
366    }
367
368    #[test]
369    fn inverse_round_trips() {
370        let m = perspective_rh(1.1, 1.7, 0.2, 80.0);
371        let id = mat4_mul(m, mat4_inverse(m));
372        for (c, col) in id.iter().enumerate() {
373            for (r, &val) in col.iter().enumerate() {
374                let expect = if c == r { 1.0 } else { 0.0 };
375                assert!(approx(val, expect, 1e-4), "[{c}][{r}]");
376            }
377        }
378    }
379
380    #[test]
381    fn oblique_clip_puts_the_plane_at_the_near_depth() {
382        // A view-space plane at z = -5 facing the camera (kept side is farther,
383        // z < -5). After oblique clipping the projection, a point ON the plane
384        // maps to ndc.z ~= 0, a point in front (far side) to ndc.z in (0, 1), and
385        // a point behind the plane to ndc.z < 0 (clipped).
386        let proj = perspective_rh(1.2, 1.0, 0.1, 100.0);
387        // Plane z = -5: n.p + d = 0 with kept side n.p + d > 0 toward -z (far).
388        // Choose C so the far/kept side is positive: C = (0,0,-1,-5) -> for
389        // p=(0,0,-50): -(-50)-5 = 45 > 0 (kept); p=(0,0,-2): 2-5 = -3 < 0 (clip).
390        let c = [0.0, 0.0, -1.0, -5.0];
391        let pobl = oblique_projection(proj, c);
392
393        let ndc_z = |z: f32| {
394            let clip = xform(pobl, [0.0, 0.0, z, 1.0]);
395            clip[2] / clip[3]
396        };
397        assert!(approx(ndc_z(-5.0), 0.0, 1e-3), "on-plane ndc.z");
398        let front = ndc_z(-50.0);
399        assert!(front > 0.0 && front < 1.0, "far side in [0,1]: {front}");
400        assert!(ndc_z(-2.0) < 0.0, "near side clipped");
401    }
402
403    #[test]
404    fn oblique_clip_preserves_x_and_y_projection() {
405        // Only the depth row changes; x/y of a projected point are untouched.
406        let proj = perspective_rh(1.0, 1.5, 0.1, 50.0);
407        let c = [0.0, 0.0, -1.0, -8.0];
408        let pobl = oblique_projection(proj, c);
409        let p = [2.0, 1.5, -20.0, 1.0];
410        let a = xform(proj, p);
411        let b = xform(pobl, p);
412        assert!(approx(a[0] / a[3], b[0] / b[3], 1e-5), "ndc.x");
413        assert!(approx(a[1] / a[3], b[1] / b[3], 1e-5), "ndc.y");
414    }
415
416    #[test]
417    fn planar_matrices_clip_below_the_water_plane() {
418        // A camera above a horizontal water plane (y = 0, normal up). The mirror
419        // pass must clip world geometry BELOW the plane (it would otherwise leak
420        // into the reflection). Verify a below-water point lands at ndc.z < 0 and
421        // an above-water point stays in [0, 1].
422        let plane = [0.0, 1.0, 0.0, 0.0]; // y = 0, normal +y (kept side: above)
423        // Simple camera at (0, 3, 6) looking toward -z and slightly down. Build a
424        // view that just translates (identity rotation is enough for the depth
425        // sign test since reflection + projection handle the rest).
426        let view = [
427            [1.0, 0.0, 0.0, 0.0],
428            [0.0, 1.0, 0.0, 0.0],
429            [0.0, 0.0, 1.0, 0.0],
430            [0.0, -3.0, -6.0, 1.0],
431        ];
432        let proj = perspective_rh(1.2, 1.6, 0.1, 100.0);
433        let m = planar_matrices(view, proj, [0.0, 3.0, 6.0], plane, 0.0);
434
435        let ndc_z = |p: [f32; 3]| {
436            let clip = xform(m.view_proj, [p[0], p[1], p[2], 1.0]);
437            clip[2] / clip[3]
438        };
439        // Above water, in front of the camera: visible (0..1).
440        let above = ndc_z([0.0, 2.0, -4.0]);
441        assert!(above > 0.0 && above < 1.0, "above-water visible: {above}");
442        // Below water, in front of the camera: clipped (ndc.z < 0).
443        let below = ndc_z([0.0, -2.0, -4.0]);
444        assert!(below < 0.0, "below-water clipped: {below}");
445        // The reflected eye sits below the plane (mirror of y = 3).
446        assert!(approx(m.eye[1], -3.0, 1e-5), "reflected eye height");
447    }
448
449    #[test]
450    fn orient_plane_faces_the_camera() {
451        // A vertical pane at z = -3 with normal pointing toward -z. A camera in
452        // front of it (at +z relative to the pane) must flip the normal so the
453        // kept (oblique-clip) side faces the viewer.
454        let plane = normalize_plane([0.0, 0.0, -1.0, -3.0]); // n.p + d = 0 -> z = -3
455        let cam = [0.0, 1.0, 0.0]; // in front of the pane (z = 0 > -3 side)
456        let oriented = orient_plane_toward(plane, cam);
457        let signed =
458            oriented[0] * cam[0] + oriented[1] * cam[1] + oriented[2] * cam[2] + oriented[3];
459        assert!(signed > 0.0, "camera must be on the +normal (kept) side");
460        // It flipped the original (which faced away from the camera).
461        assert!(approx(oriented[2], 1.0, 1e-5), "normal flipped toward +z");
462    }
463
464    #[test]
465    fn orient_plane_is_noop_for_water_above_camera() {
466        // Horizontal water plane y = 2, normal +y. A camera above it keeps the
467        // plane unchanged, so water renders identically with the orientation step.
468        let plane = normalize_plane([0.0, 1.0, 0.0, -2.0]);
469        let cam = [3.0, 5.0, -1.0]; // above the plane
470        let oriented = orient_plane_toward(plane, cam);
471        for i in 0..4 {
472            assert!(
473                approx(oriented[i], plane[i], 1e-6),
474                "component {i} unchanged"
475            );
476        }
477    }
478
479    #[test]
480    fn assign_slots_dedups_coplanar_and_caps_distinct() {
481        // Two coplanar panes (same wall), one distinct wall, plus a third distinct
482        // wall that overflows a budget of 2. The coplanar pair shares slot 0, the
483        // second wall takes slot 1, the third overflows to None.
484        let wall_a0 = [0.0, 0.0, 1.0, -3.0];
485        let wall_a1 = [0.0, 0.0, 1.0, -3.05]; // within OFFSET_EPS of a0
486        let wall_b = [1.0, 0.0, 0.0, -5.0];
487        let wall_c = [0.0, 1.0, 0.0, -1.0];
488        let a = assign_planar_slots(&[wall_a0, wall_a1, wall_b, wall_c], 2);
489        assert_eq!(a.representatives.len(), 2, "two slots allocated");
490        assert_eq!(a.slots[0], Some(0));
491        assert_eq!(a.slots[1], Some(0), "coplanar pane reuses slot 0");
492        assert_eq!(a.slots[2], Some(1));
493        assert_eq!(
494            a.slots[3], None,
495            "third distinct plane overflows the budget"
496        );
497    }
498
499    #[test]
500    fn assign_slots_is_sign_invariant() {
501        // A plane and its flip (opposite normal, opposite offset) are the same
502        // surface and must share a slot.
503        let front = [0.0, 0.0, 1.0, -3.0];
504        let back = [0.0, 0.0, -1.0, 3.0];
505        let a = assign_planar_slots(&[front, back], 4);
506        assert_eq!(a.representatives.len(), 1, "flip is the same surface");
507        assert_eq!(a.slots[0], Some(0));
508        assert_eq!(a.slots[1], Some(0));
509    }
510
511    #[test]
512    fn reflected_frustum_captures_geometry_behind_the_camera() {
513        // A vertical mirror at z = -5 (unit normal +z, facing the camera). The
514        // camera sits at the origin looking down -z, toward the mirror. An object
515        // BEHIND the camera (z = +3) is outside the main frustum, but its
516        // reflection is visible in the mirror -- so the reflected-frustum cull must
517        // capture it where the main-camera set would miss it (the V1 gap).
518        let plane = [0.0, 0.0, 1.0, 5.0]; // n.p + d = 0 -> z = -5
519        let view = [
520            [1.0, 0.0, 0.0, 0.0],
521            [0.0, 1.0, 0.0, 0.0],
522            [0.0, 0.0, 1.0, 0.0],
523            [0.0, 0.0, 0.0, 1.0],
524        ];
525        let proj = perspective_rh(1.2, 1.6, 0.1, 100.0);
526        let cam_pos = [0.0, 0.0, 0.0];
527        let m = planar_matrices(view, proj, cam_pos, plane, 0.0);
528
529        // One cullable object behind the camera.
530        let bvh = crate::render::bvh::Bvh::build(&[crate::render::bvh::BvhItem {
531            bb_min: [-0.5, -0.5, 2.5],
532            bb_max: [0.5, 0.5, 3.5],
533            cull_distance: 0.0,
534            index: 0,
535        }]);
536
537        // The main camera rejects it (behind the near plane).
538        let main_frustum = crate::gfx::frustum::Frustum::from_view_projection(proj);
539        let mut main_visible = Vec::new();
540        bvh.query(&main_frustum, cam_pos, |i| main_visible.push(i));
541        assert!(
542            !main_visible.contains(&0),
543            "object behind the camera must be outside the main frustum"
544        );
545
546        // The reflected frustum captures it, and the always-draw fallback is
547        // appended after the culled set.
548        let reflected_frustum = crate::gfx::frustum::Frustum::from_view_projection(m.view_proj);
549        let always = [7u32];
550        let mut out = Vec::new();
551        reflected_visible_set(&bvh, &reflected_frustum, m.eye, &always, &mut out);
552        assert!(
553            out.contains(&0),
554            "object behind the camera must be visible in the reflection"
555        );
556        assert_eq!(
557            out.last(),
558            Some(&7),
559            "always-draw fallback appended after the culled set"
560        );
561    }
562
563    #[test]
564    fn reflected_visible_set_reuses_the_output_buffer() {
565        // The buffer is cleared each call, so reusing it across planes never leaks
566        // a prior plane's culled indices.
567        let bvh = crate::render::bvh::Bvh::build(&[crate::render::bvh::BvhItem {
568            bb_min: [-0.5, -0.5, -0.5],
569            bb_max: [0.5, 0.5, 0.5],
570            cull_distance: 0.0,
571            index: 3,
572        }]);
573        // A frustum that rejects everything (identity clip cube, box far outside).
574        let empty_frustum = crate::gfx::frustum::Frustum::from_view_projection([
575            [1.0, 0.0, 0.0, 0.0],
576            [0.0, 1.0, 0.0, 0.0],
577            [0.0, 0.0, 1.0, 0.0],
578            [-100.0, 0.0, 0.0, 1.0],
579        ]);
580        let mut out = vec![99, 99, 99];
581        reflected_visible_set(&bvh, &empty_frustum, [0.0, 0.0, 0.0], &[], &mut out);
582        assert!(
583            out.is_empty(),
584            "stale indices must be cleared before refill"
585        );
586    }
587
588    #[test]
589    fn assign_slots_overflow_still_reuses_existing_slot() {
590        // With a budget of 1, a second distinct plane overflows, but a later plane
591        // coplanar with slot 0 still maps to slot 0 (dedup precedes the cap).
592        let a = assign_planar_slots(
593            &[
594                [0.0, 0.0, 1.0, -3.0],
595                [1.0, 0.0, 0.0, -5.0], // overflow
596                [0.0, 0.0, 1.0, -3.0], // coplanar with slot 0
597            ],
598            1,
599        );
600        assert_eq!(a.representatives.len(), 1);
601        assert_eq!(a.slots[0], Some(0));
602        assert_eq!(a.slots[1], None);
603        assert_eq!(a.slots[2], Some(0));
604    }
605
606    #[test]
607    fn planar_runs_for_water_even_while_ray_tracing() {
608        // The whole point of the gate: a water surface holding a mirror slot keeps
609        // the re-render alive under a live trace.
610        assert!(planar_pass_needed(true, true, true));
611        // Glass alone under a live trace still skips it.
612        assert!(!planar_pass_needed(true, false, true));
613        // With no trace, any reflector needs the mirror.
614        assert!(planar_pass_needed(true, false, false));
615        // No mirror target, nothing to render.
616        assert!(!planar_pass_needed(false, true, true));
617        assert!(!planar_pass_needed(false, false, false));
618    }
619}