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

@ -27,6 +27,12 @@ public sealed class RetailPViewRenderer
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
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
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// 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)
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);
UploadClipFrame(ctx.SetTerrainClipUbo);
@ -78,15 +104,31 @@ public sealed class RetailPViewRenderer
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells);
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(
ctx.ViewProjection,
ctx.CameraWorldPosition,
filter: drawableCells,
filter: prepareCells,
centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries);
var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries);
var result = new RetailPViewFrameResult
{
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(
RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly,
@ -260,6 +401,12 @@ public sealed class RetailPViewRenderer
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
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// 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>? 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
@ -713,6 +866,7 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public Action? ClearDepthForInterior { get; init; }
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
}