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

@ -175,12 +175,12 @@ public static class PortalVisibilityBuilder
continue;
}
// TODO(A8.F): neighbour-side OtherPortalClip (decomp:433524) — also clip the
// interior portal against the neighbour's matching portal polygon. Not implemented
// here; add if multi-cell conformance shows over-inclusion.
uint neighbourId = lbMask | portal.OtherCellId;
// Cross-building boundary: route to CrossBuildingViews, don't continue in-building BFS.
// (Cross-building entry is retail's CBldPortal/AddToCell channel, not OtherPortalClip;
// the reciprocal clip below is interior-cell-to-cell only, matching the OtherPortalClip
// call inside ConstructView at decomp:433692.)
if (buildingMembership != null && !buildingMembership(neighbourId))
{
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId);
@ -191,6 +191,17 @@ public static class PortalVisibilityBuilder
var neighbour = lookup(neighbourId);
if (neighbour == null) continue;
// Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip
// decomp:433524). The portal opening seen from THIS cell may be wider than the
// SAME opening seen from the neighbour (skewed/oblique apertures), so retail
// re-clips the already-near-side-clipped region against the neighbour's matching
// (reciprocal) portal polygon — the propagated region is the intersection of the
// opening "seen from A" AND "seen from B". This can only TIGHTEN, never widen, and
// degrades to the prior near-side-only region when no back-portal is found (data
// gap). Mutates clippedRegion in place before the union below.
ApplyReciprocalClip(clippedRegion, cell.CellId, neighbour, viewProj);
if (clippedRegion.Count == 0) continue; // reciprocal opening doesn't overlap → not visible
// Union the clipped region into the neighbour's accumulated view.
var nview = GetOrCreate(frame.CellViews, neighbourId);
foreach (var cp in clippedRegion) nview.Add(cp);
@ -239,6 +250,54 @@ public static class PortalVisibilityBuilder
if (area2 < 0f) Array.Reverse(poly);
}
// Phase U.2b — reciprocal OtherPortalClip (retail PView::OtherPortalClip decomp:433524).
// Finds the neighbour's portal that points BACK to `fromCellId`, projects that reciprocal
// polygon through the NEIGHBOUR's world transform to NDC, and intersects it into every
// polygon of `clippedRegion` (already clipped against the near-side opening + current view).
// The net region is "opening seen from the near cell" ∩ "opening seen from the neighbour" —
// a strict tightening that prevents over-inclusion through skewed apertures. Degrades to a
// no-op (leaves `clippedRegion` untouched) when no matching back-portal exists or the
// reciprocal polygon is missing/degenerate/projects behind the camera — NEVER throws.
//
// Retail resolves the reciprocal via an explicit back-link (arg2->other_portal_id at 005a54b2);
// our LoadedCell data model lacks that index, so we recover it by scanning the neighbour's
// Portals for the entry whose OtherCellId equals the near cell's low-16-bits id. PortalPolygons
// is in lockstep with Portals, so the matched index j gives PortalPolygons[j] as the reciprocal.
private static void ApplyReciprocalClip(
List<ViewPolygon> clippedRegion, uint fromCellId, LoadedCell neighbour, Matrix4x4 viewProj)
{
if (clippedRegion.Count == 0) return;
// Neighbour portals store the 16-bit OtherCellId; match against the near cell's low word
// (the builder composes full ids as lbMask | OtherCellId, so the low word is the key).
ushort backTarget = (ushort)(fromCellId & 0xFFFFu);
Vector3[]? reciprocalPoly = null;
for (int j = 0; j < neighbour.Portals.Count; j++)
{
if (neighbour.Portals[j].OtherCellId != backTarget) continue;
if (j >= neighbour.PortalPolygons.Count) break;
var candidate = neighbour.PortalPolygons[j];
if (candidate != null && candidate.Length >= 3) reciprocalPoly = candidate;
break;
}
if (reciprocalPoly == null) return; // data gap → keep near-side-only region (degrade gracefully)
// 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.
Vector2[] reciprocalNdc = PortalProjection.ProjectToNdc(reciprocalPoly, neighbour.WorldTransform, viewProj);
if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op
EnsureCcw(reciprocalNdc);
// Intersect the reciprocal opening into each near-side polygon; drop any that fall away.
for (int k = clippedRegion.Count - 1; k >= 0; k--)
{
var tightened = ScreenPolygonClip.Intersect(reciprocalNdc, clippedRegion[k].Vertices);
if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened);
else clippedRegion.RemoveAt(k);
}
}
private static CellView GetOrCreate(Dictionary<uint, CellView> map, uint key)
{
if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; }