// 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)] mod tests { use super::Frustum; use glam::Vec3; /// Builds a frustum for a camera at the origin looking down the -Z axis, matching the engine's right-handed Vulkan-clip projection. fn forward_facing_frustum() -> Frustum { let proj = glam::camera::rh::proj::vulkan::perspective(60f32.to_radians(), 1.0, 0.1, 100.0); let view = glam::camera::rh::view::look_at_mat4(Vec3::ZERO, Vec3::NEG_Z, Vec3::Y); Frustum::from_view_proj(proj * view) } #[test] fn box_in_front_is_visible() { let frustum = forward_facing_frustum(); assert!(frustum.intersects_aabb(Vec3::new(-1.0, -1.0, -6.0), Vec3::new(1.0, 1.0, -4.0))); } #[test] fn box_behind_camera_is_culled() { // A box entirely behind the camera. This is the case that fails if the near plane is extracted with the OpenGL `r3 + r2` formula instead of `r2`. let frustum = forward_facing_frustum(); assert!(!frustum.intersects_aabb(Vec3::new(-1.0, -1.0, 4.0), Vec3::new(1.0, 1.0, 6.0))); } #[test] fn box_far_to_the_side_is_culled() { // Well outside the horizontal field of view at an otherwise valid depth. let frustum = forward_facing_frustum(); assert!(!frustum.intersects_aabb(Vec3::new(50.0, -1.0, -5.0), Vec3::new(52.0, 1.0, -4.0))); } #[test] fn huge_box_straddling_origin_is_visible() { let frustum = forward_facing_frustum(); assert!(frustum.intersects_aabb(Vec3::splat(-100.0), Vec3::splat(100.0))); } }