Fixes the three-lens review blockers against 671eb3ad4 (S3 section 9.6 F1-F6).
F1 - slot key (blocking, retail). TerrainModernRenderer.DrawLandCells
normalizes every incoming landblockId to (id & 0xFFFF0000u) | 0xFFFFu
before the _idToSlot lookup: the walk hands 0xXXYY0000
(WalkLandBlock.LandblockId) but AddLandblock stores under the DAT id
0xXXYYFFFF (LandblockRenderPublisher.LandblockId) - every walk lookup
was missing and the walk path drew NO terrain. Unit-tested end-to-end
through a real RecordingGpuDevice-backed TerrainModernRenderer
(TerrainWalkSlotKeyNormalizationTests): AddLandblock(0xA9B4FFFF, ...)
is found by a 0xA9B40000 lookup, an unknown landblock is a silent
per-entry no-op, and a batch mixing a known and unknown entry submits
only the known one.
F2 - deferred cross-block batching (blocking, driver). Retail's
DrawSortCell always follows DrawLandCell (LC/SC strictly alternate,
never two LC in a row - S3 section 9 R1), so chunk 3's "merge
consecutive same-landblock LandCell events" rule never actually
merged anything; the driver review flagged batching as inert.
WalkFrameDriver.Replay now keeps ONE pending terrain batch across
landblocks ((landblockId, side, cellIndex) entries, cleared at
Replay's own start); a LandCell event only appends; every OTHER event
kind that will itself submit GPU work (StreamMark, Sky, CellShell,
PunchFan, AlphaBarrier, LandscapeFlush, ClearInteriorDepth,
ExitSeals) flushes the pending batch first; a StaticParticles/
CellParticles turn asks the new ParticleSystem.
HasRenderableEmittersInCell (an allocation-free sibling of
CopyRenderableEmittersInCell) and, when the cell has no renderable
emitter, submits nothing and does NOT flush either - the whole point
of the deferred rule. The end of Replay flushes the remainder. This
is order-preserving by construction: a flush always lands at the
exact point the unbatched draw would have, so GPU submission order -
and therefore pixels - is identical to the unbatched baseline; only
the number of small terrain draw calls shrinks.
TerrainModernRenderer.DrawLandCellRuns becomes DrawLandCells(
viewProjection, IReadOnlyList<(uint LandblockId, int SideCellCount,
int CellIndex)>) - one MultiDrawIndexedIndirect over every entry's
runs, unknown slots skipped per-entry. IWalkFrameLeafRenderer.
DrawLandCellBatch drops its separate landblockId parameter to match
(a batch can span several landblocks now) and gains
HasRenderableEmittersInCell.
Batch-count demonstration: driven through a real WalkFrameDriver
Replay (OnLandCellTurn_MergesAcrossLandblocksOverAnEmptyParticleTurn_
RealSubmissionsSplit), 4 LandCell turns across 3 distinct landblocks,
separated only by an empty particle turn, a real StreamMark, and a
building's alpha barrier, submit as exactly 3 DrawLandCellBatch calls
(2+1+1) instead of 4 - the empty particle turn's non-flush merges two
otherwise-separate cross-landblock entries. At production scale the
same mechanism is expected to cut the terrace-edge frame's ~578
individual DrawLandCell events (S3 section 9's captured transcript
count) to "tens" of submitted batches, per the contract's own
expectation: most terrain cells have no particle owner nearby, so the
strict LC/[empty-SC]/LC/[empty-SC]/... run collapses into one batch
per region bounded by real content (a building, a StreamMark-worthy
cell, or a genuine emitter) rather than per cell.
F3 - per-frame terrain diagnostic (blocking, build/test). The walk
leaf no longer brackets each batch with TerrainDrawDiagnosticsController
.Begin()/Complete() (a per-batch Stopwatch Restart/Stop pair that was
pushing one timing SAMPLE per batch, not per frame).
RetailPViewPassExecutor.DrawWalkLandCellBatch instead times its own
call with a raw Stopwatch.GetTimestamp() delta (no allocation) and
hands the ticks to the controller's new AccumulateWalkBatch;
RetailPViewRenderer.DrawWalkDrivenStatics calls the new
CompleteWalkTerrainFrame() exactly once, immediately after
driver.Replay finishes - "the end of the walk replay", where the
deleted whole-stage terrain leaf's own Begin()/Complete() bracket
used to close - which pushes ONE elapsed-time sample (even a
zero-batch frame pushes a zero sample: one sample per frame, not per
landscape turn) and publishes on the existing 5-second cadence.
TerrainRenderDiagnosticFacts gains a Draws field alongside
VisibleSlots (both were the same field before); TerrainModernRenderer
tracks its own per-frame WalkVisibleSlotCount/WalkDrawCount (a
HashSet<int>/int cleared in BeginFrame, populated by DrawLandCells),
and the diagnostics source reports those whenever the walk drew at
least one batch this frame, falling back to the non-walk Draw()
path's VisibleSlots otherwise (the two paths never both run in the
same frame). The [TERRAIN-DIAG] line's meaning (cpu_us per frame) is
unchanged, so the S3 section 9.5 before/after compare stays valid.
F4 - driver pins for the LandCell position (major). Three RunFrame-
level pins replace the deleted TERRAIN:0 pins: an outdoor-root
sequence (SKY, then one LANDCELL, driving RetailFrameWalk.
DrawLandscape directly with a one-view/zero-vertex WalkPortalView so
WalkLandscape.CheckBlocks' admission stays the same deterministic
"CY-only" test RetailFrameWalkTests already relies on, while still
satisfying WalkFrameDriver's real >=1-active-view fail-loud guard);
an interior-root test with one real exit view and one populated
block (SKY, LANDCELL, LFLUSH, SEALS, SHELL...) built on the existing
RunFrame_InteriorFloodWithExitView_... fixture; and the T4 batching
pin re-expressed for the F2 rule (OnLandCellTurn_
MergesAcrossLandblocksOverAnEmptyParticleTurn_RealSubmissionsSplit,
described above). The fake leaf's DrawLandCellBatch now logs
LANDCELL:<lb>:<side>:<idx>[,...] per batch and gains
HasRenderableEmittersInCell backed by an opt-out CellsWithoutEmitters
set (default true - has-emitters - so every pre-existing pin in the
file keeps its old unconditional-submission behavior unchanged).
F5 - no code change: the walk's in-view gate is unchanged; no
whole-block terrain re-added.
F6 - minor/notes: DrawLandCells' own comment now states the walk's
CheckBlocks/landcell_check admission is the sole terrain culling
authority (retail has no separate terrain frustum test); the
HandleLandscapeTurn comment's inverted claim is corrected (a FARTHER
building's punch survived because NEARER terrain was drawn BEFORE
it, not after - the interleave now draws it after, matching retail);
the "flat/directional-shadow paths" claim is corrected to the one
actual caller, WorldScenePassExecutor.DrawFlatTerrain (a directional-
shadow receiver selects its pipeline inside the SAME DrawRhi call,
not through a second caller); the cathedral order-trace token gains
the LOD side/index (":LC<lb>/<side>:<idx>"); T2's vacuous "no
TERRAIN event" assertion in RetailFrameWalkTests is replaced by a
comment pointing at the F4 driver-level pins; and the stale
"Confirmed OH5 defect" row in oh1-construction-landscape-contract.md
is retired with "FIXED by S3 chunk 3 (commit 671eb3ad4 + fix round
1)".
App hermetic lane: 6,795/6,795 (up from 671eb3ad4's 6,786 baseline -
net +9 tests: 3 F1 slot-key tests, 2 F4a/b driver RunFrame pins, 3
TerrainDrawDiagnosticsController walk-frame tests, plus the T4->F4c
rewrite and the RetailPViewPassExecutorTests split are net neutral).
InstalledDat lane: 241 passed, the same 3 accepted failures (2
pre-existing #383 layout fixture-drift tests, 1 TowerAscent
Status=KnownFailure) - unchanged from baseline. Core Vfx tests:
109/109 (108 baseline + 1 new HasRenderableEmittersInCell lifecycle
pin mirroring CopyRenderableEmittersInCell's own add/move/remove
test).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
628 lines
27 KiB
C#
628 lines
27 KiB
C#
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> _visibleCellIds = 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>
|
||
/// 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.
|
||
/// </summary>
|
||
internal HashSet<uint> VisibleCellIds => _visibleCellIds;
|
||
|
||
public void BeginVisibilityFrame() => _visibleCellIds.Clear();
|
||
|
||
/// <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,
|
||
ReadOnlySpan<Vector4> clipPlanes = default,
|
||
Vector4? ndcClipAabb = 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, 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);
|
||
}
|
||
|
||
/// <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<<24 | by<<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
|
||
// ----------------------------------------------------------------
|
||
|
||
internal static void CollectVisibleCells(
|
||
HashSet<uint> destination,
|
||
uint landblockId,
|
||
Vector3 worldOrigin,
|
||
float zMin,
|
||
float zMax,
|
||
FrustumPlanes? frustum,
|
||
Matrix4x4 viewProjection,
|
||
ReadOnlySpan<Vector4> 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<Vector4> 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<Vector4> 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<Vector4> 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<Vector4> 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;
|
||
}
|
||
}
|