From fc30285fd70cde2524ff8807d1103f4fc8627568 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 29 Aug 2026 16:06:04 +0200 Subject: [PATCH] fix(render): drawn-once look-in dynamics + farther-than-building alpha drains Two retail-parity corrections on top of 684380d4, user-gated together (no regressions; door/creature-through-opening rendering verified live at Holtburg; cathedral waterfall and look-north casts pass): 1. Drawn-once look-in dynamics. Retail marks every drawn non-player part for the frame (DrawMeshInternal @0x0059F360 GetDrawnThisFrame), so an object draws once, with its cell. acdream drew a look-in cell's dynamics twice under an outdoor root - once correctly with the look-in, then again in dynamics-last after the boundary alpha drain, where the second draw overpainted nearer flames. Both the accepted path (_lookInCellIds) and the frame product (BuildDynamicLastRoute) now exclude dynamics whose parent cell drew as a look-in. 2. Pre/inter-building barriers drain only content FARTHER than the building they precede (FlushLandscapeAlphaFartherThan + RetailAlphaQueue.FlushFartherThan + conservative anchor-origin threshold). Retail's far-to-near walk guarantees a building's FlushAlphaList(0f) @0x0059F2A0 has only farther content queued; a nearer emitter composites at a later flush. AP-236 documents the remaining barrier-order divergence. The #132 candle-before-door overdraw is NOT yet fixed by these steps and stays open: the current suspect is that houses without a constructed look-in still draw their interior-parented door in dynamics-last after the outdoor candle's boundary drain (see the 2026-08-29 ledger for the retail flush-after-objects hypothesis). Co-Authored-By: Claude Fable 5 --- .../Rendering/RetailPViewRenderer.cs | 67 ++++++++++++++++--- .../Scene/RenderScenePViewFrameProduct.cs | 24 +++++++ .../Rendering/RetailPViewPassExecutorTests.cs | 6 +- 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index d1ab0dc3..56f0a987 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -34,6 +34,13 @@ public sealed class RetailPViewRenderer private readonly HashSet _staticParticleUnionScratch = new(); private readonly HashSet _cellParticleUnionScratch = new(); + // Every cell drawn as a building look-in this frame. Retail marks each + // drawn non-player part for the frame (DrawMeshInternal @0x0059F360, + // GetDrawnThisFrame), so an object whose cell drew with a look-in cannot + // draw again in a later pass; dynamics-last consults this set to honor + // the same drawn-once contract. + private readonly HashSet _lookInCellIds = new(); + private readonly HashSet _oneCell = new(1); // Shell-batch scratch: all of a pass's cells collected for ONE batched // opaque Render call (instead of one heavy Render per cell). Reused across @@ -172,13 +179,19 @@ public sealed class RetailPViewRenderer // contains them). drawableCells itself stays the MAIN flood: it feeds the // seals, the outside-stage predicate, and the frame result. var prepareCells = drawableCells; + _lookInCellIds.Clear(); if (_lookInFrames.Count > 0) { _lookInPrepareScratch.Clear(); _lookInPrepareScratch.UnionWith(drawableCells); foreach (var f in _lookInFrames) + { foreach (uint c in f.OrderedVisibleCells) + { _lookInPrepareScratch.Add(c); + _lookInCellIds.Add(c); + } + } prepareCells = _lookInPrepareScratch; } @@ -532,12 +545,22 @@ public sealed class RetailPViewRenderer { PortalVisibilityFrame frame = _lookInFrames[frameIndex]; - // Retail enters DrawBuilding once per building and drains every - // alpha submission accumulated by the preceding building before - // punching the next building's portals. The first building uses - // the pre-look-in barrier in DrawLandscapeThroughOutsideView. + // Retail enters DrawBuilding once per building and drains the + // alpha accumulated by the preceding building before punching the + // next building's portals — and because retail's far→near walk + // has only inserted FARTHER content by then, that drain can never + // composite an emitter nearer than this building + // (FlushAlphaList(0f) @0x0059F2A0 under the walk; AP-236). + // The first building uses the pre-look-in barrier in + // DrawLandscapeThroughOutsideView. if (frameIndex > 0) - passes.FlushLandscapeAlpha(); + { + passes.FlushLandscapeAlphaFartherThan( + LookInBarrierDrainDistance( + frame, + ctx.Cells, + ctx.CameraWorldPosition)); + } // Pass 1: far-Z punch every aperture of this building. foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals) @@ -910,13 +933,25 @@ public sealed class RetailPViewRenderer } // ONE unclipped submission for the union of every slice's cone - // survivors, then retail's pre-building barrier flush. + // survivors, then retail's pre-building barrier drain. Under + // retail's far→near walk, DrawBuilding's FlushAlphaList(0f) + // @0x0059F2A0 can only ever flush content from cells FARTHER + // than the building it precedes — a nearer emitter (the Holtburg + // candle in front of a door) has not been inserted yet and + // composites at a later flush, after that building's opaques. + // Drain the far prefix only; nearer entries stay queued for the + // DrawCells-boundary flush, which runs after the late dynamics + // (AP-236 retirement). passes.DrawLandscapeStaticParticles( ctx, new RetailPViewLandscapeStaticParticleContext( _staticParticleUnionScratch)); _staticParticleUnionScratch.Clear(); - passes.FlushLandscapeAlpha(); + passes.FlushLandscapeAlphaFartherThan( + LookInBarrierDrainDistance( + _lookInFrames[0], + ctx.Cells, + ctx.CameraWorldPosition)); } // #124: far-building look-ins draw HERE — still inside the landscape @@ -1283,6 +1318,14 @@ public sealed class RetailPViewRenderer $"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)"); continue; } + // Drawn-once (retail DrawMeshInternal @0x0059F360 marks every + // non-player part for the frame): a dynamic whose cell drew as a + // building LOOK-IN already rendered with that cell inside the + // landscape stage (#131). Redrawing it here would land AFTER the + // boundary alpha drain and overpaint nearer flames — the Holtburg + // door repainting the candle in front of it. + if (indoor && _lookInCellIds.Contains(e.ParentCellId!.Value)) + continue; bool visible = indoor ? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r) : viewcone.SphereVisibleOutside(c, r); @@ -1329,16 +1372,18 @@ public sealed class RetailPViewRenderer // particles must not double-draw, unlike the depth-idempotent meshes). if (frameEntityPasses is not null) { + // Parent-cell stage split: every DynamicLast owner emits its + // particles here. Pure-outdoor dynamics are absent from this + // route (outside stage only), and interior straddlers — whose + // meshes drew in both stages — must emit HERE so the interior + // stage cannot repaint over them (matches the production + // partition-null path above). RenderFrameRouteOwnerSelector.Replace( _dynamicParticleOwnerScratch, in frameView, RenderFrameCandidateRoute.DynamicLast, 0, 0); - RenderFrameRouteOwnerSelector.ExceptRoute( - _dynamicParticleOwnerScratch, - in frameView, - RenderFrameCandidateRoute.LandscapeOutsideDynamic); } else { diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index 61f2d3e7..10bb06e3 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -1128,6 +1128,12 @@ internal sealed class RenderScenePViewFrameBuilder private RenderProjectionRecord[] _cell = []; private RenderProjectionRecord[] _dirty = []; private RenderProjectionRecord[] _survivors = []; + + // Cells drawn as building look-ins this frame — the DynamicLast route + // honors retail's drawn-once contract (DrawMeshInternal @0x0059F360 + // marks every non-player part): an object whose cell drew with a look-in + // must not enter the final dynamics route again. + private readonly HashSet _lookInCellScratch = new(); private RenderProjectionRecord[] _cellRoute = []; private readonly Dictionary _outdoorPositions = []; @@ -1528,6 +1534,16 @@ internal sealed class RenderScenePViewFrameBuilder RenderFrameWriter writer, in RenderScenePViewBuildInput input) { + _lookInCellScratch.Clear(); + for (int frameIndex = 0; + frameIndex < input.LookInFrames.Count; + frameIndex++) + { + PortalVisibilityFrame frame = input.LookInFrames[frameIndex]; + for (int i = 0; i < frame.OrderedVisibleCells.Count; i++) + _lookInCellScratch.Add(frame.OrderedVisibleCells[i]); + } + int count = 0; EnsureCapacity(ref _survivors, _dynamicCount); for (int i = 0; i < _dynamicCount; i++) @@ -1539,6 +1555,14 @@ internal sealed class RenderScenePViewFrameBuilder if (!input.RootIsOutdoor && !indoor) continue; + // Drawn-once (retail DrawMeshInternal @0x0059F360): a dynamic + // whose cell drew as a building look-in already rendered with + // that cell in the landscape stage; re-entering the final route + // would draw it after the boundary alpha drain and overpaint + // nearer flames (the Holtburg candle-behind-door class). + if (indoor && _lookInCellScratch.Contains(parentCellId!.Value)) + continue; + Sphere(in record, out Vector3 center, out float radius); bool visible = indoor ? input.Viewcone.SphereVisibleInCell( diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index 27ceefea..f288b96d 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -306,7 +306,7 @@ public sealed class RetailPViewPassExecutorTests "landscape-early", "unattached-particles-outdoor", "landscape-static-particles", - "landscape-alpha", + "landscape-alpha-farther", "look-in-punch", "landscape-late", "landscape-alpha", @@ -380,7 +380,7 @@ public sealed class RetailPViewPassExecutorTests "look-in-punch", "landscape-building-shell", "landscape-static-particles", - "landscape-alpha", + "landscape-alpha-farther", "look-in-punch", "landscape-building-shell"); } @@ -795,6 +795,8 @@ public sealed class RetailPViewPassExecutorTests ? "unattached-particles-outdoor" : "unattached-particles-interior"); public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha"); + public void FlushLandscapeAlphaFartherThan(float minViewerDistance) => + Operations.Add("landscape-alpha-farther"); public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles"); public void DrawDynamicsParticles( RetailPViewFrameInput frame,