feat(render): indoor render WORKS — terminating portal flood + every-cell seal + look-in FPS

Checkpoint of the unified retail-faithful indoor render. The two-week HANG/grey is fixed and the
interior seals (live-verified by the user). Commits the session render-rewrite foundation together
with the fixes that made it functional.

- HANG fix: PortalVisibilityBuilder.Build portal flood did not terminate (the faithful ProjectToClip
  near-side clip drifts per round, defeating the CellView dedup; the BFS had no bound after U.2a removed
  MaxReprocessPerCell). Fix = drift-tolerant snapped/canonical CellView.Add dedup (PortalView.cs) plus
  restored MaxReprocessPerCell=16 bounded re-enqueue (PortalVisibilityBuilder.cs). Re-enqueue is kept
  (load-bearing for late-slice propagation, Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit);
  only its count is capped. CellViewDedupTests added.
- Seal (DrawCells Task 2): RetailPViewRenderer.DrawEnvCellShells draws EVERY visible cell via
  IndoorDrawPlan.ShellPass (was gated on the ClipFrameAssembler slot filter, leaving slot-less cells grey).
- Look-in FPS: GameWindow exterior look-in candidates limited to the player landblock +-1 (was all ~81
  loaded LBs iterated every outdoor frame). No behaviour change (far cells were >48m, already culled).

Remaining dominant issue = the FLAP at transitions: viewer-cell metastability (render roots at the
camera-eye cell, which oscillates outdoor-indoor as the 3rd-person boom drifts across the doorway,
confirmed in render-sig). SEPARATE fix, NOT the DrawCells port. Full handoff + flap fix plan + tracked
follow-ups (#78 terrain, look-in-from-inside, look-in FPS, L-spotlight):
docs/research/2026-06-07-indoor-render-session-handoff.md.

Baselines: build 0 err; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-07 10:14:43 +02:00
parent bff1955066
commit 1405dd8e90
27 changed files with 3635 additions and 814 deletions

View file

@ -70,6 +70,117 @@ public static class PortalProjection
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 &gt;= <see cref="EyePlaneW"/>), 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. Keeping w means a near/grazing
/// portal never collapses to a zero-area edge sliver (the flap) nor blows up under an early divide
/// (the void). Returns &lt;3 verts when the portal is entirely behind the eye.</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);
foreach (var lp in localPoly)
clip.Add(Vector4.Transform(new Vector4(lp, 1f), m));
// Eye plane ONLY (w >= EyePlaneW), in clip space, homogeneous — no side planes, no divide.
// Retail's polyClipFinish clips at w = 0; EyePlaneW is a hair above 0 so the later divide in
// ClipToRegion never hits the w = 0 singularity. Everything in front of the eye is kept,
// including a portal the camera is standing in (it covers the screen) — the screen bound comes
// from ClipToRegion against the view region, not from a near plane here.
clip = ClipPlane(clip, v => v.W - EyePlaneW);
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 &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>();
// 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.
var ndc = new Vector2[poly.Count];
for (int i = 0; i < poly.Count; i++)
{
float w = poly[i].W;
ndc[i] = new Vector2(poly[i].X / w, poly[i].Y / w);
}
EnsureCcw(ndc);
return ndc;
}
// 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);
}
// Eye plane for the homogeneous clip — a hair above retail's w = 0 so the post-region divide in
// ClipToRegion never divides by zero. Far closer than any near plane: a portal the eye is standing
// in is kept (it covers the screen), so the cell behind it stays visible.
private const float EyePlaneW = 1e-4f;
// 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