From ecae0d4ce40a43c7286966ad50709912f46c4029 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Thu, 28 May 2026 12:00:26 +0200 Subject: [PATCH] feat: add face-culled chunk mesher behind IChunkMesher --- VoxelEngine.Tests/ChunkMesherTests.cs | 55 ++++++++++++++++ World/ChunkMesher.cs | 93 +++++++++++++++++++++++++++ World/IChunkMesher.cs | 7 ++ 3 files changed, 155 insertions(+) create mode 100644 VoxelEngine.Tests/ChunkMesherTests.cs create mode 100644 World/ChunkMesher.cs create mode 100644 World/IChunkMesher.cs diff --git a/VoxelEngine.Tests/ChunkMesherTests.cs b/VoxelEngine.Tests/ChunkMesherTests.cs new file mode 100644 index 0000000..aa61d52 --- /dev/null +++ b/VoxelEngine.Tests/ChunkMesherTests.cs @@ -0,0 +1,55 @@ +using VoxelEngine.World; +using Xunit; + +namespace VoxelEngine.Tests; + +public class ChunkMesherTests +{ + private const int FloatsPerFace = 36; // 6 verts * 6 floats + + private static int FaceCount(float[] mesh) => mesh.Length / FloatsPerFace; + + [Fact] + public void EmptyChunk_ProducesNoFaces() + { + var mesh = new ChunkMesher().Build(new Chunk(), new BlockRegistry()); + Assert.Empty(mesh); + } + + [Fact] + public void SingleBlockSurroundedByAir_Produces6Faces() + { + var chunk = new Chunk(); + chunk[8, 8, 8] = 1; // stone + + var mesh = new ChunkMesher().Build(chunk, new BlockRegistry()); + + Assert.Equal(6, FaceCount(mesh)); + } + + [Fact] + public void FullySolidChunk_ProducesOnlyOuterShell() + { + var chunk = new Chunk(); + for (int x = 0; x < Chunk.Size; x++) + for (int y = 0; y < Chunk.Size; y++) + for (int z = 0; z < Chunk.Size; z++) + chunk[x, y, z] = 1; // stone + + var mesh = new ChunkMesher().Build(chunk, new BlockRegistry()); + + // 6 sides * 16 * 16 visible faces + Assert.Equal(6 * Chunk.Size * Chunk.Size, FaceCount(mesh)); + } + + [Fact] + public void EveryVertexHasSixFloats() + { + var chunk = new Chunk(); + chunk[0, 0, 0] = 1; + + var mesh = new ChunkMesher().Build(chunk, new BlockRegistry()); + + Assert.Equal(0, mesh.Length % 6); + } +} diff --git a/World/ChunkMesher.cs b/World/ChunkMesher.cs new file mode 100644 index 0000000..e690fa2 --- /dev/null +++ b/World/ChunkMesher.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; + +namespace VoxelEngine.World; + +public sealed class ChunkMesher : IChunkMesher +{ + private const int AtlasTiles = 4; // horizontal strip of 4 tiles + + // Per-face geometry: base corner + two in-plane edge vectors (u, v) with + // cross(u, v) == outward normal, guaranteeing CCW winding from outside. + private readonly struct Face + { + public readonly BlockFace Kind; + public readonly (int x, int y, int z) Normal; // also the neighbor offset + public readonly (float x, float y, float z) Base; + public readonly (float x, float y, float z) U; + public readonly (float x, float y, float z) V; + public readonly float Shade; + + public Face(BlockFace kind, (int, int, int) normal, + (float, float, float) @base, (float, float, float) u, + (float, float, float) v, float shade) + { + Kind = kind; Normal = normal; Base = @base; U = u; V = v; Shade = shade; + } + } + + private static readonly Face[] Faces = + { + new(BlockFace.Top, (0, 1, 0), (0, 1, 1), (1, 0, 0), (0, 0, -1), 1.0f), + new(BlockFace.Bottom, (0, -1, 0), (0, 0, 0), (1, 0, 0), (0, 0, 1), 0.6f), + new(BlockFace.North, (0, 0, -1), (1, 0, 0), (-1, 0, 0), (0, 1, 0), 0.8f), + new(BlockFace.South, (0, 0, 1), (0, 0, 1), (1, 0, 0), (0, 1, 0), 0.8f), + new(BlockFace.East, (1, 0, 0), (1, 0, 1), (0, 0, -1), (0, 1, 0), 0.8f), + new(BlockFace.West, (-1, 0, 0), (0, 0, 0), (0, 0, 1), (0, 1, 0), 0.8f), + }; + + // Quad corners as (uCoord, vCoord) and the triangle order (0,1,2,0,2,3). + private static readonly (float u, float v)[] Corners = + { (0, 0), (1, 0), (1, 1), (0, 1) }; + private static readonly int[] Triangle = { 0, 1, 2, 0, 2, 3 }; + + public float[] Build(Chunk chunk, BlockRegistry registry) + { + var verts = new List(); + + for (int x = 0; x < Chunk.Size; x++) + for (int y = 0; y < Chunk.Size; y++) + for (int z = 0; z < Chunk.Size; z++) + { + IBlock block = registry.Get(chunk[x, y, z]); + if (!block.IsOpaque) continue; + + foreach (Face face in Faces) + { + int nx = x + face.Normal.x; + int ny = y + face.Normal.y; + int nz = z + face.Normal.z; + + // Out-of-chunk neighbors are treated as non-opaque (single chunk). + bool neighborOpaque = + Chunk.InBounds(nx, ny, nz) && registry.Get(chunk[nx, ny, nz]).IsOpaque; + if (neighborOpaque) continue; + + AddFace(verts, x, y, z, face, block.GetTextureIndex(face.Kind)); + } + } + + return verts.ToArray(); + } + + private static void AddFace(List verts, int x, int y, int z, Face face, int texIndex) + { + float tileWidth = 1f / AtlasTiles; + float uBase = texIndex * tileWidth; + + foreach (int corner in Triangle) + { + (float cu, float cv) = Corners[corner]; + + float px = x + face.Base.x + face.U.x * cu + face.V.x * cv; + float py = y + face.Base.y + face.U.y * cu + face.V.y * cv; + float pz = z + face.Base.z + face.U.z * cu + face.V.z * cv; + + float u = uBase + cu * tileWidth; + float v = cv; + + verts.Add(px); verts.Add(py); verts.Add(pz); + verts.Add(u); verts.Add(v); + verts.Add(face.Shade); + } + } +} diff --git a/World/IChunkMesher.cs b/World/IChunkMesher.cs new file mode 100644 index 0000000..dcee99e --- /dev/null +++ b/World/IChunkMesher.cs @@ -0,0 +1,7 @@ +namespace VoxelEngine.World; + +public interface IChunkMesher +{ + // Returns interleaved vertices: [pos.x, pos.y, pos.z, u, v, shade] per vertex. + float[] Build(Chunk chunk, BlockRegistry registry); +}