Skip to main content

viewport_lib/camera/
frustum.rs

1//! View frustum extraction and AABB culling.
2//!
3//! Uses the Gribb-Hartmann method to extract six planes from a view-projection
4//! matrix, then tests AABBs against them for visibility culling.
5
6use crate::scene::aabb::Aabb;
7
8/// A plane in 3D space: `normal · point + d = 0`.
9#[derive(Debug, Clone, Copy)]
10pub struct Plane {
11    /// Unit normal of the plane.
12    pub normal: glam::Vec3,
13    /// Signed distance from origin along `normal`.
14    pub d: f32,
15}
16
17/// The six planes of a view frustum: left, right, bottom, top, near, far.
18#[derive(Debug, Clone)]
19pub struct Frustum {
20    /// Inward-facing planes in order: left, right, bottom, top, near, far.
21    pub planes: [Plane; 6],
22}
23
24impl Frustum {
25    /// Extract a frustum from a combined view-projection matrix using the
26    /// Gribb-Hartmann plane extraction method.
27    ///
28    /// The resulting planes face inward (normals point toward the interior).
29    pub fn from_view_proj(vp: &glam::Mat4) -> Self {
30        let row0 = vp.row(0);
31        let row1 = vp.row(1);
32        let row2 = vp.row(2);
33        let row3 = vp.row(3);
34
35        let mut planes = [
36            // Left:   row3 + row0
37            extract_plane(row3 + row0),
38            // Right:  row3 - row0
39            extract_plane(row3 - row0),
40            // Bottom: row3 + row1
41            extract_plane(row3 + row1),
42            // Top:    row3 - row1
43            extract_plane(row3 - row1),
44            // Near:   row2         (wgpu depth 0..1, so near = row2 directly)
45            extract_plane(row2),
46            // Far:    row3 - row2
47            extract_plane(row3 - row2),
48        ];
49
50        // Normalize all planes.
51        for plane in &mut planes {
52            let len = plane.normal.length();
53            if len > 1e-8 {
54                plane.normal /= len;
55                plane.d /= len;
56            }
57        }
58
59        Self { planes }
60    }
61
62    /// Test whether an AABB should be culled (is fully outside the frustum).
63    ///
64    /// Returns `true` if the AABB is entirely outside at least one plane
65    /// (meaning it should be culled / not drawn).
66    pub fn cull_aabb(&self, aabb: &Aabb) -> bool {
67        for plane in &self.planes {
68            // Find the "positive vertex" — the corner of the AABB most in the
69            // direction of the plane normal. If even this vertex is behind the
70            // plane, the entire AABB is outside.
71            let p = glam::Vec3::new(
72                if plane.normal.x >= 0.0 {
73                    aabb.max.x
74                } else {
75                    aabb.min.x
76                },
77                if plane.normal.y >= 0.0 {
78                    aabb.max.y
79                } else {
80                    aabb.min.y
81                },
82                if plane.normal.z >= 0.0 {
83                    aabb.max.z
84                } else {
85                    aabb.min.z
86                },
87            );
88            if plane.normal.dot(p) + plane.d < 0.0 {
89                return true; // Fully outside this plane -> cull.
90            }
91        }
92        false // Inside or intersecting all planes -> visible.
93    }
94}
95
96fn extract_plane(row: glam::Vec4) -> Plane {
97    Plane {
98        normal: glam::Vec3::new(row.x, row.y, row.z),
99        d: row.w,
100    }
101}
102
103/// Statistics from a culling pass.
104#[derive(Debug, Clone, Copy, Default)]
105pub struct CullStats {
106    /// Total objects tested for culling.
107    pub total: u32,
108    /// Objects that passed the frustum test (will be rendered).
109    pub visible: u32,
110    /// Objects rejected by the frustum test (not rendered).
111    pub culled: u32,
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn test_camera_vp() -> glam::Mat4 {
119        let view = glam::Mat4::look_at_rh(
120            glam::Vec3::new(0.0, 0.0, 5.0),
121            glam::Vec3::ZERO,
122            glam::Vec3::Y,
123        );
124        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
125        proj * view
126    }
127
128    #[test]
129    fn test_frustum_from_perspective() {
130        let frustum = Frustum::from_view_proj(&test_camera_vp());
131        // All plane normals should be roughly unit length after normalization.
132        for plane in &frustum.planes {
133            let len = plane.normal.length();
134            assert!(
135                (len - 1.0).abs() < 1e-4,
136                "plane normal not unit length: {len}"
137            );
138        }
139    }
140
141    #[test]
142    fn test_cull_aabb_inside() {
143        let frustum = Frustum::from_view_proj(&test_camera_vp());
144        // Box at origin — directly in front of camera at z=5 looking at origin.
145        let aabb = Aabb {
146            min: glam::Vec3::splat(-0.5),
147            max: glam::Vec3::splat(0.5),
148        };
149        assert!(!frustum.cull_aabb(&aabb), "box at origin should be visible");
150    }
151
152    #[test]
153    fn test_cull_aabb_behind_camera() {
154        let frustum = Frustum::from_view_proj(&test_camera_vp());
155        // Camera at z=5 looking toward -Z. Box at z=100 is behind the camera.
156        let aabb = Aabb {
157            min: glam::Vec3::new(-0.5, -0.5, 99.5),
158            max: glam::Vec3::new(0.5, 0.5, 100.5),
159        };
160        assert!(
161            frustum.cull_aabb(&aabb),
162            "box behind camera should be culled"
163        );
164    }
165
166    #[test]
167    fn test_cull_aabb_far_left() {
168        let frustum = Frustum::from_view_proj(&test_camera_vp());
169        // Box far to the left — should be outside the left frustum plane.
170        let aabb = Aabb {
171            min: glam::Vec3::new(-1000.0, -0.5, -0.5),
172            max: glam::Vec3::new(-999.0, 0.5, 0.5),
173        };
174        assert!(frustum.cull_aabb(&aabb), "box far left should be culled");
175    }
176
177    #[test]
178    fn test_cull_aabb_straddling_near_plane() {
179        let frustum = Frustum::from_view_proj(&test_camera_vp());
180        // Large box that straddles the frustum — should NOT be culled.
181        let aabb = Aabb {
182            min: glam::Vec3::splat(-2.0),
183            max: glam::Vec3::splat(2.0),
184        };
185        assert!(!frustum.cull_aabb(&aabb), "large box should be visible");
186    }
187}