perf(terrain): single shared VAO/VBO/EBO for all landblocks
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>
This commit is contained in:
parent
787e0f0aff
commit
d35e4b6de7
2 changed files with 171 additions and 100 deletions
|
|
@ -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 = 4) in uvec4 aPacked2; // bytes: road0Tex, road0Alpha, road1Tex, road1Alpha
|
||||||
layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see below)
|
layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see below)
|
||||||
|
|
||||||
uniform mat4 uModel;
|
|
||||||
uniform mat4 uView;
|
uniform mat4 uView;
|
||||||
uniform mat4 uProjection;
|
uniform mat4 uProjection;
|
||||||
|
|
||||||
|
|
@ -89,7 +88,8 @@ void main() {
|
||||||
else baseUV = vec2(0.0, 0.0);
|
else baseUV = vec2(0.0, 0.0);
|
||||||
|
|
||||||
vBaseUV = baseUV;
|
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);
|
float baseTex = float(aPacked0.x);
|
||||||
if (baseTex >= 254.0) baseTex = -1.0;
|
if (baseTex >= 254.0) baseTex = -1.0;
|
||||||
|
|
@ -101,5 +101,5 @@ void main() {
|
||||||
vRoad0 = unpackOverlayLayer(aPacked2.x, aPacked2.y, rotRd0, baseUV);
|
vRoad0 = unpackOverlayLayer(aPacked2.x, aPacked2.y, rotRd0, baseUV);
|
||||||
vRoad1 = unpackOverlayLayer(aPacked2.z, aPacked2.w, rotRd1, 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,13 @@ using Silk.NET.OpenGL;
|
||||||
namespace AcDream.App.Rendering;
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Draws the Phase 3c per-cell terrain mesh. Each landblock owns its own
|
/// Draws the Phase 3c per-cell terrain mesh. All loaded landblocks share a
|
||||||
/// VBO+EBO+VAO (no chunking yet — deferred to a hypothetical Phase 3d) and
|
/// single VBO + EBO + VAO. Vertex positions are baked in world space so no
|
||||||
/// gets drawn with a single DrawElements call per landblock.
|
/// 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):
|
/// 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 1: vec3 aNormal (3 floats)
|
||||||
/// location 2: uvec4 aPacked0 (4 bytes, Data0)
|
/// location 2: uvec4 aPacked0 (4 bytes, Data0)
|
||||||
/// location 3: uvec4 aPacked1 (4 bytes, Data1)
|
/// location 3: uvec4 aPacked1 (4 bytes, Data1)
|
||||||
|
|
@ -22,58 +23,131 @@ public sealed unsafe class TerrainRenderer : IDisposable
|
||||||
private readonly GL _gl;
|
private readonly GL _gl;
|
||||||
private readonly Shader _shader;
|
private readonly Shader _shader;
|
||||||
private readonly TerrainAtlas _atlas;
|
private readonly TerrainAtlas _atlas;
|
||||||
private readonly Dictionary<uint, LandblockGpu> _landblocks = new();
|
|
||||||
|
// 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)
|
public TerrainRenderer(GL gl, Shader shader, TerrainAtlas atlas)
|
||||||
{
|
{
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_shader = shader;
|
_shader = shader;
|
||||||
_atlas = atlas;
|
_atlas = atlas;
|
||||||
|
|
||||||
|
_vao = _gl.GenVertexArray();
|
||||||
|
_vbo = _gl.GenBuffer();
|
||||||
|
_ebo = _gl.GenBuffer();
|
||||||
|
ConfigureVao();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
|
public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
|
||||||
{
|
{
|
||||||
if (_landblocks.TryGetValue(landblockId, out var existing))
|
if (_entries.ContainsKey(landblockId))
|
||||||
{
|
_entries.Remove(landblockId);
|
||||||
_gl.DeleteBuffer(existing.Vbo);
|
|
||||||
_gl.DeleteBuffer(existing.Ebo);
|
|
||||||
_gl.DeleteVertexArray(existing.Vao);
|
|
||||||
_landblocks.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;
|
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;
|
var v = meshData.Vertices[i];
|
||||||
if (z < zMin) zMin = z;
|
var worldPos = v.Position + worldOrigin;
|
||||||
if (z > zMax) zMax = z;
|
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; }
|
if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
|
||||||
|
|
||||||
var gpu = new LandblockGpu
|
_entries[landblockId] = new LandblockEntry
|
||||||
{
|
{
|
||||||
LandblockId = landblockId,
|
LandblockId = landblockId,
|
||||||
Vao = _gl.GenVertexArray(),
|
|
||||||
WorldOrigin = worldOrigin,
|
WorldOrigin = worldOrigin,
|
||||||
IndexCount = meshData.Indices.Length,
|
Vertices = worldVerts,
|
||||||
MinZ = zMin,
|
Indices = meshData.Indices, // local 0..N-1; will be rebased on rebuild
|
||||||
MaxZ = zMax,
|
MinZ = zMin,
|
||||||
|
MaxZ = zMax,
|
||||||
};
|
};
|
||||||
|
|
||||||
_gl.BindVertexArray(gpu.Vao);
|
_gpuDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
gpu.Vbo = _gl.GenBuffer();
|
public void RemoveLandblock(uint landblockId)
|
||||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, gpu.Vbo);
|
{
|
||||||
fixed (void* p = meshData.Vertices)
|
if (_entries.Remove(landblockId))
|
||||||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
_gpuDirty = true;
|
||||||
(nuint)(meshData.Vertices.Length * sizeof(TerrainVertex)), p, BufferUsageARB.StaticDraw);
|
}
|
||||||
|
|
||||||
gpu.Ebo = _gl.GenBuffer();
|
public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null)
|
||||||
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, gpu.Ebo);
|
{
|
||||||
fixed (void* p = meshData.Indices)
|
if (_entries.Count == 0)
|
||||||
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
|
return;
|
||||||
(nuint)(meshData.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
|
|
||||||
|
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);
|
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)));
|
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
|
||||||
|
|
||||||
// location 2..5: Data0..Data3 as uvec4 byte attributes (4 bytes each,
|
// 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
|
nint dataOffset = 6 * sizeof(float); // 24 bytes
|
||||||
_gl.EnableVertexAttribArray(2);
|
_gl.EnableVertexAttribArray(2);
|
||||||
_gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset);
|
_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.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
|
||||||
|
|
||||||
_gl.BindVertexArray(0);
|
_gl.BindVertexArray(0);
|
||||||
_landblocks[landblockId] = gpu;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Release GPU buffers for a previously-added landblock. No-op if the
|
/// Concatenate all loaded landblocks into a single VBO + EBO and upload.
|
||||||
/// landblock wasn't added. Called by the streaming system when a
|
/// Called on the cold path (landblock load / unload), not per frame.
|
||||||
/// landblock falls outside the visible window.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void RemoveLandblock(uint landblockId)
|
private void RebuildGpuBuffers()
|
||||||
{
|
{
|
||||||
if (!_landblocks.TryGetValue(landblockId, out var gpu))
|
// Measure totals.
|
||||||
return;
|
int totalVerts = 0;
|
||||||
|
int totalIndices = 0;
|
||||||
_gl.DeleteBuffer(gpu.Vbo);
|
foreach (var e in _entries.Values)
|
||||||
_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)
|
|
||||||
{
|
{
|
||||||
if (frustum is not null && lb.LandblockId != neverCullLandblockId)
|
totalVerts += e.Vertices.Length;
|
||||||
{
|
totalIndices += e.Indices.Length;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
_gl.BindVertexArray(0);
|
||||||
|
_gpuDirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
// -------------------------------------------------------------------------
|
||||||
{
|
// Data types
|
||||||
foreach (var lb in _landblocks.Values)
|
// -------------------------------------------------------------------------
|
||||||
{
|
|
||||||
_gl.DeleteBuffer(lb.Vbo);
|
|
||||||
_gl.DeleteBuffer(lb.Ebo);
|
|
||||||
_gl.DeleteVertexArray(lb.Vao);
|
|
||||||
}
|
|
||||||
_landblocks.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class LandblockGpu
|
private sealed class LandblockEntry
|
||||||
{
|
{
|
||||||
public uint LandblockId;
|
public uint LandblockId;
|
||||||
public uint Vao;
|
public Vector3 WorldOrigin;
|
||||||
public uint Vbo;
|
public TerrainVertex[] Vertices = Array.Empty<TerrainVertex>();
|
||||||
public uint Ebo;
|
public uint[] Indices = Array.Empty<uint>();
|
||||||
public int IndexCount;
|
public float MinZ;
|
||||||
public Vector3 WorldOrigin;
|
public float MaxZ;
|
||||||
public float MinZ;
|
// Set by RebuildGpuBuffers:
|
||||||
public float MaxZ;
|
public nint EboByteOffset;
|
||||||
|
public int IndexCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue