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