// SPDX-License-Identifier: AGPL-3.0-only //! CPU-side view-frustum culling for chunk meshes. use glam::{Mat4, Vec3, Vec4}; /// Six view-frustum planes in world space. /// /// Each plane is stored as a [`Vec4`] `(a, b, c, d)` where `(a, b, c)` is the inward-facing normal and the plane equation is `a·x + b·y + c·z + d = 0`. The planes are normalised, so evaluating the equation at a point yields the signed distance from that point to the plane; a non-negative result lies on the interior side. pub(crate) struct Frustum { /// The six planes in the order left, right, bottom, top, near, far. planes: [Vec4; 6], } impl Frustum { /// Builds the frustum from a combined view-projection matrix. /// /// The matrix is expected to map world space into Vulkan clip space, whose depth range is `[0, 1]`. Under that convention the near plane is the third matrix row alone (`r2`), not `r3 + r2` as in the OpenGL `[-1, 1]` range; the OpenGL form would cull geometry directly ahead of the camera. Each plane is normalised by the length of its `(a, b, c)` normal so subsequent evaluations return true signed distances. pub(crate) fn from_view_proj(mvp: Mat4) -> Self { // glam stores matrices column-major; the Gribb–Hartmann derivation operates on the rows of the combined matrix, so rows are read here rather than columns. let r0 = mvp.row(0); let r1 = mvp.row(1); let r2 = mvp.row(2); let r3 = mvp.row(3); let mut planes = [ r3 + r0, // left r3 - r0, // right r3 + r1, // bottom r3 - r1, // top r2, // near (Vulkan depth range [0, 1], hence r2 alone) r3 - r2, // far ]; for plane in &mut planes { let normal_length = plane.truncate().length(); *plane /= normal_length; } Self { planes } } /// Returns whether the axis-aligned box spanning `[min, max]` is at least partially inside the frustum. pub(crate) fn intersects_aabb(&self, min: Vec3, max: Vec3) -> bool { for plane in &self.planes { let normal = plane.truncate(); // The "positive vertex" is the box corner farthest along the plane normal: per axis the max component is taken when the normal's component is non-negative, otherwise the min. If even that corner lies behind the plane, the whole box does. let positive_vertex = Vec3::new( if normal.x >= 0.0 { max.x } else { min.x }, if normal.y >= 0.0 { max.y } else { min.y }, if normal.z >= 0.0 { max.z } else { min.z }, ); if plane.dot(positive_vertex.extend(1.0)) < 0.0 { return false; } } true } } #[cfg(test)] #[path = "tests/frustum.rs"] mod tests;