fix #132 (outdoor sibling): outdoor attached scene emitters move to the post-frame pass; sharpen the #131 probe

User gate on 20d1730: the candle is FIXED indoors ("now the candle
light is visible when I'm in the house when it is in front of the
opening") and the OUTDOOR sibling surfaced exactly as AP-34 recorded
("when I go out it is not showing unless I turn so the angle doesn't
put it in front of the opening"): under an OUTDOOR root the merged
building interiors draw AFTER the landscape stage (DrawEnvCellShells),
so a slice-drawn flame is overpainted by a punched aperture's interior
behind it.

Fix: outdoor roots SKIP the late-slice Scene-particle draw; attached
outdoor-static scene emitters draw in the POST-FRAME pass alongside the
T3 unattached pass, where depth is complete and flames composite
correctly against interiors. The owner-id set carries over from the
late slice (single full-screen slice outdoors); cell-pass and
dynamics-pass emitters keep their own passes (their owners are never in
the outdoor-static id set - no double-draw). Interior roots keep the
late-slice draw (their stage ends with the clear + seal discipline).
AP-34 row updated (the outdoor residual is now covered; the remaining
residual is translucent MESH batches within stage draw calls).

Portal swirl (#131): the user's "same results" on 20d1730 KILLS the
look-in-erasure hypothesis for the portal - the mesh now draws after
the look-ins and is still missing indoors. No further speculative fix;
the [outstage] probe now prints each outside-stage dynamic's
SourceGfxObjOrSetupId (portals have distinctive setups) and
[outstage-pt] lists up to 12 distinct UNMATCHED attached emitter owner
ids - the next capture identifies whether the portal entity reaches the
through-door draw at all, and where its emitters point.

Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-12 19:26:04 +02:00
parent 20d17304d7
commit 87afbc0a42
4 changed files with 60 additions and 17 deletions

View file

@ -5096,6 +5096,7 @@ public sealed class GameWindow : IDisposable
// #131 [outstage-pt] probe state (throwaway — strip when #131 closes).
private string? _lastOutStagePtSig;
private readonly HashSet<uint> _outStageUnmatchedScratch = new();
private static System.Numerics.Vector3 SkyPesAnchor(
AcDream.Core.World.SkyObjectData obj,
@ -7841,7 +7842,8 @@ public sealed class GameWindow : IDisposable
renderSky,
renderWeather: playerSeenOutside,
kf,
environOverrideActive),
environOverrideActive,
isOutdoorRoot: clipRoot.IsOutdoorNode),
// T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840).
// INTERIOR roots: one FULL depth clear between the outside stage and
// the interior stage, then SEALS re-stamp every outside-leading
@ -8002,20 +8004,26 @@ public sealed class GameWindow : IDisposable
&& _particleSystem is not null && _particleRenderer is not null)
{
// T3 (BR-5): unattached emitters (campfires, ground effects —
// AttachedObjectId == 0) under the OUTDOOR root. The unified
// path's attached emitters draw via the landscape slice + the
// per-cell callbacks; unattached ones had NO pass on
// outdoor-node frames (the unattached-particles-dropped-
// outdoors divergence, adjusted-confirmed). The outdoor root's
// outside view is full-screen (cone pass-all); depth test
// composites them against the world.
// AttachedObjectId == 0) under the OUTDOOR root. The outdoor
// root's outside view is full-screen (cone pass-all); depth
// test composites them against the world.
// #132 outdoor sibling: ATTACHED outdoor-static scene emitters
// (lantern/candle flames) moved here too — drawn in the
// landscape slice they were overpainted by merged building
// interiors (drawn later) whenever a punched aperture sat
// behind them. Post-frame, depth is complete and the flames
// composite correctly. The owner-id set is the late slice's
// (full-screen cone outdoors). Cell-pass and dynamics-pass
// emitters keep their own passes (no double-draw: their owners
// are never in the outdoor-static id set).
sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached";
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
emitter => emitter.AttachedObjectId == 0);
emitter => emitter.AttachedObjectId == 0
|| _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId));
}
// Bug A fix (post-#26 worktree, 2026-04-26): weather sky
@ -9697,7 +9705,8 @@ public sealed class GameWindow : IDisposable
bool renderSky,
bool renderWeather,
AcDream.Core.World.SkyKeyframe kf,
bool environOverrideActive)
bool environOverrideActive,
bool isOutdoorRoot)
{
var slice = lateCtx.Slice;
bool scissor = BeginDoorwayScissor(true, slice.NdcAabb);
@ -9724,19 +9733,28 @@ public sealed class GameWindow : IDisposable
_outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity));
// #131 [outstage-pt] probe: the slice Scene-particle id set + how many
// live emitters the filter would actually match. Print-on-change.
// live emitters the filter would actually match, plus the distinct
// UNMATCHED attached owner ids (the portal-identification handle —
// an emitter whose owner never lands in the set draws nowhere
// indoors). Print-on-change.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled
&& _particleSystem is not null)
{
int matched = 0, attached = 0, unattached = 0;
_outStageUnmatchedScratch.Clear();
foreach (var (emitter, _) in _particleSystem.EnumerateLive())
{
if (emitter.AttachedObjectId == 0) { unattached++; continue; }
attached++;
if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)) matched++;
else if (_outStageUnmatchedScratch.Count < 12)
_outStageUnmatchedScratch.Add(emitter.AttachedObjectId);
}
var unm = new System.Text.StringBuilder(96);
foreach (uint id in _outStageUnmatchedScratch)
unm.Append(System.FormattableString.Invariant($" 0x{id:X8}"));
string ptSig = System.FormattableString.Invariant(
$"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached}");
$"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} unmatchedIds=[{unm}]");
if (ptSig != _lastOutStagePtSig)
{
_lastOutStagePtSig = ptSig;
@ -9744,7 +9762,17 @@ public sealed class GameWindow : IDisposable
}
}
if (_outdoorSceneParticleEntityIds.Count > 0
// #132 outdoor sibling: under an OUTDOOR root the merged building
// interiors draw AFTER this stage (DrawEnvCellShells) — a flame drawn
// here is overpainted whenever a punched aperture sits behind it
// (user-confirmed at the outdoor candle). Outdoor roots therefore
// SKIP the slice Scene pass and draw attached scene particles in the
// post-frame pass alongside the T3 unattached pass (the id set built
// above carries over — the outdoor root has a single full-screen
// slice). Interior roots draw here: the look-ins already ran and the
// post-clear seal discipline owns the rest of the frame.
if (!isOutdoorRoot
&& _outdoorSceneParticleEntityIds.Count > 0
&& _particleSystem is not null
&& _particleRenderer is not null)
{

View file

@ -488,7 +488,7 @@ public sealed class RetailPViewRenderer
bool pass = viewcone.SphereVisibleInOutsideSlice(sliceIndex, c, r);
if (i > 0) sb.Append(' ');
sb.Append(System.FormattableString.Invariant(
$"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}:{(pass ? "PASS" : "CULL")}:r={r:F1}"));
$"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}(s{e.SourceGfxObjOrSetupId:X8}):{(pass ? "PASS" : "CULL")}:r={r:F1}"));
}
sb.Append(']');
string sig = sb.ToString();