diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7e785dc3..0894eb2c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 00c33ca3..0178bfd7 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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) | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6ec354f8..5a0f7c74 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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? 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?)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) diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index e7ca5ef8..2b39b1e6 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -33,6 +33,10 @@ public sealed class RetailPViewRenderer private readonly List _lookInFrames = new(); private readonly HashSet _lookInPrepareScratch = new(); + // #131/#132: the late landscape phase's scene-particle owner survivors + // (statics + outside-stage dynamics passing the slice cone). + private readonly List _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? AnimatedById)> LandblockEntries { get; init; } public required Action SetTerrainClipUbo { get; init; } public required Action DrawLandscapeSlice { get; init; } + + /// #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). + public Action? DrawLandscapeSliceLate { get; init; } /// 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 OutdoorEntities); +/// #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. +public readonly record struct RetailPViewLandscapeLateSliceContext( + ClipViewSlice Slice, + IReadOnlyList Dynamics, + IReadOnlyList ParticleOwners); + public readonly record struct RetailPViewCellSliceContext( uint CellId, ClipViewSlice Slice,