fix #131: unattached emitters had NO particle pass under interior roots

The user's capture run + a code read pinned it in one step: every
particle pass under an interior root is id-filtered (the landscape
slice's Scene pass, the per-cell pass, and the dynamics pass all
require AttachedObjectId != 0 plus owner-set membership). An UNATTACHED
emitter - AttachedObjectId == 0: portal swirls, campfires, ground
effects anchored at a position - drew NOWHERE when the viewer root was
interior. The outdoor root has the dedicated T3 pass for exactly this
class (its own comment records that "unattached ones had NO pass on
outdoor-node frames"); the identical hole on interior-root frames was
never plugged. Walking out flips to the outdoor root and the T3 pass
picks the swirl up - "appears when I walk out again", verbatim.

The [outstage] capture corroborated the rest of the chain healthy
under the interior root: outside-stage routing correct, cone PASS for
the portal-family dynamics, 57 attached emitters matched and drawn
through the doorway. Only the unattached class was orphaned.

Fix: RetailPViewDrawContext.DrawUnattachedSceneParticles - invoked ONCE
per interior-root frame at the END of the landscape stage:
- pre-clear, because drawn after the depth clear + seals an outdoor
  emitter beyond the door plane z-fails against the seal's door-plane
  stamp;
- after the #124 look-in sub-pass, so swirls blend over far-building
  interiors;
- once per frame, not per slice - alpha particles must not double-draw
  (the #121 lesson);
- mutually exclusive with the outdoor T3 pass by root kind (interior
  invokes this; outdoor keeps T3).

Residual (documented in the issue): unattached INDOOR emitters now draw
pre-clear and get overpainted by the room's shells - the same
invisibility they had before this fix; the proper per-emitter cell
classification is a future port.

[outstage-pt] probe extended with the unattached emitter count (the
probe's blind spot was exactly where the bug hid).

Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user gate: the swirl through the doorway. #132 (candle
flame vs through-opening background) remains open - different
mechanism, background-dependent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-12 19:04:12 +02:00
parent eeb1c59ded
commit 1d3f9a8c97
3 changed files with 70 additions and 24 deletions

View file

@ -7852,6 +7852,21 @@ public sealed class GameWindow : IDisposable
DrawLookInPortalPunch = sliceCtx =>
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
forceFarZ: true),
// #131: unattached emitters under an interior root — the
// landscape-stage pass (the outdoor T3 pass below is gated
// IsOutdoorNode, so the two never both run).
DrawUnattachedSceneParticles = () =>
{
if (_particleSystem is null || _particleRenderer is null)
return;
DisableClipDistances();
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
emitter => emitter.AttachedObjectId == 0);
},
DrawCellParticles = sliceCtx =>
DrawRetailPViewCellParticles(sliceCtx, camera, camPos),
DrawDynamicsParticles = survivors =>
@ -9646,15 +9661,15 @@ public sealed class GameWindow : IDisposable
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled
&& _particleSystem is not null)
{
int matched = 0, attached = 0;
int matched = 0, attached = 0, unattached = 0;
foreach (var (emitter, _) in _particleSystem.EnumerateLive())
{
if (emitter.AttachedObjectId == 0) continue;
if (emitter.AttachedObjectId == 0) { unattached++; continue; }
attached++;
if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)) matched++;
}
string ptSig = System.FormattableString.Invariant(
$"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched}");
$"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached}");
if (ptSig != _lastOutStagePtSig)
{
_lastOutStagePtSig = ptSig;

View file

@ -409,6 +409,22 @@ public sealed class RetailPViewRenderer
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
DrawBuildingLookIns(ctx, partition);
// #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. The outdoor root
// has the dedicated T3 pass for them; an INTERIOR root had NO pass
// at all — the portal swirl vanished exactly when viewed through a
// doorway. Draw them ONCE per frame (not per slice — alpha particles
// must not double-draw, the #121 lesson), still inside the landscape
// stage: drawn after the clear they would z-fail against the doorway
// seal; here they composite against the slice depth, and anything on
// screen outside the apertures is overpainted by the root's shells
// after the clear. Mutually exclusive with the outdoor T3 pass
// (this method's caller is the interior path when slices exist;
// GameWindow gates the T3 pass on IsOutdoorNode).
if (!ctx.RootCell.IsOutdoorNode)
ctx.DrawUnattachedSceneParticles?.Invoke();
// T1: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// Clear gated on portalsDrawnCount; exact gate semantics is a plan
@ -895,6 +911,12 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; init; }
/// <summary>#131: Scene-pass draw of UNATTACHED emitters
/// (AttachedObjectId == 0) for interior-root frames — invoked once at the
/// end of the landscape stage (pre-clear). Outdoor roots draw them via
/// GameWindow's dedicated post-frame pass instead.</summary>
public Action? DrawUnattachedSceneParticles { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
}