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); // Phase G: light direction + ambient + fog come from the shared // SceneLighting UBO (binding=1) uploaded by GameWindow once per // frame. Terrain bakes per-vertex AdjustPlanes lighting (r13 ยง7) // from the UBO's slot-0 sun + uCellAmbient, then the fragment // stage adds fog + lightning flash. No per-program uniforms here. // 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); } } }