diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index bc582a3d..0b1c565a 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -251,10 +251,13 @@ public sealed class RetailPViewRenderer RenderProjectionCounts sourceCounts = frameViewBorrowed ? frameView.SourceDigest.Counts : LegacySourceCounts(partition!); + // prepareCells is exactly "main flood ∪ look-in cells" — the cells + // this traversal actually reached, i.e. retail's in-view set. RetailPViewFrameResult result = _frameResultScratch.Reset( pvFrame, clipAssembly, drawableCells, + prepareCells, counts, sourceCounts, partition); @@ -1910,6 +1913,23 @@ public sealed class RetailPViewFrameResult public PortalVisibilityFrame PortalFrame { get; private set; } = null!; public ClipFrameAssembly ClipAssembly { get; private set; } = null!; public HashSet DrawableCells { get; private set; } = null!; + + /// + /// Every cell this completed view actually reached: the main flood + /// () plus the building look-in cells. This is + /// retail's per-cell in_view answer for effect consumers — + /// CPhysicsObj::ShouldDrawParticles @0x0050FE60 gates on + /// cell->IsInView(), and a cell entered through a building portal + /// (PView::ConstructView @0x005A57B0, installed by + /// RenderDeviceD3D::DrawBuilding @0x0059F2A0) is drawn by the same + /// PView::DrawCells traversal as a flooded cell, so retail marks it + /// in view identically. acdream's look-in adaptation keeps those cells out + /// of (seals / outside-stage predicate stay + /// main-flood scoped, #124); particle and light visibility must consume + /// THIS set or look-in rooms render with frozen emitters and dark lights. + /// + public HashSet InViewCells { get; private set; } = null!; + internal RenderFrameDiagnosticCounts DiagnosticCounts { get; private set; } internal RenderProjectionCounts SourceCounts { get; private set; } internal InteriorEntityPartition.Result? DiagnosticPartition @@ -1919,6 +1939,7 @@ public sealed class RetailPViewFrameResult PortalVisibilityFrame portalFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, + HashSet inViewCells, RenderFrameDiagnosticCounts diagnosticCounts, RenderProjectionCounts sourceCounts, InteriorEntityPartition.Result? diagnosticPartition) @@ -1926,6 +1947,7 @@ public sealed class RetailPViewFrameResult PortalFrame = portalFrame; ClipAssembly = clipAssembly; DrawableCells = drawableCells; + InViewCells = inViewCells; DiagnosticCounts = diagnosticCounts; SourceCounts = sourceCounts; DiagnosticPartition = diagnosticPartition; @@ -1941,6 +1963,7 @@ public sealed class RetailPViewFrameResult portalFrame, clipAssembly, drawableCells, + drawableCells, RetailPViewRenderer.LegacyDiagnosticCounts( diagnosticPartition), RetailPViewRenderer.LegacySourceCounts( diff --git a/src/AcDream.App/Rendering/WorldSceneRenderer.cs b/src/AcDream.App/Rendering/WorldSceneRenderer.cs index 9d7ec0d1..39ea601d 100644 --- a/src/AcDream.App/Rendering/WorldSceneRenderer.cs +++ b/src/AcDream.App/Rendering/WorldSceneRenderer.cs @@ -254,8 +254,14 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase camera.Camera.View, _diagnostics.CameraCellResolution)); - _particleVisibility.MarkVisibleCells(pviewResult.DrawableCells); - _frames.ObserveDrawableCells(pviewResult.DrawableCells); + // Effect visibility consumes InViewCells (main flood ∪ look-in + // cells), not the flood-only DrawableCells: retail's + // ShouldDrawParticles @0x0050FE60 asks cell->IsInView(), and a + // look-in cell drawn via DrawBuilding @0x0059F2A0 is in view + // exactly like a flooded cell. Flood-only scoping froze + // emitters and darkened lights in visible adjacent rooms. + _particleVisibility.MarkVisibleCells(pviewResult.InViewCells); + _frames.ObserveDrawableCells(pviewResult.InViewCells); _diagnostics.EmitPViewInput( pviewResult.PortalFrame, camera.ViewProjection, diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index c25a476d..7a63e96c 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -271,8 +271,10 @@ public sealed class LightManager /// geometrically closer than the player's own room's torches and win the cap, /// leaving the visible room dark. Scoping candidacy to the frame's actual /// visible cells (the render already computes this — callers pass last frame's - /// RetailPViewFrameResult.DrawableCells, one frame of latency, to avoid - /// re-threading a mid-render callback) removes those from contention before the + /// RetailPViewFrameResult.InViewCells, the main flood PLUS building + /// look-in cells, one frame of latency, to avoid re-threading a mid-render + /// callback; flood-only scoping darkened look-in rooms' lanterns) removes + /// non-visible cells from contention before the /// cap ever applies. The distance-sort anchor stays the PLAYER either way — this /// parameter only narrows candidacy, it does not change the sort (the #176 /// correction: CAMERA anchoring, not cell scoping itself, caused the earlier diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs index a8baa7b9..c67028eb 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs @@ -251,6 +251,29 @@ public sealed class WorldSceneRendererTests Assert.Equal(4, rig.PView.LastInput.RenderRadius); } + [Fact] + public void PViewWorld_PublishesLookInCellsToParticleAndLightVisibility() + { + var root = new LoadedCell + { + CellId = 0x01010001u, + IsOutdoorNode = false, + }; + var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root); + + rig.Renderer.Render(default); + + // Retail gates effects on cell->IsInView() (ShouldDrawParticles + // @0x0050FE60), and a cell entered through a building portal + // (DrawBuilding @0x0059F2A0 -> PView::ConstructView @0x005A57B0) is + // drawn by the same traversal as a flooded cell. The particle gate and + // the light-candidate scope must therefore receive InViewCells (flood + // plus look-ins), not the flood-only DrawableCells. + Assert.Contains(0x01010003u, rig.Visibility.MarkedCells); + Assert.NotNull(rig.Frames.ObservedCells); + Assert.Contains(0x01010003u, rig.Frames.ObservedCells!); + } + [Fact] public void PViewWorld_ReusesOneSynchronousFrameInputAcrossFrames() { @@ -536,7 +559,8 @@ public sealed class WorldSceneRendererTests Frames = new FrameBuilder(Calls, frame); Selection = new SelectionFrame(Calls); var alpha = new AlphaFrame(Calls); - var visibility = new ParticleVisibility(Calls); + Visibility = new ParticleVisibility(Calls); + var visibility = Visibility; PView = new PViewRenderer(Calls); Passes = new PassExecutor(Calls); var diagnostics = new Diagnostics(Calls); @@ -573,6 +597,8 @@ public sealed class WorldSceneRendererTests public SelectionFrame Selection { get; } + public ParticleVisibility Visibility { get; } + public PViewRenderer PView { get; } public PassExecutor Passes { get; } @@ -621,8 +647,13 @@ public sealed class WorldSceneRendererTests return frame; } - public void ObserveDrawableCells(IReadOnlySet drawableCells) => + public IReadOnlySet? ObservedCells { get; private set; } + + public void ObserveDrawableCells(IReadOnlySet drawableCells) + { calls.Add("frame:observe-cells"); + ObservedCells = new HashSet(drawableCells); + } public void ClearDrawableCells() => calls.Add("frame:clear-cells"); } @@ -684,8 +715,13 @@ public sealed class WorldSceneRendererTests private sealed class ParticleVisibility(List calls) : IWorldSceneParticleVisibility { - public void MarkVisibleCells(HashSet cellIds) => + public HashSet MarkedCells { get; } = []; + + public void MarkVisibleCells(HashSet cellIds) + { calls.Add("visibility:mark"); + MarkedCells.UnionWith(cellIds); + } public void CompleteFrame() => calls.Add("visibility:complete"); @@ -701,11 +737,17 @@ public sealed class WorldSceneRendererTests public PViewRenderer(List calls) { _calls = calls; + // Distinct flood-only vs in-view sets: 0x01010003 is a look-in + // cell that is drawn but never part of the main flood. + var interiorPartition = new InteriorEntityPartition.Result(); _interiorResult = new RetailPViewFrameResult().Reset( new PortalVisibilityFrame(), new ClipFrameAssembly(), - [], - new InteriorEntityPartition.Result()); + [0x01010001u], + [0x01010001u, 0x01010003u], + RetailPViewRenderer.LegacyDiagnosticCounts(interiorPartition), + RetailPViewRenderer.LegacySourceCounts(interiorPartition), + interiorPartition); var outdoorPortalFrame = new PortalVisibilityFrame(); outdoorPortalFrame.OutsideView.Add(new ViewPolygon( [