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:
parent
eeb1c59ded
commit
1d3f9a8c97
3 changed files with 70 additions and 24 deletions
|
|
@ -4583,35 +4583,44 @@ or distance.
|
|||
|
||||
## #131 — Portal swirl invisible when viewed from inside a building through the doorway
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** FIX SHIPPED — awaiting user visual gate
|
||||
**Severity:** MEDIUM (portals are landmark objects; the through-door view is common)
|
||||
**Filed:** 2026-06-12 (user report, #124 gate session)
|
||||
**Component:** render — outside-stage dynamics' particles under interior roots (#118/#121 family)
|
||||
**Component:** render — UNATTACHED emitters have no pass under interior roots
|
||||
|
||||
**Symptom (user, axiom):** "the portal swirl is missing, when I look out
|
||||
from inside a house. Appears when I walk out again."
|
||||
|
||||
**Mechanism frame:** under an interior root an outdoor dynamic routes to
|
||||
the OUTSIDE stage (`_outsideStageDynamics`, #118) and its particles'
|
||||
ONLY path is the landscape slice's Scene pass
|
||||
(`_outdoorSceneParticleEntityIds`); the last-pass particle callback
|
||||
deliberately excludes outside-stage entities (#121: "already drew in
|
||||
the slice"). If any link fails (slice cone verdict, the id set, emitter
|
||||
matching, draw order vs the slice's blend state), the swirl draws
|
||||
NOWHERE exactly when indoors — and reappears outdoors where
|
||||
DrawDynamicsLast + DrawDynamicsParticles take over. Matches the report
|
||||
exactly.
|
||||
**Root cause (confirmed by read + the [outstage] capture):** every
|
||||
particle pass under an interior root is id-FILTERED: the landscape
|
||||
slice's Scene pass and the cell/dynamics passes all require
|
||||
`emitter.AttachedObjectId != 0` and membership in an owner set. An
|
||||
UNATTACHED emitter (`AttachedObjectId == 0` — portal swirls, campfires,
|
||||
ground effects anchored at a position) therefore draws NOWHERE when the
|
||||
root is interior. The outdoor root has the dedicated T3 pass for
|
||||
exactly this class (its own comment: "unattached ones had NO pass on
|
||||
outdoor-node frames") — the identical hole on interior-root frames was
|
||||
never plugged. Walk out → the T3 pass picks the swirl up → "appears
|
||||
when I walk out again". The capture corroborated the rest of the chain
|
||||
healthy: outside-stage routing + cone PASS for the dynamics, 57
|
||||
attached emitters matched and drawn through the doorway.
|
||||
|
||||
**Desk-exonerated (2026-06-12):** key conventions are uniform
|
||||
(`ParticleEntityKey` = ServerGuid-first at all three filter sites);
|
||||
`DynamicDrawsInOutsideStage` routes outdoor dynamics correctly;
|
||||
`EntitySphere` uses the vertex-derived bounds.
|
||||
**Fix (2026-06-12):** `DrawUnattachedSceneParticles` — invoked ONCE per
|
||||
interior-root frame at the end of the landscape stage (pre-clear; drawn
|
||||
later they would z-fail against the doorway seal), after the #124
|
||||
look-ins so swirls blend over far interiors, NOT per slice (alpha
|
||||
particles must not double-draw — the #121 lesson). Mutually exclusive
|
||||
with the outdoor T3 pass by root kind. Residual (documented): unattached
|
||||
INDOOR emitters now draw pre-clear and are overpainted by the room's
|
||||
shells — same invisibility as before this fix; the proper per-emitter
|
||||
cell classification is a future port.
|
||||
|
||||
**Apparatus (shipped, env-gated):** `ACDREAM_PROBE_OUTSTAGE=1` —
|
||||
`[outstage]` (per-slice routing + cone verdict per outside-stage
|
||||
dynamic, print-on-change) + `[outstage-pt]` (slice Scene-particle id
|
||||
set + live attached-emitter matched count). Capture: stand inside,
|
||||
look at the portal through the door.
|
||||
**Apparatus (kept, env-gated):** `ACDREAM_PROBE_OUTSTAGE=1` —
|
||||
`[outstage]` (per-slice routing + cone verdicts) + `[outstage-pt]`
|
||||
(slice id set, attached matched count, unattached count).
|
||||
|
||||
**Gate:** stand inside, look out the doorway at the town portal — the
|
||||
swirl renders through the door.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue