Ports retail ACRender::polyClipFinish (0x006b6d00, pc:702749) near-eye
semantics into PortalProjection.ProjectToClip - the fundamental fix for
the in-plane portal clip family (climb strobes, tower-top roof/floor
flap while turning; live-corroborated this session: [viewer-diff]
0xAAB30108 strobing 27x mid-climb, whole interior dropping at the top).
Pseudocode: docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md.
Three legs, all decomp-driven:
1. ProjectToClip clips at w >= 0 EXACTLY (was EyePlaneW=1e-4), with
retail's any-negative-w gate. Boundary intersections land at w == 0
(homogeneous directions), so a portal the eye is CROSSING yields the
correct unbounded half-region that the bounded view-region clip cuts
to the screen. A w=0 vertex cannot survive a bounded region clip
into the divide (direction fails some edge of any bounded convex
region); the measure-zero corner case is guarded non-finite->empty.
2. CellView.CanonicalKey keys ALL-COLLINEAR (zero-area) views as their
snapped segment ("L:" + extremes) instead of rejecting them - retail
PROPAGATES degenerate views (ClipPortals decomp:433651-433711
forwards any count!=0 GetClip output, no area gate anywhere), keeping
the cell behind an exactly-in-plane portal in the draw list (cells
draw whole; onward floods die naturally). Rejection dropped the
whole chain for the frame - the parked-eye knife-edge band. Finite
key space unchanged -> dedup + strict-growth convergence intact.
3. The EyeInsidePortalOpening rescue is DELETED (the T2-documented
compensation for the 1e-4 divergence) along with EyeStandingPerpDist
+ PointInPoly2D. Empty clip = no flood, period (retail's rule).
CornerFloodReplay - the gate that REFUTED the previous deletion
attempt - passes WITHOUT the rescue under the W=0 port.
Harness criterion corrected to retail's rules (it codified the rescue):
cells fully BEHIND the camera are not required (all-behind portals clip
empty in retail); monotone area holds per root regime; the two
manufactured exact-on-plane steps assert root-only (boundary root pick
is ambiguous; the in-plane portal there is ~perpendicular to the gaze =
genuinely off-screen). Build_CollapsedInteriorPortalNearEye test
inverted to pin the retail empty-clip rule (it pinned the rescue).
New pins: eye-crossing portal -> w==0 boundary verts + half-region (not
sliver); gaze-along-plane degenerate view accepted + segment-key dedup;
non-finite guard. Replay harnesses (CornerFloodReplay, Issue120,
TowerAscent, HouseExit, Issue127) all green.
Suites: App 246+1skip / Core 1430+2skip / UI 420 / Net 294.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
294 lines
16 KiB
C#
294 lines
16 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.Collections.Generic;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
public static class PortalProjection
|
|
{
|
|
/// <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)
|
|
{
|
|
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector2>();
|
|
|
|
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));
|
|
|
|
// 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<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++)
|
|
{
|
|
float w = clip[i].W;
|
|
ndc[i] = new Vector2(clip[i].X / w, clip[i].Y / w);
|
|
}
|
|
return ndc;
|
|
}
|
|
|
|
/// <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 >= 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 < 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>();
|
|
|
|
Matrix4x4 m = cellToWorld * viewProj;
|
|
var clip = new List<Vector4>(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<Vector4>();
|
|
}
|
|
|
|
/// <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 > 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.</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>();
|
|
|
|
// 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>();
|
|
|
|
// 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<Vector2>();
|
|
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<Vector2>();
|
|
|
|
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<Vector2>(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<Vector4> ClipHomogeneousEdge(List<Vector4> poly, Vector2 a, Vector2 b)
|
|
{
|
|
var result = new List<Vector4>(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<Vector4> ClipPlane(List<Vector4> poly, System.Func<Vector4, float> dist)
|
|
{
|
|
if (poly.Count == 0) return poly;
|
|
var result = new List<Vector4>(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);
|
|
}
|
|
}
|