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:
parent
bff1955066
commit
1405dd8e90
27 changed files with 3635 additions and 814 deletions
|
|
@ -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 >= <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 <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 > 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.
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue