diff --git a/src/AcDream.App/Rendering/Shaders/terrain.vert b/src/AcDream.App/Rendering/Shaders/terrain.vert index de32ebd..b92986a 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain.vert +++ b/src/AcDream.App/Rendering/Shaders/terrain.vert @@ -6,7 +6,6 @@ layout(location = 3) in uvec4 aPacked1; // bytes: ovl1Tex, ovl1Alpha, ovl2Tex, layout(location = 4) in uvec4 aPacked2; // bytes: road0Tex, road0Alpha, road1Tex, road1Alpha layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see below) -uniform mat4 uModel; uniform mat4 uView; uniform mat4 uProjection; @@ -89,7 +88,8 @@ void main() { else baseUV = vec2(0.0, 0.0); vBaseUV = baseUV; - vWorldNormal = normalize(mat3(uModel) * aNormal); + // Vertices are baked in world space; normals need no model transform. + vWorldNormal = normalize(aNormal); float baseTex = float(aPacked0.x); if (baseTex >= 254.0) baseTex = -1.0; @@ -101,5 +101,5 @@ void main() { vRoad0 = unpackOverlayLayer(aPacked2.x, aPacked2.y, rotRd0, baseUV); vRoad1 = unpackOverlayLayer(aPacked2.z, aPacked2.w, rotRd1, baseUV); - gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0); + gl_Position = uProjection * uView * vec4(aPos, 1.0); } diff --git a/src/AcDream.App/Rendering/TerrainRenderer.cs b/src/AcDream.App/Rendering/TerrainRenderer.cs index 2705a60..15bee67 100644 --- a/src/AcDream.App/Rendering/TerrainRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainRenderer.cs @@ -5,12 +5,13 @@ using Silk.NET.OpenGL; namespace AcDream.App.Rendering; /// -/// Draws the Phase 3c per-cell terrain mesh. Each landblock owns its own -/// VBO+EBO+VAO (no chunking yet — deferred to a hypothetical Phase 3d) and -/// gets drawn with a single DrawElements call per landblock. +/// 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) +/// 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) @@ -22,58 +23,131 @@ public sealed unsafe class TerrainRenderer : IDisposable private readonly GL _gl; private readonly Shader _shader; private readonly TerrainAtlas _atlas; - private readonly Dictionary _landblocks = new(); + + // 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 (_landblocks.TryGetValue(landblockId, out var existing)) - { - _gl.DeleteBuffer(existing.Vbo); - _gl.DeleteBuffer(existing.Ebo); - _gl.DeleteVertexArray(existing.Vao); - _landblocks.Remove(landblockId); - } + 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; - foreach (var v in meshData.Vertices) + for (int i = 0; i < meshData.Vertices.Length; i++) { - float z = v.Position.Z; - if (z < zMin) zMin = z; - if (z > zMax) zMax = z; + 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; } - // Fall back to zero if no vertices (shouldn't happen in practice). if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; } - var gpu = new LandblockGpu + _entries[landblockId] = new LandblockEntry { LandblockId = landblockId, - Vao = _gl.GenVertexArray(), WorldOrigin = worldOrigin, - IndexCount = meshData.Indices.Length, - MinZ = zMin, - MaxZ = zMax, + Vertices = worldVerts, + Indices = meshData.Indices, // local 0..N-1; will be rebased on rebuild + MinZ = zMin, + MaxZ = zMax, }; - _gl.BindVertexArray(gpu.Vao); + _gpuDirty = true; + } - gpu.Vbo = _gl.GenBuffer(); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, gpu.Vbo); - fixed (void* p = meshData.Vertices) - _gl.BufferData(BufferTargetARB.ArrayBuffer, - (nuint)(meshData.Vertices.Length * sizeof(TerrainVertex)), p, BufferUsageARB.StaticDraw); + public void RemoveLandblock(uint landblockId) + { + if (_entries.Remove(landblockId)) + _gpuDirty = true; + } - gpu.Ebo = _gl.GenBuffer(); - _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, gpu.Ebo); - fixed (void* p = meshData.Indices) - _gl.BufferData(BufferTargetARB.ElementArrayBuffer, - (nuint)(meshData.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw); + 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); @@ -85,7 +159,7 @@ public sealed unsafe class TerrainRenderer : IDisposable _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). The shader reads .x/.y/.z/.w as 8-bit fields. + // offsets 24, 28, 32, 36). nint dataOffset = 6 * sizeof(float); // 24 bytes _gl.EnableVertexAttribArray(2); _gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset); @@ -97,80 +171,77 @@ public sealed unsafe class TerrainRenderer : IDisposable _gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12)); _gl.BindVertexArray(0); - _landblocks[landblockId] = gpu; } /// - /// Release GPU buffers for a previously-added landblock. No-op if the - /// landblock wasn't added. Called by the streaming system when a - /// landblock falls outside the visible window. + /// Concatenate all loaded landblocks into a single VBO + EBO and upload. + /// Called on the cold path (landblock load / unload), not per frame. /// - public void RemoveLandblock(uint landblockId) + private void RebuildGpuBuffers() { - if (!_landblocks.TryGetValue(landblockId, out var gpu)) - return; - - _gl.DeleteBuffer(gpu.Vbo); - _gl.DeleteBuffer(gpu.Ebo); - _gl.DeleteVertexArray(gpu.Vao); - _landblocks.Remove(landblockId); - } - - public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null) - { - _shader.Use(); - _shader.SetMatrix4("uView", camera.View); - _shader.SetMatrix4("uProjection", camera.Projection); - - // Bind terrain atlas on unit 0 and 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 lb in _landblocks.Values) + // Measure totals. + int totalVerts = 0; + int totalIndices = 0; + foreach (var e in _entries.Values) { - if (frustum is not null && lb.LandblockId != neverCullLandblockId) - { - var aabbMin = new Vector3(lb.WorldOrigin.X, lb.WorldOrigin.Y, lb.MinZ); - var aabbMax = new Vector3(lb.WorldOrigin.X + 192f, lb.WorldOrigin.Y + 192f, lb.MaxZ); - if (!FrustumCuller.IsAabbVisible(frustum.Value, aabbMin, aabbMax)) - continue; - } - - var model = Matrix4x4.CreateTranslation(lb.WorldOrigin); - _shader.SetMatrix4("uModel", model); - _gl.BindVertexArray(lb.Vao); - _gl.DrawElements(PrimitiveType.Triangles, (uint)lb.IndexCount, DrawElementsType.UnsignedInt, (void*)0); + 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; } - public void Dispose() - { - foreach (var lb in _landblocks.Values) - { - _gl.DeleteBuffer(lb.Vbo); - _gl.DeleteBuffer(lb.Ebo); - _gl.DeleteVertexArray(lb.Vao); - } - _landblocks.Clear(); - } + // ------------------------------------------------------------------------- + // Data types + // ------------------------------------------------------------------------- - private sealed class LandblockGpu + private sealed class LandblockEntry { - public uint LandblockId; - public uint Vao; - public uint Vbo; - public uint Ebo; - public int IndexCount; - public Vector3 WorldOrigin; - public float MinZ; - public float MaxZ; + 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; } }