Skip to main content

gizmo_math/
frustum.rs

1use crate::aabb::Aabb;
2use glam::{Mat4, Vec3A, Vec4};
3
4// ---------------------------------------------------------------------------
5// Intersection result
6// ---------------------------------------------------------------------------
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Intersection {
10    /// Completely outside the frustum — safe to cull.
11    Outside,
12    /// Partially overlapping — cannot cull, must recurse or draw.
13    Partial,
14    /// Completely inside the frustum — children can skip plane tests.
15    Inside,
16}
17
18// ---------------------------------------------------------------------------
19// Plane
20// ---------------------------------------------------------------------------
21
22/// A normalized half-space plane: `normal · X + distance = 0`.
23/// Points where `normal · X + distance > 0` are on the "positive" side.
24#[derive(Debug, Clone, Copy)]
25pub struct Plane {
26    /// Unit-length outward normal.
27    pub normal: Vec3A,
28    /// Signed distance from the origin along the normal.
29    pub distance: f32,
30}
31
32impl Plane {
33    /// Constructs a plane from raw (possibly unnormalized) coefficients `(x, y, z, w)`.
34    /// Normalizes so that `normal` is always unit-length.
35    /// Falls back to `+Z, d=0` if the normal is degenerate.
36    #[inline]
37    pub fn from_coefficients(x: f32, y: f32, z: f32, w: f32) -> Self {
38        let len_sq = x * x + y * y + z * z;
39        if len_sq < 1e-10 {
40            return Self {
41                normal: Vec3A::Z,
42                distance: 0.0,
43            };
44        }
45        let inv_len = len_sq.sqrt().recip();
46        Self {
47            normal: Vec3A::new(x * inv_len, y * inv_len, z * inv_len),
48            distance: w * inv_len,
49        }
50    }
51
52    /// Constructs a plane directly from a `Vec4` row of a VP matrix.
53    #[inline]
54    fn from_vec4(v: Vec4) -> Self {
55        Self::from_coefficients(v.x, v.y, v.z, v.w)
56    }
57
58    /// Signed distance from the plane to `pt`.
59    /// Positive = in front of (positive side of) the plane.
60    #[inline]
61    pub fn signed_distance(self, pt: Vec3A) -> f32 {
62        self.normal.dot(pt) + self.distance
63    }
64
65    /// Returns the "positive vertex" of the AABB — the corner furthest along
66    /// the plane normal. Used in conservative AABB–frustum tests.
67    #[inline]
68    fn positive_vertex(self, aabb: Aabb) -> Vec3A {
69        Vec3A::select(self.normal.cmpgt(Vec3A::ZERO), aabb.max, aabb.min)
70    }
71
72    /// Returns the "negative vertex" of the AABB — the corner furthest
73    /// against the plane normal.
74    #[inline]
75    fn negative_vertex(self, aabb: Aabb) -> Vec3A {
76        Vec3A::select(self.normal.cmpgt(Vec3A::ZERO), aabb.min, aabb.max)
77    }
78}
79
80// ---------------------------------------------------------------------------
81// Frustum
82// ---------------------------------------------------------------------------
83
84/// Six-plane view frustum extracted from a combined Projection × View matrix.
85///
86/// Plane extraction follows Gribb & Hartmann (2001) and works for both
87/// right-handed and left-handed projection conventions.
88///
89/// NDC convention: WGPU / Vulkan / DX12 (Z ∈ [0, 1]).
90/// For OpenGL (Z ∈ [−1, 1]) swap the near-plane extraction (see comments).
91#[derive(Debug, Clone, Copy)]
92pub struct Frustum {
93    /// `[left, right, bottom, top, near, far]` — all outward-facing.
94    pub planes: [Plane; 6],
95}
96
97impl Frustum {
98    /// Extracts the frustum planes from a Projection × View (VP) matrix.
99    ///
100    /// Uses the Gribb–Hartmann method: each plane is a linear combination of
101    /// the matrix rows, requiring no trigonometry and working with any
102    /// well-formed projection.
103    ///
104    /// **NDC assumed:** Z ∈ [0, 1] (WGPU, Vulkan, DirectX).  
105    /// For OpenGL Z ∈ [−1, 1], change the near plane to `r3 + r2`.
106    #[inline]
107    pub fn from_matrix(vp: &Mat4) -> Self {
108        let r0 = vp.row(0); // X column in clip space
109        let r1 = vp.row(1); // Y column
110        let r2 = vp.row(2); // Z column
111        let r3 = vp.row(3); // W column (homogeneous)
112
113        Self {
114            planes: [
115                Plane::from_vec4(r3 + r0), // Left:   w + x ≥ 0
116                Plane::from_vec4(r3 - r0), // Right:  w - x ≥ 0
117                Plane::from_vec4(r3 + r1), // Bottom: w + y ≥ 0
118                Plane::from_vec4(r3 - r1), // Top:    w - y ≥ 0
119                Plane::from_vec4(r2),      // Near:   z ≥ 0        (Z∈[0,1])
120                // Near (OpenGL Z∈[−1,1]): Plane::from_vec4(r3 + r2)
121                Plane::from_vec4(r3 - r2), // Far:    w - z ≥ 0
122            ],
123        }
124    }
125
126    /// Returns the six frustum planes as a slice.
127    #[inline]
128    pub fn planes(&self) -> &[Plane; 6] {
129        &self.planes
130    }
131
132    // -----------------------------------------------------------------------
133    // Sphere test
134    // -----------------------------------------------------------------------
135
136    /// Tests whether a sphere (center + radius) is visible.
137    ///
138    /// Culls if the sphere center is more than `radius` behind any plane.
139    /// This is a conservative test — no false negatives, some false positives
140    /// near corners.
141    #[inline]
142    pub fn intersects_sphere(&self, center: impl Into<Vec3A>, radius: f32) -> bool {
143        let c = center.into();
144        self.planes.iter().all(|p| p.signed_distance(c) >= -radius)
145    }
146
147    // -----------------------------------------------------------------------
148    // OBB tests
149    // -----------------------------------------------------------------------
150
151    /// Returns `true` if an Oriented Bounding Box (OBB) intersects the frustum.
152    ///
153    /// The OBB is defined by its center, half-extents (from center to faces),
154    /// and its rotation quaternion. This is a fast conservative test based on
155    /// projecting the OBB extents onto each frustum plane normal.
156    #[inline]
157    pub fn intersects_obb(
158        &self,
159        center: impl Into<Vec3A>,
160        half_extents: impl Into<Vec3A>,
161        rotation: glam::Quat,
162    ) -> bool {
163        self.test_obb(center, half_extents, rotation) != Intersection::Outside
164    }
165
166    /// Full OBB–frustum classification: `Outside`, `Partial`, or `Inside`.
167    #[inline]
168    pub fn test_obb(
169        &self,
170        center: impl Into<Vec3A>,
171        half_extents: impl Into<Vec3A>,
172        rotation: glam::Quat,
173    ) -> Intersection {
174        let center = center.into();
175        let extents = half_extents.into();
176
177        // Extract local axes of the OBB from the rotation quaternion
178        let rot_mat = glam::Mat3A::from_quat(rotation);
179        let axis_x = rot_mat.x_axis;
180        let axis_y = rot_mat.y_axis;
181        let axis_z = rot_mat.z_axis;
182
183        let mut all_inside = true;
184
185        for plane in &self.planes {
186            // Compute the projected "radius" of the OBB along the plane's normal
187            let r = extents.x * plane.normal.dot(axis_x).abs()
188                + extents.y * plane.normal.dot(axis_y).abs()
189                + extents.z * plane.normal.dot(axis_z).abs();
190
191            let d = plane.signed_distance(center);
192
193            // If the OBB's center is further behind the plane than its projected radius,
194            // the OBB is completely outside the frustum.
195            if d < -r {
196                return Intersection::Outside;
197            }
198            // If the center is within the radius distance of the plane, it crosses the boundary.
199            if d < r {
200                all_inside = false;
201            }
202        }
203
204        if all_inside {
205            Intersection::Inside
206        } else {
207            Intersection::Partial
208        }
209    }
210
211    // -----------------------------------------------------------------------
212    // AABB tests
213    // -----------------------------------------------------------------------
214
215    /// Full AABB–frustum classification: `Outside`, `Partial`, or `Inside`.
216    ///
217    /// Uses the positive/negative vertex (p/n-vertex) method — 2 dot products
218    /// per plane instead of 8 corner transforms.
219    ///
220    /// - **Outside**: positive vertex is behind any plane → fully culled.
221    /// - **Inside**: negative vertex is in front of every plane → fully contained.
222    /// - **Partial**: otherwise.
223    #[inline]
224    pub fn test_aabb(&self, aabb: Aabb) -> Intersection {
225        if aabb.is_empty() {
226            return Intersection::Outside;
227        }
228
229        let mut all_inside = true;
230
231        for plane in &self.planes {
232            // p-vertex: the corner most along the normal.
233            // If even this corner is behind the plane → completely outside.
234            if plane.signed_distance(plane.positive_vertex(aabb)) < 0.0 {
235                return Intersection::Outside;
236            }
237            // n-vertex: the corner most against the normal.
238            // If it is behind the plane → not fully inside.
239            if plane.signed_distance(plane.negative_vertex(aabb)) < 0.0 {
240                all_inside = false;
241            }
242        }
243
244        if all_inside {
245            Intersection::Inside
246        } else {
247            Intersection::Partial
248        }
249    }
250
251    /// Returns `true` if the AABB is at least partially visible (not culled).
252    #[inline]
253    pub fn intersects_aabb(&self, aabb: Aabb) -> bool {
254        self.test_aabb(aabb) != Intersection::Outside
255    }
256
257    // -----------------------------------------------------------------------
258    // BVH / hierarchical culling helpers
259    // -----------------------------------------------------------------------
260
261    /// Faster AABB visibility test that skips planes that the parent already
262    /// passed (used in BVH traversal with plane masking).
263    ///
264    /// `plane_mask` is a bitmask of the 6 planes to test (bit `i` = plane `i`).
265    /// Planes already known to fully contain the parent can be masked out.
266    ///
267    /// Returns `(Intersection, out_mask)` where `out_mask` can be passed to
268    /// child nodes — planes where the child is fully inside are cleared.
269    #[inline]
270    pub fn test_aabb_masked(&self, aabb: Aabb, plane_mask: u8) -> (Intersection, u8) {
271        if aabb.is_empty() {
272            return (Intersection::Outside, 0);
273        }
274
275        let mut all_inside = true;
276        let mut out_mask = 0u8;
277
278        for (i, plane) in self.planes.iter().enumerate() {
279            let bit = 1u8 << i;
280            if plane_mask & bit == 0 {
281                // Parent was already fully inside this plane; skip.
282                continue;
283            }
284            if plane.signed_distance(plane.positive_vertex(aabb)) < 0.0 {
285                return (Intersection::Outside, 0);
286            }
287            if plane.signed_distance(plane.negative_vertex(aabb)) < 0.0 {
288                all_inside = false;
289                out_mask |= bit; // Still need to test children against this plane.
290            }
291            // else: fully inside this plane — don't propagate to children.
292        }
293
294        let result = if all_inside {
295            Intersection::Inside
296        } else {
297            Intersection::Partial
298        };
299        (result, out_mask)
300    }
301
302    /// Full plane mask — test all 6 planes (use as the root mask for BVH traversal).
303    pub const FULL_MASK: u8 = 0b0011_1111;
304}
305
306// ---------------------------------------------------------------------------
307// Tests
308// ---------------------------------------------------------------------------
309
310#[cfg(test)]
311mod tests {
312    use super::{Frustum, Intersection, Plane};
313    use crate::aabb::Aabb;
314    use glam::{Mat4, Vec3, Vec3A};
315
316    // Shared camera setup: eye at Z=8, looking towards -Z.
317    fn make_frustum() -> Frustum {
318        let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 8.0), Vec3::ZERO, Vec3::Y);
319        let proj = Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
320        Frustum::from_matrix(&(proj * view))
321    }
322
323    // --- Plane tests ---
324
325    #[test]
326    fn plane_signed_distance() {
327        // XY plane (normal +Z, d = 0)
328        let p = Plane::from_coefficients(0.0, 0.0, 1.0, 0.0);
329        assert!((p.signed_distance(Vec3A::new(0.0, 0.0, 5.0)) - 5.0).abs() < 1e-5);
330        assert!((p.signed_distance(Vec3A::new(0.0, 0.0, -3.0)) + 3.0).abs() < 1e-5);
331    }
332
333    #[test]
334    fn plane_degenerate_normal() {
335        // Zero normal should not panic, falls back to Z
336        let p = Plane::from_coefficients(0.0, 0.0, 0.0, 1.0);
337        assert!((p.normal - Vec3A::Z).length() < 1e-5);
338    }
339
340    // --- Visibility ---
341
342    #[test]
343    fn aabb_fully_inside() {
344        let frustum = make_frustum();
345        let cube = Aabb::new(Vec3::splat(-0.5), Vec3::splat(0.5));
346        assert_eq!(frustum.test_aabb(cube), Intersection::Inside);
347    }
348
349    #[test]
350    fn aabb_behind_camera_outside() {
351        let frustum = make_frustum();
352        // Camera at Z=8 looking toward -Z; Z > 8 is behind near plane.
353        let behind = Aabb::new(Vec3::new(-1.0, -1.0, 10.0), Vec3::new(1.0, 1.0, 12.0));
354        assert_eq!(frustum.test_aabb(behind), Intersection::Outside);
355    }
356
357    #[test]
358    fn aabb_beyond_far_plane_outside() {
359        let frustum = make_frustum();
360        // Far plane at depth 100 from camera (Z = 8 − 100 = −92).
361        let far = Aabb::new(Vec3::new(-1.0, -1.0, -105.0), Vec3::new(1.0, 1.0, -95.0));
362        assert_eq!(frustum.test_aabb(far), Intersection::Outside);
363    }
364
365    #[test]
366    fn aabb_enclosing_frustum_is_partial() {
367        let frustum = make_frustum();
368        let huge = Aabb::new(Vec3::splat(-1000.0), Vec3::splat(1000.0));
369        assert_eq!(frustum.test_aabb(huge), Intersection::Partial);
370    }
371
372    #[test]
373    fn aabb_degenerate_point_inside() {
374        let frustum = make_frustum();
375        // A point (min == max) has zero volume (is_degenerate() == true) but is
376        // not strictly empty (is_empty() == false). Thus, the intersection test
377        // proceeds and correctly identifies it as inside the frustum.
378        let pt = Aabb::new(Vec3::ZERO, Vec3::ZERO);
379        assert_eq!(frustum.test_aabb(pt), Intersection::Inside);
380    }
381
382    #[test]
383    fn aabb_degenerate_point_outside() {
384        let frustum = make_frustum();
385        let pt = Aabb::new(Vec3::splat(1000.0), Vec3::splat(1000.0));
386        assert_eq!(frustum.test_aabb(pt), Intersection::Outside);
387    }
388
389    #[test]
390    fn aabb_empty_is_outside() {
391        let frustum = make_frustum();
392        assert_eq!(frustum.test_aabb(Aabb::empty()), Intersection::Outside);
393    }
394
395    #[test]
396    fn intersects_aabb_convenience() {
397        let frustum = make_frustum();
398        let inside = Aabb::new(Vec3::splat(-0.5), Vec3::splat(0.5));
399        let outside = Aabb::new(Vec3::new(-1.0, -1.0, 10.0), Vec3::new(1.0, 1.0, 12.0));
400        assert!(frustum.intersects_aabb(inside));
401        assert!(!frustum.intersects_aabb(outside));
402    }
403
404    // --- Sphere tests ---
405
406    #[test]
407    fn sphere_inside_frustum() {
408        let frustum = make_frustum();
409        assert!(frustum.intersects_sphere(Vec3::ZERO, 0.5));
410    }
411
412    #[test]
413    fn sphere_outside_frustum() {
414        let frustum = make_frustum();
415        // Center well behind near plane, radius too small to reach
416        assert!(!frustum.intersects_sphere(Vec3::new(0.0, 0.0, 50.0), 0.1));
417    }
418
419    #[test]
420    fn sphere_straddles_plane() {
421        let frustum = make_frustum();
422        // Large sphere centered behind camera but radius large enough to overlap
423        assert!(frustum.intersects_sphere(Vec3::new(0.0, 0.0, 9.0), 5.0));
424    }
425
426    // --- Masked test ---
427
428    #[test]
429    fn masked_test_skip_all_planes() {
430        let frustum = make_frustum();
431        // mask = 0: skip all planes → result is always Inside
432        let outside = Aabb::new(Vec3::splat(1000.0), Vec3::splat(2000.0));
433        let (result, _) = frustum.test_aabb_masked(outside, 0);
434        assert_eq!(result, Intersection::Inside, "zero mask skips all tests");
435    }
436
437    #[test]
438    fn masked_test_full_mask_matches_unmasked() {
439        let frustum = make_frustum();
440        let cube = Aabb::new(Vec3::splat(-0.5), Vec3::splat(0.5));
441        let unmasked = frustum.test_aabb(cube);
442        let (masked, _) = frustum.test_aabb_masked(cube, Frustum::FULL_MASK);
443        assert_eq!(unmasked, masked);
444    }
445}