using System.Numerics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
namespace AcDream.App.Rendering;
///
/// Phase N.5b modern terrain dispatcher. Single global vertex/index arena 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 and dispatch via one multi-draw-indirect call. Atlas
/// textures bound via the device's global texture table.
///
/// Campaign V slice V11 deleted the raw-GL submission arm
/// (TerrainModernRenderer.cs's former GL fields/constructor/Draw/Dispose
/// bodies); the RHI arm this file now exclusively hosts the shared allocator
/// and visibility logic for is defined in TerrainModernRenderer.Rhi.cs.
///
public sealed partial 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 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 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();
// Backing-store capacity bookkeeping, shared with the RHI arm's arena.
private long _globalVboCapacityBytes;
private long _globalEboCapacityBytes;
// Per-GPU-fenced-frame-slot draw bookkeeping, shared with the RHI arm.
private int _dynamicFrameSlot;
private bool _dynamicFrameStarted;
///
/// The dynamic per-frame-slot indirect-command buffer pool this used to
/// report was raw-GL-only bookkeeping, deleted with that arm at Campaign V
/// slice V11. The RHI arm allocates its indirect-command storage from the
/// GPU frame's own upload ring instead, so there is no separate pool to
/// count.
///
internal int DynamicIndirectBufferCount => 0;
// Reusable per-frame buffers.
private readonly List _visibleSlots = new();
private readonly HashSet _visibleCellIds = new();
private readonly HashSet _walkVisibleLandblocks = new();
private DrawElementsIndirectCommand[] _deicScratch = Array.Empty();
// S3 chunk 3 (§9.2 B1): DrawLandCells' reusable per-entry index-run
// scratch — repopulated per cell by AppendCellIndexRuns, one
// (Start, Count) pair per contiguous run relative to THAT entry's own
// slot (side 8: one run/cell; side 1: one run total). Converted to
// absolute (FirstIndex, Count) pairs in _batchRunScratch below because a
// fix-round-1 batch (F2) can span several landblocks/slots.
private readonly List<(int Start, int Count)> _cellRunScratch = new();
// S3 chunk 3 fix round 1 (F2): DrawLandCells' reusable ABSOLUTE run
// scratch — one (FirstIndex, Count) pair per contiguous index run across
// every entry of one batch, in entry order, cleared per call.
private readonly List<(uint FirstIndex, int Count)> _batchRunScratch = new();
// S3 chunk 3 fix round 1 (F3): the walk path's own per-FRAME submission
// stats — cleared in BeginFrame, populated by DrawLandCells. Distinct
// from _visibleSlots (Draw()'s own per-call list, the non-walk fallback
// path) because the walk submits many small batches across a frame
// rather than one Draw() call; TerrainDrawDiagnosticsController reports
// whichever path actually ran this publication window.
private readonly HashSet _walkSlotsThisFrame = new();
private int _walkDrawsThisFrame;
// Diag.
public int LoadedSlots => _alloc.LoadedCount;
public int VisibleSlots => _visibleSlots.Count;
public int CapacitySlots => _alloc.Capacity;
/// S3 chunk 3 fix round 1 (F3): distinct slots actually submitted THIS FRAME (cleared at
/// ) — the walk path's own per-frame
/// "VisibleSlots" answer, since the walk never calls .
internal int WalkVisibleSlotCount => _walkSlotsThisFrame.Count;
/// S3 chunk 3 fix round 1 (F3): the number of batches (indirect draws) submitted THIS FRAME.
internal int WalkDrawCount => _walkDrawsThisFrame;
///
/// 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();
///
/// Resets the per-GPU-fenced-frame-slot draw state. A retail outside view
/// may draw terrain more than once in a frame.
///
public void BeginFrame(int frameSlot)
{
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
if (_directionalShadowFrameSequence == long.MaxValue)
throw new InvalidOperationException(
"Directional-shadow terrain frame identity was exhausted.");
_directionalShadowFrameSequence++;
_retirementLedger.RetryPendingPublications();
_dynamicFrameSlot = frameSlot;
_dynamicFrameStarted = true;
// S3 chunk 3 fix round 1 (F3): reset the walk path's per-frame
// submission stats — one presented frame, one answer.
_walkSlotsThisFrame.Clear();
_walkDrawsThisFrame = 0;
}
///
/// 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;
UploadRhiLandblock(slot, bakedVerts, bakedIndices);
_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,
IReadOnlySet? inViewLandcells = null)
{
if (_alloc.LoadedCount == 0) return;
Matrix4x4 viewProjection = camera.View * camera.Projection;
// Campaign FW4: RetailFrameWalk is the sole visibility authority.
// Terrain remains one full-landblock MDI draw, but only landblocks
// containing a walk-admitted landcell participate, and the published
// landcell set is the walk's exact in_view set rather than a second
// frustum-derived approximation.
_walkVisibleLandblocks.Clear();
if (inViewLandcells is not null)
{
foreach (uint cellId in inViewLandcells)
{
_walkVisibleLandblocks.Add(cellId & 0xFFFF0000u);
_visibleCellIds.Add(cellId);
}
}
// 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 (inViewLandcells is not null
&& !_walkVisibleLandblocks.Contains(data.LandblockId & 0xFFFF0000u))
{
continue;
}
if (frustum is not null && data.LandblockId != neverCullLandblockId)
{
if (!FrustumCuller.IsAabbVisible(frustum.Value, data.AabbMin, data.AabbMax))
continue;
}
_visibleSlots.Add(slot);
if (inViewLandcells is null)
{
CollectVisibleCells(
_visibleCellIds,
data.LandblockId,
data.WorldOrigin,
data.AabbMin.Z,
data.AabbMax.Z,
frustum,
viewProjection,
clipPlanes,
ndcClipAabb);
}
}
if (_visibleSlots.Count == 0) return;
BuildIndirectCommands();
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
DrawRhi(viewProjection, _visibleSlots.Count);
}
///
/// Builds this frame's DrawElementsIndirectCommand array from the
/// visible slot list. Pure CPU.
///
private void BuildIndirectCommands()
{
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,
};
}
}
///
/// S3 chunk 3 (§9.2 B1/B2), fix round 1 (F1/F2): the walk's per-land-cell
/// terrain draw — retail RenderDeviceD3D::DrawLandCell
/// @0x0059f120, batched. Submits 's
/// index runs for every entry of as ONE
/// indirect draw spanning however many DISTINCT landblocks the batch
/// covers (fix round 1's F2: the terrain index buffer is one buffer with
/// a per-slot FirstIndex offset, so a batch is never required to
/// stay within one landblock —
/// only splits it at a genuine intervening GPU submission), UNCLIPPED:
/// retail never view-clips terrain — the walk's own per-cell
/// CellInView admission (RetailFrameWalk.DrawLandscape's
/// DrawLandCell gate) already decided which cells reach here, so
/// there is no frustum test and no inViewLandcells filter here.
/// Fix round 1 F6: this is not a missing culling step — retail has no
/// separate terrain frustum test beyond LScape::draw_check_blocks/
/// landcell_check (ported as WalkLandscape.CheckBlocks),
/// so the walk's own admission IS the sole culling authority for
/// terrain, matching retail exactly. Unlike the non-walk entry point this supersedes
/// for the walk path only ( keeps serving its one
/// remaining non-walk caller — fix round 1 F6 corrects the prior "flat/
/// directional-shadow paths" wording: the only caller left is the flat-
/// terrain fallback, WorldScenePassExecutor.DrawFlatTerrain; a
/// directional-shadow-receiving surface selects its receiver pipeline
/// inside the SAME DrawRhi call this makes, not through a second
/// caller).
///
/// F1 — the slot key. carries each
/// entry's landblock id in the WALK's own bx<<24 | by<<16
/// encoding (WalkLandBlock.LandblockId, low word 0x0000), while
/// is keyed by the DAT landblock id
/// (LandblockRenderPublisher.LandblockId, low word 0xFFFF — the
/// same convention /
/// already use and must keep using, since those callers already pass the
/// DAT id). Normalized HERE, at this one walk entry point, rather than at
/// every walk call site.
///
/// An entry whose normalized id has no uploaded slot (a
/// streaming-timing race between the walk's own block graph and this
/// renderer's landblock upload, OR a genuinely unknown block) is a
/// silent per-entry no-op — the walk's visited-cell bookkeeping is the
/// timing authority, not this call; the other entries in the same batch
/// still submit.
///
public void DrawLandCells(
Matrix4x4 viewProjection,
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells)
{
ArgumentNullException.ThrowIfNull(cells);
if (cells.Count == 0) return;
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
_batchRunScratch.Clear();
for (int i = 0; i < cells.Count; i++)
{
(uint landblockId, int sideCellCount, int cellIndex) = cells[i];
// F1: the walk hands 0xXXYY0000 (WalkLandBlock.LandblockId);
// AddLandblock stores under the DAT id 0xXXYYFFFF
// (LandblockRenderPublisher.LandblockId). Normalize the lookup,
// not the storage — every non-walk caller still stores/removes
// under the DAT id unchanged.
uint slotKey = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_idToSlot.TryGetValue(slotKey, out int slot))
continue;
uint baseFirstIndex = (uint)(slot * IndicesPerLandblock);
_cellRunScratch.Clear();
AppendCellIndexRuns(sideCellCount, cellIndex, _cellRunScratch);
for (int r = 0; r < _cellRunScratch.Count; r++)
{
(int start, int count) = _cellRunScratch[r];
_batchRunScratch.Add((baseFirstIndex + (uint)start, count));
}
_walkSlotsThisFrame.Add(slot);
}
int commandCount = _batchRunScratch.Count;
if (commandCount == 0) return;
if (_deicScratch.Length < commandCount)
_deicScratch = new DrawElementsIndirectCommand[Math.Max(commandCount, 64)];
for (int i = 0; i < commandCount; i++)
{
(uint firstIndex, int count) = _batchRunScratch[i];
_deicScratch[i] = new DrawElementsIndirectCommand
{
Count = (uint)count,
InstanceCount = 1u,
FirstIndex = firstIndex,
BaseVertex = 0, // baked into indices on upload
BaseInstance = 0,
};
}
_walkDrawsThisFrame++;
DrawRhi(viewProjection, commandCount);
}
///
/// S3 chunk 3 (§9.1 R3): retail LOD cell (side , LOD coords (X, Y) = (cellIndex / side,
/// cellIndex % side)) covers cx ∈ [X·8/n, (X+1)·8/n),
/// cy ∈ [Y·8/n, (Y+1)·8/n) of the 8×8 cell-major mesh index
/// buffer (LandblockMesh.Build: cy outer, cx
/// inner, 6 indices per cell, indices[i] = i). Appends one
/// contiguous run per covered cy row — side 8 emits one run of
/// 6 indices per cell, side 4 two runs of 12, side 2 four runs of 24;
/// side 1's single coarse cell covers EVERY row contiguously (the
/// mesh's rows sit back-to-back with no gap), so its 8 per-row runs
/// concatenate into ONE run of all 384 indices. Pure arithmetic — no
/// baked range table, no per-frame rebuild.
///
internal static void AppendCellIndexRuns(
int sideCellCount, int cellIndex, List<(int Start, int Count)> runs)
{
ArgumentNullException.ThrowIfNull(runs);
if (sideCellCount is not (1 or 2 or 4 or 8))
{
throw new ArgumentOutOfRangeException(
nameof(sideCellCount),
sideCellCount,
"A landscape LOD grid must be 1, 2, 4, or 8 cells per side.");
}
if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount))
throw new ArgumentOutOfRangeException(nameof(cellIndex));
int span = LandblockMesh.CellsPerSide / sideCellCount;
int coarseX = cellIndex / sideCellCount;
int coarseY = cellIndex % sideCellCount;
int firstCx = coarseX * span;
int firstCy = coarseY * span;
if (span == LandblockMesh.CellsPerSide)
{
// The block's only coarse cell (side 1) covers cx AND cy 0..7 —
// every row's run then abuts the next (row cy's run ends at
// (cy*8+8)*6 = (cy+1)*8*6, exactly row cy+1's start), so the 8
// per-row runs are one contiguous 384-index range.
runs.Add((0, VertsPerLandblock));
return;
}
int runLength = span * LandblockMesh.VerticesPerCell;
for (int cy = firstCy; cy < firstCy + span; cy++)
{
int start = (cy * LandblockMesh.CellsPerSide + firstCx) * LandblockMesh.VerticesPerCell;
runs.Add((start, runLength));
}
}
public void Dispose()
{
if (_disposed)
return;
_retirementLedger.RetryPendingPublications();
DisposeRhi();
}
// ----------------------------------------------------------------
// Private helpers
// ----------------------------------------------------------------
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;
EnsureRhiCapacity(newCapacity);
}
private sealed class SlotData
{
public uint LandblockId;
public Vector3 WorldOrigin;
public uint FirstIndex;
public int IndexCount;
public Vector3 AabbMin;
public Vector3 AabbMax;
}
}