acdream/src/AcDream.App/Rendering/PortalProjection.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

554 lines
23 KiB
C#

// PortalProjection.cs
//
// Phase A8.F: project a cell-local portal polygon to NDC screen space. Homogeneous frustum clip
// in CLIP SPACE (before the perspective divide): first the IN-FRONT-OF-EYE half-space (keep where
// w > MinW) so a portal straddling the camera does not invert under the divide and the divide
// stays bounded away from the w=0 eye singularity, then the 4 SIDE planes (x,y within ±w) so every
// surviving vertex lands on the screen [-1,1] by construction. The side-plane clip is the R1
// void-flap fix (2026-06-05) — see ProjectToNdc.
//
// The clip is NEAR-INDEPENDENT on purpose. We only use the projected x/y for the visibility clip
// REGION, so a vertex in front of the eye is meaningful even if it is closer than the projection's
// near plane. acdream's cameras build projection with Matrix4x4.CreatePerspectiveFieldOfView (D3D
// convention, NDC z in [0,1]) and a 1.0 m near plane (RetailChaseCamera). The previous w+z>=0
// predicate was the GL ([-1,1]) near-plane test; against the D3D matrix it discarded everything
// within ~0.5 m of the eye, so a doorway the chase camera was ~0.1 m from got clipped to empty ->
// the cell behind it was culled -> the cottage doorway "void" (2026-06-03). Clipping at the eye
// (w > MinW) keeps a portal you're standing in (it covers the screen) so the cell behind stays
// visible. Retail PView::GetClip / ConstructView(CBldPortal) (decomp:432344 / 433832) near-clip the
// portal poly likewise before projecting.
using System.Buffers;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
public static class PortalProjection
{
internal ref struct ClipPolygonLease
{
private readonly ArrayPool<Vector4>? _pool;
private Vector4[]? _first;
private Vector4[]? _second;
private Vector4[]? _result;
private readonly int _count;
private bool _disposed;
internal ClipPolygonLease(
ArrayPool<Vector4>? pool,
Vector4[]? first,
Vector4[]? second,
Vector4[]? result,
int count)
{
_pool = pool;
_first = first;
_second = second;
_result = result;
_count = count;
_disposed = false;
}
public int Count
{
get
{
ThrowIfDisposed();
return _count;
}
}
public ReadOnlySpan<Vector4> Span
{
get
{
ThrowIfDisposed();
return _result is null
? ReadOnlySpan<Vector4>.Empty
: _result.AsSpan(0, _count);
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
ArrayPool<Vector4>? pool = _pool;
if (_first is not null)
pool!.Return(_first);
if (_second is not null)
pool!.Return(_second);
_first = null;
_second = null;
_result = null;
}
private readonly void ThrowIfDisposed()
{
if (_disposed)
throw new ObjectDisposedException(nameof(ClipPolygonLease));
}
}
/// <summary>Project a cell-local polygon to NDC, preserving the projected winding of
/// the input (NOT normalized to CCW). The caller (PortalVisibilityBuilder) is responsible
/// for feeding camera-facing portal polygons (via the portal-side test) so the result is
/// CCW for the CCW-only <see cref="ScreenPolygonClip"/>. Returns fewer than 3 verts when
/// the polygon is entirely behind the camera / degenerate.</summary>
public static Vector2[] ProjectToNdc(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
=> ProjectToNdc(localPoly, cellToWorld, viewProj, ArrayPool<Vector4>.Shared);
internal static Vector2[] ProjectToNdc(
IReadOnlyList<Vector3> localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj,
ArrayPool<Vector4> vectorPool)
{
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector2>();
ArgumentNullException.ThrowIfNull(vectorPool);
Matrix4x4 m = cellToWorld * viewProj;
// A convex polygon can gain at most one vertex at each clipping plane. Keep the two
// Sutherland-Hodgman work buffers in ArrayPool instead of allocating six Lists per portal.
int capacity = checked(localPoly.Count + 5);
Vector4[] first = vectorPool.Rent(capacity);
Vector4[]? second = null;
// Homogeneous frustum clip in CLIP SPACE, before the perspective divide. First the
// in-front-of-eye half-space (w > MinW) — near-INDEPENDENT, so a portal the camera is
// standing in still projects (see header); then the 4 SIDE planes (x,y within ±w). The
// side clip is the R1 void-flap fix (2026-06-05): without it, a portal WITHIN the near
// plane projected small-w verts to wildly off-screen NDC (the probe saw (10.2,-67.4)),
// which corrupted the downstream 2D ScreenPolygonClip into an EMPTY region -> OutsideView
// empty -> terrain Skip -> the bluish doorway "void". Clipping the side planes here bounds
// every surviving vertex to the screen [-1,1] by construction, so a screen-covering doorway
// clips to the screen (non-empty) instead of collapsing. The eye plane is clipped FIRST so
// all survivors have w > 0, making the side-plane functionals (w ± x, w ± y) well defined.
// Near/far are intentionally NOT clipped (near-independence). Retail PView::GetClip
// (decomp:0x005a4320) projects + frustum-clips the portal poly likewise (research doc A §3.5).
try
{
second = vectorPool.Rent(capacity);
int currentCount = localPoly.Count;
for (int i = 0; i < currentCount; i++)
first[i] = Vector4.Transform(new Vector4(localPoly[i], 1f), m);
Vector4[] current = first;
Vector4[] output = second;
ReadOnlySpan<HomogeneousPlane> planes =
[
HomogeneousPlane.EyeMinW,
HomogeneousPlane.Left,
HomogeneousPlane.Right,
HomogeneousPlane.Bottom,
HomogeneousPlane.Top,
];
foreach (HomogeneousPlane plane in planes)
{
currentCount = ClipHomogeneousPlane(
current.AsSpan(0, currentCount), output, plane);
if (currentCount < 3)
return System.Array.Empty<Vector2>();
(current, output) = (output, current);
}
// Perspective divide → NDC xy. This is the only result allocation.
var ndc = new Vector2[currentCount];
for (int i = 0; i < currentCount; i++)
{
float w = current[i].W;
ndc[i] = new Vector2(current[i].X / w, current[i].Y / w);
}
return ndc;
}
finally
{
vectorPool.Return(first);
if (second is not null)
vectorPool.Return(second);
}
}
/// <summary>Faithful homogeneous projection (retail PrimD3DRender::xformStart + the W=0 clip of
/// ACRender::polyClipFinish, decomp 424310 / 702749): transform the portal to clip space and clip
/// ONLY the eye plane (w &gt;= 0, EXACT), keeping homogeneous coords — NO perspective divide, NO
/// frustum side-plane clamp. The screen bound is applied later by <see cref="ClipToRegion"/>
/// against the view region (the root region is the full screen), exactly as retail clips the portal
/// against the accumulated portal_view rather than fixed side planes.
///
/// <para>The W=0 clip is exact on purpose (the knife-edge port, 2026-06-11; pseudocode at
/// docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md): boundary intersections land
/// at w == 0 — homogeneous DIRECTIONS — so a portal the eye is crossing (stair openings, decks)
/// yields the correct UNBOUNDED half-region, which the bounded view-region clip then cuts to the
/// screen. The previous EyePlaneW = 1e-4 produced finite ~1e4-NDC boundary verts whose region
/// intersections sat at the dedup/merge degeneracy threshold — the climb-strobe class. A w=0
/// vertex can never survive ClipToRegion into its divide (a nonzero direction fails at least one
/// edge test of any BOUNDED convex region), so no divide-by-zero path exists; the measure-zero
/// corner case is guarded in ClipToRegion. Matches polyClipFinish part 1: clip pass runs only
/// when some vertex has w &lt; 0; &lt;3 survivors → reject (empty).</para></summary>
public static Vector4[] ProjectToClip(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
{
using ClipPolygonLease lease = ProjectToClipLease(localPoly, cellToWorld, viewProj);
return lease.Count < 3 ? System.Array.Empty<Vector4>() : lease.Span.ToArray();
}
internal static ClipPolygonLease ProjectToClipLease(
IReadOnlyList<Vector3> localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj)
=> ProjectToClipLease(
localPoly,
cellToWorld,
viewProj,
ArrayPool<Vector4>.Shared);
internal static ClipPolygonLease ProjectToClipLease(
IReadOnlyList<Vector3> localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj,
ArrayPool<Vector4> vectorPool)
{
ArgumentNullException.ThrowIfNull(vectorPool);
if (localPoly == null || localPoly.Count < 3)
return new ClipPolygonLease(null, null, null, null, 0);
Matrix4x4 m = cellToWorld * viewProj;
Vector4[] transformed = vectorPool.Rent(localPoly.Count);
Vector4[]? clipped = null;
bool success = false;
try
{
clipped = vectorPool.Rent(checked(localPoly.Count + 1));
bool anyBehind = false;
for (int i = 0; i < localPoly.Count; i++)
{
Vector4 vertex = Vector4.Transform(new Vector4(localPoly[i], 1f), m);
if (vertex.W < 0f) anyBehind = true;
transformed[i] = vertex;
}
// polyClipFinish part 1 (0x006b6d5d): the W pass runs only when some vertex sits behind
// the eye plane (w < 0); an all-in-front polygon passes through untouched (and an
// all-behind one clips to empty inside the pass).
ReadOnlySpan<Vector4> result = transformed.AsSpan(0, localPoly.Count);
if (anyBehind)
{
int count = ClipHomogeneousPlane(result, clipped, HomogeneousPlane.EyeZero);
if (count < 3)
{
success = true;
return new ClipPolygonLease(
vectorPool,
transformed,
clipped,
null,
0);
}
result = clipped.AsSpan(0, count);
}
Vector4[] resultArray = anyBehind ? clipped : transformed;
success = true;
return new ClipPolygonLease(
vectorPool,
transformed,
clipped,
resultArray,
result.Length);
}
finally
{
if (!success)
{
vectorPool.Return(transformed);
if (clipped is not null)
vectorPool.Return(clipped);
}
}
}
/// <summary>Clip a homogeneous (clip-space) portal polygon against an NDC view region
/// (CCW convex) with w-aware Sutherland-Hodgman edge tests, then divide the survivors to NDC and
/// normalize to CCW. Ports retail ACRender::polyClipFinish's view-region clip (decomp 702749): the
/// edge test multiplies through w (which is &gt; 0 after the eye-plane clip) so it never divides a
/// near-eye vertex, and the final divide runs only on survivors already bounded to the region —
/// stable by construction. Returns &lt;3 verts when the portal does not intersect the region.</summary>
public static Vector2[] ClipToRegion(IReadOnlyList<Vector4> subjectClip, IReadOnlyList<Vector2> regionCcwNdc)
{
if (subjectClip == null || regionCcwNdc == null || subjectClip.Count < 3 || regionCcwNdc.Count < 3)
return System.Array.Empty<Vector2>();
if (subjectClip is Vector4[] array)
return ClipToRegion(array.AsSpan(), regionCcwNdc);
Vector4[] rented = ArrayPool<Vector4>.Shared.Rent(subjectClip.Count);
try
{
for (int i = 0; i < subjectClip.Count; i++)
rented[i] = subjectClip[i];
return ClipToRegion(rented.AsSpan(0, subjectClip.Count), regionCcwNdc);
}
finally
{
ArrayPool<Vector4>.Shared.Return(rented);
}
}
internal static Vector2[] ClipToRegion(
ReadOnlySpan<Vector4> subjectClip,
IReadOnlyList<Vector2> regionCcwNdc)
=> ClipToRegionCore(subjectClip, regionCcwNdc, vertexStore: null);
internal static Vector2[] ClipToRegion(
ReadOnlySpan<Vector4> subjectClip,
IReadOnlyList<Vector2> regionCcwNdc,
PortalPolygonVertexStore vertexStore)
=> ClipToRegionCore(
subjectClip,
regionCcwNdc,
vertexStore,
ArrayPool<Vector4>.Shared,
ArrayPool<Vector2>.Shared);
internal static Vector2[] ClipToRegion(
ReadOnlySpan<Vector4> subjectClip,
IReadOnlyList<Vector2> regionCcwNdc,
PortalPolygonVertexStore vertexStore,
ArrayPool<Vector4> vector4Pool)
=> ClipToRegionCore(
subjectClip,
regionCcwNdc,
vertexStore,
vector4Pool,
ArrayPool<Vector2>.Shared);
private static Vector2[] ClipToRegionCore(
ReadOnlySpan<Vector4> subjectClip,
IReadOnlyList<Vector2> regionCcwNdc,
PortalPolygonVertexStore? vertexStore,
ArrayPool<Vector4>? vector4Pool = null,
ArrayPool<Vector2>? vector2Pool = null)
{
if (subjectClip.Length < 3 || regionCcwNdc == null || regionCcwNdc.Count < 3)
return System.Array.Empty<Vector2>();
vector4Pool ??= ArrayPool<Vector4>.Shared;
vector2Pool ??= ArrayPool<Vector2>.Shared;
// Homogeneous Sutherland-Hodgman: clip the (w > 0) subject against each CCW edge of the NDC
// region. f(P) below is the NDC inside test cross(edge, P_ndc - a) multiplied through P.W,
// which is > 0 after the eye-plane clip — so the sign is the NDC sign yet no near-eye vertex
// is ever divided (retail polyClipFinish, decomp 702749).
int regionCount = regionCcwNdc.Count;
int capacity = checked(subjectClip.Length + regionCount);
Vector4[] first = vector4Pool.Rent(capacity);
Vector4[]? second = null;
Vector2[]? ndcScratch = null;
try
{
second = vector4Pool.Rent(capacity);
int currentCount = subjectClip.Length;
subjectClip.CopyTo(first);
Vector4[] current = first;
Vector4[] output = second;
for (int edge = 0; edge < regionCount; edge++)
{
if (currentCount < 3)
return System.Array.Empty<Vector2>();
int outputCount = ClipHomogeneousEdge(
current.AsSpan(0, currentCount),
output,
regionCcwNdc[edge],
regionCcwNdc[(edge + 1) % regionCount]);
(current, output) = (output, current);
currentCount = outputCount;
}
if (currentCount < 3)
return System.Array.Empty<Vector2>();
// Divide survivors → NDC. They are already inside the bounded region. A w=0
// measure-zero corner remains the same empty knife-edge result as the prior path.
ndcScratch = vector2Pool.Rent(currentCount);
Span<Vector2> ndc = ndcScratch.AsSpan(0, currentCount);
for (int i = 0; i < currentCount; i++)
{
float w = current[i].W;
var vertex = new Vector2(current[i].X / w, current[i].Y / w);
if (!float.IsFinite(vertex.X) || !float.IsFinite(vertex.Y))
return System.Array.Empty<Vector2>();
ndc[i] = vertex;
}
// T2 (BR-4): retail's post-divide ~1-pixel vertex merge. Compact in place; only a
// genuinely shortened result needs a second exactly-sized output array.
int mergedCount = MergeSubPixelVertices(ndc);
if (mergedCount < 3)
return System.Array.Empty<Vector2>();
Vector2[] merged = vertexStore?.Rent(mergedCount)
?? GC.AllocateUninitializedArray<Vector2>(mergedCount);
ndc[..mergedCount].CopyTo(merged);
EnsureCcw(merged);
return merged;
}
finally
{
vector4Pool.Return(first);
if (second is not null)
vector4Pool.Return(second);
if (ndcScratch is not null)
vector2Pool.Return(ndcScratch);
}
}
// Retail copy_view's ~1-pixel vertex merge (see ClipToRegion). Collapses
// runs of consecutive near-identical vertices, including across the
// wrap-around. A polygon that collapses below 3 distinct vertices is
// degenerate (sub-pixel sliver) and returns empty — exactly retail's
// "<3 surviving verts → output count 0".
private const float VertexMergeEpsilonNdc = 2f / 1080f;
private static int MergeSubPixelVertices(Span<Vector2> poly)
{
if (poly.Length < 3) return poly.Length;
int kept = 0;
for (int i = 0; i < poly.Length; i++)
{
Vector2 vertex = poly[i];
if (kept > 0)
{
Vector2 previous = poly[kept - 1];
if (MathF.Abs(vertex.X - previous.X) <= VertexMergeEpsilonNdc
&& MathF.Abs(vertex.Y - previous.Y) <= VertexMergeEpsilonNdc)
continue;
}
poly[kept++] = vertex;
}
// Wrap-around: last ≈ first.
while (kept >= 2)
{
Vector2 first = poly[0];
Vector2 last = poly[kept - 1];
if (MathF.Abs(first.X - last.X) <= VertexMergeEpsilonNdc
&& MathF.Abs(first.Y - last.Y) <= VertexMergeEpsilonNdc)
kept--;
else
break;
}
return kept;
}
// One Sutherland-Hodgman half-plane against the directed NDC edge a→b, keeping the CCW-inside
// (left) part of a HOMOGENEOUS polygon. Inside test for vertex P (clip space): the NDC cross
// product cross(b-a, P/P.W - a) scaled by P.W (> 0): ex·(P.Y - P.W·a.Y) - ey·(P.X - P.W·a.X) ≥ 0.
// Crossings interpolate in homogeneous coords (perspective-correct), via the shared Lerp.
private static int ClipHomogeneousEdge(
ReadOnlySpan<Vector4> polygon,
Span<Vector4> result,
Vector2 a,
Vector2 b)
{
int outputCount = 0;
float ex = b.X - a.X, ey = b.Y - a.Y;
for (int i = 0; i < polygon.Length; i++)
{
Vector4 cur = polygon[i];
Vector4 prev = polygon[(i + polygon.Length - 1) % polygon.Length];
float dCur = ex * (cur.Y - cur.W * a.Y) - ey * (cur.X - cur.W * a.X);
float dPrev = ex * (prev.Y - prev.W * a.Y) - ey * (prev.X - prev.W * a.X);
bool curIn = dCur >= 0f;
bool prevIn = dPrev >= 0f;
if (curIn)
{
if (!prevIn) result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
result[outputCount++] = cur;
}
else if (prevIn)
{
result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
}
}
return outputCount;
}
// Reverse vertex order in place if wound clockwise (signed area < 0). Mirrors the builder's
// EnsureCcw so a clipped region is always CCW for the next hop's ClipToRegion edge test.
private static void EnsureCcw(Vector2[] poly)
{
float area2 = 0f;
for (int i = 0; i < poly.Length; i++)
{
var p = poly[i]; var q = poly[(i + 1) % poly.Length];
area2 += p.X * q.Y - q.X * p.Y;
}
if (area2 < 0f) System.Array.Reverse(poly);
}
// Minimum clip-space w (≈ metres in front of the eye) to keep a vertex. Excludes the eye
// (w=0) singularity and the ~5 cm right at it (bounding the perspective divide), but is
// INTENTIONALLY far closer than the projection's 1.0 m near plane so a doorway the camera is
// standing in still projects and the cell behind it stays visible. See the file header.
private const float MinW = 0.05f;
// Sutherland-Hodgman against one half-space of the homogeneous view frustum, in CLIP SPACE.
// The enum avoids per-plane delegate/lambda traffic in this per-portal hot path.
private static int ClipHomogeneousPlane(
ReadOnlySpan<Vector4> polygon,
Span<Vector4> result,
HomogeneousPlane plane)
{
int outputCount = 0;
for (int i = 0; i < polygon.Length; i++)
{
Vector4 cur = polygon[i];
Vector4 prev = polygon[(i + polygon.Length - 1) % polygon.Length];
float dCur = PlaneDistance(cur, plane);
float dPrev = PlaneDistance(prev, plane);
bool curIn = dCur >= 0f;
bool prevIn = dPrev >= 0f;
if (curIn)
{
if (!prevIn) result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
result[outputCount++] = cur;
}
else if (prevIn)
{
result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
}
}
return outputCount;
}
private static float PlaneDistance(in Vector4 vertex, HomogeneousPlane plane) => plane switch
{
HomogeneousPlane.EyeMinW => vertex.W - MinW,
HomogeneousPlane.EyeZero => vertex.W,
HomogeneousPlane.Left => vertex.W + vertex.X,
HomogeneousPlane.Right => vertex.W - vertex.X,
HomogeneousPlane.Bottom => vertex.W + vertex.Y,
HomogeneousPlane.Top => vertex.W - vertex.Y,
_ => throw new System.ArgumentOutOfRangeException(nameof(plane)),
};
private enum HomogeneousPlane : byte
{
EyeMinW,
EyeZero,
Left,
Right,
Bottom,
Top,
}
private static Vector4 Lerp(Vector4 p, Vector4 q, float dp, float dq)
{
float t = dp / (dp - dq);
return p + t * (q - p);
}
}