fix(render): drawn-once look-in dynamics + farther-than-building alpha drains

Two retail-parity corrections on top of 684380d4, user-gated together
(no regressions; door/creature-through-opening rendering verified live
at Holtburg; cathedral waterfall and look-north casts pass):

1. Drawn-once look-in dynamics. Retail marks every drawn non-player
part for the frame (DrawMeshInternal @0x0059F360 GetDrawnThisFrame), so
an object draws once, with its cell. acdream drew a look-in cell's
dynamics twice under an outdoor root - once correctly with the look-in,
then again in dynamics-last after the boundary alpha drain, where the
second draw overpainted nearer flames. Both the accepted path
(_lookInCellIds) and the frame product (BuildDynamicLastRoute) now
exclude dynamics whose parent cell drew as a look-in.

2. Pre/inter-building barriers drain only content FARTHER than the
building they precede (FlushLandscapeAlphaFartherThan +
RetailAlphaQueue.FlushFartherThan + conservative anchor-origin
threshold). Retail's far-to-near walk guarantees a building's
FlushAlphaList(0f) @0x0059F2A0 has only farther content queued; a
nearer emitter composites at a later flush. AP-236 documents the
remaining barrier-order divergence.

The #132 candle-before-door overdraw is NOT yet fixed by these steps
and stays open: the current suspect is that houses without a
constructed look-in still draw their interior-parented door in
dynamics-last after the outdoor candle's boundary drain (see the
2026-08-29 ledger for the retail flush-after-objects hypothesis).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-29 16:06:04 +02:00
parent 684380d421
commit fc30285fd7
3 changed files with 84 additions and 13 deletions

View file

@ -34,6 +34,13 @@ public sealed class RetailPViewRenderer
private readonly HashSet<uint> _staticParticleUnionScratch = new(); private readonly HashSet<uint> _staticParticleUnionScratch = new();
private readonly HashSet<uint> _cellParticleUnionScratch = new(); private readonly HashSet<uint> _cellParticleUnionScratch = new();
// Every cell drawn as a building look-in this frame. Retail marks each
// drawn non-player part for the frame (DrawMeshInternal @0x0059F360,
// GetDrawnThisFrame), so an object whose cell drew with a look-in cannot
// draw again in a later pass; dynamics-last consults this set to honor
// the same drawn-once contract.
private readonly HashSet<uint> _lookInCellIds = new();
private readonly HashSet<uint> _oneCell = new(1); private readonly HashSet<uint> _oneCell = new(1);
// Shell-batch scratch: all of a pass's cells collected for ONE batched // Shell-batch scratch: all of a pass's cells collected for ONE batched
// opaque Render call (instead of one heavy Render per cell). Reused across // opaque Render call (instead of one heavy Render per cell). Reused across
@ -172,13 +179,19 @@ public sealed class RetailPViewRenderer
// contains them). drawableCells itself stays the MAIN flood: it feeds the // contains them). drawableCells itself stays the MAIN flood: it feeds the
// seals, the outside-stage predicate, and the frame result. // seals, the outside-stage predicate, and the frame result.
var prepareCells = drawableCells; var prepareCells = drawableCells;
_lookInCellIds.Clear();
if (_lookInFrames.Count > 0) if (_lookInFrames.Count > 0)
{ {
_lookInPrepareScratch.Clear(); _lookInPrepareScratch.Clear();
_lookInPrepareScratch.UnionWith(drawableCells); _lookInPrepareScratch.UnionWith(drawableCells);
foreach (var f in _lookInFrames) foreach (var f in _lookInFrames)
{
foreach (uint c in f.OrderedVisibleCells) foreach (uint c in f.OrderedVisibleCells)
{
_lookInPrepareScratch.Add(c); _lookInPrepareScratch.Add(c);
_lookInCellIds.Add(c);
}
}
prepareCells = _lookInPrepareScratch; prepareCells = _lookInPrepareScratch;
} }
@ -532,12 +545,22 @@ public sealed class RetailPViewRenderer
{ {
PortalVisibilityFrame frame = _lookInFrames[frameIndex]; PortalVisibilityFrame frame = _lookInFrames[frameIndex];
// Retail enters DrawBuilding once per building and drains every // Retail enters DrawBuilding once per building and drains the
// alpha submission accumulated by the preceding building before // alpha accumulated by the preceding building before punching the
// punching the next building's portals. The first building uses // next building's portals — and because retail's far→near walk
// the pre-look-in barrier in DrawLandscapeThroughOutsideView. // has only inserted FARTHER content by then, that drain can never
// composite an emitter nearer than this building
// (FlushAlphaList(0f) @0x0059F2A0 under the walk; AP-236).
// The first building uses the pre-look-in barrier in
// DrawLandscapeThroughOutsideView.
if (frameIndex > 0) if (frameIndex > 0)
passes.FlushLandscapeAlpha(); {
passes.FlushLandscapeAlphaFartherThan(
LookInBarrierDrainDistance(
frame,
ctx.Cells,
ctx.CameraWorldPosition));
}
// Pass 1: far-Z punch every aperture of this building. // Pass 1: far-Z punch every aperture of this building.
foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals) foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals)
@ -910,13 +933,25 @@ public sealed class RetailPViewRenderer
} }
// ONE unclipped submission for the union of every slice's cone // ONE unclipped submission for the union of every slice's cone
// survivors, then retail's pre-building barrier flush. // survivors, then retail's pre-building barrier drain. Under
// retail's far→near walk, DrawBuilding's FlushAlphaList(0f)
// @0x0059F2A0 can only ever flush content from cells FARTHER
// than the building it precedes — a nearer emitter (the Holtburg
// candle in front of a door) has not been inserted yet and
// composites at a later flush, after that building's opaques.
// Drain the far prefix only; nearer entries stay queued for the
// DrawCells-boundary flush, which runs after the late dynamics
// (AP-236 retirement).
passes.DrawLandscapeStaticParticles( passes.DrawLandscapeStaticParticles(
ctx, ctx,
new RetailPViewLandscapeStaticParticleContext( new RetailPViewLandscapeStaticParticleContext(
_staticParticleUnionScratch)); _staticParticleUnionScratch));
_staticParticleUnionScratch.Clear(); _staticParticleUnionScratch.Clear();
passes.FlushLandscapeAlpha(); passes.FlushLandscapeAlphaFartherThan(
LookInBarrierDrainDistance(
_lookInFrames[0],
ctx.Cells,
ctx.CameraWorldPosition));
} }
// #124: far-building look-ins draw HERE — still inside the landscape // #124: far-building look-ins draw HERE — still inside the landscape
@ -1283,6 +1318,14 @@ public sealed class RetailPViewRenderer
$"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)"); $"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)");
continue; continue;
} }
// Drawn-once (retail DrawMeshInternal @0x0059F360 marks every
// non-player part for the frame): a dynamic whose cell drew as a
// building LOOK-IN already rendered with that cell inside the
// landscape stage (#131). Redrawing it here would land AFTER the
// boundary alpha drain and overpaint nearer flames — the Holtburg
// door repainting the candle in front of it.
if (indoor && _lookInCellIds.Contains(e.ParentCellId!.Value))
continue;
bool visible = indoor bool visible = indoor
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r) ? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
: viewcone.SphereVisibleOutside(c, r); : viewcone.SphereVisibleOutside(c, r);
@ -1329,16 +1372,18 @@ public sealed class RetailPViewRenderer
// particles must not double-draw, unlike the depth-idempotent meshes). // particles must not double-draw, unlike the depth-idempotent meshes).
if (frameEntityPasses is not null) if (frameEntityPasses is not null)
{ {
// Parent-cell stage split: every DynamicLast owner emits its
// particles here. Pure-outdoor dynamics are absent from this
// route (outside stage only), and interior straddlers — whose
// meshes drew in both stages — must emit HERE so the interior
// stage cannot repaint over them (matches the production
// partition-null path above).
RenderFrameRouteOwnerSelector.Replace( RenderFrameRouteOwnerSelector.Replace(
_dynamicParticleOwnerScratch, _dynamicParticleOwnerScratch,
in frameView, in frameView,
RenderFrameCandidateRoute.DynamicLast, RenderFrameCandidateRoute.DynamicLast,
0, 0,
0); 0);
RenderFrameRouteOwnerSelector.ExceptRoute(
_dynamicParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutsideDynamic);
} }
else else
{ {

View file

@ -1128,6 +1128,12 @@ internal sealed class RenderScenePViewFrameBuilder
private RenderProjectionRecord[] _cell = []; private RenderProjectionRecord[] _cell = [];
private RenderProjectionRecord[] _dirty = []; private RenderProjectionRecord[] _dirty = [];
private RenderProjectionRecord[] _survivors = []; private RenderProjectionRecord[] _survivors = [];
// Cells drawn as building look-ins this frame — the DynamicLast route
// honors retail's drawn-once contract (DrawMeshInternal @0x0059F360
// marks every non-player part): an object whose cell drew with a look-in
// must not enter the final dynamics route again.
private readonly HashSet<uint> _lookInCellScratch = new();
private RenderProjectionRecord[] _cellRoute = []; private RenderProjectionRecord[] _cellRoute = [];
private readonly Dictionary<RenderProjectionId, int> private readonly Dictionary<RenderProjectionId, int>
_outdoorPositions = []; _outdoorPositions = [];
@ -1528,6 +1534,16 @@ internal sealed class RenderScenePViewFrameBuilder
RenderFrameWriter writer, RenderFrameWriter writer,
in RenderScenePViewBuildInput input) in RenderScenePViewBuildInput input)
{ {
_lookInCellScratch.Clear();
for (int frameIndex = 0;
frameIndex < input.LookInFrames.Count;
frameIndex++)
{
PortalVisibilityFrame frame = input.LookInFrames[frameIndex];
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
_lookInCellScratch.Add(frame.OrderedVisibleCells[i]);
}
int count = 0; int count = 0;
EnsureCapacity(ref _survivors, _dynamicCount); EnsureCapacity(ref _survivors, _dynamicCount);
for (int i = 0; i < _dynamicCount; i++) for (int i = 0; i < _dynamicCount; i++)
@ -1539,6 +1555,14 @@ internal sealed class RenderScenePViewFrameBuilder
if (!input.RootIsOutdoor && !indoor) if (!input.RootIsOutdoor && !indoor)
continue; continue;
// Drawn-once (retail DrawMeshInternal @0x0059F360): a dynamic
// whose cell drew as a building look-in already rendered with
// that cell in the landscape stage; re-entering the final route
// would draw it after the boundary alpha drain and overpaint
// nearer flames (the Holtburg candle-behind-door class).
if (indoor && _lookInCellScratch.Contains(parentCellId!.Value))
continue;
Sphere(in record, out Vector3 center, out float radius); Sphere(in record, out Vector3 center, out float radius);
bool visible = indoor bool visible = indoor
? input.Viewcone.SphereVisibleInCell( ? input.Viewcone.SphereVisibleInCell(

View file

@ -306,7 +306,7 @@ public sealed class RetailPViewPassExecutorTests
"landscape-early", "landscape-early",
"unattached-particles-outdoor", "unattached-particles-outdoor",
"landscape-static-particles", "landscape-static-particles",
"landscape-alpha", "landscape-alpha-farther",
"look-in-punch", "look-in-punch",
"landscape-late", "landscape-late",
"landscape-alpha", "landscape-alpha",
@ -380,7 +380,7 @@ public sealed class RetailPViewPassExecutorTests
"look-in-punch", "look-in-punch",
"landscape-building-shell", "landscape-building-shell",
"landscape-static-particles", "landscape-static-particles",
"landscape-alpha", "landscape-alpha-farther",
"look-in-punch", "look-in-punch",
"landscape-building-shell"); "landscape-building-shell");
} }
@ -795,6 +795,8 @@ public sealed class RetailPViewPassExecutorTests
? "unattached-particles-outdoor" ? "unattached-particles-outdoor"
: "unattached-particles-interior"); : "unattached-particles-interior");
public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha"); public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha");
public void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
Operations.Add("landscape-alpha-farther");
public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles"); public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles");
public void DrawDynamicsParticles( public void DrawDynamicsParticles(
RetailPViewFrameInput frame, RetailPViewFrameInput frame,