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 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(); /// /// 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); _retirementLedger.RetryPendingPublications(); _dynamicFrameSlot = frameSlot; _dynamicFrameStarted = true; } /// /// 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) { 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; 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, }; } } 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; } }