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:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue