feat(renderer): cull chunk meshes outside the view frustum
This commit is contained in:
parent
e5d39be400
commit
a20d98a107
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2142,6 +2142,7 @@ dependencies = [
|
||||||
"glam 0.33.2",
|
"glam 0.33.2",
|
||||||
"gpu-allocator",
|
"gpu-allocator",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
|
"shared",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,4 @@ tracing.workspace = true
|
||||||
gpu-allocator = "0.28.0"
|
gpu-allocator = "0.28.0"
|
||||||
bytemuck.workspace = true
|
bytemuck.workspace = true
|
||||||
glam.workspace = true
|
glam.workspace = true
|
||||||
|
shared = { path = "../shared" }
|
||||||
|
|
|
||||||
101
crates/renderer/src/frustum.rs
Normal file
101
crates/renderer/src/frustum.rs
Normal 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 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
mod device;
|
mod device;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
mod frustum;
|
||||||
mod instance;
|
mod instance;
|
||||||
pub mod mesh;
|
pub mod mesh;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
use crate::sync::SyncPrimitives;
|
use crate::sync::SyncPrimitives;
|
||||||
use crate::{create_depth_resources, create_gpu_buffer, swapchain};
|
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 ash::{Device, Instance, khr, vk};
|
||||||
use gpu_allocator::vulkan::{Allocation, Allocator};
|
use gpu_allocator::vulkan::{Allocation, Allocator};
|
||||||
use std::collections::HashMap;
|
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.
|
// 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;
|
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.
|
// 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());
|
let mvp_bytes = bytemuck::cast_slice(mvp.as_ref());
|
||||||
self.device.cmd_push_constants(
|
self.device.cmd_push_constants(
|
||||||
|
|
@ -446,6 +457,13 @@ impl Renderer {
|
||||||
let chunk_offset_byte = size_of::<glam::Mat4>() as u32;
|
let chunk_offset_byte = size_of::<glam::Mat4>() as u32;
|
||||||
|
|
||||||
for mesh in self.chunk_meshes.values() {
|
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.
|
// 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 = [
|
let offset = [
|
||||||
mesh.world_offset[0],
|
mesh.world_offset[0],
|
||||||
|
|
@ -468,6 +486,10 @@ impl Renderer {
|
||||||
self.device
|
self.device
|
||||||
.cmd_draw_indexed(cmd, mesh.index_count, 1, 0, 0, 0);
|
.cmd_draw_indexed(cmd, mesh.index_count, 1, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if culled > 0 {
|
||||||
|
tracing::debug!(culled, "chunks skipped by frustum culling");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue