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); }