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