Synvael/crates/renderer/src/meshing.rs

417 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: AGPL-3.0-only
//! Cubic greedy mesher: converts a dense voxel [`Chunk`] into renderer geometry.
use crate::vertex::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,
}
impl FaceDir {
/// Returns the index identifying this direction's outward normal to the shader.
///
/// The six values are a contract with the `FACE_NORMALS` table in `assets/shaders/cube.vert`, which is indexed by them directly.
const fn to_index(self) -> u32 {
match self {
Self::PosX => 0,
Self::NegX => 1,
Self::PosY => 2,
Self::NegY => 3,
Self::PosZ => 4,
Self::NegZ => 5,
}
}
}
/// Identifies whether two faces are mergeable.
#[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 outward normal.
dir: FaceDir,
}
/// The flat RGB albedo emitted for every face.
const MATERIAL_COLOR: [f32; 3] = [0.2, 0.8, 0.2];
/// 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
}
/// 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 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, neighbors: &Neighbors) -> (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
&& occluder(chunk, neighbors, x, y, z, FaceDir::PosY) == 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
&& 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);
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
&& occluder(chunk, neighbors, x, y, z, FaceDir::PosX) == 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
&& 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);
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
&& occluder(chunk, neighbors, x, y, z, FaceDir::PosZ) == 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
&& 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);
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),
key.dir.to_index(),
);
});
}
}
/// 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 packed face normal.
///
/// A quad is planar, so all four vertices share `normal`. 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],
normal: u32,
) {
#[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: MATERIAL_COLOR,
normal,
});
}
indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 3, base]);
}
#[cfg(test)]
#[path = "tests/meshing.rs"]
mod tests;