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>
504 lines
21 KiB
C#
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 }
|
|
}
|