Files
Oxide/engine/src/math/frustum.rs
T
Homer Simpson f56a1eea3b Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:41:02 +02:00

155 lines
5.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 GribbHartmann 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)));
}
}