feat(render): Phase U.2b — reciprocal OtherPortalClip (retail 433524)

Clip the portal opening against the neighbour's matching back-portal polygon
before propagating, so a cell's clip region is the intersection of the opening
seen from both sides. Closes the M-4 stub in ISSUES #102. Can only tighten,
never under-include; degrades to prior behavior when no back-portal is found.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-30 16:37:14 +02:00
parent 306cdb069c
commit 3916b2b23e
3 changed files with 194 additions and 14 deletions

View file

@ -187,6 +187,117 @@ public class PortalVisibilityBuilderTests
$"hub + 4 rooms expected, got {f.OrderedVisibleCells.Count} — fixpoint failed to converge");
Assert.Equal(f.OrderedVisibleCells.Count, f.OrderedVisibleCells.Distinct().Count()); // no dup cells
}
// -----------------------------------------------------------------------
// Phase U.2b: reciprocal OtherPortalClip (retail PView::OtherPortalClip
// decomp:433524). When a portal leads to a loaded neighbour, the
// propagated region must ALSO be clipped against the neighbour's matching
// (reciprocal) back-portal polygon — the result is the intersection of the
// opening seen from BOTH sides. This can only tighten, never widen.
// -----------------------------------------------------------------------
// Camera in A looking down -Z through a WIDE near-side portal (A->B). B's
// matching back-portal (B->A) is a NARROW opening at the same plane, so the
// reciprocal opening projects to a strictly smaller NDC region. Without the
// reciprocal clip, B's CellView equals the wide near-side projection; with
// it, B's CellView is bounded by the narrow reciprocal opening.
private static (LoadedCell camCell, LoadedCell neighbour, Dictionary<uint, LoadedCell> lookup)
SyntheticReciprocalPair()
{
const uint A = 0x0001, B = 0x0002;
// A's portal into B: wide opening (half-width 0.9) at z = -3.
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0));
a.PortalPolygons.Add(Quad(0f, 0f, 0.9f, 0.9f, -3f));
// B's reciprocal portal back to A: NARROW opening (half-width 0.3),
// same height and plane, so it projects fully inside the near-side rect
// but covers only ~1/3 of its width.
var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0));
b.PortalPolygons.Add(Quad(0f, 0f, 0.3f, 0.9f, -3f));
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b };
return (a, b, all);
}
[Fact]
public void Build_AppliesReciprocalOtherPortalClip()
{
var (camCell, neighbour, lookup) = SyntheticReciprocalPair();
var vp = ViewProj();
var f = PortalVisibilityBuilder.Build(
camCell, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, vp);
Assert.True(f.CellViews.ContainsKey(0x0002), "neighbour cell view should be populated");
// The reciprocal opening's area in NDC: project B's narrow back-portal
// polygon and take its (CCW-magnitude) shoelace area. This is the
// tightest the neighbour region can be — the propagated region must not
// exceed it once both-sides clipping is applied.
float reciprocalArea = ProjectedPolygonArea(neighbour.PortalPolygons[0], vp);
float area = CellViewArea(f.CellViews[0x0002]);
const float eps = 1e-4f;
Assert.True(area <= reciprocalArea + eps,
$"neighbour CellView area {area} must be clipped to the narrower reciprocal opening " +
$"{reciprocalArea} (without OtherPortalClip it equals the WIDE near-side projection)");
// Falsifiability guard: the near-side projection is genuinely WIDER than
// the reciprocal, so a no-op clip would have failed the assertion above.
float nearSideArea = ProjectedPolygonArea(camCell.PortalPolygons[0], vp);
Assert.True(nearSideArea > reciprocalArea * 1.5f,
$"fixture sanity: near-side area {nearSideArea} must dominate reciprocal {reciprocalArea}");
}
[Fact]
public void Build_ReciprocalClip_DegradesGracefully_WhenNoBackPortal()
{
// Same wide A->B opening, but B has NO portal pointing back to A (data gap).
// The reciprocal clip must no-op, so B's CellView equals the WIDE near-side
// projection — proving degradation never under-includes (and never throws).
const uint A = 0x0001, B = 0x0002;
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0));
a.PortalPolygons.Add(Quad(0f, 0f, 0.9f, 0.9f, -3f));
var b = Cell(B); // no portals at all → no back-portal to match
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b };
var vp = ViewProj();
var f = PortalVisibilityBuilder.Build(
a, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp);
Assert.True(f.CellViews.ContainsKey(B), "neighbour must still be reached when no back-portal exists");
float nearSideArea = ProjectedPolygonArea(a.PortalPolygons[0], vp);
float area = CellViewArea(f.CellViews[B]);
Assert.True(System.MathF.Abs(area - nearSideArea) < 1e-3f,
$"with no reciprocal portal the region must equal the near-side projection " +
$"(got {area}, near-side {nearSideArea}) — degrade must not tighten or expand");
}
// Signed-area-magnitude (shoelace) sum over a CellView's polygons in NDC.
private static float CellViewArea(CellView view)
{
float total = 0f;
foreach (var poly in view.Polygons) total += ShoelaceAbs(poly.Vertices);
return total;
}
// Project a cell-local polygon (identity world transform in these fixtures)
// to NDC via the same path the builder uses, then take its area magnitude.
private static float ProjectedPolygonArea(Vector3[] localPoly, Matrix4x4 vp)
{
var ndc = PortalProjection.ProjectToNdc(localPoly, Matrix4x4.Identity, vp);
return ShoelaceAbs(ndc);
}
private static float ShoelaceAbs(Vector2[] poly)
{
if (poly == null || poly.Length < 3) return 0f;
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;
}
return System.MathF.Abs(area2) * 0.5f;
}
}
internal static class PortalFrameTestHelper