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>
This commit is contained in:
Erik 2026-07-18 21:35:16 +02:00
parent 3971997689
commit 749e8ceeb1
225 changed files with 29107 additions and 3914 deletions

View file

@ -3,9 +3,9 @@
// 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;
using System.Text;
namespace AcDream.App.Rendering;
@ -37,14 +37,96 @@ public readonly struct ViewPolygon
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"/>. One entry per stored polygon; HashSet membership IS the dedup.
private readonly HashSet<string> _polygonKeys = new();
// 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;
@ -52,18 +134,57 @@ public sealed class CellView
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(new ViewPolygon(new[]
{
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
}));
v.Add(v._fullScreenPolygon);
return v;
}
internal void SetFullScreen()
{
Reset();
Add(_fullScreenPolygon);
}
public bool Add(ViewPolygon p)
{
if (p.IsEmpty) return false;
@ -79,9 +200,9 @@ public sealed class CellView
// 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)
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
@ -178,8 +299,8 @@ public sealed class CellView
// 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).
// 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)
@ -190,89 +311,194 @@ public sealed class CellView
// 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)
private CanonicalKeyResult TryAddCanonicalKey(Vector2[]? verts)
{
if (verts is null || verts.Length < 3) return null;
if (verts is null || verts.Length < 3)
return CanonicalKeyResult.Degenerate;
var pts = new List<(int X, int Y)>(verts.Length);
foreach (var v in verts)
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
{
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++)
int count = 0;
foreach (Vector2 vertex in verts)
{
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)
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++)
{
pts.RemoveAt(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 (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 (count < 3)
{
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 CanonicalKeyResult.Degenerate;
Span<SnappedPoint> segment = stackalloc SnappedPoint[2] { lo, hi };
return AddCanonicalKey(PolygonKeyKind.Line, segment, start: 0);
}
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 best = 0;
for (int start = 1; start < count; start++)
if (RotationLess(points, start, best, count)) best = start;
return AddCanonicalKey(PolygonKeyKind.Polygon, points[..count], best);
}
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++)
finally
{
var q = pts[(best + i) % n];
sb.Append(q.X).Append(',').Append(q.Y).Append(';');
if (rented is not null)
ArrayPool<SnappedPoint>.Shared.Return(rented);
}
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)
private CanonicalKeyResult AddCanonicalKey(
PolygonKeyKind kind,
ReadOnlySpan<SnappedPoint> points,
int start)
{
for (int i = 0; i < n; i++)
int hash = ComputeHash(kind, points, start);
if (_polygonKeyHeads.TryGetValue(hash, out int keyIndex))
{
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;
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 }
}