diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs index 58d8e272..6efd1f1c 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs @@ -238,6 +238,10 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer public void ClearInteriorDepth() => _clearInteriorDepth(); + public void DrawStaticParticles(IReadOnlySet ownerIds) => + _passes.DrawLandscapeStaticParticles( + _frame, new RetailPViewLandscapeStaticParticleContext(ownerIds)); + public void DrawExitSeals() => _drawExitSeals(); public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex) => diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 2ca7db08..c3eacb08 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -622,11 +622,10 @@ public sealed class RetailPViewRenderer Walk.WalkFrameDriver capturedDriver = walkDriver!; _walkPreClearDynamics = () => { - // Retail inserts static-owner emitters (candle flames) - // during their cells' landscape walk turns — submit - // BEFORE the pre-clear drain so they composite against - // still-true landscape depth, never the seals. - SubmitWalkLandscapeStaticParticles(ctx, walkExecutor!, capturedDriver); + // Static-owner emitters now submit AT THEIR OWN WALK + // TURNS during Replay (WalkFrameEventKind.StaticParticles + // — the #132 positional invariant), so the pre-clear + // closure carries only the dynamics phase. passes.UseIndoorMembershipOnlyRouting(); DrawLandscapeDynamicsPhase( ctx, @@ -1358,33 +1357,6 @@ public sealed class RetailPViewRenderer /// 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). - /// The landscape-stage static-owner particle submission: the - /// union of every outdoor-static record from a landscape cell the walk - /// visited this frame, plus every shell record from a building the walk - /// visited (plan §FW3 item 4 — retail gates particles per cell turn, - /// ShouldDrawParticles @0x0050FE60, so this is MORE - /// retail-faithful than the old per-slice sphere filter it replaced). - /// Interior roots invoke this at the walk's pre-clear boundary; outdoor - /// roots post-replay. - private void SubmitWalkLandscapeStaticParticles( - RetailPViewFrameInput ctx, - RetailPViewPassExecutor passes, - Walk.WalkFrameDriver driver) - { - _staticParticleUnionScratch.Clear(); - 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( - ctx, - new RetailPViewLandscapeStaticParticleContext(_staticParticleUnionScratch)); - _staticParticleUnionScratch.Clear(); - } - } - private void DrawWalkDrivenStatics( RetailPViewFrameInput ctx, RetailPViewPassExecutor passes, @@ -1393,18 +1365,14 @@ public sealed class RetailPViewRenderer var (frame, encoder) = passes.RequireWalkSubmission(); driver.Replay(frame, encoder); - // Landscape-stage static-owner particles (candle flames on lamp and - // candle statics): for an OUTDOOR root they submit here, post-replay - // (drained at the frame-end flush over finished depth — the #132 - // placement). For an INTERIOR root they must submit at the walk's - // PRE-CLEAR boundary instead — retail inserts each one during its - // cell's landscape walk turn, before the clear+seals; a post-seal - // drain z-fails them at the aperture (the visual-gate "no candle - // flames looking out" report) — so SubmitWalkLandscapeStaticParticles - // is invoked from _walkPreClearDynamics in that case, and skipped - // here. - if (ctx.RootCell.IsOutdoorNode) - SubmitWalkLandscapeStaticParticles(ctx, passes, driver); + // Landscape-stage static-owner particles (candles, the cathedral + // falls) submit AT THEIR OWN WALK TURNS inside Replay + // (WalkFrameEventKind.StaticParticles) for BOTH root kinds — the + // #132 positional invariant (e102fb36's user-verified rule ported + // walk-natively): each owner cell's emitters enter the one alpha + // queue at that cell's far-to-near position, so every nearer + // building's pre-punch AlphaBarrier drains the farther content + // against still-true depth before its punch stamps far-Z. // Cell-stage particle owners for the interior root's OWN flood cells // (non-look-in — the walk draws their statics too, via diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index ad162c03..6a6975f1 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -112,6 +112,12 @@ internal interface IWalkFrameLeafRenderer /// this same shell-then-contents order). void DrawCellShell(uint cellId); + /// One landscape cell's or building shell's static-owner + /// particle submission, at its own walk turn — see + /// for the retail + /// positional invariant this carries (the #132 falls containment). + void DrawStaticParticles(IReadOnlySet ownerIds); + /// PView::DrawCells @0x005a4840's gated full depth clear /// (pc:432731-432732) between the outside stage and the interior root's /// own flood — production maps this to IWorldPassScope.ClearInteriorDepth @@ -236,6 +242,23 @@ internal enum WalkFrameEventKind : byte /// . ExitSeals, + + /// — + /// ONE landscape cell's () or ONE + /// building shell's () static-owner + /// particle submission, emitted AT ITS OWN WALK TURN. Retail's falls + /// containment is positional: an outdoor emitter's polys join the one + /// alpha list during its owner cell's DrawObjCell in the + /// far-to-near landscape walk, so every nearer building's pre-punch + /// alpha barrier (DrawBuilding @0x0059f2a0's + /// FlushAlphaList @0x0059f30b) drains the already-queued FARTHER + /// content against still-true depth BEFORE the punch stamps far-Z into + /// the aperture. The former single batched submission at the pre-clear + /// closure ran AFTER every punch — the barriers fired over an empty + /// queue and the falls drained against punched-far aperture pixels (the + /// cathedral bleed; the old pipeline's user-verified #132 fix + /// `e102fb36` encoded the same invariant). + StaticParticles, } /// See for what each field means per @@ -247,13 +270,15 @@ internal enum WalkFrameEventKind : byte internal readonly struct WalkFrameEvent { private WalkFrameEvent( - WalkFrameEventKind kind, int intArg, uint cellId, float floatArg, WalkPolygon? polygon) + WalkFrameEventKind kind, int intArg, uint cellId, float floatArg, WalkPolygon? polygon, + WalkBuilding? building = null) { Kind = kind; IntArg = intArg; CellId = cellId; FloatArg = floatArg; Polygon = polygon; + Building = building; } internal WalkFrameEventKind Kind { get; } @@ -266,6 +291,11 @@ internal readonly struct WalkFrameEvent internal WalkPolygon? Polygon { get; } + /// only: the + /// building whose shell statics' owners submit at this turn; null for a + /// landscape cell's turn (then names the cell). + internal WalkBuilding? Building { get; } + internal static WalkFrameEvent Mark(int exclusiveEnd) => new(WalkFrameEventKind.StreamMark, exclusiveEnd, 0, 0f, null); @@ -284,6 +314,12 @@ internal readonly struct WalkFrameEvent internal static WalkFrameEvent AlphaBarrier(float viewerDistance) => new(WalkFrameEventKind.AlphaBarrier, 0, 0, viewerDistance, null); + internal static WalkFrameEvent LandscapeCellParticles(uint cellId) => + new(WalkFrameEventKind.StaticParticles, 0, cellId, 0f, null); + + internal static WalkFrameEvent BuildingShellParticles(WalkBuilding building) => + new(WalkFrameEventKind.StaticParticles, 0, 0, 0f, null, building); + internal static WalkFrameEvent ClearInteriorDepth() => new(WalkFrameEventKind.ClearInteriorDepth, 0, 0, 0f, null); @@ -397,6 +433,20 @@ internal sealed class WalkFrameDriver : IWalkEventSink /// alpha drain splats through (the cathedral falls shine-through). internal List InteriorFloodCells { get; } = new(); + // Replay scratch for StaticParticles events (sequential replay — one + // reused set is safe). + private readonly HashSet _staticParticleOwnerScratch = new(); + + private static void UnionOwners( + in WalkFrameStaticRecords records, HashSet destination) + { + foreach (RenderProjectionRecord record in records.Records) + { + if (record.Source.LocalEntityId != 0) + destination.Add(record.Source.LocalEntityId); + } + } + internal List VisitedBuildings { get; } = new(); internal HashSet VisitedLandscapeCellIds { get; } = new(); @@ -595,6 +645,16 @@ internal sealed class WalkFrameDriver : IWalkEventSink case WalkFrameEventKind.ExitSeals: _leafRenderer.DrawExitSeals(); break; + case WalkFrameEventKind.StaticParticles: + _staticParticleOwnerScratch.Clear(); + UnionOwners( + e.Building is WalkBuilding shellOwner + ? _worldData.GetBuildingShellStatics(shellOwner) + : _worldData.GetOutdoorStatics(e.CellId), + _staticParticleOwnerScratch); + if (_staticParticleOwnerScratch.Count > 0) + _leafRenderer.DrawStaticParticles(_staticParticleOwnerScratch); + break; } } @@ -639,6 +699,27 @@ internal sealed class WalkFrameDriver : IWalkEventSink _populator.PopulateOutdoorStatics( _stream, cellId, records.Records, records.TupleLandblockId, _cameraWorldPosition, _viewProjection); + // FW4 (the #132 positional invariant): this cell's emitter owners + // submit AT THIS TURN, so nearer buildings' pre-punch barriers + // drain them against still-true depth — see + // WalkFrameEventKind.StaticParticles. Mark first so the cell's own + // meshes flush ahead of its particle submission (retail's + // per-object DrawObjCell order). + if (HasAnyOwner(records)) + { + MarkIfGrown(); + _events.Add(WalkFrameEvent.LandscapeCellParticles(cellId)); + } + } + + private static bool HasAnyOwner(in WalkFrameStaticRecords records) + { + foreach (RenderProjectionRecord record in records.Records) + { + if (record.Source.LocalEntityId != 0) + return true; + } + return false; } void IWalkEventSink.OnBuildingTurn(WalkBuilding building) @@ -672,6 +753,14 @@ internal sealed class WalkFrameDriver : IWalkEventSink _populator.PopulateCell( _stream, WalkDrawStage.BuildingShell, building.PositionCellId, shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection); + // FW4 (the #132 positional invariant): the building's own shell + // emitters submit at the shell turn, after the shell content + // flushes — see WalkFrameEventKind.StaticParticles. + if (HasAnyOwner(shell)) + { + MarkIfGrown(); + _events.Add(WalkFrameEvent.BuildingShellParticles(building)); + } } void IWalkEventSink.OnPunchGeometry( diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index e8022358..6c88a97e 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -60,6 +60,13 @@ public sealed class WalkFrameDriverTests } public void AlphaBarrier(float viewerDistance) => log.Add($"ALPHA:{viewerDistance:F2}"); + + public void DrawStaticParticles(IReadOnlySet ownerIds) + { + var sorted = new List(ownerIds); + sorted.Sort(); + log.Add($"PARTICLES:{string.Join(",", sorted.ConvertAll(o => o.ToString("x")))}"); + } } private sealed class RecordingTrace(List log) : IWalkFrameDriverTrace @@ -405,7 +412,13 @@ public sealed class WalkFrameDriverTests driver.Replay(draw.Frame, draw.Pass); Assert.Equal( - new[] { "ALPHA:12.50", "PUNCH:4@v0", "SHELL:00000104", "FLUSH:1:LookInStatic", "FLUSH:1:BuildingShell" }, + // FW4 #132 positional invariant: the building's shell emitters + // submit at the shell turn, after the shell content flushes. + new[] + { + "ALPHA:12.50", "PUNCH:4@v0", "SHELL:00000104", + "FLUSH:1:LookInStatic", "FLUSH:1:BuildingShell", "PARTICLES:c9", + }, log); // The punch polygon reached the leaf renderer in WORLD space: the @@ -554,7 +567,9 @@ public sealed class WalkFrameDriverTests 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); + // FW4 #132 positional invariant: the cell's emitters submit at its + // own landscape turn, after its meshes flush. + Assert.Equal(new[] { "FLUSH:1:OutdoorStatic", "PARTICLES:12d" }, log); GpuRecordedMultiDrawIndirect mdi = Assert.Single(fx.Device.Calls.OfType()); Assert.Equal(1u, mdi.DrawCount); }