acdream/src/AcDream.App/Rendering/PortalView.cs
Erik 749e8ceeb1 fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
2026-07-18 21:35:16 +02:00

504 lines
21 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.Buffers;
using System.Collections.Generic;
using System.Numerics;
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>
/// 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
/// <see cref="ViewPolygon.Vertices"/> contract and all clipping semantics.
/// </summary>
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<Vector2[]> Buffers = new(4);
public int Used;
}
private readonly Dictionary<int, Bucket> _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<Vector2>(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;
}
}
/// <summary>A cell's accumulated clip region: a set of convex view polygons + the union bounding rect.</summary>
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<ViewPolygon> Polygons = new();
// Canonical (snapped) keys of the polygons in <see cref="Polygons"/>, backing the drift-tolerant
// dedup in <see cref="Add"/>. 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<int, int> _polygonKeyHeads = new();
private readonly List<PolygonKey> _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;
}
/// <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(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<SnappedPoint> points = verts.Length <= 32
? stackalloc SnappedPoint[verts.Length]
: (rented = ArrayPool<SnappedPoint>.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<SnappedPoint> 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<SnappedPoint>.Shared.Return(rented);
}
}
private CanonicalKeyResult AddCanonicalKey(
PolygonKeyKind kind,
ReadOnlySpan<SnappedPoint> 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<SnappedPoint> 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<SnappedPoint> 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<SnappedPoint> 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 }
}