282 lines
9.2 KiB
Rust
282 lines
9.2 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
//! Unit tests for the greedy chunk mesher in [`crate::meshing`].
|
|
|
|
use super::*;
|
|
|
|
/// Minimal deterministic xorshift64 generator for seeded test chunks.
|
|
struct Rng(u64);
|
|
|
|
impl Rng {
|
|
fn next(&mut self) -> u64 {
|
|
let mut x = self.0;
|
|
x ^= x << 13;
|
|
x ^= x >> 7;
|
|
x ^= x << 17;
|
|
self.0 = x;
|
|
x
|
|
}
|
|
}
|
|
|
|
/// Builds a deterministic pseudo-random chunk at roughly one-third density.
|
|
fn random_chunk(seed: u64) -> Chunk {
|
|
let mut rng = Rng(seed);
|
|
let mut chunk = Chunk::default();
|
|
for x in 0..CHUNK_SIZE {
|
|
for y in 0..CHUNK_SIZE {
|
|
for z in 0..CHUNK_SIZE {
|
|
if rng.next().is_multiple_of(3) {
|
|
chunk.set(x, y, z, BlockId(1));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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.
|
|
fn count_exposed_faces(chunk: &Chunk) -> usize {
|
|
let mut n = 0;
|
|
for x in 0..CHUNK_SIZE {
|
|
for y in 0..CHUNK_SIZE {
|
|
for z in 0..CHUNK_SIZE {
|
|
if chunk.get(x, y, z) == BlockId::AIR {
|
|
continue;
|
|
}
|
|
n += usize::from(y == CHUNK_SIZE - 1 || chunk.get(x, y + 1, z) == BlockId::AIR);
|
|
n += usize::from(y == 0 || chunk.get(x, y - 1, z) == BlockId::AIR);
|
|
n += usize::from(x == CHUNK_SIZE - 1 || chunk.get(x + 1, y, z) == BlockId::AIR);
|
|
n += usize::from(x == 0 || chunk.get(x - 1, y, z) == BlockId::AIR);
|
|
n += usize::from(z == CHUNK_SIZE - 1 || chunk.get(x, y, z + 1) == BlockId::AIR);
|
|
n += usize::from(z == 0 || chunk.get(x, y, z - 1) == BlockId::AIR);
|
|
}
|
|
}
|
|
}
|
|
n
|
|
}
|
|
|
|
/// Sums the area of every triangle in the mesh via the cross-product magnitude.
|
|
fn total_area(vertices: &[Vertex], indices: &[u32]) -> f64 {
|
|
let mut area = 0.0f64;
|
|
for tri in indices.chunks_exact(3) {
|
|
let a = vertices[tri[0] as usize].position;
|
|
let b = vertices[tri[1] as usize].position;
|
|
let c = vertices[tri[2] as usize].position;
|
|
let ab = [
|
|
f64::from(b[0] - a[0]),
|
|
f64::from(b[1] - a[1]),
|
|
f64::from(b[2] - a[2]),
|
|
];
|
|
let ac = [
|
|
f64::from(c[0] - a[0]),
|
|
f64::from(c[1] - a[1]),
|
|
f64::from(c[2] - a[2]),
|
|
];
|
|
let cross = [
|
|
ab[1] * ac[2] - ab[2] * ac[1],
|
|
ab[2] * ac[0] - ab[0] * ac[2],
|
|
ab[0] * ac[1] - ab[1] * ac[0],
|
|
];
|
|
area += 0.5 * cross.iter().map(|c| c * c).sum::<f64>().sqrt();
|
|
}
|
|
area
|
|
}
|
|
|
|
#[test]
|
|
fn all_air_chunk_is_empty() {
|
|
let (vertices, indices) = generate_mesh(&Chunk::default(), &Neighbors::default());
|
|
assert!(vertices.is_empty());
|
|
assert!(indices.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn single_block_emits_six_quads() {
|
|
let mut chunk = Chunk::default();
|
|
chunk.set(5, 5, 5, BlockId(1));
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn face_direction_indices_match_the_shader_normal_table() {
|
|
// Pins the numeric contract with the FACE_NORMALS table in assets/shaders/cube.vert, which is indexed by these values. Nothing else connects the two, and a silent reordering would mis-light every face rather than fail to build.
|
|
assert_eq!(FaceDir::PosX.to_index(), 0);
|
|
assert_eq!(FaceDir::NegX.to_index(), 1);
|
|
assert_eq!(FaceDir::PosY.to_index(), 2);
|
|
assert_eq!(FaceDir::NegY.to_index(), 3);
|
|
assert_eq!(FaceDir::PosZ.to_index(), 4);
|
|
assert_eq!(FaceDir::NegZ.to_index(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn single_block_quads_carry_their_own_face_normal() {
|
|
let mut chunk = Chunk::default();
|
|
chunk.set(5, 5, 5, BlockId(1));
|
|
let (vertices, _) = generate_mesh(&chunk, &Neighbors::default());
|
|
|
|
// The constant axis and plane coordinate of each face of a block at (5, 5, 5), indexed by packed normal. Deducing the expected direction from the geometry rather than from the emission order keeps the assertion valid if the passes are reordered.
|
|
let expected: [(usize, f32); 6] = [(0, 6.0), (0, 5.0), (1, 6.0), (1, 5.0), (2, 6.0), (2, 5.0)];
|
|
|
|
let mut seen = [false; 6];
|
|
for quad in vertices.chunks_exact(4) {
|
|
let normal = quad[0].normal;
|
|
assert!(
|
|
quad.iter().all(|v| v.normal == normal),
|
|
"a planar quad carries more than one normal index"
|
|
);
|
|
|
|
let (axis, plane) = expected[normal as usize];
|
|
assert!(
|
|
quad.iter()
|
|
.all(|v| (v.position[axis] - plane).abs() < f32::EPSILON),
|
|
"the quad tagged with normal index {normal} does not lie on that face's plane"
|
|
);
|
|
|
|
seen[normal as usize] = true;
|
|
}
|
|
|
|
assert!(
|
|
seen.iter().all(|&s| s),
|
|
"an isolated block must emit one quad per face direction"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn full_chunk_merges_each_face_into_one_quad() {
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
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, &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);
|
|
}
|
|
|
|
#[test]
|
|
#[expect(
|
|
clippy::cast_precision_loss,
|
|
reason = "exposed-face counts are far below f64's exact-integer range"
|
|
)]
|
|
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, &Neighbors::default());
|
|
let naive_faces = count_exposed_faces(&chunk);
|
|
|
|
// Area equality proves no faces were lost, doubled, or misplaced.
|
|
let expected_area = naive_faces as f64;
|
|
assert!(
|
|
(total_area(&vertices, &indices) - expected_area).abs() < 1e-6,
|
|
"seed {seed}: greedy area diverged from naive"
|
|
);
|
|
|
|
// Merging can only reduce (or match) the index count of the naive mesh.
|
|
assert!(
|
|
indices.len() <= naive_faces * 6,
|
|
"seed {seed}: greedy emitted more indices than naive"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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, &Neighbors::default()),
|
|
generate_mesh(&chunk, &Neighbors::default())
|
|
);
|
|
}
|