acdream/src/AcDream.App/Rendering/TerrainModernRenderer.cs
Erik 651badc2b9 fix(render): S3 chunk 3 round 1 - slot key, deferred cross-block terrain batches, per-frame terrain diagnostic
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>
2026-09-03 09:57:34 +02:00

628 lines
27 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> _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&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
// ----------------------------------------------------------------
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;
}
}