From 212f5a12e550d3ca9cf3337359d4ec1c704730fd Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 30 Aug 2026 17:01:26 +0200 Subject: [PATCH] perf(render) Campaign FW3.4a: one walk pass; prepare-once/draw-ranges; arena records The FW3.4 dense-Arwic pair triggered the +/-20% stop rule (+33.5% CPU p50, 14x frame allocation). This slice removes the three measured costs without changing GPU command order (the referee suites assert identical recorded call sequences): - WalkFrameDriver: Collect (ONE walk per frame - no GPU work; leaf calls and flush points become a recorded event list; the driver absorbed the renderer collection pass and exposes the visited sets) + Replay (prepare the whole stream once, then replay events, interleaving DrawOrderedRange with leaf calls in the exact recorded order). RunFrame = Collect+Replay for existing callers. - WbDrawDispatcher: SubmitOrderedStream split into PrepareOrderedStream (all sections + commands + merge runs uploaded once per frame) and DrawOrderedRange (bind-once latch; per-run pipeline + DrawIdOffset + DrawIndirectRangeRhi). Load-bearing correctness catch from the implementation round: merge runs take FORCED BREAKS at the recorded event marks - whole-stream merging must not fuse two segments that retail separates with a leaf GPU call (shell, punch); the straddle assert stays as a dead-code safety net. - WalkProductionWorldData: WalkFrameStaticRecords carries an ArraySegment into a per-frame grow-only arena; the per-cell fresh-array copies (the 1.9 MB/frame alloc p50) are gone - zero steady-state allocation after warmup. Suites (lead-verified): full Release build 0 warnings; hermetic 6,758/0; Walk lane 209/1; InstalledDat Walk conformance 40/1 untouched. Next: the dense-Arwic re-measure against the same-session baseline. Co-Authored-By: Claude Fable 5 --- .../Rendering/RetailPViewRenderer.cs | 208 ++++---- .../Rendering/Walk/WalkFrameDriver.cs | 470 ++++++++++++++---- .../Rendering/Walk/WalkProductionWorldData.cs | 66 ++- .../Wb/WbDrawDispatcher.OrderedStream.cs | 358 +++++++++---- .../Walk/OrderPreservingSubmitterTests.cs | 197 +++++++- .../Rendering/Walk/WalkFrameDriverTests.cs | 95 +++- .../Walk/WalkStaticStreamPopulatorTests.cs | 16 +- 7 files changed, 1051 insertions(+), 359 deletions(-) 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