From f3a03efc81969660a38403e04682f5815e0d8f61 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 30 Aug 2026 19:37:18 +0200 Subject: [PATCH] fix(render): FW4 slice 4 - straddler particles emit pre-clear, once The cathedral falls survived slices 2 and 3; the [walk-dyn] dump then pinned the real gate: the falls emitters are interior-parented exit-plane STRADDLERS that pass BOTH the outside-stage predicate and the viewcone (outside=1 cone=1) - they were in the pre-clear union all along, and the deliberate ExceptRoute(DynamicLast) stripped them so they emitted in the post-clear last pass instead ("straddlers emit HERE"). That placement protects an indoor flame from interior repaint, but a straddling emitter whose particles hang OUTSIDE the exit portal has no depth left to occlude them after the clear - they splat across terrain and water. Retail submits an emitter''s polys at its object''s FIRST draw - the landscape stage for anything overlapping outdoor shadow cells - and drains them at the pre-clear boundary flush over true landscape depth. Under an interior root the late-union now keeps the outside-stage owners (straddlers included), records them, and BOTH last-pass arms subtract the recorded set so every owner emits exactly once. Outdoor roots keep the old except-at-union placement: both stages drain at the same final flush there, so the last-pass emission is already correct and a pre-emission would double-composite. Hermetic 6,762/0. Co-Authored-By: Claude Fable 5 --- .../Rendering/RetailPViewRenderer.cs | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index d47d5778..f50776a4 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -71,6 +71,16 @@ public sealed class RetailPViewRenderer private readonly HashSet _cellParticleOwnerScratch = new(); private readonly HashSet _dynamicParticleOwnerScratch = new(); + // FW4 slice 4: owners whose particles ALREADY submitted in the + // pre-clear outside stage this frame (interior roots only). The last + // pass subtracts them — retail submits an emitter's polys at its + // object's FIRST draw (the landscape stage for anything overlapping + // outdoor shadow cells) and drains them at the pre-clear boundary + // flush over true landscape depth; a straddler re-emitted post-clear + // has no outdoor depth left to occlude it (the cathedral falls + // shine-through, fourth surface). + private readonly HashSet _preClearParticleOwnerScratch = 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 // + 2 Lists, plus one List per visible cell) every DrawInside @@ -155,6 +165,7 @@ public sealed class RetailPViewRenderer passes.BeginFrame(); RecycleLookInFrames(); ResetBuildingGroups(); + _preClearParticleOwnerScratch.Clear(); var pvFrame = PortalVisibilityBuilder.Build( ctx.RootCell, @@ -1493,13 +1504,26 @@ public sealed class RetailPViewRenderer }); } - // Late-particle union submission — DynamicLast owners excluded, same - // as DrawLandscapeThroughOutsideView's own final submission. - if (frameEntityPasses is not null) + // Late-particle union submission. OUTDOOR root: DynamicLast owners + // excluded (both stages drain at the same final flush there — a + // duplicate submission would double-composite), matching + // DrawLandscapeThroughOutsideView's own final submission. INTERIOR + // root (FW4 slice 4): the outside-stage owners — exit-plane + // STRADDLERS included — submit HERE, pre-clear, and the last pass + // subtracts them instead: retail submits an emitter's polys at its + // object's FIRST draw (the landscape stage) and drains them at the + // pre-clear boundary flush over true landscape depth; the former + // except-here/emit-last placement left a straddler's outside half + // with no depth to occlude it after the clear (the cathedral falls + // bleeding through terrain and water, probe-pinned: + // outside=1 cone=1 yet phase=post). + if (frameEntityPasses is not null && ctx.RootCell.IsOutdoorNode) { RenderFrameRouteOwnerSelector.ExceptRoute( _staticParticleUnionScratch, in frameView, RenderFrameCandidateRoute.DynamicLast); } + if (!ctx.RootCell.IsOutdoorNode) + _preClearParticleOwnerScratch.UnionWith(_staticParticleUnionScratch); if (_staticParticleUnionScratch.Count > 0) { passes.DrawLandscapeStaticParticles( @@ -2157,6 +2181,10 @@ public sealed class RetailPViewRenderer RenderFrameCandidateRoute.DynamicLast, 0, 0); + // FW4 slice 4: owners already emitted pre-clear (the outside + // stage's late union — straddlers included) emit ONCE; see + // _preClearParticleOwnerScratch's field comment. + _dynamicParticleOwnerScratch.ExceptWith(_preClearParticleOwnerScratch); passes.UseIndoorMembershipOnlyRouting(); DrawEntityRouteOrLegacy( @@ -2291,6 +2319,9 @@ public sealed class RetailPViewRenderer if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId)) _dynamicParticleOwnerScratch.Add(e.Id); } + // FW4 slice 4: pre-clear-emitted owners (straddlers) emit once — + // see _preClearParticleOwnerScratch's field comment. + _dynamicParticleOwnerScratch.ExceptWith(_preClearParticleOwnerScratch); if (_dynamicParticleOwnerScratch.Count > 0) passes.DrawDynamicsParticles(ctx, _dynamicParticleOwnerScratch); }