concinnity_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 [`concinnity_core::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 alloc::vec::Vec;
16use concinnity_core::gfx::transform::{Mat4, mat4_inverse, mat4_mul};
17use concinnity_core::math::sqrt;
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::bvh::Bvh,
307 reflected_frustum: &crate::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 concinnity_core::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::bvh::Bvh::build(&[crate::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::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::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::bvh::Bvh::build(&[crate::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::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}