Replace 25 per-landblock VAOs with one shared buffer set. Vertex positions are now baked in world space during AddLandblock (worldOrigin added to each vertex), so uModel is eliminated from terrain.vert entirely. Buffer rebuild happens on the cold path (landblock load/unload) via RebuildGpuBuffers. Draw loop: bind VAO once, then one glDrawElements per visible landblock into its sub-range of the shared EBO — same frustum-cull logic, no VAO/VBO rebind overhead per landblock. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
247 lines
9.1 KiB
C#
247 lines
9.1 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Terrain;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
/// </summary>
|
|
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<uint, LandblockEntry> _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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Concatenate all loaded landblocks into a single VBO + EBO and upload.
|
|
/// Called on the cold path (landblock load / unload), not per frame.
|
|
/// </summary>
|
|
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<TerrainVertex>();
|
|
public uint[] Indices = Array.Empty<uint>();
|
|
public float MinZ;
|
|
public float MaxZ;
|
|
// Set by RebuildGpuBuffers:
|
|
public nint EboByteOffset;
|
|
public int IndexCount;
|
|
}
|
|
}
|