fix #124: interior-root building look-ins as a landscape-stage sub-pass

From inside a building, looking out at ANOTHER building with an opening
showed its back walls missing (see-through to the world): per-building
look-in floods only ran for outdoor roots; under an interior root the
far building's interior never flooded.

Decomp anchor (named-retail, this session's read): retail runs the
look-in INSIDE the landscape stage for ANY root - LScape::draw is the
FIRST call of PView::DrawCells' outside-view branch (pc:432719),
strictly BEFORE the depth clear (pc:432732) and the exit-portal seals
(pc:432785). ConstructView(CBldPortal) (0x005a59a0) clips each aperture
via GetClip against the INSTALLED view - the accumulated doorway region
when looked into from inside - and build_draw_portals_only pass 1
far-Z punches ALL apertures before pass 2 floods + draws any interior
cell. The nested DrawCells has an empty outside view (PView ctor
draw_landscape=0): no recursive landscape/clear/seal.

Port:
- GameWindow's per-building gather (frustum pre-gate on
  Building.PortalBounds) now runs for interior roots too; the root's
  own doorway self-excludes via the seed eye-side test (the eye is on
  its interior side).
- PortalVisibilityBuilder.BuildFromExterior/ConstructViewBuilding gain
  seedRegion - the installed-view clip: interior-root look-ins seed
  against the OutsideView polygons (a building not visible through the
  doorway never floods); null = full screen (outdoor roots unchanged).
- RetailPViewRenderer.DrawBuildingLookIns: a landscape-stage sub-pass
  (before ClearDepthForInterior + seals) - per building, punch ALL
  apertures (new DrawLookInPortalPunch callback, always forceFarZ=true,
  closing the ISSUES "forceFarZ keys on root kind, under-punches" gap),
  then draw the flooded cells' shells + statics far->near. Look-in
  frames are NEVER merged into the main frame: a merged cell would draw
  post-clear and z-fail against the root's seal (the old ledger
  portShape sketch was wrong on this point).
- Look-in cells join the Prepare + partition set so shells have batches
  and statics route to ByCell (consumed only by the sub-pass; the main
  cell-object pass iterates the main flood's cells).

Register: AP-33 added in the same commit - look-in statics draw WHOLE
(no per-part viewcone; over-include is the safe direction) and look-in
DYNAMICS are deferred (an NPC inside a far building stays invisible -
retail draws objects per overlapped cell in the landscape stage).

Pins: Issue124LookInSeedRegionTests on the real corner-building door -
a seed region containing the aperture floods (and never more than the
full-screen seed), a disjoint region floods NOTHING, and an
interior-side eye never seeds its own exit portal.

Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user gate: far-building interiors visible through their
apertures from inside; #130 re-gate (top-edge strip) rides the same
launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-12 15:59:29 +02:00
parent 5135066733
commit 77cef4cd86
6 changed files with 379 additions and 32 deletions

View file

@ -4304,28 +4304,48 @@ of which draw list the building's shell left.
## #124 — Looking out through an opening: far buildings with openings show missing/transparent back walls ## #124 — Looking out through an opening: far buildings with openings show missing/transparent back walls
**Status:** OPEN **Status:** FIX SHIPPED — awaiting user visual gate
**Severity:** MEDIUM **Severity:** MEDIUM
**Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue") **Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue";
user 2026-06-12: "especially visible when I look out through a door
opening when inside a building")
**Component:** render — per-building look-in floods under INTERIOR roots **Component:** render — per-building look-in floods under INTERIOR roots
From inside a building, looking out through a door/window at ANOTHER From inside a building, looking out through a door/window at ANOTHER
building that has an opening: the far building's back walls are building that has an opening: the far building's back walls are
missing/transparent (see the world through it). **Lead (by read):** the missing/transparent. The lead confirmed by decomp: retail runs the
per-building look-in floods (`MergeNearbyBuildingFloods`) run ONLY for look-in INSIDE the landscape stage for ANY root — `LScape::draw` is the
outdoor roots — `RetailPViewDrawContext.NearbyBuildingCells` is FIRST call of `PView::DrawCells`' outside-view branch (pc:432719),
documented "Null for interior roots." So under an interior root the far strictly BEFORE the depth clear (pc:432732) and the seals (pc:432785);
building's INTERIOR never floods: through its window you see the shell `ConstructView(CBldPortal)`'s GetClip runs under the INSTALLED view
only, and a shell has no interior back-wall faces → transparent. (the doorway region), and all apertures far-Z punch (pass 1) before any
Retail runs the building look-in inside `LScape::draw` (DrawBlock → interior cell draws (pass 2).
DrawPortal → ConstructView(CBldPortal)), which executes for ANY root
whose outside view is non-empty — including interior roots looking out **Fix (2026-06-12):**
a doorway. Fix shape: provide the nearby-building gather + per-building - The per-building gather (frustum pre-gate on `Building.PortalBounds`)
floods for interior roots too, with look-in apertures getting PUNCH now runs for interior roots too; the root's own doorway self-excludes
semantics (the `forceFarZ` selector currently keys on via the seed eye-side test.
`clipRoot.IsOutdoorNode`, which under-punches this case). Needs its own - `BuildFromExterior` gained `seedRegion` — the port of retail's
focused pass — touches the gather, the merge, and the depth-mask installed-view clip: interior-root look-ins seed clipped against the
selector. OutsideView (doorway) polygons, so a building not visible through the
doorway never floods. Outdoor roots keep the full-screen default.
- NEW `DrawBuildingLookIns` sub-pass inside the LANDSCAPE stage (before
the depth clear + seals): per building, punch ALL apertures
(`DrawLookInPortalPunch`, always far-Z), then draw the flooded cells'
shells + statics far→near. NOT merged into the main frame — a merged
cell would draw post-clear and z-fail against the root's seal.
- Look-in cells join the Prepare/partition set (shells get batches,
statics route to ByCell, consumed only by the sub-pass).
Pins: `Issue124LookInSeedRegionTests` (containing region floods ⊆
full-screen flood; disjoint region floods nothing; interior-side eye
never seeds its own exit door). Register: AP-33 (look-in statics drawn
whole — no per-part viewcone; look-in DYNAMICS deferred — an NPC inside
a far building stays invisible; both documented).
**Gate:** from inside a building, look out the door at another building
with an open door/window — its interior/back walls render through its
aperture instead of see-through to the world behind.
--- ---

View file

@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 3. Documented approximation (AP) — 32 rows ## 3. Documented approximation (AP) — 33 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -128,6 +128,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 | | AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| 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-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-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 |
--- ---

View file

@ -7628,9 +7628,9 @@ public sealed class GameWindow : IDisposable
// OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip + // OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip +
// OutsideView terrain integration that consumes this is the next (cutover) step. // OutsideView terrain integration that consumes this is the next (cutover) step.
_outdoorNode = null; _outdoorNode = null;
if (viewerRoot is null && viewerCellId != 0u)
{
_outdoorNodeBuildingCells.Clear(); _outdoorNodeBuildingCells.Clear();
if (viewerRoot is not null || viewerCellId != 0u)
{
// T2 (BR-4): draw-driven flood gating. Retail floods a building's // T2 (BR-4): draw-driven flood gating. Retail floods a building's
// interior exactly when its shell DRAWS and an aperture survives // interior exactly when its shell DRAWS and an aperture survives
// the view (DrawBuilding Ghidra 0x0059f2a0: per-view viewconeCheck // the view (DrawBuilding Ghidra 0x0059f2a0: per-view viewconeCheck
@ -7645,6 +7645,12 @@ public sealed class GameWindow : IDisposable
// Per-building iteration is also the FPS fix the 2026-06-07 // Per-building iteration is also the FPS fix the 2026-06-07
// Chebyshev hack approximated: dozens of AABB tests instead of an // Chebyshev hack approximated: dozens of AABB tests instead of an
// O(all loaded cells) portal sweep. // O(all loaded cells) portal sweep.
// #124: the gather now runs for INTERIOR roots too — retail's
// look-in executes inside LScape::draw for ANY root with a
// non-empty outside view (DrawCells pc:432719). The renderer
// routes interior-root look-ins to its landscape-stage sub-pass
// (DrawBuildingLookIns); the root's own building self-excludes
// via the seed eye-side test.
foreach (var registry in _buildingRegistries.Values) foreach (var registry in _buildingRegistries.Values)
{ {
foreach (var b in registry.All()) foreach (var b in registry.All())
@ -7659,10 +7665,11 @@ public sealed class GameWindow : IDisposable
_outdoorNodeBuildingCells.Add(bc); _outdoorNodeBuildingCells.Add(bc);
} }
} }
if (viewerRoot is null)
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId); _outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
Console.WriteLine(System.FormattableString.Invariant( Console.WriteLine(System.FormattableString.Invariant(
$"[outdoor-node] cell=0x{viewerCellId:X8} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)")); $"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)"));
} }
uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
@ -7788,10 +7795,10 @@ public sealed class GameWindow : IDisposable
var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext
{ {
RootCell = clipRoot, RootCell = clipRoot,
// R-A2: outdoor root floods each nearby building per-building (not via the root). The // R-A2: outdoor root floods each nearby building per-building (not via the root).
// gather above populates _outdoorNodeBuildingCells only on outdoor-node frames, so it // #124: interior roots get the gather too — the renderer routes them to the
// is fresh here exactly when clipRoot.IsOutdoorNode; null for interior roots. // landscape-stage look-in sub-pass instead of the merge.
NearbyBuildingCells = clipRoot.IsOutdoorNode ? _outdoorNodeBuildingCells : null, NearbyBuildingCells = _outdoorNodeBuildingCells,
ViewerEyePos = viewerEyePos, ViewerEyePos = viewerEyePos,
ViewProjection = envCellViewProj, ViewProjection = envCellViewProj,
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
@ -7837,6 +7844,11 @@ public sealed class GameWindow : IDisposable
DrawExitPortalMasks = sliceCtx => DrawExitPortalMasks = sliceCtx =>
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
forceFarZ: clipRoot.IsOutdoorNode), forceFarZ: clipRoot.IsOutdoorNode),
// #124: look-in apertures are ALWAYS the punch (retail
// maxZ1), independent of the root-keyed selector above.
DrawLookInPortalPunch = sliceCtx =>
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
forceFarZ: true),
DrawCellParticles = sliceCtx => DrawCellParticles = sliceCtx =>
DrawRetailPViewCellParticles(sliceCtx, camera, camPos), DrawRetailPViewCellParticles(sliceCtx, camera, camPos),
DrawDynamicsParticles = survivors => DrawDynamicsParticles = survivors =>

View file

@ -480,12 +480,18 @@ public static class PortalVisibilityBuilder
/// camera cell. It keeps the same retail distance-priority traversal and /// camera cell. It keeps the same retail distance-priority traversal and
/// neighbour reciprocal clipping once inside the building. /// neighbour reciprocal clipping once inside the building.
/// </summary> /// </summary>
/// <param name="seedRegion">Optional NDC region the seed apertures clip against —
/// retail's GetClip runs under the CURRENTLY INSTALLED view (PView::GetClip
/// 0x005a4320): full screen when the viewer is outdoors, the accumulated
/// outside (doorway) view when a building is looked into from an interior
/// root (#124). Null = full screen (the outdoor-root behavior).</param>
public static PortalVisibilityFrame BuildFromExterior( public static PortalVisibilityFrame BuildFromExterior(
IEnumerable<LoadedCell> candidateCells, IEnumerable<LoadedCell> candidateCells,
Vector3 cameraPos, Vector3 cameraPos,
Func<uint, LoadedCell?> lookup, Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj, Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity) float maxSeedDistance = float.PositiveInfinity,
IReadOnlyList<ViewPolygon>? seedRegion = null)
{ {
var frame = new PortalVisibilityFrame(); var frame = new PortalVisibilityFrame();
var todo = new CellTodoList(); var todo = new CellTodoList();
@ -532,7 +538,7 @@ public static class PortalVisibilityBuilder
poly, poly,
cell.WorldTransform, cell.WorldTransform,
viewProj, viewProj,
FullScreenRegion, seedRegion ?? FullScreenRegion,
out _); out _);
// T2 (BR-4): empty clip = no seed, no exceptions (retail's // T2 (BR-4): empty clip = no seed, no exceptions (retail's
@ -662,8 +668,9 @@ public static class PortalVisibilityBuilder
Vector3 cameraPos, Vector3 cameraPos,
Func<uint, LoadedCell?> lookup, Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj, Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity) float maxSeedDistance = float.PositiveInfinity,
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance); IReadOnlyList<ViewPolygon>? seedRegion = null)
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance, seedRegion);
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute. // The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
private static readonly Vector2[] FullScreenQuad = private static readonly Vector2[] FullScreenQuad =

View file

@ -27,6 +27,12 @@ public sealed class RetailPViewRenderer
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame). // R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
private readonly Dictionary<uint, List<LoadedCell>> _buildingGroups = new(); private readonly Dictionary<uint, List<LoadedCell>> _buildingGroups = new();
// #124: per-building look-in frames under an INTERIOR root, drawn as a
// landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the
// main frame (see DrawInside). Rebuilt each interior-root frame.
private readonly List<PortalVisibilityFrame> _lookInFrames = new();
private readonly HashSet<uint> _lookInPrepareScratch = new();
// T2 (BR-4): retail has NO distance constant on the flood-admission chain // T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test + // (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the // GetClip + GetVisible only). The old 48 m seed cap is replaced by the
@ -67,6 +73,26 @@ public sealed class RetailPViewRenderer
if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null) if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null)
MergeNearbyBuildingFloods(ctx, pvFrame); MergeNearbyBuildingFloods(ctx, pvFrame);
// #124: interior-root building look-ins. Retail runs the look-in INSIDE
// the landscape stage for ANY root — LScape::draw is the FIRST call of
// DrawCells' outside-view branch (pc:432719), strictly BEFORE the depth
// clear (pc:432732) and the exit-portal seals (pc:432785); a far
// building seen through our doorway floods clipped to the INSTALLED
// outside view (GetClip vs current view, ConstructView(CBldPortal)
// 0x005a59a0). These frames therefore draw in DrawBuildingLookIns
// (inside the landscape stage), NEVER merged into the main frame — a
// merged cell would draw post-clear and z-fail against the root's seal
// (its geometry is beyond the door plane). The eye-side seed test
// self-excludes the root's own building (the eye is on its interior
// side). Outdoor roots keep the MergeNearbyBuildingFloods path above
// (no depth clear under outdoor roots — the merged form is equivalent
// there).
_lookInFrames.Clear();
if (!ctx.RootCell.IsOutdoorNode
&& ctx.NearbyBuildingCells is not null
&& pvFrame.OutsideView.Polygons.Count > 0)
BuildInteriorRootLookIns(ctx, pvFrame);
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame); var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
UploadClipFrame(ctx.SetTerrainClipUbo); UploadClipFrame(ctx.SetTerrainClipUbo);
@ -78,15 +104,31 @@ public sealed class RetailPViewRenderer
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells); var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells);
UseIndoorMembershipOnlyRouting(); UseIndoorMembershipOnlyRouting();
// #124: look-in cells need prepared shell batches + their statics routed
// into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main
// cell-object pass iterates pvFrame.OrderedVisibleCells, which never
// contains them). drawableCells itself stays the MAIN flood: it feeds the
// seals, the outside-stage predicate, and the frame result.
var prepareCells = drawableCells;
if (_lookInFrames.Count > 0)
{
_lookInPrepareScratch.Clear();
_lookInPrepareScratch.UnionWith(drawableCells);
foreach (var f in _lookInFrames)
foreach (uint c in f.OrderedVisibleCells)
_lookInPrepareScratch.Add(c);
prepareCells = _lookInPrepareScratch;
}
_envCells.PrepareRenderBatches( _envCells.PrepareRenderBatches(
ctx.ViewProjection, ctx.ViewProjection,
ctx.CameraWorldPosition, ctx.CameraWorldPosition,
filter: drawableCells, filter: prepareCells,
centerLbX: ctx.RenderCenterLbX, centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY, centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius); renderRadius: ctx.RenderRadius);
var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries); var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries);
var result = new RetailPViewFrameResult var result = new RetailPViewFrameResult
{ {
PortalFrame = pvFrame, PortalFrame = pvFrame,
@ -215,6 +257,105 @@ public sealed class RetailPViewRenderer
} }
} }
// #124: per-building look-in floods for an INTERIOR root, seeded clipped
// against the OutsideView (retail: GetClip runs under the INSTALLED view —
// the accumulated doorway region — so a far building floods only within the
// doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip
// 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own
// building self-excludes via the seed eye-side test.
private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
{
foreach (var group in _buildingGroups.Values)
group.Clear();
foreach (var cell in ctx.NearbyBuildingCells!)
{
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_buildingGroups.TryGetValue(groupKey, out var group))
{
group = new List<LoadedCell>();
_buildingGroups[groupKey] = group;
}
group.Add(cell);
}
foreach (var group in _buildingGroups.Values)
{
if (group.Count == 0)
continue;
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection,
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons);
if (frame.OrderedVisibleCells.Count > 0)
_lookInFrames.Add(frame);
}
}
// #124: draw the interior-root look-ins INSIDE the landscape stage —
// retail's placement (LScape::draw → DrawBlock → DrawSortCell →
// DrawBuilding runs as the FIRST call of DrawCells' outside-view branch,
// pc:432719, before the depth clear + seals). Per building: punch ALL
// apertures first (retail finishes build_draw_portals_only pass 1 — the
// far-Z maxZ1 punch — across the whole building BSP before pass 2 floods),
// then draw the flooded cells' shells + statics far→near (the nested
// DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is
// empty by construction — PView ctor draw_landscape=0 — so no recursive
// landscape/clear/seal). Anything rasterized outside an aperture is
// repainted by the root's own shells after the depth clear, so over-draw
// here is color-safe; statics draw whole (the main viewcone has no entry
// for look-in cells; over-include is the safe direction).
private void DrawBuildingLookIns(RetailPViewDrawContext ctx, InteriorEntityPartition.Result partition)
{
if (_lookInFrames.Count == 0)
return;
foreach (var frame in _lookInFrames)
{
// Pass 1: far-Z punch every aperture of this building.
if (ctx.DrawLookInPortalPunch is not null)
{
foreach (uint cellId in frame.OrderedVisibleCells)
{
if (!frame.CellViews.TryGetValue(cellId, out var view))
continue;
foreach (var poly in view.Polygons)
{
var single = new CellView();
single.Add(poly);
var cps = ClipPlaneSet.From(single);
if (cps.IsNothingVisible)
continue;
var planes = new Vector4[cps.Count];
for (int p = 0; p < cps.Count; p++)
planes[p] = cps.Planes[p];
ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext(
cellId,
new ClipViewSlice(0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), planes),
Array.Empty<WorldEntity>()));
}
}
}
// Pass 2: shells + statics, far→near.
UseIndoorMembershipOnlyRouting();
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
_oneCell.Clear();
_oneCell.Add(cellId);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
if (partition.ByCell.TryGetValue(cellId, out var bucket) && bucket.Count > 0)
{
_cellStaticScratch.Clear();
_cellStaticScratch.AddRange(bucket);
DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
}
}
}
}
private void DrawLandscapeThroughOutsideView( private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx, RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly, ClipFrameAssembly clipAssembly,
@ -260,6 +401,12 @@ public sealed class RetailPViewRenderer
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
} }
// #124: far-building look-ins draw HERE — still inside the landscape
// stage (their punches mark against the terrain/exterior depth just
// drawn), strictly BEFORE the depth clear + seals below, matching
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
DrawBuildingLookIns(ctx, partition);
// T1: retail clears the FULL depth buffer ONCE between the outside // T1: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 — // stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// Clear gated on portalsDrawnCount; exact gate semantics is a plan // Clear gated on portalsDrawnCount; exact gate semantics is a plan
@ -667,6 +814,12 @@ public interface IRetailPViewCellDrawCallbacks
{ {
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; } public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; } public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; }
/// <summary>#124: far-Z punch one look-in aperture (a clipped view polygon
/// of a looked-into building cell) — always the PUNCH variant regardless
/// of root kind (retail maxZ1; the root-keyed forceFarZ selector only
/// governs the MAIN frame's exit-portal masks).</summary>
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; }
} }
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
@ -713,6 +866,7 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public Action? ClearDepthForInterior { get; init; } public Action? ClearDepthForInterior { get; init; }
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; } public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; } public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; } public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; } public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
} }

View file

@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #124 — far-building interiors under an INTERIOR root. Retail seeds the
/// look-in flood by clipping a building's aperture against the CURRENTLY
/// INSTALLED view (PView::GetClip 0x005a4320 inside ConstructView(CBldPortal)
/// 0x005a59a0): full screen outdoors, the accumulated doorway (outside) view
/// when looked into from inside. These tests pin BuildFromExterior's
/// seedRegion parameter — the port of that installed-view clip — against the
/// real Holtburg corner-building door.
/// </summary>
public class Issue124LookInSeedRegionTests
{
private readonly ITestOutputHelper _out;
public Issue124LookInSeedRegionTests(ITestOutputHelper output) => _out = output;
private const uint ExitCellId = CornerFloodReplayTests.Landblock | 0x0170u;
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
{
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f);
return view * proj;
}
private static (Dictionary<uint, LoadedCell> cells, LoadedCell exitCell, int exitIdx,
Vector3 centroid, Vector3 outward) LoadFixture(DatCollection dats)
{
var cells = CornerFloodReplayTests.LoadBuilding(dats);
var exitCell = cells[ExitCellId];
int exitIdx = -1;
for (int i = 0; i < exitCell.Portals.Count; i++)
{
if (exitCell.Portals[i].OtherCellId == 0xFFFF && i < exitCell.PortalPolygons.Count
&& exitCell.PortalPolygons[i].Length >= 3)
{ exitIdx = i; break; }
}
Assert.True(exitIdx >= 0);
var localPoly = exitCell.PortalPolygons[exitIdx];
Vector3 centroid = Vector3.Zero;
foreach (var lp in localPoly)
centroid += Vector3.Transform(lp, exitCell.WorldTransform);
centroid /= localPoly.Length;
var plane = exitCell.ClipPlanes[exitIdx];
var normal = Vector3.TransformNormal(plane.Normal, exitCell.WorldTransform);
var cellCenter = Vector3.Transform(
(exitCell.LocalBoundsMin + exitCell.LocalBoundsMax) * 0.5f, exitCell.WorldTransform);
// outward = away from the cell interior.
if (Vector3.Dot(normal, cellCenter - centroid) > 0)
normal = -normal;
return (cells, exitCell, exitIdx, centroid, Vector3.Normalize(normal));
}
private static Vector4 ApertureNdcAabb(LoadedCell cell, int idx, Matrix4x4 viewProj)
{
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
foreach (var lp in cell.PortalPolygons[idx])
{
var w = Vector3.Transform(lp, cell.WorldTransform);
var c = Vector4.Transform(new Vector4(w, 1f), viewProj);
Assert.True(c.W > 0.05f, "fixture eye must keep the aperture fully in front");
minX = MathF.Min(minX, c.X / c.W); maxX = MathF.Max(maxX, c.X / c.W);
minY = MathF.Min(minY, c.Y / c.W); maxY = MathF.Max(maxY, c.Y / c.W);
}
return new Vector4(minX, minY, maxX, maxY);
}
private static ViewPolygon Quad(float minX, float minY, float maxX, float maxY) =>
new(new[]
{
new Vector2(minX, minY), new Vector2(maxX, minY),
new Vector2(maxX, maxY), new Vector2(minX, maxY),
});
[Fact]
public void SeedRegion_ContainingAperture_Floods_DisjointRegion_DoesNot()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var (cells, exitCell, exitIdx, centroid, outward) = LoadFixture(dats);
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null;
// Eye OUTSIDE the building, 3 m in front of the exit door, gaze at it
// — the look-in geometry of a viewer peering at this building through
// some other opening.
var eye = centroid + outward * 3f;
var viewProj = ViewProjFor(eye, centroid);
var ap = ApertureNdcAabb(exitCell, exitIdx, viewProj);
_out.WriteLine(FormattableString.Invariant(
$"aperture ndc=({ap.X:F3},{ap.Y:F3},{ap.Z:F3},{ap.W:F3})"));
// Sanity: the full-screen (outdoor-root) seed floods.
var full = PortalVisibilityBuilder.BuildFromExterior(
cells.Values, eye, Lookup, viewProj);
Assert.True(full.OrderedVisibleCells.Count > 0, "full-screen seed must flood");
// A region containing the aperture floods — and never MORE than the
// full-screen seed (region-restricting can only shrink the flood).
var containing = new[] { Quad(ap.X - 0.05f, ap.Y - 0.05f, ap.Z + 0.05f, ap.W + 0.05f) };
var seeded = PortalVisibilityBuilder.BuildFromExterior(
cells.Values, eye, Lookup, viewProj, float.PositiveInfinity, containing);
Assert.True(seeded.OrderedVisibleCells.Count > 0, "containing region must flood");
Assert.True(seeded.OrderedVisibleCells.Count <= full.OrderedVisibleCells.Count);
// A region strictly disjoint from the aperture must not flood — the
// doorway doesn't show this building, so its interior never builds
// (retail: GetClip vs the installed view returns empty → no look-in).
Assert.True(ap.Z < 0.70f || ap.X > -0.70f, "fixture aperture unexpectedly fills the screen");
var disjoint = ap.Z < 0.70f
? new[] { Quad(0.75f, 0.75f, 0.99f, 0.99f) }
: new[] { Quad(-0.99f, -0.99f, -0.75f, -0.75f) };
var none = PortalVisibilityBuilder.BuildFromExterior(
cells.Values, eye, Lookup, viewProj, float.PositiveInfinity, disjoint);
Assert.True(none.OrderedVisibleCells.Count == 0,
FormattableString.Invariant($"disjoint region flooded {none.OrderedVisibleCells.Count} cells"));
}
[Fact]
public void EyeOnInteriorSide_ExitDoorNeverSeeds()
{
// The root's own doorway must not look-in on itself: the seed eye-side
// test (retail ConstructView's sidedness vs portal_side) excludes any
// aperture the eye is on the interior side of — this is what lets the
// interior-root gather pass ALL nearby buildings including the
// viewer's own without special-casing.
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var (cells, exitCell, _, centroid, outward) = LoadFixture(dats);
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null;
var eye = centroid - outward * 2f; // 2 m INSIDE the doorway
var viewProj = ViewProjFor(eye, centroid);
var frame = PortalVisibilityBuilder.BuildFromExterior(
new[] { exitCell }, eye, Lookup, viewProj);
Assert.True(frame.OrderedVisibleCells.Count == 0,
"an interior-side eye must not seed its own cell's exit portal");
}
}