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}