feat: add multi-chunk world with cross-chunk face culling

Add a World that stores chunks in a Dictionary keyed by chunk
coordinate, with floor-division world<->chunk coordinate mapping
(correct for negative coords). The mesher takes an optional neighbor
sampler so faces at chunk borders are culled against adjacent chunks
instead of always meshing the shell. Render one ChunkRenderer per
chunk, placed via the model matrix. Reorganize the World layer into
Blocks/ and Chunk/ sub-namespaces.
This commit is contained in:
2026-05-28 15:32:45 +02:00
parent 70c986529b
commit 49d35cab5b
13 changed files with 145 additions and 28 deletions

19
World/Chunk/Chunk.cs Normal file
View File

@@ -0,0 +1,19 @@
namespace VoxelEngine.World.Chunk;
public sealed class Chunk
{
public const int Size = 16;
private readonly ushort[] _ids = new ushort[Chunk.Size * Chunk.Size * Chunk.Size];
public ushort this[int x, int y, int z]
{
get => this._ids[Chunk.Index(x, y, z)];
set => this._ids[Chunk.Index(x, y, z)] = value;
}
public static bool InBounds(int x, int y, int z) =>
x is >= 0 and < Chunk.Size && y is >= 0 and < Chunk.Size && z is >= 0 and < Chunk.Size;
private static int Index(int x, int y, int z) => (x * Chunk.Size + y) * Chunk.Size + z;
}

View File

@@ -0,0 +1,99 @@
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.Chunk;
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)
{
this.Kind = kind;
this.Normal = normal;
this.Base = @base;
this.U = u;
this.V = v;
this.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, Func<int, int, int, bool>? neighborOpaqueOutside = null)
{
List<float> verts = new();
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 ChunkMesher.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
: (neighborOpaqueOutside?.Invoke(nx, ny, nz) ?? false);
if (neighborOpaque) continue;
ChunkMesher.AddFace(verts, x, y, z, face, block.GetTextureIndex(face.Kind));
}
}
return verts.ToArray();
}
private static void AddFace(List<float> verts, int x, int y, int z, Face face, int texIndex)
{
float tileWidth = 1f / ChunkMesher.AtlasTiles;
float uBase = texIndex * tileWidth;
foreach (int corner in ChunkMesher.Triangle)
{
(float cu, float cv) = ChunkMesher.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;
verts.Add(px); verts.Add(py); verts.Add(pz);
verts.Add(u); verts.Add(cv);
verts.Add(face.Shade);
}
}
}

View File

@@ -0,0 +1,9 @@
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.Chunk;
public interface IChunkMesher
{
// Returns interleaved vertices: [pos.x, pos.y, pos.z, u, v, shade] per vertex.
float[] Build(Chunk chunk, BlockRegistry registry, Func<int, int, int, bool>? neighborOpaqueOutside = null);
}