using System.Numerics; using AcDream.Core.Terrain; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; /// /// Draws the Phase 3c per-cell terrain mesh. All loaded landblocks share a /// single VBO + EBO + VAO. Vertex positions are baked in world space so no /// uModel uniform is needed. The VAO is bound once per frame; each visible /// landblock gets one glDrawElements call into its sub-range of the shared EBO. /// /// Attribute layout (see TerrainVertex for the byte layout): /// 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 TerrainRenderer : IDisposable { private readonly GL _gl; private readonly Shader _shader; private readonly TerrainAtlas _atlas; // Logical per-landblock data (CPU side). private readonly Dictionary _entries = new(); // Shared GPU buffers — rebuilt whenever a landblock is added or removed. private uint _vao; private uint _vbo; private uint _ebo; private bool _gpuDirty = true; // true = buffers need rebuilding before next Draw public TerrainRenderer(GL gl, Shader shader, TerrainAtlas atlas) { _gl = gl; _shader = shader; _atlas = atlas; _vao = _gl.GenVertexArray(); _vbo = _gl.GenBuffer(); _ebo = _gl.GenBuffer(); ConfigureVao(); } public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin) { if (_entries.ContainsKey(landblockId)) _entries.Remove(landblockId); // Bake world-space positions: offset every vertex by worldOrigin. 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; } _entries[landblockId] = new LandblockEntry { LandblockId = landblockId, WorldOrigin = worldOrigin, Vertices = worldVerts, Indices = meshData.Indices, // local 0..N-1; will be rebased on rebuild MinZ = zMin, MaxZ = zMax, }; _gpuDirty = true; } public void RemoveLandblock(uint landblockId) { if (_entries.Remove(landblockId)) _gpuDirty = true; } public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null) { if (_entries.Count == 0) return; if (_gpuDirty) RebuildGpuBuffers(); _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); // Bind the shared VAO once for the entire frame. _gl.BindVertexArray(_vao); foreach (var entry in _entries.Values) { // Per-landblock frustum cull using world-space AABB. if (frustum is not null && entry.LandblockId != neverCullLandblockId) { var aabbMin = new Vector3(entry.WorldOrigin.X, entry.WorldOrigin.Y, entry.MinZ); var aabbMax = new Vector3(entry.WorldOrigin.X + 192f, entry.WorldOrigin.Y + 192f, entry.MaxZ); if (!FrustumCuller.IsAabbVisible(frustum.Value, aabbMin, aabbMax)) continue; } // Draw only this landblock's sub-range in the shared EBO. // EboOffset is in bytes (uint = 4 bytes). _gl.DrawElements( PrimitiveType.Triangles, (uint)entry.IndexCount, DrawElementsType.UnsignedInt, (void*)(entry.EboByteOffset)); } _gl.BindVertexArray(0); } public void Dispose() { _gl.DeleteVertexArray(_vao); _gl.DeleteBuffer(_vbo); _gl.DeleteBuffer(_ebo); _entries.Clear(); } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- private void ConfigureVao() { _gl.BindVertexArray(_vao); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ebo); uint stride = (uint)sizeof(TerrainVertex); // 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); } /// /// Concatenate all loaded landblocks into a single VBO + EBO and upload. /// Called on the cold path (landblock load / unload), not per frame. /// private void RebuildGpuBuffers() { // Measure totals. int totalVerts = 0; int totalIndices = 0; foreach (var e in _entries.Values) { totalVerts += e.Vertices.Length; totalIndices += e.Indices.Length; } var allVerts = new TerrainVertex[totalVerts]; var allIndices = new uint[totalIndices]; int vertBase = 0; int indexBase = 0; foreach (var entry in _entries.Values) { // Copy world-space vertices. entry.Vertices.CopyTo(allVerts, vertBase); // Rebase local indices (0..N-1) → absolute (vertBase..vertBase+N-1). for (int i = 0; i < entry.Indices.Length; i++) allIndices[indexBase + i] = (uint)(vertBase + entry.Indices[i]); // Record where this landblock's indices live in the EBO (byte offset). entry.EboByteOffset = (nint)(indexBase * sizeof(uint)); entry.IndexCount = entry.Indices.Length; vertBase += entry.Vertices.Length; indexBase += entry.Indices.Length; } // Upload to GPU. _gl.BindVertexArray(_vao); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); fixed (void* p = allVerts) _gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)(totalVerts * sizeof(TerrainVertex)), p, BufferUsageARB.DynamicDraw); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ebo); fixed (void* p = allIndices) _gl.BufferData(BufferTargetARB.ElementArrayBuffer, (nuint)(totalIndices * sizeof(uint)), p, BufferUsageARB.DynamicDraw); _gl.BindVertexArray(0); _gpuDirty = false; } // ------------------------------------------------------------------------- // Data types // ------------------------------------------------------------------------- private sealed class LandblockEntry { public uint LandblockId; public Vector3 WorldOrigin; public TerrainVertex[] Vertices = Array.Empty(); public uint[] Indices = Array.Empty(); public float MinZ; public float MaxZ; // Set by RebuildGpuBuffers: public nint EboByteOffset; public int IndexCount; } }