fix #131+#132: landscape translucents drawn AFTER the #124 look-ins (FlushAlphaList deferral)

The user's screenshot pair re-attributed both reports to ONE mechanism -
a compositing gap in the #124 look-in sub-pass:
- #131: the portal swirl (a TRANSLUCENT MESH, not only particles) stood
  exactly in front of the hall's doorway. The slice drew it BEFORE the
  look-in sub-pass; translucents write no depth, so the hall's interior
  - drawn into its far-Z-punched aperture - overpainted the swirl.
  Outdoors the look-ins are the post-stage merge path, so the swirl
  survives ("stepping out it pops into existence").
- #132: the candle/lantern flame is an attached emitter in the slice's
  Scene-particle pass - same pre-look-in placement, same erasure
  whenever "the opening through a house" sat behind it; against a wall
  nothing overdraws it. Background-dependence explained exactly.

Retail cannot exhibit this class: every alpha draw of the landscape
stage is collected and flushed ONCE after LScape::draw
(D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722) - i.e.
after all building look-ins.

Port (the two-phase split): DrawLandscapeThroughOutsideView now runs
EARLY per slice (sky, terrain, outdoor STATIC meshes - the look-in
punches need their depth to mark against, the #117 lesson), then the
#124 look-ins, then LATE per slice (outside-stage dynamics' meshes +
ALL attached scene particles + weather + SkyPostScene), then the #131
unattached pass. New RetailPViewLandscapeLateSliceContext carries the
dynamics survivors + the particle-owner set (statics + dynamics cone
survivors). GameWindow's slice handler split accordingly. Outdoor
roots: no look-ins live in the stage, so the net order is unchanged
(zero behavior change outdoors).

Register: AP-34 added - the two-phase split vs retail's single
deferred flush, with the residuals recorded (outdoor-root slice
particles still draw before merged building interiors - the unreported
outdoor sibling; building exteriors' own translucent batches draw
early).

The earlier #131 unattached-emitter pass (1d3f9a8) remains - it fixes
an independent hole (that class had NO indoor pass at all) - and now
runs at the end of the late phase.

Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user gate: swirl through the doorway, candle flame with
the opening behind it, far-building interiors (#124).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-12 19:16:40 +02:00
parent 1d3f9a8c97
commit 20d17304d7
4 changed files with 177 additions and 40 deletions

View file

@ -4619,35 +4619,59 @@ cell classification is a future port.
`[outstage]` (per-slice routing + cone verdicts) + `[outstage-pt]`
(slice id set, attached matched count, unattached count).
**FIX 1 INSUFFICIENT (user screenshots, same evening):** the swirl is
the portal's TRANSLUCENT MESH, not (only) unattached particles. The
real mechanism — shared with #132 — is the #124 look-in ordering: the
slice drew the portal mesh (and all scene particles) BEFORE the look-in
sub-pass; translucents write no depth, so the far building's interior
(drawn into its far-Z-punched aperture) overpainted them wherever a
look-in opening sat behind them on screen. Both screenshots show the
swirl exactly in front of the hall's doorway. Retail cannot have this
bug: all landscape-stage alpha draws are deferred into ONE flush after
LScape::draw (`D3DPolyRender::FlushAlphaList`, DrawCells pc:432722).
**FIX 2 (the FlushAlphaList deferral, same commit family as #124):**
the landscape stage is now TWO phases per frame — EARLY per slice: sky,
terrain, outdoor static meshes (the look-in punches need their depth, the
#117 lesson); then the #124 look-ins; then LATE per slice: outside-stage
dynamics' meshes + ALL attached scene particles + weather + the
unattached pass. Outdoor roots keep their existing order (no look-ins in
the stage; net order unchanged). Residual (documented, AP-34): under
OUTDOOR roots slice particles still draw before merged building
interiors (the outdoor sibling of this bug, unreported); building
exteriors' own translucent batches draw early.
**Gate:** stand inside, look out the doorway at the town portal — the
swirl renders through the door.
swirl renders through the door; the candle flame (#132) stays visible
with the through-opening behind it.
---
## #132 — Candle flame disappears when the through-opening background is behind it
**Status:** OPEN
**Status:** FIX SHIPPED (shared mechanism with #131 fix 2) — awaiting user visual gate
**Severity:** LOW-MEDIUM
**Filed:** 2026-06-12 (user report, #124 gate session)
**Component:** render — cell-particle compositing vs aperture pixels
**Component:** render — slice particles drawn before the #124 look-ins
**Symptom (user, axiom):** "I have a candle, when I look at the candle
when a wall is behind it it shows, but if I turn a bit and the opening
through a house is behind it candle light disappears."
**Reading:** BACKGROUND-dependent disappearance — the candle (and its
owner static) stays in view; only what is behind it changes. That rules
out viewcone/owner culling (which keys on the candle's own position)
and points at per-pixel state in the aperture region: depth left by the
punch/seal/look-in machinery at those pixels, draw order of the cell
particle pass vs the aperture passes, or blend state. Candidate overlap
with the #124 look-in sub-pass (new pre-clear content in exactly those
pixels) — check whether the symptom predates `77cef4c` by looking at a
candle in front of a doorway WITHOUT a through-house view.
**Root cause (= #131's fix-2 mechanism):** the candle/lantern's flame
is an attached emitter drawn in the landscape slice's Scene-particle
pass, which ran BEFORE the #124 look-in sub-pass. Particles write no
depth; whenever a look-in opening ("the opening through a house") sat
behind the flame on screen, the far building's interior — drawn into
its far-Z-punched aperture — overpainted the flame. Against a plain
wall (no look-in aperture behind), nothing overdraws it → visible.
Background-dependence explained exactly.
**Next:** repro at the spot + `ACDREAM_PROBE_OUTSTAGE` lines for the
same frame; then a depth-state walkthrough of the aperture pixels for
the cell-particle pass.
**Fix:** the landscape stage's two-phase split (see #131 FIX 2): all
scene particles moved to the LATE phase, after the look-ins.
**Gate:** the candle at the original spot — flame stays visible when
the through-opening is behind it.
---

View file

@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 3. Documented approximation (AP) — 33 rows
## 3. Documented approximation (AP) — 34 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -129,6 +129,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in statics (**#124** sub-pass) draw WHOLE — no per-part viewcone check; retail viewconeCheck's each part vs the installed view. Look-in DYNAMICS are not drawn at all (deferred; retail draws objects per overlapped cell in the landscape stage) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | Statics: a few wasted draws only. Dynamics: an NPC inside a far building seen through two openings is invisible where retail shows it | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins), not retail's single deferred alpha flush. Residual: under OUTDOOR roots slice particles still draw before merged building interiors (the unreported outdoor sibling of **#131**/**#132**); building exteriors' own translucent batches draw early | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the interior-root cases the user can see (#131 portal swirl, #132 candle flame) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
---

View file

@ -7827,6 +7827,21 @@ public sealed class GameWindow : IDisposable
renderWeather: playerSeenOutside,
kf,
environOverrideActive),
// #131/#132: the late phase — dynamics meshes + scene
// particles + weather AFTER the look-ins (FlushAlphaList
// deferral).
DrawLandscapeSliceLate = lateCtx =>
DrawRetailPViewLandscapeSliceLate(
lateCtx,
camera,
frustum,
camPos,
playerLb,
animatedIds,
renderSky,
renderWeather: playerSeenOutside,
kf,
environOverrideActive),
// 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
@ -9652,8 +9667,60 @@ public sealed class GameWindow : IDisposable
animatedEntityIds: animatedIds);
}
// #131/#132: scene particles + weather MOVED to the LATE phase
// (DrawRetailPViewLandscapeSliceLate) — they must composite AFTER the
// #124 look-ins (retail's FlushAlphaList deferral, DrawCells
// pc:432722); drawn here they were overpainted by far-building
// interiors wherever a look-in aperture sat behind them.
if (scissor)
_gl!.Disable(EnableCap.ScissorTest);
DisableClipDistances();
}
// #131/#132: the LATE landscape phase — per slice, invoked by the renderer
// AFTER the #124 look-in sub-pass, still pre-clear. Outside-stage
// dynamics' meshes (a translucent portal swirl blends over a far interior
// instead of being overpainted by it — translucents write no depth to
// protect themselves) + ALL attached scene particles (statics' flames
// included — the #132 candle) + weather. Retail equivalent: alpha draws
// collected during LScape::draw flush ONCE after it
// (D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722).
private void DrawRetailPViewLandscapeSliceLate(
AcDream.App.Rendering.RetailPViewLandscapeLateSliceContext lateCtx,
ICamera camera,
FrustumPlanes? frustum,
System.Numerics.Vector3 camPos,
uint? playerLb,
HashSet<uint>? animatedIds,
bool renderSky,
bool renderWeather,
AcDream.Core.World.SkyKeyframe kf,
bool environOverrideActive)
{
var slice = lateCtx.Slice;
bool scissor = BeginDoorwayScissor(true, slice.NdcAabb);
_gl!.BindBufferBase(BufferTargetARB.UniformBuffer,
ClipFrame.TerrainClipUboBinding, _clipFrame!.TerrainUbo);
// Outside-stage dynamics' meshes — viewcone pre-filtered by the
// renderer, never hard-clipped (T3).
DisableClipDistances();
if (lateCtx.Dynamics.Count > 0)
{
var dynamicsEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero,
lateCtx.Dynamics,
(IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity>?)null);
_wbDrawDispatcher!.Draw(camera, new[] { dynamicsEntry }, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: null,
animatedEntityIds: animatedIds);
}
_outdoorSceneParticleEntityIds.Clear();
foreach (var entity in sliceCtx.OutdoorEntities)
foreach (var entity in lateCtx.ParticleOwners)
_outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity));
// #131 [outstage-pt] probe: the slice Scene-particle id set + how many
@ -9677,7 +9744,6 @@ public sealed class GameWindow : IDisposable
}
}
DisableClipDistances();
if (_outdoorSceneParticleEntityIds.Count > 0
&& _particleSystem is not null
&& _particleRenderer is not null)

View file

@ -33,6 +33,10 @@ public sealed class RetailPViewRenderer
private readonly List<PortalVisibilityFrame> _lookInFrames = new();
private readonly HashSet<uint> _lookInPrepareScratch = new();
// #131/#132: the late landscape phase's scene-particle owner survivors
// (statics + outside-stage dynamics passing the slice cone).
private readonly List<WorldEntity> _lateParticleOwnerScratch = new();
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
@ -365,6 +369,18 @@ public sealed class RetailPViewRenderer
if (clipAssembly.OutsideViewSlices.Length == 0)
return;
// #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha
// draws of the landscape stage and flushes them ONCE after LScape::draw
// (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent
// landscape content (portal swirl meshes, flame particles) composites
// AFTER the building look-ins. Our dispatcher draws translucency inside
// each Draw call, so the stage is split in TWO phases instead: EARLY =
// sky + terrain + outdoor STATIC meshes (the look-in punches need their
// depth to mark against, the #117 lesson); then the look-ins; then
// LATE = outside-stage dynamics' meshes + ALL scene particles +
// weather. Content drawn early and overlapped by a look-in aperture
// was otherwise overpainted by the far interior (translucents write no
// depth to protect themselves) — the portal-swirl/candle-flame class.
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
@ -386,19 +402,6 @@ public sealed class RetailPViewRenderer
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
_outdoorStaticScratch.Add(e);
}
// #118: outside-stage dynamics ride the landscape pass like retail's
// per-landcell DrawSortCell (DrawBlock 0x005a17c0, pc:430124) — drawn
// BEFORE the depth clear + seals so the seal PROTECTS their pixels in
// the aperture instead of z-killing them. Same per-slice cone test as
// the statics above. Empty under outdoor roots (see DrawInside).
foreach (var e in _outsideStageDynamics)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
_outdoorStaticScratch.Add(e);
}
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled)
EmitOutStageProbe(probeSliceIndex, viewcone);
probeSliceIndex++;
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
}
@ -409,19 +412,49 @@ public sealed class RetailPViewRenderer
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
DrawBuildingLookIns(ctx, partition);
// 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).
probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo);
_entities.ClearClipRouting();
_outdoorStaticScratch.Clear(); // late: dynamics survivors
_lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors
foreach (var e in partition.OutdoorStatic)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
_lateParticleOwnerScratch.Add(e);
}
foreach (var e in _outsideStageDynamics)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
{
_outdoorStaticScratch.Add(e);
_lateParticleOwnerScratch.Add(e);
}
}
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled)
EmitOutStageProbe(probeSliceIndex, viewcone);
probeSliceIndex++;
ctx.DrawLandscapeSliceLate?.Invoke(new RetailPViewLandscapeLateSliceContext(
slice, _outdoorStaticScratch, _lateParticleOwnerScratch));
}
// #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).
// at all. Draw them ONCE per frame (not per slice — alpha particles
// must not double-draw, the #121 lesson), at the END of the landscape
// stage: after the clear they would z-fail against the doorway seal.
if (!ctx.RootCell.IsOutdoorNode)
ctx.DrawUnattachedSceneParticles?.Invoke();
@ -903,6 +936,11 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<uint> SetTerrainClipUbo { get; init; }
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
/// <summary>#131/#132: the LATE landscape phase, per slice, after the #124
/// look-ins — outside-stage dynamics' meshes + all scene particles +
/// weather (the FlushAlphaList deferral; see DrawLandscapeThroughOutsideView).</summary>
public Action<RetailPViewLandscapeLateSliceContext>? DrawLandscapeSliceLate { get; init; }
/// <summary>T1: one full-buffer depth clear between the outside stage and the
/// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor
/// roots — outdoors the interiors must depth-test against terrain + exteriors and
@ -933,6 +971,14 @@ public readonly record struct RetailPViewLandscapeSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> OutdoorEntities);
/// <summary>#131/#132: the late landscape phase's per-slice payload —
/// outside-stage dynamics to mesh-draw, plus the full scene-particle owner
/// set (statics + dynamics cone survivors) the attached-emitter filter keys on.</summary>
public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> Dynamics,
IReadOnlyList<WorldEntity> ParticleOwners);
public readonly record struct RetailPViewCellSliceContext(
uint CellId,
ClipViewSlice Slice,