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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-30 19:37:18 +02:00
parent 5f7ccdeab5
commit f3a03efc81

View file

@ -71,6 +71,16 @@ public sealed class RetailPViewRenderer
private readonly HashSet<uint> _cellParticleOwnerScratch = new(); private readonly HashSet<uint> _cellParticleOwnerScratch = new();
private readonly HashSet<uint> _dynamicParticleOwnerScratch = new(); private readonly HashSet<uint> _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<uint> _preClearParticleOwnerScratch = new();
// MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/ // MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/
// Dynamics), reused across frames instead of `new`ing a Result (a Dictionary // Dynamics), reused across frames instead of `new`ing a Result (a Dictionary
// + 2 Lists, plus one List<WorldEntity> per visible cell) every DrawInside // + 2 Lists, plus one List<WorldEntity> per visible cell) every DrawInside
@ -155,6 +165,7 @@ public sealed class RetailPViewRenderer
passes.BeginFrame(); passes.BeginFrame();
RecycleLookInFrames(); RecycleLookInFrames();
ResetBuildingGroups(); ResetBuildingGroups();
_preClearParticleOwnerScratch.Clear();
var pvFrame = PortalVisibilityBuilder.Build( var pvFrame = PortalVisibilityBuilder.Build(
ctx.RootCell, ctx.RootCell,
@ -1493,13 +1504,26 @@ public sealed class RetailPViewRenderer
}); });
} }
// Late-particle union submission — DynamicLast owners excluded, same // Late-particle union submission. OUTDOOR root: DynamicLast owners
// as DrawLandscapeThroughOutsideView's own final submission. // excluded (both stages drain at the same final flush there — a
if (frameEntityPasses is not null) // 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( RenderFrameRouteOwnerSelector.ExceptRoute(
_staticParticleUnionScratch, in frameView, RenderFrameCandidateRoute.DynamicLast); _staticParticleUnionScratch, in frameView, RenderFrameCandidateRoute.DynamicLast);
} }
if (!ctx.RootCell.IsOutdoorNode)
_preClearParticleOwnerScratch.UnionWith(_staticParticleUnionScratch);
if (_staticParticleUnionScratch.Count > 0) if (_staticParticleUnionScratch.Count > 0)
{ {
passes.DrawLandscapeStaticParticles( passes.DrawLandscapeStaticParticles(
@ -2157,6 +2181,10 @@ public sealed class RetailPViewRenderer
RenderFrameCandidateRoute.DynamicLast, RenderFrameCandidateRoute.DynamicLast,
0, 0,
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(); passes.UseIndoorMembershipOnlyRouting();
DrawEntityRouteOrLegacy( DrawEntityRouteOrLegacy(
@ -2291,6 +2319,9 @@ public sealed class RetailPViewRenderer
if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId)) if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
_dynamicParticleOwnerScratch.Add(e.Id); _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) if (_dynamicParticleOwnerScratch.Count > 0)
passes.DrawDynamicsParticles(ctx, _dynamicParticleOwnerScratch); passes.DrawDynamicsParticles(ctx, _dynamicParticleOwnerScratch);
} }