diff --git a/crates/renderer/src/meshing.rs b/crates/renderer/src/meshing.rs index 2f38536..1744d14 100644 --- a/crates/renderer/src/meshing.rs +++ b/crates/renderer/src/meshing.rs @@ -54,15 +54,92 @@ const fn coord(i: usize) -> 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. /// -/// 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] #[expect( clippy::too_many_lines, reason = "six directional passes, each an inline sample + corners closure pair" )] -pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { +pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec, Vec) { let mut vertices = 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. @@ -76,7 +153,7 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { |y, x, z| { let block = chunk.get(x, y, z); (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 { block, dir: FaceDir::PosY, @@ -102,12 +179,12 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { &mut indices, |y, x, z| { let block = chunk.get(x, y, z); - (block != BlockId::AIR && (y == 0 || chunk.get(x, y - 1, z) == BlockId::AIR)).then_some( - FaceKey { + (block != BlockId::AIR + && occluder(chunk, neighbors, x, y, z, FaceDir::NegY) == BlockId::AIR) + .then_some(FaceKey { block, dir: FaceDir::NegY, - }, - ) + }) }, |y, x0, z0, w, h| { let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5); @@ -130,7 +207,7 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { |x, z, y| { let block = chunk.get(x, y, z); (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 { block, dir: FaceDir::PosX, @@ -156,12 +233,12 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { &mut indices, |x, z, y| { let block = chunk.get(x, y, z); - (block != BlockId::AIR && (x == 0 || chunk.get(x - 1, y, z) == BlockId::AIR)).then_some( - FaceKey { + (block != BlockId::AIR + && occluder(chunk, neighbors, x, y, z, FaceDir::NegX) == BlockId::AIR) + .then_some(FaceKey { block, dir: FaceDir::NegX, - }, - ) + }) }, |x, z0, y0, w, h| { let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + w) - 0.5); @@ -184,7 +261,7 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { |z, x, y| { let block = chunk.get(x, y, z); (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 { block, dir: FaceDir::PosZ, @@ -210,12 +287,12 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { &mut indices, |z, x, y| { let block = chunk.get(x, y, z); - (block != BlockId::AIR && (z == 0 || chunk.get(x, y, z - 1) == BlockId::AIR)).then_some( - FaceKey { + (block != BlockId::AIR + && occluder(chunk, neighbors, x, y, z, FaceDir::NegZ) == BlockId::AIR) + .then_some(FaceKey { block, dir: FaceDir::NegZ, - }, - ) + }) }, |z, x0, y0, w, h| { let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5); diff --git a/crates/renderer/src/tests/meshing.rs b/crates/renderer/src/tests/meshing.rs index db95dcd..818861a 100644 --- a/crates/renderer/src/tests/meshing.rs +++ b/crates/renderer/src/tests/meshing.rs @@ -34,6 +34,19 @@ fn random_chunk(seed: u64) -> 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). /// /// 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] 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!(indices.is_empty()); } @@ -95,7 +108,7 @@ fn all_air_chunk_is_empty() { fn single_block_emits_six_quads() { let mut chunk = Chunk::default(); 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. assert_eq!(vertices.len(), 24); 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. assert_eq!(vertices.len(), 24); assert_eq!(indices.len(), 36); @@ -122,7 +135,7 @@ fn adjacent_pair_culls_shared_face_and_merges_sides() { let mut chunk = Chunk::default(); chunk.set(0, 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. assert_eq!(vertices.len(), 24); 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() { for seed in 1..=8u64 { 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); // 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] fn mesh_is_deterministic() { 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()) + ); }