diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 67b42e63..7e785dc3 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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. --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7877c3e3..6ec354f8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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; diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index a3b7fc7d..e7ca5ef8 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -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? DrawExitPortalMasks { get; init; } public Action? DrawCellParticles { get; init; } public Action? DrawLookInPortalPunch { get; init; } + + /// #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. + public Action? DrawUnattachedSceneParticles { get; init; } public Action>? DrawDynamicsParticles { get; init; } public Action? EmitDiagnostics { get; init; } }