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;
}
}