fix(render): particles draw unclipped, once, in their retail stage
Retail never clips a particle to a portal view: each emitter's polys
join the ONE alpha list during its owner cell's far-to-near walk turn
(LScape::draw @0x00506330 iterates block_draw_list reversed; DrawBlock
@0x005A17C0 walks cells; ShouldDrawParticles @0x0050FE60 gates by cell
and distance), and occlusion is the depth test at FlushAlphaList
@0x0059D2E0 (its float is a COUNT threshold - 0f = flush all). The
1d2f2f73 architecture instead re-submitted particles once per
OutsideView slice under that slice's hardware clip slot, which cut
effects at aperture boundaries and drew nothing when no outside slice
was in view (the cathedral look-north disappearance).
Now: unattached emitters submit once per frame by owner-cell kind
(outdoor landcells in the landscape stage, interior EnvCells in the
final world scope - new UnattachedEmitterCellScope filter); cell,
shell-route, barrier-static, and late-stage owners submit their
per-slice cone-cull UNION once with clipSlot 0; and particles emit in
the stage matching their PARENT CELL - an interior dynamic whose
sphere straddles an exit-portal plane keeps its mesh in both stages
(#118) but its particles move to the final pass, so the interior
stage can no longer repaint over them (the aperture-band star cut).
Also lands the inert Change-2 primitives for the AP-236 retirement
(candle-behind-door): RetailAlphaQueue.FlushFartherThan drains only
the far prefix without resetting sources, plus the executor
passthrough and the conservative look-in threshold helper - nothing
calls them yet.
User-gated 2026-08-29 round 2 at the Sanctuary Cathedral: spell and
recall stars cover the whole room at every camera direction including
north; waterfall containment holds on retail's depth/seal mechanism;
adjacent-room particles/lights, walls, Holtburg, recall unregressed
(paperdoll remains pre-existing intermittent #443). Register: AP-236
filed for the remaining barrier-order divergence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
85530c0b7e
commit
684380d421
10 changed files with 518 additions and 85 deletions
|
|
@ -28,6 +28,12 @@ public sealed class RetailPViewRenderer
|
|||
private static readonly IReadOnlySet<uint> NoParticleOwners =
|
||||
new HashSet<uint>();
|
||||
|
||||
// Frame unions for the once-per-frame particle submissions (retail: one
|
||||
// unclipped alpha-list insertion per emitter; occlusion by depth at the
|
||||
// flush). Per-slice owner culls still run — these accumulate their union.
|
||||
private readonly HashSet<uint> _staticParticleUnionScratch = new();
|
||||
private readonly HashSet<uint> _cellParticleUnionScratch = new();
|
||||
|
||||
private readonly HashSet<uint> _oneCell = new(1);
|
||||
// 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
|
||||
|
|
@ -320,6 +326,13 @@ public sealed class RetailPViewRenderer
|
|||
frameEntityPasses,
|
||||
in frameView);
|
||||
|
||||
// Interior-cell UNATTACHED emitters (spell ground effects and
|
||||
// swirls anchored in EnvCells) draw in this final world scope —
|
||||
// the cells' walls and the seals already own the depth buffer, so
|
||||
// one unclipped submission matches retail's cell-walk insertion.
|
||||
// Outdoor-cell unattached emitters drew in the landscape stage.
|
||||
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: false);
|
||||
|
||||
if (entityFrameOpen)
|
||||
{
|
||||
frameEntityPasses!.CompleteEntityFrame(in frameView);
|
||||
|
|
@ -431,6 +444,37 @@ public sealed class RetailPViewRenderer
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conservative barrier drain threshold for one look-in frame: the viewer
|
||||
/// distance to the frame's nearest anchor-cell ORIGIN. Cell origins sit
|
||||
/// inside the building, so this over-estimates the building's
|
||||
/// nearest-point distance and under-drains; anything conservatively
|
||||
/// retained still composites correctly at the later depth-tested drains.
|
||||
/// Retail needs no threshold — its far→near walk guarantees only farther
|
||||
/// content is queued when DrawBuilding flushes (@0x0059F2A0). Returns 0
|
||||
/// (full drain, today's behavior) when no cell resolves.
|
||||
/// </summary>
|
||||
internal static float LookInBarrierDrainDistance(
|
||||
PortalVisibilityFrame frame,
|
||||
IRetailPViewCellSource cells,
|
||||
Vector3 viewerPosition)
|
||||
{
|
||||
float best = float.PositiveInfinity;
|
||||
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
|
||||
{
|
||||
LoadedCell? cell = cells.Find(frame.OrderedVisibleCells[i]);
|
||||
if (cell is null)
|
||||
continue;
|
||||
float distance = Vector3.Distance(
|
||||
cell.WorldTransform.Translation,
|
||||
viewerPosition);
|
||||
if (distance < best)
|
||||
best = distance;
|
||||
}
|
||||
|
||||
return float.IsFinite(best) ? best : 0f;
|
||||
}
|
||||
|
||||
private void RecycleLookInFrames()
|
||||
{
|
||||
for (int i = 0; i < _lookInFrames.Count; i++)
|
||||
|
|
@ -553,6 +597,8 @@ public sealed class RetailPViewRenderer
|
|||
_cellStaticScratch.Add(e);
|
||||
}
|
||||
|
||||
bool cellDrewObjects = false;
|
||||
_cellParticleUnionScratch.Clear();
|
||||
foreach (ClipViewSlice slice in cellSlices)
|
||||
{
|
||||
int routeIndex = lookInRouteIndex++;
|
||||
|
|
@ -598,12 +644,21 @@ public sealed class RetailPViewRenderer
|
|||
_cellStaticScratch,
|
||||
_oneCell);
|
||||
|
||||
// The nested DrawCells object pass includes emitters and
|
||||
// retains the exact setup_view clip until alpha playback.
|
||||
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
|
||||
cellId, slice, _cellParticleOwnerScratch));
|
||||
cellDrewObjects = true;
|
||||
_cellParticleUnionScratch.UnionWith(
|
||||
_cellParticleOwnerScratch);
|
||||
}
|
||||
}
|
||||
|
||||
// The nested DrawCells object pass includes emitters: ONE
|
||||
// unclipped submission per look-in cell (retail draws a
|
||||
// particle during its cell's walk turn; the cell walls own
|
||||
// occlusion by depth at alpha playback — never a view clip).
|
||||
if (cellDrewObjects)
|
||||
{
|
||||
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
|
||||
cellId, NoClipSlice, _cellParticleUnionScratch));
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary exterior building shell is clipped by the outer
|
||||
|
|
@ -615,6 +670,7 @@ public sealed class RetailPViewRenderer
|
|||
// anchor EnvCell; never let an unrelated building repaint a
|
||||
// look-in merely because both happen to be nearby.
|
||||
int sliceIndex = 0;
|
||||
_staticParticleUnionScratch.Clear();
|
||||
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
|
||||
{
|
||||
int shellRouteIndex = LookInBuildingShellRouteIndex(
|
||||
|
|
@ -693,14 +749,20 @@ public sealed class RetailPViewRenderer
|
|||
_lateParticleOwnerScratch,
|
||||
_buildingShellScratch);
|
||||
}
|
||||
passes.DrawLandscapeStaticParticles(
|
||||
ctx,
|
||||
new RetailPViewLandscapeStaticParticleContext(
|
||||
slice,
|
||||
_lateParticleOwnerScratch));
|
||||
_staticParticleUnionScratch.UnionWith(
|
||||
_lateParticleOwnerScratch);
|
||||
}
|
||||
sliceIndex++;
|
||||
}
|
||||
|
||||
// ONE unclipped submission for this look-in frame's shell-route
|
||||
// owners (retail: one alpha-list insertion per emitter,
|
||||
// depth-occluded at the flush — never re-drawn per outside view).
|
||||
passes.DrawLandscapeStaticParticles(
|
||||
ctx,
|
||||
new RetailPViewLandscapeStaticParticleContext(
|
||||
_staticParticleUnionScratch));
|
||||
_staticParticleUnionScratch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -798,14 +860,19 @@ public sealed class RetailPViewRenderer
|
|||
bool hasBuildingLookIns = _lookInFrames.Count > 0;
|
||||
if (hasBuildingLookIns)
|
||||
{
|
||||
int barrierSliceIndex = 0;
|
||||
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||
{
|
||||
// Ownerless outdoor emitters cannot ride an entity route. Retail
|
||||
// draws their meshes once for every installed outside_view;
|
||||
// retain that slot through deferred alpha playback.
|
||||
passes.DrawUnattachedSceneParticles(ctx, slice);
|
||||
// Ownerless OUTDOOR-cell emitters cannot ride an entity route.
|
||||
// Retail inserts each one into the single alpha list once, during
|
||||
// its cell's landscape walk turn, with no portal-view clip; the
|
||||
// interior-cell ownerless emitters submit in the final world
|
||||
// scope instead (see DrawDynamicsLast).
|
||||
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
|
||||
|
||||
_staticParticleUnionScratch.Clear();
|
||||
int outsideSliceTotal = clipAssembly.OutsideViewSlices.Length;
|
||||
for (int barrierSliceIndex = 0;
|
||||
barrierSliceIndex < outsideSliceTotal;
|
||||
barrierSliceIndex++)
|
||||
{
|
||||
_lateParticleOwnerScratch.Clear();
|
||||
if (partition is not null)
|
||||
{
|
||||
|
|
@ -838,14 +905,17 @@ public sealed class RetailPViewRenderer
|
|||
barrierSliceIndex,
|
||||
0);
|
||||
}
|
||||
|
||||
passes.DrawLandscapeStaticParticles(
|
||||
ctx,
|
||||
new RetailPViewLandscapeStaticParticleContext(
|
||||
slice,
|
||||
_lateParticleOwnerScratch));
|
||||
barrierSliceIndex++;
|
||||
_staticParticleUnionScratch.UnionWith(
|
||||
_lateParticleOwnerScratch);
|
||||
}
|
||||
|
||||
// ONE unclipped submission for the union of every slice's cone
|
||||
// survivors, then retail's pre-building barrier flush.
|
||||
passes.DrawLandscapeStaticParticles(
|
||||
ctx,
|
||||
new RetailPViewLandscapeStaticParticleContext(
|
||||
_staticParticleUnionScratch));
|
||||
_staticParticleUnionScratch.Clear();
|
||||
passes.FlushLandscapeAlpha();
|
||||
}
|
||||
|
||||
|
|
@ -865,8 +935,10 @@ public sealed class RetailPViewRenderer
|
|||
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
|
||||
// pre-clear so the seal protects their aperture pixels; AFTER the
|
||||
// look-ins so a translucent portal mesh blends over a far interior
|
||||
// instead of being overpainted) + the scene-particle owners (statics +
|
||||
// dynamics cone survivors — flames ride here for the same reason).
|
||||
// instead of being overpainted). The scene-particle owners (statics +
|
||||
// dynamics cone survivors) accumulate across the slices and submit
|
||||
// ONCE, unclipped, after the loop.
|
||||
_staticParticleUnionScratch.Clear();
|
||||
probeSliceIndex = 0;
|
||||
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||
{
|
||||
|
|
@ -894,7 +966,15 @@ public sealed class RetailPViewRenderer
|
|||
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
|
||||
{
|
||||
_outdoorStaticScratch.Add(e);
|
||||
_lateParticleOwnerScratch.Add(e.Id);
|
||||
// Particles emit in the stage matching the PARENT CELL:
|
||||
// an INTERIOR dynamic whose sphere merely straddles an
|
||||
// exit-portal plane keeps its mesh in both stages (#118)
|
||||
// but its particles belong to the final pass — draining
|
||||
// them at the pre-clear boundary lets the interior stage
|
||||
// repaint over them except on seal-protected aperture
|
||||
// pixels (the cathedral middle-cell spell-star cut).
|
||||
if (!InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
|
||||
_lateParticleOwnerScratch.Add(e.Id);
|
||||
}
|
||||
}
|
||||
if (frameEntityPasses is not null)
|
||||
|
|
@ -934,32 +1014,55 @@ public sealed class RetailPViewRenderer
|
|||
0,
|
||||
ctx.PlayerLandblockId ?? 0);
|
||||
probeSliceIndex++;
|
||||
_staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch);
|
||||
passes.DrawLandscapeSliceLate(
|
||||
ctx,
|
||||
new RetailPViewLandscapeLateSliceContext(
|
||||
slice,
|
||||
_outdoorStaticScratch,
|
||||
_lateParticleOwnerScratch)
|
||||
_outdoorStaticScratch)
|
||||
{
|
||||
EntityDraw = entityDraw,
|
||||
});
|
||||
}
|
||||
|
||||
// ONE unclipped submission for every late-stage particle owner —
|
||||
// OUTDOOR-parented outside-stage dynamics' emitters plus, without
|
||||
// look-ins, the outdoor statics' emitters (retail: one alpha-list
|
||||
// insertion per emitter during the landscape walk; per-slice
|
||||
// re-submission with clip slots was the direction-dependent
|
||||
// disappearance class). Interior-parented straddlers appear in BOTH
|
||||
// the LandscapeOutsideDynamic and DynamicLast routes; their particles
|
||||
// emit only in the final pass, so remove them here.
|
||||
if (frameEntityPasses is not null)
|
||||
{
|
||||
RenderFrameRouteOwnerSelector.ExceptRoute(
|
||||
_staticParticleUnionScratch,
|
||||
in frameView,
|
||||
RenderFrameCandidateRoute.DynamicLast);
|
||||
}
|
||||
if (_staticParticleUnionScratch.Count > 0)
|
||||
{
|
||||
passes.DrawLandscapeStaticParticles(
|
||||
ctx,
|
||||
new RetailPViewLandscapeStaticParticleContext(
|
||||
_staticParticleUnionScratch));
|
||||
_staticParticleUnionScratch.Clear();
|
||||
}
|
||||
|
||||
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
|
||||
// campfires, ground effects anchored at a position) have no owner id
|
||||
// to ride any of the id-filtered particle passes. Draw once per
|
||||
// installed outside_view for BOTH root kinds, matching retail's
|
||||
// landscape-stage placement and preserving the slot in each deferred
|
||||
// draw. The former outdoor-root post-world tail ran after building
|
||||
// cells and let exterior alpha repaint the cathedral transition.
|
||||
// With no look-ins they drain at the end of the landscape stage; the
|
||||
// look-in path submits them at its pre-building barrier so later opaque
|
||||
// cell floors can cover them.
|
||||
// to ride any of the id-filtered particle passes. OUTDOOR-cell ones
|
||||
// submit ONCE in the landscape stage, unclipped — retail inserts each
|
||||
// particle into the single alpha list during its owner cell's walk
|
||||
// turn (ShouldDrawParticles @0x0050FE60 gates by cell + distance;
|
||||
// FlushAlphaList @0x0059D2E0 depth-tests at composition). The former
|
||||
// once-per-outside-slice submission with that slice's clip slot cut
|
||||
// effects at aperture boundaries and drew NOTHING when no outside
|
||||
// slice was in view. Interior-cell unattached emitters submit in the
|
||||
// final world scope (DrawDynamicsLast) — in the landscape stage the
|
||||
// upcoming depth clear + interior repaint would erase them.
|
||||
if (!hasBuildingLookIns)
|
||||
{
|
||||
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
|
||||
passes.DrawUnattachedSceneParticles(ctx, slice);
|
||||
}
|
||||
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
|
||||
|
||||
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
|
||||
// immediately after LScape::draw and before the optional depth clear.
|
||||
|
|
@ -1137,13 +1240,13 @@ public sealed class RetailPViewRenderer
|
|||
Array.Empty<WorldEntity>(),
|
||||
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);
|
||||
// Particles emit exactly once, in the stage matching the parent
|
||||
// cell. Pure-outdoor dynamics are absent from the DynamicLast
|
||||
// route (they draw only in the outside stage), and interior
|
||||
// straddlers — present in BOTH routes — emit their particles
|
||||
// HERE so the interior stage cannot repaint over them; the late
|
||||
// landscape submission excludes DynamicLast owners for the same
|
||||
// reason.
|
||||
if (_dynamicParticleOwnerScratch.Count > 0)
|
||||
{
|
||||
passes.DrawDynamicsParticles(
|
||||
|
|
@ -1240,8 +1343,12 @@ public sealed class RetailPViewRenderer
|
|||
else
|
||||
{
|
||||
_dynamicParticleOwnerScratch.Clear();
|
||||
// Interior-parented dynamics — INCLUDING exit-portal straddlers
|
||||
// whose mesh also drew in the outside stage — emit particles in
|
||||
// this final pass; outdoor-parented ones emitted in the late
|
||||
// landscape submission (parent-cell stage split).
|
||||
foreach (var e in _dynamicsScratch)
|
||||
if (!_outsideStageDynamics.Contains(e))
|
||||
if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
|
||||
_dynamicParticleOwnerScratch.Add(e.Id);
|
||||
}
|
||||
if (_dynamicParticleOwnerScratch.Count > 0)
|
||||
|
|
@ -1633,10 +1740,29 @@ public interface IRetailPViewPassExecutor
|
|||
RetailPViewFrameInput frame,
|
||||
RetailPViewCellSliceContext context,
|
||||
int portalIndex);
|
||||
/// <summary>
|
||||
/// One unclipped submission for every renderable UNATTACHED emitter whose
|
||||
/// owner cell matches the scope: outdoor landcells in the landscape stage,
|
||||
/// interior EnvCells in the final world scope. Retail inserts each such
|
||||
/// particle into the single alpha list during its owner cell's walk turn
|
||||
/// and never clips it to a portal view.
|
||||
/// </summary>
|
||||
void DrawUnattachedSceneParticles(
|
||||
RetailPViewFrameInput frame,
|
||||
ClipViewSlice slice);
|
||||
bool outdoorCells);
|
||||
void FlushLandscapeAlpha();
|
||||
|
||||
/// <summary>
|
||||
/// Pre/inter-building barrier drain: composites only the queued alpha at
|
||||
/// or beyond <paramref name="minViewerDistance"/> and retains nearer
|
||||
/// entries for the later boundary flush — retail's far→near walk outcome
|
||||
/// (DrawBuilding's FlushAlphaList(0f) @0x0059F2A0 can only ever flush
|
||||
/// content from cells farther than that building; AP-236). The default
|
||||
/// falls back to a full flush so non-production executors keep today's
|
||||
/// behavior until they opt in.
|
||||
/// </summary>
|
||||
void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
|
||||
FlushLandscapeAlpha();
|
||||
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
|
||||
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds);
|
||||
void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result);
|
||||
|
|
@ -1979,12 +2105,13 @@ public readonly record struct RetailPViewLandscapeSliceContext(
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor-static emitters submitted at retail's pre-building alpha barrier.
|
||||
/// Mesh alpha for the same owners is already queued by the early landscape
|
||||
/// entity route.
|
||||
/// Scene-particle owners for ONE unclipped landscape-stage submission (the
|
||||
/// union of every outside slice's cone survivors). Mesh alpha for the same
|
||||
/// owners is already queued by the entity routes; retail inserts each
|
||||
/// emitter's polys into the single alpha list once, during its owner cell's
|
||||
/// walk turn, with no portal-view clip.
|
||||
/// </summary>
|
||||
public readonly record struct RetailPViewLandscapeStaticParticleContext(
|
||||
ClipViewSlice Slice,
|
||||
IReadOnlySet<uint> ParticleOwnerIds);
|
||||
|
||||
/// <summary>Retail DrawBuilding's ordinary exterior-shell pass, issued after
|
||||
|
|
@ -2001,8 +2128,7 @@ public readonly record struct RetailPViewLandscapeBuildingShellSliceContext(
|
|||
/// submitted at a pre-building barrier.</summary>
|
||||
public readonly record struct RetailPViewLandscapeLateSliceContext(
|
||||
ClipViewSlice Slice,
|
||||
IReadOnlyList<WorldEntity> Dynamics,
|
||||
IReadOnlySet<uint> ParticleOwnerIds)
|
||||
IReadOnlyList<WorldEntity> Dynamics)
|
||||
{
|
||||
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue