Wire the existing LightManager + WorldTimeService state into visible
rendering. Every draw call (terrain, static mesh, instanced mesh, sky)
now shares one SceneLighting UBO at binding=1 carrying:
- 8 Light slots (Directional / Point / Spot, retail hard-cutoff)
- Ambient RGB + active light count
- Fog start/end/mode + color + lightning flash scalar
- Camera world position + day fraction
The CPU side (SceneLightingUbo in Core.Lighting) is a POD struct that
gets BufferSubData'd once per frame from GameWindow.OnRender. Shaders
read the block via `layout(std140, binding = 1) uniform SceneLighting`
— no per-program uniform uploads.
Shader changes:
- mesh.frag + mesh_instanced.frag accumulate 8 dynamic lights per
fragment using the retail no-attenuation hard-cutoff model
(r13 §10.2 / §13.1). Sun reads slot 0; spots use hard cos-cone test.
Additive lightning flash + linear fog layered on top. Saturate
clamps per-channel to 1.0.
- terrain.vert bakes AdjustPlanes sun+ambient per vertex using the
retail MIN_FACTOR = 0.08 ambient floor (r13 §7). terrain.frag adds
fog + flash on top of the baked vertex color.
- mesh.vert + mesh_instanced.vert emit vWorldPos so the fragment
stage can do per-pixel lighting against world-space positions.
- New sky.vert / sky.frag pair — unlit, scroll-UV, camera-centered,
with its own 0.1..1e6 far plane. Ports WorldBuilder's skybox.
SkyRenderer (new file in App/Rendering/Sky/) ports WorldBuilder's
SkyboxRenderManager verbatim for the C# idiom: zeroed view translation,
dedicated projection, depth mask off, iterate each visible SkyObject
in the day group, apply arc transform (Z rot for heading + Y rot for
arc sweep), feed TexVelocityX/Y as a scrolling UV offset, apply
per-keyframe SkyObjectReplace overrides (mesh swap + transparency +
luminosity) for overcast / dusk cloud variants.
GameWindow integration:
- OnLoad parses Region (0x13000000) into LoadedSkyDesc and hot-swaps
WorldTime's provider to the dat-accurate keyframes. Seeds to noon
for offline rendering. Creates the SceneLightingUboBinding and the
SkyRenderer.
- OnRender: set clear color from atmosphere fog, tick WeatherSystem,
spawn/stop rain/snow camera-local emitters on kind change, feed
sun to LightManager (zero intensity indoors — r13 §13.7), tick
LightManager against viewer pos, build + upload the UBO, draw
sky before terrain, draw terrain + static + instanced using the
shared UBO.
5 new UBO packing tests (struct sizes, slot population, 8-light cap,
directional slot 0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
454 lines
17 KiB
C#
454 lines
17 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Terrain;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// Chunk-based terrain renderer matching ACME's architecture. Each 16x16
|
|
/// landblock region gets its own VAO/VBO/EBO with pre-allocated max-size
|
|
/// buffers. Landblocks are added/removed incrementally via glBufferSubData
|
|
/// instead of rebuilding the entire buffer.
|
|
///
|
|
/// Attribute layout (same as TerrainRenderer, see TerrainVertex):
|
|
/// 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 TerrainChunkRenderer : IDisposable
|
|
{
|
|
// -------------------------------------------------------------------------
|
|
// Constants
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>Number of landblocks per chunk dimension (matching ACME).</summary>
|
|
public const int ChunkSizeInLandblocks = 16;
|
|
|
|
/// <summary>Max landblock slots per chunk (16x16 = 256).</summary>
|
|
public const int SlotsPerChunk = ChunkSizeInLandblocks * ChunkSizeInLandblocks;
|
|
|
|
/// <summary>Vertices per landblock: 64 cells x 6 verts = 384.</summary>
|
|
public const int VerticesPerLandblock = LandblockMesh.VerticesPerLandblock;
|
|
|
|
/// <summary>Indices per landblock (trivial 0..383, same count as vertices).</summary>
|
|
public const int IndicesPerLandblock = VerticesPerLandblock;
|
|
|
|
/// <summary>Byte size of one TerrainVertex (40 bytes).</summary>
|
|
private static readonly int VertexSize = sizeof(TerrainVertex);
|
|
|
|
/// <summary>Max VBO size per chunk: 256 slots x 384 verts x 40 bytes = ~3.75 MB.</summary>
|
|
private static readonly nuint MaxVboBytes =
|
|
(nuint)(SlotsPerChunk * VerticesPerLandblock * VertexSize);
|
|
|
|
/// <summary>Max EBO size per chunk: 256 slots x 384 indices x 4 bytes = ~393 KB.</summary>
|
|
private static readonly nuint MaxEboBytes =
|
|
(nuint)(SlotsPerChunk * IndicesPerLandblock * sizeof(uint));
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Fields
|
|
// -------------------------------------------------------------------------
|
|
|
|
private readonly GL _gl;
|
|
private readonly Shader _shader;
|
|
private readonly TerrainAtlas _atlas;
|
|
|
|
/// <summary>Active chunks keyed by (chunkX, chunkY) packed into a ulong.</summary>
|
|
private readonly Dictionary<ulong, ChunkData> _chunks = new();
|
|
|
|
/// <summary>Reverse map: landblockId -> chunkId, for fast RemoveLandblock.</summary>
|
|
private readonly Dictionary<uint, ulong> _landblockToChunk = new();
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Construction
|
|
// -------------------------------------------------------------------------
|
|
|
|
public TerrainChunkRenderer(GL gl, Shader shader, TerrainAtlas atlas)
|
|
{
|
|
_gl = gl;
|
|
_shader = shader;
|
|
_atlas = atlas;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Public API
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Add (or replace) a landblock's terrain mesh. Vertices are baked to world
|
|
/// space using <paramref name="worldOrigin"/>, then uploaded to the correct
|
|
/// chunk buffer slot via glBufferSubData.
|
|
/// </summary>
|
|
public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
|
|
{
|
|
// If this landblock already exists, remove it first.
|
|
if (_landblockToChunk.ContainsKey(landblockId))
|
|
RemoveLandblock(landblockId);
|
|
|
|
// Determine chunk coordinates and slot index.
|
|
// Landblock ID format: 0xXXYYnnnn (X at bits 24-31, Y at bits 16-23).
|
|
int lbX = (int)(landblockId >> 24) & 0xFF;
|
|
int lbY = (int)(landblockId >> 16) & 0xFF;
|
|
int chunkX = lbX / ChunkSizeInLandblocks;
|
|
int chunkY = lbY / ChunkSizeInLandblocks;
|
|
ulong chunkId = PackChunkId(chunkX, chunkY);
|
|
|
|
int localX = lbX % ChunkSizeInLandblocks;
|
|
int localY = lbY % ChunkSizeInLandblocks;
|
|
int slotIndex = localX * ChunkSizeInLandblocks + localY;
|
|
|
|
// Create chunk on demand.
|
|
if (!_chunks.TryGetValue(chunkId, out var chunk))
|
|
{
|
|
chunk = CreateChunk(chunkX, chunkY);
|
|
_chunks[chunkId] = chunk;
|
|
}
|
|
|
|
// Bake world-space vertices.
|
|
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; }
|
|
|
|
// Upload vertices into the slot's region of the VBO.
|
|
nint vboOffset = (nint)(slotIndex * VerticesPerLandblock * VertexSize);
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
|
|
fixed (void* p = worldVerts)
|
|
{
|
|
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboOffset,
|
|
(nuint)(worldVerts.Length * VertexSize), p);
|
|
}
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
|
|
|
// Track the slot.
|
|
chunk.Slots[slotIndex] = new LandblockSlot
|
|
{
|
|
LandblockId = landblockId,
|
|
WorldOrigin = worldOrigin,
|
|
MinZ = zMin,
|
|
MaxZ = zMax,
|
|
};
|
|
chunk.Occupied.Add(slotIndex);
|
|
_landblockToChunk[landblockId] = chunkId;
|
|
|
|
// Rebuild the EBO for this chunk (only includes occupied slots).
|
|
RebuildChunkEbo(chunk);
|
|
|
|
// Update chunk AABB.
|
|
UpdateChunkBounds(chunk);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove a landblock from its chunk. If the chunk becomes empty, dispose it.
|
|
/// </summary>
|
|
public void RemoveLandblock(uint landblockId)
|
|
{
|
|
if (!_landblockToChunk.TryGetValue(landblockId, out var chunkId))
|
|
return;
|
|
|
|
_landblockToChunk.Remove(landblockId);
|
|
|
|
if (!_chunks.TryGetValue(chunkId, out var chunk))
|
|
return;
|
|
|
|
// Find which slot this landblock occupies.
|
|
int slotIndex = -1;
|
|
foreach (var s in chunk.Occupied)
|
|
{
|
|
if (chunk.Slots[s].LandblockId == landblockId)
|
|
{
|
|
slotIndex = s;
|
|
break;
|
|
}
|
|
}
|
|
if (slotIndex < 0)
|
|
return;
|
|
|
|
// Zero out the VBO region for this slot (optional but clean).
|
|
nint vboOffset = (nint)(slotIndex * VerticesPerLandblock * VertexSize);
|
|
nuint vboSize = (nuint)(VerticesPerLandblock * VertexSize);
|
|
var zeros = new byte[VerticesPerLandblock * VertexSize];
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
|
|
fixed (void* p = zeros)
|
|
{
|
|
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboOffset, vboSize, p);
|
|
}
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
|
|
|
chunk.Slots[slotIndex] = default;
|
|
chunk.Occupied.Remove(slotIndex);
|
|
|
|
if (chunk.Occupied.Count == 0)
|
|
{
|
|
// Chunk is empty -- dispose GPU resources.
|
|
chunk.Dispose(_gl);
|
|
_chunks.Remove(chunkId);
|
|
}
|
|
else
|
|
{
|
|
RebuildChunkEbo(chunk);
|
|
UpdateChunkBounds(chunk);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Draw all visible terrain chunks. One glDrawElements per non-empty chunk.
|
|
/// Frustum culling is performed at the chunk AABB level.
|
|
/// </summary>
|
|
public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null)
|
|
{
|
|
if (_chunks.Count == 0)
|
|
return;
|
|
|
|
// Determine which chunk the never-cull landblock lives in.
|
|
ulong? neverCullChunkId = null;
|
|
if (neverCullLandblockId is not null && _landblockToChunk.TryGetValue(neverCullLandblockId.Value, out var ncId))
|
|
neverCullChunkId = ncId;
|
|
|
|
_shader.Use();
|
|
_shader.SetMatrix4("uView", camera.View);
|
|
_shader.SetMatrix4("uProjection", camera.Projection);
|
|
|
|
// Phase G: light direction + ambient + fog come from the shared
|
|
// SceneLighting UBO (binding=1) uploaded by GameWindow once per
|
|
// frame. Terrain bakes per-vertex AdjustPlanes lighting (r13 §7)
|
|
// from the UBO's slot-0 sun + uCellAmbient, then the fragment
|
|
// stage adds fog + lightning flash. No per-program uniforms here.
|
|
|
|
// 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);
|
|
|
|
foreach (var (chunkId, chunk) in _chunks)
|
|
{
|
|
if (chunk.IndexCount == 0)
|
|
continue;
|
|
|
|
// Chunk-level frustum cull.
|
|
if (frustum is not null && chunkId != neverCullChunkId)
|
|
{
|
|
if (!FrustumCuller.IsAabbVisible(frustum.Value, chunk.AabbMin, chunk.AabbMax))
|
|
continue;
|
|
}
|
|
|
|
_gl.BindVertexArray(chunk.Vao);
|
|
_gl.DrawElements(
|
|
PrimitiveType.Triangles,
|
|
(uint)chunk.IndexCount,
|
|
DrawElementsType.UnsignedInt,
|
|
(void*)0);
|
|
}
|
|
|
|
_gl.BindVertexArray(0);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var chunk in _chunks.Values)
|
|
chunk.Dispose(_gl);
|
|
|
|
_chunks.Clear();
|
|
_landblockToChunk.Clear();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Private helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
private static ulong PackChunkId(int chunkX, int chunkY)
|
|
=> ((ulong)(uint)chunkX << 32) | (uint)chunkY;
|
|
|
|
/// <summary>
|
|
/// Allocate a new chunk with max-size VBO and empty EBO, plus a configured VAO.
|
|
/// </summary>
|
|
private ChunkData CreateChunk(int chunkX, int chunkY)
|
|
{
|
|
var chunk = new ChunkData
|
|
{
|
|
ChunkX = chunkX,
|
|
ChunkY = chunkY,
|
|
Vao = _gl.GenVertexArray(),
|
|
Vbo = _gl.GenBuffer(),
|
|
Ebo = _gl.GenBuffer(),
|
|
};
|
|
|
|
// Pre-allocate VBO to max size with DynamicDraw.
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
|
|
_gl.BufferData(BufferTargetARB.ArrayBuffer, MaxVboBytes, null, BufferUsageARB.DynamicDraw);
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
|
|
|
// Pre-allocate EBO (empty initially, will be rebuilt on first AddLandblock).
|
|
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo);
|
|
_gl.BufferData(BufferTargetARB.ElementArrayBuffer, MaxEboBytes, null, BufferUsageARB.DynamicDraw);
|
|
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
|
|
|
|
// Configure VAO with the same attribute layout as the old TerrainRenderer.
|
|
ConfigureVao(chunk);
|
|
|
|
return chunk;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set up vertex attribute pointers on the chunk's VAO. Identical layout
|
|
/// to the old TerrainRenderer.
|
|
/// </summary>
|
|
private void ConfigureVao(ChunkData chunk)
|
|
{
|
|
_gl.BindVertexArray(chunk.Vao);
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
|
|
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo);
|
|
|
|
uint stride = (uint)VertexSize;
|
|
|
|
// 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>
|
|
/// Rebuild the EBO for a chunk, emitting rebased indices only for occupied
|
|
/// slots. Each slot's indices are offset by (slotIndex * VerticesPerLandblock)
|
|
/// so they point to the correct region of the VBO.
|
|
/// </summary>
|
|
private void RebuildChunkEbo(ChunkData chunk)
|
|
{
|
|
int totalIndices = chunk.Occupied.Count * IndicesPerLandblock;
|
|
var indices = new uint[totalIndices];
|
|
|
|
int writePos = 0;
|
|
foreach (var slotIndex in chunk.Occupied)
|
|
{
|
|
uint vertexBase = (uint)(slotIndex * VerticesPerLandblock);
|
|
for (uint i = 0; i < IndicesPerLandblock; i++)
|
|
indices[writePos++] = vertexBase + i;
|
|
}
|
|
|
|
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo);
|
|
fixed (void* p = indices)
|
|
{
|
|
_gl.BufferSubData(BufferTargetARB.ElementArrayBuffer, 0,
|
|
(nuint)(totalIndices * sizeof(uint)), p);
|
|
}
|
|
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
|
|
|
|
chunk.IndexCount = totalIndices;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recompute the chunk's world-space AABB from all occupied landblock slots.
|
|
/// </summary>
|
|
private static void UpdateChunkBounds(ChunkData chunk)
|
|
{
|
|
float minX = float.MaxValue, minY = float.MaxValue, minZ = float.MaxValue;
|
|
float maxX = float.MinValue, maxY = float.MinValue, maxZ = float.MinValue;
|
|
|
|
foreach (var slotIndex in chunk.Occupied)
|
|
{
|
|
var slot = chunk.Slots[slotIndex];
|
|
float ox = slot.WorldOrigin.X;
|
|
float oy = slot.WorldOrigin.Y;
|
|
|
|
if (ox < minX) minX = ox;
|
|
if (oy < minY) minY = oy;
|
|
if (slot.MinZ < minZ) minZ = slot.MinZ;
|
|
|
|
float ex = ox + LandblockMesh.LandblockSize;
|
|
float ey = oy + LandblockMesh.LandblockSize;
|
|
if (ex > maxX) maxX = ex;
|
|
if (ey > maxY) maxY = ey;
|
|
if (slot.MaxZ > maxZ) maxZ = slot.MaxZ;
|
|
}
|
|
|
|
if (minX == float.MaxValue)
|
|
{
|
|
chunk.AabbMin = Vector3.Zero;
|
|
chunk.AabbMax = Vector3.Zero;
|
|
}
|
|
else
|
|
{
|
|
chunk.AabbMin = new Vector3(minX, minY, minZ);
|
|
chunk.AabbMax = new Vector3(maxX, maxY, maxZ);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Inner types
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Per-landblock slot tracking within a chunk's VBO.
|
|
/// </summary>
|
|
private struct LandblockSlot
|
|
{
|
|
public uint LandblockId;
|
|
public Vector3 WorldOrigin;
|
|
public float MinZ;
|
|
public float MaxZ;
|
|
}
|
|
|
|
/// <summary>
|
|
/// GPU resources and metadata for a single 16x16 terrain chunk.
|
|
/// </summary>
|
|
private sealed class ChunkData
|
|
{
|
|
public int ChunkX;
|
|
public int ChunkY;
|
|
|
|
// GPU handles.
|
|
public uint Vao;
|
|
public uint Vbo;
|
|
public uint Ebo;
|
|
|
|
/// <summary>Per-slot landblock data. Indexed by (localX * 16 + localY).</summary>
|
|
public readonly LandblockSlot[] Slots = new LandblockSlot[SlotsPerChunk];
|
|
|
|
/// <summary>Set of occupied slot indices within this chunk.</summary>
|
|
public readonly HashSet<int> Occupied = new();
|
|
|
|
/// <summary>Current number of valid indices in the EBO (set by RebuildChunkEbo).</summary>
|
|
public int IndexCount;
|
|
|
|
/// <summary>World-space AABB for chunk-level frustum culling.</summary>
|
|
public Vector3 AabbMin;
|
|
public Vector3 AabbMax;
|
|
|
|
public void Dispose(GL gl)
|
|
{
|
|
gl.DeleteVertexArray(Vao);
|
|
gl.DeleteBuffer(Vbo);
|
|
gl.DeleteBuffer(Ebo);
|
|
}
|
|
}
|
|
}
|