acdream/src/AcDream.App/Rendering/PortalView.cs
Erik 987313aa54 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>
2026-06-11 21:44:23 +02:00

278 lines
14 KiB
C#

// PortalView.cs
//
// Phase A8.F: GL-free 2D screen-space (NDC) clip-region data model.
// Mirrors retail view_poly (acclient.h:32465) and view_type (acclient.h:32338):
// a cell's clip region is a SET of convex polygons in normalized device coords.
using System.Collections.Generic;
using System.Numerics;
using System.Text;
namespace AcDream.App.Rendering;
/// <summary>One convex polygon in NDC screen space (xy in [-1,1]), plus its bounding rect.</summary>
public readonly struct ViewPolygon
{
public readonly Vector2[] Vertices;
public readonly float MinX, MinY, MaxX, MaxY;
public ViewPolygon(Vector2[] vertices)
{
Vertices = vertices;
if (vertices is null || vertices.Length < 3)
{
MinX = MinY = MaxX = MaxY = 0f;
return;
}
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
foreach (var v in vertices)
{
if (v.X < minX) minX = v.X;
if (v.X > maxX) maxX = v.X;
if (v.Y < minY) minY = v.Y;
if (v.Y > maxY) maxY = v.Y;
}
MinX = minX; MinY = minY; MaxX = maxX; MaxY = maxY;
}
public bool IsEmpty => Vertices is null || Vertices.Length < 3;
}
/// <summary>A cell's accumulated clip region: a set of convex view polygons + the union bounding rect.</summary>
public sealed class CellView
{
public readonly List<ViewPolygon> Polygons = new();
// Canonical (snapped) keys of the polygons in <see cref="Polygons"/>, backing the drift-tolerant
// dedup in <see cref="Add"/>. One entry per stored polygon; HashSet membership IS the dedup.
private readonly HashSet<string> _polygonKeys = new();
public float MinX { get; private set; } = float.MaxValue;
public float MinY { get; private set; } = float.MaxValue;
public float MaxX { get; private set; } = float.MinValue;
public float MaxY { get; private set; } = float.MinValue;
public bool IsEmpty => Polygons.Count == 0;
/// <summary>A region covering the entire NDC viewport — the camera cell's seed region
/// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814).</summary>
public static CellView FullScreen()
{
var v = new CellView();
v.Add(new ViewPolygon(new[]
{
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
}));
return v;
}
public bool Add(ViewPolygon p)
{
if (p.IsEmpty) return false;
// Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). PortalVisibilityBuilder.Build
// re-queues a cell every time its CellView GROWS, so the flood only terminates when Add
// recognises a re-clipped region as a duplicate. Across BFS rounds the SAME region returns
// float-drifted, vertex-rotated, and/or with a ±1 vertex count (homogeneous Sutherland-Hodgman +
// EnsureCcw); the old exact index-by-index match (eps 1e-4) caught none of those, so the region
// grew without bound -> O(n^2) CPU-spin hang in this method. We instead key each polygon by its
// vertices SNAPPED to a small NDC grid, consecutive snap-duplicates removed, rotated to a
// canonical start. The snapped key space is finite, so a monotonically-growing CellView is
// bounded and the flood is GUARANTEED to converge. The stored polygon keeps full precision (only
// the key is snapped), so downstream clip geometry is unchanged, and the grid (1e-3 NDC ~ sub-
// pixel) is far finer than the gap between genuinely distinct openings, so real regions never merge.
string? key = CanonicalKey(p.Vertices);
if (key is null) return false; // degenerate after snap (< 3 distinct vertices)
if (!_polygonKeys.Add(key)) return false; // duplicate region (drift / rotation / count tolerant)
// #120 convergence (2026-06-11): reject a polygon CONTAINED in one already
// stored. The reciprocal ping-pong (eye within PortalSideEpsilon of a
// portal plane → BOTH side tests pass → views lap A→B→A…) re-emits, each
// lap, a region that is — in exact arithmetic — a SUBSET of the polygon
// that originated it; near-edge-on apertures make the re-clip wobble by
// more than the 1e-3 key grid, so every lap keyed as "new" and the
// in-place growth recursed to the depth-128 tripwire (chain dumps:
// 0xA9B4015C↔0x0162, 0xA9B30103↔0x010F; Issue120ReciprocalPingPongTests
// reproduces deterministically). Containment rejection makes growth
// strictly area-increasing — no new visible area, no propagation. The
// key stays recorded so the exact emission also short-circuits later.
// Bonus: back-emission into a full-screen view (the root cell) is now
// always rejected outright.
if (ContainedInExisting(p)) return false;
Polygons.Add(p);
if (p.MinX < MinX) MinX = p.MinX;
if (p.MinY < MinY) MinY = p.MinY;
if (p.MaxX > MaxX) MaxX = p.MaxX;
if (p.MaxY > MaxY) MaxY = p.MaxY;
return true;
}
// #120: is polygon p entirely inside ONE stored polygon (with DedupGridNdc
// slack)? Single-polygon containment is sufficient for the ping-pong class —
// a round-trip re-emission descends from exactly one originator. Stored
// polygons are convex (Sutherland-Hodgman / full-screen seed outputs); the
// edge test adapts to either winding via the polygon's signed area.
private bool ContainedInExisting(in ViewPolygon p)
{
const float eps = DedupGridNdc;
for (int i = 0; i < Polygons.Count; i++)
{
var e = Polygons[i];
// bounding-rect quick reject (with slack)
if (p.MinX < e.MinX - eps || p.MaxX > e.MaxX + eps
|| p.MinY < e.MinY - eps || p.MaxY > e.MaxY + eps)
continue;
if (ContainsAllVertices(e.Vertices, p.Vertices, eps))
return true;
}
return false;
}
private static bool ContainsAllVertices(Vector2[] convex, Vector2[] pts, float eps)
{
if (convex.Length < 3) return false;
// signed area → winding (CCW positive); inside = left of every CCW edge.
float area2 = 0f;
for (int i = 0; i < convex.Length; i++)
{
var a = convex[i];
var b = convex[(i + 1) % convex.Length];
area2 += a.X * b.Y - b.X * a.Y;
}
float sign = area2 >= 0f ? 1f : -1f;
for (int i = 0; i < convex.Length; i++)
{
var a = convex[i];
var b = convex[(i + 1) % convex.Length];
var ab = b - a;
float len = ab.Length();
if (len < 1e-9f) continue; // degenerate edge — no constraint
foreach (var pt in pts)
{
// signed perpendicular distance of pt from edge a→b (positive = inside for CCW)
float cross = sign * (ab.X * (pt.Y - a.Y) - ab.Y * (pt.X - a.X));
if (cross < -eps * len)
return false; // a vertex lies outside this edge by more than eps
}
}
return true;
}
// NDC dedup grid. 1e-3 is ~0.5 px at 1080p — finer than the gap between distinct portal openings
// (so real regions stay distinct) yet far coarser than the per-round float drift of a re-clipped
// region (so a drifted duplicate snaps onto its predecessor). The finite grid is what bounds growth.
private const float DedupGridNdc = 1e-3f;
// Canonical key for a view polygon: vertices snapped to the NDC grid, consecutive snap-duplicates
// removed (including wrap-around), COLLINEAR points removed (exact integer cross-products on the
// snapped grid), then rotated to start at the lexicographically smallest vertex so a rotated
// emission of the same cycle yields the same key. Winding is already CCW for every
// builder input (ClipToRegion / EnsureCcw), so the cyclic order is canonical without a reversal step.
//
// W=0 port (2026-06-11): an ALL-COLLINEAR polygon (zero area) keys as its snapped segment
// ("L:" + extreme points) instead of null. A portal whose plane contains the eye projects to
// exactly this — and retail PROPAGATES it: PView::ClipPortals (decomp:433651-433711) forwards
// any GetClip output with count != 0 to copy_view/OtherPortalClip with no area gate anywhere,
// so the neighbour cell stays in the draw list (cells draw whole; onward floods die naturally
// against the zero-area region). Rejecting these views dropped the whole chain behind an
// exactly-in-plane portal for the frame — the parked-eye knife-edge band (tower deck, spiral
// landings). The segment key space is finite like the area-key space, so dedup + the strict
// growth convergence invariant are unchanged. Returns null only when fewer than 2 distinct
// snapped points survive (a true sub-grid point — not a real region OR segment).
//
// §4 corner/doorway fix (2026-06-10) — the collinear pass: the homogeneous region clipper
// (PortalProjection.ClipToRegion, used by the forward AND — as of today — the reciprocal hop)
// 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.
// Without collinear canonicalization those re-emissions key as distinct, defeating the dedup and
// accumulating duplicate polygons (the pre-2026-06-06 unbounded-growth hang in miniature, and the
// exact reason the reciprocal clip was previously parked on the unstable divide-first path).
// Dropping collinear snapped points makes the key purely a function of the region's CORNERS, so
// any re-emission of the same shape — drifted, rotated, vertex-count-inflated — deduplicates.
private static string? CanonicalKey(Vector2[]? verts)
{
if (verts is null || verts.Length < 3) return null;
var pts = new List<(int X, int Y)>(verts.Length);
foreach (var v in verts)
{
var q = ((int)System.MathF.Round(v.X / DedupGridNdc), (int)System.MathF.Round(v.Y / DedupGridNdc));
if (pts.Count == 0 || pts[^1] != q) pts.Add(q);
}
if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1);
// Snapshot the distinct snapped points BEFORE collinear removal — the all-collinear
// fallback keys off the segment EXTREMES of the full point set (stable across
// re-emissions regardless of the removal loop's order).
List<(int X, int Y)>? preCollinear = pts.Count >= 2 ? new List<(int, int)>(pts) : null;
// Remove collinear points: for consecutive (prev, cur, next) around the cycle, drop cur when
// cross(cur-prev, next-cur) == 0 — exact in integer grid coordinates (deltas ≤ ~4000, products
// ≤ ~1.6e7, no overflow). Loop to a fixpoint: removing one point can make its neighbour
// collinear. All-collinear inputs reduce below 3 → the segment-key fallback below.
bool removed = true;
while (removed && pts.Count >= 3)
{
removed = false;
for (int i = 0; i < pts.Count && pts.Count >= 3; i++)
{
var prev = pts[(i + pts.Count - 1) % pts.Count];
var cur = pts[i];
var next = pts[(i + 1) % pts.Count];
long cross = (long)(cur.X - prev.X) * (next.Y - cur.Y)
- (long)(cur.Y - prev.Y) * (next.X - cur.X);
if (cross == 0)
{
pts.RemoveAt(i);
removed = true;
i--;
}
}
}
if (pts.Count < 3)
{
// Zero-area (all-collinear) view — key as its snapped segment so retail's
// degenerate-view propagation works (see method doc). Extremes are the
// lexicographic min/max of the full snapped point set.
if (preCollinear is null) return null;
var lo = preCollinear[0];
var hi = preCollinear[0];
foreach (var q in preCollinear)
{
if (q.X < lo.X || (q.X == lo.X && q.Y < lo.Y)) lo = q;
if (q.X > hi.X || (q.X == hi.X && q.Y > hi.Y)) hi = q;
}
if (lo == hi) return null; // a sub-grid point — not a region or a segment
return $"L:{lo.X},{lo.Y};{hi.X},{hi.Y};";
}
int n = pts.Count;
int best = 0;
for (int s = 1; s < n; s++)
if (RotationLess(pts, s, best, n)) best = s;
var sb = new StringBuilder(n * 10);
for (int i = 0; i < n; i++)
{
var q = pts[(best + i) % n];
sb.Append(q.X).Append(',').Append(q.Y).Append(';');
}
return sb.ToString();
}
// True when the rotation of `pts` starting at index a is lexicographically less than the rotation
// starting at b (compare X then Y, vertex by vertex around the cycle). Gives a unique canonical
// start even when two vertices share the minimum snapped coordinate.
private static bool RotationLess(List<(int X, int Y)> pts, int a, int b, int n)
{
for (int i = 0; i < n; i++)
{
var pa = pts[(a + i) % n];
var pb = pts[(b + i) % n];
if (pa.X != pb.X) return pa.X < pb.X;
if (pa.Y != pb.Y) return pa.Y < pb.Y;
}
return false;
}
}