feat(render): indoor render WORKS — terminating portal flood + every-cell seal + look-in FPS

Checkpoint of the unified retail-faithful indoor render. The two-week HANG/grey is fixed and the
interior seals (live-verified by the user). Commits the session render-rewrite foundation together
with the fixes that made it functional.

- HANG fix: PortalVisibilityBuilder.Build portal flood did not terminate (the faithful ProjectToClip
  near-side clip drifts per round, defeating the CellView dedup; the BFS had no bound after U.2a removed
  MaxReprocessPerCell). Fix = drift-tolerant snapped/canonical CellView.Add dedup (PortalView.cs) plus
  restored MaxReprocessPerCell=16 bounded re-enqueue (PortalVisibilityBuilder.cs). Re-enqueue is kept
  (load-bearing for late-slice propagation, Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit);
  only its count is capped. CellViewDedupTests added.
- Seal (DrawCells Task 2): RetailPViewRenderer.DrawEnvCellShells draws EVERY visible cell via
  IndoorDrawPlan.ShellPass (was gated on the ClipFrameAssembler slot filter, leaving slot-less cells grey).
- Look-in FPS: GameWindow exterior look-in candidates limited to the player landblock +-1 (was all ~81
  loaded LBs iterated every outdoor frame). No behaviour change (far cells were >48m, already culled).

Remaining dominant issue = the FLAP at transitions: viewer-cell metastability (render roots at the
camera-eye cell, which oscillates outdoor-indoor as the 3rd-person boom drifts across the doorway,
confirmed in render-sig). SEPARATE fix, NOT the DrawCells port. Full handoff + flap fix plan + tracked
follow-ups (#78 terrain, look-in-from-inside, look-in FPS, L-spotlight):
docs/research/2026-06-07-indoor-render-session-handoff.md.

Baselines: build 0 err; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-07 10:14:43 +02:00
parent bff1955066
commit 1405dd8e90
27 changed files with 3635 additions and 814 deletions

View file

@ -37,6 +37,19 @@ public static class PortalVisibilityBuilder
{
private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon
// Bounded re-enqueue cap (restored 2026-06-07). The distance-priority portal flood re-enqueues a
// cell whenever its accumulated view GROWS, which is load-bearing — it propagates a late-discovered
// portal_view slice to that cell's exit portals (Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit).
// But the faithful near-side clip (ProjectToClip) drifts per round, so re-clipping a cell's view yields
// ever-smaller distinct sub-regions / drifted near-duplicates the dedup can't always collapse -> the
// grow flag never settles and the flood spins forever (the indoor hang). This cap bounds each cell to
// at most this many pops, so the flood terminates in <= N*cap pops regardless of drift while still
// allowing the few re-processes that legitimate late-slice propagation needs. The old hard cap removed
// in U.2a was 4; widened here because ProjectToClip drifts more than the old ProjectToNdc and Option A's
// CellView dedup already collapses most spurious growth, so the cap rarely binds. Tune from the visual
// gate if an interior view under-includes a slice.
private const int MaxReprocessPerCell = 16;
// TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the
// local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell.
private static readonly bool s_pvDump =
@ -81,7 +94,11 @@ public static class PortalVisibilityBuilder
// the instant a cell is popped). Enqueue-once across the cell set is the hard termination
// guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The
// camera cell is pre-marked so a portal looping back to it can never re-enqueue it.
var seen = new HashSet<uint> { cameraCell.CellId };
var queued = new HashSet<uint> { cameraCell.CellId };
var drawListed = new HashSet<uint>();
var processedViewCounts = new Dictionary<uint, int>();
var popCounts = new Dictionary<uint, int>(); // per-cell pop count for the MaxReprocessPerCell cap
var trace = PortalBuildTrace.Start(cameraCell, cameraPos);
bool pvDump = false;
if (s_pvDump)
@ -116,46 +133,81 @@ public static class PortalVisibilityBuilder
while (todo.Count > 0)
{
var cell = todo.PopNearest();
queued.Remove(cell.CellId);
// Bounded re-enqueue (2026-06-07 termination fix): count this pop. The re-enqueue gate below
// refuses to re-add a cell already popped MaxReprocessPerCell times, so the flood terminates
// even when ProjectToClip drift keeps a view growing forever. Re-enqueue itself is KEPT — it
// propagates late-discovered slices to exit portals (see MaxReprocessPerCell); only its count
// is capped.
popCounts.TryGetValue(cell.CellId, out int popsSoFar);
popCounts[cell.CellId] = popsSoFar + 1;
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
{
trace?.Add($"pop cell=0x{cell.CellId:X8} skip=no-view");
continue;
}
// `seen` guarantees each cell is inserted into the todo list exactly once, so this single
// pop IS the cell's closest-first draw position (retail appends to cell_draw_list once per
// pop, 433783) — no per-pop dedup needed, OrderedVisibleCells stays distinct by construction.
frame.OrderedVisibleCells.Add(cell.CellId);
if (drawListed.Add(cell.CellId))
frame.OrderedVisibleCells.Add(cell.CellId);
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
int endCount = currentView.Polygons.Count;
if (processedCount >= endCount)
{
trace?.Add($"pop cell=0x{cell.CellId:X8} skip=processed processed={processedCount} views={endCount}");
continue;
}
trace?.Add($"pop cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} drawPos={frame.OrderedVisibleCells.Count - 1}");
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
processedViewCounts[cell.CellId] = endCount;
for (int i = 0; i < cell.Portals.Count; i++)
{
if (i >= cell.PortalPolygons.Count) continue;
var portal = cell.Portals[i];
if (i >= cell.PortalPolygons.Count)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=no-poly-slot");
continue;
}
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3) continue;
if (poly == null || poly.Length < 3)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=degenerate-poly len={(poly?.Length ?? -1)}");
continue;
}
bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF;
bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos);
bool sideAllowed = true;
// Portal-side test: only traverse a portal the camera is on the interior side of
// (mirrors CellVisibility.GetVisibleCells + retail's 'seen' flag). Culls back-facing
// portals so we never feed a degenerate/wrong-facing projection downstream.
if (i < cell.ClipPlanes.Count && !CameraOnInteriorSide(cell, i, cameraPos))
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos)
&& !eyeInsideOpening)
{
sideAllowed = false;
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side eyeIn={eyeInsideOpening}");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}");
continue;
}
// Project to NDC, then normalize to CCW for the CCW-only ScreenPolygonClip
// (ProjectToNdc preserves input winding; portal dat polygons may be CW).
Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj);
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2}) ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F2},{v.Y:F2})"))}]");
var clippedRegion = new List<ViewPolygon>();
if (portalNdc.Length >= 3)
{
EnsureCcw(portalNdc);
// Intersect the portal opening with every polygon of the current cell's view.
foreach (var vp in currentView.Polygons)
{
var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices);
if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped));
}
}
// Retail PView::ClipPortals calls GetClip(..., finish=1): transform to
// homogeneous clip space, clip at the eye, then clip against the current
// portal_view region before the divide. Do the same here; the old early
// ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways.
var clippedRegion = ClipPortalAgainstView(
poly,
cell.WorldTransform,
viewProj,
activeViewPolygons,
out int clipVerts);
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
// R1 void fix (2026-06-05): the projected+clipped region is empty — normally we cull the
@ -171,25 +223,26 @@ public static class PortalVisibilityBuilder
if (clippedRegion.Count == 0)
{
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty side={sideAllowed} eyeIn={eyeInsideOpening} clipVerts={clipVerts}");
continue; // portal not visible through this chain, and the eye is not standing in it
foreach (var vp in currentView.Polygons)
}
foreach (var vp in activeViewPolygons)
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
}
var portal = cell.Portals[i];
if (portal.OtherCellId == 0xFFFF)
{
if (pvDump)
{
Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} clipPolys={clippedRegion.Count}");
Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipVerts={clipVerts} clipPolys={clippedRegion.Count}");
Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]");
Console.WriteLine($"[pv-dump] ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F3},{v.Y:F3})"))}]");
foreach (var cp in clippedRegion)
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
}
// Exit portal -> outdoors visible through this (clipped) opening.
foreach (var cp in clippedRegion) frame.OutsideView.Add(cp);
AddRegion(frame.OutsideView, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts} eyeIn={eyeInsideOpening}");
continue;
}
@ -202,12 +255,17 @@ public static class PortalVisibilityBuilder
if (buildingMembership != null && !buildingMembership(neighbourId))
{
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId);
foreach (var cp in clippedRegion) xview.Add(cp);
bool grewCross = AddRegion(xview, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}");
continue;
}
var neighbour = lookup(neighbourId);
if (neighbour == null) continue;
if (neighbour == null)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=lookup-miss polys={clippedRegion.Count}");
continue;
}
// Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip
// decomp:433524). The portal opening seen from THIS cell may be wider than the
@ -222,12 +280,24 @@ public static class PortalVisibilityBuilder
// direct index is what lets a cell with TWO portals to the same neighbour clip each
// opening against its OWN reciprocal instead of the first one. Mutates clippedRegion
// in place before the union below.
var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null;
int preReciprocalCount = clippedRegion.Count;
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
if (clippedRegion.Count == 0) continue; // reciprocal opening doesn't overlap → not visible
if (clippedRegion.Count == 0)
{
if (preReciprocalClip is null)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=reciprocal-empty pre={preReciprocalCount} otherPortal={portal.OtherPortalId}");
continue;
}
clippedRegion.AddRange(preReciprocalClip);
}
// Union the clipped region into the neighbour's accumulated view.
var nview = GetOrCreate(frame.CellViews, neighbourId);
foreach (var cp in clippedRegion) nview.Add(cp);
bool grew = AddRegion(nview, clippedRegion);
bool inserted = false;
float dist = float.NaN;
// Insert the neighbour into the distance-priority list — but ONLY on first discovery
// (retail enqueues via InsCellTodoList solely in the ecx_5==0 branch; growth into an
@ -237,11 +307,13 @@ public static class PortalVisibilityBuilder
// portal-opening vertex in world space (retail InitCell min-vertex distance,
// 432988-433004); derived from the portal geometry, so it works even when the cell's
// WorldPosition was never populated.
if (seen.Add(neighbourId))
if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
{
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist);
inserted = true;
}
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} addCell polys={clippedRegion.Count} clipVerts={clipVerts} recip={preReciprocalCount}->{clippedRegion.Count} grew={grew} queued={inserted} dist={(float.IsNaN(dist) ? "na" : dist.ToString("F2"))}");
}
}
@ -252,6 +324,161 @@ public static class PortalVisibilityBuilder
// root cell's per-portal side-test + projection + the frame's exit/visible counts.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
EmitFlapProbe(cameraCell, cameraPos, viewProj, frame);
trace?.Emit(frame);
return frame;
}
/// <summary>
/// Build a portal visibility frame for an OUTDOOR viewer looking into one or more
/// outside-facing cell portals. This is the reciprocal of <see cref="Build"/>:
/// the seed view is the projected exit-portal opening instead of a full-screen
/// camera cell. It keeps the same retail distance-priority traversal and
/// neighbour reciprocal clipping once inside the building.
/// </summary>
public static PortalVisibilityFrame BuildFromExterior(
IEnumerable<LoadedCell> candidateCells,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity)
{
var frame = new PortalVisibilityFrame();
var todo = new CellTodoList();
var queued = new HashSet<uint>();
var drawListed = new HashSet<uint>();
var processedViewCounts = new Dictionary<uint, int>();
var popCounts = new Dictionary<uint, int>(); // per-cell pop count for the MaxReprocessPerCell cap
foreach (var cell in candidateCells)
{
if (cell is null) continue;
for (int i = 0; i < cell.Portals.Count; i++)
{
var portal = cell.Portals[i];
if (portal.OtherCellId != 0xFFFF)
continue;
if (i >= cell.PortalPolygons.Count)
continue;
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3)
continue;
// Exterior peering starts from the OUTSIDE face of an exit portal.
// If the camera is on the cell-interior side, the normal indoor
// DrawInside path owns this portal instead.
if (i < cell.ClipPlanes.Count && CameraOnInteriorSide(cell, i, cameraPos))
continue;
float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
if (seedDistance > maxSeedDistance)
continue;
var clippedRegion = ClipPortalAgainstView(
poly,
cell.WorldTransform,
viewProj,
FullScreenRegion,
out _);
if (clippedRegion.Count == 0)
{
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
continue;
clippedRegion.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone()));
}
var seedView = GetOrCreate(frame.CellViews, cell.CellId);
bool grew = AddRegion(seedView, clippedRegion);
if (grew && queued.Add(cell.CellId))
todo.Insert(cell, seedDistance);
}
}
while (todo.Count > 0)
{
var cell = todo.PopNearest();
queued.Remove(cell.CellId);
// Bounded re-enqueue — see the matching note in Build(). Count this pop; the gate below caps
// re-enqueues at MaxReprocessPerCell so the look-in flood terminates under ProjectToClip drift.
popCounts.TryGetValue(cell.CellId, out int popsSoFar);
popCounts[cell.CellId] = popsSoFar + 1;
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
continue;
if (drawListed.Add(cell.CellId))
frame.OrderedVisibleCells.Add(cell.CellId);
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
int endCount = currentView.Polygons.Count;
if (processedCount >= endCount)
continue;
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
processedViewCounts[cell.CellId] = endCount;
uint lbMask = cell.CellId & 0xFFFF0000u;
for (int i = 0; i < cell.Portals.Count; i++)
{
if (i >= cell.PortalPolygons.Count)
continue;
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3)
continue;
var portal = cell.Portals[i];
if (portal.OtherCellId == 0xFFFF)
continue; // already outdoors; exterior terrain was drawn by the caller.
bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos);
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos)
&& !eyeInsideOpening)
continue;
var clippedRegion = ClipPortalAgainstView(
poly,
cell.WorldTransform,
viewProj,
activeViewPolygons,
out _);
if (clippedRegion.Count == 0)
{
if (!eyeInsideOpening)
continue;
foreach (var vp in activeViewPolygons)
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
}
uint neighbourId = lbMask | portal.OtherCellId;
var neighbour = lookup(neighbourId);
if (neighbour == null)
continue;
var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null;
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
if (clippedRegion.Count == 0)
{
if (preReciprocalClip is null)
continue;
clippedRegion.AddRange(preReciprocalClip);
}
var nview = GetOrCreate(frame.CellViews, neighbourId);
bool grew = AddRegion(nview, clippedRegion);
if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
{
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist);
}
}
}
return frame;
}
@ -260,6 +487,117 @@ public static class PortalVisibilityBuilder
private static readonly Vector2[] FullScreenQuad =
{ new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
private static readonly ViewPolygon[] FullScreenRegion =
{ new ViewPolygon(FullScreenQuad) };
private static List<ViewPolygon> ClipPortalAgainstView(
Vector3[] localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj,
IReadOnlyList<ViewPolygon> viewPolygons,
out int clipVertexCount)
{
var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj);
clipVertexCount = portalClip.Length;
var clippedRegion = new List<ViewPolygon>();
if (portalClip.Length < 3)
return clippedRegion;
foreach (var vp in viewPolygons)
{
if (vp.IsEmpty)
continue;
var clipped = PortalProjection.ClipToRegion(portalClip, vp.Vertices);
if (clipped.Length >= 3)
clippedRegion.Add(new ViewPolygon(clipped));
}
return clippedRegion;
}
private const int PortalTraceEmitLimit = 160;
private static readonly object s_portalTraceLock = new();
private static readonly Dictionary<uint, string> s_portalTraceLastSignature = new();
private static int s_portalTraceEmits;
private sealed class PortalBuildTrace
{
private readonly uint _rootCellId;
private readonly Vector3 _eye;
private readonly List<string> _lines = new();
private PortalBuildTrace(uint rootCellId, Vector3 eye)
{
_rootCellId = rootCellId;
_eye = eye;
}
public static PortalBuildTrace? Start(LoadedCell root, Vector3 eye)
{
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
return null;
if (!IsHoltburgIndoorProbeCell(root.CellId))
return null;
return new PortalBuildTrace(root.CellId, eye);
}
public void Add(string line)
{
if (_lines.Count < 96)
_lines.Add(line);
}
public void Emit(PortalVisibilityFrame frame)
{
string signature = BuildSignature(frame);
lock (s_portalTraceLock)
{
if (s_portalTraceEmits >= PortalTraceEmitLimit)
return;
if (s_portalTraceLastSignature.TryGetValue(_rootCellId, out var last) &&
string.Equals(last, signature, StringComparison.Ordinal))
return;
s_portalTraceLastSignature[_rootCellId] = signature;
s_portalTraceEmits++;
}
Console.WriteLine($"[pv-trace] root=0x{_rootCellId:X8} eye=({_eye.X:F2},{_eye.Y:F2},{_eye.Z:F2}) {signature}");
foreach (var line in _lines)
Console.WriteLine("[pv-trace] " + line);
}
}
private static bool IsHoltburgIndoorProbeCell(uint cellId)
{
if ((cellId & 0xFFFF0000u) != 0xA9B40000u)
return false;
uint low = cellId & 0xFFFFu;
return low >= 0x016F && low <= 0x0175;
}
private static string BuildSignature(PortalVisibilityFrame frame)
{
var sb = new System.Text.StringBuilder(160);
sb.Append("outPolys=").Append(frame.OutsideView.Polygons.Count);
sb.Append(" cells=[");
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
{
if (i != 0) sb.Append(',');
sb.Append("0x").Append((frame.OrderedVisibleCells[i] & 0xFFFFu).ToString("X4"));
}
sb.Append("] views=[");
bool first = true;
foreach (var kvp in frame.CellViews)
{
if (!first) sb.Append(',');
first = false;
sb.Append("0x").Append((kvp.Key & 0xFFFFu).ToString("X4")).Append(':').Append(kvp.Value.Polygons.Count);
}
sb.Append(']');
return sb.ToString();
}
// Phase U.4c flap probe. One [flap] line per Build: the root cell's per-portal
// signed distance D (eye→portal plane), traverse/cull decision, and NDC projection
// vertex count, plus the frame's OutsideView polygon count + visible-cell count.
@ -288,10 +626,10 @@ public static class PortalVisibilityBuilder
d = Vector3.Dot(pl.Normal, localEye) + pl.D;
side = CameraOnInteriorSide(cameraCell, i, cameraPos);
}
// Replicate the walk's project → EnsureCcw → Intersect(FullScreen) exactly, so a
// portal that PROJECTS (proj>=3) but still fails to ADD its neighbour shows WHY:
// clip=0 with ndc inside [-1,1] ⇒ winding/self-intersection degeneracy; clip=0 with
// ndc outside [-1,1] ⇒ genuinely off-screen. The ndc coords expose a near-plane bowtie.
// Replicate the walk's faithful path exactly (ProjectToClip → ClipToRegion(FullScreen)) so
// proj/clip mean the same as production: proj = clip-space verts in front of the eye,
// clip = verts surviving the screen-region clip. clip=0 with proj>=3 ⇒ the portal is
// genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands.
int projN = -1, clipN = -1;
string ndcText = "";
if (i < cameraCell.PortalPolygons.Count)
@ -299,12 +637,12 @@ public static class PortalVisibilityBuilder
var poly = cameraCell.PortalPolygons[i];
if (poly != null && poly.Length >= 3)
{
var ndc = PortalProjection.ProjectToNdc(poly, cameraCell.WorldTransform, viewProj);
projN = ndc.Length;
if (ndc.Length >= 3)
var clip = PortalProjection.ProjectToClip(poly, cameraCell.WorldTransform, viewProj);
projN = clip.Length;
if (clip.Length >= 3)
{
EnsureCcw(ndc);
clipN = ScreenPolygonClip.Intersect(ndc, FullScreenQuad).Length;
var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad);
clipN = ndc.Length;
var ns = new System.Text.StringBuilder(48);
foreach (var v in ndc) ns.Append('(').Append(v.X.ToString("F1")).Append(',').Append(v.Y.ToString("F1")).Append(')');
ndcText = ns.ToString();
@ -376,6 +714,13 @@ public static class PortalVisibilityBuilder
// Project the reciprocal opening through the NEIGHBOUR's transform (retail positionPush(3,
// &other_cell_ptr->pos) at 005a54d2), then normalize winding for the CCW-only clipper.
// NOTE: this stays on the divide-then-clip ProjectToNdc path on purpose. The reciprocal is a
// back-portal one hop away — never near the eye — so the homogeneous clip buys nothing here,
// and ProjectToNdc is float-stable across the BFS re-enqueue rounds. Routing it through
// ProjectToClip+ClipToRegion produced per-round float drift that defeated the CellView
// SamePolygon dedup, inflating a tight A<->B reciprocal view to ~4x its area
// (Build_AppliesReciprocalOtherPortalClip). The near-side clip (ClipPortalAgainstView) IS the
// homogeneous path; this secondary tightening is not.
Vector2[] reciprocalNdc = PortalProjection.ProjectToNdc(reciprocalPoly, neighbour.WorldTransform, viewProj);
if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op
EnsureCcw(reciprocalNdc);
@ -395,11 +740,27 @@ public static class PortalVisibilityBuilder
return v;
}
private static bool AddRegion(CellView view, List<ViewPolygon> region)
{
bool grew = false;
foreach (var poly in region)
grew |= view.Add(poly);
return grew;
}
// Camera→nearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal
// min-distance loop retail runs in PView::InitCell (decomp:432988-433004) to key the todo list:
// it walks the portal's vertices, transforms each to world space, and keeps the smallest
// straight-line distance to the camera viewpoint. Keying on the portal opening (not the cell
// origin) is both retail-faithful and robust to cells whose WorldPosition was never populated.
private static List<ViewPolygon> CloneViewPolygons(List<ViewPolygon> source)
{
var clone = new List<ViewPolygon>(source.Count);
foreach (var poly in source)
clone.Add(new ViewPolygon((Vector2[])poly.Vertices.Clone()));
return clone;
}
private static float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos)
{
float best = float.MaxValue;
@ -413,10 +774,11 @@ public static class PortalVisibilityBuilder
}
// "Eye standing in the opening": the eye is within this perpendicular distance of a portal's
// plane. At the cottage doorway the live capture measured D=0.16 m for the portal the chase
// camera was standing in; 0.5 m comfortably covers a doorway-standing eye while excluding portals
// the eye is merely facing from across a room (their projection is non-degenerate anyway).
private const float EyeStandingPerpDist = 0.5f;
// plane. Live captures hit two retail-valid degenerate cases: cottage doorway D=0.16 m and
// cellar->stair portal D=1.41 m, both traversable but ProjectToNdc returned zero vertices. We still
// require the perpendicular projection to land inside the opening, so side/offscreen portals stay
// culled; this only covers active portals whose 2D projection collapses near the chase camera.
private const float EyeStandingPerpDist = 1.75f;
/// <summary>
/// True when the camera eye is "standing in" <paramref name="localPoly"/>'s opening: within