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}