diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
index 0fcec938..26fce2f4 100644
--- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs
+++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
@@ -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();
-
- /// Campaign FW3.2b-2 (the I5 dual-shadow pattern, extended for
- /// production use): an events-only 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 prepareCells 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).
+ /// Campaign FW3.2b-2 (the I5 dual-shadow pattern): an
+ /// events-only that collects the SETS a
+ /// driven run would touch, without doing any leaf drawing itself. Used
+ /// ONLY by now — Campaign FW3.4a moved
+ /// the production role (the walk's flood cell set for the
+ /// prepareCells union, the visited building list and
+ /// landscape-cell turn ids for particle re-sourcing) onto
+ /// itself, which gathers the same sets
+ /// as a side effect of the one walk
+ /// already runs, instead of a second dedicated pass.
private sealed class WalkVisitedSetCollector : Walk.IWalkEventSink
{
public readonly HashSet Cells = new();
@@ -1142,78 +1177,27 @@ public sealed class RetailPViewRenderer
public void OnBuildingTurn(Walk.WalkBuilding building) => Buildings.Add(building);
}
- /// Campaign FW3.2b-2 — THE PRODUCTION ROOTING. Runs the real
- /// through
- /// over
- /// : 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 bound as the driver's own
- /// seal action) all draw here, in walk order — replacing
+ /// Campaign FW3.2b-2 — THE PRODUCTION ROOTING; Campaign FW3.4a —
+ /// REPLAY ONLY. already ran its Collect pass
+ /// earlier in (before PrepareCellBatches);
+ /// this method's job is now just
+ /// (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
/// 's static half,
/// 's old top-level call,
/// , and '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).
+ /// 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).
private void DrawWalkDrivenStatics(
RetailPViewFrameInput ctx,
RetailPViewPassExecutor passes,
- ClipFrameAssembly clipAssembly,
- PortalVisibilityFrame pvFrame,
- HashSet 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)
{
diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
index d7c4d28f..66dc548e 100644
--- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
@@ -15,16 +15,19 @@ namespace AcDream.App.Rendering.Walk;
/// Already-classified s
/// for this turn's cell/building, in the SAME order they must enter the walk
/// stream (never re-sorted downstream — 's
-/// own contract).
+/// own contract). Campaign FW3.4a: a segment INTO 's
+/// per-frame arena, not a freshly allocated array — see that type's own doc
+/// comment.
/// The clip-slot-resolving landblock id
/// WbDrawDispatcher.ClassifyEntityForWalk needs per record (FW3.2a's
/// tupleLandblockId parameter) — carried per-turn rather than once per
/// frame because a single frame's cells/buildings can span more than one
/// committed landblock.
internal readonly record struct WalkFrameStaticRecords(
- RenderProjectionRecord[] Records, uint TupleLandblockId)
+ ArraySegment Records, uint TupleLandblockId)
{
- public static readonly WalkFrameStaticRecords Empty = new(Array.Empty(), 0);
+ public static readonly WalkFrameStaticRecords Empty =
+ new(ArraySegment.Empty, 0);
}
///
@@ -72,12 +75,11 @@ internal interface IWalkFrameWorldData
/// TerrainModernRenderer, GameSky, and
/// PortalDepthMaskRenderer.DrawDepthFan — FW3.2b-2's job.
///
-/// Stream submission itself (WbDrawDispatcher.SubmitOrderedStream)
-/// is deliberately NOT part of this interface: it is already a real,
-/// FW2/FW3.2a-tested production method, so
-/// 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.
+/// Stream submission itself (WbDrawDispatcher.PrepareOrderedStream/
+/// DrawOrderedRange) is deliberately NOT part of this interface: it is
+/// already real, production-tested machinery, so
+/// calls it directly rather than abstracting a method that would just
+/// forward to it one layer deeper.
///
internal interface IWalkFrameLeafRenderer
{
@@ -90,7 +92,7 @@ internal interface IWalkFrameLeafRenderer
/// LScape::grab_visible_cells's terrain mesh, once per
/// ACTIVE clip slice — is caller-supplied
- /// ('s activeTerrainSliceCount)
+ /// ('s activeTerrainSliceCount)
/// since FW3.2b-1 does not wire ClipFrameAssembler/
/// ViewconeCuller (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.
/// is already transformed building-local
/// → world ( does the transform via
- /// before
- /// calling this). The real implementation is
+ /// at Collect
+ /// time — see that type's own doc comment). The real implementation is
/// PortalDepthMaskRenderer.DrawDepthFan with forceFarZ
- /// (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 's type doc comment)
- /// reaches the GPU first. is the view
- /// the emitting two-pass walk was pinned to (retail
- /// building_view = Render::portal_view_num @0x0059f3bf) —
- /// production clips the fan by that view's slice planes.
+ /// (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
+ /// 's type doc comment) reaches the GPU
+ /// first. is the view the emitting
+ /// two-pass walk was pinned to (retail building_view =
+ /// Render::portal_view_num @0x0059f3bf) — production clips the fan by
+ /// that view's slice planes.
void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex);
/// RetailAlphaQueue.FlushFartherThan's DrawBuilding
@@ -173,60 +174,187 @@ internal interface IWalkFrameLeafRenderer
///
/// Campaign FW stage FW3.2b-1 test seam: an optional, diagnostic-only
-/// observer of every stream FLUSH performs.
-/// Production callers pass (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
+/// draws. Production callers pass (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 RecordingGpuDevice.Calls' lower-level RHI call log.
///
internal interface IWalkFrameDriverTrace
{
- /// is a snapshot (never the live,
- /// about-to-be-Reset list) of every command's
- /// 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.
+ /// is a snapshot (never a live,
+ /// about-to-mutate list) of every command's 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.
void OnFlush(int commandCount, IReadOnlyList stages);
}
+///
+/// Campaign FW3.4a: one turn Collect recorded, replayed by
+/// in the exact order Collect saw it.
+/// is the collect-time analogue of
+/// the old immediate driver's flush point — see 's
+/// own doc comment for the full list and what each carries.
+///
+internal enum WalkFrameEventKind : byte
+{
+ /// The accumulated grew since the
+ /// last mark and must be drawn, via WbDrawDispatcher.DrawOrderedRange,
+ /// before whatever leaf event follows.
+ /// is the stream's exclusive-end command index at the moment this event
+ /// was recorded.
+ StreamMark,
+
+ /// .
+ Sky,
+
+ /// —
+ /// is the slice index.
+ TerrainSlice,
+
+ /// —
+ /// is the cell.
+ CellShell,
+
+ /// —
+ /// is the already-world-transformed
+ /// polygon (transformed at Collect time, exactly as the pre-FW3.4a driver
+ /// transformed it before its own immediate call),
+ /// is the active view index.
+ PunchFan,
+
+ /// —
+ /// is the viewer distance, computed
+ /// at Collect time (the context that supplies it does not outlive Collect).
+ AlphaBarrier,
+
+ /// .
+ ClearInteriorDepth,
+
+ /// .
+ ExitSeals,
+}
+
+/// See 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 List<WalkFrameEvent> —
+/// only (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).
+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);
+}
+
///
/// Campaign FW stage FW3.2b-1 — THE WALK FRAME DRIVER. Executes one full
/// static-content frame by driving with itself
-/// as the , turning each walk turn into either a
-/// stream append (, FW3.2a) or a leaf
-/// call (), 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 — WorldSceneRenderer
-/// 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
-/// RecordingGpuDevice proving the interleaving.
+/// as the , so that GPU command-buffer order
+/// equals retail's walk order (plan §FW3.2b-1's "INTERLEAVING RULE").
///
-/// The one flush rule that reproduces the whole frame script:
-/// before EVERY leaf-renderer call (,
-/// DrawTerrainSlice, DrawCellShell, ClearInteriorDepth,
-/// DrawExitSeals, DrawPunchFan) and before every
-/// 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
-/// 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
+/// Campaign FW3.4a — the ONE-walk split. Before this stage, a
+/// single frame ran TWICE — once with a
+/// set-collecting sink to learn the flood/visited-cell set before
+/// PrepareCellBatches, once more through this driver to actually
+/// submit — and each walk turn's stream content flushed IMMEDIATELY through
+/// its own full WbDrawDispatcher.SubmitOrderedStream 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
+/// 's own doc comment) and tripped the
+/// campaign's ±20% stop rule. This stage collapses both:
+/// runs 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
+/// 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.
+/// then performs the actual GPU work afterward:
+/// WbDrawDispatcher.PrepareOrderedStream uploads the WHOLE frame's
+/// stream once, and each recorded
+/// becomes one cheap DrawOrderedRange 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.
+/// remains Collect immediately followed by Replay, for
+/// callers (today: the headless referee suite) that do not need the split;
+/// RetailPViewRenderer uses the split directly, since it must run
+/// PrepareCellBatches/BuildAndBorrow BETWEEN them.
+///
+/// The one mark rule that reproduces the whole frame script:
+/// before EVERY leaf-renderer event (,
+/// TerrainSlice, CellShell, ClearInteriorDepth,
+/// ExitSeals, PunchFan) and before every
+/// event, Collect records a
+/// 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
+/// 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 's prepare step).
+/// This single rule, combined with "shell before contents" per cell,
+/// retail's own building order (alpha barrier → portal pass → shell — see
/// 's doc comment), and retail's
/// own interior-root DRAW order (landscape → clear → seals → the flood's own
/// cells — see '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.
+/// root's own flood cells], and a final mark at Replay's prepare step. No
+/// special-casing per turn kind is needed beyond that.
///
/// Retail anchors: SmartBox::RenderNormalMode @0x00453aa0 (the
/// root 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 _events = new();
+ private readonly List _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 VisitedCells { get; } = new();
+
+ internal List VisitedBuildings { get; } = new();
+
+ internal HashSet 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
///
/// Drives one complete frame at retail's root (SmartBox::RenderNormalMode):
- /// calls with this driver as the
- /// sink, sandwiched between /.
+ /// immediately followed by . Kept
+ /// for callers that don't need the split (today: the headless referee
+ /// suite) — RetailPViewRenderer calls the pair directly, since it
+ /// must run other frame work BETWEEN them (plan §FW3.4a).
///
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);
+ }
+
+ ///
+ /// Campaign FW3.4a Phase 1 — THE ONE WALK. Drives
+ /// with this driver as its sink,
+ /// sandwiched between /,
+ /// performing NO GPU work: see this type's own doc comment.
+ ///
+ 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();
}
///
- /// 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
/// or
/// call) and wants this
/// driver's turn handling without going through the top-level root.
- /// is implemented in terms of this pair.
+ /// is implemented in terms of this pair. Performs no
+ /// GPU work — see this type's own doc comment.
///
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();
}
- /// 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 try/finally discipline in
- /// — a caller driving the walk manually should
- /// follow the same shape.
+ /// 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 — 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.
internal void EndFrame()
{
try
{
- FlushIfNonEmpty();
+ MarkIfGrown();
}
finally
{
_ctx = null;
- _frame = null;
- _encoder = null;
- _stream.Reset();
+ _readyToReplay = true;
}
}
+ ///
+ /// Campaign FW3.4a Phase 2. Requires a completed Collect (an
+ /// 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
+ /// issues one
+ /// WbDrawDispatcher.DrawOrderedRange call over the segment it
+ /// closes off; every other event kind issues its corresponding
+ /// 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.
+ ///
+ 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 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 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()
+ /// Campaign FW3.4a: the collect-time analogue of the old
+ /// immediate driver's FlushIfNonEmpty — records a
+ /// 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 , which
+ /// hands to PrepareOrderedStream so a merge
+ /// run can never span it — see WbDrawDispatcher.BuildOrderedMergeRuns's
+ /// forcedBreaksAscending parameter for why that matters.
+ 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.");
/// ConstructBuildingView's polygon is building-local; the
/// punch fan needs world space. Vertices transform directly; the plane
diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs
index cf0eb9cf..0685f1be 100644
--- a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs
@@ -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;
///
///
/// - Cell statics — 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).
/// - Outdoor statics — ONE
@@ -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
/// RenderFrameEntityDrawRequest.
+///
+/// Campaign FW3.4a: , ,
+/// and used to materialize their result
+/// with _cellScratch[..count] / [.. bucket] — a FRESH
+/// RenderProjectionRecord[] 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). replaces it: a
+/// grow-only buffer, reset to length 0 once per frame in
+/// , that every materialization call
+/// 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 /'s
+/// existing grow-on-demand pattern), zero further heap allocation occurs
+/// here. Every segment is
+/// STRICTLY per-frame scratch — nothing holds one across a frame boundary
+/// (the driver/populator consume it immediately, matching
+/// 's existing lifetime contract) — so reusing the
+/// same backing array's memory next frame is safe.
///
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 bucket in _outdoorByCell.Values)
bucket.Clear();
foreach (List 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? 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? 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;
}
+ /// Copies into at
+ /// its current length, growing the arena first if needed (doubling, or
+ /// exactly enough for an unusually large sweep — the same growth shape
+ /// / 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).
+ private ArraySegment AppendToArena(
+ ReadOnlySpan source)
+ {
+ if (source.Length == 0)
+ return ArraySegment.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(_arena, _arenaLength, source.Length);
+ _arenaLength += source.Length;
+ return segment;
+ }
+
/// The building's authored shell anchor: its first non-exit
/// portal's destination cell — the SAME rule LandblockLoader used
/// when it stamped BuildingShellAnchorCellId on the shell entity.
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs
index 2c46058e..dd70fdf1 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs
@@ -20,18 +20,36 @@ namespace AcDream.App.Rendering.Wb;
/// (command i owns exactly one instance, BaseInstance = i, so
/// walk order — never material bucketing — survives into the indirect array)
/// and 's "write every section once"
-/// shape (locals here, not the persisted _alpha* fields: those belong
-/// to , which can still be mid-flight in the
-/// same frame, and overwriting them would corrupt that replay).
+/// shape (_ordered* fields here, not the persisted _alpha*
+/// fields: those belong to , which can still be
+/// mid-flight in the same frame, and overwriting them would corrupt that
+/// replay).
+///
+/// Campaign FW stage FW3.4a (2026-08-30): the FW2 submitter was ONE
+/// method, SubmitOrderedStream, 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. +
+/// replace it: the WHOLE frame's stream is
+/// written and bound ONCE, and each walk segment becomes a cheap
+/// call over the ALREADY-uploaded payload —
+/// the same split /
+/// already prove for the alpha path.
+/// SubmitOrderedStream itself is deleted; every FW2 caller/test now
+/// calls the pair (prepare once, draw the whole stream as one range, or as
+/// several — see OrderPreservingSubmitterTests).
///
/// 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 (DrawBuildingDetailRangeRhi's second
/// pass through RetailDetail/RetailDetailTransparent) — a
/// detail-category command still forces a solo merge run (mirroring the
-/// deferred-alpha detail break), but 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.
+/// 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.
///
public sealed unsafe partial class WbDrawDispatcher
{
@@ -99,12 +117,34 @@ public sealed unsafe partial class WbDrawDispatcher
/// reorders or drops anything: every command in
/// belongs to exactly one returned run, in stream order.
///
+ /// (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. 's Replay phase draws the frame's ONE
+ /// prepared stream as several 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 /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 '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.
+ ///
/// Fails loud before building any run:
- /// 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.
///
- internal static List BuildOrderedMergeRuns(OrderedDrawStream stream)
+ internal static List BuildOrderedMergeRuns(
+ OrderedDrawStream stream, IReadOnlyList? forcedBreaksAscending = null)
{
ArgumentNullException.ThrowIfNull(stream);
int count = stream.Count;
@@ -123,10 +163,19 @@ public sealed unsafe partial class WbDrawDispatcher
}
}
+ IReadOnlyList breaks = forcedBreaksAscending ?? Array.Empty();
+ int breakCursor = 0;
+
var runs = new List();
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
///
/// Campaign FW3.2b-2: the production frame/encoder pair for
- /// 's own
+ /// 's own
/// calls (contrast this stage's diagnostic-target callers, which supply
- /// their own frame/encoder — see 's own
- /// doc comment). Reads the SAME world-pass scope
- /// already requires ( /
+ /// their own frame/encoder). Reads the SAME world-pass scope
+ /// already requires ( /
/// _scope.RequireEncoder()) — fails loud rather than handing the
/// driver a null pair when the world phase is not bracketing.
///
@@ -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 _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;
+
///
- /// Submits 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
- /// call per maximal merge run from
- /// . N commands in yield indirect
- /// commands [0, N) in stream order, each covered by exactly one
- /// emitted run — nothing is reordered, sorted, or dropped.
+ /// Writes 's ENTIRE walk-order payload into the
+ /// frame ring exactly ONCE — the per-instance-first emission
+ /// ('s shape) followed by one
+ /// section write per per-instance array ('s
+ /// shape, into the _ordered* fields above). No draw happens here;
+ /// issues the actual
+ /// calls against this prepared payload,
+ /// as many times as the caller needs ( calls
+ /// it once per walk segment, interleaved with the leaf GPU calls that
+ /// must run between segments).
///
- /// and are
- /// caller-supplied rather than pulled from _frames/_scope
- /// (contrast 's RequireRhiFrame/
- /// scope.RequireEncoder()): 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
- /// _scope.Sections — those are canonical per-frame published
- /// state, not something this submitter owns.
+ /// forwards to
+ /// — see that parameter's own doc
+ /// comment. Pass the walk segment boundaries here so a later
+ /// call's range always aligns with a merge
+ /// run by construction.
+ ///
+ /// A no-op (leaves at 0) when
+ /// the stream is empty or the mesh source is not yet ready — mirrors
+ /// 's own early-outs. Fails loud
+ /// BEFORE any GPU work if the stream carries an unsupported stage (see
+ /// ).
///
- internal void SubmitOrderedStream(
+ internal void PrepareOrderedStream(
IGpuFrame frame,
- IGpuPassEncoder encoder,
OrderedDrawStream stream,
- in Matrix4x4 viewProjection)
+ in Matrix4x4 viewProjection,
+ IReadOnlyList? 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 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(frame, _batchData.AsSpan(0, count));
- RhiSection clipSlots = WriteRingSection(frame, _clipSlotData.AsSpan(0, count));
+ _orderedTransformBaseInstance = transformBaseInstance;
+ _orderedBatches = WriteRingSection(frame, _batchData.AsSpan(0, count));
+ _orderedClipSlots = WriteRingSection(frame, _clipSlotData.AsSpan(0, count));
int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int uploadCount = lightCount > 0 ? lightCount : 1;
- RhiSection globalLights = WriteRingSection(
+ _orderedGlobalLights = WriteRingSection(
frame,
_globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight));
- RhiSection lightSets = WriteRingSection(
+ _orderedLightSets = WriteRingSection(
frame, _lightSetData.AsSpan(0, count * LightManager.MaxLightsPerObject));
- RhiSection indoor = WriteRingSection(frame, _indoorData.AsSpan(0, count));
- RhiSection alpha = WriteRingSection(frame, _alphaData.AsSpan(0, count));
- RhiSection selectionLighting = WriteRingSection(
+ _orderedIndoor = WriteRingSection(frame, _indoorData.AsSpan(0, count));
+ _orderedAlpha = WriteRingSection(frame, _alphaData.AsSpan(0, count));
+ _orderedSelectionLighting = WriteRingSection(
frame, _selectionLightingData.AsSpan(0, count));
- RhiSection detailCategory = WriteRingSection(frame, _detailCategoryData.AsSpan(0, count));
+ _orderedDetailCategory = WriteRingSection(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;
+ }
+
+ ///
+ /// Draws commands [firstCommand, firstCommand + commandCount) of
+ /// the payload the most recent call
+ /// uploaded. The FIRST call in a frame also binds the pipeline, push
+ /// constants, and all nine per-instance sections —
+ /// 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 SubmitOrderedStream rebinds per
+ /// frame at a town becomes one bind plus ~40 cheap
+ /// calls). Section binds survive
+ /// pipeline switches (every mesh pipeline shares one layout — the same
+ /// reasoning SubmitRhi/the old SubmitOrderedStream already
+ /// relied on), so binding once per frame rather than once per pipeline
+ /// switch is safe.
+ ///
+ /// Fail-loud range check mirrors 's:
+ /// a range outside [0, _orderedPreparedCount] throws
+ /// 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).
+ ///
+ /// Walks the runs built that
+ /// intersect this range. By construction (the boundaries the caller fed
+ /// as forcedBreaksAscending) 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.
+ ///
+ 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;
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs
index 605f1b61..2f69d583 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs
@@ -19,9 +19,11 @@ namespace AcDream.App.Tests.Rendering.Walk;
///
/// Campaign FW stage FW2:
-/// (pure CPU merge-run legality) and
-/// (the same legality proven through actual recorded RHI calls against
-/// ).
+/// (pure CPU merge-run legality) and, from Campaign FW stage FW3.4a,
+/// +
+/// (the same legality proven
+/// through actual recorded RHI calls against ,
+/// replacing the single SubmitOrderedStream call those two now split).
///
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(
- () => fx.Dispatcher.SubmitOrderedStream(
- draw.Frame, draw.Pass, stream, Matrix4x4.Identity));
+ () => fx.Dispatcher.PrepareOrderedStream(draw.Frame, stream, Matrix4x4.Identity));
Assert.Empty(fx.Device.Calls.OfType());
Assert.Empty(fx.Device.Calls.OfType());
}
[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());
}
+ // ── 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. ───
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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).
+ ///
+ [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().Count();
+ Assert.True(boundAfterFirst > 0);
+
+ fx.Dispatcher.DrawOrderedRange(draw.Pass, 1, 1);
+ int boundAfterSecond = fx.Device.Calls.OfType().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));
+ }
+
+ ///
+ /// 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.
+ ///
+ [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(
+ () => fx.Dispatcher.DrawOrderedRange(draw.Pass, 0, 2));
+ Assert.Throws(
+ () => 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(
+ () => fx.Dispatcher.DrawOrderedRange(draw.Pass, 0, 1));
+ }
+
+ ///
+ /// 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.
+ ///
+ [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(
+ () => fx.Dispatcher.DrawOrderedRange(draw.Pass, 0, 2));
+ }
+
///
/// 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.
///
[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);
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
index 3045f37e..1da4ff6e 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
@@ -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(
() => ((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(
() => 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();
+ var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
+
+ using DrawScope draw = fx.BeginDraw();
+ Assert.Throws(() => driver.Replay(draw.Frame, draw.Pass));
+ }
+
+ [Fact]
+ public void Replay_WhileCollectIsStillOpen_Throws()
+ {
+ using var fx = new DispatcherFixture();
+ var log = new List();
+ 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(() => 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();
+ 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());
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs
index 05afdf00..4bd0f689 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs
@@ -27,8 +27,9 @@ namespace AcDream.App.Tests.Rendering.Walk;
/// equivalence referee. Covers
/// (the shared per-entity classify seam),
/// (opaque → , translucent → the alpha queue,
-/// selection publish), and the FW3.2a own-cull-scratch fix to
-/// SubmitOrderedStream.
+/// selection publish), and the FW3.2a own-cull-scratch fix to the ordered
+/// submitter (PrepareOrderedStream/DrawOrderedRange as of
+/// Campaign FW stage FW3.4a).
///
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