perf(renderer): merge coplanar voxel faces with a greedy mesher
This commit is contained in:
parent
748e783f1e
commit
edc72a0f6f
|
|
@ -12,6 +12,7 @@ pub mod error;
|
|||
mod frustum;
|
||||
mod instance;
|
||||
pub mod mesh;
|
||||
pub mod meshing;
|
||||
mod pipeline;
|
||||
mod renderer;
|
||||
mod surface;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use bytemuck::{Pod, Zeroable};
|
|||
/// Uses `repr(C)` to ensure the memory layout matches what the GPU expects (no Rust-specific reordering).
|
||||
/// `Pod` and `Zeroable` allows safely casting this struct to a raw byte slice.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)]
|
||||
pub struct Vertex {
|
||||
/// 3D position of the vertex (X, Y, Z).
|
||||
pub position: [f32; 3],
|
||||
|
|
|
|||
361
crates/renderer/src/meshing.rs
Normal file
361
crates/renderer/src/meshing.rs
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Cubic greedy mesher: converts a dense voxel [`Chunk`] into renderer geometry.
|
||||
//!
|
||||
//! Exposed voxel faces are merged into the largest possible axis-aligned
|
||||
//! rectangles before emission. The output is visually identical to a naive
|
||||
//! per-face emitter (same faces, colours, and world positions) but carries far
|
||||
//! fewer vertices and indices: a flat `CHUNK_SIZE`×`CHUNK_SIZE` surface becomes a
|
||||
//! single quad rather than one quad per voxel.
|
||||
//!
|
||||
//! This is the **cubic** meshing path only. It is a pure `chunk → (vertices,
|
||||
//! indices)` function and makes no assumption of being the sole mesher, so a
|
||||
//! merged-granular mesher can coexist for softer materials.
|
||||
//!
|
||||
//! Out-of-chunk neighbours are treated as air, so every face on a chunk boundary
|
||||
//! is emitted. Cross-chunk face culling is a separate concern layered on top.
|
||||
|
||||
use crate::mesh::Vertex;
|
||||
use shared::world::{BlockId, CHUNK_SIZE, Chunk};
|
||||
|
||||
/// The signed direction a face points along one of the three axes.
|
||||
///
|
||||
/// The sign is part of the merge key: two faces on the same plane but pointing
|
||||
/// in opposite directions (for example a top face and the bottom face directly
|
||||
/// above it) must never merge, so `PosY` and `NegY` are distinct variants.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
enum FaceDir {
|
||||
/// Points toward increasing X.
|
||||
PosX,
|
||||
/// Points toward decreasing X.
|
||||
NegX,
|
||||
/// Points toward increasing Y (upward).
|
||||
PosY,
|
||||
/// Points toward decreasing Y (downward).
|
||||
NegY,
|
||||
/// Points toward increasing Z.
|
||||
PosZ,
|
||||
/// Points toward decreasing Z.
|
||||
NegZ,
|
||||
}
|
||||
|
||||
/// Identifies whether two faces are mergeable.
|
||||
///
|
||||
/// Two faces merge only if every attribute a vertex carries is identical. Colour
|
||||
/// currently depends only on [`FaceDir`], but keying additionally on [`BlockId`]
|
||||
/// keeps the merge correct once per-material colours are introduced: two distinct
|
||||
/// block types will not silently coalesce into one quad.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
struct FaceKey {
|
||||
/// The material of the voxel owning the face.
|
||||
block: BlockId,
|
||||
/// The face's signed axis direction, which selects its colour.
|
||||
dir: FaceDir,
|
||||
}
|
||||
|
||||
/// Returns the flat RGB colour for a face pointing in `dir`.
|
||||
///
|
||||
/// The values reproduce the previous per-face emitter exactly so the rendered
|
||||
/// output is unchanged.
|
||||
const fn color_of(dir: FaceDir) -> [f32; 3] {
|
||||
match dir {
|
||||
FaceDir::PosY => [0.2, 0.8, 0.2],
|
||||
FaceDir::NegY => [0.1, 0.4, 0.1],
|
||||
FaceDir::PosX | FaceDir::NegX => [0.15, 0.6, 0.15],
|
||||
FaceDir::PosZ | FaceDir::NegZ => [0.18, 0.7, 0.18],
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a chunk-local integer coordinate to its floating-point value.
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "chunk-local coordinates never exceed CHUNK_SIZE (32) and are exact as f32"
|
||||
)]
|
||||
const fn coord(i: usize) -> f32 {
|
||||
i as f32
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[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<Vertex>, Vec<u32>) {
|
||||
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.
|
||||
let mut mask = vec![None; CHUNK_SIZE * CHUNK_SIZE];
|
||||
|
||||
// +Y (top): slice = y, mask u = x, mask v = z.
|
||||
run_pass(
|
||||
&mut mask,
|
||||
&mut vertices,
|
||||
&mut indices,
|
||||
|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))
|
||||
.then_some(FaceKey {
|
||||
block,
|
||||
dir: FaceDir::PosY,
|
||||
})
|
||||
},
|
||||
|y, x0, z0, w, h| {
|
||||
let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5);
|
||||
let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + h) - 0.5);
|
||||
let yp = coord(y) + 0.5;
|
||||
[
|
||||
[xmin, yp, zmax],
|
||||
[xmax, yp, zmax],
|
||||
[xmax, yp, zmin],
|
||||
[xmin, yp, zmin],
|
||||
]
|
||||
},
|
||||
);
|
||||
|
||||
// -Y (bottom): slice = y, mask u = x, mask v = z.
|
||||
run_pass(
|
||||
&mut mask,
|
||||
&mut vertices,
|
||||
&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,
|
||||
dir: FaceDir::NegY,
|
||||
},
|
||||
)
|
||||
},
|
||||
|y, x0, z0, w, h| {
|
||||
let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5);
|
||||
let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + h) - 0.5);
|
||||
let yp = coord(y) - 0.5;
|
||||
[
|
||||
[xmin, yp, zmin],
|
||||
[xmax, yp, zmin],
|
||||
[xmax, yp, zmax],
|
||||
[xmin, yp, zmax],
|
||||
]
|
||||
},
|
||||
);
|
||||
|
||||
// +X: slice = x, mask u = z, mask v = y.
|
||||
run_pass(
|
||||
&mut mask,
|
||||
&mut vertices,
|
||||
&mut indices,
|
||||
|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))
|
||||
.then_some(FaceKey {
|
||||
block,
|
||||
dir: FaceDir::PosX,
|
||||
})
|
||||
},
|
||||
|x, z0, y0, w, h| {
|
||||
let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + w) - 0.5);
|
||||
let (ymin, ymax) = (coord(y0) - 0.5, coord(y0 + h) - 0.5);
|
||||
let xp = coord(x) + 0.5;
|
||||
[
|
||||
[xp, ymin, zmax],
|
||||
[xp, ymin, zmin],
|
||||
[xp, ymax, zmin],
|
||||
[xp, ymax, zmax],
|
||||
]
|
||||
},
|
||||
);
|
||||
|
||||
// -X: slice = x, mask u = z, mask v = y.
|
||||
run_pass(
|
||||
&mut mask,
|
||||
&mut vertices,
|
||||
&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,
|
||||
dir: FaceDir::NegX,
|
||||
},
|
||||
)
|
||||
},
|
||||
|x, z0, y0, w, h| {
|
||||
let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + w) - 0.5);
|
||||
let (ymin, ymax) = (coord(y0) - 0.5, coord(y0 + h) - 0.5);
|
||||
let xp = coord(x) - 0.5;
|
||||
[
|
||||
[xp, ymin, zmin],
|
||||
[xp, ymin, zmax],
|
||||
[xp, ymax, zmax],
|
||||
[xp, ymax, zmin],
|
||||
]
|
||||
},
|
||||
);
|
||||
|
||||
// +Z: slice = z, mask u = x, mask v = y.
|
||||
run_pass(
|
||||
&mut mask,
|
||||
&mut vertices,
|
||||
&mut indices,
|
||||
|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))
|
||||
.then_some(FaceKey {
|
||||
block,
|
||||
dir: FaceDir::PosZ,
|
||||
})
|
||||
},
|
||||
|z, x0, y0, w, h| {
|
||||
let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5);
|
||||
let (ymin, ymax) = (coord(y0) - 0.5, coord(y0 + h) - 0.5);
|
||||
let zp = coord(z) + 0.5;
|
||||
[
|
||||
[xmin, ymin, zp],
|
||||
[xmax, ymin, zp],
|
||||
[xmax, ymax, zp],
|
||||
[xmin, ymax, zp],
|
||||
]
|
||||
},
|
||||
);
|
||||
|
||||
// -Z: slice = z, mask u = x, mask v = y.
|
||||
run_pass(
|
||||
&mut mask,
|
||||
&mut vertices,
|
||||
&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,
|
||||
dir: FaceDir::NegZ,
|
||||
},
|
||||
)
|
||||
},
|
||||
|z, x0, y0, w, h| {
|
||||
let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5);
|
||||
let (ymin, ymax) = (coord(y0) - 0.5, coord(y0 + h) - 0.5);
|
||||
let zp = coord(z) - 0.5;
|
||||
[
|
||||
[xmax, ymin, zp],
|
||||
[xmin, ymin, zp],
|
||||
[xmin, ymax, zp],
|
||||
[xmax, ymax, zp],
|
||||
]
|
||||
},
|
||||
);
|
||||
|
||||
(vertices, indices)
|
||||
}
|
||||
|
||||
/// Runs one directional meshing pass over all `CHUNK_SIZE` slices.
|
||||
///
|
||||
/// `sample(slice, u, v)` returns the [`FaceKey`] for the face at mask cell
|
||||
/// `(u, v)` of `slice`, or `None` when no face is exposed there. `corners(slice,
|
||||
/// u0, v0, w, h)` yields the four world-space corners, ordered counter-clockwise
|
||||
/// as seen from outside the face, of a merged rectangle rooted at `(u0, v0)` with
|
||||
/// width `w` along `u` and height `h` along `v`.
|
||||
fn run_pass(
|
||||
mask: &mut [Option<FaceKey>],
|
||||
vertices: &mut Vec<Vertex>,
|
||||
indices: &mut Vec<u32>,
|
||||
mut sample: impl FnMut(usize, usize, usize) -> Option<FaceKey>,
|
||||
corners: impl Fn(usize, usize, usize, usize, usize) -> [[f32; 3]; 4],
|
||||
) {
|
||||
for slice in 0..CHUNK_SIZE {
|
||||
for v in 0..CHUNK_SIZE {
|
||||
for u in 0..CHUNK_SIZE {
|
||||
mask[u + v * CHUNK_SIZE] = sample(slice, u, v);
|
||||
}
|
||||
}
|
||||
|
||||
merge_mask(mask, |key, u0, v0, w, h| {
|
||||
push_quad(
|
||||
vertices,
|
||||
indices,
|
||||
corners(slice, u0, v0, w, h),
|
||||
color_of(key.dir),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Greedily covers the exposed cells of `mask` with maximal rectangles.
|
||||
///
|
||||
/// Cells are scanned row-major. At the first exposed, unconsumed cell the run is
|
||||
/// extended along `u` while the key matches, then along `v` while every cell of
|
||||
/// the next row over the current width matches. The covered cells are marked
|
||||
/// consumed (set to `None`) so they are not re-emitted, and `emit(key, u0, v0, w,
|
||||
/// h)` is called once for the rectangle.
|
||||
fn merge_mask(
|
||||
mask: &mut [Option<FaceKey>],
|
||||
mut emit: impl FnMut(FaceKey, usize, usize, usize, usize),
|
||||
) {
|
||||
for v in 0..CHUNK_SIZE {
|
||||
for u in 0..CHUNK_SIZE {
|
||||
let Some(key) = mask[u + v * CHUNK_SIZE] else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Extend width along u while the key is unbroken.
|
||||
let mut w = 1;
|
||||
while u + w < CHUNK_SIZE && mask[(u + w) + v * CHUNK_SIZE] == Some(key) {
|
||||
w += 1;
|
||||
}
|
||||
|
||||
// Extend height along v while every cell of the next row matches over [0, w).
|
||||
let mut h = 1;
|
||||
'grow: while v + h < CHUNK_SIZE {
|
||||
for du in 0..w {
|
||||
if mask[(u + du) + (v + h) * CHUNK_SIZE] != Some(key) {
|
||||
break 'grow;
|
||||
}
|
||||
}
|
||||
h += 1;
|
||||
}
|
||||
|
||||
// Consume the covered rectangle so its cells are not re-emitted.
|
||||
for dv in 0..h {
|
||||
for du in 0..w {
|
||||
mask[(u + du) + (v + dv) * CHUNK_SIZE] = None;
|
||||
}
|
||||
}
|
||||
|
||||
emit(key, u, v, w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends one quad (four vertices, six indices) with the given corners and colour.
|
||||
///
|
||||
/// Indices wind the two triangles as `[base, base+1, base+2, base+2, base+3,
|
||||
/// base]`, matching the corner ordering supplied by the caller.
|
||||
fn push_quad(
|
||||
vertices: &mut Vec<Vertex>,
|
||||
indices: &mut Vec<u32>,
|
||||
corners: [[f32; 3]; 4],
|
||||
color: [f32; 3],
|
||||
) {
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "a chunk mesh holds far fewer than u32::MAX vertices"
|
||||
)]
|
||||
let base = vertices.len() as u32;
|
||||
for position in corners {
|
||||
vertices.push(Vertex { position, color });
|
||||
}
|
||||
indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 3, base]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/meshing.rs"]
|
||||
mod tests;
|
||||
161
crates/renderer/src/tests/meshing.rs
Normal file
161
crates/renderer/src/tests/meshing.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// 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
|
||||
}
|
||||
|
||||
/// 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());
|
||||
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);
|
||||
// Six exposed faces, none mergeable: 6 quads.
|
||||
assert_eq!(vertices.len(), 24);
|
||||
assert_eq!(indices.len(), 36);
|
||||
}
|
||||
|
||||
#[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);
|
||||
// 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);
|
||||
// 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);
|
||||
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 mesh_is_deterministic() {
|
||||
let chunk = random_chunk(42);
|
||||
assert_eq!(generate_mesh(&chunk), generate_mesh(&chunk));
|
||||
}
|
||||
Loading…
Reference in a new issue