feat(renderer): cull chunk meshes outside the view frustum

This commit is contained in:
Serkyo 2026-07-22 05:00:27 +02:00
parent e5d39be400
commit a20d98a107
5 changed files with 127 additions and 1 deletions

1
Cargo.lock generated
View file

@ -2142,6 +2142,7 @@ dependencies = [
"glam 0.33.2",
"gpu-allocator",
"raw-window-handle",
"shared",
"thiserror 2.0.18",
"tracing",
]

View file

@ -17,3 +17,4 @@ tracing.workspace = true
gpu-allocator = "0.28.0"
bytemuck.workspace = true
glam.workspace = true
shared = { path = "../shared" }

View file

@ -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 GribbHartmann 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)));
}
}

View file

@ -9,6 +9,7 @@
mod device;
pub mod error;
mod frustum;
mod instance;
pub mod mesh;
mod pipeline;

View file

@ -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::<glam::Mat4>() 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");
}
}
}