feat(renderer): cull voxel faces against neighbouring chunks

This commit is contained in:
Serkyo 2026-07-23 03:40:09 +02:00
parent d8d6635f37
commit 3b3a4a107b
2 changed files with 176 additions and 23 deletions

View file

@ -54,15 +54,92 @@ const fn coord(i: usize) -> f32 {
i as f32 i as f32
} }
/// The six face-adjacent neighbour chunks, if resident.
///
/// A `None` side means the neighbour is not loaded; that boundary is treated as exposed (its faces are emitted) so the load frontier shows no holes. The referenced chunks must outlive the [`Neighbors`] value, which is what the `'a` lifetime records.
#[derive(Default)]
pub struct Neighbors<'a> {
/// Neighbour toward decreasing X, sampled at its `x = CHUNK_SIZE - 1` face.
pub neg_x: Option<&'a Chunk>,
/// Neighbour toward increasing X, sampled at its `x = 0` face.
pub pos_x: Option<&'a Chunk>,
/// Neighbour toward decreasing Y, sampled at its `y = CHUNK_SIZE - 1` face.
pub neg_y: Option<&'a Chunk>,
/// Neighbour toward increasing Y, sampled at its `y = 0` face.
pub pos_y: Option<&'a Chunk>,
/// Neighbour toward decreasing Z, sampled at its `z = CHUNK_SIZE - 1` face.
pub neg_z: Option<&'a Chunk>,
/// Neighbour toward increasing Z, sampled at its `z = 0` face.
pub pos_z: Option<&'a Chunk>,
}
/// Returns the block occluding the `dir` face of the voxel at (`x`, `y`, `z`).
///
/// When the adjacent voxel lies inside the chunk it is read directly. When it lies across the chunk boundary it is read from the matching entry of `neighbors` at the opposite edge; a `None` neighbour is treated as [`BlockId::AIR`] so the boundary face is emitted (frontier safety).
fn occluder(
chunk: &Chunk,
neighbors: &Neighbors,
x: usize,
y: usize,
z: usize,
dir: FaceDir,
) -> BlockId {
const LAST: usize = CHUNK_SIZE - 1;
match dir {
FaceDir::PosX => {
if x < LAST {
chunk.get(x + 1, y, z)
} else {
neighbors.pos_x.map_or(BlockId::AIR, |c| c.get(0, y, z))
}
}
FaceDir::NegX => {
if x > 0 {
chunk.get(x - 1, y, z)
} else {
neighbors.neg_x.map_or(BlockId::AIR, |c| c.get(LAST, y, z))
}
}
FaceDir::PosY => {
if y < LAST {
chunk.get(x, y + 1, z)
} else {
neighbors.pos_y.map_or(BlockId::AIR, |c| c.get(x, 0, z))
}
}
FaceDir::NegY => {
if y > 0 {
chunk.get(x, y - 1, z)
} else {
neighbors.neg_y.map_or(BlockId::AIR, |c| c.get(x, LAST, z))
}
}
FaceDir::PosZ => {
if z < LAST {
chunk.get(x, y, z + 1)
} else {
neighbors.pos_z.map_or(BlockId::AIR, |c| c.get(x, y, 0))
}
}
FaceDir::NegZ => {
if z > 0 {
chunk.get(x, y, z - 1)
} else {
neighbors.neg_z.map_or(BlockId::AIR, |c| c.get(x, y, LAST))
}
}
}
}
/// Meshes `chunk` into GPU vertices and triangle indices via greedy merging. /// Meshes `chunk` into GPU vertices and triangle indices via greedy merging.
/// ///
/// Each axis is swept slice by slice; on every slice a 2D mask of exposed faces over the two perpendicular axes is built and merged into rectangles. Boundary voxels treat out-of-chunk neighbours as air, so chunk-edge faces are emitted. /// Each axis is swept slice by slice; on every slice a 2D mask of exposed faces over the two perpendicular axes is built and merged into rectangles. Boundary voxels are tested against `neighbors`: a chunk-edge face is emitted only when the adjoining voxel in the matching neighbour is air, or when that neighbour is absent (see [`Neighbors`]).
#[must_use] #[must_use]
#[expect( #[expect(
clippy::too_many_lines, clippy::too_many_lines,
reason = "six directional passes, each an inline sample + corners closure pair" reason = "six directional passes, each an inline sample + corners closure pair"
)] )]
pub fn generate_mesh(chunk: &Chunk) -> (Vec<Vertex>, Vec<u32>) { pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec<Vertex>, Vec<u32>) {
let mut vertices = Vec::new(); let mut vertices = Vec::new();
let mut indices = Vec::new(); let mut indices = Vec::new();
// A single u×v mask, reused across every slice of every axis; each pass fully overwrites it per slice, so no explicit clearing is required. // A single u×v mask, reused across every slice of every axis; each pass fully overwrites it per slice, so no explicit clearing is required.
@ -76,7 +153,7 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec<Vertex>, Vec<u32>) {
|y, x, z| { |y, x, z| {
let block = chunk.get(x, y, z); let block = chunk.get(x, y, z);
(block != BlockId::AIR (block != BlockId::AIR
&& (y == CHUNK_SIZE - 1 || chunk.get(x, y + 1, z) == BlockId::AIR)) && occluder(chunk, neighbors, x, y, z, FaceDir::PosY) == BlockId::AIR)
.then_some(FaceKey { .then_some(FaceKey {
block, block,
dir: FaceDir::PosY, dir: FaceDir::PosY,
@ -102,12 +179,12 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec<Vertex>, Vec<u32>) {
&mut indices, &mut indices,
|y, x, z| { |y, x, z| {
let block = chunk.get(x, y, z); let block = chunk.get(x, y, z);
(block != BlockId::AIR && (y == 0 || chunk.get(x, y - 1, z) == BlockId::AIR)).then_some( (block != BlockId::AIR
FaceKey { && occluder(chunk, neighbors, x, y, z, FaceDir::NegY) == BlockId::AIR)
.then_some(FaceKey {
block, block,
dir: FaceDir::NegY, dir: FaceDir::NegY,
}, })
)
}, },
|y, x0, z0, w, h| { |y, x0, z0, w, h| {
let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5); let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5);
@ -130,7 +207,7 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec<Vertex>, Vec<u32>) {
|x, z, y| { |x, z, y| {
let block = chunk.get(x, y, z); let block = chunk.get(x, y, z);
(block != BlockId::AIR (block != BlockId::AIR
&& (x == CHUNK_SIZE - 1 || chunk.get(x + 1, y, z) == BlockId::AIR)) && occluder(chunk, neighbors, x, y, z, FaceDir::PosX) == BlockId::AIR)
.then_some(FaceKey { .then_some(FaceKey {
block, block,
dir: FaceDir::PosX, dir: FaceDir::PosX,
@ -156,12 +233,12 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec<Vertex>, Vec<u32>) {
&mut indices, &mut indices,
|x, z, y| { |x, z, y| {
let block = chunk.get(x, y, z); let block = chunk.get(x, y, z);
(block != BlockId::AIR && (x == 0 || chunk.get(x - 1, y, z) == BlockId::AIR)).then_some( (block != BlockId::AIR
FaceKey { && occluder(chunk, neighbors, x, y, z, FaceDir::NegX) == BlockId::AIR)
.then_some(FaceKey {
block, block,
dir: FaceDir::NegX, dir: FaceDir::NegX,
}, })
)
}, },
|x, z0, y0, w, h| { |x, z0, y0, w, h| {
let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + w) - 0.5); let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + w) - 0.5);
@ -184,7 +261,7 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec<Vertex>, Vec<u32>) {
|z, x, y| { |z, x, y| {
let block = chunk.get(x, y, z); let block = chunk.get(x, y, z);
(block != BlockId::AIR (block != BlockId::AIR
&& (z == CHUNK_SIZE - 1 || chunk.get(x, y, z + 1) == BlockId::AIR)) && occluder(chunk, neighbors, x, y, z, FaceDir::PosZ) == BlockId::AIR)
.then_some(FaceKey { .then_some(FaceKey {
block, block,
dir: FaceDir::PosZ, dir: FaceDir::PosZ,
@ -210,12 +287,12 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec<Vertex>, Vec<u32>) {
&mut indices, &mut indices,
|z, x, y| { |z, x, y| {
let block = chunk.get(x, y, z); let block = chunk.get(x, y, z);
(block != BlockId::AIR && (z == 0 || chunk.get(x, y, z - 1) == BlockId::AIR)).then_some( (block != BlockId::AIR
FaceKey { && occluder(chunk, neighbors, x, y, z, FaceDir::NegZ) == BlockId::AIR)
.then_some(FaceKey {
block, block,
dir: FaceDir::NegZ, dir: FaceDir::NegZ,
}, })
)
}, },
|z, x0, y0, w, h| { |z, x0, y0, w, h| {
let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5); let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5);

View file

@ -34,6 +34,19 @@ fn random_chunk(seed: u64) -> Chunk {
chunk chunk
} }
/// Builds a fully solid chunk (every voxel `BlockId(1)`).
fn solid_chunk() -> Chunk {
let mut chunk = Chunk::default();
for x in 0..CHUNK_SIZE {
for y in 0..CHUNK_SIZE {
for z in 0..CHUNK_SIZE {
chunk.set(x, y, z, BlockId(1));
}
}
}
chunk
}
/// Counts exposed unit faces the naive way (out-of-chunk neighbours are air). /// Counts exposed unit faces the naive way (out-of-chunk neighbours are air).
/// ///
/// Every unit face has area 1, so this count equals the total surface area a correct greedy mesh must reproduce. /// Every unit face has area 1, so this count equals the total surface area a correct greedy mesh must reproduce.
@ -86,7 +99,7 @@ fn total_area(vertices: &[Vertex], indices: &[u32]) -> f64 {
#[test] #[test]
fn all_air_chunk_is_empty() { fn all_air_chunk_is_empty() {
let (vertices, indices) = generate_mesh(&Chunk::default()); let (vertices, indices) = generate_mesh(&Chunk::default(), &Neighbors::default());
assert!(vertices.is_empty()); assert!(vertices.is_empty());
assert!(indices.is_empty()); assert!(indices.is_empty());
} }
@ -95,7 +108,7 @@ fn all_air_chunk_is_empty() {
fn single_block_emits_six_quads() { fn single_block_emits_six_quads() {
let mut chunk = Chunk::default(); let mut chunk = Chunk::default();
chunk.set(5, 5, 5, BlockId(1)); chunk.set(5, 5, 5, BlockId(1));
let (vertices, indices) = generate_mesh(&chunk); let (vertices, indices) = generate_mesh(&chunk, &Neighbors::default());
// Six exposed faces, none mergeable: 6 quads. // Six exposed faces, none mergeable: 6 quads.
assert_eq!(vertices.len(), 24); assert_eq!(vertices.len(), 24);
assert_eq!(indices.len(), 36); assert_eq!(indices.len(), 36);
@ -111,7 +124,7 @@ fn full_chunk_merges_each_face_into_one_quad() {
} }
} }
} }
let (vertices, indices) = generate_mesh(&chunk); let (vertices, indices) = generate_mesh(&chunk, &Neighbors::default());
// Only the six boundary planes are exposed, each merging to a single quad. // Only the six boundary planes are exposed, each merging to a single quad.
assert_eq!(vertices.len(), 24); assert_eq!(vertices.len(), 24);
assert_eq!(indices.len(), 36); assert_eq!(indices.len(), 36);
@ -122,7 +135,7 @@ fn adjacent_pair_culls_shared_face_and_merges_sides() {
let mut chunk = Chunk::default(); let mut chunk = Chunk::default();
chunk.set(0, 0, 0, BlockId(1)); chunk.set(0, 0, 0, BlockId(1));
chunk.set(1, 0, 0, BlockId(1)); chunk.set(1, 0, 0, BlockId(1));
let (vertices, indices) = generate_mesh(&chunk); let (vertices, indices) = generate_mesh(&chunk, &Neighbors::default());
// Shared internal face pair is culled; +Y/-Y/+Z/-Z each merge across the pair into one quad, and the two X ends are one quad each: 6 quads total. // Shared internal face pair is culled; +Y/-Y/+Z/-Z each merge across the pair into one quad, and the two X ends are one quad each: 6 quads total.
assert_eq!(vertices.len(), 24); assert_eq!(vertices.len(), 24);
assert_eq!(indices.len(), 36); assert_eq!(indices.len(), 36);
@ -136,7 +149,7 @@ fn adjacent_pair_culls_shared_face_and_merges_sides() {
fn greedy_area_equals_naive_and_never_more_indices() { fn greedy_area_equals_naive_and_never_more_indices() {
for seed in 1..=8u64 { for seed in 1..=8u64 {
let chunk = random_chunk(seed); let chunk = random_chunk(seed);
let (vertices, indices) = generate_mesh(&chunk); let (vertices, indices) = generate_mesh(&chunk, &Neighbors::default());
let naive_faces = count_exposed_faces(&chunk); let naive_faces = count_exposed_faces(&chunk);
// Area equality proves no faces were lost, doubled, or misplaced. // Area equality proves no faces were lost, doubled, or misplaced.
@ -154,8 +167,71 @@ fn greedy_area_equals_naive_and_never_more_indices() {
} }
} }
#[test]
fn absent_neighbor_emits_boundary_faces() {
// A solid chunk with no neighbours (frontier) still emits all six boundary sheets: None ⇒ air ⇒ emit.
let (vertices, _) = generate_mesh(&solid_chunk(), &Neighbors::default());
assert_eq!(vertices.len(), 6 * 4);
}
#[test]
fn solid_neighbor_culls_that_boundary() {
// A solid neighbour on +X occludes the whole +X sheet; the other five boundary planes each still merge to one quad.
let neighbor = solid_chunk();
let neighbors = Neighbors {
pos_x: Some(&neighbor),
..Default::default()
};
let (vertices, indices) = generate_mesh(&solid_chunk(), &neighbors);
assert_eq!(vertices.len(), 5 * 4);
assert_eq!(indices.len(), 5 * 6);
}
#[test]
fn adjacent_solid_chunks_cull_shared_boundary() {
// Two solid chunks touching along X: the left chunk's +X sheet and the right chunk's -X sheet are both culled.
let left = solid_chunk();
let right = solid_chunk();
let (left_verts, _) = generate_mesh(
&left,
&Neighbors {
pos_x: Some(&right),
..Default::default()
},
);
let (right_verts, _) = generate_mesh(
&right,
&Neighbors {
neg_x: Some(&left),
..Default::default()
},
);
assert_eq!(left_verts.len(), 5 * 4);
assert_eq!(right_verts.len(), 5 * 4);
}
#[test]
fn fully_enclosed_solid_chunk_is_empty() {
// A solid chunk surrounded on all six sides by solid neighbours exposes no faces at all.
let neighbor = solid_chunk();
let neighbors = Neighbors {
neg_x: Some(&neighbor),
pos_x: Some(&neighbor),
neg_y: Some(&neighbor),
pos_y: Some(&neighbor),
neg_z: Some(&neighbor),
pos_z: Some(&neighbor),
};
let (vertices, indices) = generate_mesh(&solid_chunk(), &neighbors);
assert!(vertices.is_empty());
assert!(indices.is_empty());
}
#[test] #[test]
fn mesh_is_deterministic() { fn mesh_is_deterministic() {
let chunk = random_chunk(42); let chunk = random_chunk(42);
assert_eq!(generate_mesh(&chunk), generate_mesh(&chunk)); assert_eq!(
generate_mesh(&chunk, &Neighbors::default()),
generate_mesh(&chunk, &Neighbors::default())
);
} }