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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 B

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -4,7 +4,8 @@ using OpenTK.Windowing.Common;
using OpenTK.Windowing.Desktop;
using OpenTK.Windowing.GraphicsLibraryFramework;
using VoxelEngine.Rendering;
using VoxelEngine.World;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Chunk;
namespace VoxelEngine.Core;
@@ -16,9 +17,11 @@ public sealed class Game : GameWindow
private readonly BlockRegistry _registry = new();
private Shader _shader = null!;
private Texture _texture = null!;
private ChunkRenderer _renderer = null!;
private readonly Dictionary<Vector3i, ChunkRenderer> _renderer = new();
private Camera _camera = null!;
private World.World _world;
public Game(GameWindowSettings gameSettings, NativeWindowSettings nativeSettings)
: base(gameSettings, nativeSettings) { }
@@ -33,9 +36,28 @@ public sealed class Game : GameWindow
this._shader = new Shader("Assets/shaders/chunk.vert", "Assets/shaders/chunk.frag");
this._texture = new Texture("Assets/textures/atlas.png");
Chunk chunk = this.BuildChunk();
float[] mesh = new ChunkMesher().Build(chunk, this._registry);
this._renderer = new ChunkRenderer(mesh);
this._world = new World.World(this._registry);
for (int x = -2; x < 2; x++)
for (int z = -2; z < 2; z++)
{
this._world.SetChunk(new Vector3i(x, 0, z), this.BuildChunk());
}
ChunkMesher mesher = new();
foreach ((Vector3i pos, Chunk chunk) in this._world)
{
Vector3i origin = pos * Chunk.Size;
float[] mesh = mesher.Build(
chunk,
this._registry,
(nx, ny, nz) => this._world.GetBlock(
origin.X + nx,
origin.Y + ny,
origin.Z + nz).IsOpaque
);
this._renderer[pos] = new ChunkRenderer(mesh);
}
this._camera = new Camera(new Vector3(8f, 20f, 40f), this.ClientSize.X / (float)this.ClientSize.Y);
@@ -65,15 +87,20 @@ public sealed class Game : GameWindow
if (!this.IsFocused) return;
if (this.KeyboardState.IsKeyDown(Keys.Escape)) {
this.Close(); return; }
this.Close(); return;
}
float distance = Game.MoveSpeed * (float)args.Time;
float speedMulti = 1f;
if (this.KeyboardState.IsKeyDown(Keys.LeftShift)) speedMulti = 1.8f ;
float distance = Game.MoveSpeed * speedMulti * (float)args.Time;
if (this.KeyboardState.IsKeyDown(Keys.W)) this._camera.Move(this._camera.Front * distance);
if (this.KeyboardState.IsKeyDown(Keys.S)) this._camera.Move(-this._camera.Front * distance);
if (this.KeyboardState.IsKeyDown(Keys.A)) this._camera.Move(-this._camera.Right * distance);
if (this.KeyboardState.IsKeyDown(Keys.D)) this._camera.Move(this._camera.Right * distance);
if (this.KeyboardState.IsKeyDown(Keys.Space)) this._camera.Move(Vector3.UnitY * distance);
if (this.KeyboardState.IsKeyDown(Keys.LeftShift)) this._camera.Move(-Vector3.UnitY * distance);
if (this.KeyboardState.IsKeyDown(Keys.LeftControl)) this._camera.Move(-Vector3.UnitY * distance);
Vector2 delta = this.MouseState.Delta;
this._camera.AddRotation(delta.X * Game.Sensitivity, -delta.Y * Game.Sensitivity);
@@ -86,12 +113,20 @@ public sealed class Game : GameWindow
GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
this._shader.Use();
this._shader.SetMatrix4("model", Matrix4.Identity);
this._shader.SetMatrix4("view", this._camera.GetViewMatrix());
this._shader.SetMatrix4("projection", this._camera.GetProjectionMatrix());
this._shader.SetInt("atlas", 0);
this._texture.Use(TextureUnit.Texture0);
this._renderer.Draw();
foreach ((Vector3i pos, ChunkRenderer renderer) in this._renderer)
{
this._shader.SetMatrix4("model", Matrix4.CreateTranslation(
pos.X * Chunk.Size,
pos.Y * Chunk.Size,
pos.Z * Chunk.Size
));
renderer.Draw();
}
this.SwapBuffers();
}
@@ -106,7 +141,10 @@ public sealed class Game : GameWindow
protected override void OnUnload()
{
this._renderer.Dispose();
foreach (ChunkRenderer rendererValue in this._renderer.Values)
{
rendererValue.Dispose();
}
this._texture.Dispose();
this._shader.Dispose();
base.OnUnload();

View File

@@ -1,4 +1,6 @@
using VoxelEngine.World;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Chunk;
using Xunit;
namespace VoxelEngine.Tests;

View File

@@ -1,4 +1,5 @@
using VoxelEngine.World;
using VoxelEngine.World.Chunk;
using Xunit;
namespace VoxelEngine.Tests;

View File

@@ -1,4 +1,4 @@
namespace VoxelEngine.World;
namespace VoxelEngine.World.Blocks;
public enum BlockFace
{

View File

@@ -1,6 +1,4 @@
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World;
namespace VoxelEngine.World.Blocks;
public sealed class BlockRegistry
{

View File

@@ -1,4 +1,4 @@
namespace VoxelEngine.World;
namespace VoxelEngine.World.Blocks;
public interface IBlock
{

View File

@@ -1,4 +1,4 @@
namespace VoxelEngine.World;
namespace VoxelEngine.World.Chunk;
public sealed class Chunk
{

View File

@@ -1,6 +1,6 @@
using System.Collections.Generic;
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World;
namespace VoxelEngine.World.Chunk;
public sealed class ChunkMesher : IChunkMesher
{
@@ -45,7 +45,7 @@ public sealed class ChunkMesher : IChunkMesher
{ (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)
public float[] Build(Chunk chunk, BlockRegistry registry, Func<int, int, int, bool>? neighborOpaqueOutside = null)
{
List<float> verts = new();
@@ -64,7 +64,9 @@ public sealed class ChunkMesher : IChunkMesher
// 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;
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));

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);
}

View File

@@ -1,7 +0,0 @@
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);
}

11
World/IWorld.cs Normal file
View File

@@ -0,0 +1,11 @@
using OpenTK.Mathematics;
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World;
public interface IWorld
{
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk);
public Chunk.Chunk? GetChunk(Vector3i chunkPos);
public IBlock GetBlock(int worldX, int worldY, int worldZ);
}

63
World/World.cs Normal file
View File

@@ -0,0 +1,63 @@
using System.Collections;
using OpenTK.Mathematics;
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World;
public sealed class World : IWorld, IEnumerable<KeyValuePair<Vector3i, Chunk.Chunk>>
{
private readonly Dictionary<Vector3i, Chunk.Chunk> _chunks = new();
private readonly BlockRegistry _registry;
public World(BlockRegistry registry)
{
this._registry = registry;
}
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk)
{
this._chunks[chunkPos] = chunk;
}
public Chunk.Chunk? GetChunk(Vector3i chunkPos)
{
return this._chunks.GetValueOrDefault(chunkPos);
}
public IBlock GetBlock(int worldX, int worldY, int worldZ)
{
Vector3i chunkPos = new(
World.FloorDiv(worldX, Chunk.Chunk.Size),
World.FloorDiv(worldY, Chunk.Chunk.Size),
World.FloorDiv(worldZ, Chunk.Chunk.Size)
);
Chunk.Chunk? chunk = this.GetChunk(chunkPos);
if (chunk == null)
{
return this._registry.Air;
}
int localX = worldX - chunkPos.X * Chunk.Chunk.Size;
int localY = worldY - chunkPos.Y * Chunk.Chunk.Size;
int localZ = worldZ - chunkPos.Z * Chunk.Chunk.Size;
return this._registry.Get(chunk[localX, localY, localZ]);
}
private static int FloorDiv(int value, int size)
{
int quotient = value / size;
if (value % size != 0 && value < 0)
{
quotient--;
}
return quotient;
}
public IEnumerator<KeyValuePair<Vector3i, Chunk.Chunk>> GetEnumerator()
=> this._chunks.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
}