// 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.Buffers; using System.Collections.Generic; using System.Numerics; namespace AcDream.App.Rendering; /// One convex polygon in NDC screen space (xy in [-1,1]), plus its bounding rect. 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; } /// /// Frame-owned exact-length storage for projected portal polygons. A polygon's /// vertex array remains immutable for the lifetime of its visibility frame, then /// becomes reusable when that frame is reset. Exact lengths preserve the existing /// contract and all clipping semantics. /// internal sealed class PortalPolygonVertexStore { private const int MaxRetainedArrays = 4_096; private const int MaxRetainedVertices = 65_536; private const int MaxRetainedPolygonVertices = 256; private sealed class Bucket { public readonly List Buffers = new(4); public int Used; } private readonly Dictionary _buckets = new(); private int _retainedArrays; private int _retainedVertices; internal int AllocationCount { get; private set; } internal int RetainedArrayCount => _retainedArrays; internal Vector2[] Rent(int vertexCount) { ArgumentOutOfRangeException.ThrowIfLessThan(vertexCount, 1); if (_buckets.TryGetValue(vertexCount, out Bucket? bucket) && bucket.Used < bucket.Buffers.Count) { return bucket.Buffers[bucket.Used++]; } Vector2[] result = GC.AllocateUninitializedArray(vertexCount); AllocationCount++; bool retain = vertexCount <= MaxRetainedPolygonVertices && _retainedArrays < MaxRetainedArrays && _retainedVertices + vertexCount <= MaxRetainedVertices; if (!retain) return result; if (bucket is null) { bucket = new Bucket(); _buckets.Add(vertexCount, bucket); } bucket.Buffers.Add(result); bucket.Used++; _retainedArrays++; _retainedVertices += vertexCount; return result; } internal void ResetUsage() { foreach (Bucket bucket in _buckets.Values) bucket.Used = 0; } } /// A cell's accumulated clip region: a set of convex view polygons + the union bounding rect. public sealed class CellView { // ViewPolygon exposes its vertex array for the renderer, so this seed must // be owned by the CellView rather than shared globally. Pooling the // CellView then reuses the four vertices without allowing one caller to // corrupt every future full-screen seed. private readonly ViewPolygon _fullScreenPolygon = new(new[] { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f), }); public readonly List Polygons = new(); // Canonical (snapped) keys of the polygons in , backing the drift-tolerant // dedup in . Hash-bucket membership is the dedup; a stored key owns only its // snapped integer coordinates while duplicate probes use stack or ArrayPool scratch. // Hash -> head index in _polygonKeyStorage. Collision chains are integer // links rather than one List allocation per accepted hash. Storage and // coordinate arrays stay with this pooled CellView and are reused across // frames; _activePolygonKeyCount marks the live prefix. private readonly Dictionary _polygonKeyHeads = new(); private readonly List _polygonKeyStorage = new(); private int _activePolygonKeyCount; 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; internal bool IsRetainable { get { if (Polygons.Capacity > 256 || _polygonKeyHeads.EnsureCapacity(0) > 512 || _polygonKeyStorage.Count > 512) { return false; } int retainedCoordinateInts = 0; for (int i = 0; i < _polygonKeyStorage.Count; i++) { int length = _polygonKeyStorage[i].Coordinates.Length; if (length > 256) return false; retainedCoordinateInts += length; if (retainedCoordinateInts > 8192) return false; } return true; } } internal void Reset() { Polygons.Clear(); _polygonKeyHeads.Clear(); _activePolygonKeyCount = 0; MinX = float.MaxValue; MinY = float.MaxValue; MaxX = float.MinValue; MaxY = float.MinValue; } /// A region covering the entire NDC viewport — the camera cell's seed region /// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814). public static CellView FullScreen() { var v = new CellView(); v.Add(v._fullScreenPolygon); return v; } internal void SetFullScreen() { Reset(); Add(_fullScreenPolygon); } 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. CanonicalKeyResult keyResult = TryAddCanonicalKey(p.Vertices); if (keyResult == CanonicalKeyResult.Degenerate) return false; if (keyResult == CanonicalKeyResult.Duplicate) return false; // #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. Degenerate is returned 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 CanonicalKeyResult TryAddCanonicalKey(Vector2[]? verts) { if (verts is null || verts.Length < 3) return CanonicalKeyResult.Degenerate; SnappedPoint[]? rented = null; Span points = verts.Length <= 32 ? stackalloc SnappedPoint[verts.Length] : (rented = ArrayPool.Shared.Rent(verts.Length)).AsSpan(0, verts.Length); try { int count = 0; foreach (Vector2 vertex in verts) { var point = new SnappedPoint( (int)MathF.Round(vertex.X / DedupGridNdc), (int)MathF.Round(vertex.Y / DedupGridNdc)); if (count == 0 || points[count - 1] != point) points[count++] = point; } if (count >= 2 && points[count - 1] == points[0]) count--; if (count < 2) return CanonicalKeyResult.Degenerate; SnappedPoint lo = points[0]; SnappedPoint hi = points[0]; for (int i = 1; i < count; i++) { SnappedPoint point = points[i]; if (point.X < lo.X || (point.X == lo.X && point.Y < lo.Y)) lo = point; if (point.X > hi.X || (point.X == hi.X && point.Y > hi.Y)) hi = point; } bool removed = true; while (removed && count >= 3) { removed = false; for (int i = 0; i < count && count >= 3; i++) { SnappedPoint previous = points[(i + count - 1) % count]; SnappedPoint current = points[i]; SnappedPoint next = points[(i + 1) % count]; long cross = (long)(current.X - previous.X) * (next.Y - current.Y) - (long)(current.Y - previous.Y) * (next.X - current.X); if (cross != 0) continue; points.Slice(i + 1, count - i - 1).CopyTo(points.Slice(i)); count--; removed = true; i--; } } if (count < 3) { if (lo == hi) return CanonicalKeyResult.Degenerate; Span segment = stackalloc SnappedPoint[2] { lo, hi }; return AddCanonicalKey(PolygonKeyKind.Line, segment, start: 0); } int best = 0; for (int start = 1; start < count; start++) if (RotationLess(points, start, best, count)) best = start; return AddCanonicalKey(PolygonKeyKind.Polygon, points[..count], best); } finally { if (rented is not null) ArrayPool.Shared.Return(rented); } } private CanonicalKeyResult AddCanonicalKey( PolygonKeyKind kind, ReadOnlySpan points, int start) { int hash = ComputeHash(kind, points, start); if (_polygonKeyHeads.TryGetValue(hash, out int keyIndex)) { while (keyIndex >= 0) { PolygonKey existing = _polygonKeyStorage[keyIndex]; if (existing.Equals(kind, points, start)) return CanonicalKeyResult.Duplicate; keyIndex = existing.Next; } } int coordinateCount = points.Length * 2; int storageIndex = FindOrCreateCoordinateStorage(coordinateCount); int[] coordinates = _polygonKeyStorage[storageIndex].Coordinates; for (int i = 0; i < points.Length; i++) { SnappedPoint point = points[(start + i) % points.Length]; coordinates[i * 2] = point.X; coordinates[i * 2 + 1] = point.Y; } int next = _polygonKeyHeads.GetValueOrDefault(hash, -1); _polygonKeyStorage[storageIndex] = new PolygonKey(kind, coordinates, next); _polygonKeyHeads[hash] = storageIndex; _activePolygonKeyCount++; return CanonicalKeyResult.Added; } private int FindOrCreateCoordinateStorage(int coordinateCount) { int storageIndex = _activePolygonKeyCount; for (int i = storageIndex; i < _polygonKeyStorage.Count; i++) { if (_polygonKeyStorage[i].Coordinates.Length != coordinateCount) continue; if (i != storageIndex) (_polygonKeyStorage[storageIndex], _polygonKeyStorage[i]) = (_polygonKeyStorage[i], _polygonKeyStorage[storageIndex]); return storageIndex; } _polygonKeyStorage.Add(new PolygonKey( PolygonKeyKind.Polygon, new int[coordinateCount], -1)); int addedIndex = _polygonKeyStorage.Count - 1; if (addedIndex != storageIndex) (_polygonKeyStorage[storageIndex], _polygonKeyStorage[addedIndex]) = (_polygonKeyStorage[addedIndex], _polygonKeyStorage[storageIndex]); return storageIndex; } private static int ComputeHash( PolygonKeyKind kind, ReadOnlySpan points, int start) { unchecked { uint hash = 2166136261u; hash = (hash ^ (byte)kind) * 16777619u; hash = (hash ^ (uint)points.Length) * 16777619u; for (int i = 0; i < points.Length; i++) { SnappedPoint point = points[(start + i) % points.Length]; hash = (hash ^ (uint)point.X) * 16777619u; hash = (hash ^ (uint)point.Y) * 16777619u; } return (int)hash; } } private static bool RotationLess( ReadOnlySpan points, int a, int b, int count) { for (int i = 0; i < count; i++) { SnappedPoint left = points[(a + i) % count]; SnappedPoint right = points[(b + i) % count]; if (left.X != right.X) return left.X < right.X; if (left.Y != right.Y) return left.Y < right.Y; } return false; } private readonly record struct SnappedPoint(int X, int Y); private readonly record struct PolygonKey(PolygonKeyKind Kind, int[] Coordinates, int Next) { public bool Equals(PolygonKeyKind kind, ReadOnlySpan points, int start) { if (Kind != kind || Coordinates.Length != points.Length * 2) return false; for (int i = 0; i < points.Length; i++) { SnappedPoint point = points[(start + i) % points.Length]; if (Coordinates[i * 2] != point.X || Coordinates[i * 2 + 1] != point.Y) return false; } return true; } } private enum PolygonKeyKind : byte { Polygon, Line } private enum CanonicalKeyResult : byte { Degenerate, Duplicate, Added } }