// 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.Collections.Generic; using System.Numerics; namespace AcDream.App.Rendering; public static class PortalProjection { /// 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) { if (localPoly == null || localPoly.Count < 3) return System.Array.Empty(); Matrix4x4 m = cellToWorld * viewProj; // To clip space (keep w). var clip = new List(localPoly.Count); foreach (var lp in localPoly) clip.Add(Vector4.Transform(new Vector4(lp, 1f), m)); // 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). clip = ClipPlane(clip, v => v.W - MinW); // in front of eye (near-independent) if (clip.Count < 3) return System.Array.Empty(); 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(); // Perspective divide → NDC xy. var ndc = new Vector2[clip.Count]; for (int i = 0; i < clip.Count; i++) { float w = clip[i].W; ndc[i] = new Vector2(clip[i].X / w, clip[i].Y / w); } return ndc; } /// 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) { if (localPoly == null || localPoly.Count < 3) return System.Array.Empty(); Matrix4x4 m = cellToWorld * viewProj; var clip = new List(localPoly.Count); bool anyBehind = false; foreach (var lp in localPoly) { var v = Vector4.Transform(new Vector4(lp, 1f), m); if (v.W < 0f) anyBehind = true; clip.Add(v); } // 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(); } /// 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(); // 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(subjectClip); int n = regionCcwNdc.Count; for (int e = 0; e < n; e++) { if (poly.Count < 3) return System.Array.Empty(); poly = ClipHomogeneousEdge(poly, regionCcwNdc[e], regionCcwNdc[(e + 1) % n]); } if (poly.Count < 3) return System.Array.Empty(); // 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++) { 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)) return System.Array.Empty(); ndc[i] = v; } // 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(); EnsureCcw(merged); return merged; } // 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 Vector2[] MergeSubPixelVertices(Vector2[] poly) { if (poly.Length < 3) return poly; var kept = new List(poly.Length); foreach (var v in poly) { if (kept.Count > 0) { var prev = kept[^1]; if (MathF.Abs(v.X - prev.X) <= VertexMergeEpsilonNdc && MathF.Abs(v.Y - prev.Y) <= VertexMergeEpsilonNdc) continue; } kept.Add(v); } // Wrap-around: last ≈ first. while (kept.Count >= 2) { var first = kept[0]; var last = kept[^1]; if (MathF.Abs(first.X - last.X) <= VertexMergeEpsilonNdc && MathF.Abs(first.Y - last.Y) <= VertexMergeEpsilonNdc) kept.RemoveAt(kept.Count - 1); else break; } return kept.Count == poly.Length ? poly : kept.ToArray(); } // 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 ClipHomogeneousEdge(List poly, Vector2 a, Vector2 b) { var result = new List(poly.Count + 1); float ex = b.X - a.X, ey = b.Y - a.Y; for (int i = 0; i < poly.Count; i++) { Vector4 cur = poly[i]; Vector4 prev = poly[(i + poly.Count - 1) % poly.Count]; 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.Add(Lerp(prev, cur, dPrev, dCur)); result.Add(cur); } else if (prevIn) { result.Add(Lerp(prev, cur, dPrev, dCur)); } } return result; } // 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. // `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 ClipPlane(List poly, System.Func dist) { if (poly.Count == 0) return poly; var result = new List(poly.Count + 1); for (int i = 0; i < poly.Count; i++) { Vector4 cur = poly[i]; Vector4 prev = poly[(i + poly.Count - 1) % poly.Count]; float dCur = dist(cur); float dPrev = dist(prev); bool curIn = dCur >= 0f; bool prevIn = dPrev >= 0f; if (curIn) { if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur)); result.Add(cur); } else if (prevIn) { result.Add(Lerp(prev, cur, dPrev, dCur)); } } return result; } private static Vector4 Lerp(Vector4 p, Vector4 q, float dp, float dq) { float t = dp / (dp - dq); return p + t * (q - p); } }