//! A view [`Frustum`]: six planes used for visibility culling. use crate::math::{Aabb, Plane}; use glam::{Mat4, Vec3, Vec4, Vec4Swizzles}; use serde::{Deserialize, Serialize}; /// A frustum represented by six bounding planes, each with its normal pointing /// *inward*. A point is inside the frustum when it lies in the positive /// half-space of every plane. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Frustum { /// Planes ordered: left, right, bottom, top, near, far. pub planes: [Plane; 6], } impl Frustum { /// Extracts the six frustum planes from a combined view-projection matrix /// using the Gribb–Hartmann method. Works for both perspective and /// orthographic projections. pub fn from_view_projection(view_projection: Mat4) -> Self { // Rows of the matrix (glam is column-major, so build rows explicitly). let m = view_projection; let row0 = Vec4::new(m.x_axis.x, m.y_axis.x, m.z_axis.x, m.w_axis.x); let row1 = Vec4::new(m.x_axis.y, m.y_axis.y, m.z_axis.y, m.w_axis.y); let row2 = Vec4::new(m.x_axis.z, m.y_axis.z, m.z_axis.z, m.w_axis.z); let row3 = Vec4::new(m.x_axis.w, m.y_axis.w, m.z_axis.w, m.w_axis.w); let plane_from = |v: Vec4| Plane::new(v.xyz(), v.w); let planes = [ plane_from(row3 + row0), // left plane_from(row3 - row0), // right plane_from(row3 + row1), // bottom plane_from(row3 - row1), // top plane_from(row3 + row2), // near plane_from(row3 - row2), // far ]; Self { planes } } /// Returns `true` if `point` is inside (or on the boundary of) the frustum. pub fn contains_point(&self, point: Vec3) -> bool { self.planes .iter() .all(|plane| plane.signed_distance(point) >= 0.0) } /// Returns `true` if any part of `aabb` is inside the frustum. /// /// This is a conservative test: it may very rarely report a box as visible /// when it is just outside a corner, but never culls a visible box. That is /// the correct trade-off for rendering. pub fn intersects_aabb(&self, aabb: &Aabb) -> bool { for plane in &self.planes { // The "positive vertex": the AABB corner farthest along the normal. let p = Vec3::new( if plane.normal.x >= 0.0 { aabb.max.x } else { aabb.min.x }, if plane.normal.y >= 0.0 { aabb.max.y } else { aabb.min.y }, if plane.normal.z >= 0.0 { aabb.max.z } else { aabb.min.z }, ); // If the farthest corner is behind a plane, the box is fully outside. if plane.signed_distance(p) < 0.0 { return false; } } true } /// Returns `true` if the sphere at `center` with `radius` is at least /// partially inside the frustum. pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool { self.planes .iter() .all(|plane| plane.signed_distance(center) >= -radius) } } #[cfg(test)] mod tests { use super::*; fn perspective_vp() -> Mat4 { let proj = Mat4::perspective_rh(60_f32.to_radians(), 1.0, 1.0, 100.0); let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 0.0), Vec3::NEG_Z, Vec3::Y); proj * view } #[test] fn point_in_front_is_inside() { let f = Frustum::from_view_projection(perspective_vp()); assert!(f.contains_point(Vec3::new(0.0, 0.0, -10.0))); } #[test] fn point_behind_camera_is_outside() { let f = Frustum::from_view_projection(perspective_vp()); assert!(!f.contains_point(Vec3::new(0.0, 0.0, 10.0))); } #[test] fn point_beyond_far_is_outside() { let f = Frustum::from_view_projection(perspective_vp()); assert!(!f.contains_point(Vec3::new(0.0, 0.0, -500.0))); } #[test] fn point_way_off_to_side_is_outside() { let f = Frustum::from_view_projection(perspective_vp()); assert!(!f.contains_point(Vec3::new(500.0, 0.0, -10.0))); } #[test] fn aabb_in_view_intersects() { let f = Frustum::from_view_projection(perspective_vp()); let bb = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, -10.0), Vec3::splat(1.0)); assert!(f.intersects_aabb(&bb)); } #[test] fn aabb_behind_camera_is_culled() { let f = Frustum::from_view_projection(perspective_vp()); let bb = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, 50.0), Vec3::splat(1.0)); assert!(!f.intersects_aabb(&bb)); } #[test] fn sphere_culling() { let f = Frustum::from_view_projection(perspective_vp()); assert!(f.intersects_sphere(Vec3::new(0.0, 0.0, -10.0), 1.0)); // Just behind the camera but large enough to poke into the near plane. assert!(!f.intersects_sphere(Vec3::new(0.0, 0.0, 50.0), 1.0)); } #[test] fn orthographic_frustum_works() { let proj = Mat4::orthographic_rh(-10.0, 10.0, -10.0, 10.0, 1.0, 100.0); let view = Mat4::look_at_rh(Vec3::ZERO, Vec3::NEG_Z, Vec3::Y); let f = Frustum::from_view_projection(proj * view); assert!(f.contains_point(Vec3::new(5.0, 5.0, -10.0))); assert!(!f.contains_point(Vec3::new(50.0, 0.0, -10.0))); } }