THE BUG (pinned deterministically by the new CornerFloodReplayTests harness — real Holtburg cells, captured corner-press scenario): a smooth 2 cm/step monotonic eye sweep across the 0172↔0173↔0171 doorway produced a NON-monotonic flood — on ~10 of 61 steps the player's room (0172) vanished from the flood entirely or collapsed to a sub-pixel sliver, taking its downstream chain (016F, the outside view) with it. Live, those isolated frames are the §4 background strobe: openings/passages flash the clear color during transitions, and the corner press shows background at the angles that park the eye near the doorway plane. TWO root causes, both fixed: 1. ApplyReciprocalClip ran the reciprocal portal polygon through the legacy divide-first ProjectToNdc + 2D Intersect path, justified by "the reciprocal is never near the eye." That assumption is exactly false at doorways/corners: the reciprocal IS the same opening whose plane the eye presses against (2-60 cm). ProjectToNdc's MinW=0.05 eye-clip + side-plane clip + divide is knife-edge there — 2 cm eye moves flipped its output between a no-op and a duplicated- vertex hairline that ground the healthy region down to <3 distinct vertices. FIX: route the reciprocal through the SAME homogeneous pipeline as the forward clip (ProjectToClip + ClipToRegion) — which is what retail does: PView::OtherPortalClip (decomp:433524-433563) runs the reciprocal through the very same GetClip(finish=1) → ACRender::polyClipFinish homogeneous clipper. Also ported retail's skip: exact_match portals (CCellPortal.exact_match, acclient.h:32300; PView::ClipPortals :433689) bypass the reciprocal clip — both sides share the same polygon, so re-clipping is redundant. 2. CellView.CanonicalKey missed COLLINEAR re-emissions: the homogeneous region clipper legitimately inserts intersection vertices ON a subject edge when a region edge grazes it, so BFS re-clip rounds re-emit the SAME geometric region with 1-2 extra collinear edge vertices — keyed as distinct, defeating the dedup and accumulating duplicate polygons (this was the real mechanism behind the historical "float drift defeats the dedup" rationale that had parked the reciprocal on the unstable path). FIX: canonicalize away collinear snapped points (exact integer cross-products on the 1e-3 NDC grid) so the key is purely a function of the region's corners. Conformance: CornerSweep_FloodIsCompleteAndMonotone pins the fixed behavior — 61-step monotonic eye sweep ⇒ full flood every step, outside view always reached, player-room region monotone (was: clean shrink 4.000→2.879 with zero drops, vs ~10 glitch steps before). Diagnostic facts (trace diff, hop microscope, primitive scratch) retained as the apparatus. Suites: App 223 green (incl. Build_AppliesReciprocalOtherPortalClip, now passing with proper tightening AND dedup), Core 1377 green + the 4 pre-existing #99-era failures + 1 skip, UI 420, Net 294. Visual gate pending: corner press, room↔room, cellar↔floor, indoor↔outdoor transitions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
961 lines
52 KiB
C#
961 lines
52 KiB
C#
// 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
|
||
|
||
// 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 =
|
||
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();
|
||
|
||
// Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor
|
||
// node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC
|
||
// quad. ClipFrameAssembler turns that into a full-screen OutsideView slice, so DrawInside's
|
||
// DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's "shell" —
|
||
// the very same callback that already draws the doorway slice when an INTERIOR root reaches
|
||
// outdoors. Keyed on the explicit IsOutdoorNode flag (set by OutdoorCellNode.Build), NOT a
|
||
// cell-id heuristic: production EnvCell ids are >= 0x100 but test fixtures use low interior
|
||
// ids, so an id test would misfire. An interior root never sets this flag, so the indoor
|
||
// exit-portal path (OtherCellId==0xFFFF below) still owns the doorway OutsideView region.
|
||
if (cameraCell.IsOutdoorNode)
|
||
frame.OutsideView.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone()));
|
||
|
||
// 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 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);
|
||
|
||
// [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn
|
||
// + reciprocal pre/post region counts, emitted as one summary line at end of Build. Inert when off.
|
||
bool churnProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled;
|
||
int churnReenqueues = 0;
|
||
var churnReciprocal = churnProbe ? new System.Text.StringBuilder(256) : null;
|
||
|
||
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();
|
||
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.
|
||
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++)
|
||
{
|
||
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)
|
||
{
|
||
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);
|
||
|
||
// (R-A2b Phase 1 pin, throwaway) Log the side-test inputs for EVERY portal so a back-portal
|
||
// traversal (cell=0x..0173 p->0x0171) can be attributed to B1 (eyeInsideOpening bypasses the
|
||
// side-cull) vs B2 (CameraOnInteriorSide returns interior where retail's InitCell culls).
|
||
// |D|<=1.75 means eyeInsideOpening is in range. Strip with the rest of the [pv-trace] apparatus.
|
||
if (trace != null)
|
||
{
|
||
bool camInterior = i >= cell.ClipPlanes.Count || CameraOnInteriorSide(cell, i, cameraPos);
|
||
float sideD = (i < cell.ClipPlanes.Count && cell.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
|
||
? Vector3.Dot(cell.ClipPlanes[i].Normal, Vector3.Transform(cameraPos, cell.InverseWorldTransform)) + cell.ClipPlanes[i].D
|
||
: float.NaN;
|
||
trace.Add($"sidechk cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} camInterior={camInterior} eyeIn={eyeInsideOpening} D={(float.IsNaN(sideD) ? "na" : sideD.ToString("F2"))}");
|
||
}
|
||
|
||
bool sideAllowed = true;
|
||
|
||
// Portal-side test (retail PView::InitCell side test, decomp:432962): only traverse a portal
|
||
// the camera is on the INTERIOR side of. Retail culls the back-facing portal (the doorway just
|
||
// flooded through) by this test ALONE — there is NO eye-in-opening bypass. R-A2b: the old
|
||
// `&& !eyeInsideOpening` bypass let a back portal within 1.75 m through, forming the
|
||
// 0171<->0173 flood cycle -> re-enqueue churn -> the doorway flap (pinned in flap-sidechk.log:
|
||
// back portals show camInterior=False eyeIn=True). The forward-portal clip-empty void rescue
|
||
// (below, the `clippedRegion.Count == 0` branch) is a SEPARATE path and stays.
|
||
if (i < cell.ClipPlanes.Count
|
||
&& !CameraOnInteriorSide(cell, i, cameraPos))
|
||
{
|
||
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;
|
||
}
|
||
|
||
// 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
|
||
// 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))
|
||
{
|
||
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 activeViewPolygons)
|
||
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
|
||
}
|
||
|
||
if (portal.OtherCellId == 0xFFFF)
|
||
{
|
||
if (pvDump)
|
||
{
|
||
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})"))}]");
|
||
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.
|
||
AddRegion(frame.OutsideView, clippedRegion);
|
||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts} eyeIn={eyeInsideOpening}");
|
||
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);
|
||
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)
|
||
{
|
||
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
|
||
// 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.
|
||
var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null;
|
||
int preReciprocalCount = clippedRegion.Count;
|
||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
|
||
if (churnProbe)
|
||
churnReciprocal!.Append(System.FormattableString.Invariant(
|
||
$" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]"));
|
||
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);
|
||
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
|
||
// 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 (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
|
||
{
|
||
dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||
todo.Insert(neighbour, dist);
|
||
inserted = true;
|
||
if (churnProbe) churnReenqueues++;
|
||
}
|
||
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"))}");
|
||
}
|
||
}
|
||
|
||
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);
|
||
trace?.Emit(frame);
|
||
|
||
if (churnProbe)
|
||
{
|
||
int maxPop = 0; uint maxCell = 0; int rePopped = 0;
|
||
foreach (var kv in popCounts)
|
||
{
|
||
if (kv.Value > maxPop) { maxPop = kv.Value; maxCell = kv.Key; }
|
||
if (kv.Value > 1) rePopped++;
|
||
}
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[portal-churn] root=0x{cameraCell.CellId:X8} cells={frame.OrderedVisibleCells.Count} reEnqueues={churnReenqueues} rePoppedCells={rePopped} maxPop=0x{maxCell:X8}:{maxPop}") + churnReciprocal);
|
||
}
|
||
|
||
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);
|
||
// R-A2b: cull back portals by the side test alone (no eye-in-opening bypass) — see Build().
|
||
if (i < cell.ClipPlanes.Count
|
||
&& !CameraOnInteriorSide(cell, i, cameraPos))
|
||
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, portal.Flags, 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail per-building flood — <c>PView::ConstructView(CBldPortal*, …)</c> (decomp:433827),
|
||
/// reached from <c>BSPPORTAL::portal_draw_portals_only</c> (0x53d870) → <c>DrawPortal</c>
|
||
/// (0x5a5ab0) during the terrain BSP walk. Floods ONE building's cells from its outside-facing
|
||
/// entrance portal(s). Identical machinery to <see cref="BuildFromExterior"/>, but the CONTRACT is
|
||
/// per-building: the caller passes exactly one building's cells, so the seed is that building's
|
||
/// FINITE entrance opening (bounded flood depth → the stable ~2-cell view retail draws per visible
|
||
/// building, measured live §3.4). This differs from the synthetic outdoor node's single unified
|
||
/// flood whose full-screen-ish seed reaches variable depth into a building as the eye moves — the
|
||
/// 2↔6 oscillation. Robustness is validated by the conformance test, not assumed.
|
||
/// </summary>
|
||
public static PortalVisibilityFrame ConstructViewBuilding(
|
||
IEnumerable<LoadedCell> buildingCells,
|
||
Vector3 cameraPos,
|
||
Func<uint, LoadedCell?> lookup,
|
||
Matrix4x4 viewProj,
|
||
float maxSeedDistance = float.PositiveInfinity)
|
||
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance);
|
||
|
||
// 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) };
|
||
|
||
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.
|
||
// `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 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)
|
||
{
|
||
var poly = cameraCell.PortalPolygons[i];
|
||
if (poly != null && poly.Length >= 3)
|
||
{
|
||
var clip = PortalProjection.ProjectToClip(poly, cameraCell.WorldTransform, viewProj);
|
||
projN = clip.Length;
|
||
if (clip.Length >= 3)
|
||
{
|
||
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();
|
||
}
|
||
}
|
||
}
|
||
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.
|
||
// Dat CellPortal flags bit 0 (DatReaderWriter.Enums.PortalFlags.ExactMatch; retail
|
||
// CCellPortal.exact_match at +0x14, acclient.h:32300).
|
||
private const ushort PortalFlagExactMatch = 0x0001;
|
||
|
||
private static void ApplyReciprocalClip(
|
||
List<ViewPolygon> clippedRegion, ushort otherPortalId, ushort portalFlags,
|
||
LoadedCell neighbour, Matrix4x4 viewProj)
|
||
{
|
||
if (clippedRegion.Count == 0) return;
|
||
|
||
// Retail skips OtherPortalClip entirely for exact-match portals — both cells share
|
||
// the SAME opening polygon, so re-clipping against the reciprocal can only re-derive
|
||
// the near-side clip: PView::ClipPortals decomp:433689
|
||
// `if (exact_match != 0 || other_portal_id < 0) goto propagate-without-reciprocal`.
|
||
if ((portalFlags & PortalFlagExactMatch) != 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
|
||
|
||
// §4 corner/doorway fix (2026-06-10): the reciprocal clip now runs the SAME homogeneous
|
||
// pipeline as the forward clip — retail PView::OtherPortalClip (decomp:433524-433563) routes
|
||
// the reciprocal polygon through the very same GetClip(finish=1) → ACRender::polyClipFinish
|
||
// homogeneous clipper as the near-side portal; there is no divide-first special case.
|
||
//
|
||
// HISTORY: this used to be ProjectToNdc + 2D ScreenPolygonClip.Intersect, justified by "the
|
||
// reciprocal is a back-portal one hop away — never near the eye". That assumption is FALSE
|
||
// exactly at doorways/corners: the reciprocal IS the same opening whose plane the eye presses
|
||
// against (2-60 cm). ProjectToNdc's MinW=0.05 eye-clip + side-plane clip + divide is knife-edge
|
||
// there — 2 cm eye moves flipped its output between "covers the region" and a duplicated-vertex
|
||
// hairline, which CellView.Add's snap-dedup then rejected → the neighbour room dropped from the
|
||
// flood for isolated frames → the corner/transition background strobe (CornerFloodReplayTests
|
||
// pins this deterministically; the glitch steps die with this change). The old path's other
|
||
// rationale — per-round float drift defeating the exact-match CellView dedup — is obsolete:
|
||
// CanonicalKey's 1e-3-grid snap dedup (2026-06-06) absorbs re-clip drift by construction.
|
||
var reciprocalClip = PortalProjection.ProjectToClip(reciprocalPoly, neighbour.WorldTransform, viewProj);
|
||
if (reciprocalClip.Length < 3) return; // reciprocal entirely behind the eye → no constraint (over-include)
|
||
|
||
// Intersect the reciprocal opening into each near-side polygon; drop any that fall away.
|
||
// ClipToRegion(subject=homogeneous reciprocal, region=near-side NDC polygon) = the same
|
||
// region-edge homogeneous Sutherland-Hodgman the forward hop uses (polyClipFinish port).
|
||
for (int k = clippedRegion.Count - 1; k >= 0; k--)
|
||
{
|
||
var tightened = PortalProjection.ClipToRegion(reciprocalClip, 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;
|
||
}
|
||
|
||
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;
|
||
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. 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
|
||
/// <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;
|
||
}
|
||
}
|
||
}
|