fix(render): §4 flood strobe — homogeneous reciprocal clip + collinear-aware region dedup
THE BUG (pinned deterministically by the new CornerFloodReplayTests harness — real Holtburg cells, captured corner-press scenario): a smooth 2 cm/step monotonic eye sweep across the 0172↔0173↔0171 doorway produced a NON-monotonic flood — on ~10 of 61 steps the player's room (0172) vanished from the flood entirely or collapsed to a sub-pixel sliver, taking its downstream chain (016F, the outside view) with it. Live, those isolated frames are the §4 background strobe: openings/passages flash the clear color during transitions, and the corner press shows background at the angles that park the eye near the doorway plane. TWO root causes, both fixed: 1. ApplyReciprocalClip ran the reciprocal portal polygon through the legacy divide-first ProjectToNdc + 2D Intersect path, justified by "the reciprocal is never near the eye." That assumption is exactly false at doorways/corners: the reciprocal IS the same opening whose plane the eye presses against (2-60 cm). ProjectToNdc's MinW=0.05 eye-clip + side-plane clip + divide is knife-edge there — 2 cm eye moves flipped its output between a no-op and a duplicated- vertex hairline that ground the healthy region down to <3 distinct vertices. FIX: route the reciprocal through the SAME homogeneous pipeline as the forward clip (ProjectToClip + ClipToRegion) — which is what retail does: PView::OtherPortalClip (decomp:433524-433563) runs the reciprocal through the very same GetClip(finish=1) → ACRender::polyClipFinish homogeneous clipper. Also ported retail's skip: exact_match portals (CCellPortal.exact_match, acclient.h:32300; PView::ClipPortals :433689) bypass the reciprocal clip — both sides share the same polygon, so re-clipping is redundant. 2. CellView.CanonicalKey missed COLLINEAR re-emissions: the homogeneous region clipper legitimately inserts intersection vertices ON a subject edge when a region edge grazes it, so BFS re-clip rounds re-emit the SAME geometric region with 1-2 extra collinear edge vertices — keyed as distinct, defeating the dedup and accumulating duplicate polygons (this was the real mechanism behind the historical "float drift defeats the dedup" rationale that had parked the reciprocal on the unstable path). FIX: canonicalize away collinear snapped points (exact integer cross-products on the 1e-3 NDC grid) so the key is purely a function of the region's corners. Conformance: CornerSweep_FloodIsCompleteAndMonotone pins the fixed behavior — 61-step monotonic eye sweep ⇒ full flood every step, outside view always reached, player-room region monotone (was: clean shrink 4.000→2.879 with zero drops, vs ~10 glitch steps before). Diagnostic facts (trace diff, hop microscope, primitive scratch) retained as the apparatus. Suites: App 223 green (incl. Build_AppliesReciprocalOtherPortalClip, now passing with proper tightening AND dedup), Core 1377 green + the 4 pre-existing #99-era failures + 1 skip, UI 420, Net 294. Visual gate pending: corner press, room↔room, cellar↔floor, indoor↔outdoor transitions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
482b0dea1b
commit
dac8f6ad1f
3 changed files with 565 additions and 18 deletions
|
|
@ -97,10 +97,21 @@ public sealed class CellView
|
|||
private const float DedupGridNdc = 1e-3f;
|
||||
|
||||
// Canonical key for a view polygon: vertices snapped to the NDC grid, consecutive snap-duplicates
|
||||
// removed (including wrap-around), then rotated to start at the lexicographically smallest vertex so
|
||||
// a rotated emission of the same cycle yields the same key. Returns null when fewer than 3 distinct
|
||||
// removed (including wrap-around), COLLINEAR points removed (exact integer cross-products on the
|
||||
// snapped grid), then rotated to start at the lexicographically smallest vertex so a rotated
|
||||
// emission of the same cycle yields the same key. Returns null when fewer than 3 distinct
|
||||
// snapped vertices survive (a degenerate sliver, not a real region). Winding is already CCW for every
|
||||
// builder input (ClipToRegion / EnsureCcw), so the cyclic order is canonical without a reversal step.
|
||||
//
|
||||
// §4 corner/doorway fix (2026-06-10) — the collinear pass: the homogeneous region clipper
|
||||
// (PortalProjection.ClipToRegion, used by the forward AND — as of today — the reciprocal hop)
|
||||
// legitimately inserts intersection vertices ON a subject edge when a region edge grazes it, so
|
||||
// BFS re-clip rounds re-emit the SAME geometric region with 1-2 extra collinear edge vertices.
|
||||
// Without collinear canonicalization those re-emissions key as distinct, defeating the dedup and
|
||||
// accumulating duplicate polygons (the pre-2026-06-06 unbounded-growth hang in miniature, and the
|
||||
// exact reason the reciprocal clip was previously parked on the unstable divide-first path).
|
||||
// Dropping collinear snapped points makes the key purely a function of the region's CORNERS, so
|
||||
// any re-emission of the same shape — drifted, rotated, vertex-count-inflated — deduplicates.
|
||||
private static string? CanonicalKey(Vector2[]? verts)
|
||||
{
|
||||
if (verts is null || verts.Length < 3) return null;
|
||||
|
|
@ -112,6 +123,30 @@ public sealed class CellView
|
|||
if (pts.Count == 0 || pts[^1] != q) pts.Add(q);
|
||||
}
|
||||
if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1);
|
||||
|
||||
// Remove collinear points: for consecutive (prev, cur, next) around the cycle, drop cur when
|
||||
// cross(cur-prev, next-cur) == 0 — exact in integer grid coordinates (deltas ≤ ~4000, products
|
||||
// ≤ ~1.6e7, no overflow). Loop to a fixpoint: removing one point can make its neighbour
|
||||
// collinear. Degenerate inputs (all points on one line) reduce below 3 → rejected below.
|
||||
bool removed = true;
|
||||
while (removed && pts.Count >= 3)
|
||||
{
|
||||
removed = false;
|
||||
for (int i = 0; i < pts.Count && pts.Count >= 3; i++)
|
||||
{
|
||||
var prev = pts[(i + pts.Count - 1) % pts.Count];
|
||||
var cur = pts[i];
|
||||
var next = pts[(i + 1) % pts.Count];
|
||||
long cross = (long)(cur.X - prev.X) * (next.Y - cur.Y)
|
||||
- (long)(cur.Y - prev.Y) * (next.X - cur.X);
|
||||
if (cross == 0)
|
||||
{
|
||||
pts.RemoveAt(i);
|
||||
removed = true;
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pts.Count < 3) return null;
|
||||
|
||||
int n = pts.Count;
|
||||
|
|
|
|||
|
|
@ -317,7 +317,7 @@ public static class PortalVisibilityBuilder
|
|||
// in place before the union below.
|
||||
var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null;
|
||||
int preReciprocalCount = clippedRegion.Count;
|
||||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
|
||||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
|
||||
if (churnProbe)
|
||||
churnReciprocal!.Append(System.FormattableString.Invariant(
|
||||
$" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]"));
|
||||
|
|
@ -512,7 +512,7 @@ public static class PortalVisibilityBuilder
|
|||
continue;
|
||||
|
||||
var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null;
|
||||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
|
||||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
|
||||
if (clippedRegion.Count == 0)
|
||||
{
|
||||
if (preReciprocalClip is null)
|
||||
|
|
@ -772,33 +772,51 @@ public static class PortalVisibilityBuilder
|
|||
// (< 3 verts), OR it projects entirely behind the camera. Over-inclusion is the safe default;
|
||||
// mis-resolution is the bug this method exists to remove. PortalPolygons is in lockstep with
|
||||
// Portals, so index `otherPortalId` selects the reciprocal polygon. NEVER throws.
|
||||
// Dat CellPortal flags bit 0 (DatReaderWriter.Enums.PortalFlags.ExactMatch; retail
|
||||
// CCellPortal.exact_match at +0x14, acclient.h:32300).
|
||||
private const ushort PortalFlagExactMatch = 0x0001;
|
||||
|
||||
private static void ApplyReciprocalClip(
|
||||
List<ViewPolygon> clippedRegion, ushort otherPortalId, LoadedCell neighbour, Matrix4x4 viewProj)
|
||||
List<ViewPolygon> clippedRegion, ushort otherPortalId, ushort portalFlags,
|
||||
LoadedCell neighbour, Matrix4x4 viewProj)
|
||||
{
|
||||
if (clippedRegion.Count == 0) return;
|
||||
|
||||
// Retail skips OtherPortalClip entirely for exact-match portals — both cells share
|
||||
// the SAME opening polygon, so re-clipping against the reciprocal can only re-derive
|
||||
// the near-side clip: PView::ClipPortals decomp:433689
|
||||
// `if (exact_match != 0 || other_portal_id < 0) goto propagate-without-reciprocal`.
|
||||
if ((portalFlags & PortalFlagExactMatch) != 0) return;
|
||||
|
||||
// Direct back-link index (retail arg2->other_portal_id). Out-of-range → over-include.
|
||||
if (otherPortalId >= neighbour.PortalPolygons.Count) return;
|
||||
Vector3[]? reciprocalPoly = neighbour.PortalPolygons[otherPortalId];
|
||||
if (reciprocalPoly == null || reciprocalPoly.Length < 3) return; // missing/degenerate → over-include
|
||||
|
||||
// Project the reciprocal opening through the NEIGHBOUR's transform (retail positionPush(3,
|
||||
// &other_cell_ptr->pos) at 005a54d2), then normalize winding for the CCW-only clipper.
|
||||
// NOTE: this stays on the divide-then-clip ProjectToNdc path on purpose. The reciprocal is a
|
||||
// back-portal one hop away — never near the eye — so the homogeneous clip buys nothing here,
|
||||
// and ProjectToNdc is float-stable across the BFS re-enqueue rounds. Routing it through
|
||||
// ProjectToClip+ClipToRegion produced per-round float drift that defeated the CellView
|
||||
// SamePolygon dedup, inflating a tight A<->B reciprocal view to ~4x its area
|
||||
// (Build_AppliesReciprocalOtherPortalClip). The near-side clip (ClipPortalAgainstView) IS the
|
||||
// homogeneous path; this secondary tightening is not.
|
||||
Vector2[] reciprocalNdc = PortalProjection.ProjectToNdc(reciprocalPoly, neighbour.WorldTransform, viewProj);
|
||||
if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op
|
||||
EnsureCcw(reciprocalNdc);
|
||||
// §4 corner/doorway fix (2026-06-10): the reciprocal clip now runs the SAME homogeneous
|
||||
// pipeline as the forward clip — retail PView::OtherPortalClip (decomp:433524-433563) routes
|
||||
// the reciprocal polygon through the very same GetClip(finish=1) → ACRender::polyClipFinish
|
||||
// homogeneous clipper as the near-side portal; there is no divide-first special case.
|
||||
//
|
||||
// HISTORY: this used to be ProjectToNdc + 2D ScreenPolygonClip.Intersect, justified by "the
|
||||
// reciprocal is a back-portal one hop away — never near the eye". That assumption is FALSE
|
||||
// exactly at doorways/corners: the reciprocal IS the same opening whose plane the eye presses
|
||||
// against (2-60 cm). ProjectToNdc's MinW=0.05 eye-clip + side-plane clip + divide is knife-edge
|
||||
// there — 2 cm eye moves flipped its output between "covers the region" and a duplicated-vertex
|
||||
// hairline, which CellView.Add's snap-dedup then rejected → the neighbour room dropped from the
|
||||
// flood for isolated frames → the corner/transition background strobe (CornerFloodReplayTests
|
||||
// pins this deterministically; the glitch steps die with this change). The old path's other
|
||||
// rationale — per-round float drift defeating the exact-match CellView dedup — is obsolete:
|
||||
// CanonicalKey's 1e-3-grid snap dedup (2026-06-06) absorbs re-clip drift by construction.
|
||||
var reciprocalClip = PortalProjection.ProjectToClip(reciprocalPoly, neighbour.WorldTransform, viewProj);
|
||||
if (reciprocalClip.Length < 3) return; // reciprocal entirely behind the eye → no constraint (over-include)
|
||||
|
||||
// Intersect the reciprocal opening into each near-side polygon; drop any that fall away.
|
||||
// ClipToRegion(subject=homogeneous reciprocal, region=near-side NDC polygon) = the same
|
||||
// region-edge homogeneous Sutherland-Hodgman the forward hop uses (polyClipFinish port).
|
||||
for (int k = clippedRegion.Count - 1; k >= 0; k--)
|
||||
{
|
||||
var tightened = ScreenPolygonClip.Intersect(reciprocalNdc, clippedRegion[k].Vertices);
|
||||
var tightened = PortalProjection.ClipToRegion(reciprocalClip, clippedRegion[k].Vertices);
|
||||
if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened);
|
||||
else clippedRegion.RemoveAt(k);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue