acdream/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
Erik 9f95252d20 fix(render): flood the neighbour when the eye stands in an interior portal
When the chase camera roots in a thin doorway cell and the eye stands in an interior portal opening (live capture: vestibule->room portal D=0.16m, proj=0), the 2D projection degenerates and the neighbour was culled (cells=1) -> only the thin cell drew -> bluish void / transparent ceiling. Retail's 3D clip imposes no constraint for a portal the eye is inside, so the neighbour is fully visible. When the clipped region is empty but the eye stands in the opening (EyeInsidePortalOpening: within 0.5m of the portal plane AND point-in-opening), flood the neighbour with the current view. Guarded so an off-screen degenerate portal stays culled (no #95 blowup; over-include is mesh-frustum-culled at draw). Visual-verified: cellar ceiling now solid.

Band-aid for thin-cell-root coverage; likely superseded by the boom-stability + viewer-cell dead-zone + w=0 near-plane clip fix next session (reassess / maybe revert). 2 RED->GREEN tests; cyclic/hub termination guards unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:27:39 +02:00

511 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PortalVisibilityBuilder.cs
//
// Phase A8.F: recursive portal-clip visibility (the builder). Port of retail
// PView::ConstructView (decomp:433750) -> ClipPortals (433572) -> AddViewToPortals
// (433446). Walks the portal graph from the camera cell, accumulating a per-cell
// screen-space CellView; exit portals union their clipped region into OutsideView.
// GL-free; unit-tested without a GPU context.
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>Per-frame output of the portal-frame BFS.</summary>
public sealed class PortalVisibilityFrame
{
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
public CellView OutsideView { get; } = new();
/// <summary>Per-cell accumulated clip region, keyed by full cell id (wire-in #2).</summary>
public Dictionary<uint, CellView> CellViews { get; } = new();
/// <summary>Visible interior cells in the exact order they were first dequeued from the
/// distance-priority work list — closest-first (Phase U.2a). Mirrors retail's
/// PView::cell_draw_list, appended in PView::ConstructView (decomp:433783) as each cell pops
/// off the nearest-vertex-sorted cell_todo_list (InsCellTodoList 433183). Deduplicated: a cell
/// appears exactly once, on its first dequeue. The camera cell is always first.</summary>
public List<uint> OrderedVisibleCells { get; } = new();
/// <summary>Entry clip regions for other buildings reached through our portals, keyed by the
/// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5).</summary>
public Dictionary<uint, CellView> CrossBuildingViews { get; } = new();
}
public static class PortalVisibilityBuilder
{
private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon
// 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 =
Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1";
private static readonly Dictionary<uint, int> s_pvDumpCount = new();
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
/// <param name="buildingMembership">Optional: true if a cell id is in the camera building's cell
/// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of
/// continuing the in-building BFS. Pass null to treat all reachable cells as in-building.</param>
public static PortalVisibilityFrame Build(
LoadedCell cameraCell,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
Func<uint, bool>? buildingMembership = null)
{
var frame = new PortalVisibilityFrame();
if (cameraCell == null) return frame;
// Interior portals never cross landblocks (same invariant as CellVisibility.GetVisibleCells);
// building-boundary crossings are handled separately via the buildingMembership escape hatch.
uint lbMask = cameraCell.CellId & 0xFFFF0000u;
frame.CellViews[cameraCell.CellId] = CellView.FullScreen();
// Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first;
// each cell carries the camera→nearest-portal-vertex distance that put it on the list
// (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The
// camera cell seeds at distance 0 (retail InsCellTodoList(this, arg2, 0f) at 433758) so it
// always pops first.
var todo = new CellTodoList();
todo.Insert(cameraCell, 0f);
// Fixpoint termination replacing the old MaxReprocessPerCell hard cap. This mirrors the
// retail portal_view slice offset 0x44 (last-incorporated view-poly watermark) vs 0x38
// (current view_count) decision in AddViewToPortals (433446): a cell is INSERTED into the
// todo list exactly once — on first discovery (retail's ecx_5==0 branch calls
// InsCellTodoList; the ecx_5!=eax_2 growth branch calls AddToCell IN PLACE and never
// re-enqueues). Later growth into an already-discovered cell is unioned into its CellView but
// does NOT re-enqueue it — the `cell_view_done` guarantee (ConstructView sets it at 433784
// 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 };
bool pvDump = false;
if (s_pvDump)
{
lock (s_pvDumpCount)
{
s_pvDumpCount.TryGetValue(cameraCell.CellId, out int dc);
if (dc < 2) { s_pvDumpCount[cameraCell.CellId] = dc + 1; pvDump = true; }
}
if (pvDump)
{
Console.WriteLine($"[pv-dump] camCell=0x{cameraCell.CellId:X8} portals={cameraCell.Portals.Count} polyLists={cameraCell.PortalPolygons.Count} vp[M11={viewProj.M11:F3} M22={viewProj.M22:F3} M33={viewProj.M33:F3} M34={viewProj.M34:F3} M43={viewProj.M43:F3} M44={viewProj.M44:F3}]");
// Camera-cell portal census (A8.F triage 2026-05-29): report, for EVERY
// portal, the exact inputs the BFS guards read — BEFORE the guards run, so
// a portal the loop silently `continue`s past is still visible here. An
// empty OUTSIDEVIEW can then be traced to the precise gate: polyLen<3 (empty
// polygon from BuildLoadedCell), interiorSide=false (camera back-facing the
// portal — a legitimately-empty result, not a bug), or (if both OK) a
// downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines.
for (int ci = 0; ci < cameraCell.Portals.Count; ci++)
{
int plen = ci < cameraCell.PortalPolygons.Count
? (cameraCell.PortalPolygons[ci]?.Length ?? -1) : -2;
bool hasPlane = ci < cameraCell.ClipPlanes.Count;
bool interiorSide = !hasPlane || CameraOnInteriorSide(cameraCell, ci, cameraPos);
var n = hasPlane ? cameraCell.ClipPlanes[ci].Normal : Vector3.Zero;
Console.WriteLine($"[pv-dump] CAMPORTAL[{ci}] other=0x{cameraCell.Portals[ci].OtherCellId:X4} polyLen={plen} hasPlane={hasPlane} interiorSide={interiorSide} planeN=({n.X:F3},{n.Y:F3},{n.Z:F3})");
}
}
}
while (todo.Count > 0)
{
var cell = todo.PopNearest();
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
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);
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;
bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF;
// 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 (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));
}
}
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
// portal here. BUT if the eye is STANDING IN this portal's opening, the 2D projection has
// degenerated (the eye is in the doorway plane / within the near plane of the opening; the
// live capture saw the vestibule->room portal at D=0.16 m project to 0 verts). Retail's 3D
// portal clip imposes no constraint for a portal the eye is inside, so the neighbour is
// fully visible — substitute the CURRENT cell's view as the region so the flood reaches it
// (without this, rooting at a thin doorway cell drew only that cell -> the bluish void).
// EyeInsidePortalOpening (near-plane perp + point-in-opening) keeps a merely off-screen
// degenerate portal culled, so the visible set does not blow up (#95). Over-inclusion is
// otherwise safe: the neighbour mesh is frustum-culled per-vertex at draw time.
if (clippedRegion.Count == 0)
{
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
continue; // portal not visible through this chain, and the eye is not standing in it
foreach (var vp in currentView.Polygons)
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] 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);
continue;
}
uint neighbourId = lbMask | portal.OtherCellId;
// Cross-building boundary: route to CrossBuildingViews, don't continue in-building BFS.
// (Cross-building entry is retail's CBldPortal/AddToCell channel, not OtherPortalClip;
// the reciprocal clip below is interior-cell-to-cell only, matching the OtherPortalClip
// call inside ConstructView at decomp:433692.)
if (buildingMembership != null && !buildingMembership(neighbourId))
{
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId);
foreach (var cp in clippedRegion) xview.Add(cp);
continue;
}
var neighbour = lookup(neighbourId);
if (neighbour == null) continue;
// Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip
// decomp:433524). The portal opening seen from THIS cell may be wider than the
// SAME opening seen from the neighbour (skewed/oblique apertures), so retail
// re-clips the already-near-side-clipped region against the neighbour's matching
// (reciprocal) portal polygon — the propagated region is the intersection of the
// opening "seen from A" AND "seen from B". This can only TIGHTEN, never widen, and
// degrades to the prior near-side-only region when the reciprocal is unresolvable
// (over-include is the safe default). The reciprocal is the portal at index
// `portal.OtherPortalId` in the NEIGHBOUR's portal list — retail's direct back-link
// (arg2->other_portal_id, 433557), NOT a scan for the first OtherCellId match. The
// 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.
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
if (clippedRegion.Count == 0) continue; // reciprocal opening doesn't overlap → not visible
// Union the clipped region into the neighbour's accumulated view.
var nview = GetOrCreate(frame.CellViews, neighbourId);
foreach (var cp in clippedRegion) nview.Add(cp);
// 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
// already-seen cell is handled in place, never by re-enqueue). `seen` is the
// enqueue-once / `cell_view_done` gate: a neighbour already discovered is never
// re-enqueued, which is what bounds cyclic & hub graphs. Distance = camera→nearest
// 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))
{
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist);
}
}
}
if (pvDump)
Console.WriteLine($"[pv-dump] OUTSIDEVIEW polys={frame.OutsideView.Polygons.Count} bfsCellViews={frame.CellViews.Count} crossBldg={frame.CrossBuildingViews.Count}");
// Phase U.4c flap probe (ACDREAM_PROBE_FLAP) — read-only per-frame snapshot of the
// 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);
return frame;
}
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
private static readonly Vector2[] FullScreenQuad =
{ new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
// 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.
// `localEye` is the eye in root-local space — its component along an interior portal
// plane reveals when the eye has crossed past that plane (the stale-root region that
// makes the side test cull a still-needed portal). Read-only recompute; no effect on
// the returned frame. Throwaway apparatus — strip with the probe.
private static void EmitFlapProbe(
LoadedCell cameraCell, Vector3 cameraPos, Matrix4x4 viewProj, PortalVisibilityFrame frame)
{
var localEye = Vector3.Transform(cameraPos, cameraCell.InverseWorldTransform);
var sb = new System.Text.StringBuilder(220);
sb.Append("[flap] root=0x").Append(cameraCell.CellId.ToString("X8"));
sb.Append(" eye=(").Append(cameraPos.X.ToString("F2")).Append(',')
.Append(cameraPos.Y.ToString("F2")).Append(',').Append(cameraPos.Z.ToString("F2")).Append(')');
sb.Append(" localEye=(").Append(localEye.X.ToString("F2")).Append(',')
.Append(localEye.Y.ToString("F2")).Append(',').Append(localEye.Z.ToString("F2")).Append(')');
for (int i = 0; i < cameraCell.Portals.Count; i++)
{
var portal = cameraCell.Portals[i];
float d = float.NaN;
bool side = true;
if (i < cameraCell.ClipPlanes.Count && cameraCell.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
{
var pl = cameraCell.ClipPlanes[i];
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.
int projN = -1, clipN = -1;
string ndcText = "";
if (i < cameraCell.PortalPolygons.Count)
{
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)
{
EnsureCcw(ndc);
clipN = ScreenPolygonClip.Intersect(ndc, FullScreenQuad).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();
}
}
}
sb.Append(" | p").Append(i).Append("->0x").Append(portal.OtherCellId.ToString("X4"));
sb.Append(" D=").Append(float.IsNaN(d) ? "na" : d.ToString("F2"));
sb.Append(side ? " TRV" : " CULL");
sb.Append(" proj=").Append(projN).Append(" clip=").Append(clipN);
if (ndcText.Length > 0) sb.Append(" ndc=").Append(ndcText);
}
sb.Append(" || outPolys=").Append(frame.OutsideView.Polygons.Count);
sb.Append(" vis=").Append(frame.OrderedVisibleCells.Count);
Console.WriteLine(sb.ToString());
}
// Mirrors CellVisibility's portal-side test (InsideSide convention).
private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos)
{
var plane = cell.ClipPlanes[portalIndex];
if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
}
// Reverse vertex order in place if the polygon is wound clockwise (signed area < 0).
private static void EnsureCcw(Vector2[] poly)
{
float area2 = 0f;
for (int i = 0; i < poly.Length; i++)
{
var p = poly[i]; var q = poly[(i + 1) % poly.Length];
area2 += p.X * q.Y - q.X * p.Y;
}
if (area2 < 0f) Array.Reverse(poly);
}
// Phase U.2b — reciprocal OtherPortalClip (retail PView::OtherPortalClip decomp:433524).
// Resolves the neighbour's reciprocal back-portal by DIRECT INDEX (`otherPortalId`), projects
// that reciprocal polygon through the NEIGHBOUR's world transform to NDC, and intersects it into
// every polygon of `clippedRegion` (already clipped against the near-side opening + current
// view). The net region is "opening seen from the near cell" ∩ "opening seen from the
// neighbour" — a strict tightening that prevents over-inclusion through skewed apertures.
//
// `otherPortalId` is the near-side portal's reciprocal back-link, straight from the dat's
// CellPortal.OtherPortalId. Retail indexes the neighbour's portal array with it directly —
// `portals->portal[arg2->other_portal_id ...]` at 005a54b2/005a54f6 — rather than scanning for
// the first OtherCellId match. A scan picks the FIRST back-portal for EVERY near-side portal to
// the same neighbour, so a cell with two openings into one neighbour clips both against the same
// (first) reciprocal — hiding the second opening when the apertures are disjoint (under-inclusion
// bug #102 M-4). The direct index gives each opening its own reciprocal.
//
// GUARDS — degrade to over-include (leave `clippedRegion` untouched), NEVER clip against a
// guessed polygon: the index is out of range, OR the indexed polygon is missing/degenerate
// (< 3 verts), OR it projects entirely behind the camera. Over-inclusion is the safe default;
// mis-resolution is the bug this method exists to remove. PortalPolygons is in lockstep with
// Portals, so index `otherPortalId` selects the reciprocal polygon. NEVER throws.
private static void ApplyReciprocalClip(
List<ViewPolygon> clippedRegion, ushort otherPortalId, LoadedCell neighbour, Matrix4x4 viewProj)
{
if (clippedRegion.Count == 0) return;
// Direct back-link index (retail arg2->other_portal_id). Out-of-range → over-include.
if (otherPortalId >= neighbour.PortalPolygons.Count) return;
Vector3[]? reciprocalPoly = neighbour.PortalPolygons[otherPortalId];
if (reciprocalPoly == null || reciprocalPoly.Length < 3) return; // missing/degenerate → over-include
// 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.
Vector2[] reciprocalNdc = PortalProjection.ProjectToNdc(reciprocalPoly, neighbour.WorldTransform, viewProj);
if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op
EnsureCcw(reciprocalNdc);
// Intersect the reciprocal opening into each near-side polygon; drop any that fall away.
for (int k = clippedRegion.Count - 1; k >= 0; k--)
{
var tightened = ScreenPolygonClip.Intersect(reciprocalNdc, clippedRegion[k].Vertices);
if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened);
else clippedRegion.RemoveAt(k);
}
}
private static CellView GetOrCreate(Dictionary<uint, CellView> map, uint key)
{
if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; }
return v;
}
// 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 float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos)
{
float best = float.MaxValue;
for (int i = 0; i < localPoly.Length; i++)
{
var world = Vector3.Transform(localPoly[i], worldTransform);
float d2 = Vector3.DistanceSquared(world, cameraPos);
if (d2 < best) best = d2;
}
return best == float.MaxValue ? 0f : MathF.Sqrt(best);
}
// "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;
/// <summary>
/// True when the camera eye is "standing in" <paramref name="localPoly"/>'s opening: within
/// <see cref="EyeStandingPerpDist"/> of the portal plane AND its perpendicular projection onto
/// that plane falls inside the portal polygon. This is the case where the 2D portal projection
/// degenerates to empty (the eye is in the doorway plane) yet the neighbour is genuinely visible
/// — retail's 3D portal clip imposes no constraint there. Used only as the gate that lets such a
/// portal flood its neighbour with the current view; a degenerate portal the eye is NOT inside
/// (off-screen / across the room) returns false and stays culled, so the visible set cannot blow up.
/// </summary>
private static bool EyeInsidePortalOpening(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 eyeWorld)
{
if (localPoly == null || localPoly.Length < 3) return false;
var p0 = Vector3.Transform(localPoly[0], worldTransform);
var p1 = Vector3.Transform(localPoly[1], worldTransform);
var p2 = Vector3.Transform(localPoly[2], worldTransform);
var n = Vector3.Cross(p1 - p0, p2 - p0);
float nl = n.Length();
if (nl < 1e-8f) return false; // degenerate polygon — no plane
n /= nl;
float perp = Vector3.Dot(n, eyeWorld - p0);
if (MathF.Abs(perp) > EyeStandingPerpDist) return false; // eye not close to the portal plane
// In-plane 2D basis (u along the first edge, v = n × u). Project the eye + every vertex into
// it (the perpendicular component drops out of the dot products) and run a point-in-polygon test.
var u = p1 - p0;
float ul = u.Length();
if (ul < 1e-8f) return false;
u /= ul;
var v = Vector3.Cross(n, u);
var rel = eyeWorld - p0;
var eye2 = new Vector2(Vector3.Dot(rel, u), Vector3.Dot(rel, v));
var poly2 = new Vector2[localPoly.Length];
for (int k = 0; k < localPoly.Length; k++)
{
var w = Vector3.Transform(localPoly[k], worldTransform) - p0;
poly2[k] = new Vector2(Vector3.Dot(w, u), Vector3.Dot(w, v));
}
return PointInPoly2D(eye2, poly2);
}
// Standard ray-crossing (even-odd) point-in-polygon test.
private static bool PointInPoly2D(Vector2 p, Vector2[] poly)
{
bool inside = false;
for (int i = 0, j = poly.Length - 1; i < poly.Length; j = i++)
{
var a = poly[i];
var b = poly[j];
if (((a.Y > p.Y) != (b.Y > p.Y)) &&
(p.X < (b.X - a.X) * (p.Y - a.Y) / (b.Y - a.Y) + a.X))
inside = !inside;
}
return inside;
}
/// <summary>
/// Distance-sorted work list for the portal BFS, ported from retail PView::cell_todo_list +
/// InsCellTodoList (decomp:433183). Insertion keeps the list ordered so the NEAREST cell sits at
/// the tail; <see cref="PopNearest"/> removes the tail — giving closest-first traversal exactly
/// as ConstructView's pop-from-(cell_todo_num-1) does (433767-433769). The insertion only shifts
/// entries strictly farther than the newcomer (retail's flag test breaks on the first
/// not-greater entry), so an equal-distance newcomer lands at the tail and pops FIRST —
/// LIFO on ties, matching retail's break-on-first-not-greater + pop-from-tail.
/// </summary>
private sealed class CellTodoList
{
private readonly List<(LoadedCell Cell, float Distance)> _items = new();
public int Count => _items.Count;
public void Insert(LoadedCell cell, float distance)
{
// Find the slot: scan from the tail (nearest) toward the head while existing entries are
// strictly nearer than `distance`, so the newcomer lands just ABOVE every entry that is
// farther-or-equal — i.e. nearest-at-tail order, LIFO on ties (an equal-distance
// newcomer inserts at the tail and pops first).
int idx = _items.Count;
while (idx > 0 && _items[idx - 1].Distance < distance)
idx--;
_items.Insert(idx, (cell, distance));
}
public LoadedCell PopNearest()
{
int last = _items.Count - 1;
var cell = _items[last].Cell;
_items.RemoveAt(last);
return cell;
}
}
}