Skip to main content

rustial_engine/math/
frustum.rs

1//! View frustum extraction and intersection testing (AABB, sphere, point).
2//!
3//! # Algorithm
4//!
5//! Frustum planes are extracted from a combined view-projection matrix using
6//! the Gribb/Hartmann method (["Fast Extraction of Viewing Frustum Planes
7//! from the World-View-Projection Matrix"][gh]).  The six resulting planes
8//! have **inward-pointing** normals: a positive signed distance from a plane
9//! means the query point is *inside* the frustum on that side.
10//!
11//! # Coordinate convention
12//!
13//! This module operates in **world space** (meters, EPSG:3857, right-handed
14//! Z-up).  The input `DMat4` must be `projection * view`, both in f64.
15//! The engine computes these matrices camera-relative to avoid f32 jitter,
16//! then passes the result here before the f64-to-f32 GPU cast.
17//!
18//! # Culling guarantee
19//!
20//! All three tests (`contains_point`, `intersects_sphere`,
21//! `intersects_aabb`) are **conservative**: they will never report a visible
22//! object as outside (no false negatives), but may occasionally report an
23//! object outside the frustum as inside (false positives) for AABB/sphere
24//! near frustum corners.  This is the standard trade-off -- exact corner
25//! tests cost 8x more and are not needed for tile/model culling.
26//!
27//! # Degenerate input
28//!
29//! A zero or near-zero view-projection matrix produces all-zero planes.
30//! These pass every intersection test, so a degenerate camera never
31//! incorrectly culls geometry (safe conservative fallback).
32//!
33//! [gh]: http://www.cs.otago.ac.nz/postgrads/alexis/planeExtraction.pdf
34
35use crate::bounds::WorldBounds;
36use crate::coord::WorldCoord;
37use glam::DVec4;
38
39// ---------------------------------------------------------------------------
40// Plane
41// ---------------------------------------------------------------------------
42
43/// A plane in Hessian normal form: `ax + by + cz + d = 0`.
44///
45/// The normal `[a, b, c]` is always unit-length after extraction.  The sign
46/// convention is: positive [`distance_to_point`](Self::distance_to_point)
47/// means the query point is on the **inside** (visible) half-space.
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct Plane {
50    normal: [f64; 3],
51    d: f64,
52}
53
54impl Plane {
55    /// Construct a plane from a unit normal and signed distance.
56    ///
57    /// The caller is responsible for ensuring the normal is unit-length;
58    /// no normalisation is performed here.
59    #[inline]
60    pub fn new(normal: [f64; 3], d: f64) -> Self {
61        Self { normal, d }
62    }
63
64    /// The inward-facing normal of this plane.
65    ///
66    /// For frustum planes extracted via the Gribb/Hartmann method, this
67    /// normal points **into** the frustum volume.  A positive
68    /// [`distance_to_point`](Self::distance_to_point) value means the
69    /// query point is on the inside (visible) side of the plane.
70    #[inline]
71    pub fn normal(&self) -> [f64; 3] {
72        self.normal
73    }
74
75    /// The signed distance constant `d` in `n . p + d = 0`.
76    #[inline]
77    pub fn d(&self) -> f64 {
78        self.d
79    }
80
81    /// Signed distance from a point to this plane.
82    ///
83    /// - **Positive**: the point is inside the frustum on this plane's side.
84    /// - **Zero**: the point lies exactly on the plane.
85    /// - **Negative**: the point is outside.
86    ///
87    /// Because the normal is unit-length, the returned value is a true
88    /// metric distance in the same units as the input coordinates (meters).
89    #[inline]
90    pub fn distance_to_point(&self, x: f64, y: f64, z: f64) -> f64 {
91        self.normal[0] * x + self.normal[1] * y + self.normal[2] * z + self.d
92    }
93}
94
95// ---------------------------------------------------------------------------
96// Frustum
97// ---------------------------------------------------------------------------
98
99/// Index constant for the left frustum plane.
100///
101/// These match the extraction order used by
102/// [`Frustum::from_view_projection`] and correspond to the array returned
103/// by [`Frustum::planes`].
104pub const PLANE_LEFT: usize = 0;
105/// Right frustum plane index.
106pub const PLANE_RIGHT: usize = 1;
107/// Bottom frustum plane index.
108pub const PLANE_BOTTOM: usize = 2;
109/// Top frustum plane index.
110pub const PLANE_TOP: usize = 3;
111/// Near frustum plane index.
112pub const PLANE_NEAR: usize = 4;
113/// Far frustum plane index.
114pub const PLANE_FAR: usize = 5;
115
116/// Six-plane view frustum extracted from a view-projection matrix.
117///
118/// Constructed via [`from_view_projection`](Self::from_view_projection),
119/// then queried with one of the intersection methods.  The plane order is
120/// `[left, right, bottom, top, near, far]` and is accessible through
121/// [`planes()`](Self::planes) or the `PLANE_*` index constants.
122///
123/// # When to rebuild
124///
125/// Rebuild every frame (or whenever the camera moves).  Construction is
126/// six normalisations -- negligible compared to the tile-selection loop
127/// it gates.
128#[derive(Debug, Clone)]
129pub struct Frustum {
130    planes: [Plane; 6],
131}
132
133impl Frustum {
134    /// Extract the 6 frustum planes from a combined view-projection matrix.
135    ///
136    /// Uses the Gribb/Hartmann row-combination method:
137    ///
138    /// ```text
139    /// left   = row3 + row0      right = row3 - row0
140    /// bottom = row3 + row1      top   = row3 - row1
141    /// near   = row3 + row2      far   = row3 - row2
142    /// ```
143    ///
144    /// Each raw plane `(a, b, c, d)` is then normalised by dividing by
145    /// `sqrt(a^2 + b^2 + c^2)` so that `distance_to_point` returns a
146    /// true metric distance.  Degenerate planes (norm < 1e-15) are left
147    /// as all-zeros, which makes them pass all intersection tests (safe
148    /// conservative default).
149    pub fn from_view_projection(vp: &glam::DMat4) -> Self {
150        // Transpose the column-major matrix into row vectors.
151        let row0 = DVec4::new(vp.col(0).x, vp.col(1).x, vp.col(2).x, vp.col(3).x);
152        let row1 = DVec4::new(vp.col(0).y, vp.col(1).y, vp.col(2).y, vp.col(3).y);
153        let row2 = DVec4::new(vp.col(0).z, vp.col(1).z, vp.col(2).z, vp.col(3).z);
154        let row3 = DVec4::new(vp.col(0).w, vp.col(1).w, vp.col(2).w, vp.col(3).w);
155
156        let raw_planes = [
157            row3 + row0, // left
158            row3 - row0, // right
159            row3 + row1, // bottom
160            row3 - row1, // top
161            row3 + row2, // near
162            row3 - row2, // far
163        ];
164
165        let mut planes = [Plane {
166            normal: [0.0; 3],
167            d: 0.0,
168        }; 6];
169        for (i, p) in raw_planes.iter().enumerate() {
170            let len = (p.x * p.x + p.y * p.y + p.z * p.z).sqrt();
171            // Guard against degenerate matrices (e.g. zero-area viewport).
172            // A zero-normal plane has distance 0 to every point, so all
173            // intersection tests conservatively return "inside".
174            if len > 1e-15 {
175                planes[i] = Plane {
176                    normal: [p.x / len, p.y / len, p.z / len],
177                    d: p.w / len,
178                };
179            }
180        }
181
182        Self { planes }
183    }
184
185    /// Access the six frustum planes: `[left, right, bottom, top, near, far]`.
186    #[inline]
187    pub fn planes(&self) -> &[Plane; 6] {
188        &self.planes
189    }
190
191    /// Test whether a single point is inside the frustum.
192    ///
193    /// Returns `true` if the point is on the inside (or boundary) of
194    /// every frustum plane.  Useful for quick single-coordinate checks
195    /// such as cursor hit-testing or model anchor visibility.
196    pub fn contains_point(&self, point: &WorldCoord) -> bool {
197        let (x, y, z) = (point.position.x, point.position.y, point.position.z);
198        for plane in &self.planes {
199            if plane.distance_to_point(x, y, z) < 0.0 {
200                return false;
201            }
202        }
203        true
204    }
205
206    /// Test whether a bounding sphere intersects the frustum.
207    ///
208    /// Returns `true` if the sphere (defined by a centre point and
209    /// radius in meters) is at least partially inside.  Preferred over
210    /// AABB for rotated 3D models where the axis-aligned box would be
211    /// excessively loose.
212    ///
213    /// When `radius == 0.0` this degrades to a point-containment test.
214    ///
215    /// Conservative: may return `true` for spheres that only overlap a
216    /// frustum corner region without actually intersecting the volume.
217    pub fn intersects_sphere(&self, center: &WorldCoord, radius: f64) -> bool {
218        let (x, y, z) = (center.position.x, center.position.y, center.position.z);
219        for plane in &self.planes {
220            if plane.distance_to_point(x, y, z) < -radius {
221                return false;
222            }
223        }
224        true
225    }
226
227    /// Test whether an axis-aligned bounding box intersects the frustum.
228    ///
229    /// Uses the "p-vertex" (positive vertex) optimisation: for each plane,
230    /// only the single AABB corner most aligned with the plane normal is
231    /// tested.  If that corner is outside, the entire box is outside.
232    ///
233    /// Returns `true` if the AABB is at least partially inside.
234    ///
235    /// Conservative: may return `true` for boxes near frustum corners that
236    /// are geometrically outside the exact frustum volume.
237    pub fn intersects_aabb(&self, bounds: &WorldBounds) -> bool {
238        let min = bounds.min.position;
239        let max = bounds.max.position;
240
241        for plane in &self.planes {
242            // P-vertex: the AABB corner furthest along the plane normal.
243            // If even this corner is on the outside, every other corner
244            // is further outside, so the whole box is culled.
245            let px = if plane.normal[0] >= 0.0 { max.x } else { min.x };
246            let py = if plane.normal[1] >= 0.0 { max.y } else { min.y };
247            let pz = if plane.normal[2] >= 0.0 { max.z } else { min.z };
248
249            if plane.distance_to_point(px, py, pz) < 0.0 {
250                return false;
251            }
252        }
253        true
254    }
255}
256
257// ---------------------------------------------------------------------------
258// Tests
259// ---------------------------------------------------------------------------
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use glam::DMat4;
265
266    // -- Plane constructor ------------------------------------------------
267
268    #[test]
269    fn plane_new_and_accessors() {
270        let p = Plane::new([0.0, 1.0, 0.0], -5.0);
271        assert_eq!(p.normal(), [0.0, 1.0, 0.0]);
272        assert_eq!(p.d(), -5.0);
273    }
274
275    // -- Identity / orthographic ------------------------------------------
276
277    #[test]
278    fn identity_frustum_contains_origin() {
279        let vp = DMat4::IDENTITY;
280        let frustum = Frustum::from_view_projection(&vp);
281        let bounds = WorldBounds::new(
282            WorldCoord::new(-0.5, -0.5, -0.5),
283            WorldCoord::new(0.5, 0.5, 0.5),
284        );
285        assert!(frustum.intersects_aabb(&bounds));
286    }
287
288    #[test]
289    fn ortho_frustum_culls_outside() {
290        let vp = DMat4::orthographic_rh(-100.0, 100.0, -100.0, 100.0, -100.0, 100.0);
291        let frustum = Frustum::from_view_projection(&vp);
292
293        let inside = WorldBounds::new(
294            WorldCoord::new(-50.0, -50.0, -50.0),
295            WorldCoord::new(50.0, 50.0, 50.0),
296        );
297        let outside = WorldBounds::new(
298            WorldCoord::new(200.0, 200.0, 200.0),
299            WorldCoord::new(300.0, 300.0, 300.0),
300        );
301        assert!(frustum.intersects_aabb(&inside));
302        assert!(!frustum.intersects_aabb(&outside));
303    }
304
305    // -- Extracted normals are unit-length ---------------------------------
306
307    #[test]
308    fn extracted_normals_are_unit_length() {
309        let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.0, 0.1, 1000.0);
310        let view = DMat4::look_at_rh(
311            glam::DVec3::new(0.0, 0.0, 10.0),
312            glam::DVec3::ZERO,
313            glam::DVec3::Y,
314        );
315        let frustum = Frustum::from_view_projection(&(proj * view));
316        for plane in frustum.planes() {
317            let n = plane.normal();
318            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
319            assert!(
320                (len - 1.0).abs() < 1e-12,
321                "plane normal not unit-length: {len}"
322            );
323        }
324    }
325
326    // -- Degenerate (zero) matrix -----------------------------------------
327
328    #[test]
329    fn degenerate_matrix_passes_all_tests() {
330        let frustum = Frustum::from_view_projection(&DMat4::ZERO);
331        // All-zero planes should conservatively pass everything.
332        assert!(frustum.contains_point(&WorldCoord::new(999.0, 999.0, 999.0)));
333        assert!(frustum.intersects_sphere(&WorldCoord::new(0.0, 0.0, 0.0), 1.0));
334        let bounds = WorldBounds::new(
335            WorldCoord::new(-1.0, -1.0, -1.0),
336            WorldCoord::new(1.0, 1.0, 1.0),
337        );
338        assert!(frustum.intersects_aabb(&bounds));
339    }
340
341    // -- Plane index constants --------------------------------------------
342
343    #[test]
344    fn plane_index_constants() {
345        let vp = DMat4::orthographic_rh(-100.0, 100.0, -100.0, 100.0, -100.0, 100.0);
346        let frustum = Frustum::from_view_projection(&vp);
347        let planes = frustum.planes();
348        // Left plane normal should have positive X component (points right/inward).
349        // Right plane normal should have negative X component (points left/inward).
350        assert!(planes[PLANE_LEFT].normal()[0] > 0.0);
351        assert!(planes[PLANE_RIGHT].normal()[0] < 0.0);
352    }
353
354    // -- Perspective (axis-aligned camera) --------------------------------
355
356    #[test]
357    fn perspective_frustum() {
358        let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.0, 0.1, 1000.0);
359        let view = DMat4::look_at_rh(
360            glam::DVec3::new(0.0, 0.0, 10.0),
361            glam::DVec3::ZERO,
362            glam::DVec3::Y,
363        );
364        let vp = proj * view;
365        let frustum = Frustum::from_view_projection(&vp);
366
367        let visible = WorldBounds::new(
368            WorldCoord::new(-1.0, -1.0, -1.0),
369            WorldCoord::new(1.0, 1.0, 1.0),
370        );
371        let behind = WorldBounds::new(
372            WorldCoord::new(-1.0, -1.0, 20.0),
373            WorldCoord::new(1.0, 1.0, 30.0),
374        );
375        assert!(frustum.intersects_aabb(&visible));
376        assert!(!frustum.intersects_aabb(&behind));
377    }
378
379    // -- Pitched camera (real 2.5D map scenario) --------------------------
380
381    #[test]
382    fn pitched_camera_frustum() {
383        let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.5, 1.0, 5000.0);
384        let eye = glam::DVec3::new(0.0, -500.0, 500.0);
385        let target = glam::DVec3::ZERO;
386        let view = DMat4::look_at_rh(eye, target, glam::DVec3::Z);
387        let vp = proj * view;
388        let frustum = Frustum::from_view_projection(&vp);
389
390        let ahead = WorldBounds::new(
391            WorldCoord::new(-100.0, 100.0, 0.0),
392            WorldCoord::new(100.0, 300.0, 0.0),
393        );
394        assert!(frustum.intersects_aabb(&ahead));
395
396        let behind = WorldBounds::new(
397            WorldCoord::new(-100.0, -2000.0, 0.0),
398            WorldCoord::new(100.0, -1500.0, 0.0),
399        );
400        assert!(!frustum.intersects_aabb(&behind));
401
402        let far_left = WorldBounds::new(
403            WorldCoord::new(-5000.0, 0.0, 0.0),
404            WorldCoord::new(-4000.0, 100.0, 0.0),
405        );
406        assert!(!frustum.intersects_aabb(&far_left));
407    }
408
409    // -- Point containment ------------------------------------------------
410
411    #[test]
412    fn contains_point_inside() {
413        let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.0, 0.1, 1000.0);
414        let view = DMat4::look_at_rh(
415            glam::DVec3::new(0.0, 0.0, 10.0),
416            glam::DVec3::ZERO,
417            glam::DVec3::Y,
418        );
419        let frustum = Frustum::from_view_projection(&(proj * view));
420
421        assert!(frustum.contains_point(&WorldCoord::new(0.0, 0.0, 0.0)));
422    }
423
424    #[test]
425    fn contains_point_outside() {
426        let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.0, 0.1, 1000.0);
427        let view = DMat4::look_at_rh(
428            glam::DVec3::new(0.0, 0.0, 10.0),
429            glam::DVec3::ZERO,
430            glam::DVec3::Y,
431        );
432        let frustum = Frustum::from_view_projection(&(proj * view));
433
434        assert!(!frustum.contains_point(&WorldCoord::new(0.0, 0.0, 50.0)));
435        assert!(!frustum.contains_point(&WorldCoord::new(1000.0, 0.0, 0.0)));
436    }
437
438    // -- Sphere intersection ----------------------------------------------
439
440    #[test]
441    fn intersects_sphere_inside() {
442        let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.0, 0.1, 1000.0);
443        let view = DMat4::look_at_rh(
444            glam::DVec3::new(0.0, 0.0, 10.0),
445            glam::DVec3::ZERO,
446            glam::DVec3::Y,
447        );
448        let frustum = Frustum::from_view_projection(&(proj * view));
449
450        assert!(frustum.intersects_sphere(&WorldCoord::new(0.0, 0.0, 0.0), 1.0));
451    }
452
453    #[test]
454    fn intersects_sphere_partially_outside() {
455        let vp = DMat4::orthographic_rh(-100.0, 100.0, -100.0, 100.0, -100.0, 100.0);
456        let frustum = Frustum::from_view_projection(&vp);
457
458        // Centre 5m outside the right plane, but 10m radius reaches in.
459        assert!(frustum.intersects_sphere(&WorldCoord::new(105.0, 0.0, 0.0), 10.0));
460        // 100m outside -- radius cannot reach.
461        assert!(!frustum.intersects_sphere(&WorldCoord::new(200.0, 0.0, 0.0), 10.0));
462    }
463
464    #[test]
465    fn intersects_sphere_zero_radius_degrades_to_point() {
466        let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.0, 0.1, 1000.0);
467        let view = DMat4::look_at_rh(
468            glam::DVec3::new(0.0, 0.0, 10.0),
469            glam::DVec3::ZERO,
470            glam::DVec3::Y,
471        );
472        let frustum = Frustum::from_view_projection(&(proj * view));
473        let inside = WorldCoord::new(0.0, 0.0, 0.0);
474        let outside = WorldCoord::new(0.0, 0.0, 50.0);
475        assert_eq!(
476            frustum.intersects_sphere(&inside, 0.0),
477            frustum.contains_point(&inside),
478        );
479        assert_eq!(
480            frustum.intersects_sphere(&outside, 0.0),
481            frustum.contains_point(&outside),
482        );
483    }
484
485    // -- Plane helpers ----------------------------------------------------
486
487    #[test]
488    fn plane_partial_eq() {
489        let a = Plane::new([1.0, 0.0, 0.0], 5.0);
490        let b = Plane::new([1.0, 0.0, 0.0], 5.0);
491        assert_eq!(a, b);
492    }
493}