// 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? _pool; private Vector4[]? _first; private Vector4[]? _second; private Vector4[]? _result; private readonly int _count; private bool _disposed; internal ClipPolygonLease( ArrayPool? 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 Span { get { ThrowIfDisposed(); return _result is null ? ReadOnlySpan.Empty : _result.AsSpan(0, _count); } } public void Dispose() { if (_disposed) return; _disposed = true; ArrayPool? 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)); } } /// 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 . Returns fewer than 3 verts when /// the polygon is entirely behind the camera / degenerate. public static Vector2[] ProjectToNdc(IReadOnlyList localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj) => ProjectToNdc(localPoly, cellToWorld, viewProj, ArrayPool.Shared); internal static Vector2[] ProjectToNdc( IReadOnlyList localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj, ArrayPool vectorPool) { if (localPoly == null || localPoly.Count < 3) return System.Array.Empty(); 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 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(); (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); } } /// 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 >= 0, EXACT), keeping homogeneous coords — NO perspective divide, NO /// frustum side-plane clamp. The screen bound is applied later by /// 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. /// /// 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 < 0; <3 survivors → reject (empty). public static Vector4[] ProjectToClip(IReadOnlyList localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj) { using ClipPolygonLease lease = ProjectToClipLease(localPoly, cellToWorld, viewProj); return lease.Count < 3 ? System.Array.Empty() : lease.Span.ToArray(); } internal static ClipPolygonLease ProjectToClipLease( IReadOnlyList localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj) => ProjectToClipLease( localPoly, cellToWorld, viewProj, ArrayPool.Shared); internal static ClipPolygonLease ProjectToClipLease( IReadOnlyList localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj, ArrayPool 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 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); } } } /// 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 > 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 <3 verts when the portal does not intersect the region. public static Vector2[] ClipToRegion(IReadOnlyList subjectClip, IReadOnlyList regionCcwNdc) { if (subjectClip == null || regionCcwNdc == null || subjectClip.Count < 3 || regionCcwNdc.Count < 3) return System.Array.Empty(); if (subjectClip is Vector4[] array) return ClipToRegion(array.AsSpan(), regionCcwNdc); Vector4[] rented = ArrayPool.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.Shared.Return(rented); } } internal static Vector2[] ClipToRegion( ReadOnlySpan subjectClip, IReadOnlyList regionCcwNdc) => ClipToRegionCore(subjectClip, regionCcwNdc, vertexStore: null); internal static Vector2[] ClipToRegion( ReadOnlySpan subjectClip, IReadOnlyList regionCcwNdc, PortalPolygonVertexStore vertexStore) => ClipToRegionCore( subjectClip, regionCcwNdc, vertexStore, ArrayPool.Shared, ArrayPool.Shared); internal static Vector2[] ClipToRegion( ReadOnlySpan subjectClip, IReadOnlyList regionCcwNdc, PortalPolygonVertexStore vertexStore, ArrayPool vector4Pool) => ClipToRegionCore( subjectClip, regionCcwNdc, vertexStore, vector4Pool, ArrayPool.Shared); private static Vector2[] ClipToRegionCore( ReadOnlySpan subjectClip, IReadOnlyList regionCcwNdc, PortalPolygonVertexStore? vertexStore, ArrayPool? vector4Pool = null, ArrayPool? vector2Pool = null) { if (subjectClip.Length < 3 || regionCcwNdc == null || regionCcwNdc.Count < 3) return System.Array.Empty(); vector4Pool ??= ArrayPool.Shared; vector2Pool ??= ArrayPool.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(); 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(); // 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 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(); 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[] merged = vertexStore?.Rent(mergedCount) ?? GC.AllocateUninitializedArray(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 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 polygon, Span 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 polygon, Span 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); } }