From 49d35cab5b660948225c840ab676c383e790fa88 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Thu, 28 May 2026 15:32:45 +0200 Subject: [PATCH] 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. --- Assets/textures/atlas.png | Bin 236 -> 2486 bytes Core/Game.cs | 60 +++++++++++++++++++----- VoxelEngine.Tests/ChunkMesherTests.cs | 2 + VoxelEngine.Tests/ChunkTests.cs | 1 + World/{ => Blocks}/BlockFace.cs | 2 +- World/{ => Blocks}/BlockRegistry.cs | 4 +- World/{ => Blocks}/IBlock.cs | 2 +- World/{ => Chunk}/Chunk.cs | 2 +- World/{ => Chunk}/ChunkMesher.cs | 10 ++-- World/Chunk/IChunkMesher.cs | 9 ++++ World/IChunkMesher.cs | 7 --- World/IWorld.cs | 11 +++++ World/World.cs | 63 ++++++++++++++++++++++++++ 13 files changed, 145 insertions(+), 28 deletions(-) rename World/{ => Blocks}/BlockFace.cs (78%) rename World/{ => Blocks}/BlockRegistry.cs (91%) rename World/{ => Blocks}/IBlock.cs (93%) rename World/{ => Chunk}/Chunk.cs (94%) rename World/{ => Chunk}/ChunkMesher.cs (89%) create mode 100644 World/Chunk/IChunkMesher.cs delete mode 100644 World/IChunkMesher.cs create mode 100644 World/IWorld.cs create mode 100644 World/World.cs diff --git a/Assets/textures/atlas.png b/Assets/textures/atlas.png index 943844a32621b67aa496dd73b26332d88df088cb..6d50d5e6cb580ed91046cd76e8693bd0897992b9 100644 GIT binary patch delta 2450 zcmV;D32pZ50k#v6DN_O2$t-^W000DMK}|sb0I`n?{9y$E001CkNK#Dz0D2|>0Dy!5 z0Qvv`0D$NK0Cg|`0P0`>06Lfe02gqax=}m;000JJOGiWi{{a60|De66laW3ce+P6) zO+^Rl1sn|zGnup-nE(I?`bk7VR9M5MmC24Jxp_tp$zYH%-Fx%qP?=d%qttDyZM5Kp zy)o>a7JdXjj-J7Q-^!2y+cFw%Nj+6nRt|TJ8xwnr%`1pT^hvHxU^`8^D zM}(a1w&m^WZjgzwFuR!$JPARlzo0NV$NLY|=T}s#3-mY6QsZF`~;3>|XvM3GR*y5R3lt9>k z^vD6$=`UE6E5>QVS8uO~f5}tl7QYu*cgq{|ccc`NN|0MiQI>=di7{aEjG`z|Sw@Hn zV=Gb;)K!I_9X>)P;rpNN8GOT(m3VDA?DyP#xZ~KK&{`suLg}0!6w^FYSc{T5MY$q3 z6`z|wJI$U&bwd^cheJeZ#p{<}@#)h8nJ$^eo^Rj(f$=-V?Zq{CAR&-aArRzw&g4esIbuxC>=R{GkdR2}pPx#e^Y8!h6TkY)OAr;i-G<|C$9jE% zwHi~NGlqfka9Gg2Um6Ab}9LIS~9zt~HPt$AOqY zNlATv#jBUMNGY*JgPR8Gy5Vg7f*&6`hOQ^e1WH6if|x*PL)*3Fxk8(JJ|};6>;{6D zSg9#4HJir`+9hTjo6QHvG78g>O-F?6xvt+K3&poTJTjoDe^O!?2AaCYxe*^>ntBp2 zOf!WwEEf$aCB~sc2*`7dl!_1|=c|So24qki`x6ie2p%8rc=zU){PFv5X^I6gdWLae z=zE;^6m^BkE%IT>$HVVg)`qz9e1G=?*Rn-w$HS+eAsU30zmdAo{=$KfmXz zn}0;+K~YA@e~XJ3Y@RwYZwRwu@+VYPqcl7`ej<8!clI@k6~-(%jR&6Y?~xLO5bTdF zAc!$yY=JR`Oy@W^F-;>%36#pvI>#0jAjnk4>{l?V&Fp8|uBW;B1*IvOhk>^5ndh0{ z9a*Lcf6-G`H7Ooa;w?Ip{M7!I=se@>xnADz)6;u0Tk!7eugOcvhvR?Y z{7CXSR!6Kggy5Ndq-mD8;mFW;Bm~pgk&xt>ee;{juG zF4b$iPoz^sO7&+6X9C46B9G(mXvZzyC6?6{O0?W>f2NUVxS$xs10Nnf(v)uz;gP=E ze`9lt6$O)XC?Ob!6ES#V4A|oNOPt(H-;W?AMoaQMgOtd%X6QPi?CDNNN&_f`lnNgl zT5E3CUto>l>ES&$uU->UV(d>8RmIY;8K*!WdX{FzcC*J?gOtxRo;F)do*{%}a)B5; zA!vk1IOmWmp_C-cL`8q_C|xslC$uWre;u|c<2h{;)xuKNmdE20QWtc?0WSxn5C|3= z+m^x>%+aCq96_QeDyGRnib#FCCP0;l7S>5LV!q$vOGg& zh95s}ux5qN3uZsj9e0eA$BmA&>7kWxa5Uib!dw&Mw*RH;Cl;)i1BOxw$4KNwYl1mIc}v zE-r4l+YZE#$aThIu^=KzDN@)RDWI?gnYMTzXxjmuYlIL;DM=)brw-Yae`j>;c{*)) z@%kEL>YQKJES+v}Bw->+=$QJ@EeUZ)Bln+-lr(V*fBumK6wr&J;>X+%&P>?(ja4 zVgw;Exgn6@y#G^639Z$isF|=@qEyB(40K(`>C~}UEh(#t<-(%1Ao_vheuI>Ph~VMz zj(2a~{2vJinr2C1G(+DJe`91CpHH1Bq>?;59XPffr&CKzg4Jq8CNoX9Kd|v1(T@Y? zO-(!9QB>zlryi{}K?UA_e9vw?QK|uzK_&|F(t?B8`{xTMXom?Qc*Z_>rfJ59fcK6} zfsm5I)(H8$te2N1T4lI-qAU$fvt+Y-Boog9!%xK7;Km-M6uMple}eUT&Ee^PIJ2K8 zVR;(vSyUBCNCNPO`;WLmGYo*4alPlT+aRX|ULYid8D3u8aSR+y2=;`7Z65;;#!a~tQ4k8Q( zO _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(); diff --git a/VoxelEngine.Tests/ChunkMesherTests.cs b/VoxelEngine.Tests/ChunkMesherTests.cs index eefbfde..88b3619 100644 --- a/VoxelEngine.Tests/ChunkMesherTests.cs +++ b/VoxelEngine.Tests/ChunkMesherTests.cs @@ -1,4 +1,6 @@ using VoxelEngine.World; +using VoxelEngine.World.Blocks; +using VoxelEngine.World.Chunk; using Xunit; namespace VoxelEngine.Tests; diff --git a/VoxelEngine.Tests/ChunkTests.cs b/VoxelEngine.Tests/ChunkTests.cs index 646a569..d548786 100644 --- a/VoxelEngine.Tests/ChunkTests.cs +++ b/VoxelEngine.Tests/ChunkTests.cs @@ -1,4 +1,5 @@ using VoxelEngine.World; +using VoxelEngine.World.Chunk; using Xunit; namespace VoxelEngine.Tests; diff --git a/World/BlockFace.cs b/World/Blocks/BlockFace.cs similarity index 78% rename from World/BlockFace.cs rename to World/Blocks/BlockFace.cs index 4193936..4e81fd9 100644 --- a/World/BlockFace.cs +++ b/World/Blocks/BlockFace.cs @@ -1,4 +1,4 @@ -namespace VoxelEngine.World; +namespace VoxelEngine.World.Blocks; public enum BlockFace { diff --git a/World/BlockRegistry.cs b/World/Blocks/BlockRegistry.cs similarity index 91% rename from World/BlockRegistry.cs rename to World/Blocks/BlockRegistry.cs index c08c86c..69e9905 100644 --- a/World/BlockRegistry.cs +++ b/World/Blocks/BlockRegistry.cs @@ -1,6 +1,4 @@ -using VoxelEngine.World.Blocks; - -namespace VoxelEngine.World; +namespace VoxelEngine.World.Blocks; public sealed class BlockRegistry { diff --git a/World/IBlock.cs b/World/Blocks/IBlock.cs similarity index 93% rename from World/IBlock.cs rename to World/Blocks/IBlock.cs index d181611..6f5de68 100644 --- a/World/IBlock.cs +++ b/World/Blocks/IBlock.cs @@ -1,4 +1,4 @@ -namespace VoxelEngine.World; +namespace VoxelEngine.World.Blocks; public interface IBlock { diff --git a/World/Chunk.cs b/World/Chunk/Chunk.cs similarity index 94% rename from World/Chunk.cs rename to World/Chunk/Chunk.cs index b680dd1..4bc4f89 100644 --- a/World/Chunk.cs +++ b/World/Chunk/Chunk.cs @@ -1,4 +1,4 @@ -namespace VoxelEngine.World; +namespace VoxelEngine.World.Chunk; public sealed class Chunk { diff --git a/World/ChunkMesher.cs b/World/Chunk/ChunkMesher.cs similarity index 89% rename from World/ChunkMesher.cs rename to World/Chunk/ChunkMesher.cs index 4cdb53c..ac333c7 100644 --- a/World/ChunkMesher.cs +++ b/World/Chunk/ChunkMesher.cs @@ -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? neighborOpaqueOutside = null) { List 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)); diff --git a/World/Chunk/IChunkMesher.cs b/World/Chunk/IChunkMesher.cs new file mode 100644 index 0000000..4799123 --- /dev/null +++ b/World/Chunk/IChunkMesher.cs @@ -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? neighborOpaqueOutside = null); +} diff --git a/World/IChunkMesher.cs b/World/IChunkMesher.cs deleted file mode 100644 index dcee99e..0000000 --- a/World/IChunkMesher.cs +++ /dev/null @@ -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); -} diff --git a/World/IWorld.cs b/World/IWorld.cs new file mode 100644 index 0000000..f286a70 --- /dev/null +++ b/World/IWorld.cs @@ -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); +} \ No newline at end of file diff --git a/World/World.cs b/World/World.cs new file mode 100644 index 0000000..cc44063 --- /dev/null +++ b/World/World.cs @@ -0,0 +1,63 @@ +using System.Collections; +using OpenTK.Mathematics; +using VoxelEngine.World.Blocks; + +namespace VoxelEngine.World; + +public sealed class World : IWorld, IEnumerable> +{ + private readonly Dictionary _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> GetEnumerator() + => this._chunks.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + +} \ No newline at end of file