From edc72a0f6fb753304d6999489b1e5dfba20de625 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Thu, 23 Jul 2026 02:12:15 +0200 Subject: [PATCH] perf(renderer): merge coplanar voxel faces with a greedy mesher --- crates/renderer/src/lib.rs | 1 + crates/renderer/src/mesh.rs | 2 +- crates/renderer/src/meshing.rs | 361 +++++++++++++++++++++++++++ crates/renderer/src/tests/meshing.rs | 161 ++++++++++++ 4 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 crates/renderer/src/meshing.rs create mode 100644 crates/renderer/src/tests/meshing.rs diff --git a/crates/renderer/src/lib.rs b/crates/renderer/src/lib.rs index 7fe6dea..9a3bce3 100644 --- a/crates/renderer/src/lib.rs +++ b/crates/renderer/src/lib.rs @@ -12,6 +12,7 @@ pub mod error; mod frustum; mod instance; pub mod mesh; +pub mod meshing; mod pipeline; mod renderer; mod surface; diff --git a/crates/renderer/src/mesh.rs b/crates/renderer/src/mesh.rs index 6117cea..d2e0011 100644 --- a/crates/renderer/src/mesh.rs +++ b/crates/renderer/src/mesh.rs @@ -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], diff --git a/crates/renderer/src/meshing.rs b/crates/renderer/src/meshing.rs new file mode 100644 index 0000000..940c1b0 --- /dev/null +++ b/crates/renderer/src/meshing.rs @@ -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, 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. + 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], + vertices: &mut Vec, + indices: &mut Vec, + mut sample: impl FnMut(usize, usize, usize) -> Option, + 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], + 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, + indices: &mut Vec, + 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; diff --git a/crates/renderer/src/tests/meshing.rs b/crates/renderer/src/tests/meshing.rs new file mode 100644 index 0000000..db95dcd --- /dev/null +++ b/crates/renderer/src/tests/meshing.rs @@ -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::().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)); +}