T2 slice 2 (BR-4): in-place growth propagation, strict reciprocal cull, retail seed in-plane reject - two retail constants REFUTED by the gate and documented
IN (retail-faithful): - Growth propagation is IN PLACE, never by re-enqueue: retail AddViewToPortals (Ghidra 0x005a52d0) enqueues only on first discovery (InsCellTodoList); growth into a popped cell runs AdjustCellView - re-clip ONLY the new views through that cell's portals immediately. ProcessCellPortals + the processedViewCounts watermark port exactly that; the MaxReprocessPerCell=16 drift cap is DELETED (the 1-px merge is the physical fixpoint floor). Depth-128 tripwire logs loudly if the convergence invariant ever breaks (failsafe, not control flow). Same restructure in BuildFromExterior. - Reciprocal-empty culls strictly (retail OtherPortalClip returning nothing = the opening is invisible from the neighbour's side); the eye-in-opening pre-clip restore is gone. - Look-in seeds: retail ConstructView(CBldPortal) IN_PLANE reject at the TRUE F_EPSILON (SeedInPlaneEpsilon=0.0002, const @0x007c8c70) + the full-screen substitute rescue DELETED (the verifier-flagged non-retail bypass that admitted floods retail strictly rejects). REFUTED BY THE CONFORMANCE GATE (attempted, reverted, documented inline): - PortalSideEpsilon 0.0002: retail's tight epsilon assumes the viewer cell transits the instant the eye crosses a plane; our root can lag ~1 cm at pressed corners (CornerFloodReplay failed at every step - 0x0171/0x0173 chain dead). 0.01 KEPT as the documented root-lag tolerance; tighten only with eye-exact viewer tracking + cdstW. - Deleting the clip-empty eye-in-opening rescue: same gate, same total failure - our ProjectToClip near-eye behavior (EyePlaneW=1e-4) diverges from retail polyClipFinish's UNPINNED cdstW constant. Rescue KEPT as the documented cdstW-gap compensation; re-attempt only after pinning cdstW from the binary. Gates: App 226/226 green (CornerFloodReplay + MeetingHallFlood + the collapsed-portal pin all pass); Core baseline unchanged (1398 + 4 pre-existing #99-era). CloneViewPolygons orphan removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
cf8a2c379b
commit
529dfcfee9
1 changed files with 180 additions and 121 deletions
|
|
@ -35,20 +35,25 @@ public sealed class PortalVisibilityFrame
|
|||
|
||||
public static class PortalVisibilityBuilder
|
||||
{
|
||||
private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon
|
||||
// Side-classification epsilon. Retail's is F_EPSILON = 0.000199999995
|
||||
// (const @0x007c8c70; PView::InitCell Ghidra 0x005a4b70). T2 (BR-4)
|
||||
// attempted the retail value and the CornerFloodReplay gate REFUTED it:
|
||||
// retail's tight epsilon works because retail's viewer cell transits the
|
||||
// INSTANT the eye crosses a portal plane (the sweep's curr_cell), so the
|
||||
// side test never sees a stale root more than F_EPSILON past a plane. Our
|
||||
// root can lag the eye by up to ~1 cm at pressed corners (the harness's
|
||||
// fixed-root sweep models this), and 0.01 is that documented root-lag
|
||||
// tolerance — NOT a retail constant. Tighten to F_EPSILON only together
|
||||
// with eye-exact viewer-cell tracking verification (the #108-membership
|
||||
// family) + the cdstW near-clip pin.
|
||||
private const float PortalSideEpsilon = 0.01f;
|
||||
|
||||
// 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;
|
||||
// Retail F_EPSILON proper — used where the semantic is knife-edge
|
||||
// REJECTION (ConstructView(CBldPortal) Sidedness IN_PLANE → return 0,
|
||||
// Ghidra 0x005a59a0), which must NOT inherit the root-lag tolerance above
|
||||
// (a 1 cm-wide in-plane band would reject look-in seeds whenever the eye
|
||||
// stands near a doorway plane).
|
||||
private const float SeedInPlaneEpsilon = 0.0002f;
|
||||
|
||||
// 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.
|
||||
|
|
@ -109,7 +114,6 @@ public static class PortalVisibilityBuilder
|
|||
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
|
||||
|
|
@ -148,37 +152,43 @@ public static class PortalVisibilityBuilder
|
|||
}
|
||||
}
|
||||
|
||||
while (todo.Count > 0)
|
||||
// T2 (BR-4): retail's growth propagation is IN PLACE, never by re-enqueue
|
||||
// — PView::AddViewToPortals (Ghidra 0x005a52d0, pc:433446): first
|
||||
// discovery enqueues via InsCellTodoList; growth into a cell whose
|
||||
// cell_view_done is set calls AdjustCellView (pc:433741-433745), which
|
||||
// re-clips ONLY the new views (the update_count watermark) through that
|
||||
// cell's portals immediately. Our processedViewCounts IS that watermark,
|
||||
// so in-place propagation = call ProcessCellPortals on the grown
|
||||
// neighbour; it processes exactly the new tail and recurses further
|
||||
// growth. Termination is physical: recursion fires only when AddRegion
|
||||
// added a DISTINCT polygon (CanonicalKey dedup) that survived the 1-px
|
||||
// vertex merge — the finite fixpoint floor that replaced the old
|
||||
// MaxReprocessPerCell=16 drift cap (deleted). The depth tripwire below
|
||||
// is a loud failsafe, not control flow: it firing means the convergence
|
||||
// invariant broke and must be fixed, not tuned.
|
||||
const int RecursionTripwire = 128;
|
||||
|
||||
void ProcessCellPortals(LoadedCell cell, int depth)
|
||||
{
|
||||
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 (depth >= RecursionTripwire)
|
||||
{
|
||||
Console.WriteLine($"[pv-ERROR] in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate");
|
||||
return;
|
||||
}
|
||||
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
|
||||
{
|
||||
trace?.Add($"pop cell=0x{cell.CellId:X8} skip=no-view");
|
||||
continue;
|
||||
trace?.Add($"proc cell=0x{cell.CellId:X8} skip=no-view");
|
||||
return;
|
||||
}
|
||||
|
||||
// `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($"proc cell=0x{cell.CellId:X8} skip=processed processed={processedCount} views={endCount}");
|
||||
return;
|
||||
}
|
||||
trace?.Add($"pop cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} drawPos={frame.OrderedVisibleCells.Count - 1}");
|
||||
trace?.Add($"proc cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} depth={depth}");
|
||||
|
||||
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
|
||||
processedViewCounts[cell.CellId] = endCount;
|
||||
|
|
@ -245,22 +255,25 @@ public static class PortalVisibilityBuilder
|
|||
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.
|
||||
// T2 (BR-4) attempted to delete this eye-in-opening rescue as
|
||||
// non-retail (retail's empty GetClip = no flood, no bypass) and
|
||||
// the CornerFloodReplay conformance gate REFUTED the deletion:
|
||||
// with the eye pressed at the 0x0172 corner, the 0x0173/0x0171
|
||||
// doorway chain clipped EMPTY at every sweep step — our
|
||||
// ProjectToClip near-eye behavior (EyePlaneW=1e-4) diverges from
|
||||
// retail polyClipFinish's near-W clip at its UNPINNED constant
|
||||
// cdstW (comparison doc open question). Until cdstW is read from
|
||||
// the binary and our near-eye clip matched to it, this rescue is
|
||||
// the documented compensation for that gap: a portal whose
|
||||
// opening the eye stands in (≤1.75 m perp + inside the opening)
|
||||
// substitutes the current view. Re-attempt the deletion ONLY
|
||||
// against the corner harness after pinning cdstW.
|
||||
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
|
||||
continue;
|
||||
}
|
||||
foreach (var vp in activeViewPolygons)
|
||||
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
|
||||
|
|
@ -315,7 +328,10 @@ 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;
|
||||
// T2 (BR-4): reciprocal-empty culls — retail OtherPortalClip
|
||||
// returning nothing means the opening is invisible from the
|
||||
// neighbour's side; the old eye-in-opening restore was part of
|
||||
// the deleted rescue.
|
||||
int preReciprocalCount = clippedRegion.Count;
|
||||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
|
||||
if (churnProbe)
|
||||
|
|
@ -323,39 +339,62 @@ public static class PortalVisibilityBuilder
|
|||
$" 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);
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=reciprocal-empty pre={preReciprocalCount} otherPortal={portal.OtherPortalId}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Union the clipped region into the neighbour's accumulated view.
|
||||
var nview = GetOrCreate(frame.CellViews, neighbourId);
|
||||
bool grew = AddRegion(nview, clippedRegion);
|
||||
bool inserted = false;
|
||||
bool inPlace = 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))
|
||||
if (grew)
|
||||
{
|
||||
dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
todo.Insert(neighbour, dist);
|
||||
inserted = true;
|
||||
if (churnProbe) churnReenqueues++;
|
||||
// First discovery → enqueue once (retail InsCellTodoList in
|
||||
// the ecx_5==0 branch). Distance = camera→nearest portal-
|
||||
// opening vertex (retail InitCell min-vertex distance,
|
||||
// pc:432988-433004).
|
||||
if (queued.Add(neighbourId))
|
||||
{
|
||||
dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
todo.Insert(neighbour, dist);
|
||||
inserted = true;
|
||||
}
|
||||
// Growth into an already-POPPED cell → retail AdjustCellView:
|
||||
// process only the new views, immediately, in place. A cell
|
||||
// discovered but still pending in the todo list needs nothing
|
||||
// — its pop processes everything to date via the watermark.
|
||||
else if (drawListed.Contains(neighbourId))
|
||||
{
|
||||
inPlace = true;
|
||||
if (churnProbe) churnReenqueues++;
|
||||
ProcessCellPortals(neighbour, depth + 1);
|
||||
}
|
||||
}
|
||||
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"))}");
|
||||
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} inPlace={inPlace} dist={(float.IsNaN(dist) ? "na" : dist.ToString("F2"))}");
|
||||
}
|
||||
}
|
||||
|
||||
while (todo.Count > 0)
|
||||
{
|
||||
var cell = todo.PopNearest();
|
||||
|
||||
// Single pop per cell (enqueue-once) IS the cell's closest-first
|
||||
// draw position (retail appends to cell_draw_list once per pop,
|
||||
// pc:433783). Note: retail also RE-SORTS the draw list when a
|
||||
// late-grown cell's dependency order changes (AdjustCellPlace,
|
||||
// pc:433247); we keep first-pop order — under T1's whole-cell
|
||||
// far→near draws + depth testing, order affects only transparent-
|
||||
// pass compositing in exotic chains (documented residual for T5).
|
||||
if (drawListed.Add(cell.CellId))
|
||||
frame.OrderedVisibleCells.Add(cell.CellId);
|
||||
trace?.Add($"pop cell=0x{cell.CellId:X8} drawPos={frame.OrderedVisibleCells.Count - 1}");
|
||||
|
||||
ProcessCellPortals(cell, 0);
|
||||
}
|
||||
|
||||
if (pvDump)
|
||||
Console.WriteLine($"[pv-dump] OUTSIDEVIEW polys={frame.OutsideView.Polygons.Count} bfsCellViews={frame.CellViews.Count} crossBldg={frame.CrossBuildingViews.Count}");
|
||||
|
||||
|
|
@ -367,14 +406,10 @@ public static class PortalVisibilityBuilder
|
|||
|
||||
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++;
|
||||
}
|
||||
// T2: pops are enqueue-once now; churnReenqueues counts retail-style
|
||||
// IN-PLACE propagations (AdjustCellView equivalents) instead.
|
||||
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);
|
||||
$"[portal-churn] root=0x{cameraCell.CellId:X8} cells={frame.OrderedVisibleCells.Count} inPlaceProps={churnReenqueues}") + churnReciprocal);
|
||||
}
|
||||
|
||||
return frame;
|
||||
|
|
@ -399,7 +434,6 @@ public static class PortalVisibilityBuilder
|
|||
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)
|
||||
{
|
||||
|
|
@ -419,9 +453,18 @@ public static class PortalVisibilityBuilder
|
|||
|
||||
// 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;
|
||||
// DrawInside path owns this portal instead. T2 (BR-4): a seed
|
||||
// portal the eye is IN-PLANE with (|dist| <= F_EPSILON) rejects
|
||||
// OUTRIGHT — retail ConstructView(CBldPortal) returns 0 on
|
||||
// Sidedness IN_PLANE (Ghidra 0x005a59a0); no degenerate view is
|
||||
// ever built from a knife-edge aperture.
|
||||
if (i < cell.ClipPlanes.Count)
|
||||
{
|
||||
if (CameraOnInteriorSide(cell, i, cameraPos))
|
||||
continue;
|
||||
if (EyeInPlaneOfPortal(cell, i, cameraPos))
|
||||
continue;
|
||||
}
|
||||
|
||||
float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
if (seedDistance > maxSeedDistance)
|
||||
|
|
@ -434,12 +477,11 @@ public static class PortalVisibilityBuilder
|
|||
FullScreenRegion,
|
||||
out _);
|
||||
|
||||
// T2 (BR-4): empty clip = no seed, no exceptions (retail's
|
||||
// empty-GetClip rule; the full-screen substitute rescue is
|
||||
// deleted — see Build()).
|
||||
if (clippedRegion.Count == 0)
|
||||
{
|
||||
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
|
||||
continue;
|
||||
clippedRegion.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone()));
|
||||
}
|
||||
continue;
|
||||
|
||||
var seedView = GetOrCreate(frame.CellViews, cell.CellId);
|
||||
bool grew = AddRegion(seedView, clippedRegion);
|
||||
|
|
@ -449,24 +491,26 @@ public static class PortalVisibilityBuilder
|
|||
}
|
||||
}
|
||||
|
||||
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;
|
||||
// T2 (BR-4): in-place growth propagation — mirrors Build()'s
|
||||
// ProcessCellPortals (retail AdjustCellView via the watermark); the
|
||||
// re-enqueue + MaxReprocessPerCell cap and the eye-in-opening rescues
|
||||
// are deleted (empty clip culls, period).
|
||||
const int RecursionTripwire = 128;
|
||||
|
||||
if (drawListed.Add(cell.CellId))
|
||||
frame.OrderedVisibleCells.Add(cell.CellId);
|
||||
void ProcessCellPortals(LoadedCell cell, int depth)
|
||||
{
|
||||
if (depth >= RecursionTripwire)
|
||||
{
|
||||
Console.WriteLine($"[pv-ERROR] look-in in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate");
|
||||
return;
|
||||
}
|
||||
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
|
||||
return;
|
||||
|
||||
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
|
||||
int endCount = currentView.Polygons.Count;
|
||||
if (processedCount >= endCount)
|
||||
continue;
|
||||
return;
|
||||
|
||||
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
|
||||
processedViewCounts[cell.CellId] = endCount;
|
||||
|
|
@ -485,8 +529,7 @@ public static class PortalVisibilityBuilder
|
|||
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().
|
||||
// R-A2b: cull back portals by the side test alone — see Build().
|
||||
if (i < cell.ClipPlanes.Count
|
||||
&& !CameraOnInteriorSide(cell, i, cameraPos))
|
||||
continue;
|
||||
|
|
@ -499,38 +542,45 @@ public static class PortalVisibilityBuilder
|
|||
out _);
|
||||
|
||||
if (clippedRegion.Count == 0)
|
||||
{
|
||||
if (!eyeInsideOpening)
|
||||
continue;
|
||||
foreach (var vp in activeViewPolygons)
|
||||
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
|
||||
}
|
||||
continue;
|
||||
|
||||
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);
|
||||
}
|
||||
continue;
|
||||
|
||||
var nview = GetOrCreate(frame.CellViews, neighbourId);
|
||||
bool grew = AddRegion(nview, clippedRegion);
|
||||
|
||||
if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
|
||||
if (grew)
|
||||
{
|
||||
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
todo.Insert(neighbour, dist);
|
||||
if (queued.Add(neighbourId))
|
||||
{
|
||||
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
todo.Insert(neighbour, dist);
|
||||
}
|
||||
else if (drawListed.Contains(neighbourId))
|
||||
{
|
||||
ProcessCellPortals(neighbour, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (todo.Count > 0)
|
||||
{
|
||||
var cell = todo.PopNearest();
|
||||
|
||||
if (drawListed.Add(cell.CellId))
|
||||
frame.OrderedVisibleCells.Add(cell.CellId);
|
||||
|
||||
ProcessCellPortals(cell, 0);
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
|
|
@ -731,6 +781,10 @@ public static class PortalVisibilityBuilder
|
|||
}
|
||||
|
||||
// Mirrors CellVisibility's portal-side test (InsideSide convention).
|
||||
// In-plane (|dot| <= PortalSideEpsilon) counts as interior-side — retail
|
||||
// InitCell leaves the in-plane case a CANDIDATE for cell portals (Ghidra
|
||||
// 0x005a4b70); building/exterior SEED portals additionally reject in-plane
|
||||
// via EyeInPlaneOfPortal (retail ConstructView(CBldPortal) IN_PLANE → 0).
|
||||
private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos)
|
||||
{
|
||||
var plane = cell.ClipPlanes[portalIndex];
|
||||
|
|
@ -740,6 +794,19 @@ public static class PortalVisibilityBuilder
|
|||
return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
|
||||
}
|
||||
|
||||
// T2 (BR-4): retail ConstructView(CBldPortal)'s Sidedness IN_PLANE reject
|
||||
// (Ghidra 0x005a59a0): |eye·N + d| <= F_EPSILON → the building/exterior
|
||||
// portal contributes nothing this frame (knife-edge aperture). Uses the
|
||||
// true retail epsilon, NOT the side test's root-lag tolerance.
|
||||
private static bool EyeInPlaneOfPortal(LoadedCell cell, int portalIndex, Vector3 cameraPos)
|
||||
{
|
||||
var plane = cell.ClipPlanes[portalIndex];
|
||||
if (plane.Normal.LengthSquared() < 1e-8f) return false;
|
||||
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
|
||||
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
|
||||
return MathF.Abs(dot) <= SeedInPlaneEpsilon;
|
||||
}
|
||||
|
||||
// Reverse vertex order in place if the polygon is wound clockwise (signed area < 0).
|
||||
private static void EnsureCcw(Vector2[] poly)
|
||||
{
|
||||
|
|
@ -841,14 +908,6 @@ public static class PortalVisibilityBuilder
|
|||
// 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue