using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
///
/// Phase N.5b modern terrain dispatcher. Single global VBO/EBO with a slot
/// allocator (one slot per landblock, 384 verts × 40 bytes = 15,360 bytes
/// per slot). Per-frame: build a DrawElementsIndirectCommand array from
/// visible slots, upload, dispatch via glMultiDrawElementsIndirect. Atlas
/// textures bound via bindless handles set per-frame as sampler uniforms.
///
/// Total ~6-8 GL calls per frame for terrain regardless of visible
/// landblock count.
///
public sealed unsafe class TerrainModernRenderer : IDisposable
{
// VertsPerLandblock MUST stay divisible by 6 — terrain_modern.vert uses
// `gl_VertexID % 6` to pick the cell-corner index (BL/BR/TR/TL), and
// because we bake `slot * VertsPerLandblock` into indices CPU-side and
// pass BaseVertex=0 to MultiDrawElementsIndirect, gl_VertexID becomes
// `slot * VertsPerLandblock + local_index`. The shader's modulo-6 only
// reduces to `local_index % 6` because 384 is a multiple of 6. Changing
// either constant without auditing the shader will silently mis-render.
private const int VertsPerLandblock = LandblockMesh.VerticesPerLandblock; // 384 (= 64 cells * 6 verts)
private const int IndicesPerLandblock = VertsPerLandblock;
private const int VertexSize = 40; // sizeof(TerrainVertex)
private const int IndexSize = sizeof(uint);
private const float LandblockSize = LandblockMesh.LandblockSize; // 192
private readonly GL _gl;
private readonly BindlessSupport _bindless;
private readonly Shader _shader;
private readonly TerrainAtlas _atlas;
/// A.5 T22.5: exposes the terrain atlas so callers can update
/// anisotropic level mid-session via .
public TerrainAtlas Atlas => _atlas;
private readonly GpuRetiredTerrainSlotAllocator _alloc;
private readonly GpuRetirementLedger _retirementLedger;
private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposed;
// Per-slot live data (index by slot integer; null entries are unused slots).
private SlotData?[] _slots;
// Reverse map: landblockId -> slot, for RemoveLandblock and replacement.
private readonly Dictionary _idToSlot = new();
// GPU buffers.
private uint _globalVao;
private uint _globalVbo;
private uint _globalEbo;
private long _globalVboCapacityBytes;
private long _globalEboCapacityBytes;
private uint _indirectBuffer;
private int _indirectCapacity;
private sealed class DynamicIndirectBuffer
{
public uint Buffer;
public int Capacity;
}
private readonly List[] _indirectBuffersByFrame =
[[], [], []];
private int _dynamicFrameSlot;
private int _dynamicBufferCursor;
private bool _dynamicFrameStarted;
internal int DynamicIndirectBufferCount =>
_indirectBuffersByFrame.Sum(frameBuffers => frameBuffers.Count);
// Phase U.3: terrain clip UBO (binding=2, terrain_modern.vert TerrainClip).
// The shared one is created + uploaded by the GameWindow-level ClipFrame and
// handed in via SetClipUbo. When 0, we bind a lazily-created no-clip fallback
// (count 0 = ungated) so the shader never reads an unbound UBO at binding=2.
private TerrainClipBufferBinding _sharedClipBinding;
private uint _fallbackClipUbo;
// Cached uvec2-handle uniform locations (matrix uniforms are set by name via Shader.SetMatrix4).
private int _uTerrainHandleLoc;
private int _uAlphaHandleLoc;
private int _uTexTilingLoc;
private bool _textureTilingUploaded;
// Reusable per-frame buffers.
private readonly List _visibleSlots = new();
private readonly HashSet _visibleCellIds = new();
private DrawElementsIndirectCommand[] _deicScratch = Array.Empty();
// Diag.
public int LoadedSlots => _alloc.LoadedCount;
public int VisibleSlots => _visibleSlots.Count;
public int CapacitySlots => _alloc.Capacity;
///
/// Outdoor landcells admitted by the current landscape view. The set is
/// accumulated across doorway landscape slices and consumed after the
/// completed render frame by particle visibility.
///
internal HashSet VisibleCellIds => _visibleCellIds;
public void BeginVisibilityFrame() => _visibleCellIds.Clear();
public TerrainModernRenderer(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
int initialSlotCapacity = 64)
: this(
gl,
bindless,
shader,
atlas,
ImmediateGpuResourceRetirementQueue.Instance,
initialSlotCapacity)
{
}
internal TerrainModernRenderer(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
IGpuResourceRetirementQueue resourceRetirement,
int initialSlotCapacity = 64)
{
_gl = gl;
_bindless = bindless;
_shader = shader;
_atlas = atlas;
ArgumentNullException.ThrowIfNull(resourceRetirement);
_retirementLedger = new GpuRetirementLedger(resourceRetirement);
_alloc = new GpuRetiredTerrainSlotAllocator(initialSlotCapacity, resourceRetirement);
_slots = new SlotData?[initialSlotCapacity];
_uTerrainHandleLoc = _gl.GetUniformLocation(_shader.Program, "uTerrainHandle");
_uAlphaHandleLoc = _gl.GetUniformLocation(_shader.Program, "uAlphaHandle");
_uTexTilingLoc = _gl.GetUniformLocation(_shader.Program, "uTexTiling[0]");
if (_uTexTilingLoc < 0)
throw new InvalidOperationException("terrain_modern.frag is missing the required uTexTiling uniform.");
try
{
_globalVao = TrackedGlResource.CreateVertexArray(
_gl,
"creating terrain global VAO");
_globalVbo = TrackedGlResource.CreateBuffer(
_gl,
"creating terrain global vertex buffer");
_globalEbo = TrackedGlResource.CreateBuffer(
_gl,
"creating terrain global index buffer");
AllocateGpuBuffers(initialSlotCapacity);
ConfigureVao(_globalVao, _globalVbo, _globalEbo);
}
catch
{
TrackedGlResource.DeleteVertexArray(
_gl,
_globalVao,
"rolling back terrain global VAO");
TrackedGlResource.DeleteBuffer(
_gl,
_globalVbo,
_globalVboCapacityBytes,
"rolling back terrain global vertex buffer");
TrackedGlResource.DeleteBuffer(
_gl,
_globalEbo,
_globalEboCapacityBytes,
"rolling back terrain global index buffer");
_globalVao = 0;
_globalVbo = 0;
_globalEbo = 0;
_globalVboCapacityBytes = 0;
_globalEboCapacityBytes = 0;
throw;
}
}
///
/// Resets the indirect-command submission cursor for a GPU-fenced frame
/// slot. A retail outside view may draw terrain more than once in a frame;
/// each draw receives storage that cannot overwrite an earlier command.
///
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_indirectBuffersByFrame.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
_retirementLedger.RetryPendingPublications();
_dynamicFrameSlot = frameSlot;
_dynamicBufferCursor = 0;
_dynamicFrameStarted = true;
}
private void ActivateNextIndirectBuffer()
{
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
List frameBuffers = _indirectBuffersByFrame[_dynamicFrameSlot];
if (_dynamicBufferCursor == frameBuffers.Count)
{
uint buffer = TrackedGlResource.CreateBuffer(
_gl,
$"creating terrain indirect buffer for frame slot {_dynamicFrameSlot}");
try
{
frameBuffers.Add(new DynamicIndirectBuffer { Buffer = buffer });
}
catch
{
TrackedGlResource.DeleteBuffer(
_gl,
buffer,
0,
"rolling back terrain indirect buffer");
throw;
}
}
DynamicIndirectBuffer active = frameBuffers[_dynamicBufferCursor++];
_indirectBuffer = active.Buffer;
_indirectCapacity = active.Capacity;
}
private void PersistIndirectCapacity()
{
_indirectBuffersByFrame[_dynamicFrameSlot][_dynamicBufferCursor - 1].Capacity =
_indirectCapacity;
}
///
/// Hand the renderer the current aligned terrain-clip range (binding=2).
/// Each outside-view slice occupies a distinct range in the current
/// GPU-fenced frame's UBO arena.
///
public void SetClipUbo(TerrainClipBufferBinding sharedClipBinding) =>
_sharedClipBinding = sharedClipBinding;
///
/// Two-tier streaming entry point. Accepts a prebuilt mesh from
/// built on the worker
/// thread, together with the world-space origin computed by the caller
/// (render-thread GameWindow derives it from landblockId + liveCenterX/Y).
///
/// Delegates to
/// so both paths share one upload path. Per Phase A.5 spec T15.
///
public void AddLandblockWithMesh(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
=> AddLandblock(landblockId, meshData, worldOrigin);
public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
{
ArgumentNullException.ThrowIfNull(meshData);
if (meshData.Vertices.Length != VertsPerLandblock)
throw new ArgumentException(
$"Expected {VertsPerLandblock} vertices, got {meshData.Vertices.Length}",
nameof(meshData));
if (meshData.Indices.Length != IndicesPerLandblock)
throw new ArgumentException(
$"Expected {IndicesPerLandblock} indices, got {meshData.Indices.Length}",
nameof(meshData));
// A prior replacement may have committed the logical slot switch
// before queue publication failed. Retry those retained physical-slot
// transactions before allocating more terrain storage.
_alloc.RetryPendingPublications();
bool replacing = _idToSlot.TryGetValue(landblockId, out int replacedSlot);
int slot = _alloc.Allocate(out var needsGrow);
bool published = false;
try
{
if (needsGrow)
{
int newCap = Math.Max(_alloc.Capacity * 2, slot + 1);
EnsureCapacity(newCap);
}
// Bake worldOrigin into vertex positions; capture min/max Z for AABB.
var bakedVerts = new TerrainVertex[VertsPerLandblock];
float zMin = float.MaxValue, zMax = float.MinValue;
for (int i = 0; i < VertsPerLandblock; i++)
{
var v = meshData.Vertices[i];
var worldPos = v.Position + worldOrigin;
bakedVerts[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; }
// Bake baseVertex into indices on the CPU side (driver-portable pattern).
uint baseVertex = (uint)(slot * VertsPerLandblock);
var bakedIndices = new uint[IndicesPerLandblock];
for (int i = 0; i < IndicesPerLandblock; i++)
bakedIndices[i] = meshData.Indices[i] + baseVertex;
// glBufferSubData into the slot's VBO + EBO regions.
nint vboByteOffset = (nint)(slot * VertsPerLandblock * VertexSize);
nint eboByteOffset = (nint)(slot * IndicesPerLandblock * IndexSize);
fixed (TerrainVertex* p = bakedVerts)
{
TrackedGlResource.UpdateBufferSubData(
_gl,
BufferTargetARB.ArrayBuffer,
_globalVbo,
vboByteOffset,
VertsPerLandblock * VertexSize,
p,
$"uploading terrain vertices for 0x{landblockId:X8}");
}
fixed (uint* p = bakedIndices)
{
TrackedGlResource.UpdateBufferSubData(
_gl,
BufferTargetARB.ElementArrayBuffer,
_globalEbo,
eboByteOffset,
IndicesPerLandblock * IndexSize,
p,
$"uploading terrain indices for 0x{landblockId:X8}");
}
_slots[slot] = new SlotData
{
LandblockId = landblockId,
WorldOrigin = worldOrigin,
FirstIndex = (uint)(slot * IndicesPerLandblock),
IndexCount = IndicesPerLandblock,
AabbMin = new Vector3(worldOrigin.X, worldOrigin.Y, zMin),
AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax),
};
_idToSlot[landblockId] = slot;
published = true;
if (replacing)
{
_slots[replacedSlot] = null;
_alloc.FreeAfterGpuUse(replacedSlot);
}
}
finally
{
if (!published)
_alloc.ReleaseUnsubmitted(slot);
}
}
public void RemoveLandblock(uint landblockId)
{
// Removal clears the logical lookup before retirement publication. A
// retry therefore has to advance retained publications even when the
// landblock is no longer present in the map.
_alloc.RetryPendingPublications();
if (!_idToSlot.TryGetValue(landblockId, out var slot))
return;
_idToSlot.Remove(landblockId);
_slots[slot] = null;
_alloc.FreeAfterGpuUse(slot);
// No GPU clear: the per-frame DEIC array won't reference this slot.
}
public void Draw(
ICamera camera,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
ReadOnlySpan clipPlanes = default,
Vector4? ndcClipAabb = null)
{
if (_alloc.LoadedCount == 0) return;
Matrix4x4 viewProjection = camera.View * camera.Projection;
// Build visible slot list with per-slot frustum cull.
_visibleSlots.Clear();
for (int slot = 0; slot < _slots.Length; slot++)
{
var data = _slots[slot];
if (data is null) continue;
if (frustum is not null && data.LandblockId != neverCullLandblockId)
{
if (!FrustumCuller.IsAabbVisible(frustum.Value, data.AabbMin, data.AabbMax))
continue;
}
_visibleSlots.Add(slot);
CollectVisibleCells(
_visibleCellIds,
data.LandblockId,
data.WorldOrigin,
data.AabbMin.Z,
data.AabbMax.Z,
frustum,
viewProjection,
clipPlanes,
ndcClipAabb);
}
if (_visibleSlots.Count == 0) return;
ActivateNextIndirectBuffer();
// Build DEIC array.
if (_deicScratch.Length < _visibleSlots.Count)
_deicScratch = new DrawElementsIndirectCommand[Math.Max(_visibleSlots.Count, 64)];
for (int i = 0; i < _visibleSlots.Count; i++)
{
var data = _slots[_visibleSlots[i]]!;
_deicScratch[i] = new DrawElementsIndirectCommand
{
Count = (uint)data.IndexCount,
InstanceCount = 1u,
FirstIndex = data.FirstIndex,
BaseVertex = 0, // baked into indices on upload
BaseInstance = 0,
};
}
// Grow indirect buffer if needed.
if (_visibleSlots.Count > _indirectCapacity)
{
int grownCapacity = Math.Max(64, _visibleSlots.Count * 2);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.DrawIndirectBuffer,
_indirectBuffer,
checked((long)_indirectCapacity * sizeof(DrawElementsIndirectCommand)),
checked((long)grownCapacity * sizeof(DrawElementsIndirectCommand)),
GLEnum.DynamicDraw,
"growing terrain indirect command buffer");
_indirectCapacity = grownCapacity;
}
// Upload DEIC array.
fixed (DrawElementsIndirectCommand* p = _deicScratch)
{
TrackedGlResource.UpdateBufferSubData(
_gl,
GLEnum.DrawIndirectBuffer,
_indirectBuffer,
0,
checked((long)_visibleSlots.Count * sizeof(DrawElementsIndirectCommand)),
p,
"uploading terrain indirect commands");
}
PersistIndirectCapacity();
// Bind shader + uniforms + atlas handles.
// Verified Phase W Stage 4 (T4.2): terrain projects from the camera view-proj;
// no separate landscape viewpoint to sync. Both uView and uProjection derive
// from the ICamera passed into this method — the same camera used for all other
// renderers in the unified pipeline. Retail's LScape::update_viewpoint
// pre-positions terrain to the outdoor landcell, but acdream uses the
// unified camera matrix everywhere, so no separate viewpoint divergence can occur.
_shader.Use();
UploadTextureTilingOnce();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
var (terrainHandle, alphaHandle) = _atlas.GetBindlessHandles();
// Pass each 64-bit handle as a uvec2 (low 32 bits, high 32 bits).
// GLSL constructs sampler2DArray(uTerrainHandle) at the use site —
// see terrain_modern.frag for why this is the safe pattern.
_gl.ProgramUniform2(_shader.Program, _uTerrainHandleLoc,
(uint)(terrainHandle & 0xFFFFFFFFu), (uint)(terrainHandle >> 32));
_gl.ProgramUniform2(_shader.Program, _uAlphaHandleLoc,
(uint)(alphaHandle & 0xFFFFFFFFu), (uint)(alphaHandle >> 32));
// Phase U.3: bind the terrain clip UBO (binding=2). Shared ClipFrame UBO
// when wired, else the no-clip fallback (count 0 = ungated terrain).
BindClipUboBinding2();
// #108-residual: retail terrain is SINGLE-SIDED — ACRender::landPolysDraw
// (0x006b7040) draws each land triangle ONLY when the camera is on the
// POSITIVE (upper) side of its plane (Plane::which_side2 vs
// Render::FrameCurrent, zFightTerrainAdjust bias). GL backface culling
// evaluates the same per-triangle eye-side predicate at rasterization.
// LandblockMesh emits every triangle CCW in world XY seen from above
// (LandblockMeshTests winding pin), which the unified camera chain
// (CreateLookAt up=+Z + Numerics perspective) maps to CCW window
// winding from above / CW from below (TerrainCullOrientationTests) —
// so FrontFace(Ccw)+Cull(Back) keeps the top side and culls the
// underside. WB drew the whole world with culling DISABLED
// frame-globally (WB GameScene.cs:841 — an editor camera goes
// underground); inheriting that drew terrain DOUBLE-SIDED, and a
// below-grade eye (cellar ascent) saw the UNDERSIDE of the grade
// sheet through the exit-door aperture — the #108 grass window.
// Self-contained state per feedback_render_self_contained_gl_state;
// the frame-global CW + cull-off baseline is restored after the draw.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
_gl.BindVertexArray(_globalVao);
_gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit);
_gl.MultiDrawElementsIndirect(
PrimitiveType.Triangles, DrawElementsType.UnsignedInt,
(void*)0,
(uint)_visibleSlots.Count,
(uint)sizeof(DrawElementsIndirectCommand));
_gl.BindVertexArray(0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.Disable(EnableCap.CullFace);
}
public void Dispose()
{
if (_disposed)
return;
_retirementLedger.RetryPendingPublications();
if (_disposeResources is null)
{
var releases = new List<(string Name, Action Release)>();
if (_globalVao != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
_globalVao,
"deleting terrain global VAO");
releases.Add(("global-vao", release.Run));
}
if (_globalVbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_globalVbo,
_globalVboCapacityBytes,
"deleting terrain global vertex buffer");
releases.Add(("global-vbo", release.Run));
}
if (_globalEbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_globalEbo,
_globalEboCapacityBytes,
"deleting terrain global index buffer");
releases.Add(("global-ebo", release.Run));
}
for (int frame = 0; frame < _indirectBuffersByFrame.Length; frame++)
{
List frameBuffers = _indirectBuffersByFrame[frame];
for (int index = 0; index < frameBuffers.Count; index++)
{
DynamicIndirectBuffer buffer = frameBuffers[index];
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
buffer.Buffer,
checked((long)buffer.Capacity * sizeof(DrawElementsIndirectCommand)),
"deleting terrain indirect command buffer");
releases.Add(($"indirect-{frame}-{index}", release.Run));
}
}
if (_fallbackClipUbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_fallbackClipUbo,
ClipFrame.TerrainUboBytes,
"deleting terrain fallback clip UBO");
releases.Add(("fallback-clip-ubo", release.Run));
}
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
{
throw attempt.ToException(
"One or more terrain GPU resources could not be released.");
}
_globalVao = 0;
_globalVbo = 0;
_globalEbo = 0;
_globalVboCapacityBytes = 0;
_globalEboCapacityBytes = 0;
foreach (List frameBuffers in _indirectBuffersByFrame)
frameBuffers.Clear();
_indirectBuffer = 0;
_indirectCapacity = 0;
_dynamicFrameStarted = false;
_fallbackClipUbo = 0;
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
{
throw attempt.ToException(
"Terrain GPU resources released with exceptional committed outcomes.");
}
}
// ----------------------------------------------------------------
// Private helpers
// ----------------------------------------------------------------
///
/// Upload the texture-array adapter for retail's per-surface repeat count.
/// Retail passes TerrainTex::tex_tiling directly to
/// ImgTex::TileCSI / ImgTex::MergeTexture
/// (`TexMerge::CopyAndTile` 0x00503580, `TexMerge::Merge` 0x005038C0).
/// Uniform values persist for the lifetime of this linked shader program,
/// so the immutable atlas table is uploaded on its first bound draw.
///
private void UploadTextureTilingOnce()
{
if (_textureTilingUploaded)
return;
if (_atlas.TilingByLayer.Count != TerrainTextureTilingTable.LayerCapacity)
{
throw new InvalidOperationException(
$"Terrain tiling table has {_atlas.TilingByLayer.Count} entries; " +
$"expected {TerrainTextureTilingTable.LayerCapacity}.");
}
Span values = stackalloc float[TerrainTextureTilingTable.LayerCapacity];
for (int i = 0; i < values.Length; i++)
values[i] = _atlas.TilingByLayer[i];
_gl.Uniform1(_uTexTilingLoc, values);
_textureTilingUploaded = true;
}
///
/// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared
/// UBO range (); otherwise lazily
/// creates + binds a no-clip fallback (count 0 = ungated) so the shader never
/// reads an unbound UBO. The fallback is std140-sized to
/// and zero-filled (count 0).
///
private void BindClipUboBinding2()
{
if (_sharedClipBinding.IsValid)
{
_sharedClipBinding.Bind(_gl);
return;
}
if (_fallbackClipUbo == 0)
{
var zero = stackalloc byte[ClipFrame.TerrainUboBytes];
for (int i = 0; i < ClipFrame.TerrainUboBytes; i++) zero[i] = 0;
uint fallback = TrackedGlResource.CreateBuffer(
_gl,
"creating terrain fallback clip UBO");
try
{
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.UniformBuffer,
fallback,
0,
ClipFrame.TerrainUboBytes,
BufferUsageARB.DynamicDraw,
zero,
"allocating terrain fallback clip UBO");
_fallbackClipUbo = fallback;
}
catch
{
TrackedGlResource.DeleteBuffer(
_gl,
fallback,
0,
"rolling back terrain fallback clip UBO");
throw;
}
}
_gl.BindBufferBase(BufferTargetARB.UniformBuffer,
ClipFrame.TerrainClipUboBinding, _fallbackClipUbo);
}
private void AllocateGpuBuffers(int capacitySlots)
{
long vboBytes = checked((long)capacitySlots * VertsPerLandblock * VertexSize);
long eboBytes = checked((long)capacitySlots * IndicesPerLandblock * IndexSize);
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.ArrayBuffer,
_globalVbo,
_globalVboCapacityBytes,
vboBytes,
BufferUsageARB.DynamicDraw,
"allocating terrain global vertex storage");
_globalVboCapacityBytes = vboBytes;
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.ElementArrayBuffer,
_globalEbo,
_globalEboCapacityBytes,
eboBytes,
BufferUsageARB.DynamicDraw,
"allocating terrain global index storage");
_globalEboCapacityBytes = eboBytes;
}
private void ConfigureVao(uint vao, uint vbo, uint ebo)
{
_gl.BindVertexArray(vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
uint stride = (uint)VertexSize;
// location 0: Position
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
// location 1: Normal
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
// locations 2-5: Data0..Data3 (uvec4 byte attributes)
nint dataOffset = 6 * sizeof(float);
_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);
GLHelpers.ThrowOnResourceError(_gl, "configuring terrain VAO");
}
internal static void CollectVisibleCells(
HashSet destination,
uint landblockId,
Vector3 worldOrigin,
float zMin,
float zMax,
FrustumPlanes? frustum,
Matrix4x4 viewProjection,
ReadOnlySpan clipPlanes,
Vector4? ndcClipAabb = null)
{
ArgumentNullException.ThrowIfNull(destination);
const float cellSize = AcDream.Core.Physics.TerrainSurface.CellSize;
const int cellsPerSide = AcDream.Core.Physics.TerrainSurface.CellsPerSide;
uint prefix = landblockId & 0xFFFF0000u;
for (int cellX = 0; cellX < cellsPerSide; cellX++)
{
float minX = worldOrigin.X + cellX * cellSize;
float maxX = minX + cellSize;
for (int cellY = 0; cellY < cellsPerSide; cellY++)
{
float minY = worldOrigin.Y + cellY * cellSize;
float maxY = minY + cellSize;
var cellMin = new Vector3(minX, minY, zMin);
var cellMax = new Vector3(maxX, maxY, zMax);
if (frustum is not null
&& !FrustumCuller.IsAabbVisible(frustum.Value, cellMin, cellMax))
{
continue;
}
// Retail publishes landcell in_view from the clipped landscape
// view, not merely from the camera frustum. The modern renderer
// expresses each doorway slice as homogeneous clip-space planes
// plus its scissor AABB; use both products here so particle
// simulation follows the same visible terrain slice as the GPU.
if (!IsAabbVisibleThroughClipRegion(
cellMin,
cellMax,
viewProjection,
clipPlanes,
ndcClipAabb))
{
continue;
}
uint low = AcDream.Core.Physics.TerrainSurface.ComputeOutdoorCellLowId(
cellX * cellSize,
cellY * cellSize);
destination.Add(prefix | low);
}
}
}
private static bool IsAabbVisibleThroughClipRegion(
Vector3 min,
Vector3 max,
Matrix4x4 viewProjection,
ReadOnlySpan clipPlanes,
Vector4? ndcClipAabb)
{
Vector4 aabb = ndcClipAabb.GetValueOrDefault();
bool hasScissorConstraint = ndcClipAabb.HasValue
&& (aabb.X > -1f || aabb.Y > -1f || aabb.Z < 1f || aabb.W < 1f);
if (clipPlanes.IsEmpty && !hasScissorConstraint)
return true;
Span clipCorners = stackalloc Vector4[8];
for (int corner = 0; corner < clipCorners.Length; corner++)
{
var world = new Vector4(
(corner & 1) == 0 ? min.X : max.X,
(corner & 2) == 0 ? min.Y : max.Y,
(corner & 4) == 0 ? min.Z : max.Z,
1f);
clipCorners[corner] = Vector4.Transform(world, viewProjection);
}
for (int planeIndex = 0; planeIndex < clipPlanes.Length; planeIndex++)
{
if (IsAabbOutsideHomogeneousPlane(clipCorners, clipPlanes[planeIndex]))
{
return false;
}
}
if (!hasScissorConstraint)
return true;
Span scissorPlanes = stackalloc Vector4[4]
{
new( 1f, 0f, 0f, -aabb.X),
new(-1f, 0f, 0f, aabb.Z),
new( 0f, 1f, 0f, -aabb.Y),
new( 0f, -1f, 0f, aabb.W),
};
for (int planeIndex = 0; planeIndex < scissorPlanes.Length; planeIndex++)
{
if (IsAabbOutsideHomogeneousPlane(clipCorners, scissorPlanes[planeIndex]))
{
return false;
}
}
return true;
}
private static bool IsAabbOutsideHomogeneousPlane(
ReadOnlySpan clipCorners,
Vector4 plane)
{
// A linear half-space reaches its maximum over the transformed AABB at
// one of the eight corners. If every corner is negative, no point in
// the cell box can survive this GPU clip plane.
for (int corner = 0; corner < clipCorners.Length; corner++)
{
if (Vector4.Dot(plane, clipCorners[corner]) >= 0f)
return false;
}
return true;
}
private void EnsureCapacity(int newCapacity)
{
if (newCapacity <= _alloc.Capacity)
return;
var grownSlots = new SlotData?[newCapacity];
Array.Copy(_slots, grownSlots, _slots.Length);
long newVboBytes = checked((long)newCapacity * VertsPerLandblock * VertexSize);
long newEboBytes = checked((long)newCapacity * IndicesPerLandblock * IndexSize);
uint newVbo = 0;
uint newEbo = 0;
uint newVao = 0;
long allocatedNewVboBytes = 0;
long allocatedNewEboBytes = 0;
bool published = false;
try
{
newVbo = TrackedGlResource.CreateBuffer(
_gl,
"creating grown terrain vertex buffer");
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.ArrayBuffer,
newVbo,
0,
newVboBytes,
BufferUsageARB.DynamicDraw,
"allocating grown terrain vertex buffer");
allocatedNewVboBytes = newVboBytes;
newEbo = TrackedGlResource.CreateBuffer(
_gl,
"creating grown terrain index buffer");
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.ElementArrayBuffer,
newEbo,
0,
newEboBytes,
BufferUsageARB.DynamicDraw,
"allocating grown terrain index buffer");
allocatedNewEboBytes = newEboBytes;
GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers (precondition)");
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo);
_gl.CopyBufferSubData(
CopyBufferSubDataTarget.CopyReadBuffer,
CopyBufferSubDataTarget.CopyWriteBuffer,
0,
0,
checked((nuint)_globalVboCapacityBytes));
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo);
_gl.CopyBufferSubData(
CopyBufferSubDataTarget.CopyReadBuffer,
CopyBufferSubDataTarget.CopyWriteBuffer,
0,
0,
checked((nuint)_globalEboCapacityBytes));
GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers");
newVao = TrackedGlResource.CreateVertexArray(
_gl,
"creating grown terrain VAO");
ConfigureVao(newVao, newVbo, newEbo);
uint oldVao = _globalVao;
uint oldVbo = _globalVbo;
uint oldEbo = _globalEbo;
long oldVboBytes = _globalVboCapacityBytes;
long oldEboBytes = _globalEboCapacityBytes;
_globalVao = newVao;
_globalVbo = newVbo;
_globalEbo = newEbo;
_globalVboCapacityBytes = newVboBytes;
_globalEboCapacityBytes = newEboBytes;
_slots = grownSlots;
_alloc.GrowTo(newCapacity);
published = true;
// Older submitted draws captured the former VAO/buffer bindings.
// Retire the complete old set only after the replacement is valid.
RetryableGpuResourceRelease oldVaoRelease =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
oldVao,
"retiring terrain VAO after growth");
RetryableGpuResourceRelease oldVboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
oldVbo,
oldVboBytes,
"retiring terrain vertex buffer after growth");
RetryableGpuResourceRelease oldEboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
oldEbo,
oldEboBytes,
"retiring terrain index buffer after growth");
_retirementLedger.RetireMany(
[oldVaoRelease, oldVboRelease, oldEboRelease]);
}
finally
{
if (!published)
{
TrackedGlResource.DeleteVertexArray(
_gl,
newVao,
"rolling back grown terrain VAO");
TrackedGlResource.DeleteBuffer(
_gl,
newVbo,
allocatedNewVboBytes,
"rolling back grown terrain vertex buffer");
TrackedGlResource.DeleteBuffer(
_gl,
newEbo,
allocatedNewEboBytes,
"rolling back grown terrain index buffer");
}
}
}
private sealed class SlotData
{
public uint LandblockId;
public Vector3 WorldOrigin;
public uint FirstIndex;
public int IndexCount;
public Vector3 AabbMin;
public Vector3 AabbMax;
}
}