T2 slice 1 (BR-4): multi-view UNION merge + retail 1-px vertex merge (the fixpoint floor)

(a) MergeBuildingFrame now UNIONS a building flood's views into cells
already present in the frame (retail Render::copy_view APPENDS every
clipped portal polygon as a new view_poly, Ghidra 0x0054dfc0 - a cell
visible through two apertures holds two views). The old first-wins
'ContainsKey -> continue' dropped the second aperture's views: the
multiview-loss-first-wins divergence, a named #109 suspect.

(b) ClipToRegion output now runs retail's post-divide vertex merge:
consecutive vertices closer than ~1 pixel collapse (copy_view's
|dx|<=1 && |dy|<=1 screen-unit merge), polygons that collapse below 3
distinct verts return empty (retail's '<3 survivors -> count 0'). This
is the flood's PHYSICAL fixpoint floor - re-clipping can only insert
sub-pixel slivers, which the merge removes, so accumulated views
converge instead of drifting. Unit note: builder has no viewport, so
1 px is expressed as NDC at reference 1080p (0.00185); coarser at higher
res, which only strengthens convergence. This is the prerequisite for
removing the MaxReprocessPerCell=16 drift cap (T2 slice 2) and the
EyeInsidePortalOpening rescue.

Gates: all 10 flood conformance tests green (CornerSweep monotone pin
included); App 226 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-11 11:20:12 +02:00
parent 579c8b06bc
commit cf8a2c379b
2 changed files with 75 additions and 8 deletions

View file

@ -129,8 +129,59 @@ public static class PortalProjection
float w = poly[i].W; float w = poly[i].W;
ndc[i] = new Vector2(poly[i].X / w, poly[i].Y / w); ndc[i] = new Vector2(poly[i].X / w, poly[i].Y / w);
} }
EnsureCcw(ndc);
return ndc; // 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 // One Sutherland-Hodgman half-plane against the directed NDC edge a→b, keeping the CCW-inside

View file

@ -144,17 +144,33 @@ public sealed class RetailPViewRenderer
} }
} }
// Append a per-building flood's cells + views into the frame. Each building cell belongs to exactly // T2 (BR-4): merge a per-building flood's cells + views into the frame as a
// one building, so there is no cross-building overlap; ContainsKey is a safety dedup. OutsideView is // UNION. Retail accumulates EVERY clipped portal polygon as a new view_poly
// NOT merged — the outdoor root already seeds full-screen terrain, and ConstructViewBuilding // on the cell (Render::copy_view appends + view_count++, Ghidra 0x0054dfc0;
// (BuildFromExterior) leaves OutsideView empty (it stops at exit portals once inside the building). // a cell visible through two apertures holds two views, all consumed
// downstream). The old first-wins (`ContainsKey -> continue`) dropped the
// second building flood's views whenever a cell was already in the frame —
// the multiview-loss-first-wins divergence (a named #109 suspect: per-frame
// winner flips between apertures). CellView.Add dedups exact/collinear
// re-emissions (the dac8f6a CanonicalKey), so unioning is convergent.
// OutsideView is NOT merged — the outdoor root already seeds full-screen
// terrain, and ConstructViewBuilding (BuildFromExterior) leaves OutsideView
// empty (it stops at exit portals once inside the building).
private static void MergeBuildingFrame(PortalVisibilityFrame target, PortalVisibilityFrame src) private static void MergeBuildingFrame(PortalVisibilityFrame target, PortalVisibilityFrame src)
{ {
foreach (uint cellId in src.OrderedVisibleCells) foreach (uint cellId in src.OrderedVisibleCells)
{ {
if (target.CellViews.ContainsKey(cellId)) if (!src.CellViews.TryGetValue(cellId, out var srcView))
continue; continue;
target.CellViews[cellId] = src.CellViews[cellId];
if (target.CellViews.TryGetValue(cellId, out var existing))
{
foreach (var p in srcView.Polygons)
existing.Add(p);
continue;
}
target.CellViews[cellId] = srcView;
target.OrderedVisibleCells.Add(cellId); target.OrderedVisibleCells.Add(cellId);
} }
} }