acdream/src/AcDream.App/Rendering/TerrainModernRenderer.cs
Erik 94a6b5ef39 feat(overhaul): port S5 particle cell visibility
Publish the exact completed walk landscape set as the typed retained particle-view product. Apply retail CLandCell membership versus constant-true non-null CEnvCell eligibility while preserving the x87 distance and AP-116 behavior.

Delete the null-root terrain visibility reconstruction and the dead drawable-cell point-light feedback chain. Retire AP-117, correct AP-85 and AD-21, and keep directional shadows, building degrade, AP-232, probes, RHI, shaders, and DAT outside this chunk.

Automated return: Release 0 warnings/0 errors; Core VFX 111/111; App particle/frame/renderer/terrain 146/146; warmed production allocation 1/1 at 0 B. First official hermetic artifact s5-c1-hermetic-20260904 is preserved at 16759/16760 with only the stale 162-row assertion; after the bounded 161 correction, exact pin 1/1 and fresh s5-c1-hermetic-corrected-20260904 16760/16760. InstalledDat 385 pass, 10 documented failures, 1 documented skip, no new identity.

Mutation 1: making EnvCell eligibility set-dependent first failed ParticleSystemTests.cs:553 Assert.True, expected true actual false. Mutation 2: making outdoor eligibility constant true first failed ParticleSystemTests.cs:559 Assert.False, expected false actual true.

Mutation 3: feeding the diagnostic union first failed WorldSceneRendererTests.cs:279 HashSet equality, expected [16842755], actual [16843008, 16842755]. Mutation 4: restoring CollectVisibleCells first failed TerrainParticleCellVisibilityTests.cs:37 and named TerrainModernRenderer.cs.

Mutation 5: restoring ObserveDrawableCells first failed TerrainParticleCellVisibilityTests.cs:37 and named WorldRenderFrameBuilder.cs. Mutation 6: changing the inclusive boundary from <= to < first failed ParticleSystemTests.cs:519 Assert.True, expected true actual false.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-04 17:09:51 +02:00

478 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
namespace AcDream.App.Rendering;
/// <summary>
/// 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.
///
/// <para>Campaign V slice V11 deleted the raw-GL submission arm
/// (<c>TerrainModernRenderer.cs</c>'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 <c>TerrainModernRenderer.Rhi.cs</c>.</para>
/// </summary>
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;
/// <summary>A.5 T22.5: exposes the terrain atlas so callers can update
/// anisotropic level mid-session via <see cref="TerrainAtlas.SetAnisotropic"/>.</summary>
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<uint, int> _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;
/// <summary>
/// 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.
/// </summary>
internal int DynamicIndirectBufferCount => 0;
// Reusable per-frame buffers.
private readonly List<int> _visibleSlots = new();
private readonly HashSet<uint> _walkVisibleLandblocks = new();
private DrawElementsIndirectCommand[] _deicScratch = Array.Empty<DrawElementsIndirectCommand>();
// 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<int> _walkSlotsThisFrame = new();
private int _walkDrawsThisFrame;
// Diag.
public int LoadedSlots => _alloc.LoadedCount;
public int VisibleSlots => _visibleSlots.Count;
public int CapacitySlots => _alloc.Capacity;
/// <summary>S3 chunk 3 fix round 1 (F3): distinct slots <see
/// cref="DrawLandCells"/> actually submitted THIS FRAME (cleared at
/// <see cref="BeginFrame"/>) — the walk path's own per-frame
/// "VisibleSlots" answer, since the walk never calls <see cref="Draw"/>.</summary>
internal int WalkVisibleSlotCount => _walkSlotsThisFrame.Count;
/// <summary>S3 chunk 3 fix round 1 (F3): the number of <see
/// cref="DrawLandCells"/> batches (indirect draws) submitted THIS FRAME.</summary>
internal int WalkDrawCount => _walkDrawsThisFrame;
/// <summary>
/// Resets the per-GPU-fenced-frame-slot draw state. A retail outside view
/// may draw terrain more than once in a frame.
/// </summary>
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;
}
/// <summary>
/// Two-tier streaming entry point. Accepts a prebuilt mesh from
/// <see cref="LandblockStreamResult.Loaded.MeshData"/> 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 <see cref="AddLandblock(uint,LandblockMeshData,Vector3)"/>
/// so both paths share one upload path. Per Phase A.5 spec T15.
/// </summary>
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,
IReadOnlySet<uint>? 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. The walk publishes
// its exact in_view set directly to typed consumers; this draw path
// does not reconstruct or retain a second visibility answer.
_walkVisibleLandblocks.Clear();
if (inViewLandcells is not null)
{
foreach (uint cellId in inViewLandcells)
{
_walkVisibleLandblocks.Add(cellId & 0xFFFF0000u);
}
}
// 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 (_visibleSlots.Count == 0) return;
BuildIndirectCommands();
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
DrawRhi(viewProjection, _visibleSlots.Count);
}
/// <summary>
/// Builds this frame's <c>DrawElementsIndirectCommand</c> array from the
/// visible slot list. Pure CPU.
/// </summary>
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,
};
}
}
/// <summary>
/// S3 chunk 3 (§9.2 B1/B2), fix round 1 (F1/F2): the walk's per-land-cell
/// terrain draw — retail <c>RenderDeviceD3D::DrawLandCell</c>
/// @0x0059f120, batched. Submits <see cref="AppendCellIndexRuns"/>'s
/// index runs for every entry of <paramref name="cells"/> 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 <c>FirstIndex</c> offset, so a batch is never required to
/// stay within one landblock — <see cref="Walk.WalkFrameDriver.Replay"/>
/// only splits it at a genuine intervening GPU submission), UNCLIPPED:
/// retail never view-clips terrain — the walk's own per-cell
/// <c>CellInView</c> admission (<c>RetailFrameWalk.DrawLandscape</c>'s
/// <c>DrawLandCell</c> gate) already decided which cells reach here, so
/// there is no frustum test and no <c>inViewLandcells</c> filter here.
/// Fix round 1 F6: this is not a missing culling step — retail has no
/// separate terrain frustum test beyond <c>LScape::draw_check_blocks</c>/
/// <c>landcell_check</c> (ported as <c>WalkLandscape.CheckBlocks</c>),
/// so the walk's own admission IS the sole culling authority for
/// terrain, matching retail exactly. Unlike the non-walk <see cref="Draw"/> entry point this supersedes
/// for the walk path only (<see cref="Draw"/> 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, <c>WorldScenePassExecutor.DrawFlatTerrain</c>; a
/// directional-shadow-receiving surface selects its receiver pipeline
/// inside the SAME <c>DrawRhi</c> call this makes, not through a second
/// caller).
///
/// <para><b>F1 — the slot key.</b> <paramref name="cells"/> carries each
/// entry's landblock id in the WALK's own <c>bx&lt;&lt;24 | by&lt;&lt;16</c>
/// encoding (<c>WalkLandBlock.LandblockId</c>, low word 0x0000), while
/// <see cref="_idToSlot"/> is keyed by the DAT landblock id
/// (<c>LandblockRenderPublisher.LandblockId</c>, low word 0xFFFF — the
/// same convention <see cref="AddLandblock"/>/<see cref="RemoveLandblock"/>
/// 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.</para>
///
/// <para>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.</para>
/// </summary>
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);
}
/// <summary>
/// S3 chunk 3 (§9.1 R3): retail LOD cell (side <paramref
/// name="sideCellCount"/>, LOD coords <c>(X, Y) = (cellIndex / side,
/// cellIndex % side)</c>) covers <c>cx ∈ [X·8/n, (X+1)·8/n)</c>,
/// <c>cy ∈ [Y·8/n, (Y+1)·8/n)</c> of the 8×8 cell-major mesh index
/// buffer (<c>LandblockMesh.Build</c>: <c>cy</c> outer, <c>cx</c>
/// inner, 6 indices per cell, <c>indices[i] = i</c>). Appends one
/// contiguous run per covered <c>cy</c> 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.
/// </summary>
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
// ----------------------------------------------------------------
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;
}
}