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
|
|
@ -17,6 +17,7 @@
|
|||
// (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;
|
||||
|
||||
|
|
@ -24,21 +25,97 @@ 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;
|
||||
|
||||
// To clip space (keep w).
|
||||
var clip = new List<Vector4>(localPoly.Count);
|
||||
foreach (var lp in localPoly)
|
||||
clip.Add(Vector4.Transform(new Vector4(lp, 1f), m));
|
||||
// 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
|
||||
|
|
@ -52,22 +129,47 @@ public static class PortalProjection
|
|||
// 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).
|
||||
clip = ClipPlane(clip, v => v.W - MinW); // in front of eye (near-independent)
|
||||
if (clip.Count < 3) return System.Array.Empty<Vector2>();
|
||||
clip = ClipPlane(clip, v => v.W + v.X); // left: x/w >= -1 <=> w + x >= 0
|
||||
clip = ClipPlane(clip, v => v.W - v.X); // right: x/w <= 1 <=> w - x >= 0
|
||||
clip = ClipPlane(clip, v => v.W + v.Y); // bottom: y/w >= -1 <=> w + y >= 0
|
||||
clip = ClipPlane(clip, v => v.W - v.Y); // top: y/w <= 1 <=> w - y >= 0
|
||||
if (clip.Count < 3) return System.Array.Empty<Vector2>();
|
||||
|
||||
// Perspective divide → NDC xy.
|
||||
var ndc = new Vector2[clip.Count];
|
||||
for (int i = 0; i < clip.Count; i++)
|
||||
try
|
||||
{
|
||||
float w = clip[i].W;
|
||||
ndc[i] = new Vector2(clip[i].X / w, clip[i].Y / w);
|
||||
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);
|
||||
}
|
||||
return ndc;
|
||||
}
|
||||
|
||||
/// <summary>Faithful homogeneous projection (retail PrimD3DRender::xformStart + the W=0 clip of
|
||||
|
|
@ -89,24 +191,83 @@ public static class PortalProjection
|
|||
/// when some vertex has w < 0; <3 survivors → reject (empty).</para></summary>
|
||||
public static Vector4[] ProjectToClip(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
|
||||
{
|
||||
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector4>();
|
||||
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;
|
||||
var clip = new List<Vector4>(localPoly.Count);
|
||||
bool anyBehind = false;
|
||||
foreach (var lp in localPoly)
|
||||
Vector4[] transformed = vectorPool.Rent(localPoly.Count);
|
||||
Vector4[]? clipped = null;
|
||||
bool success = false;
|
||||
try
|
||||
{
|
||||
var v = Vector4.Transform(new Vector4(lp, 1f), m);
|
||||
if (v.W < 0f) anyBehind = true;
|
||||
clip.Add(v);
|
||||
}
|
||||
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).
|
||||
if (anyBehind)
|
||||
clip = ClipPlane(clip, v => v.W);
|
||||
return clip.Count >= 3 ? clip.ToArray() : System.Array.Empty<Vector4>();
|
||||
// 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
|
||||
|
|
@ -120,55 +281,128 @@ public static class PortalProjection
|
|||
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).
|
||||
var poly = new List<Vector4>(subjectClip);
|
||||
int n = regionCcwNdc.Count;
|
||||
for (int e = 0; e < n; e++)
|
||||
{
|
||||
if (poly.Count < 3) return System.Array.Empty<Vector2>();
|
||||
poly = ClipHomogeneousEdge(poly, regionCcwNdc[e], regionCcwNdc[(e + 1) % n]);
|
||||
}
|
||||
if (poly.Count < 3) return System.Array.Empty<Vector2>();
|
||||
int regionCount = regionCcwNdc.Count;
|
||||
int capacity = checked(subjectClip.Length + regionCount);
|
||||
Vector4[] first = vector4Pool.Rent(capacity);
|
||||
Vector4[]? second = null;
|
||||
Vector2[]? ndcScratch = null;
|
||||
|
||||
// Divide survivors → NDC. They are inside the region now, so |x| ≤ |w| and |y| ≤ |w|: the
|
||||
// divide is bounded by construction (this is why the homogeneous clip avoids the early-divide
|
||||
// blow-up). Normalize to CCW so the result is a valid clip region for the next portal hop.
|
||||
//
|
||||
// W=0 port (2026-06-11): with ProjectToClip clipping at exactly w >= 0, a w == 0 vertex
|
||||
// (a direction) cannot survive the bounded region clip above — a nonzero direction fails at
|
||||
// least one edge's inside test of any bounded convex region — EXCEPT the measure-zero case
|
||||
// of a direction lying exactly on a region corner with d == 0 on the adjoining edges. That
|
||||
// case divides to ±Inf/NaN; treat it as the degenerate knife-edge sliver it is and return
|
||||
// empty (retail's effective result for the same input: a <1 px degenerate region).
|
||||
var ndc = new Vector2[poly.Count];
|
||||
for (int i = 0; i < poly.Count; i++)
|
||||
try
|
||||
{
|
||||
float w = poly[i].W;
|
||||
var v = new Vector2(poly[i].X / w, poly[i].Y / w);
|
||||
if (!float.IsFinite(v.X) || !float.IsFinite(v.Y))
|
||||
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>();
|
||||
ndc[i] = v;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// T2 (BR-4): retail's post-divide vertex merge — Render::copy_view
|
||||
// (Ghidra 0x0054dfc0) collapses consecutive vertices closer than ~1
|
||||
// PIXEL (|dx|<=1 && |dy|<=1 screen units) after the perspective divide.
|
||||
// This is the flood's physical fixpoint floor: re-clipping a view can
|
||||
// only insert sub-pixel sliver vertices, which this merge removes, so
|
||||
// accumulated views converge instead of drifting (the drift is what
|
||||
// forced the MaxReprocessPerCell=16 cap). Unit approximation: the
|
||||
// builder has no viewport, so 1 px is expressed in NDC at a reference
|
||||
// 1080p (2/1080 ≈ 0.00185); at higher resolutions the merge is merely
|
||||
// slightly coarser than retail's, which only strengthens convergence.
|
||||
var merged = MergeSubPixelVertices(ndc);
|
||||
if (merged.Length < 3)
|
||||
return System.Array.Empty<Vector2>();
|
||||
|
||||
EnsureCcw(merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Retail copy_view's ~1-pixel vertex merge (see ClipToRegion). Collapses
|
||||
|
|
@ -178,47 +412,52 @@ public static class PortalProjection
|
|||
// "<3 surviving verts → output count 0".
|
||||
private const float VertexMergeEpsilonNdc = 2f / 1080f;
|
||||
|
||||
private static Vector2[] MergeSubPixelVertices(Vector2[] poly)
|
||||
private static int MergeSubPixelVertices(Span<Vector2> poly)
|
||||
{
|
||||
if (poly.Length < 3) return poly;
|
||||
var kept = new List<Vector2>(poly.Length);
|
||||
foreach (var v in poly)
|
||||
if (poly.Length < 3) return poly.Length;
|
||||
int kept = 0;
|
||||
for (int i = 0; i < poly.Length; i++)
|
||||
{
|
||||
if (kept.Count > 0)
|
||||
Vector2 vertex = poly[i];
|
||||
if (kept > 0)
|
||||
{
|
||||
var prev = kept[^1];
|
||||
if (MathF.Abs(v.X - prev.X) <= VertexMergeEpsilonNdc
|
||||
&& MathF.Abs(v.Y - prev.Y) <= VertexMergeEpsilonNdc)
|
||||
Vector2 previous = poly[kept - 1];
|
||||
if (MathF.Abs(vertex.X - previous.X) <= VertexMergeEpsilonNdc
|
||||
&& MathF.Abs(vertex.Y - previous.Y) <= VertexMergeEpsilonNdc)
|
||||
continue;
|
||||
}
|
||||
kept.Add(v);
|
||||
poly[kept++] = vertex;
|
||||
}
|
||||
// Wrap-around: last ≈ first.
|
||||
while (kept.Count >= 2)
|
||||
while (kept >= 2)
|
||||
{
|
||||
var first = kept[0];
|
||||
var last = kept[^1];
|
||||
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.RemoveAt(kept.Count - 1);
|
||||
kept--;
|
||||
else
|
||||
break;
|
||||
}
|
||||
return kept.Count == poly.Length ? poly : kept.ToArray();
|
||||
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 List<Vector4> ClipHomogeneousEdge(List<Vector4> poly, Vector2 a, Vector2 b)
|
||||
private static int ClipHomogeneousEdge(
|
||||
ReadOnlySpan<Vector4> polygon,
|
||||
Span<Vector4> result,
|
||||
Vector2 a,
|
||||
Vector2 b)
|
||||
{
|
||||
var result = new List<Vector4>(poly.Count + 1);
|
||||
int outputCount = 0;
|
||||
float ex = b.X - a.X, ey = b.Y - a.Y;
|
||||
for (int i = 0; i < poly.Count; i++)
|
||||
for (int i = 0; i < polygon.Length; i++)
|
||||
{
|
||||
Vector4 cur = poly[i];
|
||||
Vector4 prev = poly[(i + poly.Count - 1) % poly.Count];
|
||||
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;
|
||||
|
|
@ -226,15 +465,15 @@ public static class PortalProjection
|
|||
|
||||
if (curIn)
|
||||
{
|
||||
if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur));
|
||||
result.Add(cur);
|
||||
if (!prevIn) result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
|
||||
result[outputCount++] = cur;
|
||||
}
|
||||
else if (prevIn)
|
||||
{
|
||||
result.Add(Lerp(prev, cur, dPrev, dCur));
|
||||
result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
return outputCount;
|
||||
}
|
||||
|
||||
// Reverse vertex order in place if wound clockwise (signed area < 0). Mirrors the builder's
|
||||
|
|
@ -257,33 +496,54 @@ public static class PortalProjection
|
|||
private const float MinW = 0.05f;
|
||||
|
||||
// Sutherland-Hodgman against one half-space of the homogeneous view frustum, in CLIP SPACE.
|
||||
// `dist` is the signed plane functional (>= 0 keeps the vertex); crossings are interpolated in
|
||||
// homogeneous coords (perspective-correct). Callers apply the eye plane first so every survivor
|
||||
// has w > 0, making the side-plane functionals (w ± x, w ± y) well defined.
|
||||
private static List<Vector4> ClipPlane(List<Vector4> poly, System.Func<Vector4, float> dist)
|
||||
// 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)
|
||||
{
|
||||
if (poly.Count == 0) return poly;
|
||||
var result = new List<Vector4>(poly.Count + 1);
|
||||
for (int i = 0; i < poly.Count; i++)
|
||||
int outputCount = 0;
|
||||
for (int i = 0; i < polygon.Length; i++)
|
||||
{
|
||||
Vector4 cur = poly[i];
|
||||
Vector4 prev = poly[(i + poly.Count - 1) % poly.Count];
|
||||
float dCur = dist(cur);
|
||||
float dPrev = dist(prev);
|
||||
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.Add(Lerp(prev, cur, dPrev, dCur));
|
||||
result.Add(cur);
|
||||
if (!prevIn) result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
|
||||
result[outputCount++] = cur;
|
||||
}
|
||||
else if (prevIn)
|
||||
{
|
||||
result.Add(Lerp(prev, cur, dPrev, dCur));
|
||||
result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue