From 1b3387f991a9c9151acb19d348c67fb10f147bb0 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Apr 2026 21:50:40 +0200 Subject: [PATCH] refactor(terrain): chunk-based TerrainChunkRenderer matching ACME architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-giant-buffer TerrainRenderer with TerrainChunkRenderer that groups landblocks into 16×16 chunks, each with its own VAO/VBO/EBO. Matches ACME's ChunkMetrics/ChunkRenderData/TerrainGPUResourceManager pattern: - Pre-allocated max-size VBO (~3.75MB) + EBO per chunk with DynamicDraw - Incremental glBufferSubData uploads per landblock slot (no full rebuild) - Fixed slot layout: slot = (localX * 16 + localY), vertBase = slot * 384 - EBO contains only indices for occupied slots → tight draw calls - One DrawElements per chunk with chunk-level AABB frustum culling - Empty chunks auto-dispose GPU resources Streaming-friendly: add/remove a single landblock touches only its slot in the chunk buffer, not the entire terrain. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 4 +- .../Rendering/TerrainChunkRenderer.cs | 448 ++++++++++++++++++ 2 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 src/AcDream.App/Rendering/TerrainChunkRenderer.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2e911b0..6ea8fb7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -16,7 +16,7 @@ public sealed class GameWindow : IDisposable private IWindow? _window; private GL? _gl; private IInputContext? _input; - private TerrainRenderer? _terrain; + private TerrainChunkRenderer? _terrain; private Shader? _shader; private CameraController? _cameraController; private IMouse? _capturedMouse; @@ -339,7 +339,7 @@ public sealed class GameWindow : IDisposable // Build the terrain atlas once from the Region dat. var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats); - _terrain = new TerrainRenderer(_gl, _shader, terrainAtlas); + _terrain = new TerrainChunkRenderer(_gl, _shader, terrainAtlas); int centerX = (int)((centerLandblockId >> 24) & 0xFFu); int centerY = (int)((centerLandblockId >> 16) & 0xFFu); diff --git a/src/AcDream.App/Rendering/TerrainChunkRenderer.cs b/src/AcDream.App/Rendering/TerrainChunkRenderer.cs new file mode 100644 index 0000000..026005d --- /dev/null +++ b/src/AcDream.App/Rendering/TerrainChunkRenderer.cs @@ -0,0 +1,448 @@ +using System.Numerics; +using AcDream.Core.Terrain; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// Chunk-based terrain renderer matching ACME's architecture. Each 16x16 +/// landblock region gets its own VAO/VBO/EBO with pre-allocated max-size +/// buffers. Landblocks are added/removed incrementally via glBufferSubData +/// instead of rebuilding the entire buffer. +/// +/// Attribute layout (same as TerrainRenderer, see TerrainVertex): +/// location 0: vec3 aPos (3 floats, world space) +/// location 1: vec3 aNormal (3 floats) +/// location 2: uvec4 aPacked0 (4 bytes, Data0) +/// location 3: uvec4 aPacked1 (4 bytes, Data1) +/// location 4: uvec4 aPacked2 (4 bytes, Data2) +/// location 5: uvec4 aPacked3 (4 bytes, Data3) +/// +public sealed unsafe class TerrainChunkRenderer : IDisposable +{ + // ------------------------------------------------------------------------- + // Constants + // ------------------------------------------------------------------------- + + /// Number of landblocks per chunk dimension (matching ACME). + public const int ChunkSizeInLandblocks = 16; + + /// Max landblock slots per chunk (16x16 = 256). + public const int SlotsPerChunk = ChunkSizeInLandblocks * ChunkSizeInLandblocks; + + /// Vertices per landblock: 64 cells x 6 verts = 384. + public const int VerticesPerLandblock = LandblockMesh.VerticesPerLandblock; + + /// Indices per landblock (trivial 0..383, same count as vertices). + public const int IndicesPerLandblock = VerticesPerLandblock; + + /// Byte size of one TerrainVertex (40 bytes). + private static readonly int VertexSize = sizeof(TerrainVertex); + + /// Max VBO size per chunk: 256 slots x 384 verts x 40 bytes = ~3.75 MB. + private static readonly nuint MaxVboBytes = + (nuint)(SlotsPerChunk * VerticesPerLandblock * VertexSize); + + /// Max EBO size per chunk: 256 slots x 384 indices x 4 bytes = ~393 KB. + private static readonly nuint MaxEboBytes = + (nuint)(SlotsPerChunk * IndicesPerLandblock * sizeof(uint)); + + // ------------------------------------------------------------------------- + // Fields + // ------------------------------------------------------------------------- + + private readonly GL _gl; + private readonly Shader _shader; + private readonly TerrainAtlas _atlas; + + /// Active chunks keyed by (chunkX, chunkY) packed into a ulong. + private readonly Dictionary _chunks = new(); + + /// Reverse map: landblockId -> chunkId, for fast RemoveLandblock. + private readonly Dictionary _landblockToChunk = new(); + + // ------------------------------------------------------------------------- + // Construction + // ------------------------------------------------------------------------- + + public TerrainChunkRenderer(GL gl, Shader shader, TerrainAtlas atlas) + { + _gl = gl; + _shader = shader; + _atlas = atlas; + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /// + /// Add (or replace) a landblock's terrain mesh. Vertices are baked to world + /// space using , then uploaded to the correct + /// chunk buffer slot via glBufferSubData. + /// + public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin) + { + // If this landblock already exists, remove it first. + if (_landblockToChunk.ContainsKey(landblockId)) + RemoveLandblock(landblockId); + + // Determine chunk coordinates and slot index. + // Landblock ID format: 0xXXYYnnnn (X at bits 24-31, Y at bits 16-23). + int lbX = (int)(landblockId >> 24) & 0xFF; + int lbY = (int)(landblockId >> 16) & 0xFF; + int chunkX = lbX / ChunkSizeInLandblocks; + int chunkY = lbY / ChunkSizeInLandblocks; + ulong chunkId = PackChunkId(chunkX, chunkY); + + int localX = lbX % ChunkSizeInLandblocks; + int localY = lbY % ChunkSizeInLandblocks; + int slotIndex = localX * ChunkSizeInLandblocks + localY; + + // Create chunk on demand. + if (!_chunks.TryGetValue(chunkId, out var chunk)) + { + chunk = CreateChunk(chunkX, chunkY); + _chunks[chunkId] = chunk; + } + + // Bake world-space vertices. + var worldVerts = new TerrainVertex[meshData.Vertices.Length]; + float zMin = float.MaxValue, zMax = float.MinValue; + for (int i = 0; i < meshData.Vertices.Length; i++) + { + var v = meshData.Vertices[i]; + var worldPos = v.Position + worldOrigin; + worldVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3); + if (worldPos.Z < zMin) zMin = worldPos.Z; + if (worldPos.Z > zMax) zMax = worldPos.Z; + } + if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; } + + // Upload vertices into the slot's region of the VBO. + nint vboOffset = (nint)(slotIndex * VerticesPerLandblock * VertexSize); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo); + fixed (void* p = worldVerts) + { + _gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboOffset, + (nuint)(worldVerts.Length * VertexSize), p); + } + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + + // Track the slot. + chunk.Slots[slotIndex] = new LandblockSlot + { + LandblockId = landblockId, + WorldOrigin = worldOrigin, + MinZ = zMin, + MaxZ = zMax, + }; + chunk.Occupied.Add(slotIndex); + _landblockToChunk[landblockId] = chunkId; + + // Rebuild the EBO for this chunk (only includes occupied slots). + RebuildChunkEbo(chunk); + + // Update chunk AABB. + UpdateChunkBounds(chunk); + } + + /// + /// Remove a landblock from its chunk. If the chunk becomes empty, dispose it. + /// + public void RemoveLandblock(uint landblockId) + { + if (!_landblockToChunk.TryGetValue(landblockId, out var chunkId)) + return; + + _landblockToChunk.Remove(landblockId); + + if (!_chunks.TryGetValue(chunkId, out var chunk)) + return; + + // Find which slot this landblock occupies. + int slotIndex = -1; + foreach (var s in chunk.Occupied) + { + if (chunk.Slots[s].LandblockId == landblockId) + { + slotIndex = s; + break; + } + } + if (slotIndex < 0) + return; + + // Zero out the VBO region for this slot (optional but clean). + nint vboOffset = (nint)(slotIndex * VerticesPerLandblock * VertexSize); + nuint vboSize = (nuint)(VerticesPerLandblock * VertexSize); + var zeros = new byte[VerticesPerLandblock * VertexSize]; + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo); + fixed (void* p = zeros) + { + _gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboOffset, vboSize, p); + } + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + + chunk.Slots[slotIndex] = default; + chunk.Occupied.Remove(slotIndex); + + if (chunk.Occupied.Count == 0) + { + // Chunk is empty -- dispose GPU resources. + chunk.Dispose(_gl); + _chunks.Remove(chunkId); + } + else + { + RebuildChunkEbo(chunk); + UpdateChunkBounds(chunk); + } + } + + /// + /// Draw all visible terrain chunks. One glDrawElements per non-empty chunk. + /// Frustum culling is performed at the chunk AABB level. + /// + public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null) + { + if (_chunks.Count == 0) + return; + + // Determine which chunk the never-cull landblock lives in. + ulong? neverCullChunkId = null; + if (neverCullLandblockId is not null && _landblockToChunk.TryGetValue(neverCullLandblockId.Value, out var ncId)) + neverCullChunkId = ncId; + + _shader.Use(); + _shader.SetMatrix4("uView", camera.View); + _shader.SetMatrix4("uProjection", camera.Projection); + + // Terrain atlas on unit 0, alpha atlas on unit 1. + _gl.ActiveTexture(TextureUnit.Texture0); + _gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlTexture); + _gl.ActiveTexture(TextureUnit.Texture1); + _gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlAlphaTexture); + + int terrainLoc = _gl.GetUniformLocation(_shader.Program, "uTerrain"); + if (terrainLoc >= 0) _gl.Uniform1(terrainLoc, 0); + int alphaLoc = _gl.GetUniformLocation(_shader.Program, "uAlpha"); + if (alphaLoc >= 0) _gl.Uniform1(alphaLoc, 1); + + foreach (var (chunkId, chunk) in _chunks) + { + if (chunk.IndexCount == 0) + continue; + + // Chunk-level frustum cull. + if (frustum is not null && chunkId != neverCullChunkId) + { + if (!FrustumCuller.IsAabbVisible(frustum.Value, chunk.AabbMin, chunk.AabbMax)) + continue; + } + + _gl.BindVertexArray(chunk.Vao); + _gl.DrawElements( + PrimitiveType.Triangles, + (uint)chunk.IndexCount, + DrawElementsType.UnsignedInt, + (void*)0); + } + + _gl.BindVertexArray(0); + } + + public void Dispose() + { + foreach (var chunk in _chunks.Values) + chunk.Dispose(_gl); + + _chunks.Clear(); + _landblockToChunk.Clear(); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private static ulong PackChunkId(int chunkX, int chunkY) + => ((ulong)(uint)chunkX << 32) | (uint)chunkY; + + /// + /// Allocate a new chunk with max-size VBO and empty EBO, plus a configured VAO. + /// + private ChunkData CreateChunk(int chunkX, int chunkY) + { + var chunk = new ChunkData + { + ChunkX = chunkX, + ChunkY = chunkY, + Vao = _gl.GenVertexArray(), + Vbo = _gl.GenBuffer(), + Ebo = _gl.GenBuffer(), + }; + + // Pre-allocate VBO to max size with DynamicDraw. + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo); + _gl.BufferData(BufferTargetARB.ArrayBuffer, MaxVboBytes, null, BufferUsageARB.DynamicDraw); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + + // Pre-allocate EBO (empty initially, will be rebuilt on first AddLandblock). + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo); + _gl.BufferData(BufferTargetARB.ElementArrayBuffer, MaxEboBytes, null, BufferUsageARB.DynamicDraw); + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0); + + // Configure VAO with the same attribute layout as the old TerrainRenderer. + ConfigureVao(chunk); + + return chunk; + } + + /// + /// Set up vertex attribute pointers on the chunk's VAO. Identical layout + /// to the old TerrainRenderer. + /// + private void ConfigureVao(ChunkData chunk) + { + _gl.BindVertexArray(chunk.Vao); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo); + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo); + + uint stride = (uint)VertexSize; + + // location 0: Position (12 bytes) + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0); + // location 1: Normal (12 bytes, offset 12) + _gl.EnableVertexAttribArray(1); + _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); + + // location 2..5: Data0..Data3 as uvec4 byte attributes (4 bytes each, offsets 24, 28, 32, 36). + nint dataOffset = 6 * sizeof(float); // 24 bytes + _gl.EnableVertexAttribArray(2); + _gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset); + _gl.EnableVertexAttribArray(3); + _gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4)); + _gl.EnableVertexAttribArray(4); + _gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8)); + _gl.EnableVertexAttribArray(5); + _gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12)); + + _gl.BindVertexArray(0); + } + + /// + /// Rebuild the EBO for a chunk, emitting rebased indices only for occupied + /// slots. Each slot's indices are offset by (slotIndex * VerticesPerLandblock) + /// so they point to the correct region of the VBO. + /// + private void RebuildChunkEbo(ChunkData chunk) + { + int totalIndices = chunk.Occupied.Count * IndicesPerLandblock; + var indices = new uint[totalIndices]; + + int writePos = 0; + foreach (var slotIndex in chunk.Occupied) + { + uint vertexBase = (uint)(slotIndex * VerticesPerLandblock); + for (uint i = 0; i < IndicesPerLandblock; i++) + indices[writePos++] = vertexBase + i; + } + + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo); + fixed (void* p = indices) + { + _gl.BufferSubData(BufferTargetARB.ElementArrayBuffer, 0, + (nuint)(totalIndices * sizeof(uint)), p); + } + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0); + + chunk.IndexCount = totalIndices; + } + + /// + /// Recompute the chunk's world-space AABB from all occupied landblock slots. + /// + private static void UpdateChunkBounds(ChunkData chunk) + { + float minX = float.MaxValue, minY = float.MaxValue, minZ = float.MaxValue; + float maxX = float.MinValue, maxY = float.MinValue, maxZ = float.MinValue; + + foreach (var slotIndex in chunk.Occupied) + { + var slot = chunk.Slots[slotIndex]; + float ox = slot.WorldOrigin.X; + float oy = slot.WorldOrigin.Y; + + if (ox < minX) minX = ox; + if (oy < minY) minY = oy; + if (slot.MinZ < minZ) minZ = slot.MinZ; + + float ex = ox + LandblockMesh.LandblockSize; + float ey = oy + LandblockMesh.LandblockSize; + if (ex > maxX) maxX = ex; + if (ey > maxY) maxY = ey; + if (slot.MaxZ > maxZ) maxZ = slot.MaxZ; + } + + if (minX == float.MaxValue) + { + chunk.AabbMin = Vector3.Zero; + chunk.AabbMax = Vector3.Zero; + } + else + { + chunk.AabbMin = new Vector3(minX, minY, minZ); + chunk.AabbMax = new Vector3(maxX, maxY, maxZ); + } + } + + // ------------------------------------------------------------------------- + // Inner types + // ------------------------------------------------------------------------- + + /// + /// Per-landblock slot tracking within a chunk's VBO. + /// + private struct LandblockSlot + { + public uint LandblockId; + public Vector3 WorldOrigin; + public float MinZ; + public float MaxZ; + } + + /// + /// GPU resources and metadata for a single 16x16 terrain chunk. + /// + private sealed class ChunkData + { + public int ChunkX; + public int ChunkY; + + // GPU handles. + public uint Vao; + public uint Vbo; + public uint Ebo; + + /// Per-slot landblock data. Indexed by (localX * 16 + localY). + public readonly LandblockSlot[] Slots = new LandblockSlot[SlotsPerChunk]; + + /// Set of occupied slot indices within this chunk. + public readonly HashSet Occupied = new(); + + /// Current number of valid indices in the EBO (set by RebuildChunkEbo). + public int IndexCount; + + /// World-space AABB for chunk-level frustum culling. + public Vector3 AabbMin; + public Vector3 AabbMax; + + public void Dispose(GL gl) + { + gl.DeleteVertexArray(Vao); + gl.DeleteBuffer(Vbo); + gl.DeleteBuffer(Ebo); + } + } +}