acdream/src/AcDream.App/Rendering/TerrainModernRenderer.cs
Erik 99d9c6d564 chore(render): S3 landing hygiene — scissor-era dead state, stale comments, the #130 harness, a conjunction-proof weather-gate pin
Chunk H of Campaign OVERHAUL v2's S3 walk-ownership program
(docs/research/2026-09-01-overhaul/s3-walk-ownership-map.md §10.6), landing
the three post-hoc three-lens findings on S3 chunk 4 round 2. Pure dead-state
deletion, comment truth, one test deletion, one stronger pin — no admission,
order, or behavior change; no new flag or probe; no register row (AD-17's
correction is the lead's own, already on the campaign branch).

H1 — ClipPlaneSet.cs: deleted ScissorNdcAabb (no production reader — only
AppendOutsideSlice consumes ClipPlaneSet, and it reads only
IsNothingVisible/Count/PlaneArray) and renamed UseScissorFallback ->
IsPlaneOverflow with a doc stating the true consumer contract: Count==0
overflow means the region draws UNCLIPPED via the no-clip slot, never a
scissor box. Rewrote the file header's convexity-rule and Count==0-states
sections to the two states that exist (Empty / overflow) instead of three,
and simplified the private Scissor(...) AABB-computing helpers into a
parameterless Overflow() factory since nothing reads the AABB any more.
ClipPlaneSetTests.cs: renamed every UseScissorFallback reference, deleted
the AABB assertions (From_MultiplePolygons's four ScissorNdcAabb.X/Y/Z/W
checks, Empty_StaticProperty_DrawsNothing's degenerate-box check,
ScissorFallback_IsNotNothingVisible's bounds check) that pinned the deleted
consumer contract.

H2 — TerrainModernRenderer.cs: deleted Draw's clipPlanes/ndcClipAabb
parameters (grep: TerrainModernRenderer.Draw has exactly one production
caller, WorldScenePassExecutor.DrawFlatTerrain, and it never passed
either), which made CollectVisibleCells's own clipPlanes/ndcClipAabb/
viewProjection parameters and the IsAabbVisibleThroughClipRegion +
IsAabbOutsideHomogeneousPlane helper pair they fed entirely dead — deleted
along with the stale "Retail publishes landcell in_view from the clipped
landscape view" comment; CollectVisibleCells is frustum-only now, matching
DrawLandCells's own "retail never view-clips terrain" doc. Deleted the
terrainUploadCount computation at RetailPViewRenderer.cs (the
DrawInside/DrawLandscapeDynamicsPhase caller) and PrepareClipFrame's
GL-era reservation-count parameter end to end:
IWorldPassSurface.PrepareClipFrame(int), RhiWorldPassSurface's matching
body and its now-pointless "_ = terrainUploadCount" discard,
RetailPViewPassExecutor.PrepareClipFrame's pass-through, and
WorldScenePassExecutor.PrepareFlatWorldClip's hardcoded `1` argument —
PrepareClipFrame() takes nothing on every arm.
TerrainParticleCellVisibilityTests.cs: deleted the three tests that only
existed to pin the removed clip-plane/scissor mechanism
(RejectsCellsOutsideDoorwayClipPlanes, RejectsCellsOutsideDoorwayScissorAabb,
UnionsCellsFromEveryLandscapeSlice) and updated the two surviving frustum
tests to the new signature.

H3 — deleted tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs
outright. #130 was a background-color strip along a doorway's top edge from
a since-fixed under-inclusion in the CPU clip pipeline; its scissor half was
already retired at S3 chunk 4 fix round 2 (L7), and its surviving plane-gap
half exercised only PortalVisibilityBuilder.Build and
ClipFrameAssembler.Assemble, both confirmed zero-production-caller legacy
paths (grep) — the one production stage it actually touched,
ClipPlaneSet.From, is already pinned through the real producers
(BeginWalkFrame/ReassembleOutsideViewFromWalk) by the K6/L4 punch-fan
equivalence pin in ClipFrameLayoutTests, so nothing production-relevant lost
coverage.

H4 — comment truth pass:
- ClipFrameAssembler.cs: rewrote the file-header "renderer uses scissor for
  passes that need that fallback" line and the TerrainClipMode summary's
  "that path never produces Scissor" clause. Deleted the TerrainClipMode
  enum's Scissor member — grepped every reader first
  (WorldRenderDiagnostics.cs, WorldSceneDiagnosticsController.cs,
  WorldSceneRendererTests.cs all only pass the value through to a string
  Append or a mock parameter; WorldSceneRenderer.cs, the only writer, sets
  only Planes (default) or Skip, never Scissor) — kept Planes/Skip for the
  flat path and corrected Skip's stale doc ("No outside_view slice is
  visible; skip landscape indoors", a leftover from the pre-round-2 walk
  meaning) to what the flat path's own writer actually means now ("the
  PView walk ran instead of the flat-world path this frame"). Corrected
  ClipViewSlice's doc: the AABB is retained for WorldRenderDiagnostics only,
  not for a scissor consumer.
- WorldPassSurface.cs: "Four concerns... the clip-frame publication, the
  doorway scissor, gl_ClipDistance enablement, and retail's interior depth
  clear" -> three concerns (the doorway scissor was retired at L2), with a
  pointer to ClearInteriorDepth's own truthful doc. EnableClipDistances'
  "all three world vertex shaders already write 1.0" -> only
  portal_depth.vert writes gl_ClipDistance now (grep: sky.vert and
  terrain_modern.vert/terrain_atmospheric.vert no longer declare it at all
  — L3 deleted the block outright; portal_depth.vert still writes 1.0 past
  its active clip count). RhiWorldPassSurface's class doc dropped "the
  scissor is dynamic state on the borrowed encoder" (no producer left).
- RetailPViewPassExecutorTests.cs: DrawLandscapeDynamicsPhase_CallsDraw
  WeatherOnceExactlyOnce's doc corrected "unconditionally" (false since K2)
  to "conditional on WalkFrameDriver.WeatherTurnFired, see the L1 pin".
  Added the switch-table scope note (matching L8's own note on
  DrawWalkSky_RenderSkyCallSiteHasNoEnclosingBackwardBranch) to the K1 pin's
  doc (DrawLandscapeDynamicsPhase_DrawWeatherOnceCallSiteHasNoEnclosing
  BackwardBranch).
- RetailPViewRenderer.cs: the DrawWeatherOnce citation cited the callee
  address (GameSky::Draw(sky,1) @0x00506ff0) where §10.1 cites the call
  site (@0x00506396) — corrected to cite the call site with the callee
  address alongside it.

H5 — MUTATION CHECK (mandatory, run against the production gate at
RetailPViewRenderer.DrawLandscapeDynamicsPhase):
  Added DrawLandscapeDynamicsPhase_ExactlyOneBranchGuardsDrawWeatherOnce,
  which widens the L1 pin's window to start at the
  DrawUnattachedSceneParticles call (the last call before the whole gate)
  and asserts EXACTLY ONE branch total in that window — the L1 pin's own
  window (getter-call to draw-call) misses a conjoined gate's extra,
  earlier condition because that condition's own brfalse lands BEFORE the
  getter call's offset.
  - Mutation 1 (the conjoined gate this pin exists to reject): changed the
    gate to `if (clipAssembly.OutsideViewSlices.Length != 0 &&
    walkDriver.WeatherTurnFired)`, built, ran the new pin — FAILED with:
      Assert.Single() Failure: The collection contained 2 matching items
      Expected:      (predicate expression)
      Collection:    [CompiledBranch { Offset = 7, OpCode = brfalse.s, TargetOffset = 17 }, CompiledBranch { Offset = 24, OpCode = brfalse.s, TargetOffset = 42 }, CompiledBranch { Offset = 33, OpCode = brfalse.s, TargetOffset = 42 }]
      Match indices: 1, 2
    (offset 7 = the DrawUnattachedSceneParticles block's own guard, correctly
    excluded since it precedes that call's offset; offsets 24 and 33 are the
    conjoined gate's two brfalse's, both inside the widened window — exactly
    the failure this pin exists to catch). Restored the single-flag gate;
    rebuilt; the new pin and all 13 sibling tests in
    RetailPViewPassExecutorTests pass again (14/14).
  - Mutation M1 (the existing round-2 regression: restore the pre-fix gate
    `if (clipAssembly.OutsideViewSlices.Length != 0)`), built, ran the full
    suite — the L1 pin
    (DrawLandscapeDynamicsPhase_GatesDrawWeatherOnceOnWalkDriverWeatherTurnFired)
    still FAILS, as required, with:
      Assert.Equal() Failure: Values differ
      Expected: typeof(AcDream.App.Rendering.Walk.WalkFrameDriver)
      Actual:   typeof(AcDream.App.Rendering.ClipFrameAssembly)
    (the new H5 pin does NOT fail under M1 — a single-flag gate still
    produces exactly one branch in the widened window, which is correct:
    catching "wrong condition source" is L1's job, catching "an extra
    conjoined condition" is H5's). Restored the correct gate; rebuilt;
    14/14 green again.

H6 grep sweep (extends §10.5's L6 sweep per the plan):
  grep -rnE "ScissorNdcAabb|UseScissorFallback|TerrainClipMode\.Scissor|ndcClipAabb|terrainUploadCount" --include=*.cs --include=*.vert --include=*.frag src tests
  -> empty (every mention, including in this commit's own explanatory
  comments, was rephrased to avoid the literal deleted identifiers).
  The base L6 sweep (SetTerrainClip|BeginDoorwayScissor|BindTerrainClip|
  TerrainBytes|active scissor|TerrainClipMode\.Scissor|NdcScissorRect) is
  also still empty.

Gates: dotnet build tests/AcDream.App.Tests -c Release -> 0 warnings / 0
errors. Hermetic lane -> 6825/6825 passed, 0 failed. InstalledDat lane
(ACDREAM_DAT_DIR set) -> 243 passed / 4 known failures (2x #383 layout
tests, TowerAscentReplayTests's TowerAscent KnownFailure,
WalkTraceConformanceTests.Oh_doorway_still_first_frame_diff #458
KnownFailure) / 1 skipped / 248 total — one fewer test than before H3, as
expected from Issue130DoorwayStripTests's deletion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:24:37 +02:00

546 lines
24 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,
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);
}
}
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
// ----------------------------------------------------------------
/// <summary>
/// S3 landing hygiene (H2): frustum-only now. Retail never view-clips
/// terrain (see <see cref="DrawLandCells"/>'s doc); the CPU/GPU
/// clip-region equivalence check this used to also run (a doorway
/// slice's clip-space planes plus its NDC-AABB scissor fallback) had
/// exactly one caller — <see cref="Draw"/> — and that caller's own two
/// clip-region parameters were themselves dead (grep: no production
/// caller ever passed either), so the equivalence helper and its
/// homogeneous-plane subroutine are deleted along with them.
/// </summary>
internal static void CollectVisibleCells(
HashSet<uint> destination,
uint landblockId,
Vector3 worldOrigin,
float zMin,
float zMax,
FrustumPlanes? frustum)
{
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;
}
uint low = AcDream.Core.Physics.TerrainSurface.ComputeOutdoorCellLowId(
cellX * cellSize,
cellY * cellSize);
destination.Add(prefix | low);
}
}
}
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;
}
}