diff --git a/Cargo.lock b/Cargo.lock index 5dbbf69..ac4e2a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2142,6 +2142,7 @@ dependencies = [ "glam 0.33.2", "gpu-allocator", "raw-window-handle", + "shared", "thiserror 2.0.18", "tracing", ] diff --git a/crates/renderer/Cargo.toml b/crates/renderer/Cargo.toml index 6c3904b..9e31cf1 100644 --- a/crates/renderer/Cargo.toml +++ b/crates/renderer/Cargo.toml @@ -17,3 +17,4 @@ tracing.workspace = true gpu-allocator = "0.28.0" bytemuck.workspace = true glam.workspace = true +shared = { path = "../shared" } diff --git a/crates/renderer/src/frustum.rs b/crates/renderer/src/frustum.rs new file mode 100644 index 0000000..d50083c --- /dev/null +++ b/crates/renderer/src/frustum.rs @@ -0,0 +1,101 @@ +// 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))); + } +} diff --git a/crates/renderer/src/lib.rs b/crates/renderer/src/lib.rs index d9b0b2b..7fe6dea 100644 --- a/crates/renderer/src/lib.rs +++ b/crates/renderer/src/lib.rs @@ -9,6 +9,7 @@ mod device; pub mod error; +mod frustum; mod instance; pub mod mesh; mod pipeline; diff --git a/crates/renderer/src/renderer.rs b/crates/renderer/src/renderer.rs index ba75418..380c2f2 100644 --- a/crates/renderer/src/renderer.rs +++ b/crates/renderer/src/renderer.rs @@ -2,7 +2,7 @@ use crate::sync::SyncPrimitives; use crate::{create_depth_resources, create_gpu_buffer, swapchain}; -use crate::{error::RendererError, mesh::Vertex}; +use crate::{error::RendererError, frustum::Frustum, mesh::Vertex}; use ash::{Device, Instance, khr, vk}; use gpu_allocator::vulkan::{Allocation, Allocator}; use std::collections::HashMap; @@ -428,6 +428,17 @@ impl Renderer { // The view matrix is supplied by the caller (the client's camera); the renderer owns only the projection, which depends on the swapchain aspect ratio it manages. let mvp = projection * camera_view; + // The view frustum is derived from the same matrix and reused to reject chunks whose bounding box lies entirely outside the view before any draw work is recorded. + let frustum = Frustum::from_view_proj(mvp); + + // A chunk spans CHUNK_SIZE blocks on each axis. The mesher centres block i on [i - 0.5, i + 0.5], so a chunk's box runs [offset - 0.5, offset + CHUNK_SIZE - 0.5]; the extent below is added to that shifted minimum corner. + #[expect( + clippy::cast_precision_loss, + reason = "CHUNK_SIZE is 32, exactly representable as f32" + )] + let chunk_extent = glam::Vec3::splat(shared::world::CHUNK_SIZE as f32); + let mut culled: u32 = 0; + // The MVP is identical for every chunk this frame, so it is pushed once before the loop. let mvp_bytes = bytemuck::cast_slice(mvp.as_ref()); self.device.cmd_push_constants( @@ -446,6 +457,13 @@ impl Renderer { let chunk_offset_byte = size_of::() as u32; for mesh in self.chunk_meshes.values() { + // Reject the chunk when its world-space bounding box falls entirely outside the frustum. + let box_min = glam::Vec3::from(mesh.world_offset) - glam::Vec3::splat(0.5); + if !frustum.intersects_aabb(box_min, box_min + chunk_extent) { + culled += 1; + continue; + } + // The offset is padded to a vec4 to match the std140 layout of the push-constant block; only xyz is read by the shader. let offset = [ mesh.world_offset[0], @@ -468,6 +486,10 @@ impl Renderer { self.device .cmd_draw_indexed(cmd, mesh.index_count, 1, 0, 0, 0); } + + if culled > 0 { + tracing::debug!(culled, "chunks skipped by frustum culling"); + } } }