Skip to main content

concinnity_core/gfx/
frustum.rs

1//! Backend-agnostic frustum culling.
2//!
3//! Given a column-major view-projection matrix the six clip-space planes are
4//! extracted using the Gribb-Hartmann method (left/right/bottom/top/near/far).
5//! `Frustum::intersects_aabb` returns false only when an axis-aligned bounding
6//! box is fully outside at least one plane.  False positives are acceptable for
7//! culling (a few extra draws), false negatives are not, so the test treats
8//! the box as visible whenever it overlaps any plane.
9
10use crate::math::sqrt;
11
12/// One frustum plane in clip space.
13#[derive(Copy, Clone, Debug)]
14pub struct Plane {
15    /// Plane equation in clip space: dot(normal, p) + d >= 0 == inside.
16    pub normal: [f32; 3],
17    /// Plane constant: the signed distance from the origin along `normal`.
18    pub d: f32,
19}
20
21/// The six clip-space planes of a view frustum.
22#[derive(Copy, Clone, Debug)]
23pub struct Frustum {
24    /// Left, right, bottom, top, near, far.
25    pub planes: [Plane; 6],
26}
27
28impl Frustum {
29    /// Build a frustum from a column-major view-projection matrix.
30    /// `vp[col][row]`: same layout used by the renderer's ViewUniforms.
31    pub fn from_view_projection(vp: [[f32; 4]; 4]) -> Self {
32        // Row r of vp = [vp[0][r], vp[1][r], vp[2][r], vp[3][r]].
33        let row = |r: usize| -> [f32; 4] { [vp[0][r], vp[1][r], vp[2][r], vp[3][r]] };
34        let r0 = row(0);
35        let r1 = row(1);
36        let r2 = row(2);
37        let r3 = row(3);
38
39        let make = |a: [f32; 4], b: [f32; 4], sign: f32| -> Plane {
40            let p = [
41                a[0] * sign + b[0],
42                a[1] * sign + b[1],
43                a[2] * sign + b[2],
44                a[3] * sign + b[3],
45            ];
46            normalise_plane(p)
47        };
48
49        Self {
50            planes: [
51                make(r0, r3, 1.0),  // left:   row3 + row0
52                make(r0, r3, -1.0), // right:  row3 - row0
53                make(r1, r3, 1.0),  // bottom: row3 + row1
54                make(r1, r3, -1.0), // top:    row3 - row1
55                make(r2, r3, 1.0),  // near:   row3 + row2   (works for 0..1 z and -1..1 z)
56                make(r2, r3, -1.0), // far:    row3 - row2
57            ],
58        }
59    }
60
61    /// True when the AABB is not entirely outside any plane.
62    pub fn intersects_aabb(&self, bb_min: [f32; 3], bb_max: [f32; 3]) -> bool {
63        for plane in &self.planes {
64            // Pick the AABB corner furthest along the plane normal ("p-vertex"
65            // in the SAT against a plane). If that corner is still behind the
66            // plane the entire AABB is outside.
67            let mut farthest = [0.0f32; 3];
68            for (i, n) in plane.normal.iter().enumerate() {
69                farthest[i] = if *n >= 0.0 { bb_max[i] } else { bb_min[i] };
70            }
71            let dist = plane.normal[0] * farthest[0]
72                + plane.normal[1] * farthest[1]
73                + plane.normal[2] * farthest[2]
74                + plane.d;
75            if dist < 0.0 {
76                return false;
77            }
78        }
79        true
80    }
81}
82
83fn normalise_plane(p: [f32; 4]) -> Plane {
84    let len = sqrt(p[0] * p[0] + p[1] * p[1] + p[2] * p[2]);
85    let inv = if len > 1e-6 { 1.0 / len } else { 1.0 };
86    Plane {
87        normal: [p[0] * inv, p[1] * inv, p[2] * inv],
88        d: p[3] * inv,
89    }
90}
91
92/// Compute the world-space AABB enclosing a local-space AABB transformed by
93/// a column-major model matrix.  All eight corners are transformed and
94/// min/max'd component-wise.
95pub fn transform_aabb(
96    bb_min: [f32; 3],
97    bb_max: [f32; 3],
98    model: [[f32; 4]; 4],
99) -> ([f32; 3], [f32; 3]) {
100    let corners = [
101        [bb_min[0], bb_min[1], bb_min[2]],
102        [bb_max[0], bb_min[1], bb_min[2]],
103        [bb_min[0], bb_max[1], bb_min[2]],
104        [bb_max[0], bb_max[1], bb_min[2]],
105        [bb_min[0], bb_min[1], bb_max[2]],
106        [bb_max[0], bb_min[1], bb_max[2]],
107        [bb_min[0], bb_max[1], bb_max[2]],
108        [bb_max[0], bb_max[1], bb_max[2]],
109    ];
110    let mut out_min = [f32::INFINITY; 3];
111    let mut out_max = [f32::NEG_INFINITY; 3];
112    for c in &corners {
113        // column-major mul: out = M * (c.x, c.y, c.z, 1)
114        let x = model[0][0] * c[0] + model[1][0] * c[1] + model[2][0] * c[2] + model[3][0];
115        let y = model[0][1] * c[0] + model[1][1] * c[1] + model[2][1] * c[2] + model[3][1];
116        let z = model[0][2] * c[0] + model[1][2] * c[1] + model[2][2] * c[2] + model[3][2];
117        out_min[0] = out_min[0].min(x);
118        out_min[1] = out_min[1].min(y);
119        out_min[2] = out_min[2].min(z);
120        out_max[0] = out_max[0].max(x);
121        out_max[1] = out_max[1].max(y);
122        out_max[2] = out_max[2].max(z);
123    }
124    (out_min, out_max)
125}
126
127/// Squared distance from `cam` to the closest point on the AABB.
128/// Returns 0 if `cam` is inside.
129pub fn aabb_distance_sq(cam: [f32; 3], bb_min: [f32; 3], bb_max: [f32; 3]) -> f32 {
130    let mut sq = 0.0f32;
131    for i in 0..3 {
132        let v = cam[i];
133        if v < bb_min[i] {
134            let d = bb_min[i] - v;
135            sq += d * d;
136        } else if v > bb_max[i] {
137            let d = v - bb_max[i];
138            sq += d * d;
139        }
140    }
141    sq
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn identity4() -> [[f32; 4]; 4] {
149        [
150            [1.0, 0.0, 0.0, 0.0],
151            [0.0, 1.0, 0.0, 0.0],
152            [0.0, 0.0, 1.0, 0.0],
153            [0.0, 0.0, 0.0, 1.0],
154        ]
155    }
156
157    #[test]
158    fn identity_vp_contains_origin_aabb() {
159        // Identity VP defines the [-1,1]^3 clip cube as the visible region.
160        let f = Frustum::from_view_projection(identity4());
161        assert!(f.intersects_aabb([-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]));
162    }
163
164    #[test]
165    fn identity_vp_rejects_far_aabb() {
166        let f = Frustum::from_view_projection(identity4());
167        // entirely past the right clip plane
168        assert!(!f.intersects_aabb([5.0, -0.5, -0.5], [6.0, 0.5, 0.5]));
169    }
170
171    #[test]
172    fn transform_aabb_identity_passthrough() {
173        let (mn, mx) = transform_aabb([0.0, 0.0, 0.0], [1.0, 2.0, 3.0], identity4());
174        assert_eq!(mn, [0.0, 0.0, 0.0]);
175        assert_eq!(mx, [1.0, 2.0, 3.0]);
176    }
177
178    #[test]
179    fn transform_aabb_translates_corners() {
180        let mut model = identity4();
181        model[3][0] = 5.0;
182        model[3][1] = -2.0;
183        let (mn, mx) = transform_aabb([0.0, 0.0, 0.0], [1.0, 1.0, 1.0], model);
184        assert_eq!(mn, [5.0, -2.0, 0.0]);
185        assert_eq!(mx, [6.0, -1.0, 1.0]);
186    }
187
188    #[test]
189    fn aabb_distance_inside_is_zero() {
190        let d = aabb_distance_sq([0.5, 0.5, 0.5], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
191        assert_eq!(d, 0.0);
192    }
193
194    #[test]
195    fn aabb_distance_outside_is_squared() {
196        // Camera 3 units to the right of a unit box at origin
197        let d = aabb_distance_sq([4.0, 0.0, 0.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
198        assert!((d - 9.0).abs() < 1e-5);
199    }
200}