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    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    // AABB tests
149    // -----------------------------------------------------------------------
150
151    /// Full AABB–frustum classification: `Outside`, `Partial`, or `Inside`.
152    ///
153    /// Uses the positive/negative vertex (p/n-vertex) method — 2 dot products
154    /// per plane instead of 8 corner transforms.
155    ///
156    /// - **Outside**: positive vertex is behind any plane → fully culled.
157    /// - **Inside**: negative vertex is in front of every plane → fully contained.
158    /// - **Partial**: otherwise.
159    #[inline]
160    pub fn test_aabb(&self, aabb: Aabb) -> Intersection {
161        if aabb.is_empty() {
162            return Intersection::Outside;
163        }
164
165        let mut all_inside = true;
166
167        for plane in &self.planes {
168            // p-vertex: the corner most along the normal.
169            // If even this corner is behind the plane → completely outside.
170            if plane.signed_distance(plane.positive_vertex(aabb)) < 0.0 {
171                return Intersection::Outside;
172            }
173            // n-vertex: the corner most against the normal.
174            // If it is behind the plane → not fully inside.
175            if plane.signed_distance(plane.negative_vertex(aabb)) < 0.0 {
176                all_inside = false;
177            }
178        }
179
180        if all_inside {
181            Intersection::Inside
182        } else {
183            Intersection::Partial
184        }
185    }
186
187    /// Returns `true` if the AABB is at least partially visible (not culled).
188    #[inline]
189    pub fn intersects_aabb(&self, aabb: Aabb) -> bool {
190        self.test_aabb(aabb) != Intersection::Outside
191    }
192
193    // -----------------------------------------------------------------------
194    // BVH / hierarchical culling helpers
195    // -----------------------------------------------------------------------
196
197    /// Faster AABB visibility test that skips planes that the parent already
198    /// passed (used in BVH traversal with plane masking).
199    ///
200    /// `plane_mask` is a bitmask of the 6 planes to test (bit `i` = plane `i`).
201    /// Planes already known to fully contain the parent can be masked out.
202    ///
203    /// Returns `(Intersection, out_mask)` where `out_mask` can be passed to
204    /// child nodes — planes where the child is fully inside are cleared.
205    #[inline]
206    pub fn test_aabb_masked(&self, aabb: Aabb, plane_mask: u8) -> (Intersection, u8) {
207        if aabb.is_empty() {
208            return (Intersection::Outside, 0);
209        }
210
211        let mut all_inside = true;
212        let mut out_mask = 0u8;
213
214        for (i, plane) in self.planes.iter().enumerate() {
215            let bit = 1u8 << i;
216            if plane_mask & bit == 0 {
217                // Parent was already fully inside this plane; skip.
218                continue;
219            }
220            if plane.signed_distance(plane.positive_vertex(aabb)) < 0.0 {
221                return (Intersection::Outside, 0);
222            }
223            if plane.signed_distance(plane.negative_vertex(aabb)) < 0.0 {
224                all_inside = false;
225                out_mask |= bit; // Still need to test children against this plane.
226            }
227            // else: fully inside this plane — don't propagate to children.
228        }
229
230        let result = if all_inside {
231            Intersection::Inside
232        } else {
233            Intersection::Partial
234        };
235        (result, out_mask)
236    }
237
238    /// Full plane mask — test all 6 planes (use as the root mask for BVH traversal).
239    pub const FULL_MASK: u8 = 0b0011_1111;
240}
241
242// ---------------------------------------------------------------------------
243// Tests
244// ---------------------------------------------------------------------------
245
246#[cfg(test)]
247mod tests {
248    use super::{Frustum, Intersection, Plane};
249    use crate::aabb::Aabb;
250    use glam::{Mat4, Vec3, Vec3A};
251
252    // Shared camera setup: eye at Z=8, looking towards -Z.
253    fn make_frustum() -> Frustum {
254        let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 8.0), Vec3::ZERO, Vec3::Y);
255        let proj = Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
256        Frustum::from_matrix(&(proj * view))
257    }
258
259    // --- Plane tests ---
260
261    #[test]
262    fn plane_signed_distance() {
263        // XY plane (normal +Z, d = 0)
264        let p = Plane::from_coefficients(0.0, 0.0, 1.0, 0.0);
265        assert!((p.signed_distance(Vec3A::new(0.0, 0.0, 5.0)) - 5.0).abs() < 1e-5);
266        assert!((p.signed_distance(Vec3A::new(0.0, 0.0, -3.0)) + 3.0).abs() < 1e-5);
267    }
268
269    #[test]
270    fn plane_degenerate_normal() {
271        // Zero normal should not panic, falls back to Z
272        let p = Plane::from_coefficients(0.0, 0.0, 0.0, 1.0);
273        assert!((p.normal - Vec3A::Z).length() < 1e-5);
274    }
275
276    // --- Visibility ---
277
278    #[test]
279    fn aabb_fully_inside() {
280        let frustum = make_frustum();
281        let cube = Aabb::new(Vec3::splat(-0.5), Vec3::splat(0.5));
282        assert_eq!(frustum.test_aabb(cube), Intersection::Inside);
283    }
284
285    #[test]
286    fn aabb_behind_camera_outside() {
287        let frustum = make_frustum();
288        // Camera at Z=8 looking toward -Z; Z > 8 is behind near plane.
289        let behind = Aabb::new(Vec3::new(-1.0, -1.0, 10.0), Vec3::new(1.0, 1.0, 12.0));
290        assert_eq!(frustum.test_aabb(behind), Intersection::Outside);
291    }
292
293    #[test]
294    fn aabb_beyond_far_plane_outside() {
295        let frustum = make_frustum();
296        // Far plane at depth 100 from camera (Z = 8 − 100 = −92).
297        let far = Aabb::new(Vec3::new(-1.0, -1.0, -105.0), Vec3::new(1.0, 1.0, -95.0));
298        assert_eq!(frustum.test_aabb(far), Intersection::Outside);
299    }
300
301    #[test]
302    fn aabb_enclosing_frustum_is_partial() {
303        let frustum = make_frustum();
304        let huge = Aabb::new(Vec3::splat(-1000.0), Vec3::splat(1000.0));
305        assert_eq!(frustum.test_aabb(huge), Intersection::Partial);
306    }
307
308    #[test]
309    fn aabb_degenerate_point_inside() {
310        let frustum = make_frustum();
311        // A point (min == max) has zero volume (is_degenerate() == true) but is
312        // not strictly empty (is_empty() == false). Thus, the intersection test
313        // proceeds and correctly identifies it as inside the frustum.
314        let pt = Aabb::new(Vec3::ZERO, Vec3::ZERO);
315        assert_eq!(frustum.test_aabb(pt), Intersection::Inside);
316    }
317
318    #[test]
319    fn aabb_degenerate_point_outside() {
320        let frustum = make_frustum();
321        let pt = Aabb::new(Vec3::splat(1000.0), Vec3::splat(1000.0));
322        assert_eq!(frustum.test_aabb(pt), Intersection::Outside);
323    }
324
325    #[test]
326    fn aabb_empty_is_outside() {
327        let frustum = make_frustum();
328        assert_eq!(frustum.test_aabb(Aabb::empty()), Intersection::Outside);
329    }
330
331    #[test]
332    fn intersects_aabb_convenience() {
333        let frustum = make_frustum();
334        let inside = Aabb::new(Vec3::splat(-0.5), Vec3::splat(0.5));
335        let outside = Aabb::new(Vec3::new(-1.0, -1.0, 10.0), Vec3::new(1.0, 1.0, 12.0));
336        assert!(frustum.intersects_aabb(inside));
337        assert!(!frustum.intersects_aabb(outside));
338    }
339
340    // --- Sphere tests ---
341
342    #[test]
343    fn sphere_inside_frustum() {
344        let frustum = make_frustum();
345        assert!(frustum.intersects_sphere(Vec3::ZERO, 0.5));
346    }
347
348    #[test]
349    fn sphere_outside_frustum() {
350        let frustum = make_frustum();
351        // Center well behind near plane, radius too small to reach
352        assert!(!frustum.intersects_sphere(Vec3::new(0.0, 0.0, 50.0), 0.1));
353    }
354
355    #[test]
356    fn sphere_straddles_plane() {
357        let frustum = make_frustum();
358        // Large sphere centered behind camera but radius large enough to overlap
359        assert!(frustum.intersects_sphere(Vec3::new(0.0, 0.0, 9.0), 5.0));
360    }
361
362    // --- Masked test ---
363
364    #[test]
365    fn masked_test_skip_all_planes() {
366        let frustum = make_frustum();
367        // mask = 0: skip all planes → result is always Inside
368        let outside = Aabb::new(Vec3::splat(1000.0), Vec3::splat(2000.0));
369        let (result, _) = frustum.test_aabb_masked(outside, 0);
370        assert_eq!(result, Intersection::Inside, "zero mask skips all tests");
371    }
372
373    #[test]
374    fn masked_test_full_mask_matches_unmasked() {
375        let frustum = make_frustum();
376        let cube = Aabb::new(Vec3::splat(-0.5), Vec3::splat(0.5));
377        let unmasked = frustum.test_aabb(cube);
378        let (masked, _) = frustum.test_aabb_masked(cube, Frustum::FULL_MASK);
379        assert_eq!(unmasked, masked);
380    }
381}