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}