knife-edge port: polyClipFinish W=0 eye-plane clip + degenerate-view propagation; EyeInsidePortalOpening rescue DELETED
Ports retail ACRender::polyClipFinish (0x006b6d00, pc:702749) near-eye
semantics into PortalProjection.ProjectToClip - the fundamental fix for
the in-plane portal clip family (climb strobes, tower-top roof/floor
flap while turning; live-corroborated this session: [viewer-diff]
0xAAB30108 strobing 27x mid-climb, whole interior dropping at the top).
Pseudocode: docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md.
Three legs, all decomp-driven:
1. ProjectToClip clips at w >= 0 EXACTLY (was EyePlaneW=1e-4), with
retail's any-negative-w gate. Boundary intersections land at w == 0
(homogeneous directions), so a portal the eye is CROSSING yields the
correct unbounded half-region that the bounded view-region clip cuts
to the screen. A w=0 vertex cannot survive a bounded region clip
into the divide (direction fails some edge of any bounded convex
region); the measure-zero corner case is guarded non-finite->empty.
2. CellView.CanonicalKey keys ALL-COLLINEAR (zero-area) views as their
snapped segment ("L:" + extremes) instead of rejecting them - retail
PROPAGATES degenerate views (ClipPortals decomp:433651-433711
forwards any count!=0 GetClip output, no area gate anywhere), keeping
the cell behind an exactly-in-plane portal in the draw list (cells
draw whole; onward floods die naturally). Rejection dropped the
whole chain for the frame - the parked-eye knife-edge band. Finite
key space unchanged -> dedup + strict-growth convergence intact.
3. The EyeInsidePortalOpening rescue is DELETED (the T2-documented
compensation for the 1e-4 divergence) along with EyeStandingPerpDist
+ PointInPoly2D. Empty clip = no flood, period (retail's rule).
CornerFloodReplay - the gate that REFUTED the previous deletion
attempt - passes WITHOUT the rescue under the W=0 port.
Harness criterion corrected to retail's rules (it codified the rescue):
cells fully BEHIND the camera are not required (all-behind portals clip
empty in retail); monotone area holds per root regime; the two
manufactured exact-on-plane steps assert root-only (boundary root pick
is ambiguous; the in-plane portal there is ~perpendicular to the gaze =
genuinely off-screen). Build_CollapsedInteriorPortalNearEye test
inverted to pin the retail empty-clip rule (it pinned the rescue).
New pins: eye-crossing portal -> w==0 boundary verts + half-region (not
sliver); gaze-along-plane degenerate view accepted + segment-key dedup;
non-finite guard. Replay harnesses (CornerFloodReplay, Issue120,
TowerAscent, HouseExit, Issue127) all green.
Suites: App 246+1skip / Core 1430+2skip / UI 420 / Net 294.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2163308032
commit
987313aa54
7 changed files with 357 additions and 130 deletions
|
|
@ -255,35 +255,29 @@ public static class PortalVisibilityBuilder
|
|||
}
|
||||
|
||||
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.
|
||||
// traversal (cell=0x..0173 p->0x0171) can be attributed to the side test.
|
||||
// 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"))}");
|
||||
trace.Add($"sidechk cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} camInterior={camInterior} 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.
|
||||
// back portals show camInterior=False eyeIn=True).
|
||||
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}");
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side");
|
||||
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;
|
||||
}
|
||||
|
|
@ -301,28 +295,17 @@ 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}");
|
||||
|
||||
// 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.
|
||||
// Empty clip = no flood through this portal, period — retail's empty-GetClip rule
|
||||
// (polyClipFinish <3 survivors → reject; ClipPortals adds no view). The
|
||||
// EyeInsidePortalOpening rescue that used to substitute the current view here was
|
||||
// the documented compensation for ProjectToClip's old EyePlaneW=1e-4 divergence
|
||||
// from polyClipFinish's exact W=0 clip; with the W=0 port (2026-06-11, pseudocode
|
||||
// at docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md) an eye-crossing
|
||||
// portal projects to its true half-region and the rescue is DELETED.
|
||||
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;
|
||||
}
|
||||
foreach (var vp in activeViewPolygons)
|
||||
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty clipVerts={clipVerts}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (portal.OtherCellId == 0xFFFF)
|
||||
|
|
@ -336,7 +319,7 @@ public static class PortalVisibilityBuilder
|
|||
}
|
||||
// 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}");
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}");
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -970,68 +953,6 @@ public static class PortalVisibilityBuilder
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue