From 10ccce3f2dc38d1ff8ce5d0fbc962156a50b022d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 10:50:57 +0200 Subject: [PATCH] perf(rendering): retire production entity partition Route production meshes and attached particles from the exact retained PView frame ranges, while keeping the former WorldEntity partition only for standalone tests and explicit diagnostic/oracle probes. Copy retained source/count facts before arena release so world diagnostics no longer require the old partition. Record the accepted G4 visual gate and open the exact G5 production matrix. Co-authored-by: OpenAI Codex --- docs/plans/2026-04-11-roadmap.md | 10 +- docs/plans/2026-05-12-milestones.md | 12 +- .../2026-07-24-modern-runtime-architecture.md | 5 +- ...-modern-runtime-slices-f-g-render-scene.md | 16 +- .../2026-07-25-modern-runtime-slice-i.md | 3 +- .../2026-07-25-slice-g5-production-profile.md | 27 +- .../Rendering/RetailPViewPassExecutor.cs | 20 +- .../Rendering/RetailPViewRenderer.cs | 438 ++++++++++++++---- .../WorldSceneDiagnosticsController.cs | 2 +- .../Rendering/WorldSceneRenderer.cs | 11 +- .../Rendering/RetailPViewPassExecutorTests.cs | 33 +- 11 files changed, 452 insertions(+), 125 deletions(-) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 6b5485cd..67c01ece 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1639,9 +1639,13 @@ port in any phase — no separate listing here. > comparisons have zero mismatches. The first retained-path visual gate exposed > independent portal scheduling/private-view/ACE-revisit ownership regressions. > Their correction passed visually while the draw cutover was temporarily -> reverted, which proves the correction but not the retained source. -> `20f9fadb` reapplies the cutover over those fixes; its connected portal/revisit -> re-gate is pending before G5 old-path deletion and production-only profiling. +> reverted. The reapplied retained source subsequently passed the connected +> portal/revisit matrix on 2026-07-25, including stable server objects and +> paperdoll, distant interaction, and correct `/ls` plus spell-recall +> materialization. G4 is complete. G5 cleanup is active: normal production no +> longer builds `InteriorEntityPartition`; mesh and attached-particle ownership +> come from the retained frame product. The exact production-only +> performance/lifecycle matrix remains. > The historical exact cutover > rollback remains > `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete: diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 04f58c60..f404bd9a 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -108,11 +108,13 @@ publication, retirement, upload, warmup, or resource backlog. Capped/uncapped CPU p99 is 34.7%/30.4% below Slice A and maximum frame allocation is 84.7%/82.2% lower. The user explicitly approved Slices F–L, including both the F/G ECS and Slice J gameplay-owner gates, on 2026-07-24. Slice F and -G0–G4 are implemented and G4's automated gate has zero mismatches. The portal -scheduling/private-viewport/ACE-revisit correction passed visually while the -retained draw cutover was temporarily reverted, so `20f9fadb` reapplies the -cutover over those fixes for the pending connected retained-path re-gate. G5 -deletion and production-only profiling remain gated on that pass. Slice H is +G0–G4 are complete: G4's automated gate has zero mismatches and the user +accepted the corrected retained-path portal/revisit matrix on 2026-07-25. +G5 cleanup is active; normal production now routes meshes and attached +particles from the retained frame product without building the old +`InteriorEntityPartition`. Focused route tests, 3,825 App tests / 3 skips, +and 8,334 solution tests / 5 skips pass. The exact production-only +performance/lifecycle matrix remains before G5 closeout. Slice H is complete: retained UI/frame work, exact bounded light selection, and pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the seven-checkpoint connected lifecycle/reconnect gate. Slice I is active under diff --git a/docs/plans/2026-07-24-modern-runtime-architecture.md b/docs/plans/2026-07-24-modern-runtime-architecture.md index c9da2d15..b7c7d545 100644 --- a/docs/plans/2026-07-24-modern-runtime-architecture.md +++ b/docs/plans/2026-07-24-modern-runtime-architecture.md @@ -2,8 +2,9 @@ **Date:** 2026-07-24 -**Status:** Slices A–E complete. Slices F–L explicitly approved 2026-07-24; -Slice F is active. +**Status:** Slices A–F and H are complete; G0–G4 and I0 are complete. Slices +F–L were explicitly approved 2026-07-24; G5 production-matrix closeout is +active. **Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit. diff --git a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md index 01e33365..1e5e4561 100644 --- a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md +++ b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md @@ -2,8 +2,8 @@ **Date:** 2026-07-24 -**Status:** approved 2026-07-24; Slice F complete, G4 retained-path re-gate -active, G5 prepared but not unblocked +**Status:** approved 2026-07-24; Slice F and G4 complete, G5 retained-route +cleanup active **Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md` @@ -28,8 +28,8 @@ retail-faithful gameplay | G1 — scene-query replacement | complete | Exact `e346f8bb` passed capped and uncapped nine-stop routes plus uncapped dense Arwic: 29,392 + 29,025 + 1,522 same-frame candidate comparisons, zero mismatches, equal counts/digests at every checkpoint, exact binary/source identity, and graceful shutdown. Evidence: `docs/research/2026-07-25-slice-g1-scene-query-candidate-gate.md`. | | G2 — packed dispatcher input | complete | Exact `f9829d5f` passed capped and uncapped nine-stop routes plus dense Arwic. Candidate order, packed input, classified output, and selection each completed 16,879 + 17,282 + 770 comparisons with zero mismatch; journal/rejection counts were zero and shutdown graceful. Evidence: `docs/research/2026-07-25-slice-g2-packed-dispatcher-gate.md`. | | G3 — retained classification/storage | complete | Exact `6a026c5a` passed capped and uncapped nine-stop routes plus dense Arwic with 17,069 + 16,827 + 757 exact comparisons in every channel, zero mismatch, warm cross-frame reuse, and graceful shutdown. Evidence: `docs/research/2026-07-25-slice-g3-retained-classification-gate.md`. | -| G4 — production cutover | re-gate active | Exact cutover `ef1d263337997bb030eadb7b8e71d73dc659907a` makes the retained frame product the production entity source at the existing five retail PView stages. Exact `03b10183` passed capped/uncapped nine-stop and uncapped dense-Arwic automation with 46,599 total product comparisons and zero mismatch. The portal correction was visually accepted while the cutover was temporarily reverted by `2c848d41`; that proves the correction but cannot accept the retained draw source. Commit `20f9fadb` reapplies the cutover over the correction and is the current connected visual re-gate. Evidence: `docs/research/2026-07-25-slice-g4-production-cutover-automated-gate.md` and `docs/research/2026-07-25-portal-regression-closeout.md`. | -| G5 | prepared | Old-route deletion and the production-only reference matrix remain blocked only on the connected `20f9fadb` retained-path re-gate. An allocation-free retained-route particle-owner selector and steady RDP sample are prepared without changing the running gate binary. | +| G4 — production cutover | complete | Exact cutover `ef1d263337997bb030eadb7b8e71d73dc659907a` makes the retained frame product the production entity source at the existing five retail PView stages. Exact `03b10183` passed capped/uncapped nine-stop and uncapped dense-Arwic automation with 46,599 total product comparisons and zero mismatch. After the independent portal/private-view/revisit corrections, the user accepted the connected retained-path visual matrix on 2026-07-25: server objects and paperdoll survive travel, distant use works, `/ls` and spell recalls both materialize through the retail purple haze, and neither character pop nor recall tail remains. Evidence: `docs/research/2026-07-25-slice-g4-production-cutover-automated-gate.md` and `docs/research/2026-07-25-portal-regression-closeout.md`. | +| G5 | cleanup active | Normal production no longer builds `InteriorEntityPartition`: mesh and attached-particle ownership now consume the exact retained route ranges. The legacy partition remains only for standalone tests and explicitly enabled diagnostic/oracle probes. Focused route tests, 3,825 App tests / 3 skips, and 8,334 complete-solution tests / 5 skips pass; the exact production-only performance/lifecycle matrix remains. | The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is non-drawing diagnostic infrastructure and therefore is not a visual-cutover @@ -653,9 +653,11 @@ correction is recorded in `docs/research/2026-07-25-portal-regression-closeout.md`. The user's subsequent pass occurred while exact cutover `ef1d2633` was temporarily reverted by `2c848d41`, so it proves those corrections but not the retained draw source. -`20f9fadb` reapplies the cutover over the accepted corrections. G4 acceptance, -old-route deletion, and the G5 production-only matrix remain gated on the -connected retained-path recheck. +`20f9fadb` reapplies the cutover over the accepted corrections. The user +accepted that connected retained path on 2026-07-25, including stable objects +and paperdoll through travel, distant interaction, and the corrected `/ls` and +spell-recall materialization. G4 is complete. G5 removes the old production +partition route and runs the production-only matrix. ### G5 — Physical performance and lifecycle gate diff --git a/docs/plans/2026-07-25-modern-runtime-slice-i.md b/docs/plans/2026-07-25-modern-runtime-slice-i.md index 54290fdd..50d55577 100644 --- a/docs/plans/2026-07-25-modern-runtime-slice-i.md +++ b/docs/plans/2026-07-25-modern-runtime-slice-i.md @@ -1,7 +1,8 @@ # Modern runtime Slice I — flat collision assets and zero-allocation physics **Status:** I0 COMPLETE — retail oracle, representative graph fixtures, and -four-mover allocation baselines fixed; accepted G5 closeout now precedes I1 +four-mover allocation baselines fixed; active G5 production-matrix closeout +precedes I1 **Parent:** `2026-07-24-modern-runtime-architecture.md`, Slice I **Purpose:** remove parsed DAT object graphs and steady collision allocations without changing one retail collision decision, float, comparison, or traversal diff --git a/docs/research/2026-07-25-slice-g5-production-profile.md b/docs/research/2026-07-25-slice-g5-production-profile.md index c3f1c1f6..d9181fc5 100644 --- a/docs/research/2026-07-25-slice-g5-production-profile.md +++ b/docs/research/2026-07-25-slice-g5-production-profile.md @@ -2,10 +2,13 @@ ## Status -Work in progress. The retained-scene cutover is running at `20f9fadb`, but G5 -old-route deletion remains blocked on its connected portal/revisit visual -re-gate. This note records production-only evidence gathered without enabling -the referee or lifecycle automation. +Work in progress. The retained-scene connected visual re-gate passed on +2026-07-25. Normal production now consumes retained frame-product ranges for +both mesh submission and attached-particle ownership and does not build +`InteriorEntityPartition`. The legacy partition is retained only for +standalone tests and explicitly enabled diagnostic/oracle probes. This note +records production-only evidence gathered without enabling the referee or +lifecycle automation; the exact post-removal route matrix is still pending. ## First steady RDP sample @@ -36,8 +39,14 @@ mandatory after the visual re-gate. `RenderFrameRouteOwnerSelector` derives attached particle owner IDs from the exact retained PView route ranges. It supports exact-route replace/union and -all-range exclusion without allocation after warmup. This removes the final -particle-routing reason for normal production to build -`InteriorEntityPartition`; it is not wired into the running visual-gate binary. -Four focused tests and the 3,821-pass / 3-skip App Release suite are green -through an isolated output path. +all-range exclusion without allocation after warmup. It now supplies the +landscape-static, outside-dynamic, look-in, cell-static, and dynamic-last +particle passes. `RetailPViewRenderer` builds the former partition only when +the retained frame product is unavailable in a standalone test or when an +explicit legacy comparison/probe is enabled. The production frame result +copies retained source and route counts before releasing its arena, so +diagnostics do not require the partition either. + +The focused route suite, 3,825 App tests / 3 skips, Release solution build, +and 8,334 complete-solution tests / 5 skips are green. The production-only +reference matrix below remains the G5 closeout gate. diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index ea873c97..992c3212 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -64,6 +64,12 @@ internal sealed class RetailPViewParticleClassifications foreach (WorldEntity owner in owners) _outdoor.Add(owner.Id); } + + public void ReplaceOutdoor(IReadOnlySet ownerIds) + { + _outdoor.Clear(); + _outdoor.UnionWith(ownerIds); + } } /// @@ -452,7 +458,7 @@ internal sealed class RetailPViewPassExecutor : animatedEntityIds: frame.AnimatedEntityIds); } - _particleClassifications.ReplaceOutdoor(context.ParticleOwners); + _particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds); _diagnostics.EmitOutStageParticles( RenderingDiagnostics.ProbeOutStageEnabled, @@ -539,15 +545,14 @@ internal sealed class RetailPViewPassExecutor : { if (_particles is null || _particleRenderer is null - || context.CellEntities.Count == 0) + || context.ParticleOwnerIds.Count == 0) { return; } HashSet visible = _particleClassifications.Visible; visible.Clear(); - foreach (WorldEntity entity in context.CellEntities) - visible.Add(entity.Id); + visible.UnionWith(context.ParticleOwnerIds); if (visible.Count == 0) return; @@ -562,15 +567,14 @@ internal sealed class RetailPViewPassExecutor : public void DrawDynamicsParticles( RetailPViewFrameInput frame, - IReadOnlyList survivors) + IReadOnlySet ownerIds) { - if (_particles is null || _particleRenderer is null || survivors.Count == 0) + if (_particles is null || _particleRenderer is null || ownerIds.Count == 0) return; HashSet dynamics = _particleClassifications.Dynamics; dynamics.Clear(); - foreach (WorldEntity entity in survivors) - dynamics.Add(entity.Id); + dynamics.UnionWith(ownerIds); if (dynamics.Count == 0) return; diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index c9997e72..9b515852 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -25,6 +25,8 @@ public sealed class RetailPViewRenderer private static readonly ClipViewSlice NoClipSlice = new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty()); private static readonly ClipViewSlice[] NoClipSlices = { NoClipSlice }; + private static readonly IReadOnlySet NoParticleOwners = + new HashSet(); private readonly HashSet _oneCell = new(1); // Shell-batch scratch: all of a pass's cells collected for ONE batched @@ -50,7 +52,9 @@ public sealed class RetailPViewRenderer // #131/#132: the late landscape phase's scene-particle owner survivors // (statics + outside-stage dynamics passing the slice cone). - private readonly List _lateParticleOwnerScratch = new(); + private readonly HashSet _lateParticleOwnerScratch = new(); + private readonly HashSet _cellParticleOwnerScratch = new(); + private readonly HashSet _dynamicParticleOwnerScratch = new(); // MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/ // Dynamics), reused across frames instead of `new`ing a Result (a Dictionary @@ -58,6 +62,8 @@ public sealed class RetailPViewRenderer // call. See InteriorEntityPartition.Partition(Result, ...) — clears in // place and reuses each cell's list across frames when the cell stays // visible. + // Slice G4: this is now a diagnostic/fallback oracle only. Normal + // production consumes the retained RenderFrameView routes directly. private readonly InteriorEntityPartition.Result _partitionResult = new(); // MP-Alloc (2026-07-05): DrawInside's drawable-cell set, reused across @@ -174,20 +180,6 @@ public sealed class RetailPViewRenderer passes.PrepareCellBatches(ctx, prepareCells); - InteriorEntityPartition.Partition( - _partitionResult, - prepareCells, - ctx.LandblockEntries, - _partitionObserver); - var partition = _partitionResult; - RetailPViewFrameResult result = _frameResultScratch.Reset( - pvFrame, - clipAssembly, - drawableCells, - partition); - - passes.EmitDiagnostics(ctx, result); - // T1 (fused BR-2/3): retail's frame order — static world, then the // aperture depth writes, then interior cells WHOLE far→near, then // per-cell statics, then ALL dynamics last (retail draws objects after @@ -218,6 +210,51 @@ public sealed class RetailPViewRenderer _candidateObserver?.BeginPViewFrame(); try { + if (_sceneFrameProduct is not null) + { + frameView = _sceneFrameProduct.BuildAndBorrow( + pvFrame, + clipAssembly, + viewcone, + _lookInFrames, + drawableCells, + ctx.Cells, + ctx.AnimatedEntityIds, + ctx.RootCell.IsOutdoorNode); + frameViewBorrowed = true; + frameEntityPasses!.BeginEntityFrame(in frameView); + entityFrameOpen = true; + } + + // The retained scene product is the production object source. + // Rebuild the former WorldEntity partition only for the standalone + // fallback and explicitly enabled comparison/probe paths. + InteriorEntityPartition.Result? partition = null; + if (_sceneFrameProduct is null || LegacyPartitionDiagnosticsEnabled) + { + InteriorEntityPartition.Partition( + _partitionResult, + prepareCells, + ctx.LandblockEntries, + _partitionObserver); + partition = _partitionResult; + } + + RenderFrameDiagnosticCounts counts = frameViewBorrowed + ? frameView.DiagnosticCounts + : LegacyDiagnosticCounts(partition!); + RenderProjectionCounts sourceCounts = frameViewBorrowed + ? frameView.SourceDigest.Counts + : LegacySourceCounts(partition!); + RetailPViewFrameResult result = _frameResultScratch.Reset( + pvFrame, + clipAssembly, + drawableCells, + counts, + sourceCounts, + partition); + passes.EmitDiagnostics(ctx, result); + // #118: stage assignment for dynamics under an INTERIOR root. Retail // draws the OUTSIDE world's objects inside the landscape stage — // PView::DrawCells runs LScape::draw FIRST (pc:432719), then the gated @@ -235,7 +272,7 @@ public sealed class RetailPViewRenderer // z-buffered equivalent of retail's painter-ordered outdoor pass (the // BR-2 punch-after-dynamics lesson, reverted 88be519). _outsideStageDynamics.Clear(); - if (!ctx.RootCell.IsOutdoorNode) + if (partition is not null && !ctx.RootCell.IsOutdoorNode) { foreach (var e in partition.Dynamics) { @@ -245,22 +282,6 @@ public sealed class RetailPViewRenderer } } - if (_sceneFrameProduct is not null) - { - frameView = _sceneFrameProduct.BuildAndBorrow( - pvFrame, - clipAssembly, - viewcone, - _lookInFrames, - drawableCells, - ctx.Cells, - ctx.AnimatedEntityIds, - ctx.RootCell.IsOutdoorNode); - frameViewBorrowed = true; - frameEntityPasses!.BeginEntityFrame(in frameView); - entityFrameOpen = true; - } - DrawLandscapeThroughOutsideView( ctx, passes, @@ -441,7 +462,7 @@ public sealed class RetailPViewRenderer RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, - InteriorEntityPartition.Result partition, + InteriorEntityPartition.Result? partition, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView) { @@ -466,7 +487,7 @@ public sealed class RetailPViewRenderer 0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), cps.PlaneArray), - Array.Empty())); + NoParticleOwners)); } } @@ -494,8 +515,11 @@ public sealed class RetailPViewRenderer passes.DrawTransparentCellShells(_oneCell); _cellStaticScratch.Clear(); - if (partition.ByCell.TryGetValue(cellId, out var bucket)) + if (partition is not null + && partition.ByCell.TryGetValue(cellId, out var bucket)) + { _cellStaticScratch.AddRange(bucket); + } // #131 ROOT CAUSE: DYNAMICS living in a look-in cell (the // Holtburg hall-porch PORTAL, pCell 0xA9B4017A) draw NOWHERE @@ -510,11 +534,31 @@ public sealed class RetailPViewRenderer // cell is absent from the main cone), and their emitters ride // the DrawCellParticles call below, not DrawDynamicsParticles // (which only sees dynamics-last cone survivors). - foreach (var e in partition.Dynamics) - if (e.ParentCellId == cellId) - _cellStaticScratch.Add(e); + if (partition is not null) + { + foreach (var e in partition.Dynamics) + if (e.ParentCellId == cellId) + _cellStaticScratch.Add(e); + } - if (_cellStaticScratch.Count > 0) + if (frameEntityPasses is not null) + { + RenderFrameRouteOwnerSelector.Replace( + _cellParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.LookInObject, + i, + cellId); + } + else + { + ReplaceOwnerIds( + _cellParticleOwnerScratch, + _cellStaticScratch); + } + + if (frameEntityPasses is not null + || _cellStaticScratch.Count > 0) { _candidateObserver?.ObservePViewBucket( CurrentRenderPViewRoute.LookInObject, @@ -536,7 +580,7 @@ public sealed class RetailPViewRenderer // nested DrawCells draws objects WITH their emitters. foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext( - cellId, slice, _cellStaticScratch)); + cellId, slice, _cellParticleOwnerScratch)); } } } @@ -546,7 +590,7 @@ public sealed class RetailPViewRenderer RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, - InteriorEntityPartition.Result partition, + InteriorEntityPartition.Result? partition, ViewconeCuller viewcone, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView) @@ -580,11 +624,19 @@ public sealed class RetailPViewRenderer passes.EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex); _outdoorStaticScratch.Clear(); - foreach (var e in partition.OutdoorStatic) + if (partition is not null) { - EntitySphere(e, out var c, out float r); - if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) - _outdoorStaticScratch.Add(e); + foreach (var e in partition.OutdoorStatic) + { + EntitySphere(e, out var c, out float r); + if (viewcone.SphereVisibleInOutsideSlice( + probeSliceIndex, + c, + r)) + { + _outdoorStaticScratch.Add(e); + } + } } _candidateObserver?.ObservePViewBucket( CurrentRenderPViewRoute.LandscapeOutdoorStatic, @@ -636,21 +688,27 @@ public sealed class RetailPViewRenderer _outdoorStaticScratch.Clear(); // late: dynamics survivors _lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors - foreach (var e in partition.OutdoorStatic) + if (partition is not null) { - EntitySphere(e, out var c, out float r); - bool ownerPass = viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r); - if (ownerPass) - _lateParticleOwnerScratch.Add(e); + foreach (var e in partition.OutdoorStatic) + { + EntitySphere(e, out var c, out float r); + bool ownerPass = viewcone.SphereVisibleInOutsideSlice( + probeSliceIndex, + c, + r); + if (ownerPass) + _lateParticleOwnerScratch.Add(e.Id); // #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids // double as an ENTITY-id watchlist here — one line per watched // outdoor-static owner per CHANGE of its cone verdict. - passes.EmitOutStageOwner( - e, - c, - r, - probeSliceIndex, - ownerPass); + passes.EmitOutStageOwner( + e, + c, + r, + probeSliceIndex, + ownerPass); + } } foreach (var e in _outsideStageDynamics) { @@ -658,9 +716,24 @@ public sealed class RetailPViewRenderer if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) { _outdoorStaticScratch.Add(e); - _lateParticleOwnerScratch.Add(e); + _lateParticleOwnerScratch.Add(e.Id); } } + if (frameEntityPasses is not null) + { + RenderFrameRouteOwnerSelector.Replace( + _lateParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.LandscapeOutdoorStatic, + probeSliceIndex, + 0); + RenderFrameRouteOwnerSelector.Union( + _lateParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.LandscapeOutsideDynamic, + probeSliceIndex, + 0); + } passes.EmitOutStageRouting( probeSliceIndex, _outsideStageDynamics, @@ -735,7 +808,10 @@ public sealed class RetailPViewRenderer foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) passes.DrawExitPortalMask( ctx, - new RetailPViewCellSliceContext(cellId, slice, Array.Empty())); + new RetailPViewCellSliceContext( + cellId, + slice, + NoParticleOwners)); } } @@ -795,13 +871,51 @@ public sealed class RetailPViewRenderer private void DrawDynamicsLast( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, - InteriorEntityPartition.Result partition, + InteriorEntityPartition.Result? partition, ViewconeCuller viewcone, bool rootIsOutdoor, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView) { - if (partition.Dynamics.Count == 0) + if (partition is null) + { + RenderFrameRouteOwnerSelector.Replace( + _dynamicParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.DynamicLast, + 0, + 0); + + passes.UseIndoorMembershipOnlyRouting(); + DrawEntityRouteOrLegacy( + ctx, + passes, + frameEntityPasses, + in frameView, + RenderFrameCandidateRoute.DynamicLast, + 0, + 0, + Array.Empty(), + visibleCellIds: null); + + // An owner routed through any pre-clear outside slice already had + // its alpha particles drawn there. Meshes may be submitted in both + // stages, but particles must be emitted exactly once. + RenderFrameRouteOwnerSelector.ExceptRoute( + _dynamicParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.LandscapeOutsideDynamic); + if (_dynamicParticleOwnerScratch.Count > 0) + { + passes.DrawDynamicsParticles( + ctx, + _dynamicParticleOwnerScratch); + } + return; + } + + if (partition.Dynamics.Count == 0 + && frameEntityPasses is null) return; _dynamicsScratch.Clear(); @@ -837,14 +951,18 @@ public sealed class RetailPViewRenderer _dynamicsScratch.Add(e); } - if (_dynamicsScratch.Count == 0) + if (_dynamicsScratch.Count == 0 + && frameEntityPasses is null) return; - _candidateObserver?.ObservePViewBucket( - CurrentRenderPViewRoute.DynamicLast, - 0, - 0, - _dynamicsScratch); + if (_dynamicsScratch.Count > 0) + { + _candidateObserver?.ObservePViewBucket( + CurrentRenderPViewRoute.DynamicLast, + 0, + 0, + _dynamicsScratch); + } passes.UseIndoorMembershipOnlyRouting(); DrawEntityRouteOrLegacy( ctx, @@ -867,12 +985,28 @@ public sealed class RetailPViewRenderer // portals went invisible. Outside-stage dynamics are excluded here: // their emitters already drew in the landscape slice (alpha-blended // particles must not double-draw, unlike the depth-idempotent meshes). - _dynamicsParticleScratch.Clear(); - foreach (var e in _dynamicsScratch) - if (!_outsideStageDynamics.Contains(e)) - _dynamicsParticleScratch.Add(e); - if (_dynamicsParticleScratch.Count > 0) - passes.DrawDynamicsParticles(ctx, _dynamicsParticleScratch); + if (frameEntityPasses is not null) + { + RenderFrameRouteOwnerSelector.Replace( + _dynamicParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.DynamicLast, + 0, + 0); + RenderFrameRouteOwnerSelector.ExceptRoute( + _dynamicParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.LandscapeOutsideDynamic); + } + else + { + _dynamicParticleOwnerScratch.Clear(); + foreach (var e in _dynamicsScratch) + if (!_outsideStageDynamics.Contains(e)) + _dynamicParticleOwnerScratch.Add(e.Id); + } + if (_dynamicParticleOwnerScratch.Count > 0) + passes.DrawDynamicsParticles(ctx, _dynamicParticleOwnerScratch); } private void DrawCellObjectLists( @@ -881,11 +1015,40 @@ public sealed class RetailPViewRenderer PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, - InteriorEntityPartition.Result partition, + InteriorEntityPartition.Result? partition, ViewconeCuller viewcone, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView) { + if (partition is null) + { + RenderFrameRouteOwnerSelector.Replace( + _cellParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.CellStatic, + 0, + 0); + + passes.UseIndoorMembershipOnlyRouting(); + DrawEntityRouteOrLegacy( + ctx, + passes, + frameEntityPasses, + in frameView, + RenderFrameCandidateRoute.CellStatic, + 0, + 0, + Array.Empty(), + visibleCellIds: null); + passes.DrawCellParticles( + ctx, + new RetailPViewCellSliceContext( + 0u, + NoClipSlice, + _cellParticleOwnerScratch)); + return; + } + // T1: per-cell STATIC object lists only (dat-baked 0x40 statics) — // dynamics moved to DrawDynamicsLast. Far→near with the cells, after // the shells (retail DrawCells epilogue: PortalList = cell's views → @@ -940,7 +1103,8 @@ public sealed class RetailPViewRenderer // draw in DrawDynamicsLast. T3 (BR-5): each static was sphere-tested against // ITS cell's views above (the statics-through-walls fix is preserved by the // cull; only the draw is batched). - if (_allCellStatics.Count > 0) + if (frameEntityPasses is not null + || _allCellStatics.Count > 0) { _candidateObserver?.ObservePViewBucket( CurrentRenderPViewRoute.CellStatic, @@ -974,10 +1138,31 @@ public sealed class RetailPViewRenderer // the buffer (the statics-before-particles order). cellId/slice are unused // by the particle pass — pass NoClipSlice + the union owner list. This also // drops the per-cell BuildDrawList allocations (N → 1). - if (_allCellStatics.Count > 0) + if (frameEntityPasses is not null + || _allCellStatics.Count > 0) + { + if (frameEntityPasses is not null) + { + RenderFrameRouteOwnerSelector.Replace( + _cellParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.CellStatic, + 0, + 0); + } + else + { + ReplaceOwnerIds( + _cellParticleOwnerScratch, + _allCellStatics); + } passes.DrawCellParticles( ctx, - new RetailPViewCellSliceContext(0u, NoClipSlice, _allCellStatics)); + new RetailPViewCellSliceContext( + 0u, + NoClipSlice, + _cellParticleOwnerScratch)); + } } private static void DrawEntityRouteOrLegacy( @@ -1016,9 +1201,6 @@ public sealed class RetailPViewRenderer // #118: dynamics assigned to the OUTSIDE stage this frame (interior roots // only) — outdoor-classified + exit-portal straddlers. Cleared per frame. private readonly List _outsideStageDynamics = new(); - // #121: cone-surviving dynamics whose emitters draw in the dynamics - // particle pass (survivors minus outside-stage). Cleared per use. - private readonly List _dynamicsParticleScratch = new(); // Dense-town FPS iteration-1 (cellobject batching): all visible cells' // viewcone-surviving statics accumulated for ONE batched DrawEntityBucket, // plus the union of their cell ids for the dispatcher's visibleCellIds gate. @@ -1026,6 +1208,70 @@ public sealed class RetailPViewRenderer private readonly List _allCellStatics = new(); private readonly HashSet _cellObjCells = new(); + private bool LegacyPartitionDiagnosticsEnabled => + _partitionObserver is not null + || AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled + || AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled + || AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled + || AcDream.App.Streaming.EntityVanishProbe.Enabled; + + internal static RenderFrameDiagnosticCounts LegacyDiagnosticCounts( + InteriorEntityPartition.Result partition) + { + int cellStaticCount = 0; + foreach (List bucket in partition.ByCell.Values) + cellStaticCount = checked(cellStaticCount + bucket.Count); + + return new RenderFrameDiagnosticCounts( + partition.OutdoorStatic.Count, + cellStaticCount, + partition.Dynamics.Count, + TransformCount: 0, + OpaqueClassificationCount: 0, + AlphaClassificationCount: 0, + LightSetCount: 0, + SelectionPartCount: 0, + RouteCandidateCount: + checked( + partition.OutdoorStatic.Count + + cellStaticCount + + partition.Dynamics.Count), + EntityCandidateCount: 0, + MeshPartCount: 0); + } + + internal static RenderProjectionCounts LegacySourceCounts( + InteriorEntityPartition.Result partition) + { + int cellStaticCount = 0; + foreach (List bucket in partition.ByCell.Values) + cellStaticCount = checked(cellStaticCount + bucket.Count); + int total = checked( + partition.OutdoorStatic.Count + + cellStaticCount + + partition.Dynamics.Count); + return new RenderProjectionCounts( + total, + partition.OutdoorStatic.Count, + cellStaticCount, + partition.Dynamics.Count, + ActiveAnimatedStatic: 0, + EquippedChild: 0); + } + + private static void ReplaceOwnerIds( + HashSet destination, + IReadOnlyList entities) + { + destination.Clear(); + for (int index = 0; index < entities.Count; index++) + { + uint localEntityId = entities[index].Id; + if (localEntityId != 0) + destination.Add(localEntityId); + } + } + /// /// #118 stage assignment for a dynamic under an INTERIOR root: does it draw /// in the OUTSIDE (landscape) stage — before the gated depth clear and the @@ -1153,7 +1399,7 @@ public interface IRetailPViewPassExecutor void DrawUnattachedSceneParticles(RetailPViewFrameInput frame); void FlushLandscapeAlpha(); void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); - void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlyList survivors); + void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet ownerIds); void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result); } @@ -1428,20 +1674,42 @@ public sealed class RetailPViewFrameResult public PortalVisibilityFrame PortalFrame { get; private set; } = null!; public ClipFrameAssembly ClipAssembly { get; private set; } = null!; public HashSet DrawableCells { get; private set; } = null!; - public InteriorEntityPartition.Result Partition { get; private set; } = null!; + internal RenderFrameDiagnosticCounts DiagnosticCounts { get; private set; } + internal RenderProjectionCounts SourceCounts { get; private set; } + internal InteriorEntityPartition.Result? DiagnosticPartition + { get; private set; } internal RetailPViewFrameResult Reset( PortalVisibilityFrame portalFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, - InteriorEntityPartition.Result partition) + RenderFrameDiagnosticCounts diagnosticCounts, + RenderProjectionCounts sourceCounts, + InteriorEntityPartition.Result? diagnosticPartition) { PortalFrame = portalFrame; ClipAssembly = clipAssembly; DrawableCells = drawableCells; - Partition = partition; + DiagnosticCounts = diagnosticCounts; + SourceCounts = sourceCounts; + DiagnosticPartition = diagnosticPartition; return this; } + + internal RetailPViewFrameResult Reset( + PortalVisibilityFrame portalFrame, + ClipFrameAssembly clipAssembly, + HashSet drawableCells, + InteriorEntityPartition.Result diagnosticPartition) => + Reset( + portalFrame, + clipAssembly, + drawableCells, + RetailPViewRenderer.LegacyDiagnosticCounts( + diagnosticPartition), + RetailPViewRenderer.LegacySourceCounts( + diagnosticPartition), + diagnosticPartition); } public readonly record struct RetailPViewLandscapeSliceContext( @@ -1457,7 +1725,7 @@ public readonly record struct RetailPViewLandscapeSliceContext( public readonly record struct RetailPViewLandscapeLateSliceContext( ClipViewSlice Slice, IReadOnlyList Dynamics, - IReadOnlyList ParticleOwners) + IReadOnlySet ParticleOwnerIds) { internal RenderFrameEntityDrawRequest? EntityDraw { get; init; } } @@ -1465,4 +1733,4 @@ public readonly record struct RetailPViewLandscapeLateSliceContext( public readonly record struct RetailPViewCellSliceContext( uint CellId, ClipViewSlice Slice, - IReadOnlyList CellEntities); + IReadOnlySet ParticleOwnerIds); diff --git a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs index 04d245cb..28e1c978 100644 --- a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs +++ b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs @@ -157,7 +157,7 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics pviewResult?.PortalFrame, pviewResult?.ClipAssembly, pviewResult?.DrawableCells, - pviewResult?.Partition, + pviewResult?.DiagnosticPartition, exteriorPortalFrame: null, exteriorClipAssembly: null, exteriorDrawableCells: null, diff --git a/src/AcDream.App/Rendering/WorldSceneRenderer.cs b/src/AcDream.App/Rendering/WorldSceneRenderer.cs index 39a38d34..3a5f1d47 100644 --- a/src/AcDream.App/Rendering/WorldSceneRenderer.cs +++ b/src/AcDream.App/Rendering/WorldSceneRenderer.cs @@ -1,3 +1,4 @@ +using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Selection; using AcDream.App.Rendering.Vfx; using AcDream.App.Streaming; @@ -218,11 +219,15 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase // outdoor roots whose PView executor does not issue an interior // depth clear. depthClear = hasOutsideSlice; - if (pviewResult.Partition.ByCell.Count > 0 || hasOutsideSlice) + RenderProjectionCounts sourceCounts = + pviewResult.SourceCounts; + if (sourceCounts.IndoorCellStatic > 0 || hasOutsideSlice) sceneParticles = "pviewScoped"; - outdoorSceneryDrawn = pviewResult.Partition.OutdoorStatic.Count > 0 + outdoorSceneryDrawn = + sourceCounts.OutdoorStatic > 0 && hasOutsideSlice; - liveDynamicDrawnCount = pviewResult.Partition.Dynamics.Count; + liveDynamicDrawnCount = + sourceCounts.LiveDynamicRoot; } else { diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index cf313e09..ac1047f6 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -310,6 +310,34 @@ public sealed class RetailPViewPassExecutorTests "passes.DrawCellParticles(ctx"); } + [Fact] + public void Production_uses_retained_routes_without_rebuilding_legacy_partition() + { + string source = RendererSource(); + string drawInside = MethodBody( + source, + "public RetailPViewFrameResult DrawInside("); + + Assert.Contains( + "if (_sceneFrameProduct is null || LegacyPartitionDiagnosticsEnabled)", + drawInside, + StringComparison.Ordinal); + AssertAppearsInOrder( + drawInside, + "_sceneFrameProduct.BuildAndBorrow(", + "if (_sceneFrameProduct is null || LegacyPartitionDiagnosticsEnabled)", + "InteriorEntityPartition.Partition("); + + Assert.Contains( + "RenderFrameRouteOwnerSelector.Replace(", + source, + StringComparison.Ordinal); + Assert.Contains( + "RenderFrameRouteOwnerSelector.ExceptRoute(", + source, + StringComparison.Ordinal); + } + [Fact] public void Extracted_contracts_retain_no_window_callbacks_or_visibility_owner() { @@ -698,7 +726,10 @@ public sealed class RetailPViewPassExecutorTests public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame) => Operations.Add("unattached-particles"); public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha"); public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles"); - public void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlyList survivors) => Operations.Add("dynamics-particles"); + public void DrawDynamicsParticles( + RetailPViewFrameInput frame, + IReadOnlySet ownerIds) => + Operations.Add("dynamics-particles"); public void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result) => Operations.Add("diagnostics"); public void BeginEntityFrame(in RenderFrameView view) =>