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

@ -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");
}
}