Skip to main content

renderling/
bvol.rs

1//! Bounding volumes and culling primitives.
2//!
3//! The initial implementation here was gleaned from `treeculler`, which
4//! unfortunately cannot compile to SPIR-V because of its use of `u8`.
5//!
6//! Also, here we use `glam`, whereas `treeculler` uses its own internal
7//! primitives.
8//!
9//! More resources:
10//! * <https://fgiesen.wordpress.com/2010/10/17/view-frustum-culling/>
11//! * <http://old.cescg.org/CESCG-2002/DSykoraJJelinek/>
12//! * <https://iquilezles.org/www/articles/frustumcorrect/frustumcorrect.htm>
13
14use crabslab::SlabItem;
15use glam::{Mat4, Vec2, Vec3, Vec3Swizzles, Vec4, Vec4Swizzles};
16#[cfg(gpu)]
17use spirv_std::num_traits::Float;
18
19use crate::{camera::shader::CameraDescriptor, transform::shader::TransformDescriptor};
20
21/// Normalize a plane.
22pub fn normalize_plane(mut plane: Vec4) -> Vec4 {
23    let normal_magnitude = (plane.x.powi(2) + plane.y.powi(2) + plane.z.powi(2))
24        .sqrt()
25        .max(f32::EPSILON);
26    plane.x /= normal_magnitude;
27    plane.y /= normal_magnitude;
28    plane.z /= normal_magnitude;
29    plane.w /= normal_magnitude;
30    plane
31}
32
33/// Find the intersection point of three planes.
34///
35/// # Notes
36/// This assumes that the planes will not intersect in a line.
37pub fn intersect_planes(p0: &Vec4, p1: &Vec4, p2: &Vec4) -> Vec3 {
38    let bxc = p1.xyz().cross(p2.xyz());
39    let cxa = p2.xyz().cross(p0.xyz());
40    let axb = p0.xyz().cross(p1.xyz());
41    let r = -bxc * p0.w - cxa * p1.w - axb * p2.w;
42    r * (1.0 / bxc.dot(p0.xyz()))
43}
44
45/// Calculates distance between plane and point
46pub fn dist_bpp(plane: &Vec4, point: Vec3) -> f32 {
47    plane.x * point.x + plane.y * point.y + plane.z * point.z + plane.w
48}
49
50/// Calculates the most inside vertex of an AABB.
51pub fn mi_vertex(plane: &Vec4, aabb: &Aabb) -> Vec3 {
52    Vec3::new(
53        if plane.x >= 0.0 {
54            aabb.max.x
55        } else {
56            aabb.min.x
57        },
58        if plane.y >= 0.0 {
59            aabb.max.y
60        } else {
61            aabb.min.y
62        },
63        if plane.z >= 0.0 {
64            aabb.max.z
65        } else {
66            aabb.min.z
67        },
68    )
69}
70
71/// Calculates the most outside vertex of an AABB.
72pub fn mo_vertex(plane: &Vec4, aabb: &Aabb) -> Vec3 {
73    Vec3::new(
74        if plane.x >= 0.0 {
75            aabb.min.x
76        } else {
77            aabb.max.x
78        },
79        if plane.y >= 0.0 {
80            aabb.min.y
81        } else {
82            aabb.max.y
83        },
84        if plane.z >= 0.0 {
85            aabb.min.z
86        } else {
87            aabb.max.z
88        },
89    )
90}
91
92/// Axis aligned bounding box.
93#[derive(Clone, Copy, Debug, Default, PartialEq, SlabItem)]
94pub struct Aabb {
95    pub min: Vec3,
96    pub max: Vec3,
97}
98
99impl From<(Vec3, Vec3)> for Aabb {
100    fn from((a, b): (Vec3, Vec3)) -> Self {
101        Aabb::new(a, b)
102    }
103}
104
105impl Aabb {
106    pub fn new(a: Vec3, b: Vec3) -> Self {
107        Self {
108            min: a.min(b),
109            max: a.max(b),
110        }
111    }
112
113    /// Return the length along the x axis.
114    pub fn width(&self) -> f32 {
115        self.max.x - self.min.x
116    }
117
118    /// Return the length along the y axis.
119    pub fn height(&self) -> f32 {
120        self.max.y - self.min.y
121    }
122
123    /// Return the length along the z axis.
124    pub fn depth(&self) -> f32 {
125        self.max.z - self.min.z
126    }
127
128    pub fn center(&self) -> Vec3 {
129        (self.min + self.max) * 0.5
130    }
131
132    pub fn extents(&self) -> Vec3 {
133        self.max - self.center()
134    }
135
136    pub fn diagonal_length(&self) -> f32 {
137        self.min.distance(self.max)
138    }
139
140    pub fn is_zero(&self) -> bool {
141        self.min == self.max
142    }
143
144    /// Returns the union of the two [`Aabb`]s.
145    pub fn union(a: Self, b: Self) -> Self {
146        Aabb {
147            min: a.min.min(a.max).min(b.min).min(b.max),
148            max: a.max.max(a.min).max(b.max).max(b.min),
149        }
150    }
151
152    /// Determines whether this `Aabb` can be seen by `camera` after being
153    /// transformed by `transform`.
154    pub fn is_outside_camera_view(
155        &self,
156        camera: &CameraDescriptor,
157        transform: TransformDescriptor,
158    ) -> bool {
159        let transform = Mat4::from(transform);
160        let min = transform.transform_point3(self.min);
161        let max = transform.transform_point3(self.max);
162        Aabb::new(min, max).is_inside_frustum(camera.frustum())
163    }
164
165    #[cfg(not(target_arch = "spirv"))]
166    /// Return a triangle mesh connecting this `Aabb`'s corners.
167    ///
168    /// ```ignore
169    ///    y           1_____2     _____
170    ///    |           /    /|    /|    |  (same box, left and front sides removed)
171    ///    |___x     0/___3/ |   /7|____|6
172    ///   /           |    | /   | /    /
173    /// z/            |____|/   4|/____/5
174    ///
175    /// 7 is min
176    /// 3 is max
177    /// ```
178    pub fn get_mesh(&self) -> Vec<(Vec3, Vec3)> {
179        let p0 = Vec3::new(self.min.x, self.max.y, self.max.z);
180        let p1 = Vec3::new(self.min.x, self.max.y, self.min.z);
181        let p2 = Vec3::new(self.max.x, self.max.y, self.min.z);
182        let p3 = Vec3::new(self.max.x, self.max.y, self.max.z);
183        let p4 = Vec3::new(self.min.x, self.min.y, self.max.z);
184        let p7 = Vec3::new(self.min.x, self.min.y, self.min.z);
185        let p6 = Vec3::new(self.max.x, self.min.y, self.min.z);
186        let p5 = Vec3::new(self.max.x, self.min.y, self.max.z);
187
188        let positions = crate::math::convex_mesh([p0, p1, p2, p3, p4, p5, p6, p7]);
189        positions
190            .chunks_exact(3)
191            .flat_map(|chunk| match chunk {
192                [a, b, c] => {
193                    let n = crate::math::triangle_face_normal(*a, *b, *c);
194                    [(*a, n), (*b, n), (*c, n)]
195                }
196                _ => unreachable!(),
197            })
198            .collect()
199    }
200
201    /// Returns whether this `Aabb` intersects another `Aabb`.
202    ///
203    /// Returns `false` if the two are touching, but not overlapping.
204    pub fn intersects_aabb(&self, other: &Aabb) -> bool {
205        self.min.x < other.max.x
206            && self.max.x > other.min.x
207            && self.min.y < other.max.y
208            && self.max.y > other.min.y
209            && self.min.z < other.max.z
210            && self.max.z > other.min.z
211    }
212}
213
214/// Six planes of a view frustum.
215#[derive(Clone, Copy, Default, PartialEq, SlabItem, core::fmt::Debug)]
216pub struct Frustum {
217    /// Planes constructing the sides of the frustum,
218    /// each expressed as a normal vector (xyz) and the distance (w)
219    /// from the origin along that vector.
220    pub planes: [Vec4; 6],
221    /// Points representing the corners of the frustum
222    pub points: [Vec3; 8],
223    /// Centroid of the corners of the frustum
224    pub center: Vec3,
225}
226
227impl Frustum {
228    /// Compute a frustum in world space from the given [`CameraDescriptor`].
229    pub fn from_camera(camera: &CameraDescriptor) -> Self {
230        let viewprojection = camera.view_projection();
231        let mvp = viewprojection.to_cols_array_2d();
232
233        let left = normalize_plane(Vec4::new(
234            mvp[0][0] + mvp[0][3],
235            mvp[1][0] + mvp[1][3],
236            mvp[2][0] + mvp[2][3],
237            mvp[3][0] + mvp[3][3],
238        ));
239        let right = normalize_plane(Vec4::new(
240            -mvp[0][0] + mvp[0][3],
241            -mvp[1][0] + mvp[1][3],
242            -mvp[2][0] + mvp[2][3],
243            -mvp[3][0] + mvp[3][3],
244        ));
245        let bottom = normalize_plane(Vec4::new(
246            mvp[0][1] + mvp[0][3],
247            mvp[1][1] + mvp[1][3],
248            mvp[2][1] + mvp[2][3],
249            mvp[3][1] + mvp[3][3],
250        ));
251        let top = normalize_plane(Vec4::new(
252            -mvp[0][1] + mvp[0][3],
253            -mvp[1][1] + mvp[1][3],
254            -mvp[2][1] + mvp[2][3],
255            -mvp[3][1] + mvp[3][3],
256        ));
257        // For wgpu/Vulkan/D3D (z_ndc ∈ [0, 1]) the near plane is just row2,
258        // not row2 + row3 (which is the OpenGL z_ndc ∈ [-1, 1] convention).
259        let near = normalize_plane(Vec4::new(mvp[0][2], mvp[1][2], mvp[2][2], mvp[3][2]));
260        let far = normalize_plane(Vec4::new(
261            -mvp[0][2] + mvp[0][3],
262            -mvp[1][2] + mvp[1][3],
263            -mvp[2][2] + mvp[2][3],
264            -mvp[3][2] + mvp[3][3],
265        ));
266
267        // Account for the possibility that the projection is infinite.
268        //
269        // See <https://renderling.xyz/devlog/index.html#actual_frustum_culling>
270        // for more details.
271        let far = (-1.0 * near.xyz()).extend(far.w);
272
273        let flt = intersect_planes(&far, &left, &top);
274        let frt = intersect_planes(&far, &right, &top);
275        let flb = intersect_planes(&far, &left, &bottom);
276        let frb = intersect_planes(&far, &right, &bottom);
277        let nlt = intersect_planes(&near, &left, &top);
278        let nrt = intersect_planes(&near, &right, &top);
279        let nlb = intersect_planes(&near, &left, &bottom);
280        let nrb = intersect_planes(&near, &right, &bottom);
281
282        Self {
283            center: (nlt + nrt + nlb + nrb) / 4.0,
284            planes: [near, left, right, bottom, top, far],
285            points: [nlt, nrt, nlb, nrb, flt, frt, flb, frb],
286        }
287    }
288
289    #[cfg(not(target_arch = "spirv"))]
290    /// Return a triangle mesh connecting this `Frustum`'s corners.
291    pub fn get_mesh(&self) -> Vec<(Vec3, Vec3)> {
292        let [nlt, nrt, nlb, nrb, flt, frt, flb, frb] = self.points;
293        let p0 = nlt;
294        let p1 = flt;
295        let p2 = frt;
296        let p3 = nrt;
297        let p4 = nlb;
298        let p5 = nrb;
299        let p6 = frb;
300        let p7 = flb;
301        crate::math::convex_mesh([p0, p1, p2, p3, p4, p5, p6, p7])
302            .chunks_exact(3)
303            .flat_map(|chunk| match chunk {
304                [a, b, c] => {
305                    let n = crate::math::triangle_face_normal(*a, *b, *c);
306                    [(*a, n), (*b, n), (*c, n)]
307                }
308                _ => unreachable!(),
309            })
310            .collect()
311    }
312
313    pub fn test_against_aabb(&self, aabb: &Aabb) -> bool {
314        for i in 0..3 {
315            let mut out = 0;
316            for j in 0..8 {
317                if self.points[j].to_array()[i] < aabb.min.to_array()[i] {
318                    out += 1;
319                }
320            }
321            if out == 8 {
322                return false;
323            }
324            out = 0;
325            for j in 0..8 {
326                if self.points[j].to_array()[i] > aabb.max.to_array()[i] {
327                    out += 1;
328                }
329            }
330            if out == 8 {
331                return false;
332            }
333        }
334        true
335    }
336
337    /// Returns the depth of the frustum.
338    pub fn depth(&self) -> f32 {
339        (self.planes[0].w - self.planes[5].w).abs()
340    }
341}
342
343/// Bounding box consisting of a center and three half extents.
344///
345/// Essentially a point at the center and a vector pointing from
346/// the center to the corner.
347///
348/// This is _not_ an axis aligned bounding box.
349#[derive(Clone, Copy, Default, PartialEq, SlabItem, core::fmt::Debug)]
350pub struct BoundingBox {
351    pub center: Vec3,
352    pub half_extent: Vec3,
353}
354
355impl BoundingBox {
356    pub fn from_min_max(min: Vec3, max: Vec3) -> Self {
357        let center = (min + max) / 2.0;
358        let half_extent = max - center;
359        Self {
360            center,
361            half_extent,
362        }
363    }
364
365    pub fn distance(&self, point: Vec3) -> f32 {
366        let p = point - self.center;
367        let component_edge_distance = p.abs() - self.half_extent;
368        let outside = component_edge_distance.max(Vec3::ZERO).length();
369        let inside = component_edge_distance
370            .x
371            .max(component_edge_distance.y)
372            .min(0.0);
373        inside + outside
374    }
375
376    #[cfg(cpu)]
377    /// Return a triangle mesh connecting this `Aabb`'s corners.
378    ///
379    /// ```ignore
380    ///    y           1_____2     _____
381    ///    |           /    /|    /|    |  (same box, left and front sides removed)
382    ///    |___x     0/___3/ |   /7|____|6
383    ///   /           |    | /   | /    /
384    /// z/            |____|/   4|/____/5
385    ///
386    /// 7 is min
387    /// 3 is max
388    /// ```
389    pub fn get_mesh(&self) -> [(Vec3, Vec3); 36] {
390        // Deriving the corner positions from centre and half-extent,
391
392        let p0 = Vec3::new(-self.half_extent.x, self.half_extent.y, self.half_extent.z);
393        let p1 = Vec3::new(-self.half_extent.x, self.half_extent.y, -self.half_extent.z);
394        let p2 = Vec3::new(self.half_extent.x, self.half_extent.y, -self.half_extent.z);
395        let p3 = self.half_extent;
396        let p4 = Vec3::new(-self.half_extent.x, -self.half_extent.y, self.half_extent.z);
397        let p5 = Vec3::new(self.half_extent.x, -self.half_extent.y, self.half_extent.z);
398        let p6 = Vec3::new(self.half_extent.x, -self.half_extent.y, -self.half_extent.z);
399        // min
400        let p7 = -self.half_extent;
401
402        let positions =
403            crate::math::convex_mesh([p0, p1, p2, p3, p4, p5, p6, p7].map(|p| p + self.center));
404
405        // Attach per-triangle face normals.
406        let vertices: Vec<(Vec3, Vec3)> = positions
407            .chunks_exact(3)
408            .flat_map(|chunk| match chunk {
409                [a, b, c] => {
410                    let n = crate::math::triangle_face_normal(*a, *b, *c);
411                    [(*a, n), (*b, n), (*c, n)]
412                }
413                _ => unreachable!(),
414            })
415            .collect();
416
417        // Convert into fixed-size array (12 triangles × 3 vertices).
418        vertices
419            .try_into()
420            .unwrap_or_else(|v: Vec<(Vec3, Vec3)>| panic!("expected 36 vertices, got {}", v.len()))
421    }
422
423    pub fn contains_point(&self, point: Vec3) -> bool {
424        let delta = (point - self.center).abs();
425        let extent = self.half_extent.abs();
426        delta.x <= extent.x && delta.y <= extent.y && delta.z <= extent.z
427    }
428}
429
430/// Bounding sphere consisting of a center and radius.
431#[derive(Clone, Copy, Debug, Default, PartialEq, SlabItem)]
432pub struct BoundingSphere {
433    pub center: Vec3,
434    pub radius: f32,
435}
436
437impl From<(Vec3, Vec3)> for BoundingSphere {
438    fn from((min, max): (Vec3, Vec3)) -> Self {
439        let center = (min + max) * 0.5;
440        let radius = center.distance(max);
441        BoundingSphere { center, radius }
442    }
443}
444
445impl From<Aabb> for BoundingSphere {
446    fn from(value: Aabb) -> Self {
447        (value.min, value.max).into()
448    }
449}
450
451impl BoundingSphere {
452    /// Creates a new bounding sphere.
453    pub fn new(center: impl Into<Vec3>, radius: f32) -> BoundingSphere {
454        BoundingSphere {
455            center: center.into(),
456            radius,
457        }
458    }
459
460    /// Determine whether this sphere is inside the camera's view frustum after
461    /// being transformed by `transform`.  
462    pub fn is_inside_camera_view(
463        &self,
464        camera: &CameraDescriptor,
465        transform: TransformDescriptor,
466    ) -> (bool, BoundingSphere) {
467        let center = Mat4::from(transform).transform_point3(self.center);
468        let scale = Vec3::splat(transform.scale.max_element());
469        let radius = Mat4::from_scale(scale)
470            .transform_point3(Vec3::new(self.radius, 0.0, 0.0))
471            .distance(Vec3::ZERO);
472        let sphere = BoundingSphere::new(center, radius);
473        (sphere.is_inside_frustum(camera.frustum()), sphere)
474    }
475
476    /// Transform this `BoundingSphere` by the given view projection matrix.
477    pub fn project_by(&self, view_projection: &Mat4) -> Self {
478        let center = self.center;
479        // Pick any direction to find a point on the surface.
480        let surface_point = self.center + self.radius * Vec3::Z;
481        let new_center = view_projection.project_point3(center);
482        let new_surface_point = view_projection.project_point3(surface_point);
483        let new_radius = new_center.distance(new_surface_point);
484        Self {
485            center: new_center,
486            radius: new_radius,
487        }
488    }
489
490    /// Returns an [`Aabb`] with x and y coordinates in viewport pixels and z
491    /// coordinate in NDC depth.
492    pub fn project_onto_viewport(&self, camera: &CameraDescriptor, viewport: Vec2) -> Aabb {
493        fn ndc_to_pixel(viewport: Vec2, ndc: Vec3) -> Vec2 {
494            let screen = Vec3::new((ndc.x + 1.0) * 0.5, 1.0 - (ndc.y + 1.0) * 0.5, ndc.z);
495            (screen * viewport.extend(1.0)).xy()
496        }
497
498        let viewproj = camera.view_projection();
499        let frustum = camera.frustum();
500
501        // Find the center and radius of the bounding sphere in pixel space.
502        // By pixel space, I mean where (0, 0) is the top-left of the screen
503        // and (w, h) is is the bottom-left.
504        let center_clip = viewproj * self.center.extend(1.0);
505        let front_center_ndc =
506            viewproj.project_point3(self.center + self.radius * frustum.planes[5].xyz());
507        let back_center_ndc =
508            viewproj.project_point3(self.center + self.radius * frustum.planes[0].xyz());
509        let center_ndc = center_clip.xyz() / center_clip.w;
510        let center_pixels = ndc_to_pixel(viewport, center_ndc);
511        let radius_pixels = viewport.x * (self.radius / center_clip.w);
512        Aabb::new(
513            (center_pixels - radius_pixels).extend(front_center_ndc.z),
514            (center_pixels + radius_pixels).extend(back_center_ndc.z),
515        )
516    }
517}
518
519impl BVol for BoundingSphere {
520    fn get_aabb(&self) -> Aabb {
521        Aabb {
522            min: self.center - Vec3::splat(self.radius),
523            max: self.center + Vec3::splat(self.radius),
524        }
525    }
526
527    fn culls_this_plane(&self, plane: &Vec4) -> bool {
528        dist_bpp(plane, self.center) < -self.radius
529    }
530}
531
532/// Bounding volume trait.
533pub trait BVol {
534    /// Returns an AABB that contains the bounding volume.
535    fn get_aabb(&self) -> Aabb;
536
537    /// Checks if the given bounding volume is culled by this plane.
538    ///
539    /// Returns true if it does, false otherwise.
540    fn culls_this_plane(&self, plane: &Vec4) -> bool;
541
542    fn is_inside_frustum(&self, frustum: Frustum) -> bool {
543        let (inside, _) = self.coherent_test_is_volume_outside_frustum(&frustum, 0);
544        !inside
545    }
546
547    /// Checks if bounding volume is outside the frustum "coherently".
548    ///
549    /// In order for a bounding volume to be inside the frustum, it must not be
550    /// culled by any plane.
551    ///
552    /// Coherence is provided by the `lpindex` argument, which should be the
553    /// index of the first plane found that culls this volume, given as part
554    /// of the return value of this function.
555    ///
556    /// Returns `true` if the volume is outside the frustum, `false` otherwise.
557    ///
558    /// Returns the index of first plane found that culls this volume, to cache
559    /// and use later as a short circuit.
560    fn coherent_test_is_volume_outside_frustum(
561        &self,
562        frustum: &Frustum,
563        lpindex: u32,
564    ) -> (bool, u32) {
565        if self.culls_this_plane(&frustum.planes[lpindex as usize]) {
566            return (true, lpindex);
567        }
568
569        for i in 0..6 {
570            if (i != lpindex) && self.culls_this_plane(&frustum.planes[i as usize]) {
571                return (true, i);
572            }
573        }
574
575        if !frustum.test_against_aabb(&self.get_aabb()) {
576            return (true, lpindex);
577        }
578
579        (false, lpindex)
580    }
581}
582
583impl BVol for Aabb {
584    fn get_aabb(&self) -> Aabb {
585        *self
586    }
587
588    fn culls_this_plane(&self, plane: &Vec4) -> bool {
589        dist_bpp(plane, mi_vertex(plane, self)) < 0.0
590    }
591}
592
593#[cfg(test)]
594mod test {
595    use glam::{Mat4, Quat};
596
597    use crate::{context::Context, geometry::Vertex, test::BlockOnFuture};
598
599    use super::*;
600
601    #[test]
602    fn bvol_frustum_is_in_world_space_sanity() {
603        let (p, v) = crate::camera::default_perspective(800.0, 600.0);
604        let camera = CameraDescriptor::new(p, v);
605        let aabb_outside = Aabb {
606            min: Vec3::new(-10.0, -12.0, 20.0),
607            max: Vec3::new(10.0, 12.0, 40.0),
608        };
609        assert!(!aabb_outside.is_inside_frustum(camera.frustum()));
610
611        let aabb_inside = Aabb {
612            min: Vec3::new(-3.0, -3.0, -3.0),
613            max: Vec3::new(3.0, 3.0, 3.0),
614        };
615        assert!(aabb_inside.is_inside_frustum(camera.frustum()));
616    }
617
618    #[test]
619    fn frustum_culling_debug_corner_case() {
620        // https://github.com/schell/renderling/issues/131
621        // https://renderling.xyz/devlog/index.html#frustum_culling_last_debugging__aabb_vs_frustum_corner_case
622        let camera = {
623            let aspect = 1.0;
624            let fovy = core::f32::consts::FRAC_PI_4;
625            let znear = 4.0;
626            let zfar = 1000.0;
627            let projection = Mat4::perspective_rh(fovy, aspect, znear, zfar);
628            let eye = Vec3::new(0.0, 0.0, 10.0);
629            let target = Vec3::ZERO;
630            let up = Vec3::Y;
631            let view = Mat4::look_at_rh(eye, target, up);
632            CameraDescriptor::new(projection, view)
633        };
634        let aabb = Aabb {
635            min: Vec3::new(-3.2869213, -3.0652206, -3.8715153),
636            max: Vec3::new(3.2869213, 3.0652206, 3.8715153),
637        };
638        let transform = TransformDescriptor {
639            translation: Vec3::new(7.5131035, -9.947085, -5.001645),
640            rotation: Quat::from_xyzw(0.4700742, 0.34307128, 0.6853008, -0.43783003),
641            scale: Vec3::new(1.0, 1.0, 1.0),
642        };
643        assert!(
644            !aabb.is_outside_camera_view(&camera, transform),
645            "aabb should be inside the frustum"
646        );
647    }
648
649    #[test]
650    fn bounding_box_from_min_max() {
651        let ctx = Context::headless(256, 256).block();
652        let stage = ctx
653            .new_stage()
654            .with_background_color(Vec4::ZERO)
655            .with_msaa_sample_count(4)
656            .with_lighting(true);
657        let _camera = stage.new_camera().with_projection_and_view(
658            // Fixed: was `near=10, far=-10` which reversed depth ordering.
659            // Using `near=0.1, far=20` ensures correct depth testing.
660            Mat4::orthographic_rh(-3.0, 3.0, -3.0, 3.0, 0.1, 20.0),
661            Mat4::look_at_rh(Vec3::new(-3.0, 3.0, 5.0), Vec3::ZERO, Vec3::Y),
662        );
663        let _lights = crate::test::make_two_directional_light_setup(&stage);
664
665        let white = stage.new_material();
666        let red = stage
667            .new_material()
668            .with_albedo_factor(Vec4::new(1.0, 0.0, 0.0, 1.0));
669
670        let _w = stage.new_primitive().with_material(&white).with_vertices(
671            stage.new_vertices(
672                crate::math::unit_cube()
673                    .into_iter()
674                    .map(|(p, n)| Vertex::default().with_position(p).with_normal(n)),
675            ),
676        );
677
678        let mut corners = vec![];
679        for x in [-1.0, 1.0] {
680            for y in [-1.0, 1.0] {
681                for z in [-1.0, 1.0] {
682                    corners.push(Vec3::new(x, y, z));
683                }
684            }
685        }
686        let mut rs = vec![];
687        for corner in corners.iter() {
688            let bb = BoundingBox {
689                center: Vec3::new(0.5, 0.5, 0.5) * corner,
690                half_extent: Vec3::splat(0.25),
691            };
692            assert!(
693                bb.contains_point(bb.center),
694                "BoundingBox {bb:?} does not contain center"
695            );
696
697            rs.push(
698                stage.new_primitive().with_material(&red).with_vertices(
699                    stage.new_vertices(
700                        bb.get_mesh()
701                            .map(|(p, n)| Vertex::default().with_position(p).with_normal(n)),
702                    ),
703                ),
704            );
705        }
706
707        let frame = ctx.get_next_frame().unwrap();
708        stage.render(&frame.view());
709        let img = frame.read_image().block().unwrap();
710        img_diff::assert_img_eq("bvol/bounding_box/get_mesh.png", img);
711    }
712
713    /// Verifies that an orthographic projection with correct `near < far`
714    /// produces visible lit output.
715    ///
716    /// The original bug was that `orthographic_rh(-10, 10, -10, 10, 10, -10)`
717    /// (note: `near=10 > far=-10`) reversed the depth mapping, causing back
718    /// faces to win the depth test and produce zero lighting output.
719    ///
720    /// The fix is to use `near < far`, matching the convention of glTF, wgpu,
721    /// and every major rendering engine.
722    #[test]
723    fn orthographic_projection_lighting() {
724        let ctx = Context::headless(256, 256).block();
725        let view = Mat4::look_at_rh(Vec3::new(-3.0, 3.0, 5.0) * 0.5, Vec3::ZERO, Vec3::Y);
726
727        // Helper: render a lit unit cube and return the number of bright
728        // pixels (any RGB channel > 10).
729        let render = |projection: Mat4, label: &str| -> usize {
730            let stage = ctx
731                .new_stage()
732                .with_background_color(Vec4::ZERO)
733                .with_msaa_sample_count(4)
734                .with_lighting(true);
735            let _camera = stage
736                .new_camera()
737                .with_projection_and_view(projection, view);
738            let _lights = crate::test::make_two_directional_light_setup(&stage);
739            let white = stage.new_material().with_albedo_factor(Vec4::ONE);
740            let _prim = stage.new_primitive().with_material(&white).with_vertices(
741                stage.new_vertices(
742                    crate::math::unit_cube()
743                        .into_iter()
744                        .map(|(p, n)| Vertex::default().with_position(p).with_normal(n)),
745                ),
746            );
747            let frame = ctx.get_next_frame().unwrap();
748            stage.render(&frame.view());
749            let img = frame.read_image().block().unwrap();
750            img_diff::save(&format!("bvol/ortho_lighting_{label}.png"), img.clone());
751            let bright = img
752                .pixels()
753                .filter(|p| p.0[0] > 10 || p.0[1] > 10 || p.0[2] > 10)
754                .count();
755            bright
756        };
757
758        // Correct orthographic: near=-10, far=10 (near < far)
759        let ortho_bright = render(
760            Mat4::orthographic_rh(-10.0, 10.0, -10.0, 10.0, -10.0, 10.0),
761            "ortho_correct",
762        );
763
764        // Perspective for reference
765        let persp_bright = render(crate::camera::perspective(256.0, 256.0), "perspective");
766
767        assert!(
768            persp_bright > 100,
769            "Sanity: perspective should have bright pixels, got {persp_bright}"
770        );
771        assert!(
772            ortho_bright > 100,
773            "Orthographic projection (near=-10, far=10) should produce visible lit output, got \
774             only {ortho_bright} bright pixels (perspective had {persp_bright})"
775        );
776    }
777
778    #[test]
779    fn aabb_intersection() {
780        let a = Aabb::new(Vec3::ZERO, Vec3::ONE);
781        let b = Aabb::new(Vec3::splat(0.9), Vec3::splat(1.9));
782        assert!(a.intersects_aabb(&b));
783        assert!(b.intersects_aabb(&a));
784    }
785
786    #[test]
787    fn aabb_union() {
788        let a = Aabb::new(Vec3::splat(4.0), Vec3::splat(5.0));
789        let b = Aabb::new(Vec3::ZERO, Vec3::ONE);
790        let c = Aabb::union(a, b);
791        assert_eq!(
792            Aabb {
793                min: Vec3::ZERO,
794                max: Vec3::splat(5.0)
795            },
796            c
797        );
798    }
799}