fix(render): feed look-in cells to particle and light visibility

Look-in rooms (cells reached through building portals) drew their
geometry but never entered the visible-cell set consumed by the
particle gate and the point-light candidacy scope, so their emitters
stayed frozen and their lanterns dark until the player entered or the
camera left the building. Retail has no look-in split:
ShouldDrawParticles @0x0050FE60 gates on cell->IsInView(), and a cell
installed by DrawBuilding @0x0059F2A0 -> PView::ConstructView
@0x005A57B0 is drawn by the same DrawCells traversal as any flooded
cell, so it is in view identically (per-cell in_view stamped by
PView::InitCell @0x005A4B70).

RetailPViewFrameResult now carries InViewCells (main flood + look-in
cells, the existing prepareCells union; the borrowed-scratch contract
is unchanged because RecycleLookInFrames runs at the start of the next
DrawInside). WorldSceneRenderer feeds InViewCells to
ParticleVisibilityController and the point-light scope. DrawableCells
is untouched for seals, the outside-stage predicate, diagnostics, and
the packed-product referee. The legacy 4-arg Reset defaults
InViewCells = DrawableCells for standalone callers.

User-gated 2026-08-29 at the Sanctuary Cathedral: adjacent look-in
rooms show particles and lantern lights from the root cell without
entry or an outside camera; recall/waterfall/Holtburg/paperdoll
unregressed. The pre-existing #132-family candle-behind-opening
report remains open and is unchanged by this fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-29 11:44:19 +02:00
parent f7aa8e0eb7
commit 85530c0b7e
4 changed files with 82 additions and 9 deletions

View file

@ -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<uint> DrawableCells { get; private set; } = null!;
/// <summary>
/// Every cell this completed view actually reached: the main flood
/// (<see cref="DrawableCells"/>) plus the building look-in cells. This is
/// retail's per-cell <c>in_view</c> answer for effect consumers —
/// <c>CPhysicsObj::ShouldDrawParticles</c> @0x0050FE60 gates on
/// <c>cell-&gt;IsInView()</c>, and a cell entered through a building portal
/// (<c>PView::ConstructView</c> @0x005A57B0, installed by
/// <c>RenderDeviceD3D::DrawBuilding</c> @0x0059F2A0) is drawn by the same
/// <c>PView::DrawCells</c> traversal as a flooded cell, so retail marks it
/// in view identically. acdream's look-in adaptation keeps those cells out
/// of <see cref="DrawableCells"/> (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.
/// </summary>
public HashSet<uint> 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<uint> drawableCells,
HashSet<uint> 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(

View file

@ -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,

View file

@ -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
/// <c>RetailPViewFrameResult.DrawableCells</c>, one frame of latency, to avoid
/// re-threading a mid-render callback) removes those from contention before the
/// <c>RetailPViewFrameResult.InViewCells</c>, 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