perf(render) Campaign FW3.4a: one walk pass; prepare-once/draw-ranges; arena records
The FW3.4 dense-Arwic pair triggered the +/-20% stop rule (+33.5% CPU p50, 14x frame allocation). This slice removes the three measured costs without changing GPU command order (the referee suites assert identical recorded call sequences): - WalkFrameDriver: Collect (ONE walk per frame - no GPU work; leaf calls and flush points become a recorded event list; the driver absorbed the renderer collection pass and exposes the visited sets) + Replay (prepare the whole stream once, then replay events, interleaving DrawOrderedRange with leaf calls in the exact recorded order). RunFrame = Collect+Replay for existing callers. - WbDrawDispatcher: SubmitOrderedStream split into PrepareOrderedStream (all sections + commands + merge runs uploaded once per frame) and DrawOrderedRange (bind-once latch; per-run pipeline + DrawIdOffset + DrawIndirectRangeRhi). Load-bearing correctness catch from the implementation round: merge runs take FORCED BREAKS at the recorded event marks - whole-stream merging must not fuse two segments that retail separates with a leaf GPU call (shell, punch); the straddle assert stays as a dead-code safety net. - WalkProductionWorldData: WalkFrameStaticRecords carries an ArraySegment into a per-frame grow-only arena; the per-cell fresh-array copies (the 1.9 MB/frame alloc p50) are gone - zero steady-state allocation after warmup. Suites (lead-verified): full Release build 0 warnings; hermetic 6,758/0; Walk lane 209/1; InstalledDat Walk conformance 40/1 untouched. Next: the dense-Arwic re-measure against the same-session baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6301e4dbea
commit
212f5a12e5
7 changed files with 1051 additions and 359 deletions
|
|
@ -227,16 +227,22 @@ public sealed class RetailPViewRenderer
|
|||
&& _sceneFrameProduct is not null
|
||||
&& walkRegistriesReady;
|
||||
|
||||
// Campaign FW3.2b-2 pre-walk collection pass: run the production walk
|
||||
// EVENTS-ONLY (no leaf draws) to learn the flood cell set it will draw
|
||||
// this frame, BEFORE prepareCells is finalized below — so EnvCellRenderer
|
||||
// prepares batches for every shell the driver will draw later in this
|
||||
// same DrawInside call. The same WalkProductionFrameContext + camera
|
||||
// cell resolved here are reused for the real driven run further down
|
||||
// (plan §FW3 "FW3.2b-2 — the production rooting", item 1).
|
||||
Walk.WalkProductionFrameContext? walkContext = null;
|
||||
Walk.WalkLandscape? walkLandscape = null;
|
||||
Walk.WalkCell? walkCameraCell = null;
|
||||
// Campaign FW3.4a: THE ONE WALK. Builds walkContext/walkLandscape/
|
||||
// walkCameraCell exactly as the pre-FW3.4a pre-walk collection pass
|
||||
// did, then drives WalkFrameDriver.Collect — a SINGLE RetailFrameWalk
|
||||
// pass that both learns the flood/visited-cell set (needed below,
|
||||
// BEFORE prepareCells is finalized, so EnvCellRenderer prepares
|
||||
// batches for every shell the driver will draw later in this same
|
||||
// DrawInside call) and records the walk's draw events for Replay
|
||||
// further down, in DrawWalkDrivenStatics. The former SECOND walk pass
|
||||
// (a dedicated set-collecting sink, run again through this same
|
||||
// driver machinery just to submit) is gone — see WalkFrameDriver's
|
||||
// own doc comment for the FW3.4 perf numbers that motivated this.
|
||||
// _walkWorldData.BeginFrame precedes Collect deliberately: Collect's
|
||||
// stream appends classify records immediately (WalkStaticStreamPopulator
|
||||
// runs at append time, not at Replay time), so the world data must
|
||||
// already be rebuilt for this frame before the walk starts.
|
||||
Walk.WalkFrameDriver? walkDriver = null;
|
||||
if (walkActive)
|
||||
{
|
||||
Matrix4x4 view = ctx.CameraView;
|
||||
|
|
@ -247,7 +253,7 @@ public sealed class RetailPViewRenderer
|
|||
// reached before the world pass has published its scope.
|
||||
float viewportWidth = attachment?.Width ?? 1024f;
|
||||
float viewportHeight = attachment?.Height ?? 720f;
|
||||
walkContext = new Walk.WalkProductionFrameContext(
|
||||
var walkContext = new Walk.WalkProductionFrameContext(
|
||||
_walkCellRegistry!,
|
||||
_walkBuildings!,
|
||||
ctx.ViewerEyePos,
|
||||
|
|
@ -256,8 +262,9 @@ public sealed class RetailPViewRenderer
|
|||
viewportWidth,
|
||||
viewportHeight);
|
||||
_walkLandscape!.SetViewer(ctx.ViewerCellId, ctx.ViewerEyePos);
|
||||
walkLandscape = _walkLandscape.Landscape;
|
||||
Walk.WalkLandscape walkLandscape = _walkLandscape.Landscape;
|
||||
|
||||
Walk.WalkCell? walkCameraCell = null;
|
||||
if ((ctx.ViewerCellId & 0xFFFFu) >= 0x100)
|
||||
{
|
||||
walkCameraCell = _walkCellRegistry!.TryGetCell(ctx.ViewerCellId, out LoadedCell? loaded)
|
||||
|
|
@ -274,9 +281,42 @@ public sealed class RetailPViewRenderer
|
|||
}
|
||||
}
|
||||
|
||||
_walkVisitedScratch.Reset();
|
||||
_frameWalk.WalkFrame(
|
||||
ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext, _walkVisitedScratch);
|
||||
int activeTerrainSliceCount = clipAssembly.OutsideViewSlices.Length;
|
||||
if (ctx.RootCell.IsOutdoorNode && activeTerrainSliceCount != 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"walk static cutover: an outdoor root's clip assembly produced "
|
||||
+ $"{activeTerrainSliceCount} outside-view slices, not the expected 1 — "
|
||||
+ "WalkFrameDriver's landscape turn assumes the outdoor root's default "
|
||||
+ "full-screen view (plan §FW3 item 2c's pinned assumption; assert "
|
||||
+ "rather than silently coercing to 1).");
|
||||
}
|
||||
|
||||
_walkWorldData!.BeginFrame(
|
||||
_sceneFrameProduct!.SceneQuery,
|
||||
ctx.PlayerLandblockId ?? 0u,
|
||||
ctx.RenderCenterLbX,
|
||||
ctx.RenderCenterLbY);
|
||||
|
||||
Action clearInteriorDepth = () =>
|
||||
{
|
||||
// Retail PView::DrawCells 0x005A4872 drains the landscape
|
||||
// alpha list immediately before the gated full depth clear —
|
||||
// mirrors DrawLandscapeThroughOutsideView's own pre-clear
|
||||
// drain.
|
||||
passes.FlushLandscapeAlpha();
|
||||
passes.ClearInteriorDepth();
|
||||
};
|
||||
Action drawExitSeals = () =>
|
||||
DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells);
|
||||
|
||||
var leafRenderer = new WalkProductionLeafRenderer(
|
||||
walkExecutor!, ctx, clipAssembly, clearInteriorDepth, drawExitSeals);
|
||||
walkDriver = new Walk.WalkFrameDriver(walkExecutor!.Dispatcher, leafRenderer, _walkWorldData);
|
||||
|
||||
walkDriver.Collect(
|
||||
_frameWalk, ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext,
|
||||
ctx.ViewProjection, ctx.CameraWorldPosition, activeTerrainSliceCount);
|
||||
}
|
||||
|
||||
// #124: look-in cells need prepared shell batches + their statics routed
|
||||
|
|
@ -303,7 +343,7 @@ public sealed class RetailPViewRenderer
|
|||
// The walk's own flood/look-in cell set — unioned in (never
|
||||
// aliased with drawableCells, which the outside-stage and seal
|
||||
// predicates below still need scoped to the OLD flood only).
|
||||
_lookInPrepareScratch.UnionWith(_walkVisitedScratch.Cells);
|
||||
_lookInPrepareScratch.UnionWith(walkDriver!.VisitedCells);
|
||||
}
|
||||
prepareCells = _lookInPrepareScratch;
|
||||
}
|
||||
|
|
@ -438,16 +478,9 @@ public sealed class RetailPViewRenderer
|
|||
// retail turn). The OLD visibility (pvFrame/clipAssembly/
|
||||
// viewcone, already built above) keeps running unchanged to
|
||||
// feed the surviving dynamic routes only (plan §FW3 item 1's
|
||||
// dual-compute split).
|
||||
DrawWalkDrivenStatics(
|
||||
ctx,
|
||||
walkExecutor!,
|
||||
clipAssembly,
|
||||
pvFrame,
|
||||
drawableCells,
|
||||
walkContext!,
|
||||
walkCameraCell,
|
||||
walkLandscape!);
|
||||
// dual-compute split). Campaign FW3.4a: the walk itself
|
||||
// already ran (Collect, above) — this is Replay only.
|
||||
DrawWalkDrivenStatics(ctx, walkExecutor!, walkDriver!);
|
||||
passes.UseIndoorMembershipOnlyRouting();
|
||||
DrawLandscapeDynamicsPhase(
|
||||
ctx,
|
||||
|
|
@ -1089,24 +1122,26 @@ public sealed class RetailPViewRenderer
|
|||
}
|
||||
|
||||
// Campaign FW3.2b-2: the one RetailFrameWalk instance shared by the
|
||||
// diagnostic shadow probe, the production pre-walk collection pass, and
|
||||
// the real driven run — WalkFrame calls are never concurrent/re-entrant
|
||||
// within a single-threaded render loop, so one shared instance is safe
|
||||
// and avoids re-allocating the walk's own PView scratch per call site.
|
||||
// diagnostic shadow probe and the real Collect run (WalkFrameDriver.Collect,
|
||||
// driven from DrawInside's walkActive block) — WalkFrame calls are never
|
||||
// concurrent/re-entrant within a single-threaded render loop, so one
|
||||
// shared instance is safe and avoids re-allocating the walk's own PView
|
||||
// scratch per call site. Campaign FW3.4a retired the THIRD role this
|
||||
// field used to serve (a dedicated pre-walk collection pass) — Collect
|
||||
// now gathers the same visited sets itself, on WalkFrameDriver, as a
|
||||
// side effect of the one walk it already runs.
|
||||
private readonly Walk.RetailFrameWalk _frameWalk = new();
|
||||
|
||||
// Campaign FW3.2b-2: the pre-walk collection pass's reusable sink (see
|
||||
// DrawInside's walkActive block) — reset and re-driven once per frame.
|
||||
private readonly WalkVisitedSetCollector _walkVisitedScratch = new();
|
||||
|
||||
/// <summary>Campaign FW3.2b-2 (the I5 dual-shadow pattern, extended for
|
||||
/// production use): an events-only <see cref="Walk.IWalkEventSink"/> that
|
||||
/// collects the SETS a driven run would touch, without doing any leaf
|
||||
/// drawing itself — the shadow probe's original role, now ALSO the
|
||||
/// pre-walk collection pass's role (plan §FW3 item 1: the walk's flood
|
||||
/// cell set for the <c>prepareCells</c> union, the visited building list
|
||||
/// and landscape-cell turn ids for re-sourcing particle owners once the
|
||||
/// walk owns the static routes those owners used to ride).</summary>
|
||||
/// <summary>Campaign FW3.2b-2 (the I5 dual-shadow pattern): an
|
||||
/// events-only <see cref="Walk.IWalkEventSink"/> that collects the SETS a
|
||||
/// driven run would touch, without doing any leaf drawing itself. Used
|
||||
/// ONLY by <see cref="RunWalkShadowProbe"/> now — Campaign FW3.4a moved
|
||||
/// the production role (the walk's flood cell set for the
|
||||
/// <c>prepareCells</c> union, the visited building list and
|
||||
/// landscape-cell turn ids for particle re-sourcing) onto
|
||||
/// <see cref="Walk.WalkFrameDriver"/> itself, which gathers the same sets
|
||||
/// as a side effect of the one walk <see cref="Walk.WalkFrameDriver.Collect"/>
|
||||
/// already runs, instead of a second dedicated pass.</summary>
|
||||
private sealed class WalkVisitedSetCollector : Walk.IWalkEventSink
|
||||
{
|
||||
public readonly HashSet<uint> Cells = new();
|
||||
|
|
@ -1142,78 +1177,27 @@ public sealed class RetailPViewRenderer
|
|||
public void OnBuildingTurn(Walk.WalkBuilding building) => Buildings.Add(building);
|
||||
}
|
||||
|
||||
/// <summary>Campaign FW3.2b-2 — THE PRODUCTION ROOTING. Runs the real
|
||||
/// <see cref="Walk.RetailFrameWalk"/> through
|
||||
/// <see cref="Walk.WalkFrameDriver"/> over
|
||||
/// <see cref="WbDrawDispatcher.SubmitOrderedStream"/>: terrain/sky, every
|
||||
/// building's shell + punch + look-in cell statics, and the interior
|
||||
/// root's own flood shells + statics (with retail's depth-clear/exit-seal
|
||||
/// turn, via <see cref="DrawExitPortalMasks"/> bound as the driver's own
|
||||
/// seal action) all draw here, in walk order — replacing
|
||||
/// <summary>Campaign FW3.2b-2 — THE PRODUCTION ROOTING; Campaign FW3.4a —
|
||||
/// REPLAY ONLY. <paramref name="driver"/> already ran its Collect pass
|
||||
/// earlier in <see cref="DrawInside"/> (before <c>PrepareCellBatches</c>);
|
||||
/// this method's job is now just <see cref="Walk.WalkFrameDriver.Replay"/>
|
||||
/// (terrain/sky, every building's shell + punch + look-in cell statics,
|
||||
/// and the interior root's own flood shells + statics, with retail's
|
||||
/// depth-clear/exit-seal turn — all in walk order, replacing
|
||||
/// <see cref="DrawLandscapeThroughOutsideView"/>'s static half,
|
||||
/// <see cref="DrawExitPortalMasks"/>'s old top-level call,
|
||||
/// <see cref="DrawEnvCellShells"/>, and <see cref="DrawCellObjectLists"/>'s
|
||||
/// static half for this frame (plan §FW3 "FW3.2b-2 — the production
|
||||
/// rooting", item 2). Also re-sources the particle owners the routes it
|
||||
/// just replaced used to ride (item 4).</summary>
|
||||
/// static half for this frame) plus re-sourcing the particle owners the
|
||||
/// routes it replaced used to ride, from the driver's own visited
|
||||
/// sets (plan §FW3 "FW3.2b-2 — the production rooting", items 2 and
|
||||
/// 4).</summary>
|
||||
private void DrawWalkDrivenStatics(
|
||||
RetailPViewFrameInput ctx,
|
||||
RetailPViewPassExecutor passes,
|
||||
ClipFrameAssembly clipAssembly,
|
||||
PortalVisibilityFrame pvFrame,
|
||||
HashSet<uint> drawableCells,
|
||||
Walk.WalkProductionFrameContext walkContext,
|
||||
Walk.WalkCell? cameraCell,
|
||||
Walk.WalkLandscape landscape)
|
||||
Walk.WalkFrameDriver driver)
|
||||
{
|
||||
_walkWorldData!.BeginFrame(
|
||||
_sceneFrameProduct!.SceneQuery,
|
||||
ctx.PlayerLandblockId ?? 0u,
|
||||
ctx.RenderCenterLbX,
|
||||
ctx.RenderCenterLbY);
|
||||
|
||||
Action clearInteriorDepth = () =>
|
||||
{
|
||||
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha
|
||||
// list immediately before the gated full depth clear — mirrors
|
||||
// DrawLandscapeThroughOutsideView's own pre-clear drain (this
|
||||
// action only ever fires for an INTERIOR root; see
|
||||
// IWalkFrameLeafRenderer.ClearInteriorDepth's own doc comment for
|
||||
// why the driver never invokes it outdoors).
|
||||
passes.FlushLandscapeAlpha();
|
||||
passes.ClearInteriorDepth();
|
||||
};
|
||||
Action drawExitSeals = () =>
|
||||
DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells);
|
||||
|
||||
var leafRenderer = new WalkProductionLeafRenderer(
|
||||
passes, ctx, clipAssembly, clearInteriorDepth, drawExitSeals);
|
||||
var driver = new Walk.WalkFrameDriver(passes.Dispatcher, leafRenderer, _walkWorldData);
|
||||
|
||||
var (frame, encoder) = passes.RequireWalkSubmission();
|
||||
|
||||
int activeTerrainSliceCount = clipAssembly.OutsideViewSlices.Length;
|
||||
if (ctx.RootCell.IsOutdoorNode && activeTerrainSliceCount != 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"walk static cutover: an outdoor root's clip assembly produced "
|
||||
+ $"{activeTerrainSliceCount} outside-view slices, not the expected 1 — "
|
||||
+ "WalkFrameDriver's landscape turn assumes the outdoor root's default "
|
||||
+ "full-screen view (plan §FW3 item 2c's pinned assumption; assert "
|
||||
+ "rather than silently coercing to 1).");
|
||||
}
|
||||
|
||||
driver.RunFrame(
|
||||
_frameWalk,
|
||||
ctx.ViewerCellId,
|
||||
cameraCell,
|
||||
landscape,
|
||||
walkContext,
|
||||
frame,
|
||||
encoder,
|
||||
ctx.ViewProjection,
|
||||
ctx.CameraWorldPosition,
|
||||
activeTerrainSliceCount);
|
||||
driver.Replay(frame, encoder);
|
||||
|
||||
// Landscape-stage particle owners: the union of every outdoor-static
|
||||
// record from a landscape cell the walk visited this frame, plus
|
||||
|
|
@ -1222,10 +1206,10 @@ public sealed class RetailPViewRenderer
|
|||
// @0x0050FE60, so this is MORE retail-faithful than the old per-
|
||||
// slice sphere filter it replaces).
|
||||
_staticParticleUnionScratch.Clear();
|
||||
foreach (uint cellId in _walkVisitedScratch.LandscapeCellIds)
|
||||
UnionRecordOwners(_walkWorldData.GetOutdoorStatics(cellId), _staticParticleUnionScratch);
|
||||
foreach (Walk.WalkBuilding building in _walkVisitedScratch.Buildings)
|
||||
UnionRecordOwners(_walkWorldData.GetBuildingShellStatics(building), _staticParticleUnionScratch);
|
||||
foreach (uint cellId in driver.VisitedLandscapeCellIds)
|
||||
UnionRecordOwners(_walkWorldData!.GetOutdoorStatics(cellId), _staticParticleUnionScratch);
|
||||
foreach (Walk.WalkBuilding building in driver.VisitedBuildings)
|
||||
UnionRecordOwners(_walkWorldData!.GetBuildingShellStatics(building), _staticParticleUnionScratch);
|
||||
if (_staticParticleUnionScratch.Count > 0)
|
||||
{
|
||||
passes.DrawLandscapeStaticParticles(
|
||||
|
|
@ -1242,11 +1226,11 @@ public sealed class RetailPViewRenderer
|
|||
// DrawBuildingLookInDynamics so a static owner is never submitted
|
||||
// twice.
|
||||
_cellParticleOwnerScratch.Clear();
|
||||
foreach (uint cellId in _walkVisitedScratch.Cells)
|
||||
foreach (uint cellId in driver.VisitedCells)
|
||||
{
|
||||
if (_lookInCellIds.Contains(cellId))
|
||||
continue;
|
||||
UnionRecordOwners(_walkWorldData.GetCellStatics(cellId), _cellParticleOwnerScratch);
|
||||
UnionRecordOwners(_walkWorldData!.GetCellStatics(cellId), _cellParticleOwnerScratch);
|
||||
}
|
||||
if (_cellParticleOwnerScratch.Count > 0)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,16 +15,19 @@ namespace AcDream.App.Rendering.Walk;
|
|||
/// <param name="Records">Already-classified <see cref="RenderProjectionRecord"/>s
|
||||
/// for this turn's cell/building, in the SAME order they must enter the walk
|
||||
/// stream (never re-sorted downstream — <see cref="WalkStaticStreamPopulator"/>'s
|
||||
/// own contract).</param>
|
||||
/// own contract). Campaign FW3.4a: a segment INTO <see cref="WalkProductionWorldData"/>'s
|
||||
/// per-frame arena, not a freshly allocated array — see that type's own doc
|
||||
/// comment.</param>
|
||||
/// <param name="TupleLandblockId">The clip-slot-resolving landblock id
|
||||
/// <c>WbDrawDispatcher.ClassifyEntityForWalk</c> needs per record (FW3.2a's
|
||||
/// <c>tupleLandblockId</c> parameter) — carried per-turn rather than once per
|
||||
/// frame because a single frame's cells/buildings can span more than one
|
||||
/// committed landblock.</param>
|
||||
internal readonly record struct WalkFrameStaticRecords(
|
||||
RenderProjectionRecord[] Records, uint TupleLandblockId)
|
||||
ArraySegment<RenderProjectionRecord> Records, uint TupleLandblockId)
|
||||
{
|
||||
public static readonly WalkFrameStaticRecords Empty = new(Array.Empty<RenderProjectionRecord>(), 0);
|
||||
public static readonly WalkFrameStaticRecords Empty =
|
||||
new(ArraySegment<RenderProjectionRecord>.Empty, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -72,12 +75,11 @@ internal interface IWalkFrameWorldData
|
|||
/// <c>TerrainModernRenderer</c>, <c>GameSky</c>, and
|
||||
/// <c>PortalDepthMaskRenderer.DrawDepthFan</c> — FW3.2b-2's job.
|
||||
///
|
||||
/// <para>Stream submission itself (<c>WbDrawDispatcher.SubmitOrderedStream</c>)
|
||||
/// is deliberately NOT part of this interface: it is already a real,
|
||||
/// FW2/FW3.2a-tested production method, so <see cref="WalkFrameDriver"/>
|
||||
/// calls it directly (plan §FW3.2b-1's "the driver calls
|
||||
/// WbDrawDispatcher.SubmitOrderedStream" wording) rather than abstracting a
|
||||
/// method that would just forward to it one layer deeper.</para>
|
||||
/// <para>Stream submission itself (<c>WbDrawDispatcher.PrepareOrderedStream</c>/
|
||||
/// <c>DrawOrderedRange</c>) is deliberately NOT part of this interface: it is
|
||||
/// already real, production-tested machinery, so <see cref="WalkFrameDriver"/>
|
||||
/// calls it directly rather than abstracting a method that would just
|
||||
/// forward to it one layer deeper.</para>
|
||||
/// </summary>
|
||||
internal interface IWalkFrameLeafRenderer
|
||||
{
|
||||
|
|
@ -90,7 +92,7 @@ internal interface IWalkFrameLeafRenderer
|
|||
|
||||
/// <summary><c>LScape::grab_visible_cells</c>'s terrain mesh, once per
|
||||
/// ACTIVE clip slice — <paramref name="sliceIndex"/> is caller-supplied
|
||||
/// (<see cref="WalkFrameDriver.RunFrame"/>'s <c>activeTerrainSliceCount</c>)
|
||||
/// (<see cref="WalkFrameDriver.Collect"/>'s <c>activeTerrainSliceCount</c>)
|
||||
/// since FW3.2b-1 does not wire <c>ClipFrameAssembler</c>/
|
||||
/// <c>ViewconeCuller</c> (FW3.2b-2's job — see plan §FW3.2's dynamic-route
|
||||
/// survival note). Terrain draws FULLY before any per-cell building/
|
||||
|
|
@ -143,19 +145,18 @@ internal interface IWalkFrameLeafRenderer
|
|||
/// punch fan — pass 1 of the building portal walk.
|
||||
/// <paramref name="worldPolygon"/> is already transformed building-local
|
||||
/// → world (<see cref="WalkFrameDriver"/> does the transform via
|
||||
/// <see cref="IWalkFrameWorldData.GetBuildingWorldTransform"/> before
|
||||
/// calling this). The real implementation is
|
||||
/// <see cref="IWalkFrameWorldData.GetBuildingWorldTransform"/> at Collect
|
||||
/// time — see that type's own doc comment). The real implementation is
|
||||
/// <c>PortalDepthMaskRenderer.DrawDepthFan</c> with <c>forceFarZ</c>
|
||||
/// (FW3.2b-2 wiring) — this stage only proves the CALL happens at the
|
||||
/// right point in walk order: the driver flushes the accumulated stream
|
||||
/// segment immediately before this call, so ANY content queued ahead of
|
||||
/// the punch (a preceding cell's/building's contents — never this
|
||||
/// building's OWN shell, which retail draws only after the whole portal
|
||||
/// walk completes; see <see cref="WalkFrameDriver"/>'s type doc comment)
|
||||
/// reaches the GPU first. <paramref name="activeViewIndex"/> is the view
|
||||
/// the emitting two-pass walk was pinned to (retail
|
||||
/// <c>building_view = Render::portal_view_num</c> @0x0059f3bf) —
|
||||
/// production clips the fan by that view's slice planes.</summary>
|
||||
/// (FW3.2b-2 wiring) — Replay calls this at exactly the point Collect
|
||||
/// recorded it: any content queued ahead of the punch (a preceding
|
||||
/// cell's/building's contents — never this building's OWN shell, which
|
||||
/// retail draws only after the whole portal walk completes; see
|
||||
/// <see cref="WalkFrameDriver"/>'s type doc comment) reaches the GPU
|
||||
/// first. <paramref name="activeViewIndex"/> is the view the emitting
|
||||
/// two-pass walk was pinned to (retail <c>building_view =
|
||||
/// Render::portal_view_num</c> @0x0059f3bf) — production clips the fan by
|
||||
/// that view's slice planes.</summary>
|
||||
void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex);
|
||||
|
||||
/// <summary><c>RetailAlphaQueue.FlushFartherThan</c>'s <c>DrawBuilding</c>
|
||||
|
|
@ -173,60 +174,187 @@ internal interface IWalkFrameLeafRenderer
|
|||
|
||||
/// <summary>
|
||||
/// Campaign FW stage FW3.2b-1 test seam: an optional, diagnostic-only
|
||||
/// observer of every stream FLUSH <see cref="WalkFrameDriver"/> performs.
|
||||
/// Production callers pass <see langword="null"/> (the default) — this
|
||||
/// exists purely so the headless referee suite can assert flush COUNT,
|
||||
/// per-flush command count, and per-flush stage without re-deriving them
|
||||
/// observer of every ordered-stream range <see cref="WalkFrameDriver.Replay"/>
|
||||
/// draws. Production callers pass <see langword="null"/> (the default) — this
|
||||
/// exists purely so the headless referee suite can assert range COUNT,
|
||||
/// per-range command count, and per-range stage without re-deriving them
|
||||
/// from <c>RecordingGpuDevice.Calls</c>' lower-level RHI call log.
|
||||
/// </summary>
|
||||
internal interface IWalkFrameDriverTrace
|
||||
{
|
||||
/// <summary><paramref name="stages"/> is a snapshot (never the live,
|
||||
/// about-to-be-<c>Reset</c> list) of every command's
|
||||
/// <see cref="WalkDrawStage"/> in the flushed segment, in stream order —
|
||||
/// by this stage's own flush discipline (flush before every non-stream
|
||||
/// leaf action) a segment is always single-stage in practice, but the
|
||||
/// full list is passed so a test can assert that invariant itself
|
||||
/// instead of trusting it.</summary>
|
||||
/// <summary><paramref name="stages"/> is a snapshot (never a live,
|
||||
/// about-to-mutate list) of every command's <see cref="WalkDrawStage"/> in
|
||||
/// the drawn segment, in stream order — by this stage's own segmenting
|
||||
/// discipline (a segment boundary before every non-stream leaf action) a
|
||||
/// segment is always single-stage in practice, but the full list is
|
||||
/// passed so a test can assert that invariant itself instead of trusting
|
||||
/// it.</summary>
|
||||
void OnFlush(int commandCount, IReadOnlyList<WalkDrawStage> stages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FW3.4a: one turn Collect recorded, replayed by
|
||||
/// <see cref="WalkFrameDriver.Replay"/> in the exact order Collect saw it.
|
||||
/// <see cref="WalkFrameEventKind.StreamMark"/> is the collect-time analogue of
|
||||
/// the old immediate driver's flush point — see <see cref="WalkFrameEventKind"/>'s
|
||||
/// own doc comment for the full list and what each carries.
|
||||
/// </summary>
|
||||
internal enum WalkFrameEventKind : byte
|
||||
{
|
||||
/// <summary>The accumulated <see cref="OrderedDrawStream"/> grew since the
|
||||
/// last mark and must be drawn, via <c>WbDrawDispatcher.DrawOrderedRange</c>,
|
||||
/// before whatever leaf event follows. <see cref="WalkFrameEvent.IntArg"/>
|
||||
/// is the stream's exclusive-end command index at the moment this event
|
||||
/// was recorded.</summary>
|
||||
StreamMark,
|
||||
|
||||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawSky"/>.</summary>
|
||||
Sky,
|
||||
|
||||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawTerrainSlice"/> —
|
||||
/// <see cref="WalkFrameEvent.IntArg"/> is the slice index.</summary>
|
||||
TerrainSlice,
|
||||
|
||||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawCellShell"/> —
|
||||
/// <see cref="WalkFrameEvent.CellId"/> is the cell.</summary>
|
||||
CellShell,
|
||||
|
||||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> —
|
||||
/// <see cref="WalkFrameEvent.Polygon"/> is the already-world-transformed
|
||||
/// polygon (transformed at Collect time, exactly as the pre-FW3.4a driver
|
||||
/// transformed it before its own immediate call), <see cref="WalkFrameEvent.IntArg"/>
|
||||
/// is the active view index.</summary>
|
||||
PunchFan,
|
||||
|
||||
/// <summary><see cref="IWalkFrameLeafRenderer.AlphaBarrier"/> —
|
||||
/// <see cref="WalkFrameEvent.FloatArg"/> is the viewer distance, computed
|
||||
/// at Collect time (the context that supplies it does not outlive Collect).</summary>
|
||||
AlphaBarrier,
|
||||
|
||||
/// <summary><see cref="IWalkFrameLeafRenderer.ClearInteriorDepth"/>.</summary>
|
||||
ClearInteriorDepth,
|
||||
|
||||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawExitSeals"/>.</summary>
|
||||
ExitSeals,
|
||||
}
|
||||
|
||||
/// <summary>See <see cref="WalkFrameEventKind"/> for what each field means per
|
||||
/// kind. A single struct (rather than a kind hierarchy) keeps Collect's
|
||||
/// per-turn list a flat, allocation-cheap <c>List<WalkFrameEvent></c> —
|
||||
/// only <see cref="Polygon"/> (a punch fan's already-transformed geometry)
|
||||
/// allocates, and only once per punch, which is rare enough per frame to be
|
||||
/// unconditionally acceptable (plan §FW3.4a's own call).</summary>
|
||||
internal readonly struct WalkFrameEvent
|
||||
{
|
||||
private WalkFrameEvent(
|
||||
WalkFrameEventKind kind, int intArg, uint cellId, float floatArg, WalkPolygon? polygon)
|
||||
{
|
||||
Kind = kind;
|
||||
IntArg = intArg;
|
||||
CellId = cellId;
|
||||
FloatArg = floatArg;
|
||||
Polygon = polygon;
|
||||
}
|
||||
|
||||
internal WalkFrameEventKind Kind { get; }
|
||||
|
||||
internal int IntArg { get; }
|
||||
|
||||
internal uint CellId { get; }
|
||||
|
||||
internal float FloatArg { get; }
|
||||
|
||||
internal WalkPolygon? Polygon { get; }
|
||||
|
||||
internal static WalkFrameEvent Mark(int exclusiveEnd) =>
|
||||
new(WalkFrameEventKind.StreamMark, exclusiveEnd, 0, 0f, null);
|
||||
|
||||
internal static WalkFrameEvent Sky() =>
|
||||
new(WalkFrameEventKind.Sky, 0, 0, 0f, null);
|
||||
|
||||
internal static WalkFrameEvent TerrainSlice(int sliceIndex) =>
|
||||
new(WalkFrameEventKind.TerrainSlice, sliceIndex, 0, 0f, null);
|
||||
|
||||
internal static WalkFrameEvent CellShell(uint cellId) =>
|
||||
new(WalkFrameEventKind.CellShell, 0, cellId, 0f, null);
|
||||
|
||||
internal static WalkFrameEvent PunchFan(WalkPolygon worldPolygon, int activeViewIndex) =>
|
||||
new(WalkFrameEventKind.PunchFan, activeViewIndex, 0, 0f, worldPolygon);
|
||||
|
||||
internal static WalkFrameEvent AlphaBarrier(float viewerDistance) =>
|
||||
new(WalkFrameEventKind.AlphaBarrier, 0, 0, viewerDistance, null);
|
||||
|
||||
internal static WalkFrameEvent ClearInteriorDepth() =>
|
||||
new(WalkFrameEventKind.ClearInteriorDepth, 0, 0, 0f, null);
|
||||
|
||||
internal static WalkFrameEvent ExitSeals() =>
|
||||
new(WalkFrameEventKind.ExitSeals, 0, 0, 0f, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FW stage FW3.2b-1 — THE WALK FRAME DRIVER. Executes one full
|
||||
/// static-content frame by driving <see cref="RetailFrameWalk"/> with itself
|
||||
/// as the <see cref="IWalkEventSink"/>, turning each walk turn into either a
|
||||
/// stream append (<see cref="WalkStaticStreamPopulator"/>, FW3.2a) or a leaf
|
||||
/// call (<see cref="IWalkFrameLeafRenderer"/>), so that GPU command-buffer
|
||||
/// order equals retail's walk order (plan §FW3.2b-1's "INTERLEAVING RULE").
|
||||
/// NOT rooted into any production caller yet — <c>WorldSceneRenderer</c>
|
||||
/// does not construct or call this class (FW3.2b-2's job); this stage's
|
||||
/// deliverable is the driver plus a headless referee suite on
|
||||
/// <c>RecordingGpuDevice</c> proving the interleaving.
|
||||
/// as the <see cref="IWalkEventSink"/>, so that GPU command-buffer order
|
||||
/// equals retail's walk order (plan §FW3.2b-1's "INTERLEAVING RULE").
|
||||
///
|
||||
/// <para><b>The one flush rule that reproduces the whole frame script:</b>
|
||||
/// before EVERY leaf-renderer call (<see cref="IWalkFrameLeafRenderer.DrawSky"/>,
|
||||
/// <c>DrawTerrainSlice</c>, <c>DrawCellShell</c>, <c>ClearInteriorDepth</c>,
|
||||
/// <c>DrawExitSeals</c>, <c>DrawPunchFan</c>) and before every
|
||||
/// <see cref="IWalkFrameLeafRenderer.AlphaBarrier"/> call, the driver
|
||||
/// flushes the accumulated opaque stream (a no-op when the stream is empty —
|
||||
/// "empty segments submit nothing"); a building's own shell content is
|
||||
/// APPENDED (not flushed) the moment <see cref="IWalkEventSink.OnBuildingShellTurn"/>
|
||||
/// fires, so it flushes only at whatever non-stream action comes next (the
|
||||
/// next building's alpha barrier, or end of frame). This single rule,
|
||||
/// combined with "shell before contents" per cell, retail's own building
|
||||
/// order (alpha barrier → portal pass → shell — see
|
||||
/// <para><b>Campaign FW3.4a — the ONE-walk split.</b> Before this stage, a
|
||||
/// single frame ran <see cref="RetailFrameWalk"/> TWICE — once with a
|
||||
/// set-collecting sink to learn the flood/visited-cell set before
|
||||
/// <c>PrepareCellBatches</c>, once more through this driver to actually
|
||||
/// submit — and each walk turn's stream content flushed IMMEDIATELY through
|
||||
/// its own full <c>WbDrawDispatcher.SubmitOrderedStream</c> call (~40 of
|
||||
/// those per frame at a town, each rewriting and rebinding all nine
|
||||
/// per-instance sections for that turn's handful of instances). The FW3.4
|
||||
/// perf checkpoint measured +33.5% CPU p50 and 14× frame allocation from
|
||||
/// exactly those two costs (plus a third, unrelated one — see
|
||||
/// <see cref="WalkProductionWorldData"/>'s own doc comment) and tripped the
|
||||
/// campaign's ±20% stop rule. This stage collapses both: <see cref="Collect"/>
|
||||
/// runs <see cref="RetailFrameWalk"/> ONCE, doing everything the immediate
|
||||
/// driver used to do EXCEPT the actual GPU submission — stream appends
|
||||
/// accumulate without flushing, every former immediate leaf call records a
|
||||
/// <see cref="WalkFrameEvent"/> instead, and the driver keeps its
|
||||
/// visited-set bookkeeping (absorbing the renderer's old dedicated
|
||||
/// set-collecting sink) so the SAME walk answers both questions.
|
||||
/// <see cref="Replay"/> then performs the actual GPU work afterward:
|
||||
/// <c>WbDrawDispatcher.PrepareOrderedStream</c> uploads the WHOLE frame's
|
||||
/// stream once, and each recorded <see cref="WalkFrameEventKind.StreamMark"/>
|
||||
/// becomes one cheap <c>DrawOrderedRange</c> call over the already-uploaded
|
||||
/// payload — interleaved, in the exact recorded order, with the leaf
|
||||
/// renderer calls the OLD immediate driver made inline. Because Replay walks
|
||||
/// the SAME event sequence Collect recorded at the SAME points the old code
|
||||
/// flushed, GPU command order is unchanged bit-for-bit; only the number of
|
||||
/// walks (two → one) and the shape of the GPU submission (many small
|
||||
/// rebind-and-draw calls → one bind, many cheap draws) changes.
|
||||
/// <see cref="RunFrame"/> remains Collect immediately followed by Replay, for
|
||||
/// callers (today: the headless referee suite) that do not need the split;
|
||||
/// <c>RetailPViewRenderer</c> uses the split directly, since it must run
|
||||
/// <c>PrepareCellBatches</c>/<c>BuildAndBorrow</c> BETWEEN them.</para>
|
||||
///
|
||||
/// <para><b>The one mark rule that reproduces the whole frame script:</b>
|
||||
/// before EVERY leaf-renderer event (<see cref="WalkFrameEventKind.Sky"/>,
|
||||
/// <c>TerrainSlice</c>, <c>CellShell</c>, <c>ClearInteriorDepth</c>,
|
||||
/// <c>ExitSeals</c>, <c>PunchFan</c>) and before every
|
||||
/// <see cref="WalkFrameEventKind.AlphaBarrier"/> event, Collect records a
|
||||
/// <see cref="WalkFrameEventKind.StreamMark"/> if the stream grew since the
|
||||
/// last one (a no-op otherwise — "empty segments submit nothing"); a
|
||||
/// building's own shell content is APPENDED (not marked) the moment
|
||||
/// <see cref="IWalkEventSink.OnBuildingShellTurn"/> fires, so it only gets a
|
||||
/// mark ahead of whatever non-stream event comes next (the next building's
|
||||
/// alpha barrier, or the final mark at <see cref="Replay"/>'s prepare step).
|
||||
/// This single rule, combined with "shell before contents" per cell,
|
||||
/// retail's own building order (alpha barrier → portal pass → shell — see
|
||||
/// <see cref="RetailFrameWalk.DrawBuilding"/>'s doc comment), and retail's
|
||||
/// own interior-root DRAW order (landscape → clear → seals → the flood's own
|
||||
/// cells — see <see cref="IWalkEventSink.OnInteriorFloodDrawTurn"/>'s doc
|
||||
/// comment; this is NOT the order the walk's EVENTS fire in, which is
|
||||
/// breakpoint-entry order matching the FW0 oracle traces), is what produces
|
||||
/// every ordering constraint the plan's frame script names: [cell1 shell]
|
||||
/// [cell1 contents flush] [cell2 shell] …, [alpha barrier] [punch fan(s) +
|
||||
/// [cell1 contents mark] [cell2 shell] …, [alpha barrier] [punch fan(s) +
|
||||
/// look-in flood(s), each following the SAME shell-then-contents per-cell
|
||||
/// discipline] [building shell content flush], [landscape (if exit views
|
||||
/// discipline] [building shell content mark], [landscape (if exit views
|
||||
/// survived)] [interior depth clear] [exit-portal seals] [the interior
|
||||
/// root's own flood cells], and the final end-of-frame flush. No special-
|
||||
/// casing per turn kind is needed beyond that.</para>
|
||||
/// root's own flood cells], and a final mark at Replay's prepare step. No
|
||||
/// special-casing per turn kind is needed beyond that.</para>
|
||||
///
|
||||
/// <para>Retail anchors: <c>SmartBox::RenderNormalMode</c> @0x00453aa0 (the
|
||||
/// root <see cref="RetailFrameWalk.WalkFrame"/> already ports),
|
||||
|
|
@ -248,16 +376,29 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
|||
private readonly IWalkFrameWorldData _worldData;
|
||||
private readonly IWalkFrameDriverTrace? _trace;
|
||||
private readonly OrderedDrawStream _stream = new();
|
||||
private readonly List<WalkFrameEvent> _events = new();
|
||||
private readonly List<int> _markPositions = new();
|
||||
|
||||
// ---- transient per-RunFrame state (set in BeginFrame, cleared in EndFrame) ----
|
||||
// Campaign FW3.4a: visited-set collection, absorbed from the renderer's
|
||||
// former dedicated set-collecting sink (RetailPViewRenderer's old
|
||||
// WalkVisitedSetCollector) — the SAME shapes that sink produced, now
|
||||
// populated by the ONE walk Collect already runs instead of a second
|
||||
// walk pass dedicated to nothing but set-gathering.
|
||||
internal HashSet<uint> VisitedCells { get; } = new();
|
||||
|
||||
internal List<WalkBuilding> VisitedBuildings { get; } = new();
|
||||
|
||||
internal HashSet<uint> VisitedLandscapeCellIds { get; } = new();
|
||||
|
||||
// ---- transient per-Collect state (set in BeginFrame, read by Replay,
|
||||
// cleared by Replay's own completion) ----
|
||||
private IWalkBuildingFrameContext? _ctx;
|
||||
private IGpuFrame? _frame;
|
||||
private IGpuPassEncoder? _encoder;
|
||||
private Matrix4x4 _viewProjection;
|
||||
private Vector3 _cameraWorldPosition;
|
||||
private int _activeTerrainSliceCount;
|
||||
private bool _skyDrawnThisFrame;
|
||||
private WalkDrawStage? _currentDcStage;
|
||||
private bool _readyToReplay;
|
||||
|
||||
internal WalkFrameDriver(
|
||||
WbDrawDispatcher dispatcher,
|
||||
|
|
@ -274,8 +415,10 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
|||
|
||||
/// <summary>
|
||||
/// Drives one complete frame at retail's root (<c>SmartBox::RenderNormalMode</c>):
|
||||
/// calls <see cref="RetailFrameWalk.WalkFrame"/> with this driver as the
|
||||
/// sink, sandwiched between <see cref="BeginFrame"/>/<see cref="EndFrame"/>.
|
||||
/// <see cref="Collect"/> immediately followed by <see cref="Replay"/>. Kept
|
||||
/// for callers that don't need the split (today: the headless referee
|
||||
/// suite) — <c>RetailPViewRenderer</c> calls the pair directly, since it
|
||||
/// must run other frame work BETWEEN them (plan §FW3.4a).
|
||||
/// </summary>
|
||||
internal void RunFrame(
|
||||
RetailFrameWalk walk,
|
||||
|
|
@ -288,35 +431,57 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
|||
Matrix4x4 viewProjection,
|
||||
Vector3 cameraWorldPosition,
|
||||
int activeTerrainSliceCount = 1)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
ArgumentNullException.ThrowIfNull(encoder);
|
||||
|
||||
Collect(
|
||||
walk, cameraCellId, cameraCell, landscape, ctx,
|
||||
viewProjection, cameraWorldPosition, activeTerrainSliceCount);
|
||||
Replay(frame, encoder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FW3.4a Phase 1 — THE ONE WALK. Drives
|
||||
/// <see cref="RetailFrameWalk.WalkFrame"/> with this driver as its sink,
|
||||
/// sandwiched between <see cref="BeginFrame"/>/<see cref="EndFrame"/>,
|
||||
/// performing NO GPU work: see this type's own doc comment.
|
||||
/// </summary>
|
||||
internal void Collect(
|
||||
RetailFrameWalk walk,
|
||||
uint cameraCellId,
|
||||
WalkCell? cameraCell,
|
||||
WalkLandscape landscape,
|
||||
IRetailFrameWalkContext ctx,
|
||||
Matrix4x4 viewProjection,
|
||||
Vector3 cameraWorldPosition,
|
||||
int activeTerrainSliceCount)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(walk);
|
||||
ArgumentNullException.ThrowIfNull(landscape);
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
|
||||
BeginFrame(ctx, frame, encoder, viewProjection, cameraWorldPosition, activeTerrainSliceCount);
|
||||
BeginFrame(ctx, viewProjection, cameraWorldPosition, activeTerrainSliceCount);
|
||||
walk.WalkFrame(cameraCellId, cameraCell, landscape, ctx, this);
|
||||
EndFrame();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a driver frame without driving the walk itself — for a caller
|
||||
/// Opens a collect scope without driving the walk itself — for a caller
|
||||
/// (or a test) that already holds an isolated walk entry point (e.g. one
|
||||
/// <see cref="RetailFrameWalk.DrawBuilding"/> or
|
||||
/// <see cref="RetailFrameWalk.DrawLandscape"/> call) and wants this
|
||||
/// driver's turn handling without going through the top-level root.
|
||||
/// <see cref="RunFrame"/> is implemented in terms of this pair.
|
||||
/// <see cref="Collect"/> is implemented in terms of this pair. Performs no
|
||||
/// GPU work — see this type's own doc comment.
|
||||
/// </summary>
|
||||
internal void BeginFrame(
|
||||
IWalkBuildingFrameContext ctx,
|
||||
IGpuFrame frame,
|
||||
IGpuPassEncoder encoder,
|
||||
Matrix4x4 viewProjection,
|
||||
Vector3 cameraWorldPosition,
|
||||
int activeTerrainSliceCount)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
ArgumentNullException.ThrowIfNull(encoder);
|
||||
if (activeTerrainSliceCount < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
|
|
@ -332,36 +497,112 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
|||
}
|
||||
|
||||
_ctx = ctx;
|
||||
_frame = frame;
|
||||
_encoder = encoder;
|
||||
_viewProjection = viewProjection;
|
||||
_cameraWorldPosition = cameraWorldPosition;
|
||||
_activeTerrainSliceCount = activeTerrainSliceCount;
|
||||
_skyDrawnThisFrame = false;
|
||||
_currentDcStage = null;
|
||||
_readyToReplay = false;
|
||||
_stream.Reset();
|
||||
_events.Clear();
|
||||
_markPositions.Clear();
|
||||
VisitedCells.Clear();
|
||||
VisitedBuildings.Clear();
|
||||
VisitedLandscapeCellIds.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Final segment flush (plan §FW3.2b-1's "at frame end: final
|
||||
/// segment flush"), then clears transient per-frame state. Always runs
|
||||
/// via the caller's <c>try</c>/<c>finally</c> discipline in
|
||||
/// <see cref="RunFrame"/> — a caller driving the walk manually should
|
||||
/// follow the same shape.</summary>
|
||||
/// <summary>Records the final segment mark (plan §FW3.2b-1's "at frame
|
||||
/// end: final segment flush", now a mark rather than a draw — see this
|
||||
/// type's own doc comment), then closes the collect scope. The recorded
|
||||
/// stream/events survive this call — <see cref="Replay"/> consumes them —
|
||||
/// which is the one behavioral difference from the pre-FW3.4a EndFrame,
|
||||
/// which reset the stream here because it had just drawn it.</summary>
|
||||
internal void EndFrame()
|
||||
{
|
||||
try
|
||||
{
|
||||
FlushIfNonEmpty();
|
||||
MarkIfGrown();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ctx = null;
|
||||
_frame = null;
|
||||
_encoder = null;
|
||||
_stream.Reset();
|
||||
_readyToReplay = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FW3.4a Phase 2. Requires a completed Collect (an
|
||||
/// <see cref="EndFrame"/> having run since the last Replay) — throws
|
||||
/// otherwise, rather than silently replaying a stale or empty event list.
|
||||
/// Uploads the WHOLE collected stream exactly once (skipped when it is
|
||||
/// empty), then walks the recorded events in order: a
|
||||
/// <see cref="WalkFrameEventKind.StreamMark"/> issues one
|
||||
/// <c>WbDrawDispatcher.DrawOrderedRange</c> call over the segment it
|
||||
/// closes off; every other event kind issues its corresponding
|
||||
/// <see cref="IWalkFrameLeafRenderer"/> call. Because Collect recorded
|
||||
/// these events at EXACTLY the points the pre-FW3.4a immediate driver
|
||||
/// used to flush/draw, this reproduces the SAME interleaved GPU command
|
||||
/// order — the campaign invariant — from one walk instead of two.
|
||||
/// </summary>
|
||||
internal void Replay(IGpuFrame frame, IGpuPassEncoder encoder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
ArgumentNullException.ThrowIfNull(encoder);
|
||||
if (!_readyToReplay)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"WalkFrameDriver.Replay was called without a completed Collect (BeginFrame/"
|
||||
+ "EndFrame, or Collect/RunFrame) preceding it — there is nothing recorded to "
|
||||
+ "replay.");
|
||||
}
|
||||
|
||||
if (_stream.Count > 0)
|
||||
_dispatcher.PrepareOrderedStream(frame, _stream, _viewProjection, _markPositions);
|
||||
|
||||
int cursor = 0;
|
||||
for (int i = 0; i < _events.Count; i++)
|
||||
{
|
||||
WalkFrameEvent e = _events[i];
|
||||
switch (e.Kind)
|
||||
{
|
||||
case WalkFrameEventKind.StreamMark:
|
||||
int end = e.IntArg;
|
||||
int count = end - cursor;
|
||||
if (_trace is not null)
|
||||
_trace.OnFlush(count, _stream.Stages.GetRange(cursor, count));
|
||||
_dispatcher.DrawOrderedRange(encoder, cursor, count);
|
||||
cursor = end;
|
||||
break;
|
||||
case WalkFrameEventKind.Sky:
|
||||
_leafRenderer.DrawSky();
|
||||
break;
|
||||
case WalkFrameEventKind.TerrainSlice:
|
||||
_leafRenderer.DrawTerrainSlice(e.IntArg);
|
||||
break;
|
||||
case WalkFrameEventKind.CellShell:
|
||||
_leafRenderer.DrawCellShell(e.CellId);
|
||||
break;
|
||||
case WalkFrameEventKind.PunchFan:
|
||||
_leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg);
|
||||
break;
|
||||
case WalkFrameEventKind.AlphaBarrier:
|
||||
_leafRenderer.AlphaBarrier(e.FloatArg);
|
||||
break;
|
||||
case WalkFrameEventKind.ClearInteriorDepth:
|
||||
_leafRenderer.ClearInteriorDepth();
|
||||
break;
|
||||
case WalkFrameEventKind.ExitSeals:
|
||||
_leafRenderer.DrawExitSeals();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_stream.Reset();
|
||||
_events.Clear();
|
||||
_markPositions.Clear();
|
||||
_readyToReplay = false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// IWalkEventSink
|
||||
// ------------------------------------------------------------------
|
||||
|
|
@ -372,11 +613,14 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
|||
{
|
||||
case WalkEventKind.DrawInside:
|
||||
_currentDcStage = WalkDrawStage.CellStatic;
|
||||
VisitedCells.Add(walkEvent.CellId);
|
||||
break;
|
||||
case WalkEventKind.Landscape:
|
||||
HandleLandscapeTurn();
|
||||
break;
|
||||
case WalkEventKind.DrawCells:
|
||||
foreach (uint id in walkEvent.Cells)
|
||||
VisitedCells.Add(id);
|
||||
HandleDrawCellsTurn(walkEvent.Cells);
|
||||
break;
|
||||
case WalkEventKind.Building:
|
||||
|
|
@ -389,6 +633,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
|||
void IWalkEventSink.OnLandscapeCellTurn(uint cellId)
|
||||
{
|
||||
RequireOpenFrame();
|
||||
VisitedLandscapeCellIds.Add(cellId);
|
||||
WalkFrameStaticRecords records = _worldData.GetOutdoorStatics(cellId);
|
||||
_populator.PopulateOutdoorStatics(
|
||||
_stream, cellId, records.Records, records.TupleLandblockId,
|
||||
|
|
@ -399,13 +644,14 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(building);
|
||||
IWalkBuildingFrameContext ctx = RequireOpenFrame();
|
||||
VisitedBuildings.Add(building);
|
||||
|
||||
// D3DPolyRender::FlushAlphaList(0f) @0x0059f30b — retail's alpha
|
||||
// barrier, first inside the gate. The portal pass (punches +
|
||||
// look-ins) follows this call; the building's own shell content is
|
||||
// appended only once that pass completes (OnBuildingShellTurn).
|
||||
FlushIfNonEmpty();
|
||||
_leafRenderer.AlphaBarrier(ctx.ViewerDistanceTo(building));
|
||||
MarkIfGrown();
|
||||
_events.Add(WalkFrameEvent.AlphaBarrier(ctx.ViewerDistanceTo(building)));
|
||||
|
||||
_currentDcStage = WalkDrawStage.LookInStatic;
|
||||
}
|
||||
|
|
@ -417,10 +663,10 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
|||
|
||||
// CPhysicsPart::Draw(parts, 0) @0x0059f331 — retail's plain-mesh
|
||||
// shell draw, strictly after the portal pass (CPhysicsPart::Draw
|
||||
// (parts, 1)). Flush first so this building's shell content never
|
||||
// shares a segment with whatever the portal pass's last look-in
|
||||
// flood appended (keeps every flushed segment single-stage).
|
||||
FlushIfNonEmpty();
|
||||
// (parts, 1)). Mark first so this building's shell content never
|
||||
// shares a replayed range with whatever the portal pass's last
|
||||
// look-in flood appended (keeps every range single-stage).
|
||||
MarkIfGrown();
|
||||
WalkFrameStaticRecords shell = _worldData.GetBuildingShellStatics(building);
|
||||
_populator.PopulateCell(
|
||||
_stream, WalkDrawStage.BuildingShell, building.PositionCellId,
|
||||
|
|
@ -434,10 +680,10 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
|||
ArgumentNullException.ThrowIfNull(polygon);
|
||||
RequireOpenFrame();
|
||||
|
||||
FlushIfNonEmpty();
|
||||
MarkIfGrown();
|
||||
Matrix4x4 worldTransform = _worldData.GetBuildingWorldTransform(building);
|
||||
_leafRenderer.DrawPunchFan(
|
||||
TransformToWorld(polygon, worldTransform), activeViewIndex);
|
||||
_events.Add(
|
||||
WalkFrameEvent.PunchFan(TransformToWorld(polygon, worldTransform), activeViewIndex));
|
||||
}
|
||||
|
||||
void IWalkEventSink.OnInteriorFloodDrawTurn(IReadOnlyList<uint> cells)
|
||||
|
|
@ -450,11 +696,11 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
|||
// both unconditional for an interior root's own flood, whether or
|
||||
// not a landscape turn just ran (see this driver's type doc
|
||||
// comment).
|
||||
FlushIfNonEmpty();
|
||||
_leafRenderer.ClearInteriorDepth();
|
||||
MarkIfGrown();
|
||||
_events.Add(WalkFrameEvent.ClearInteriorDepth());
|
||||
|
||||
FlushIfNonEmpty();
|
||||
_leafRenderer.DrawExitSeals();
|
||||
MarkIfGrown();
|
||||
_events.Add(WalkFrameEvent.ExitSeals());
|
||||
|
||||
for (int i = 0; i < cells.Count; i++)
|
||||
EmitCellTurn(WalkDrawStage.CellStatic, cells[i]);
|
||||
|
|
@ -478,11 +724,11 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
|||
+ "FW3.2b-1 fail-loud rule).");
|
||||
}
|
||||
|
||||
FlushIfNonEmpty();
|
||||
_leafRenderer.DrawSky();
|
||||
MarkIfGrown();
|
||||
_events.Add(WalkFrameEvent.Sky());
|
||||
_skyDrawnThisFrame = true;
|
||||
for (int slice = 0; slice < _activeTerrainSliceCount; slice++)
|
||||
_leafRenderer.DrawTerrainSlice(slice);
|
||||
_events.Add(WalkFrameEvent.TerrainSlice(slice));
|
||||
}
|
||||
|
||||
private void HandleDrawCellsTurn(IReadOnlyList<uint> cells)
|
||||
|
|
@ -512,38 +758,46 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
|||
// Any other stage (LookInStatic) is a building's look-in flood:
|
||||
// retail calls DrawCells re-entrantly there with no landscape/clear/
|
||||
// seal step, so its DC event already fires at the real draw point —
|
||||
// draw immediately, unchanged from before this correction.
|
||||
// record immediately, unchanged from before this correction.
|
||||
for (int i = 0; i < cells.Count; i++)
|
||||
EmitCellTurn(stage, cells[i]);
|
||||
}
|
||||
|
||||
private void EmitCellTurn(WalkDrawStage stage, uint cellId)
|
||||
{
|
||||
FlushIfNonEmpty();
|
||||
_leafRenderer.DrawCellShell(cellId);
|
||||
MarkIfGrown();
|
||||
_events.Add(WalkFrameEvent.CellShell(cellId));
|
||||
WalkFrameStaticRecords records = _worldData.GetCellStatics(cellId);
|
||||
_populator.PopulateCell(
|
||||
_stream, stage, cellId, records.Records, records.TupleLandblockId,
|
||||
_cameraWorldPosition, _viewProjection);
|
||||
}
|
||||
|
||||
private void FlushIfNonEmpty()
|
||||
/// <summary>Campaign FW3.4a: the collect-time analogue of the old
|
||||
/// immediate driver's <c>FlushIfNonEmpty</c> — records a
|
||||
/// <see cref="WalkFrameEventKind.StreamMark"/> at the stream's current
|
||||
/// length if it grew since the last mark (a no-op otherwise, exactly
|
||||
/// like that method's own "empty segments submit nothing" rule). Also
|
||||
/// appends the boundary to <see cref="_markPositions"/>, which
|
||||
/// <see cref="Replay"/> hands to <c>PrepareOrderedStream</c> so a merge
|
||||
/// run can never span it — see <c>WbDrawDispatcher.BuildOrderedMergeRuns</c>'s
|
||||
/// <c>forcedBreaksAscending</c> parameter for why that matters.</summary>
|
||||
private void MarkIfGrown()
|
||||
{
|
||||
if (_stream.Count == 0)
|
||||
int count = _stream.Count;
|
||||
int last = _markPositions.Count > 0 ? _markPositions[^1] : 0;
|
||||
if (count == last)
|
||||
return;
|
||||
|
||||
if (_trace is not null)
|
||||
_trace.OnFlush(_stream.Count, _stream.Stages.ToArray());
|
||||
|
||||
_dispatcher.SubmitOrderedStream(_frame!, _encoder!, _stream, _viewProjection);
|
||||
_stream.Reset();
|
||||
_markPositions.Add(count);
|
||||
_events.Add(WalkFrameEvent.Mark(count));
|
||||
}
|
||||
|
||||
private IWalkBuildingFrameContext RequireOpenFrame() =>
|
||||
_ctx ?? throw new InvalidOperationException(
|
||||
"WalkFrameDriver received a walk turn outside BeginFrame/EndFrame — call "
|
||||
+ "BeginFrame (or RunFrame) before driving the walk with this driver as its "
|
||||
+ "IWalkEventSink.");
|
||||
+ "BeginFrame (or Collect/RunFrame) before driving the walk with this driver as "
|
||||
+ "its IWalkEventSink.");
|
||||
|
||||
/// <summary><c>ConstructBuildingView</c>'s polygon is building-local; the
|
||||
/// punch fan needs world space. Vertices transform directly; the plane
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
|
||||
namespace AcDream.App.Rendering.Walk;
|
||||
|
|
@ -11,7 +12,7 @@ namespace AcDream.App.Rendering.Walk;
|
|||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>Cell statics — <see cref="RenderSceneQuery.CopyCellStaticsTo"/> on
|
||||
/// demand, one pooled array per distinct cell per frame (a cell can be
|
||||
/// demand, one arena segment per distinct cell per frame (a cell can be
|
||||
/// visited once by the root flood OR once per admitting look-in portal; the
|
||||
/// per-frame cache keeps the copy single).</item>
|
||||
/// <item>Outdoor statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/>
|
||||
|
|
@ -31,6 +32,25 @@ namespace AcDream.App.Rendering.Walk;
|
|||
/// The tuple landblock id handed to the classifier is the frame's player
|
||||
/// landblock — the packed path's own convention for every
|
||||
/// <c>RenderFrameEntityDrawRequest</c>.
|
||||
///
|
||||
/// <para>Campaign FW3.4a: <see cref="GetCellStatics"/>, <see cref="GetOutdoorStatics"/>,
|
||||
/// and <see cref="GetBuildingShellStatics"/> used to materialize their result
|
||||
/// with <c>_cellScratch[..count]</c> / <c>[.. bucket]</c> — a FRESH
|
||||
/// <c>RenderProjectionRecord[]</c> allocation per distinct cell/anchor per
|
||||
/// frame. At a town-density frame (dozens of cells) that was the single
|
||||
/// largest contributor to the FW3.4 perf checkpoint's 14× frame-allocation
|
||||
/// regression (1.9 MB/frame p50). <see cref="_arena"/> replaces it: a
|
||||
/// grow-only buffer, reset to length 0 once per frame in
|
||||
/// <see cref="BeginFrame"/>, that every materialization call
|
||||
/// <see cref="AppendToArena"/>s its records into instead of snapshotting a
|
||||
/// new array — after the arena reaches its steady-state size (a few frames
|
||||
/// of warmup, same shape as <see cref="_sweepScratch"/>/<see cref="_cellScratch"/>'s
|
||||
/// existing grow-on-demand pattern), zero further heap allocation occurs
|
||||
/// here. Every <see cref="WalkFrameStaticRecords.Records"/> segment is
|
||||
/// STRICTLY per-frame scratch — nothing holds one across a frame boundary
|
||||
/// (the driver/populator consume it immediately, matching
|
||||
/// <see cref="_sweepScratch"/>'s existing lifetime contract) — so reusing the
|
||||
/// same backing array's memory next frame is safe.</para>
|
||||
/// </summary>
|
||||
internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
||||
{
|
||||
|
|
@ -48,6 +68,11 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
|||
private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024];
|
||||
private RenderProjectionRecord[] _cellScratch = new RenderProjectionRecord[256];
|
||||
|
||||
// Campaign FW3.4a: the per-frame, grow-only materialization arena — see
|
||||
// this type's own doc comment.
|
||||
private RenderProjectionRecord[] _arena = new RenderProjectionRecord[4096];
|
||||
private int _arenaLength;
|
||||
|
||||
internal WalkProductionWorldData(WalkBuildingRegistry buildings)
|
||||
{
|
||||
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
|
||||
|
|
@ -75,6 +100,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
|||
_cellCache.Clear();
|
||||
_outdoorMaterialized.Clear();
|
||||
_shellMaterialized.Clear();
|
||||
_arenaLength = 0;
|
||||
foreach (List<RenderProjectionRecord> bucket in _outdoorByCell.Values)
|
||||
bucket.Clear();
|
||||
foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values)
|
||||
|
|
@ -148,7 +174,8 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
|||
int count = _scene.CopyCellStaticsTo(cellId, _cellScratch);
|
||||
WalkFrameStaticRecords records = count == 0
|
||||
? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }
|
||||
: new WalkFrameStaticRecords(_cellScratch[..count], _tupleLandblockId);
|
||||
: new WalkFrameStaticRecords(
|
||||
AppendToArena(_cellScratch.AsSpan(0, count)), _tupleLandblockId);
|
||||
_cellCache[cellId] = records;
|
||||
return records;
|
||||
}
|
||||
|
|
@ -160,7 +187,8 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
|||
WalkFrameStaticRecords records =
|
||||
_outdoorByCell.TryGetValue(cellId, out List<RenderProjectionRecord>? bucket)
|
||||
&& bucket.Count > 0
|
||||
? new WalkFrameStaticRecords([.. bucket], _tupleLandblockId)
|
||||
? new WalkFrameStaticRecords(
|
||||
AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
|
||||
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
|
||||
_outdoorMaterialized[cellId] = records;
|
||||
return records;
|
||||
|
|
@ -176,12 +204,42 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
|||
WalkFrameStaticRecords records =
|
||||
_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells)
|
||||
&& shells.Count > 0
|
||||
? new WalkFrameStaticRecords([.. shells], _tupleLandblockId)
|
||||
? new WalkFrameStaticRecords(
|
||||
AppendToArena(CollectionsMarshal.AsSpan(shells)), _tupleLandblockId)
|
||||
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
|
||||
_shellMaterialized[anchor] = records;
|
||||
return records;
|
||||
}
|
||||
|
||||
/// <summary>Copies <paramref name="source"/> into <see cref="_arena"/> at
|
||||
/// its current length, growing the arena first if needed (doubling, or
|
||||
/// exactly enough for an unusually large sweep — the same growth shape
|
||||
/// <see cref="_sweepScratch"/>/<see cref="_cellScratch"/> already use),
|
||||
/// and returns the segment the copy landed in. A prior frame's growth can
|
||||
/// leave an earlier-returned segment pointing at a retired backing array
|
||||
/// — harmless, since that array's content stays valid and nothing reads
|
||||
/// a segment across a frame boundary (see this type's own doc
|
||||
/// comment).</summary>
|
||||
private ArraySegment<RenderProjectionRecord> AppendToArena(
|
||||
ReadOnlySpan<RenderProjectionRecord> source)
|
||||
{
|
||||
if (source.Length == 0)
|
||||
return ArraySegment<RenderProjectionRecord>.Empty;
|
||||
|
||||
int required = _arenaLength + source.Length;
|
||||
if (required > _arena.Length)
|
||||
{
|
||||
var grown = new RenderProjectionRecord[Math.Max(required, _arena.Length * 2)];
|
||||
Array.Copy(_arena, grown, _arenaLength);
|
||||
_arena = grown;
|
||||
}
|
||||
|
||||
source.CopyTo(_arena.AsSpan(_arenaLength, source.Length));
|
||||
var segment = new ArraySegment<RenderProjectionRecord>(_arena, _arenaLength, source.Length);
|
||||
_arenaLength += source.Length;
|
||||
return segment;
|
||||
}
|
||||
|
||||
/// <summary>The building's authored shell anchor: its first non-exit
|
||||
/// portal's destination cell — the SAME rule <c>LandblockLoader</c> used
|
||||
/// when it stamped <c>BuildingShellAnchorCellId</c> on the shell entity.</summary>
|
||||
|
|
|
|||
|
|
@ -20,18 +20,36 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// (command <c>i</c> owns exactly one instance, <c>BaseInstance = i</c>, so
|
||||
/// walk order — never material bucketing — survives into the indirect array)
|
||||
/// and <see cref="PrepareRhiAlphaSections"/>'s "write every section once"
|
||||
/// shape (locals here, not the persisted <c>_alpha*</c> fields: those belong
|
||||
/// to <see cref="RetailAlphaQueue"/>, which can still be mid-flight in the
|
||||
/// same frame, and overwriting them would corrupt that replay).</para>
|
||||
/// shape (<c>_ordered*</c> fields here, not the persisted <c>_alpha*</c>
|
||||
/// fields: those belong to <see cref="RetailAlphaQueue"/>, which can still be
|
||||
/// mid-flight in the same frame, and overwriting them would corrupt that
|
||||
/// replay).</para>
|
||||
///
|
||||
/// <para>Campaign FW stage FW3.4a (2026-08-30): the FW2 submitter was ONE
|
||||
/// method, <c>SubmitOrderedStream</c>, that wrote every per-instance section
|
||||
/// AND drew every merge run in one call — fine for FW2/FW3.2's proof, but the
|
||||
/// FW3.4 perf checkpoint measured ~40 of these per frame at a town (one per
|
||||
/// walk segment — every cell shell, every building's alpha barrier, every
|
||||
/// punch fan flushes the accumulated stream so far), each one re-writing all
|
||||
/// nine per-instance ring sections and rebinding everything, for that
|
||||
/// segment's handful of instances. <see cref="PrepareOrderedStream"/> +
|
||||
/// <see cref="DrawOrderedRange"/> replace it: the WHOLE frame's stream is
|
||||
/// written and bound ONCE, and each walk segment becomes a cheap
|
||||
/// <see cref="DrawOrderedRange"/> call over the ALREADY-uploaded payload —
|
||||
/// the same split <see cref="PrepareRhiAlphaSections"/>/
|
||||
/// <see cref="DrawPreparedAlphaBatchRhi"/> already prove for the alpha path.
|
||||
/// <c>SubmitOrderedStream</c> itself is deleted; every FW2 caller/test now
|
||||
/// calls the pair (prepare once, draw the whole stream as one range, or as
|
||||
/// several — see <c>OrderPreservingSubmitterTests</c>).</para>
|
||||
///
|
||||
/// <para>Scope: this stage proves walk-order submission through the existing
|
||||
/// RHI on static content (plan §FW2). It does NOT wire the retail
|
||||
/// building-detail overlay replay (<c>DrawBuildingDetailRangeRhi</c>'s second
|
||||
/// pass through <c>RetailDetail</c>/<c>RetailDetailTransparent</c>) — a
|
||||
/// detail-category command still forces a solo merge run (mirroring the
|
||||
/// deferred-alpha detail break), but <see cref="SubmitOrderedStream"/> issues
|
||||
/// only the base-pipeline draw for it. The overlay replay is production
|
||||
/// wiring, deferred to whichever stage cuts the walk over for real content.</para>
|
||||
/// deferred-alpha detail break), but this submitter issues only the
|
||||
/// base-pipeline draw for it. The overlay replay is production wiring,
|
||||
/// deferred to whichever stage cuts the walk over for real content.</para>
|
||||
/// </summary>
|
||||
public sealed unsafe partial class WbDrawDispatcher
|
||||
{
|
||||
|
|
@ -99,12 +117,34 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
/// reorders or drops anything: every command in <paramref name="stream"/>
|
||||
/// belongs to exactly one returned run, in stream order.</para>
|
||||
///
|
||||
/// <para><paramref name="forcedBreaksAscending"/> (FW3.4a addition,
|
||||
/// default none): extra command indices, sorted ascending, at which a run
|
||||
/// must end even when the state comparison above would otherwise extend
|
||||
/// it. <see cref="WalkFrameDriver"/>'s Replay phase draws the frame's ONE
|
||||
/// prepared stream as several <see cref="DrawOrderedRange"/> calls — one
|
||||
/// per walk segment, each separated by a leaf GPU call (a cell shell, a
|
||||
/// punch fan, an alpha barrier) that MUST execute between them — and nothing
|
||||
/// about <see cref="WalkDrawStage"/>/bucket/cull/detail forbids two
|
||||
/// DIFFERENT segments from sharing all four (two consecutive indoor cells
|
||||
/// drawn Opaque/CounterClockwise, the overwhelmingly common case). Without
|
||||
/// this parameter, a whole-stream merge pass would happily fuse such
|
||||
/// segments into one run spanning the leaf call that must run BETWEEN
|
||||
/// them, silently reordering GPU commands relative to retail's walk — the
|
||||
/// one invariant this campaign may never trade away. Passing each
|
||||
/// segment's start index here makes <see cref="DrawOrderedRange"/>'s
|
||||
/// "a range boundary always coincides with a run boundary" assumption
|
||||
/// true BY CONSTRUCTION instead of by hope; that method's own assert
|
||||
/// stays as insurance against a future bug in how boundaries are
|
||||
/// supplied. Every FW2 call site keeps passing none, so existing
|
||||
/// single-segment behavior (and its tests) is unchanged.</para>
|
||||
///
|
||||
/// <para>Fails loud before building any run: <see cref="WalkDrawStage.PortalPunch"/>
|
||||
/// has no FW2 submission path (see that value's own documentation), so a
|
||||
/// has no submission path (see that value's own documentation), so a
|
||||
/// stream carrying one throws immediately rather than silently degrading
|
||||
/// to some other stage's handling.</para>
|
||||
/// </summary>
|
||||
internal static List<OrderedMergeRun> BuildOrderedMergeRuns(OrderedDrawStream stream)
|
||||
internal static List<OrderedMergeRun> BuildOrderedMergeRuns(
|
||||
OrderedDrawStream stream, IReadOnlyList<int>? forcedBreaksAscending = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
int count = stream.Count;
|
||||
|
|
@ -123,10 +163,19 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<int> breaks = forcedBreaksAscending ?? Array.Empty<int>();
|
||||
int breakCursor = 0;
|
||||
|
||||
var runs = new List<OrderedMergeRun>();
|
||||
int cursor = 0;
|
||||
while (cursor < count)
|
||||
{
|
||||
// A break AT OR BEFORE cursor already ended the previous run (or
|
||||
// predates the stream entirely) — only a break STRICTLY AFTER
|
||||
// cursor can stop the one starting here.
|
||||
while (breakCursor < breaks.Count && breaks[breakCursor] <= cursor)
|
||||
breakCursor++;
|
||||
|
||||
WalkDrawStage stage = stream.Stages[cursor];
|
||||
PipelineBucket bucket = BucketFor(stream.Keys[cursor].Translucency);
|
||||
CullMode cull = stream.Keys[cursor].CullMode;
|
||||
|
|
@ -136,6 +185,7 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
if (!detail)
|
||||
{
|
||||
while (end < count
|
||||
&& !(breakCursor < breaks.Count && breaks[breakCursor] == end)
|
||||
&& stream.Stages[end] == stage
|
||||
&& stream.DetailCategories[end] == 0
|
||||
&& BucketFor(stream.Keys[end].Translucency) == bucket
|
||||
|
|
@ -212,11 +262,10 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
|
||||
/// <summary>
|
||||
/// Campaign FW3.2b-2: the production frame/encoder pair for
|
||||
/// <see cref="WalkFrameDriver"/>'s own <see cref="SubmitOrderedStream"/>
|
||||
/// <see cref="WalkFrameDriver"/>'s own <see cref="DrawOrderedRange"/>
|
||||
/// calls (contrast this stage's diagnostic-target callers, which supply
|
||||
/// their own frame/encoder — see <see cref="SubmitOrderedStream"/>'s own
|
||||
/// doc comment). Reads the SAME world-pass scope <see cref="SubmitRhi"/>
|
||||
/// already requires (<see cref="RequireRhiFrame"/> /
|
||||
/// their own frame/encoder). Reads the SAME world-pass scope
|
||||
/// <see cref="SubmitRhi"/> already requires (<see cref="RequireRhiFrame"/> /
|
||||
/// <c>_scope.RequireEncoder()</c>) — fails loud rather than handing the
|
||||
/// driver a null pair when the world phase is not bracketing.
|
||||
/// </summary>
|
||||
|
|
@ -236,55 +285,101 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
internal (int Width, int Height)? WalkAttachmentExtent =>
|
||||
_scope is null ? null : (_scope.AttachmentWidth, _scope.AttachmentHeight);
|
||||
|
||||
// ── Campaign FW3.4a: the prepared-once, drawn-in-ranges pair ───────────
|
||||
//
|
||||
// Persisted state a PrepareOrderedStream call fills and every later
|
||||
// DrawOrderedRange call in the SAME frame reads. Deliberately its own set
|
||||
// — never _alpha* — for the same reason SubmitOrderedStream's per-frame
|
||||
// locals were never _alpha* (see this file's type doc comment):
|
||||
// RetailAlphaQueue can still be mid-flight when a walk segment flushes,
|
||||
// and sharing storage would corrupt whichever path writes second.
|
||||
|
||||
private OrderedDrawStream? _orderedStream;
|
||||
private List<OrderedMergeRun> _orderedRuns = new();
|
||||
private int _orderedPreparedCount;
|
||||
private bool _orderedSectionsBound;
|
||||
// Caller-supplied, exactly like SubmitOrderedStream's own frame/encoder
|
||||
// parameters were (see this file's type doc comment: the walk submitter
|
||||
// draws into whatever pass its caller has open, never pulled from
|
||||
// _frames/_scope) — DrawOrderedRange's bind-once step needs the SAME
|
||||
// frame Prepare wrote sections into, for WorldFrameSectionBinding's
|
||||
// clip-region/scene-lighting binds; RequireRhiFrame() is the wrong tool
|
||||
// here since it demands the dispatcher's OWN BeginFrame/_dynamicFrameStarted
|
||||
// bookkeeping, which the walk path never participates in.
|
||||
private IGpuFrame? _orderedFrame;
|
||||
private Matrix4x4 _orderedViewProjection;
|
||||
private uint _orderedTransformBaseInstance;
|
||||
private RhiSection _orderedInstances;
|
||||
private RhiSection _orderedBatches;
|
||||
private RhiSection _orderedClipSlots;
|
||||
private RhiSection _orderedGlobalLights;
|
||||
private RhiSection _orderedLightSets;
|
||||
private RhiSection _orderedIndoor;
|
||||
private RhiSection _orderedAlpha;
|
||||
private RhiSection _orderedSelectionLighting;
|
||||
private RhiSection _orderedDetailCategory;
|
||||
private RhiSection _orderedCommands;
|
||||
|
||||
/// <summary>
|
||||
/// Submits <paramref name="stream"/> in walk order through the existing
|
||||
/// RHI: per-instance-first emission (see the type doc comment), one
|
||||
/// section write per per-instance array, then one
|
||||
/// <see cref="DrawIndirectRangeRhi"/> call per maximal merge run from
|
||||
/// <see cref="BuildOrderedMergeRuns"/>. N commands in yield indirect
|
||||
/// commands <c>[0, N)</c> in stream order, each covered by exactly one
|
||||
/// emitted run — nothing is reordered, sorted, or dropped.
|
||||
/// Writes <paramref name="stream"/>'s ENTIRE walk-order payload into the
|
||||
/// frame ring exactly ONCE — the per-instance-first emission
|
||||
/// (<see cref="PrepareDeferredAlphaDraws"/>'s shape) followed by one
|
||||
/// section write per per-instance array (<see cref="PrepareRhiAlphaSections"/>'s
|
||||
/// shape, into the <c>_ordered*</c> fields above). No draw happens here;
|
||||
/// <see cref="DrawOrderedRange"/> issues the actual
|
||||
/// <see cref="DrawIndirectRangeRhi"/> calls against this prepared payload,
|
||||
/// as many times as the caller needs (<see cref="WalkFrameDriver"/> calls
|
||||
/// it once per walk segment, interleaved with the leaf GPU calls that
|
||||
/// must run between segments).
|
||||
///
|
||||
/// <para><paramref name="frame"/> and <paramref name="encoder"/> are
|
||||
/// caller-supplied rather than pulled from <c>_frames</c>/<c>_scope</c>
|
||||
/// (contrast <see cref="SubmitRhi"/>'s <c>RequireRhiFrame</c>/
|
||||
/// <c>scope.RequireEncoder()</c>): the walk submitter draws into whatever
|
||||
/// pass its caller has open, including an offscreen diagnostic target
|
||||
/// that never touches the dispatcher's own world-pass scope. The frame's
|
||||
/// shared clip-region/scene-lighting sections still come from
|
||||
/// <c>_scope.Sections</c> — those are canonical per-frame published
|
||||
/// state, not something this submitter owns.</para>
|
||||
/// <para><paramref name="forcedBreaksAscending"/> forwards to
|
||||
/// <see cref="BuildOrderedMergeRuns"/> — see that parameter's own doc
|
||||
/// comment. Pass the walk segment boundaries here so a later
|
||||
/// <see cref="DrawOrderedRange"/> call's range always aligns with a merge
|
||||
/// run by construction.</para>
|
||||
///
|
||||
/// <para>A no-op (leaves <see cref="_orderedPreparedCount"/> at 0) when
|
||||
/// the stream is empty or the mesh source is not yet ready — mirrors
|
||||
/// <see cref="PrepareDeferredAlphaDraws"/>'s own early-outs. Fails loud
|
||||
/// BEFORE any GPU work if the stream carries an unsupported stage (see
|
||||
/// <see cref="BuildOrderedMergeRuns"/>).</para>
|
||||
/// </summary>
|
||||
internal void SubmitOrderedStream(
|
||||
internal void PrepareOrderedStream(
|
||||
IGpuFrame frame,
|
||||
IGpuPassEncoder encoder,
|
||||
OrderedDrawStream stream,
|
||||
in Matrix4x4 viewProjection)
|
||||
in Matrix4x4 viewProjection,
|
||||
IReadOnlyList<int>? forcedBreaksAscending = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
ArgumentNullException.ThrowIfNull(encoder);
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
_orderedStream = stream;
|
||||
_orderedFrame = frame;
|
||||
_orderedSectionsBound = false;
|
||||
_orderedPreparedCount = 0;
|
||||
|
||||
// Fail loud before any GPU work: a PortalPunch command has no
|
||||
// submission path.
|
||||
_orderedRuns = BuildOrderedMergeRuns(stream, forcedBreaksAscending);
|
||||
|
||||
int count = stream.Count;
|
||||
if (count == 0)
|
||||
return;
|
||||
|
||||
// Fail loud before any GPU work: a PortalPunch command has no FW2
|
||||
// submission path.
|
||||
List<OrderedMergeRun> runs = BuildOrderedMergeRuns(stream);
|
||||
|
||||
GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer;
|
||||
if (global is null || !MeshSourceReady())
|
||||
return;
|
||||
|
||||
// Per-instance-first emission — the PrepareDeferredAlphaDraws shape,
|
||||
// into the SAME per-frame scratch arrays PrepareDeferredAlphaDraws/
|
||||
// SubmitRhi write, EXCEPT cull modes: this stage (FW3.2a) gives the
|
||||
// ordered path its own _orderedDrawCullModes scratch (see
|
||||
// DrawIndirectRangeRhi's doc comment) precisely so this loop and its
|
||||
// draws below can freely interleave with a mid-flight
|
||||
// RetailAlphaQueue scope without corrupting — or being corrupted by
|
||||
// — the alpha path's _drawCullModes.
|
||||
// Per-instance-first emission — see PrepareDeferredAlphaDraws, into
|
||||
// the SAME shared per-instance scratch arrays that method writes
|
||||
// (safe: this is a single-threaded render frame, and the writer here
|
||||
// runs to completion — including the section uploads below — before
|
||||
// any other per-instance producer touches the scratch again). Cull
|
||||
// modes are the one exception: this stage keeps its own
|
||||
// _orderedDrawCullModes scratch (see DrawIndirectRangeRhi's doc
|
||||
// comment) precisely so a walk-ordered draw can freely interleave
|
||||
// with a mid-flight RetailAlphaQueue scope without corrupting — or
|
||||
// being corrupted by — the alpha path's _drawCullModes.
|
||||
EnsureDeferredAlphaCapacity(count);
|
||||
EnsureOrderedCullModeCapacity(count);
|
||||
for (int i = 0; i < count; i++)
|
||||
|
|
@ -315,76 +410,159 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
_orderedDrawCullModes[i] = key.CullMode;
|
||||
}
|
||||
|
||||
// Write every section ONCE — the PrepareRhiAlphaSections shape, but
|
||||
// into locals rather than the persisted _alpha* fields (see the type
|
||||
// doc comment for why those must stay untouched here).
|
||||
RhiSection instances = WriteWorldTransformSection(
|
||||
// Write every section ONCE — the PrepareRhiAlphaSections shape, into
|
||||
// _ordered* rather than _alpha* (see this file's type doc comment).
|
||||
_orderedViewProjection = viewProjection;
|
||||
_orderedInstances = WriteWorldTransformSection(
|
||||
frame, _instanceData.AsSpan(0, count * 16), out uint transformBaseInstance);
|
||||
RhiSection batches = WriteRingSection<BatchData>(frame, _batchData.AsSpan(0, count));
|
||||
RhiSection clipSlots = WriteRingSection<uint>(frame, _clipSlotData.AsSpan(0, count));
|
||||
_orderedTransformBaseInstance = transformBaseInstance;
|
||||
_orderedBatches = WriteRingSection<BatchData>(frame, _batchData.AsSpan(0, count));
|
||||
_orderedClipSlots = WriteRingSection<uint>(frame, _clipSlotData.AsSpan(0, count));
|
||||
int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||
int uploadCount = lightCount > 0 ? lightCount : 1;
|
||||
RhiSection globalLights = WriteRingSection<float>(
|
||||
_orderedGlobalLights = WriteRingSection<float>(
|
||||
frame,
|
||||
_globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight));
|
||||
RhiSection lightSets = WriteRingSection<int>(
|
||||
_orderedLightSets = WriteRingSection<int>(
|
||||
frame, _lightSetData.AsSpan(0, count * LightManager.MaxLightsPerObject));
|
||||
RhiSection indoor = WriteRingSection<uint>(frame, _indoorData.AsSpan(0, count));
|
||||
RhiSection alpha = WriteRingSection<float>(frame, _alphaData.AsSpan(0, count));
|
||||
RhiSection selectionLighting = WriteRingSection<Vector2>(
|
||||
_orderedIndoor = WriteRingSection<uint>(frame, _indoorData.AsSpan(0, count));
|
||||
_orderedAlpha = WriteRingSection<float>(frame, _alphaData.AsSpan(0, count));
|
||||
_orderedSelectionLighting = WriteRingSection<Vector2>(
|
||||
frame, _selectionLightingData.AsSpan(0, count));
|
||||
RhiSection detailCategory = WriteRingSection<uint>(frame, _detailCategoryData.AsSpan(0, count));
|
||||
_orderedDetailCategory = WriteRingSection<uint>(frame, _detailCategoryData.AsSpan(0, count));
|
||||
GpuRingAllocation commandsAllocation = WriteIndirectCommands(
|
||||
frame, _indirectCommands.AsSpan(0, count), transformBaseInstance);
|
||||
IGpuBuffer commandBuffer = commandsAllocation.Buffer;
|
||||
uint commandBase = commandsAllocation.OffsetBytes;
|
||||
_orderedCommands = new RhiSection(
|
||||
commandsAllocation.Buffer,
|
||||
commandsAllocation.OffsetBytes,
|
||||
checked((uint)(count * DrawCommandStride)));
|
||||
|
||||
_orderedPreparedCount = count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws commands <c>[firstCommand, firstCommand + commandCount)</c> of
|
||||
/// the payload the most recent <see cref="PrepareOrderedStream"/> call
|
||||
/// uploaded. The FIRST call in a frame also binds the pipeline, push
|
||||
/// constants, and all nine per-instance sections — <see cref="_orderedSectionsBound"/>
|
||||
/// gates that so every later call in the same frame is just the merge-run
|
||||
/// walk-and-draw loop, never a rebind (the whole point of the FW3.4a
|
||||
/// split: what used to be ~40 full <c>SubmitOrderedStream</c> rebinds per
|
||||
/// frame at a town becomes one bind plus ~40 cheap
|
||||
/// <see cref="DrawIndirectRangeRhi"/> calls). Section binds survive
|
||||
/// pipeline switches (every mesh pipeline shares one layout — the same
|
||||
/// reasoning <c>SubmitRhi</c>/the old <c>SubmitOrderedStream</c> already
|
||||
/// relied on), so binding once per frame rather than once per pipeline
|
||||
/// switch is safe.
|
||||
///
|
||||
/// <para>Fail-loud range check mirrors <see cref="DrawPreparedAlphaBatchRhi"/>'s:
|
||||
/// a range outside <c>[0, _orderedPreparedCount]</c> throws
|
||||
/// <see cref="ArgumentOutOfRangeException"/> rather than silently
|
||||
/// clamping or drawing garbage — including when nothing was ever
|
||||
/// prepared this frame (a caller drawing without preparing is a real
|
||||
/// bug, not a valid empty draw). A zero-length range is a legal no-op
|
||||
/// (mirrors an empty walk segment).</para>
|
||||
///
|
||||
/// <para>Walks the runs <see cref="PrepareOrderedStream"/> built that
|
||||
/// intersect this range. By construction (the boundaries the caller fed
|
||||
/// <see cref="PrepareOrderedStream"/> as <c>forcedBreaksAscending</c>) a
|
||||
/// run never starts before the range and never ends after it — this is
|
||||
/// asserted, not assumed: a run that straddles the range edge throws
|
||||
/// rather than being silently sliced, per plan §FW3.4a.</para>
|
||||
/// </summary>
|
||||
internal void DrawOrderedRange(IGpuPassEncoder encoder, int firstCommand, int commandCount)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(encoder);
|
||||
if (firstCommand < 0
|
||||
|| commandCount < 0
|
||||
|| firstCommand > _orderedPreparedCount - commandCount)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(firstCommand),
|
||||
"The ordered draw range exceeds the payload the most recent "
|
||||
+ "PrepareOrderedStream call uploaded.");
|
||||
}
|
||||
if (commandCount == 0)
|
||||
return;
|
||||
if (_orderedCommands.Buffer is null || _orderedStream is null)
|
||||
return;
|
||||
|
||||
GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer;
|
||||
if (global is null)
|
||||
return;
|
||||
|
||||
MeshPipelineSet pipelines = PipelinesFor(encoder);
|
||||
|
||||
var pushConstants = new GpuPushConstants
|
||||
{
|
||||
ViewProjection = viewProjection,
|
||||
ViewProjection = _orderedViewProjection,
|
||||
DrawIdOffset = 0,
|
||||
LightingMode = 0,
|
||||
RenderPass = 0,
|
||||
LightDebug = RenderingDiagnostics.LightDebugMode,
|
||||
TextureIndexA = 0,
|
||||
TextureIndexB = transformBaseInstance,
|
||||
TextureIndexB = _orderedTransformBaseInstance,
|
||||
ParamA = 0f,
|
||||
ParamB = 0f,
|
||||
};
|
||||
|
||||
// Bind the opaque variant first so the storage/uniform binds below
|
||||
// land on a live program (SubmitRhi's own rationale) — every mesh
|
||||
// pipeline shares one layout, so these bindings survive the per-run
|
||||
// pipeline switches in the loop below.
|
||||
BindPipelineWithMesh(encoder, pipelines.Opaque, global);
|
||||
encoder.SetPushConstants(in pushConstants);
|
||||
BindSection(encoder, GpuBindingModel.StorageInstances, instances);
|
||||
BindSection(encoder, GpuBindingModel.StorageBatches, batches);
|
||||
BindSection(encoder, GpuBindingModel.StorageClipSlots, clipSlots);
|
||||
BindSection(encoder, GpuBindingModel.StorageGlobalLights, globalLights);
|
||||
BindSection(encoder, GpuBindingModel.StorageInstanceLightSets, lightSets);
|
||||
BindSection(encoder, GpuBindingModel.StorageInstanceIndoor, indoor);
|
||||
BindSection(encoder, GpuBindingModel.StorageInstanceAlpha, alpha);
|
||||
BindSection(encoder, GpuBindingModel.StorageInstanceSelectionLighting, selectionLighting);
|
||||
BindSection(encoder, GpuBindingModel.StorageInstanceDetailCategory, detailCategory);
|
||||
AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions(
|
||||
encoder, _scope!.Sections, frame);
|
||||
AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting(
|
||||
encoder, _scope!.Sections, frame);
|
||||
|
||||
// One in-order pass over the pre-built merge runs: bind the run's
|
||||
// pipeline, set RenderPass, draw. DrawIndirectRangeRhi still splits
|
||||
// internally on _orderedDrawCullModes (issue #52's absolute
|
||||
// DrawIdOffset per sub-call) — every run here already shares one
|
||||
// cull mode by construction, so that inner split is a no-op here,
|
||||
// never a second boundary this loop failed to expect.
|
||||
foreach (OrderedMergeRun run in runs)
|
||||
if (!_orderedSectionsBound)
|
||||
{
|
||||
ValidateMergeRun(stream, run);
|
||||
IGpuFrame frame = _orderedFrame
|
||||
?? throw new InvalidOperationException(
|
||||
"DrawOrderedRange's first call this frame has no frame to bind clip-region/"
|
||||
+ "scene-lighting sections against — PrepareOrderedStream must run first.");
|
||||
|
||||
PipelineBucket bucket = BucketFor(stream.Keys[run.FirstCommand].Translucency);
|
||||
// Bind the opaque variant first so the storage/uniform binds
|
||||
// below land on a live program (SubmitRhi's own rationale) —
|
||||
// every mesh pipeline shares one layout, so these bindings
|
||||
// survive every per-run pipeline switch below, across every
|
||||
// DrawOrderedRange call this frame.
|
||||
BindPipelineWithMesh(encoder, pipelines.Opaque, global);
|
||||
encoder.SetPushConstants(in pushConstants);
|
||||
BindSection(encoder, GpuBindingModel.StorageInstances, _orderedInstances);
|
||||
BindSection(encoder, GpuBindingModel.StorageBatches, _orderedBatches);
|
||||
BindSection(encoder, GpuBindingModel.StorageClipSlots, _orderedClipSlots);
|
||||
BindSection(encoder, GpuBindingModel.StorageGlobalLights, _orderedGlobalLights);
|
||||
BindSection(encoder, GpuBindingModel.StorageInstanceLightSets, _orderedLightSets);
|
||||
BindSection(encoder, GpuBindingModel.StorageInstanceIndoor, _orderedIndoor);
|
||||
BindSection(encoder, GpuBindingModel.StorageInstanceAlpha, _orderedAlpha);
|
||||
BindSection(
|
||||
encoder, GpuBindingModel.StorageInstanceSelectionLighting, _orderedSelectionLighting);
|
||||
BindSection(
|
||||
encoder, GpuBindingModel.StorageInstanceDetailCategory, _orderedDetailCategory);
|
||||
AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions(
|
||||
encoder, _scope!.Sections, frame);
|
||||
AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting(
|
||||
encoder, _scope!.Sections, frame);
|
||||
|
||||
_orderedSectionsBound = true;
|
||||
}
|
||||
|
||||
IGpuBuffer commandBuffer = _orderedCommands.Buffer!;
|
||||
uint commandBase = _orderedCommands.OffsetBytes;
|
||||
int rangeEnd = firstCommand + commandCount;
|
||||
|
||||
foreach (OrderedMergeRun run in _orderedRuns)
|
||||
{
|
||||
int runEnd = run.FirstCommand + run.CommandCount;
|
||||
if (runEnd <= firstCommand)
|
||||
continue;
|
||||
if (run.FirstCommand >= rangeEnd)
|
||||
break;
|
||||
|
||||
if (run.FirstCommand < firstCommand || runEnd > rangeEnd)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"DrawOrderedRange [{firstCommand}, {rangeEnd}) straddles merge run "
|
||||
+ $"[{run.FirstCommand}, {runEnd}) — a range boundary must coincide with "
|
||||
+ "a run boundary by construction (PrepareOrderedStream's "
|
||||
+ "forcedBreaksAscending should have forced a break here; Campaign FW "
|
||||
+ "§FW3.4a).");
|
||||
}
|
||||
|
||||
ValidateMergeRun(_orderedStream, run);
|
||||
|
||||
PipelineBucket bucket = BucketFor(_orderedStream.Keys[run.FirstCommand].Translucency);
|
||||
IGpuPipeline pipeline = PipelineForBucket(pipelines, bucket);
|
||||
pushConstants.RenderPass = bucket == PipelineBucket.Opaque ? 0 : 1;
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ namespace AcDream.App.Tests.Rendering.Walk;
|
|||
|
||||
/// <summary>
|
||||
/// Campaign FW stage FW2: <see cref="WbDrawDispatcher.BuildOrderedMergeRuns"/>
|
||||
/// (pure CPU merge-run legality) and <see cref="WbDrawDispatcher.SubmitOrderedStream"/>
|
||||
/// (the same legality proven through actual recorded RHI calls against
|
||||
/// <see cref="RecordingGpuDevice"/>).
|
||||
/// (pure CPU merge-run legality) and, from Campaign FW stage FW3.4a,
|
||||
/// <see cref="WbDrawDispatcher.PrepareOrderedStream"/> +
|
||||
/// <see cref="WbDrawDispatcher.DrawOrderedRange"/> (the same legality proven
|
||||
/// through actual recorded RHI calls against <see cref="RecordingGpuDevice"/>,
|
||||
/// replacing the single <c>SubmitOrderedStream</c> call those two now split).
|
||||
/// </summary>
|
||||
public sealed class OrderPreservingSubmitterTests
|
||||
{
|
||||
|
|
@ -197,10 +199,24 @@ public sealed class OrderPreservingSubmitterTests
|
|||
Assert.Equal(stream.Count, totalCommands);
|
||||
}
|
||||
|
||||
// ── SubmitOrderedStream — recorded RHI calls against RecordingGpuDevice ─
|
||||
// ── PrepareOrderedStream + DrawOrderedRange — recorded RHI calls against
|
||||
// RecordingGpuDevice. Campaign FW3.4a replaced the single
|
||||
// SubmitOrderedStream call with this pair (prepare the whole stream once,
|
||||
// draw it via one or more ranges) — every test below that used to call
|
||||
// SubmitOrderedStream now calls Prepare once and Draw the WHOLE stream as
|
||||
// ONE range, which is exactly SubmitOrderedStream's old behavior; the
|
||||
// "several ranges" and "bind once" shapes get their own tests further
|
||||
// down since they have no FW2 analogue. ────────────────────────────────
|
||||
|
||||
private static void PrepareAndDrawWhole(WbDrawDispatcher dispatcher, DrawScope draw, OrderedDrawStream stream)
|
||||
{
|
||||
dispatcher.PrepareOrderedStream(draw.Frame, stream, Matrix4x4.Identity);
|
||||
if (stream.Count > 0)
|
||||
dispatcher.DrawOrderedRange(draw.Pass, 0, stream.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitOrderedStream_AlternatingStateCommandsRecordOneDrawEachInOrder()
|
||||
public void PrepareThenDraw_AlternatingStateCommandsRecordOneDrawEachInOrder()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
|
@ -211,14 +227,14 @@ public sealed class OrderPreservingSubmitterTests
|
|||
MakeCommand(2, translucency: TranslucencyKind.Opaque),
|
||||
MakeCommand(3, translucency: TranslucencyKind.AlphaBlend));
|
||||
|
||||
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||
PrepareAndDrawWhole(fx.Dispatcher, draw, stream);
|
||||
|
||||
List<(int Start, int Count)> ranges = DecodeDrawRanges(fx.Device);
|
||||
Assert.Equal([(0, 1), (1, 1), (2, 1), (3, 1)], ranges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitOrderedStream_MergesAdjacentSameStateCommandsIntoOneMultiDrawIndirect()
|
||||
public void PrepareThenDraw_MergesAdjacentSameStateCommandsIntoOneMultiDrawIndirect()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
|
@ -226,14 +242,14 @@ public sealed class OrderPreservingSubmitterTests
|
|||
OrderedDrawStream stream = StreamOf(
|
||||
MakeCommand(0), MakeCommand(1), MakeCommand(2));
|
||||
|
||||
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||
PrepareAndDrawWhole(fx.Dispatcher, draw, stream);
|
||||
|
||||
List<(int Start, int Count)> ranges = DecodeDrawRanges(fx.Device);
|
||||
Assert.Equal([(0, 3)], ranges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitOrderedStream_CullModeChangeRecordsSeparateCullCallsAndSplitsTheDraw()
|
||||
public void PrepareThenDraw_CullModeChangeRecordsSeparateCullCallsAndSplitsTheDraw()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
|
@ -243,7 +259,7 @@ public sealed class OrderPreservingSubmitterTests
|
|||
MakeCommand(1, cullMode: CullMode.None),
|
||||
MakeCommand(2, cullMode: CullMode.Clockwise));
|
||||
|
||||
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||
PrepareAndDrawWhole(fx.Dispatcher, draw, stream);
|
||||
|
||||
Assert.Equal([(0, 2), (2, 1)], DecodeDrawRanges(fx.Device));
|
||||
|
||||
|
|
@ -254,7 +270,7 @@ public sealed class OrderPreservingSubmitterTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitOrderedStream_StageChangeSplitsTheDrawEvenWithIdenticalMaterialState()
|
||||
public void PrepareThenDraw_StageChangeSplitsTheDrawEvenWithIdenticalMaterialState()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
|
@ -263,13 +279,13 @@ public sealed class OrderPreservingSubmitterTests
|
|||
MakeCommand(0, stage: WalkDrawStage.Terrain),
|
||||
MakeCommand(1, stage: WalkDrawStage.CellStatic));
|
||||
|
||||
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||
PrepareAndDrawWhole(fx.Dispatcher, draw, stream);
|
||||
|
||||
Assert.Equal([(0, 1), (1, 1)], DecodeDrawRanges(fx.Device));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitOrderedStream_ADetailCategoryCommandRecordsItsOwnSoloDraw()
|
||||
public void PrepareThenDraw_ADetailCategoryCommandRecordsItsOwnSoloDraw()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
|
@ -279,13 +295,13 @@ public sealed class OrderPreservingSubmitterTests
|
|||
MakeCommand(1, detailCategory: 1),
|
||||
MakeCommand(2));
|
||||
|
||||
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||
PrepareAndDrawWhole(fx.Dispatcher, draw, stream);
|
||||
|
||||
Assert.Equal([(0, 1), (1, 1), (2, 1)], DecodeDrawRanges(fx.Device));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitOrderedStream_OpaqueRunUsesRenderPassZeroAndAlphaBlendRunUsesRenderPassOne()
|
||||
public void PrepareThenDraw_OpaqueRunUsesRenderPassZeroAndAlphaBlendRunUsesRenderPassOne()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
|
@ -294,7 +310,7 @@ public sealed class OrderPreservingSubmitterTests
|
|||
MakeCommand(0, translucency: TranslucencyKind.Opaque),
|
||||
MakeCommand(1, translucency: TranslucencyKind.AlphaBlend));
|
||||
|
||||
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||
PrepareAndDrawWhole(fx.Dispatcher, draw, stream);
|
||||
|
||||
List<(GpuPushConstants Constants, int Start, int Count)> runs = DecodeRuns(fx.Device);
|
||||
Assert.Equal(2, runs.Count);
|
||||
|
|
@ -303,7 +319,7 @@ public sealed class OrderPreservingSubmitterTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitOrderedStream_ThrowsNotSupportedForAPortalPunchCommandBeforeAnyDraw()
|
||||
public void PrepareOrderedStream_ThrowsNotSupportedForAPortalPunchCommandBeforeAnyDraw()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
|
@ -312,32 +328,165 @@ public sealed class OrderPreservingSubmitterTests
|
|||
MakeCommand(0, stage: WalkDrawStage.PortalPunch));
|
||||
|
||||
Assert.Throws<NotSupportedException>(
|
||||
() => fx.Dispatcher.SubmitOrderedStream(
|
||||
draw.Frame, draw.Pass, stream, Matrix4x4.Identity));
|
||||
() => fx.Dispatcher.PrepareOrderedStream(draw.Frame, stream, Matrix4x4.Identity));
|
||||
|
||||
Assert.Empty(fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
|
||||
Assert.Empty(fx.Device.Calls.OfType<GpuRecordedStorageBind>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitOrderedStream_EmptyStreamRecordsNoDraws()
|
||||
public void PrepareOrderedStream_EmptyStreamRecordsNoDraws()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
||||
fx.Dispatcher.SubmitOrderedStream(
|
||||
draw.Frame, draw.Pass, new OrderedDrawStream(), Matrix4x4.Identity);
|
||||
fx.Dispatcher.PrepareOrderedStream(draw.Frame, new OrderedDrawStream(), Matrix4x4.Identity);
|
||||
|
||||
Assert.Empty(fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
|
||||
}
|
||||
|
||||
// ── Campaign FW3.4a — the shapes with no FW2 analogue: drawing the SAME
|
||||
// prepared stream as several ranges, the bind-once optimization, the
|
||||
// fail-loud range check, and the assert-don't-slice straddle guard. ───
|
||||
|
||||
/// <summary>
|
||||
/// The whole point of the split: drawing the SAME stream as TWO ranges
|
||||
/// (with the boundary between them supplied to Prepare, exactly as
|
||||
/// WalkFrameDriver.Replay supplies its recorded mark positions) produces
|
||||
/// the identical total recorded draw/cull/push-constant calls as drawing
|
||||
/// it as one range — the range split changes nothing about what reaches
|
||||
/// the GPU, only how many DrawOrderedRange calls got there.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DrawOrderedRange_AsTwoRangesAtASegmentBoundary_MatchesOneRangeOverTheWholeStream()
|
||||
{
|
||||
OrderedDrawStream stream = StreamOf(
|
||||
MakeCommand(0, translucency: TranslucencyKind.Opaque),
|
||||
MakeCommand(1, translucency: TranslucencyKind.Opaque),
|
||||
MakeCommand(2, translucency: TranslucencyKind.Opaque),
|
||||
MakeCommand(3, translucency: TranslucencyKind.Opaque));
|
||||
|
||||
using var wholeFx = new DispatcherFixture();
|
||||
using (DrawScope draw = wholeFx.BeginDraw())
|
||||
{
|
||||
wholeFx.Dispatcher.PrepareOrderedStream(draw.Frame, stream, Matrix4x4.Identity);
|
||||
wholeFx.Dispatcher.DrawOrderedRange(draw.Pass, 0, stream.Count);
|
||||
}
|
||||
List<(int Start, int Count)> wholeRanges = DecodeDrawRanges(wholeFx.Device);
|
||||
|
||||
using var splitFx = new DispatcherFixture();
|
||||
using (DrawScope draw = splitFx.BeginDraw())
|
||||
{
|
||||
// Command 2 is a segment boundary (mirrors a mark WalkFrameDriver
|
||||
// would record there, e.g. a cell shell between two same-state
|
||||
// segments) — without it, all four commands would merge into ONE
|
||||
// run; the boundary forces two.
|
||||
splitFx.Dispatcher.PrepareOrderedStream(
|
||||
draw.Frame, stream, Matrix4x4.Identity, forcedBreaksAscending: [2]);
|
||||
splitFx.Dispatcher.DrawOrderedRange(draw.Pass, 0, 2);
|
||||
splitFx.Dispatcher.DrawOrderedRange(draw.Pass, 2, 2);
|
||||
}
|
||||
List<(int Start, int Count)> splitRanges = DecodeDrawRanges(splitFx.Device);
|
||||
|
||||
// The split path draws two runs where the whole-range path drew one
|
||||
// (the forced boundary is the only difference) — but every command
|
||||
// reaches the GPU exactly once, in order, with identical coverage.
|
||||
Assert.Equal([(0, 4)], wholeRanges);
|
||||
Assert.Equal([(0, 2), (2, 2)], splitRanges);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The FW3.4a perf shape itself: the nine per-instance storage binds plus
|
||||
/// the warm-up pipeline bind happen on the FIRST DrawOrderedRange call in
|
||||
/// a frame only — a second call over the same prepared payload issues no
|
||||
/// further StorageBind calls, which is the whole reason this stage exists
|
||||
/// (the old SubmitOrderedStream rebound everything on every call).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DrawOrderedRange_SecondCallInTheSameFrame_BindsNoFurtherStorageSections()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
||||
OrderedDrawStream stream = StreamOf(
|
||||
MakeCommand(0, stage: WalkDrawStage.Terrain),
|
||||
MakeCommand(1, stage: WalkDrawStage.CellStatic));
|
||||
|
||||
fx.Dispatcher.PrepareOrderedStream(draw.Frame, stream, Matrix4x4.Identity);
|
||||
fx.Dispatcher.DrawOrderedRange(draw.Pass, 0, 1);
|
||||
int boundAfterFirst = fx.Device.Calls.OfType<GpuRecordedStorageBind>().Count();
|
||||
Assert.True(boundAfterFirst > 0);
|
||||
|
||||
fx.Dispatcher.DrawOrderedRange(draw.Pass, 1, 1);
|
||||
int boundAfterSecond = fx.Device.Calls.OfType<GpuRecordedStorageBind>().Count();
|
||||
|
||||
Assert.Equal(boundAfterFirst, boundAfterSecond);
|
||||
// Both commands still drew — the bind-once optimization changed
|
||||
// nothing about draw coverage.
|
||||
Assert.Equal([(0, 1), (1, 1)], DecodeDrawRanges(fx.Device));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-loud range check (mirrors DrawPreparedAlphaBatchRhi's): a range
|
||||
/// outside what PrepareOrderedStream uploaded throws rather than drawing
|
||||
/// garbage or silently clamping — including a draw attempted before ANY
|
||||
/// Prepare call this frame.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DrawOrderedRange_RangeExceedingThePreparedPayload_Throws()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
||||
OrderedDrawStream stream = StreamOf(MakeCommand(0));
|
||||
fx.Dispatcher.PrepareOrderedStream(draw.Frame, stream, Matrix4x4.Identity);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => fx.Dispatcher.DrawOrderedRange(draw.Pass, 0, 2));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => fx.Dispatcher.DrawOrderedRange(draw.Pass, 1, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawOrderedRange_BeforeAnyPrepareCallThisFrame_Throws()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => fx.Dispatcher.DrawOrderedRange(draw.Pass, 0, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The plan's "assert it" rule: a range that does not align with a merge
|
||||
/// run boundary throws rather than silently slicing the run — proven
|
||||
/// directly here (skipping the boundary a real WalkFrameDriver mark would
|
||||
/// supply) since production code always supplies the boundary and would
|
||||
/// never exercise this path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DrawOrderedRange_RangeStraddlingAMergeRun_Throws()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
||||
// All four commands share stage/bucket/cull — ONE merge run [0, 4) —
|
||||
// and no forced break is supplied, so a [0, 2) range straddles it.
|
||||
OrderedDrawStream stream = StreamOf(
|
||||
MakeCommand(0), MakeCommand(1), MakeCommand(2), MakeCommand(3));
|
||||
fx.Dispatcher.PrepareOrderedStream(draw.Frame, stream, Matrix4x4.Identity);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => fx.Dispatcher.DrawOrderedRange(draw.Pass, 0, 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-loud invariant: whatever the state pattern, the recorded
|
||||
/// MultiDrawIndirect calls' DrawCounts always sum to the stream's Count —
|
||||
/// no command is ever silently skipped, and none is drawn twice.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SubmitOrderedStream_TotalRecordedDrawCountAlwaysEqualsTheStreamCount()
|
||||
public void PrepareThenDraw_TotalRecordedDrawCountAlwaysEqualsTheStreamCount()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
|
@ -361,7 +510,7 @@ public sealed class OrderPreservingSubmitterTests
|
|||
detailCategory: i == 5 ? 1u : 0u));
|
||||
}
|
||||
|
||||
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||
PrepareAndDrawWhole(fx.Dispatcher, draw, stream);
|
||||
|
||||
List<(int Start, int Count)> ranges = DecodeDrawRanges(fx.Device);
|
||||
int sum = ranges.Sum(r => r.Count);
|
||||
|
|
|
|||
|
|
@ -216,9 +216,9 @@ public sealed class WalkFrameDriverTests
|
|||
|
||||
var worldData = new FakeWorldData();
|
||||
worldData.CellStaticsByCell[0x100] = new WalkFrameStaticRecords(
|
||||
[MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)])], 0x8C04u);
|
||||
new[] { MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)]) }, 0x8C04u);
|
||||
worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords(
|
||||
[MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)])], 0x8C04u);
|
||||
new[] { MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)]) }, 0x8C04u);
|
||||
|
||||
var leaf = new RecordingLeafRenderer(log);
|
||||
var trace = new RecordingTrace(log);
|
||||
|
|
@ -294,9 +294,9 @@ public sealed class WalkFrameDriverTests
|
|||
|
||||
var worldData = new FakeWorldData();
|
||||
worldData.CellStaticsByCell[0x100] = new WalkFrameStaticRecords(
|
||||
[MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)])], 0x8C04u);
|
||||
new[] { MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)]) }, 0x8C04u);
|
||||
worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords(
|
||||
[MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)])], 0x8C04u);
|
||||
new[] { MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)]) }, 0x8C04u);
|
||||
|
||||
var leaf = new RecordingLeafRenderer(log);
|
||||
var trace = new RecordingTrace(log);
|
||||
|
|
@ -383,9 +383,9 @@ public sealed class WalkFrameDriverTests
|
|||
|
||||
var worldData = new FakeWorldData();
|
||||
worldData.ShellByBuilding[building] = new WalkFrameStaticRecords(
|
||||
[MakeRecord(201, 0, Vector3.Zero, [new MeshRef((uint)shellGfxObj, Matrix4x4.Identity)])], 0x8C04u);
|
||||
new[] { MakeRecord(201, 0, Vector3.Zero, [new MeshRef((uint)shellGfxObj, Matrix4x4.Identity)]) }, 0x8C04u);
|
||||
worldData.CellStaticsByCell[0x104] = new WalkFrameStaticRecords(
|
||||
[MakeRecord(202, 0, Vector3.Zero, [new MeshRef((uint)interiorGfxObj, Matrix4x4.Identity)])], 0x8C04u);
|
||||
new[] { MakeRecord(202, 0, Vector3.Zero, [new MeshRef((uint)interiorGfxObj, Matrix4x4.Identity)]) }, 0x8C04u);
|
||||
Matrix4x4 buildingWorld = Matrix4x4.CreateTranslation(10f, 0f, 0f);
|
||||
worldData.WorldTransformByBuilding[building] = buildingWorld;
|
||||
|
||||
|
|
@ -401,9 +401,10 @@ public sealed class WalkFrameDriverTests
|
|||
Assert.Equal(1, activeView.ViewCount);
|
||||
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
walk.DrawBuilding(building, activeView, ctx, driver);
|
||||
driver.EndFrame();
|
||||
driver.Replay(draw.Frame, draw.Pass);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "ALPHA:12.50", "PUNCH:4@v0", "SHELL:00000104", "FLUSH:1:LookInStatic", "FLUSH:1:BuildingShell" },
|
||||
|
|
@ -433,7 +434,7 @@ public sealed class WalkFrameDriverTests
|
|||
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
|
||||
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => ((IWalkEventSink)driver).Emit(WalkEvent.DrawCells(0, [0x100u])));
|
||||
|
|
@ -450,18 +451,82 @@ public sealed class WalkFrameDriverTests
|
|||
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
|
||||
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => driver.BeginFrame(
|
||||
ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0));
|
||||
ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0));
|
||||
|
||||
driver.EndFrame();
|
||||
// EndFrame cleared the open-frame guard: BeginFrame is usable again.
|
||||
driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
driver.EndFrame();
|
||||
}
|
||||
|
||||
// ── Campaign FW3.4a fail-loud: Replay without a completed Collect (no
|
||||
// BeginFrame/EndFrame at all, or BeginFrame with no matching EndFrame)
|
||||
// has nothing recorded to draw — throw rather than silently drawing
|
||||
// nothing, which would look like an empty frame instead of a misuse. ──
|
||||
|
||||
[Fact]
|
||||
public void Replay_WithNoPrecedingCollect_Throws()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
var log = new List<string>();
|
||||
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
|
||||
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
Assert.Throws<InvalidOperationException>(() => driver.Replay(draw.Frame, draw.Pass));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_WhileCollectIsStillOpen_Throws()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
var log = new List<string>();
|
||||
var ctx = new TestContext();
|
||||
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
|
||||
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => driver.Replay(draw.Frame, draw.Pass));
|
||||
}
|
||||
|
||||
// ── Deliverable: Collect + Replay called as the SPLIT PAIR (never
|
||||
// RunFrame) — the shape RetailPViewRenderer uses, since it must run other
|
||||
// frame work (PrepareCellBatches/BuildAndBorrow) between the two. Proves
|
||||
// the pair alone — with no GPU work happening until Replay — reproduces
|
||||
// the same turn order RunFrame's combined call would. ──────────────────
|
||||
|
||||
[Fact]
|
||||
public void CollectThenReplay_AsSeparateCalls_PerformsNoGpuWorkUntilReplay()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
var log = new List<string>();
|
||||
var ctx = new TestContext();
|
||||
var driver = new WalkFrameDriver(
|
||||
fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData(), new RecordingTrace(log));
|
||||
var walk = new RetailFrameWalk();
|
||||
// Outdoor root (camera cell low word < 0x100): a minimal, no-op
|
||||
// landscape — LScape::draw still runs its sky/terrain turn against
|
||||
// it even though nothing is published to walk cells/buildings for.
|
||||
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
||||
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
driver.Collect(
|
||||
walk, cameraCellId: 0u, cameraCell: null, landscape, ctx,
|
||||
Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero, activeTerrainSliceCount: 1);
|
||||
|
||||
// No GPU calls at all yet — Collect is CPU-only.
|
||||
Assert.Empty(log);
|
||||
Assert.Empty(fx.Device.Calls);
|
||||
|
||||
driver.Replay(draw.Frame, draw.Pass);
|
||||
|
||||
Assert.Equal(new[] { "SKY", "TERRAIN:0" }, log);
|
||||
}
|
||||
|
||||
// ── Deliverable: an outdoor landscape-cell turn with no building appends
|
||||
// straight to the stream (no shell call — outdoor cells have no EnvCell
|
||||
// shell), and the accumulated content flushes at frame end. ───────────
|
||||
|
|
@ -478,16 +543,18 @@ public sealed class WalkFrameDriverTests
|
|||
var ctx = new TestContext();
|
||||
var worldData = new FakeWorldData();
|
||||
worldData.OutdoorStaticsByCell[0x8C040005u] = new WalkFrameStaticRecords(
|
||||
[MakeRecord(301, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)])], 0x8C04u);
|
||||
new[] { MakeRecord(301, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) }, 0x8C04u);
|
||||
|
||||
var driver = new WalkFrameDriver(
|
||||
fx.Dispatcher, new RecordingLeafRenderer(log), worldData, new RecordingTrace(log));
|
||||
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
((IWalkEventSink)driver).OnLandscapeCellTurn(0x8C040005u);
|
||||
Assert.Empty(log); // accumulates in the stream; nothing flushed yet
|
||||
Assert.Empty(log); // no GPU work at Collect time; nothing recorded to the log yet
|
||||
driver.EndFrame();
|
||||
Assert.Empty(log); // still nothing — EndFrame closes Collect, it does not Replay
|
||||
driver.Replay(draw.Frame, draw.Pass);
|
||||
|
||||
Assert.Equal(new[] { "FLUSH:1:OutdoorStatic" }, log);
|
||||
GpuRecordedMultiDrawIndirect mdi = Assert.Single(fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ namespace AcDream.App.Tests.Rendering.Walk;
|
|||
/// equivalence referee. Covers <see cref="WbDrawDispatcher.ClassifyEntityForWalk"/>
|
||||
/// (the shared per-entity classify seam), <see cref="WalkStaticStreamPopulator"/>
|
||||
/// (opaque → <see cref="OrderedDrawStream"/>, translucent → the alpha queue,
|
||||
/// selection publish), and the FW3.2a own-cull-scratch fix to
|
||||
/// <c>SubmitOrderedStream</c>.
|
||||
/// selection publish), and the FW3.2a own-cull-scratch fix to the ordered
|
||||
/// submitter (<c>PrepareOrderedStream</c>/<c>DrawOrderedRange</c> as of
|
||||
/// Campaign FW stage FW3.4a).
|
||||
/// </summary>
|
||||
public sealed class WalkStaticStreamPopulatorTests
|
||||
{
|
||||
|
|
@ -364,18 +365,18 @@ public sealed class WalkStaticStreamPopulatorTests
|
|||
fx.AlphaQueue.AbortFrame();
|
||||
}
|
||||
|
||||
// ── Deliverable 3: SubmitOrderedStream's own cull scratch ──────────────
|
||||
// ── Deliverable 3: the ordered submitter's own cull scratch ────────────
|
||||
|
||||
[Fact]
|
||||
public void SubmitOrderedStream_DoesNotReadOrCorruptTheSharedAlphaCullScratch()
|
||||
public void PrepareThenDrawOrderedStream_DoesNotReadOrCorruptTheSharedAlphaCullScratch()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
|
||||
// Poison the SHARED _drawCullModes scratch the alpha path owns — the
|
||||
// exact array SubmitOrderedStream used to write into before FW3.2a.
|
||||
// exact array the ordered submitter used to write into before FW3.2a.
|
||||
// Under the OLD shared-scratch behavior this test's second assertion
|
||||
// fails: SubmitOrderedStream's own command overwrites index 0 with
|
||||
// fails: the ordered path's own command overwrites index 0 with
|
||||
// its own cull mode (Clockwise), destroying the alpha path's poison.
|
||||
FieldInfo field = typeof(WbDrawDispatcher).GetField(
|
||||
"_drawCullModes", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
|
|
@ -388,7 +389,8 @@ public sealed class WalkStaticStreamPopulatorTests
|
|||
Matrix4x4.Identity, WalkDrawStage.Terrain, 0, 0,
|
||||
WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f, Vector2.Zero, 0));
|
||||
|
||||
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
||||
fx.Dispatcher.PrepareOrderedStream(draw.Frame, stream, Matrix4x4.Identity);
|
||||
fx.Dispatcher.DrawOrderedRange(draw.Pass, 0, stream.Count);
|
||||
|
||||
// (1) The ordered submission's OWN recorded cull call reflects the
|
||||
// STREAM's cull mode (Clockwise -> GpuCullMode.Front), not the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue