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>
This commit is contained in:
parent
4ee2866a7b
commit
651badc2b9
15 changed files with 1003 additions and 131 deletions
|
|
@ -307,7 +307,7 @@ weather/sky post-pass
|
||||||
| Outside cells reached by the static bbox transit remain in the CELLARRAY. | Dirty `CellTransit` removed an outdoor prune globally, including collision callers. | Correct retail fact, wrong ownership boundary risk. OH3 must port the exact bbox route without mutating unrelated sphere/collision routes. |
|
| Outside cells reached by the static bbox transit remain in the CELLARRAY. | Dirty `CellTransit` removed an outdoor prune globally, including collision callers. | Correct retail fact, wrong ownership boundary risk. OH3 must port the exact bbox route without mutating unrelated sphere/collision routes. |
|
||||||
| Dynamics and attached children inherit the root's exact ordered CELLARRAY and publish exact per-part entries. | `WalkProductionWorldData` buckets whole `RenderProjectionRecord`s by `ShadowObjectRegistry.GetOwnerCells`, resolving equipped children by a parent callback. The draw classifier later tries to restore per-part stamps. | Membership is reconstructed from the collision registry, loses the canonical `CShadowPart` transaction/order, and creates a second model. |
|
| Dynamics and attached children inherit the root's exact ordered CELLARRAY and publish exact per-part entries. | `WalkProductionWorldData` buckets whole `RenderProjectionRecord`s by `ShadowObjectRegistry.GetOwnerCells`, resolving equipped children by a parent callback. The draw classifier later tries to restore per-part stamps. | Membership is reconstructed from the collision registry, loses the canonical `CShadowPart` transaction/order, and creates a second model. |
|
||||||
| `LScape::draw` runs once and visibility unions all views. | `RetailFrameWalk.DrawLandscape` correctly installs one view set and walks blocks/cells once. | Keep. |
|
| `LScape::draw` runs once and visibility unions all views. | `RetailFrameWalk.DrawLandscape` correctly installs one view set and walks blocks/cells once. | Keep. |
|
||||||
| Per landscape cell: terrain -> building -> ordinary objects. | `WalkFrameDriver.HandleLandscapeTurn` emits one whole `TerrainSlice(0)` immediately after sky; all terrain is submitted before `RetailFrameWalk` later emits building/object cell turns. The source comment explicitly calls this an intra-stage simplification. | Confirmed OH5 defect. It can expose background/behind-wall content because retail's depth/painter interleave no longer exists. |
|
| Per landscape cell: terrain -> building -> ordinary objects. | `RetailFrameWalk.DrawLandscape` now emits one `OnLandCellTurn` per admitted land cell BEFORE that cell's own building/object turn (`RenderDeviceD3D::DrawBlock`'s real per-cell interleave), and `WalkFrameDriver.Replay` submits each cell's terrain through a deferred, order-preserving batch rather than one whole pre-stage draw. | FIXED by S3 chunk 3 (commit 671eb3ad4 + fix round 1) — the whole-stage `TerrainSlice(0)`/`DrawTerrainSlice` this row described is deleted outright; row retired. |
|
||||||
| Cell object draw consumes the cell's exact stable far-to-near `shadow_part_list`. | The driver materializes per-cell record buckets, classifies/batches them, and uses a scope-level ordered stream plus separate alpha collection. | OH3/OH7 must replace reconstructed membership/order with exact part entries and exact per-cell alpha barriers. |
|
| Cell object draw consumes the cell's exact stable far-to-near `shadow_part_list`. | The driver materializes per-cell record buckets, classifies/batches them, and uses a scope-level ordered stream plus separate alpha collection. | OH3/OH7 must replace reconstructed membership/order with exact part entries and exact per-cell alpha barriers. |
|
||||||
| Building is alpha drain -> portal pass -> shell, then cell objects. | `RetailFrameWalk.DrawBuilding` and the driver's building events model this order. | Mechanism broadly matches, but its correctness depends on the sibling OH1 built-mesh/view and depth-lifecycle findings. |
|
| Building is alpha drain -> portal pass -> shell, then cell objects. | `RetailFrameWalk.DrawBuilding` and the driver's building events model this order. | Mechanism broadly matches, but its correctness depends on the sibling OH1 built-mesh/view and depth-lifecycle findings. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.Rendering.Scene;
|
using AcDream.App.Rendering.Scene;
|
||||||
using AcDream.App.Rendering.Walk;
|
using AcDream.App.Rendering.Walk;
|
||||||
|
|
@ -74,23 +75,48 @@ internal sealed partial class RetailPViewPassExecutor
|
||||||
|
|
||||||
/// <summary>S3 chunk 3 (§9.2 B1): the walk's per-land-cell terrain
|
/// <summary>S3 chunk 3 (§9.2 B1): the walk's per-land-cell terrain
|
||||||
/// turn — retail <c>RenderDeviceD3D::DrawLandCell</c> @0x0059f120,
|
/// turn — retail <c>RenderDeviceD3D::DrawLandCell</c> @0x0059f120,
|
||||||
/// batched (S3 §9.2 B2). UNCLIPPED for BOTH root kinds — retail never
|
/// batched (S3 §9.2 B2, fix round 1 F2 — a batch may span several
|
||||||
|
/// landblocks). UNCLIPPED for BOTH root kinds — retail never
|
||||||
/// view-clips terrain (<c>LScape::draw</c> draws whole blocks; the
|
/// view-clips terrain (<c>LScape::draw</c> draws whole blocks; the
|
||||||
/// walk's own <c>CellInView</c> admission already decided which cells
|
/// walk's own <c>CellInView</c> admission already decided which cells
|
||||||
/// reach here, and the exit-seal/interior-repaint turn owns aperture
|
/// reach here, and the exit-seal/interior-repaint turn owns aperture
|
||||||
/// exactness afterward, S3 §9.1 R4/R5), so unlike the whole-stage
|
/// exactness afterward, S3 §9.1 R4/R5), so unlike the whole-stage
|
||||||
/// turn this superseded, there is no scissor, no terrain clip, and no
|
/// turn this superseded, there is no scissor, no terrain clip, and no
|
||||||
/// per-slice loop — one call per (already order-merged) batch.</summary>
|
/// per-slice loop — one call per (already order-merged) batch.
|
||||||
|
///
|
||||||
|
/// <para>S3 chunk 3 fix round 1 (F3): times THIS ONE batch with a raw
|
||||||
|
/// <c>Stopwatch.GetTimestamp()</c> delta (no allocation, no per-batch
|
||||||
|
/// stopwatch Restart/Stop) and accumulates it into
|
||||||
|
/// <see cref="TerrainDrawDiagnosticsController.AccumulateWalkBatch"/> —
|
||||||
|
/// <see cref="RetailPViewRenderer.DrawWalkDrivenStatics"/> pushes the
|
||||||
|
/// frame's single resulting sample once, after the whole walk replay
|
||||||
|
/// finishes.</para></summary>
|
||||||
internal void DrawWalkLandCellBatch(
|
internal void DrawWalkLandCellBatch(
|
||||||
RetailPViewFrameInput frame,
|
RetailPViewFrameInput frame,
|
||||||
uint landblockId,
|
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells)
|
||||||
IReadOnlyList<(int SideCellCount, int CellIndex)> cells)
|
|
||||||
{
|
{
|
||||||
_terrainDiagnostics.Begin();
|
long start = Stopwatch.GetTimestamp();
|
||||||
_terrain?.DrawLandCellRuns(landblockId, frame.ViewProjection, cells);
|
_terrain?.DrawLandCells(frame.ViewProjection, cells);
|
||||||
_terrainDiagnostics.Complete();
|
_terrainDiagnostics.AccumulateWalkBatch(Stopwatch.GetTimestamp() - start);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>S3 chunk 3 fix round 1 (F3): pushes THIS FRAME'S single
|
||||||
|
/// accumulated terrain-timing sample (from every
|
||||||
|
/// <see cref="DrawWalkLandCellBatch"/> call this frame) and publishes
|
||||||
|
/// the periodic diagnostic if the cadence is due — the walk path's
|
||||||
|
/// analogue of the deleted whole-stage <c>DrawWalkTerrainSlice</c>
|
||||||
|
/// leaf's own Begin()/Complete() bracket. Called exactly once, at the
|
||||||
|
/// end of the walk replay (<see cref="RetailPViewRenderer.DrawWalkDrivenStatics"/>).</summary>
|
||||||
|
internal void CompleteWalkTerrainFrame() => _terrainDiagnostics.CompleteWalkFrame();
|
||||||
|
|
||||||
|
/// <summary>S3 chunk 3 fix round 1 (F2): the production
|
||||||
|
/// <c>IWalkFrameLeafRenderer.HasRenderableEmittersInCell</c> wiring —
|
||||||
|
/// both <see cref="DrawLandscapeStaticParticles"/> and
|
||||||
|
/// <see cref="DrawCellParticles"/> draw through
|
||||||
|
/// <c>ParticleRenderPass.Scene</c>, so this asks that SAME pass.</summary>
|
||||||
|
internal bool HasWalkRenderableEmittersInCell(uint cellId) =>
|
||||||
|
_particles?.HasRenderableEmittersInCell(ParticleRenderPass.Scene, cellId) ?? false;
|
||||||
|
|
||||||
/// <summary>The walk's punch-fan turn — <c>DrawPortalPolyInternal</c>
|
/// <summary>The walk's punch-fan turn — <c>DrawPortalPolyInternal</c>
|
||||||
/// @0x0059bc90's far-Z punch through <c>PortalDepthMaskRenderer</c>,
|
/// @0x0059bc90's far-Z punch through <c>PortalDepthMaskRenderer</c>,
|
||||||
/// clipped by the pinned view's slice planes (retail
|
/// clipped by the pinned view's slice planes (retail
|
||||||
|
|
@ -209,8 +235,11 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
|
||||||
public void DrawSky() => _passes.DrawWalkSky(_frame, _clipAssembly);
|
public void DrawSky() => _passes.DrawWalkSky(_frame, _clipAssembly);
|
||||||
|
|
||||||
public void DrawLandCellBatch(
|
public void DrawLandCellBatch(
|
||||||
uint landblockId, IReadOnlyList<(int SideCellCount, int CellIndex)> cells) =>
|
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells) =>
|
||||||
_passes.DrawWalkLandCellBatch(_frame, landblockId, cells);
|
_passes.DrawWalkLandCellBatch(_frame, cells);
|
||||||
|
|
||||||
|
public bool HasRenderableEmittersInCell(uint cellId) =>
|
||||||
|
_passes.HasWalkRenderableEmittersInCell(cellId);
|
||||||
|
|
||||||
public void DrawCellShell(uint cellId)
|
public void DrawCellShell(uint cellId)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,13 @@ internal sealed class RetailPViewRenderer
|
||||||
// the sole terrain-visibility authority, so there is no plumbing
|
// the sole terrain-visibility authority, so there is no plumbing
|
||||||
// left to feed here.
|
// left to feed here.
|
||||||
driver.Replay(frame, encoder);
|
driver.Replay(frame, encoder);
|
||||||
|
// S3 chunk 3 fix round 1 (F3): "the end of the walk replay" —
|
||||||
|
// exactly where the deleted whole-stage terrain leaf's own
|
||||||
|
// Begin()/Complete() bracket used to close. Pushes this frame's ONE
|
||||||
|
// accumulated terrain-timing sample (every DrawWalkLandCellBatch
|
||||||
|
// call between here and the last CompleteWalkTerrainFrame) and
|
||||||
|
// publishes the periodic [TERRAIN-DIAG] line if the cadence is due.
|
||||||
|
passes.CompleteWalkTerrainFrame();
|
||||||
|
|
||||||
// Landscape-stage static-owner particles (candles, the cathedral
|
// Landscape-stage static-owner particles (candles, the cathedral
|
||||||
// falls) submit AT THEIR OWN WALK TURNS inside Replay
|
// falls) submit AT THEIR OWN WALK TURNS inside Replay
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,22 @@ internal sealed class RuntimeFramePipelineDiagnosticFactsSource :
|
||||||
_world = world ?? throw new ArgumentNullException(nameof(world));
|
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||||
}
|
}
|
||||||
|
|
||||||
public TerrainRenderDiagnosticFacts CaptureTerrain() => new(
|
public TerrainRenderDiagnosticFacts CaptureTerrain()
|
||||||
_terrain?.VisibleSlots ?? 0,
|
{
|
||||||
_terrain?.LoadedSlots ?? 0,
|
// S3 chunk 3 fix round 1 (F3): only ONE of the two terrain paths
|
||||||
_terrain?.CapacitySlots ?? 0);
|
// draws in a given frame (the walk path never calls Draw() any
|
||||||
|
// more — S3 §9.2 B3). Report the walk path's own per-frame counters
|
||||||
|
// whenever it submitted at least one batch this frame; otherwise
|
||||||
|
// fall back to the non-walk Draw() path's own per-call VisibleSlots
|
||||||
|
// (its "draws" has always equalled its visible-slot count — ONE
|
||||||
|
// MultiDrawIndexedIndirect spanning every visible slot).
|
||||||
|
int walkDraws = _terrain?.WalkDrawCount ?? 0;
|
||||||
|
if (walkDraws > 0)
|
||||||
|
return new(_terrain!.WalkVisibleSlotCount, walkDraws, _terrain.LoadedSlots, _terrain.CapacitySlots);
|
||||||
|
|
||||||
|
int visibleSlots = _terrain?.VisibleSlots ?? 0;
|
||||||
|
return new(visibleSlots, visibleSlots, _terrain?.LoadedSlots ?? 0, _terrain?.CapacitySlots ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
public FramePipelineDiagnosticFacts CaptureFrame() => new(
|
public FramePipelineDiagnosticFacts CaptureFrame() => new(
|
||||||
_presentation?.Diagnostics ?? default,
|
_presentation?.Diagnostics ?? default,
|
||||||
|
|
@ -88,6 +100,10 @@ internal sealed class TerrainDrawDiagnosticsController
|
||||||
private readonly IRenderFrameDiagnosticLog _log;
|
private readonly IRenderFrameDiagnosticLog _log;
|
||||||
private long _lastPublicationMilliseconds;
|
private long _lastPublicationMilliseconds;
|
||||||
|
|
||||||
|
// S3 chunk 3 fix round 1 (F3): the walk path's per-BATCH tick
|
||||||
|
// accumulator — see AccumulateWalkBatch/CompleteWalkFrame.
|
||||||
|
private long _walkAccumulatedTicks;
|
||||||
|
|
||||||
public TerrainDrawDiagnosticsController(
|
public TerrainDrawDiagnosticsController(
|
||||||
bool enabled,
|
bool enabled,
|
||||||
WorldRenderDiagnostics world,
|
WorldRenderDiagnostics world,
|
||||||
|
|
@ -107,6 +123,40 @@ internal sealed class TerrainDrawDiagnosticsController
|
||||||
internal void Complete(long nowMilliseconds)
|
internal void Complete(long nowMilliseconds)
|
||||||
{
|
{
|
||||||
_world.EndTerrainDraw();
|
_world.EndTerrainDraw();
|
||||||
|
PublishIfDue(nowMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>S3 chunk 3 fix round 1 (F3): the leaf calls this once per
|
||||||
|
/// land-cell batch, with that ONE batch's own elapsed
|
||||||
|
/// <c>Stopwatch.GetTimestamp()</c> delta (no allocation, no per-batch
|
||||||
|
/// Stopwatch Restart/Stop — <see cref="Begin"/>/<see cref="Complete()"/>
|
||||||
|
/// stay reserved for the non-walk fallback path's own single bracketed
|
||||||
|
/// call). Accumulates into this frame's running total; <see
|
||||||
|
/// cref="CompleteWalkFrame()"/> converts and pushes it as ONE sample.</summary>
|
||||||
|
public void AccumulateWalkBatch(long elapsedTicks) => _walkAccumulatedTicks += elapsedTicks;
|
||||||
|
|
||||||
|
/// <summary>S3 chunk 3 fix round 1 (F3): call once, after <see
|
||||||
|
/// cref="Walk.WalkFrameDriver.Replay"/> finishes — the walk path's
|
||||||
|
/// analogue of <see cref="Complete()"/> for the deleted whole-stage
|
||||||
|
/// terrain draw's own Begin()/Complete() bracket (superseded by the S3
|
||||||
|
/// chunk 3 per-cell interleave). Converts this frame's accumulated
|
||||||
|
/// per-batch tick total into ONE elapsed-time sample (a frame that
|
||||||
|
/// submitted zero batches still pushes a zero sample — "one sample per
|
||||||
|
/// frame", not "one sample per landscape turn") and resets the
|
||||||
|
/// accumulator for the next frame.</summary>
|
||||||
|
public void CompleteWalkFrame() => CompleteWalkFrame(_enabled ? Environment.TickCount64 : 0L);
|
||||||
|
|
||||||
|
internal void CompleteWalkFrame(long nowMilliseconds)
|
||||||
|
{
|
||||||
|
double ticksToHundredthsMicroseconds = 1_000_000.0 * 100.0 / Stopwatch.Frequency;
|
||||||
|
_world.PushTerrainSample(
|
||||||
|
(long)(_walkAccumulatedTicks * ticksToHundredthsMicroseconds));
|
||||||
|
_walkAccumulatedTicks = 0;
|
||||||
|
PublishIfDue(nowMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PublishIfDue(long nowMilliseconds)
|
||||||
|
{
|
||||||
if (!_enabled
|
if (!_enabled
|
||||||
|| nowMilliseconds - _lastPublicationMilliseconds <= PublicationIntervalMilliseconds)
|
|| nowMilliseconds - _lastPublicationMilliseconds <= PublicationIntervalMilliseconds)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -71,15 +71,43 @@ public sealed partial class TerrainModernRenderer : IDisposable
|
||||||
private readonly HashSet<uint> _walkVisibleLandblocks = new();
|
private readonly HashSet<uint> _walkVisibleLandblocks = new();
|
||||||
private DrawElementsIndirectCommand[] _deicScratch = Array.Empty<DrawElementsIndirectCommand>();
|
private DrawElementsIndirectCommand[] _deicScratch = Array.Empty<DrawElementsIndirectCommand>();
|
||||||
|
|
||||||
// S3 chunk 3 (§9.2 B1): DrawLandCellRuns' reusable index-run scratch —
|
// S3 chunk 3 (§9.2 B1): DrawLandCells' reusable per-entry index-run
|
||||||
// repopulated per call by AppendCellIndexRuns, one (Start, Count) pair
|
// scratch — repopulated per cell by AppendCellIndexRuns, one
|
||||||
// per contiguous run (side 8: one run/cell; side 1: one run total).
|
// (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();
|
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.
|
// Diag.
|
||||||
public int LoadedSlots => _alloc.LoadedCount;
|
public int LoadedSlots => _alloc.LoadedCount;
|
||||||
public int VisibleSlots => _visibleSlots.Count;
|
public int VisibleSlots => _visibleSlots.Count;
|
||||||
public int CapacitySlots => _alloc.Capacity;
|
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>
|
/// <summary>
|
||||||
/// Outdoor landcells admitted by the current landscape view. The set is
|
/// Outdoor landcells admitted by the current landscape view. The set is
|
||||||
/// accumulated across doorway landscape slices and consumed after the
|
/// accumulated across doorway landscape slices and consumed after the
|
||||||
|
|
@ -103,6 +131,10 @@ public sealed partial class TerrainModernRenderer : IDisposable
|
||||||
_retirementLedger.RetryPendingPublications();
|
_retirementLedger.RetryPendingPublications();
|
||||||
_dynamicFrameSlot = frameSlot;
|
_dynamicFrameSlot = frameSlot;
|
||||||
_dynamicFrameStarted = true;
|
_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>
|
/// <summary>
|
||||||
|
|
@ -294,54 +326,100 @@ public sealed partial class TerrainModernRenderer : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// S3 chunk 3 (§9.2 B1/B2): the walk's per-land-cell terrain draw —
|
/// S3 chunk 3 (§9.2 B1/B2), fix round 1 (F1/F2): the walk's per-land-cell
|
||||||
/// retail <c>RenderDeviceD3D::DrawLandCell</c> @0x0059f120, batched.
|
/// terrain draw — retail <c>RenderDeviceD3D::DrawLandCell</c>
|
||||||
/// Submits <see cref="AppendCellIndexRuns"/>'s index runs for every
|
/// @0x0059f120, batched. Submits <see cref="AppendCellIndexRuns"/>'s
|
||||||
/// entry of <paramref name="cells"/> as ONE indirect draw, UNCLIPPED:
|
/// 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
|
/// retail never view-clips terrain — the walk's own per-cell
|
||||||
/// <c>CellInView</c> admission (<c>RetailFrameWalk.DrawLandscape</c>'s
|
/// <c>CellInView</c> admission (<c>RetailFrameWalk.DrawLandscape</c>'s
|
||||||
/// <c>DrawLandCell</c> gate) already decided which cells reach here, so
|
/// <c>DrawLandCell</c> gate) already decided which cells reach here, so
|
||||||
/// there is no frustum test and no <c>inViewLandcells</c> filter here,
|
/// there is no frustum test and no <c>inViewLandcells</c> filter here.
|
||||||
/// unlike the non-walk <see cref="Draw"/> entry point this supersedes
|
/// Fix round 1 F6: this is not a missing culling step — retail has no
|
||||||
/// for the walk path only (<see cref="Draw"/> keeps serving non-walk
|
/// separate terrain frustum test beyond <c>LScape::draw_check_blocks</c>/
|
||||||
/// callers, e.g. the flat/directional-shadow paths). A landblockId with
|
/// <c>landcell_check</c> (ported as <c>WalkLandscape.CheckBlocks</c>),
|
||||||
/// no uploaded slot (a streaming-timing race between the walk's own
|
/// so the walk's own admission IS the sole culling authority for
|
||||||
/// block graph and this renderer's landblock upload) is a silent
|
/// terrain, matching retail exactly. Unlike the non-walk <see cref="Draw"/> entry point this supersedes
|
||||||
/// no-op — the walk's visited-cell bookkeeping is the timing authority,
|
/// for the walk path only (<see cref="Draw"/> keeps serving its one
|
||||||
/// not this call.
|
/// 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>
|
/// </summary>
|
||||||
public void DrawLandCellRuns(
|
public void DrawLandCells(
|
||||||
uint landblockId,
|
|
||||||
Matrix4x4 viewProjection,
|
Matrix4x4 viewProjection,
|
||||||
IReadOnlyList<(int SideCellCount, int CellIndex)> cells)
|
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(cells);
|
ArgumentNullException.ThrowIfNull(cells);
|
||||||
if (cells.Count == 0) return;
|
if (cells.Count == 0) return;
|
||||||
if (!_idToSlot.TryGetValue(landblockId, out int slot)) return;
|
|
||||||
if (!_dynamicFrameStarted)
|
if (!_dynamicFrameStarted)
|
||||||
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
|
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
|
||||||
|
|
||||||
_cellRunScratch.Clear();
|
_batchRunScratch.Clear();
|
||||||
for (int i = 0; i < cells.Count; i++)
|
for (int i = 0; i < cells.Count; i++)
|
||||||
AppendCellIndexRuns(cells[i].SideCellCount, cells[i].CellIndex, _cellRunScratch);
|
{
|
||||||
int commandCount = _cellRunScratch.Count;
|
(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 (commandCount == 0) return;
|
||||||
|
|
||||||
if (_deicScratch.Length < commandCount)
|
if (_deicScratch.Length < commandCount)
|
||||||
_deicScratch = new DrawElementsIndirectCommand[Math.Max(commandCount, 64)];
|
_deicScratch = new DrawElementsIndirectCommand[Math.Max(commandCount, 64)];
|
||||||
uint baseFirstIndex = (uint)(slot * IndicesPerLandblock);
|
|
||||||
for (int i = 0; i < commandCount; i++)
|
for (int i = 0; i < commandCount; i++)
|
||||||
{
|
{
|
||||||
(int start, int count) = _cellRunScratch[i];
|
(uint firstIndex, int count) = _batchRunScratch[i];
|
||||||
_deicScratch[i] = new DrawElementsIndirectCommand
|
_deicScratch[i] = new DrawElementsIndirectCommand
|
||||||
{
|
{
|
||||||
Count = (uint)count,
|
Count = (uint)count,
|
||||||
InstanceCount = 1u,
|
InstanceCount = 1u,
|
||||||
FirstIndex = baseFirstIndex + (uint)start,
|
FirstIndex = firstIndex,
|
||||||
BaseVertex = 0, // baked into indices on upload
|
BaseVertex = 0, // baked into indices on upload
|
||||||
BaseInstance = 0,
|
BaseInstance = 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
_walkDrawsThisFrame++;
|
||||||
DrawRhi(viewProjection, commandCount);
|
DrawRhi(viewProjection, commandCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,24 +102,40 @@ internal interface IWalkFrameLeafRenderer
|
||||||
/// calls this exactly once per frame's Landscape turn).</summary>
|
/// calls this exactly once per frame's Landscape turn).</summary>
|
||||||
void DrawSky();
|
void DrawSky();
|
||||||
|
|
||||||
/// <summary>S3 chunk 3 (§9.2 B1/B2): retail <c>RenderDeviceD3D::
|
/// <summary>S3 chunk 3 (§9.2 B1/B2), fix round 1 (F2): retail
|
||||||
/// DrawLandCell</c> @0x0059f120 — one landblock's terrain at one or
|
/// <c>RenderDeviceD3D::DrawLandCell</c> @0x0059f120 — one or more
|
||||||
/// more admitted LOD cells, submitted together as ONE indirect draw.
|
/// admitted LOD cells, possibly spanning several landblocks, submitted
|
||||||
/// <see cref="WalkFrameDriver.Replay"/> merges consecutive same-slot
|
/// together as ONE indirect draw. <see cref="WalkFrameDriver.Replay"/>
|
||||||
/// <see cref="WalkFrameEventKind.LandCell"/> events with no intervening
|
/// keeps a PENDING batch that <see cref="WalkFrameEventKind.LandCell"/>
|
||||||
/// event before calling this once (order-preserving batching — any
|
/// events append to and flushes (calls this once) immediately before any
|
||||||
/// other event, e.g. a building's alpha barrier or the SAME cell's own
|
/// OTHER event that will itself submit GPU work — see that method's own
|
||||||
/// object-list turn, splits the batch), so a single-cell call is the
|
/// doc comment for the exact flush points, including the F2 particle-turn
|
||||||
/// common case whenever a cell's own <see cref="DrawStaticParticles"/>/
|
/// exception (a genuinely empty <see cref="DrawStaticParticles"/>/<see
|
||||||
/// stream mark interposes. <paramref name="cells"/> carries (side,
|
/// cref="DrawCellParticles"/> turn neither submits nor flushes). This is
|
||||||
/// cellIndex) pairs in event (draw) order — the R3 index-run arithmetic
|
/// order-preserving by construction: the batch's GPU submission point is
|
||||||
/// (contiguous runs inside the landblock's 384-index slot,
|
/// always the SAME point the unbatched terrain draws would have occupied
|
||||||
|
/// (immediately before the next real submission), so pixel output is
|
||||||
|
/// identical to submitting each entry as its own draw. <paramref
|
||||||
|
/// name="cells"/> carries (landblockId, side, cellIndex) triples in
|
||||||
|
/// event (draw) order — the R3 index-run arithmetic (contiguous runs
|
||||||
|
/// inside each entry's own landblock's 384-index slot,
|
||||||
/// <c>TerrainModernRenderer.AppendCellIndexRuns</c>) runs per entry.
|
/// <c>TerrainModernRenderer.AppendCellIndexRuns</c>) runs per entry.
|
||||||
/// Draws UNCLIPPED for both root kinds: retail never view-clips terrain
|
/// Draws UNCLIPPED for both root kinds: retail never view-clips terrain
|
||||||
/// (<c>LScape::draw</c> draws whole blocks; the exit-seal/interior-
|
/// (<c>LScape::draw</c> draws whole blocks; the exit-seal/interior-
|
||||||
/// repaint turn owns aperture exactness afterward, S3 §9.1 R4/R5).</summary>
|
/// repaint turn owns aperture exactness afterward, S3 §9.1 R4/R5).</summary>
|
||||||
void DrawLandCellBatch(
|
void DrawLandCellBatch(
|
||||||
uint landblockId, IReadOnlyList<(int SideCellCount, int CellIndex)> cells);
|
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells);
|
||||||
|
|
||||||
|
/// <summary>S3 chunk 3 fix round 1 (F2): whether <paramref
|
||||||
|
/// name="cellId"/> has any renderable emitter right now — production
|
||||||
|
/// wires this to <c>ParticleSystem.HasRenderableEmittersInCell</c> with
|
||||||
|
/// the SAME <c>ParticleRenderPass.Scene</c> pass both <see
|
||||||
|
/// cref="DrawStaticParticles"/> and <see cref="DrawCellParticles"/>
|
||||||
|
/// already draw through. <see cref="WalkFrameDriver.Replay"/> asks this
|
||||||
|
/// BEFORE flushing the pending terrain batch (or calling either particle
|
||||||
|
/// leaf) so a genuinely empty particle turn submits nothing and does not
|
||||||
|
/// force an otherwise-unneeded terrain flush.</summary>
|
||||||
|
bool HasRenderableEmittersInCell(uint cellId);
|
||||||
|
|
||||||
/// <summary>One committed cell's EnvCell shell —
|
/// <summary>One committed cell's EnvCell shell —
|
||||||
/// <c>PView::DrawCells</c>'s <c>DrawEnvCell</c> @0x005a4abe. Retail first
|
/// <c>PView::DrawCells</c>'s <c>DrawEnvCell</c> @0x005a4abe. Retail first
|
||||||
|
|
@ -563,11 +579,13 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
private readonly List<int> _floodViewRouteScratch = new();
|
private readonly List<int> _floodViewRouteScratch = new();
|
||||||
private WalkPlane _lookInCyPlane;
|
private WalkPlane _lookInCyPlane;
|
||||||
|
|
||||||
/// <summary>S3 chunk 3 (§9.2 B2): <see cref="Replay"/>'s reusable
|
/// <summary>S3 chunk 3 (§9.2 B2), fix round 1 (F2): <see cref="Replay"/>'s
|
||||||
/// batch-merge scratch — the (side, cellIndex) pairs of a run of
|
/// PENDING terrain batch — every <see cref="WalkFrameEventKind.LandCell"/>
|
||||||
/// consecutive same-landblock <see cref="WalkFrameEventKind.LandCell"/>
|
/// event since the last flush, across however many landblocks, in event
|
||||||
/// events, cleared and repopulated at each batch boundary.</summary>
|
/// order. Cleared at the start of each <see cref="Replay"/> call ("frame
|
||||||
private readonly List<(int SideCellCount, int CellIndex)> _landCellBatchScratch = new();
|
/// start") and by every flush; NOT cleared between events, since it is
|
||||||
|
/// the whole point of the deferred-batching rule.</summary>
|
||||||
|
private readonly List<(uint LandblockId, int SideCellCount, int CellIndex)> _pendingTerrainBatch = new();
|
||||||
|
|
||||||
private readonly HashSet<uint> _cellShellsDrawnThisFrame = new();
|
private readonly HashSet<uint> _cellShellsDrawnThisFrame = new();
|
||||||
// Chunk 6 review F1: one particle turn per cell per render stamp (see EmitCellContentsTurn).
|
// Chunk 6 review F1: one particle turn per cell per render stamp (see EmitCellContentsTurn).
|
||||||
|
|
@ -929,6 +947,10 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
if (_stream.Count > 0)
|
if (_stream.Count > 0)
|
||||||
_dispatcher.PrepareOrderedStream(frame, _stream, _viewProjection, _markPositions);
|
_dispatcher.PrepareOrderedStream(frame, _stream, _viewProjection, _markPositions);
|
||||||
|
|
||||||
|
// S3 chunk 3 fix round 1 (F2): cleared at the start of THIS
|
||||||
|
// replay ("frame start") — a batch never survives across frames.
|
||||||
|
_pendingTerrainBatch.Clear();
|
||||||
|
|
||||||
int cursor = 0;
|
int cursor = 0;
|
||||||
int alphaCursor = 0;
|
int alphaCursor = 0;
|
||||||
for (int i = 0; i < _events.Count; i++)
|
for (int i = 0; i < _events.Count; i++)
|
||||||
|
|
@ -937,6 +959,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
switch (e.Kind)
|
switch (e.Kind)
|
||||||
{
|
{
|
||||||
case WalkFrameEventKind.StreamMark:
|
case WalkFrameEventKind.StreamMark:
|
||||||
|
FlushPendingTerrainBatch();
|
||||||
int end = e.IntArg;
|
int end = e.IntArg;
|
||||||
int count = end - cursor;
|
int count = end - cursor;
|
||||||
if (_trace is not null)
|
if (_trace is not null)
|
||||||
|
|
@ -945,6 +968,12 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
cursor = end;
|
cursor = end;
|
||||||
break;
|
break;
|
||||||
case WalkFrameEventKind.AlphaSubmitMark:
|
case WalkFrameEventKind.AlphaSubmitMark:
|
||||||
|
// Not a flush point (F2): an AlphaSubmitMark event is
|
||||||
|
// always recorded alongside — immediately after — a
|
||||||
|
// StreamMark from the SAME MarkIfGrown/MarkAlphaIfGrown
|
||||||
|
// pairing (every Collect-side call site pairs them),
|
||||||
|
// so the StreamMark case above already flushed any
|
||||||
|
// pending batch by the time this one runs.
|
||||||
int alphaEnd = e.IntArg;
|
int alphaEnd = e.IntArg;
|
||||||
for (; alphaCursor < alphaEnd; alphaCursor++)
|
for (; alphaCursor < alphaEnd; alphaCursor++)
|
||||||
{
|
{
|
||||||
|
|
@ -957,52 +986,39 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case WalkFrameEventKind.Sky:
|
case WalkFrameEventKind.Sky:
|
||||||
|
FlushPendingTerrainBatch();
|
||||||
_leafRenderer.DrawSky();
|
_leafRenderer.DrawSky();
|
||||||
break;
|
break;
|
||||||
case WalkFrameEventKind.LandCell:
|
case WalkFrameEventKind.LandCell:
|
||||||
{
|
// S3 chunk 3 fix round 1 (F2): append-only — no
|
||||||
// S3 chunk 3 (§9.2 B2): order-preserving batching —
|
// lookahead, no same-landblock restriction, no
|
||||||
// merge this run of CONSECUTIVE same-landblock
|
// flush. The batch may span several landblocks; it
|
||||||
// LandCell events into ONE DrawLandCellBatch call.
|
// flushes only when a later event actually needs the
|
||||||
// Any other event kind sits between two runs in
|
// GPU (or at Replay's own end).
|
||||||
// _events (Collect recorded it there in retail's
|
_pendingTerrainBatch.Add((e.CellId, e.IntArg >> 8, e.IntArg & 0xFF));
|
||||||
// own order), so this lookahead naturally splits the
|
|
||||||
// batch there without inspecting event kinds itself.
|
|
||||||
uint landblockId = e.CellId;
|
|
||||||
_landCellBatchScratch.Clear();
|
|
||||||
_landCellBatchScratch.Add((e.IntArg >> 8, e.IntArg & 0xFF));
|
|
||||||
int j = i + 1;
|
|
||||||
while (j < _events.Count)
|
|
||||||
{
|
|
||||||
WalkFrameEvent next = _events[j];
|
|
||||||
if (next.Kind != WalkFrameEventKind.LandCell
|
|
||||||
|| next.CellId != landblockId)
|
|
||||||
{
|
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
_landCellBatchScratch.Add((next.IntArg >> 8, next.IntArg & 0xFF));
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
_leafRenderer.DrawLandCellBatch(landblockId, _landCellBatchScratch);
|
|
||||||
i = j - 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case WalkFrameEventKind.CellShell:
|
case WalkFrameEventKind.CellShell:
|
||||||
|
FlushPendingTerrainBatch();
|
||||||
_leafRenderer.DrawCellShell(e.CellId);
|
_leafRenderer.DrawCellShell(e.CellId);
|
||||||
break;
|
break;
|
||||||
case WalkFrameEventKind.PunchFan:
|
case WalkFrameEventKind.PunchFan:
|
||||||
|
FlushPendingTerrainBatch();
|
||||||
_leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg);
|
_leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg);
|
||||||
break;
|
break;
|
||||||
case WalkFrameEventKind.AlphaBarrier:
|
case WalkFrameEventKind.AlphaBarrier:
|
||||||
|
FlushPendingTerrainBatch();
|
||||||
_leafRenderer.AlphaBarrier();
|
_leafRenderer.AlphaBarrier();
|
||||||
break;
|
break;
|
||||||
case WalkFrameEventKind.LandscapeFlush:
|
case WalkFrameEventKind.LandscapeFlush:
|
||||||
|
FlushPendingTerrainBatch();
|
||||||
_leafRenderer.FlushLandscape();
|
_leafRenderer.FlushLandscape();
|
||||||
break;
|
break;
|
||||||
case WalkFrameEventKind.ClearInteriorDepth:
|
case WalkFrameEventKind.ClearInteriorDepth:
|
||||||
|
FlushPendingTerrainBatch();
|
||||||
_leafRenderer.ClearInteriorDepth();
|
_leafRenderer.ClearInteriorDepth();
|
||||||
break;
|
break;
|
||||||
case WalkFrameEventKind.ExitSeals:
|
case WalkFrameEventKind.ExitSeals:
|
||||||
|
FlushPendingTerrainBatch();
|
||||||
// S3 §8.2 B2: the driver — not Collect — owns adding
|
// S3 §8.2 B2: the driver — not Collect — owns adding
|
||||||
// the leaf's returned submitted-fan count to the
|
// the leaf's returned submitted-fan count to the
|
||||||
// persistent PortalsDrawnCount, since the real
|
// persistent PortalsDrawnCount, since the real
|
||||||
|
|
@ -1012,18 +1028,35 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
PortalsDrawnCount += _leafRenderer.DrawExitSeals();
|
PortalsDrawnCount += _leafRenderer.DrawExitSeals();
|
||||||
break;
|
break;
|
||||||
case WalkFrameEventKind.StaticParticles:
|
case WalkFrameEventKind.StaticParticles:
|
||||||
// Retail CPhysicsObj::add_particle_shadow_to_cell
|
// S3 chunk 3 fix round 1 (F2): a genuinely empty
|
||||||
|
// particle turn submits nothing — and, since it
|
||||||
|
// submits nothing, must NOT flush the pending
|
||||||
|
// terrain batch either (the batch is order-preserving
|
||||||
|
// only because a skipped turn changes nothing about
|
||||||
|
// what reaches the GPU, at all). Retail
|
||||||
|
// CPhysicsObj::add_particle_shadow_to_cell
|
||||||
// (0x00514a70): an emitter owns one shadow in its OWN
|
// (0x00514a70): an emitter owns one shadow in its OWN
|
||||||
// current cell, drawn at that cell's object turn
|
// current cell, drawn at that cell's object turn
|
||||||
// regardless of any owner's registry membership —
|
// regardless of any owner's registry membership —
|
||||||
// this is a cell lookup, not an owner union.
|
// this is a cell lookup, not an owner union.
|
||||||
|
if (_leafRenderer.HasRenderableEmittersInCell(e.CellId))
|
||||||
|
{
|
||||||
|
FlushPendingTerrainBatch();
|
||||||
_leafRenderer.DrawStaticParticles(e.CellId);
|
_leafRenderer.DrawStaticParticles(e.CellId);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case WalkFrameEventKind.CellParticles:
|
case WalkFrameEventKind.CellParticles:
|
||||||
|
if (_leafRenderer.HasRenderableEmittersInCell(e.CellId))
|
||||||
|
{
|
||||||
|
FlushPendingTerrainBatch();
|
||||||
_leafRenderer.DrawCellParticles(e.CellId);
|
_leafRenderer.DrawCellParticles(e.CellId);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// S3 chunk 3 fix round 1 (F2): the end of Replay flushes the
|
||||||
|
// remainder — a batch never crosses into the next frame.
|
||||||
|
FlushPendingTerrainBatch();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -1037,6 +1070,21 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>S3 chunk 3 fix round 1 (F2): submits the pending terrain
|
||||||
|
/// batch as ONE <see cref="IWalkFrameLeafRenderer.DrawLandCellBatch"/>
|
||||||
|
/// call, if it holds anything, and clears it. Called immediately before
|
||||||
|
/// every OTHER event kind that will itself submit GPU work — see
|
||||||
|
/// <see cref="Replay"/>'s own doc comment for the full flush-point
|
||||||
|
/// list — so the batch's actual submission point is always the exact
|
||||||
|
/// point the unbatched per-cell draws would have occupied.</summary>
|
||||||
|
private void FlushPendingTerrainBatch()
|
||||||
|
{
|
||||||
|
if (_pendingTerrainBatch.Count == 0)
|
||||||
|
return;
|
||||||
|
_leafRenderer.DrawLandCellBatch(_pendingTerrainBatch);
|
||||||
|
_pendingTerrainBatch.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Output-only Campaign FW cathedral trace. The event list described here
|
/// Output-only Campaign FW cathedral trace. The event list described here
|
||||||
/// is the same list <see cref="Replay"/> immediately executes, so this
|
/// is the same list <see cref="Replay"/> immediately executes, so this
|
||||||
|
|
@ -1109,7 +1157,12 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
order.Append('>').Append(i).Append(":SKY");
|
order.Append('>').Append(i).Append(":SKY");
|
||||||
break;
|
break;
|
||||||
case WalkFrameEventKind.LandCell:
|
case WalkFrameEventKind.LandCell:
|
||||||
order.Append('>').Append(i).Append(":LC").Append(e.CellId.ToString("x8"));
|
// S3 chunk 3 fix round 1 (F6): the trace token now
|
||||||
|
// carries the LOD side/index too — ":LC<lb>/<side>:<idx>".
|
||||||
|
order.Append('>').Append(i).Append(":LC")
|
||||||
|
.Append(e.CellId.ToString("x8"))
|
||||||
|
.Append('/').Append(e.IntArg >> 8)
|
||||||
|
.Append(':').Append(e.IntArg & 0xFF);
|
||||||
break;
|
break;
|
||||||
case WalkFrameEventKind.CellShell when IsCathedralCell(e.CellId):
|
case WalkFrameEventKind.CellShell when IsCathedralCell(e.CellId):
|
||||||
order.Append('>').Append(i).Append(":S")
|
order.Append('>').Append(i).Append(":S")
|
||||||
|
|
@ -1474,11 +1527,15 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||||||
// interleave now emits one WalkFrameEventKind.LandCell turn per
|
// interleave now emits one WalkFrameEventKind.LandCell turn per
|
||||||
// admitted land cell (IWalkEventSink.OnLandCellTurn, fired from
|
// admitted land cell (IWalkEventSink.OnLandCellTurn, fired from
|
||||||
// RetailFrameWalk.DrawLandscape's own per-cell loop), not a single
|
// RetailFrameWalk.DrawLandscape's own per-cell loop), not a single
|
||||||
// TerrainSlice(0) here. Drawing terrain BEFORE every building would
|
// TerrainSlice(0) here. S3 chunk 3 fix round 1 (F6) corrects the
|
||||||
// let a nearer building's far-Z punch survive under farther terrain
|
// direction of this note: drawing ALL terrain BEFORE every building
|
||||||
// that draws afterward and overwrite it — the doorway-behind-a-hill
|
// let a FARTHER building's far-Z punch survive, because the NEARER
|
||||||
// fragment bug the interleave removes by ORDER alone. The walk's
|
// terrain that retail draws AFTER that punch (and which overwrites
|
||||||
// active views still own only the punch fans and dynamics
|
// it) was instead drawn BEFORE it here — nothing later covered the
|
||||||
|
// punched depth, so a doorway behind a hill punched straight through
|
||||||
|
// it. The interleave fixes this by ORDER alone: nearer terrain now
|
||||||
|
// draws AFTER the farther building's punch, exactly like retail. The
|
||||||
|
// walk's active views still own only the punch fans and dynamics
|
||||||
// apertures; terrain itself is UNCLIPPED for both root kinds.
|
// apertures; terrain itself is UNCLIPPED for both root kinds.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ internal readonly record struct RenderGlScissorSnapshot(
|
||||||
|
|
||||||
internal readonly record struct TerrainRenderDiagnosticFacts(
|
internal readonly record struct TerrainRenderDiagnosticFacts(
|
||||||
int VisibleSlots,
|
int VisibleSlots,
|
||||||
|
int Draws,
|
||||||
int LoadedSlots,
|
int LoadedSlots,
|
||||||
int CapacitySlots);
|
int CapacitySlots);
|
||||||
|
|
||||||
|
|
@ -85,6 +86,15 @@ internal sealed class WorldRenderDiagnostics
|
||||||
(long)(_terrainStopwatch.Elapsed.TotalMicroseconds * 100.0));
|
(long)(_terrainStopwatch.Elapsed.TotalMicroseconds * 100.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>S3 chunk 3 fix round 1 (F3): the walk path's per-frame
|
||||||
|
/// analogue of <see cref="EndTerrainDraw"/> — pushes ONE precomputed
|
||||||
|
/// elapsed-time sample (the sum of every land-cell batch's own
|
||||||
|
/// Stopwatch.GetTimestamp() delta this frame, converted by the caller)
|
||||||
|
/// instead of stopping this owner's own stopwatch, since the walk path
|
||||||
|
/// times many small batches per frame rather than one bracketed call.</summary>
|
||||||
|
public void PushTerrainSample(long elapsedHundredthsMicroseconds) =>
|
||||||
|
_terrainSamples.PushHundredthsMicroseconds(elapsedHundredthsMicroseconds);
|
||||||
|
|
||||||
public void PublishTerrainDiagnostics(TerrainRenderDiagnosticFacts facts)
|
public void PublishTerrainDiagnostics(TerrainRenderDiagnosticFacts facts)
|
||||||
{
|
{
|
||||||
RollingTimingPercentiles timing = _terrainSamples.Snapshot();
|
RollingTimingPercentiles timing = _terrainSamples.Snapshot();
|
||||||
|
|
@ -93,7 +103,7 @@ internal sealed class WorldRenderDiagnostics
|
||||||
string budget = medianMicroseconds > 1000.0 ? " BUDGET_OVER" : string.Empty;
|
string budget = medianMicroseconds > 1000.0 ? " BUDGET_OVER" : string.Empty;
|
||||||
_log.WriteLine(
|
_log.WriteLine(
|
||||||
$"[TERRAIN-DIAG]{budget} cpu_us={medianMicroseconds:F2}m/"
|
$"[TERRAIN-DIAG]{budget} cpu_us={medianMicroseconds:F2}m/"
|
||||||
+ $"{p95Microseconds:F2}p95 draws={facts.VisibleSlots}/frame "
|
+ $"{p95Microseconds:F2}p95 draws={facts.Draws}/frame "
|
||||||
+ $"visible={facts.VisibleSlots} loaded={facts.LoadedSlots} "
|
+ $"visible={facts.VisibleSlots} loaded={facts.LoadedSlots} "
|
||||||
+ $"capacity={facts.CapacitySlots}");
|
+ $"capacity={facts.CapacitySlots}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -617,6 +617,24 @@ public sealed class ParticleSystem : IParticleSystem
|
||||||
/// warmup: the bucket's own sorted handle list is copied through the
|
/// warmup: the bucket's own sorted handle list is copied through the
|
||||||
/// retained <see cref="_scopeHandleScratch"/> buffer.
|
/// retained <see cref="_scopeHandleScratch"/> buffer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// S3 chunk 3 fix round 1 (F2): whether <paramref name="cellId"/> has any
|
||||||
|
/// renderable emitter for <paramref name="renderPass"/>, WITHOUT copying
|
||||||
|
/// the bucket's handle list — <see cref="CopyRenderableEmittersInCell"/>'s
|
||||||
|
/// allocation-free sibling for a Replay-time gate
|
||||||
|
/// (<c>WalkFrameDriver.Replay</c>'s <c>StaticParticles</c>/<c>CellParticles</c>
|
||||||
|
/// arms) that must not force a pending terrain-batch flush for a
|
||||||
|
/// genuinely empty particle turn. A bucket's <c>FirstRenderableHandle</c>
|
||||||
|
/// is 0 exactly when it holds no renderable handle (handles start at 1),
|
||||||
|
/// so this is a single dictionary lookup plus a field read.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasRenderableEmittersInCell(ParticleRenderPass renderPass, uint cellId)
|
||||||
|
{
|
||||||
|
int passIndex = RenderPassIndex(renderPass);
|
||||||
|
return _cellHandlesByPass[passIndex].TryGetValue(cellId, out OwnerEmitterBucket? bucket)
|
||||||
|
&& bucket.FirstRenderableHandle != 0;
|
||||||
|
}
|
||||||
|
|
||||||
public void CopyRenderableEmittersInCell(
|
public void CopyRenderableEmittersInCell(
|
||||||
ParticleRenderPass renderPass,
|
ParticleRenderPass renderPass,
|
||||||
uint cellId,
|
uint cellId,
|
||||||
|
|
|
||||||
|
|
@ -27,27 +27,58 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Concrete_executor_brackets_terrain_diagnostics()
|
public void Concrete_executor_accumulates_walk_terrain_batch_timing()
|
||||||
{
|
{
|
||||||
|
// S3 chunk 3 fix round 1 (F3): the walk leaf no longer brackets
|
||||||
|
// itself with Begin()/Complete() (that stopwatch-restart pair would
|
||||||
|
// push one timing SAMPLE per batch, not one per frame) — it times
|
||||||
|
// itself with a raw Stopwatch.GetTimestamp() delta and hands the
|
||||||
|
// elapsed ticks to AccumulateWalkBatch, which only accumulates.
|
||||||
MethodInfo landscape = typeof(RetailPViewPassExecutor).GetMethod(
|
MethodInfo landscape = typeof(RetailPViewPassExecutor).GetMethod(
|
||||||
"DrawWalkLandCellBatch",
|
"DrawWalkLandCellBatch",
|
||||||
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||||
IReadOnlyList<CompiledCall> landscapeCalls = CompiledCallGraph.Read(landscape);
|
IReadOnlyList<CompiledCall> landscapeCalls = CompiledCallGraph.Read(landscape);
|
||||||
int diagnosticsBegin = RequiredCallIndex(
|
|
||||||
landscapeCalls,
|
|
||||||
typeof(TerrainDrawDiagnosticsController),
|
|
||||||
nameof(TerrainDrawDiagnosticsController.Begin));
|
|
||||||
int terrainDraw = RequiredCallIndex(
|
int terrainDraw = RequiredCallIndex(
|
||||||
landscapeCalls,
|
landscapeCalls,
|
||||||
typeof(TerrainModernRenderer),
|
typeof(TerrainModernRenderer),
|
||||||
nameof(TerrainModernRenderer.DrawLandCellRuns));
|
nameof(TerrainModernRenderer.DrawLandCells));
|
||||||
int diagnosticsComplete = RequiredCallIndex(
|
int accumulate = RequiredCallIndex(
|
||||||
landscapeCalls,
|
landscapeCalls,
|
||||||
typeof(TerrainDrawDiagnosticsController),
|
typeof(TerrainDrawDiagnosticsController),
|
||||||
nameof(TerrainDrawDiagnosticsController.Complete));
|
nameof(TerrainDrawDiagnosticsController.AccumulateWalkBatch));
|
||||||
|
|
||||||
Assert.True(diagnosticsBegin < terrainDraw);
|
Assert.True(terrainDraw < accumulate);
|
||||||
Assert.True(terrainDraw < diagnosticsComplete);
|
Assert.DoesNotContain(
|
||||||
|
landscapeCalls,
|
||||||
|
call => call.Target.DeclaringType == typeof(TerrainDrawDiagnosticsController)
|
||||||
|
&& call.Target.Name == nameof(TerrainDrawDiagnosticsController.Begin));
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
landscapeCalls,
|
||||||
|
call => call.Target.DeclaringType == typeof(TerrainDrawDiagnosticsController)
|
||||||
|
&& call.Target.Name == nameof(TerrainDrawDiagnosticsController.Complete));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Concrete_executor_pushes_the_walk_terrain_frame_sample_at_replay_end()
|
||||||
|
{
|
||||||
|
// S3 chunk 3 fix round 1 (F3): DrawWalkDrivenStatics is the ONE call
|
||||||
|
// site of driver.Replay in production — CompleteWalkTerrainFrame
|
||||||
|
// must run immediately after it, so the frame's sample is pushed
|
||||||
|
// exactly once, at "the end of the walk replay".
|
||||||
|
MethodInfo drawWalkDrivenStatics = typeof(RetailPViewRenderer).GetMethod(
|
||||||
|
"DrawWalkDrivenStatics",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||||
|
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(drawWalkDrivenStatics);
|
||||||
|
int replay = RequiredCallIndex(
|
||||||
|
calls,
|
||||||
|
typeof(AcDream.App.Rendering.Walk.WalkFrameDriver),
|
||||||
|
nameof(AcDream.App.Rendering.Walk.WalkFrameDriver.Replay));
|
||||||
|
int completeWalkFrame = RequiredCallIndex(
|
||||||
|
calls,
|
||||||
|
typeof(RetailPViewPassExecutor),
|
||||||
|
nameof(RetailPViewPassExecutor.CompleteWalkTerrainFrame));
|
||||||
|
|
||||||
|
Assert.True(replay < completeWalkFrame);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,72 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
||||||
Assert.Equal(4, log.Messages.Count);
|
Assert.Equal(4, log.Messages.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── S3 chunk 3 fix round 1 (F3): the walk path's own per-frame timing
|
||||||
|
// bracket — AccumulateWalkBatch/CompleteWalkFrame replace the deleted
|
||||||
|
// whole-stage terrain leaf's Begin()/Complete() bracket for the walk
|
||||||
|
// path only; Begin()/Complete() stay reserved for the non-walk
|
||||||
|
// fallback path (TerrainDrawDiagnosticsControllerTests above pin
|
||||||
|
// those unchanged). ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompleteWalkFrame_PublishesOnTheSameCadenceAsComplete()
|
||||||
|
{
|
||||||
|
var log = new RecordingLog();
|
||||||
|
var facts = new RecordingFacts();
|
||||||
|
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||||
|
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||||
|
|
||||||
|
controller.AccumulateWalkBatch(1_000);
|
||||||
|
controller.AccumulateWalkBatch(2_000);
|
||||||
|
controller.CompleteWalkFrame(10_000);
|
||||||
|
|
||||||
|
Assert.Equal(1, facts.TerrainCaptureCount);
|
||||||
|
Assert.Equal(1, facts.FrameCaptureCount);
|
||||||
|
Assert.Equal(2, log.Messages.Count);
|
||||||
|
Assert.StartsWith("[TERRAIN-DIAG]", log.Messages[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompleteWalkFrame_WithNoAccumulatedBatchesStillPublishesOnCadence()
|
||||||
|
{
|
||||||
|
// F3: "a frame that submitted zero batches still pushes a zero
|
||||||
|
// sample — one sample per frame, not one sample per landscape
|
||||||
|
// turn" — CompleteWalkFrame's own cadence/publish behavior does
|
||||||
|
// not depend on AccumulateWalkBatch ever having been called.
|
||||||
|
var log = new RecordingLog();
|
||||||
|
var facts = new RecordingFacts();
|
||||||
|
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||||
|
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||||
|
|
||||||
|
controller.CompleteWalkFrame(10_000);
|
||||||
|
|
||||||
|
Assert.Equal(1, facts.TerrainCaptureCount);
|
||||||
|
Assert.Equal(2, log.Messages.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompleteWalkFrame_ResetsTheAccumulatorAcrossFrames()
|
||||||
|
{
|
||||||
|
var log = new RecordingLog();
|
||||||
|
var facts = new RecordingFacts();
|
||||||
|
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||||
|
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||||
|
|
||||||
|
controller.AccumulateWalkBatch(5_000);
|
||||||
|
controller.CompleteWalkFrame(10_000);
|
||||||
|
// A second frame's own cadence-gated publish (still inside the
|
||||||
|
// interval) proves the first frame's ticks were not left to bleed
|
||||||
|
// into a value that could only be observed via the [TERRAIN-DIAG]
|
||||||
|
// line's cpu_us field — the accumulator itself is private, so this
|
||||||
|
// asserts indirectly: a second CompleteWalkFrame with ZERO new
|
||||||
|
// batches, once the cadence is due again, does not throw and still
|
||||||
|
// reports a fresh (not doubled) sample.
|
||||||
|
controller.CompleteWalkFrame(15_001);
|
||||||
|
|
||||||
|
Assert.Equal(2, facts.TerrainCaptureCount);
|
||||||
|
Assert.Equal(4, log.Messages.Count);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class RecordingFacts : IFramePipelineDiagnosticFactsSource
|
private sealed class RecordingFacts : IFramePipelineDiagnosticFactsSource
|
||||||
{
|
{
|
||||||
public int TerrainCaptureCount { get; private set; }
|
public int TerrainCaptureCount { get; private set; }
|
||||||
|
|
@ -95,7 +161,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
||||||
public TerrainRenderDiagnosticFacts CaptureTerrain()
|
public TerrainRenderDiagnosticFacts CaptureTerrain()
|
||||||
{
|
{
|
||||||
TerrainCaptureCount++;
|
TerrainCaptureCount++;
|
||||||
return new TerrainRenderDiagnosticFacts(1, 2, 3);
|
return new TerrainRenderDiagnosticFacts(VisibleSlots: 1, Draws: 1, LoadedSlots: 2, CapacitySlots: 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FramePipelineDiagnosticFacts CaptureFrame()
|
public FramePipelineDiagnosticFacts CaptureFrame()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Gpu;
|
||||||
|
using AcDream.App.Rendering.Gpu.Vk;
|
||||||
|
using AcDream.App.Tests.Rendering.Gpu;
|
||||||
|
using AcDream.Content;
|
||||||
|
using AcDream.Core.Terrain;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Lib.IO;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S3 chunk 3 fix round 1 (§9.6 F1): <c>TerrainModernRenderer._idToSlot</c>
|
||||||
|
/// is keyed by the DAT landblock id <c>0xXXYYFFFF</c>
|
||||||
|
/// (<c>LandblockRenderPublisher.LandblockId => Build.Landblock.LandblockId</c>,
|
||||||
|
/// which <see cref="TerrainModernRenderer.AddLandblock"/> stores under
|
||||||
|
/// verbatim), while the walk hands <c>0xXXYY0000</c>
|
||||||
|
/// (<c>WalkLandBlock.LandblockId = bx<<24 | by<<16</c>). Before
|
||||||
|
/// this fix, <see cref="TerrainModernRenderer.DrawLandCells"/> normalized
|
||||||
|
/// nothing, so every walk-path lookup missed and the walk drew NO terrain.
|
||||||
|
/// This pins the fix end-to-end through the REAL RHI submission path (a
|
||||||
|
/// <see cref="RecordingGpuDevice"/>, not a mock of the lookup alone) — an
|
||||||
|
/// entry keyed by the walk's <c>0xA9B40000</c> convention must resolve the
|
||||||
|
/// SAME slot <see cref="TerrainModernRenderer.AddLandblock"/> published
|
||||||
|
/// under the DAT's <c>0xA9B4FFFF</c> convention, and an entry for a
|
||||||
|
/// genuinely unknown landblock must be a silent per-entry no-op rather than
|
||||||
|
/// a thrown exception or a spurious draw.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TerrainWalkSlotKeyNormalizationTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly RecordingGpuDevice _device = new();
|
||||||
|
private readonly GpuDeviceFrameLifetime _frameLifetime;
|
||||||
|
private readonly VulkanWorldPassScope _scope = new(sampleCount: 1);
|
||||||
|
private readonly TerrainAtlas _atlas;
|
||||||
|
private readonly TerrainModernRenderer _terrain;
|
||||||
|
|
||||||
|
public TerrainWalkSlotKeyNormalizationTests()
|
||||||
|
{
|
||||||
|
_frameLifetime = new GpuDeviceFrameLifetime(_device);
|
||||||
|
// A Region with no TerrainInfo takes BuildBackendNeutral's own
|
||||||
|
// documented single-white-fallback-layer branch — no installed DAT
|
||||||
|
// needed, and this suite stays hermetic.
|
||||||
|
_atlas = TerrainAtlas.BuildBackendNeutral(_device, new EmptyRegionDats());
|
||||||
|
_terrain = new TerrainModernRenderer(_device, _frameLifetime, _scope, _atlas, _device.Retirement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DrawScope BeginDraw()
|
||||||
|
{
|
||||||
|
_frameLifetime.BeginFrame();
|
||||||
|
IGpuFrame frame = _frameLifetime.CurrentFrame!;
|
||||||
|
IGpuPassEncoder pass = frame.BeginPass(
|
||||||
|
GpuPassDescription.BackbufferClear(
|
||||||
|
"s3-chunk3-fix-round-1-f1", Vector4.Zero, sampleCount: 1));
|
||||||
|
IDisposable publication = _scope.Publish(pass);
|
||||||
|
_device.Clear();
|
||||||
|
return new DrawScope(frame, pass, publication);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly struct DrawScope(
|
||||||
|
IGpuFrame frame, IGpuPassEncoder pass, IDisposable publication) : IDisposable
|
||||||
|
{
|
||||||
|
public IGpuFrame Frame { get; } = frame;
|
||||||
|
public IGpuPassEncoder Pass { get; } = pass;
|
||||||
|
|
||||||
|
public void Dispose() => publication.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LandblockMeshData MakeFullSizeMesh()
|
||||||
|
{
|
||||||
|
var vertices = new TerrainVertex[LandblockMesh.VerticesPerLandblock];
|
||||||
|
var indices = new uint[LandblockMesh.VerticesPerLandblock];
|
||||||
|
for (int i = 0; i < indices.Length; i++)
|
||||||
|
indices[i] = (uint)i;
|
||||||
|
return new LandblockMeshData(vertices, indices);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawLandCells_ResolvesTheDatSlotFromTheWalksLowWordZeroLandblockId()
|
||||||
|
{
|
||||||
|
// Stored under the DAT id (LandblockRenderPublisher's own
|
||||||
|
// convention, low word 0xFFFF).
|
||||||
|
_terrain.AddLandblock(0xA9B4FFFFu, MakeFullSizeMesh(), Vector3.Zero);
|
||||||
|
|
||||||
|
using DrawScope draw = BeginDraw();
|
||||||
|
_terrain.BeginFrame(frameSlot: 0);
|
||||||
|
// The walk's own convention (WalkLandBlock.LandblockId), low word
|
||||||
|
// 0x0000 — F1's normalization must still find the slot above.
|
||||||
|
_terrain.DrawLandCells(
|
||||||
|
Matrix4x4.Identity,
|
||||||
|
new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xA9B40000u, 8, 0) });
|
||||||
|
|
||||||
|
GpuRecordedMultiDrawIndirect call = Assert.Single(
|
||||||
|
_device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
|
||||||
|
Assert.Equal(1u, call.DrawCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawLandCells_UnknownLandblockIsASilentNoOp()
|
||||||
|
{
|
||||||
|
using DrawScope draw = BeginDraw();
|
||||||
|
_terrain.BeginFrame(frameSlot: 0);
|
||||||
|
|
||||||
|
_terrain.DrawLandCells(
|
||||||
|
Matrix4x4.Identity,
|
||||||
|
new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xDEAD0000u, 8, 0) });
|
||||||
|
|
||||||
|
Assert.Empty(_device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
|
||||||
|
Assert.Equal(0, _terrain.WalkDrawCount);
|
||||||
|
Assert.Equal(0, _terrain.WalkVisibleSlotCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawLandCells_KnownAndUnknownLandblocksInOneBatch_SubmitsOnlyTheKnownEntry()
|
||||||
|
{
|
||||||
|
_terrain.AddLandblock(0xA9B4FFFFu, MakeFullSizeMesh(), Vector3.Zero);
|
||||||
|
|
||||||
|
using DrawScope draw = BeginDraw();
|
||||||
|
_terrain.BeginFrame(frameSlot: 0);
|
||||||
|
_terrain.DrawLandCells(
|
||||||
|
Matrix4x4.Identity,
|
||||||
|
new (uint LandblockId, int SideCellCount, int CellIndex)[]
|
||||||
|
{
|
||||||
|
(0xDEAD0000u, 8, 0), (0xA9B40000u, 8, 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
GpuRecordedMultiDrawIndirect call = Assert.Single(
|
||||||
|
_device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
|
||||||
|
Assert.Equal(1u, call.DrawCount);
|
||||||
|
Assert.Equal(1, _terrain.WalkDrawCount);
|
||||||
|
Assert.Equal(1, _terrain.WalkVisibleSlotCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_terrain.Dispose();
|
||||||
|
_atlas.Dispose();
|
||||||
|
_device.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal hermetic <see cref="IDatReaderWriter"/> — every read
|
||||||
|
/// misses except <c>Get<Region>(0x13000000)</c>, which returns a
|
||||||
|
/// Region with no <c>TerrainInfo</c> so <see
|
||||||
|
/// cref="TerrainAtlas.BuildBackendNeutral"/> takes its documented
|
||||||
|
/// single-white-fallback-layer branch instead of throwing.</summary>
|
||||||
|
private sealed class EmptyRegionDats : IDatReaderWriter
|
||||||
|
{
|
||||||
|
private readonly Region _region = new();
|
||||||
|
private readonly StubDatabase _portal = new();
|
||||||
|
private readonly StubDatabase _highRes = new();
|
||||||
|
private readonly StubDatabase _language = new();
|
||||||
|
private readonly StubDatabase _cell = new();
|
||||||
|
|
||||||
|
public string SourceDirectory => string.Empty;
|
||||||
|
|
||||||
|
public IDatDatabase Portal => _portal;
|
||||||
|
|
||||||
|
public IDatDatabase Cell => _cell;
|
||||||
|
|
||||||
|
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
|
||||||
|
new(new Dictionary<uint, IDatDatabase>());
|
||||||
|
|
||||||
|
public IDatDatabase HighRes => _highRes;
|
||||||
|
|
||||||
|
public IDatDatabase Language => _language;
|
||||||
|
|
||||||
|
public IDatDatabase Local => _language;
|
||||||
|
|
||||||
|
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
||||||
|
new(new Dictionary<uint, uint>());
|
||||||
|
|
||||||
|
public int PortalIteration => 0;
|
||||||
|
|
||||||
|
public int CellIteration => 0;
|
||||||
|
|
||||||
|
public int HighResIteration => 0;
|
||||||
|
|
||||||
|
public int LanguageIteration => 0;
|
||||||
|
|
||||||
|
public bool TryGetFileBytes(
|
||||||
|
uint regionId,
|
||||||
|
uint fileId,
|
||||||
|
ref byte[] bytes,
|
||||||
|
out int bytesRead)
|
||||||
|
{
|
||||||
|
bytesRead = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
||||||
|
Array.Empty<uint>();
|
||||||
|
|
||||||
|
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
||||||
|
Array.Empty<IDatReaderWriter.IdResolution>();
|
||||||
|
|
||||||
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public bool TrySave<T>(
|
||||||
|
uint regionId,
|
||||||
|
T obj,
|
||||||
|
int iteration = 0) where T : IDBObj =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
[return: MaybeNull]
|
||||||
|
public T Get<T>(uint fileId) where T : IDBObj
|
||||||
|
{
|
||||||
|
if (typeof(T) == typeof(Region) && fileId == 0x13000000u)
|
||||||
|
return (T)(object)_region;
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGet<T>(
|
||||||
|
uint fileId,
|
||||||
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||||
|
{
|
||||||
|
value = Get<T>(fileId);
|
||||||
|
return value is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubDatabase : IDatDatabase
|
||||||
|
{
|
||||||
|
public DatDatabase Db => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public int Iteration => 0;
|
||||||
|
|
||||||
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
||||||
|
Array.Empty<uint>();
|
||||||
|
|
||||||
|
public bool TryGet<T>(
|
||||||
|
uint fileId,
|
||||||
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetFileBytes(
|
||||||
|
uint fileId,
|
||||||
|
[MaybeNullWhen(false)] out byte[] value)
|
||||||
|
{
|
||||||
|
value = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetFileBytes(
|
||||||
|
uint fileId,
|
||||||
|
ref byte[] bytes,
|
||||||
|
out int bytesRead)
|
||||||
|
{
|
||||||
|
bytesRead = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -244,8 +244,14 @@ public sealed class RetailFrameWalkTests
|
||||||
walk.DrawLandscape(landscape, new WalkPortalView(), ctx, recorder);
|
walk.DrawLandscape(landscape, new WalkPortalView(), ctx, recorder);
|
||||||
|
|
||||||
Assert.Equal("LS", recorder.Combined[0]);
|
Assert.Equal("LS", recorder.Combined[0]);
|
||||||
Assert.DoesNotContain(
|
// S3 chunk 3 fix round 1 (F6): the "no TERRAIN event" assertion this
|
||||||
recorder.Combined, e => e.StartsWith("TERRAIN", StringComparison.Ordinal));
|
// line used to make was vacuous — this Recorder's own vocabulary
|
||||||
|
// (Combined) never had a "TERRAIN"-prefixed entry to begin with, so
|
||||||
|
// the assertion could never fail. WalkFrameEventKind has no
|
||||||
|
// TerrainSlice case any more (chunk 3 deleted it outright), and the
|
||||||
|
// driver-level pins in WalkFrameDriverTests (F4: the outdoor-root,
|
||||||
|
// interior-root, and cross-landblock-batching RunFrame tests) are
|
||||||
|
// what actually prove no whole-stage terrain draw survives.
|
||||||
|
|
||||||
const uint farCellId = 0x22220001u;
|
const uint farCellId = 0x22220001u;
|
||||||
int farLcIndex = recorder.Combined.IndexOf($"LC:{farCellId:x8}");
|
int farLcIndex = recorder.Combined.IndexOf($"LC:{farCellId:x8}");
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,16 @@ public sealed class WalkFrameDriverTests
|
||||||
public readonly List<WalkPolygon> Punches = new();
|
public readonly List<WalkPolygon> Punches = new();
|
||||||
public readonly List<uint> Shells = new();
|
public readonly List<uint> Shells = new();
|
||||||
public readonly List<int> AlphaPendingAtBarrier = new();
|
public readonly List<int> AlphaPendingAtBarrier = new();
|
||||||
public readonly List<(uint LandblockId, int CellCount)> LandCellBatches = new();
|
public readonly List<(uint LandblockId, int SideCellCount, int CellIndex)[]> LandCellBatches = new();
|
||||||
|
|
||||||
|
/// <summary>S3 chunk 3 fix round 1 (F2/F4c): cell ids
|
||||||
|
/// <see cref="HasRenderableEmittersInCell"/> reports as having NO
|
||||||
|
/// renderable emitter. Empty by default so EVERY pre-existing pin in
|
||||||
|
/// this file keeps its old "every StaticParticles/CellParticles turn
|
||||||
|
/// submits unconditionally" behavior unchanged; a test proving the
|
||||||
|
/// new "genuinely empty turn submits nothing and does not flush"
|
||||||
|
/// rule opts specific cells OUT via this set.</summary>
|
||||||
|
public readonly HashSet<uint> CellsWithoutEmitters = new();
|
||||||
|
|
||||||
/// <summary>S3 chunk 2: the exit-seal polygon count this fake
|
/// <summary>S3 chunk 2: the exit-seal polygon count this fake
|
||||||
/// reports back to the driver (B2 — <see cref="DrawExitSeals"/>
|
/// reports back to the driver (B2 — <see cref="DrawExitSeals"/>
|
||||||
|
|
@ -62,12 +71,15 @@ public sealed class WalkFrameDriverTests
|
||||||
public void DrawSky() => log.Add("SKY");
|
public void DrawSky() => log.Add("SKY");
|
||||||
|
|
||||||
public void DrawLandCellBatch(
|
public void DrawLandCellBatch(
|
||||||
uint landblockId, IReadOnlyList<(int SideCellCount, int CellIndex)> cells)
|
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells)
|
||||||
{
|
{
|
||||||
LandCellBatches.Add((landblockId, cells.Count));
|
LandCellBatches.Add(cells.ToArray());
|
||||||
log.Add($"LANDCELL:{landblockId:x8}:{cells.Count}");
|
log.Add("LANDCELL:" + string.Join(
|
||||||
|
',', cells.Select(c => $"{c.LandblockId:x8}:{c.SideCellCount}:{c.CellIndex}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool HasRenderableEmittersInCell(uint cellId) => !CellsWithoutEmitters.Contains(cellId);
|
||||||
|
|
||||||
public void DrawCellShell(uint cellId)
|
public void DrawCellShell(uint cellId)
|
||||||
{
|
{
|
||||||
Shells.Add(cellId);
|
Shells.Add(cellId);
|
||||||
|
|
@ -1301,50 +1313,246 @@ public sealed class WalkFrameDriverTests
|
||||||
Assert.Equal(1, fx.AlphaQueue.PendingCount);
|
Assert.Equal(1, fx.AlphaQueue.PendingCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── T4 (S3 chunk 3 §9.3): order-preserving batching — consecutive
|
// ── F4(a) (S3 chunk 3 fix round 1 §9.6): an outdoor-root sequence — the
|
||||||
// same-landblock LandCell turns with NO intervening event merge into
|
// LandCell terrain turn precedes its own cell's object-list turn, and
|
||||||
// ONE DrawLandCellBatch call; any other event (here, a building's own
|
// (since nothing else intervenes) the whole frame's terrain stays one
|
||||||
// alpha barrier) splits the batch; a later run of the SAME landblock
|
// PENDING batch until Replay's own end. Drives RetailFrameWalk.
|
||||||
// after a split is its own new batch; a different landblock never
|
// DrawLandscape directly with a ZERO-view WalkPortalView — the SAME
|
||||||
// merges with a prior one even when adjacent. ────────────────────────
|
// deterministic "CY-only" admission technique RetailFrameWalkTests'
|
||||||
|
// own outdoor tests use (a real WalkFrame root's 1-view default quad
|
||||||
|
// depends on the production ray-caster's screen geometry, which this
|
||||||
|
// suite's synthetic Caster does not model faithfully enough to predict
|
||||||
|
// block/cell admission from). A side=1 block's own object-list turn
|
||||||
|
// (WalkFrameDriver.OnLandscapeCellTurn's coarse-cell expansion) fires a
|
||||||
|
// StaticParticles turn for every one of the underlying 64 owner
|
||||||
|
// buckets; marking them all "no emitters" keeps this test's sequence
|
||||||
|
// to exactly SKY + one LANDCELL, the same way a genuinely empty turn
|
||||||
|
// stays silent (F2). ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static IEnumerable<uint> CoarseLandscapeBuckets(uint landblockPrefix)
|
||||||
|
{
|
||||||
|
for (int x = 0; x < 8; x++)
|
||||||
|
for (int y = 0; y < 8; y++)
|
||||||
|
yield return landblockPrefix | (uint)(x * 8 + y + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A ONE-view <see cref="WalkPortalView"/> whose single polygon
|
||||||
|
/// has ZERO vertices — <c>WalkLandscape.CheckBlocks</c> reads only
|
||||||
|
/// <c>poly.VertexCount</c> to build its edge-plane list, so this reaches
|
||||||
|
/// the SAME permissive <c>edgeCount == 0</c> ("CY-only") admission test
|
||||||
|
/// RetailFrameWalkTests' own outdoor fixtures use via a bare
|
||||||
|
/// zero-VIEW <see cref="WalkPortalView"/> — but with <c>ViewCount == 1</c>,
|
||||||
|
/// satisfying <see cref="WalkFrameDriver"/>'s fail-loud
|
||||||
|
/// "a Landscape turn needs at least one active view" guard, which a
|
||||||
|
/// driver-level test (unlike a bare <c>IWalkEventSink</c> recorder) must
|
||||||
|
/// pass through <c>RetailFrameWalk.DrawLandscape</c>'s real
|
||||||
|
/// <c>Emit(WalkEvent.Landscape(...))</c> call.</summary>
|
||||||
|
private static WalkPortalView OneDegenerateView()
|
||||||
|
{
|
||||||
|
var view = new WalkPortalView { ViewCount = 1 };
|
||||||
|
view.View.Polys.Add(new WalkViewPoly(0, 0, 0, 0, 0, 0));
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OnLandCellTurn_ConsecutiveSameSlotEventsMergeIntoOneBatch_AnyOtherEventSplits()
|
public void OutdoorRoot_LandCellPrecedesItsOwnCellsObjectTurn_ThenFlushesAtReplaysEnd()
|
||||||
{
|
{
|
||||||
using var fx = new DispatcherFixture();
|
using var fx = new DispatcherFixture();
|
||||||
var log = new List<string>();
|
var log = new List<string>();
|
||||||
var leaf = new RecordingLeafRenderer(log);
|
var leaf = new RecordingLeafRenderer(log);
|
||||||
|
leaf.CellsWithoutEmitters.UnionWith(CoarseLandscapeBuckets(0xF4180000u));
|
||||||
var ctx = new TestContext();
|
var ctx = new TestContext();
|
||||||
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
||||||
|
var walk = new RetailFrameWalk();
|
||||||
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
||||||
|
var block = new WalkLandBlock
|
||||||
|
{
|
||||||
|
LandblockId = 0xF4180000u, SideCellCount = 1, MaxZ = 10f, MinZ = 0f,
|
||||||
|
};
|
||||||
|
block.EnsureCellArrays();
|
||||||
|
landscape.Blocks[0] = block;
|
||||||
|
|
||||||
|
using DrawScope draw = fx.BeginDraw();
|
||||||
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
||||||
|
walk.DrawLandscape(landscape, OneDegenerateView(), ctx, driver);
|
||||||
|
driver.EndFrame();
|
||||||
|
driver.Replay(draw.Frame, draw.Pass);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "SKY", "LANDCELL:f4180000:1:0" }, log);
|
||||||
|
Assert.Equal(new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF4180000u, 1, 0) },
|
||||||
|
Assert.Single(leaf.LandCellBatches));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F4(b) (S3 chunk 3 fix round 1 §9.6): an interior root with one
|
||||||
|
// surviving exit view — the SAME fixture as
|
||||||
|
// RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells
|
||||||
|
// above, with one populated land block added to the (previously
|
||||||
|
// unpublished) landscape. Sequence: SKY, LANDCELL…, LFLUSH, SEALS,
|
||||||
|
// SHELL… — the LandCell terrain turn(s) drawn through the interior
|
||||||
|
// root's own exit view precede the landscape-flush/seal/flood-cell
|
||||||
|
// turns, exactly like the outdoor case above. ────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InteriorRootWithExitView_DrawsLandCellThroughTheExitViewBeforeFlushSealsAndFlood()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
var log = new List<string>();
|
||||||
|
const ulong gfxObjA = 0x0200_0025UL;
|
||||||
|
const ulong gfxObjB = 0x0200_0026UL;
|
||||||
|
InjectRenderData(fx.Manager, gfxObjA, MakeFlatMesh(
|
||||||
|
MakeBatch(0x08100025u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
||||||
|
InjectRenderData(fx.Manager, gfxObjB, MakeFlatMesh(
|
||||||
|
MakeBatch(0x08100026u, TranslucencyKind.Opaque, 3, 4, 3, 2)));
|
||||||
|
|
||||||
|
var ctx = new TestContext();
|
||||||
|
var cell1 = new WalkCell
|
||||||
|
{
|
||||||
|
CellId = 0x100,
|
||||||
|
StabList = [0x101u],
|
||||||
|
Portals =
|
||||||
|
[
|
||||||
|
new WalkCellPortal
|
||||||
|
{
|
||||||
|
OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0,
|
||||||
|
},
|
||||||
|
// The exit portal (retail's "world beyond the door") — this
|
||||||
|
// is what raises ov to 1 and drives the landscape (and now
|
||||||
|
// its own LandCell turns) before clear+seals+the flood cells.
|
||||||
|
new WalkCellPortal
|
||||||
|
{
|
||||||
|
OtherCellId = 0xFFFFFFFF, PolygonIndex = 1, PortalSide = 0, OtherPortalId = -1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
PortalPolygons = [Quad(-2f), Quad(-3f)],
|
||||||
|
};
|
||||||
|
var cell2 = new WalkCell
|
||||||
|
{
|
||||||
|
CellId = 0x101,
|
||||||
|
Portals = [new WalkCellPortal
|
||||||
|
{
|
||||||
|
OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0,
|
||||||
|
}],
|
||||||
|
PortalPolygons = [Quad(-2f)],
|
||||||
|
};
|
||||||
|
ctx.Cells[cell1.CellId] = cell1;
|
||||||
|
ctx.Cells[cell2.CellId] = cell2;
|
||||||
|
|
||||||
|
var worldData = new FakeWorldData();
|
||||||
|
worldData.CellStaticsByCell[0x100] = new WalkFrameStaticRecords(
|
||||||
|
new[] { MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)]) }, 0x8C04u);
|
||||||
|
worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords(
|
||||||
|
new[] { MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)]) }, 0x8C04u);
|
||||||
|
|
||||||
|
var leaf = new RecordingLeafRenderer(log);
|
||||||
|
leaf.CellsWithoutEmitters.UnionWith(CoarseLandscapeBuckets(0xF4180000u));
|
||||||
|
var trace = new RecordingTrace(log);
|
||||||
|
using ClipFrame clipFrame = ClipFrame.NoClip();
|
||||||
|
var driver = new WalkFrameDriver(
|
||||||
|
fx.Dispatcher, leaf, worldData, trace, clipFrame);
|
||||||
|
var walk = new RetailFrameWalk();
|
||||||
|
// The same 1x1 landscape RunFrame_InteriorFloodWithExitView... uses,
|
||||||
|
// but with a real block published in its one slot so the exit view
|
||||||
|
// has something to admit.
|
||||||
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
||||||
|
var block = new WalkLandBlock
|
||||||
|
{
|
||||||
|
LandblockId = 0xF4180000u, SideCellCount = 1, MaxZ = 10f, MinZ = 0f,
|
||||||
|
};
|
||||||
|
block.EnsureCellArrays();
|
||||||
|
landscape.Blocks[0] = block;
|
||||||
|
|
||||||
|
using DrawScope draw = fx.BeginDraw();
|
||||||
|
driver.RunFrame(
|
||||||
|
walk, cameraCellId: cell1.CellId, cameraCell: cell1, landscape: landscape,
|
||||||
|
ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"SKY", "LANDCELL:f4180000:1:0", "LFLUSH", "SEALS",
|
||||||
|
"SHELL:00000101", "SHELL:00000100",
|
||||||
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
|
||||||
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
|
||||||
|
},
|
||||||
|
log);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T4 (S3 chunk 3 §9.3), re-expressed for fix round 1's F2 rule
|
||||||
|
// (§9.6 F4c): the batch is now a single PENDING list Replay keeps
|
||||||
|
// across the WHOLE frame, not a same-landblock lookahead merge — two
|
||||||
|
// LandCell turns of DIFFERENT landblocks with only an empty particle
|
||||||
|
// turn between them still merge into ONE DrawLandCellBatch call; a
|
||||||
|
// StreamMark (a real cell with statics) OR a building's own alpha
|
||||||
|
// barrier between two LandCell turns splits the batch; the fake leaf's
|
||||||
|
// HasRenderableEmittersInCell reports "no emitters" for one cell (via
|
||||||
|
// CellsWithoutEmitters) and "has emitters" (the default) for another,
|
||||||
|
// so both arms of the F2 particle-turn gate are covered in one test. ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnLandCellTurn_MergesAcrossLandblocksOverAnEmptyParticleTurn_RealSubmissionsSplit()
|
||||||
|
{
|
||||||
|
using var fx = new DispatcherFixture();
|
||||||
|
const ulong gfxObj = 0x0200_0024UL;
|
||||||
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
||||||
|
MakeBatch(0x08100024u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
||||||
|
|
||||||
|
var worldData = new FakeWorldData();
|
||||||
|
worldData.OutdoorStaticsByCell[0xBBBB0002u] = new WalkFrameStaticRecords(
|
||||||
|
new[] { MakeRecord(310, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
|
||||||
|
0xBBBBu);
|
||||||
|
|
||||||
|
var log = new List<string>();
|
||||||
|
var leaf = new RecordingLeafRenderer(log);
|
||||||
|
// The 0xAAAA0001 particle turn has no world-data records AND is
|
||||||
|
// marked without emitters -> a genuinely empty turn (F2's other
|
||||||
|
// arm: 0xBBBB0002 below keeps the default "has emitters").
|
||||||
|
leaf.CellsWithoutEmitters.Add(0xAAAA0001u);
|
||||||
|
var ctx = new TestContext();
|
||||||
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
|
||||||
IWalkEventSink sink = driver;
|
IWalkEventSink sink = driver;
|
||||||
|
|
||||||
using DrawScope draw = fx.BeginDraw();
|
using DrawScope draw = fx.BeginDraw();
|
||||||
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
||||||
sink.OnLandCellTurn(0xF4180000u, 8, 0);
|
sink.OnLandscapeViews(new WalkPortalView());
|
||||||
sink.OnLandCellTurn(0xF4180000u, 8, 1); // same slot, nothing between -> merges
|
|
||||||
sink.OnBuildingTurn(new WalkBuilding()); // intervening event splits the batch
|
sink.OnLandCellTurn(0xF4180000u, 8, 0); // landblock A
|
||||||
sink.OnLandCellTurn(0xF4180000u, 8, 2); // same slot as before the split, but a NEW batch
|
sink.OnLandscapeCellTurn(0xAAAA0001u); // empty particle turn: no emitters -> no submit, no flush
|
||||||
sink.OnLandCellTurn(0xF3180000u, 8, 0); // a different slot never merges
|
sink.OnLandCellTurn(0xF3180000u, 8, 0); // landblock B, DIFFERENT -> still merges (F2)
|
||||||
|
|
||||||
|
sink.OnLandscapeCellTurn(0xBBBB0002u); // real content -> StreamMark splits; has emitters -> submits
|
||||||
|
|
||||||
|
sink.OnLandCellTurn(0xF3180000u, 8, 1); // new batch, started after the StreamMark split
|
||||||
|
|
||||||
|
sink.OnBuildingTurn(new WalkBuilding()); // the building's own alpha barrier splits again
|
||||||
|
|
||||||
|
sink.OnLandCellTurn(0xF2180000u, 8, 0); // final batch, flushed at Replay's own end
|
||||||
driver.EndFrame();
|
driver.EndFrame();
|
||||||
driver.Replay(draw.Frame, draw.Pass);
|
driver.Replay(draw.Frame, draw.Pass);
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
"LANDCELL:f4180000:2",
|
"LANDCELL:f4180000:8:0,f3180000:8:0",
|
||||||
|
"PARTICLES:bbbb0002",
|
||||||
|
"LANDCELL:f3180000:8:1",
|
||||||
"ALPHA",
|
"ALPHA",
|
||||||
"LANDCELL:f4180000:1",
|
"LANDCELL:f2180000:8:0",
|
||||||
"LANDCELL:f3180000:1",
|
|
||||||
},
|
},
|
||||||
log);
|
log);
|
||||||
|
Assert.DoesNotContain("PARTICLES:aaaa0001", log);
|
||||||
|
Assert.Equal(3, leaf.LandCellBatches.Count);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
new[]
|
new (uint LandblockId, int SideCellCount, int CellIndex)[]
|
||||||
{
|
{
|
||||||
(0xF4180000u, 2),
|
(0xF4180000u, 8, 0), (0xF3180000u, 8, 0),
|
||||||
(0xF4180000u, 1),
|
|
||||||
(0xF3180000u, 1),
|
|
||||||
},
|
},
|
||||||
leaf.LandCellBatches);
|
leaf.LandCellBatches[0]);
|
||||||
|
Assert.Equal(
|
||||||
|
new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF3180000u, 8, 1) },
|
||||||
|
leaf.LandCellBatches[1]);
|
||||||
|
Assert.Equal(
|
||||||
|
new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF2180000u, 8, 0) },
|
||||||
|
leaf.LandCellBatches[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture —
|
// ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture —
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ public sealed class WorldRenderDiagnosticsTests
|
||||||
{
|
{
|
||||||
var log = new ThrowOnceLog();
|
var log = new ThrowOnceLog();
|
||||||
var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
|
var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
|
||||||
var facts = new TerrainRenderDiagnosticFacts(3, 5, 8);
|
var facts = new TerrainRenderDiagnosticFacts(VisibleSlots: 3, Draws: 3, LoadedSlots: 5, CapacitySlots: 8);
|
||||||
|
|
||||||
diagnostics.BeginTerrainDraw();
|
diagnostics.BeginTerrainDraw();
|
||||||
diagnostics.EndTerrainDraw();
|
diagnostics.EndTerrainDraw();
|
||||||
|
|
|
||||||
|
|
@ -882,6 +882,46 @@ public sealed class ParticleSystemTests
|
||||||
Assert.Empty(destination);
|
Assert.Empty(destination);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S3 chunk 3 fix round 1 (§9.6 F2): HasRenderableEmittersInCell is
|
||||||
|
// CopyRenderableEmittersInCell's allocation-free sibling —
|
||||||
|
// WalkFrameDriver.Replay's StaticParticles/CellParticles gate. Same
|
||||||
|
// add/move/remove lifecycle as CellIndex_AddRemoveMove... above, but
|
||||||
|
// asserted as a bool answer instead of a copied list.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasRenderableEmittersInCell_AddRemoveMove_TracksTheSameLifecycleAsCopy()
|
||||||
|
{
|
||||||
|
var sys = MakeSystem();
|
||||||
|
var desc = new EmitterDesc
|
||||||
|
{
|
||||||
|
DatId = 0x32000092u,
|
||||||
|
Type = ParticleType.Still,
|
||||||
|
MaxParticles = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A cell with no bucket at all (never touched) reports false, not a
|
||||||
|
// KeyNotFoundException or similar.
|
||||||
|
Assert.False(sys.HasRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0001u));
|
||||||
|
|
||||||
|
int handle = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: 503u);
|
||||||
|
sys.UpdateEmitterOwnerCell(handle, 0x0102_0001u);
|
||||||
|
var visible = new HashSet<uint> { 0x0102_0001u, 0x0102_0002u };
|
||||||
|
sys.ApplyRetailView(Vector3.Zero, visible, hasCompletedView: true);
|
||||||
|
|
||||||
|
// Add: renderable in its own cell, nowhere else.
|
||||||
|
Assert.True(sys.HasRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0001u));
|
||||||
|
Assert.False(sys.HasRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0002u));
|
||||||
|
|
||||||
|
// Move: the handle relocates from the old cell's bucket to the new one.
|
||||||
|
sys.UpdateEmitterOwnerCell(handle, 0x0102_0002u);
|
||||||
|
Assert.False(sys.HasRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0001u));
|
||||||
|
Assert.True(sys.HasRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0002u));
|
||||||
|
|
||||||
|
// Remove: a hard stop clears the cell bucket too.
|
||||||
|
sys.StopEmitter(handle, fadeOut: false);
|
||||||
|
Assert.False(sys.HasRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0002u));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CellIndex_MultipleEmittersInOneCell_EnumerateInSpawnOrder()
|
public void CellIndex_MultipleEmittersInOneCell_EnumerateInSpawnOrder()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue