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>
180 lines
8.5 KiB
C#
180 lines
8.5 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)
|
|
|
|
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;
|
|
}
|
|
|
|
// 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. Returns null when fewer than 3 distinct
|
|
// snapped vertices survive (a degenerate sliver, not a real region). Winding is already CCW for every
|
|
// builder input (ClipToRegion / EnsureCcw), so the cyclic order is canonical without a reversal step.
|
|
//
|
|
// §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);
|
|
|
|
// 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. Degenerate inputs (all points on one line) reduce below 3 → rejected 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) return null;
|
|
|
|
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;
|
|
}
|
|
}
|