fix(render): flood the neighbour when the eye stands in an interior portal

When the chase camera roots in a thin doorway cell and the eye stands in an interior portal opening (live capture: vestibule->room portal D=0.16m, proj=0), the 2D projection degenerates and the neighbour was culled (cells=1) -> only the thin cell drew -> bluish void / transparent ceiling. Retail's 3D clip imposes no constraint for a portal the eye is inside, so the neighbour is fully visible. When the clipped region is empty but the eye stands in the opening (EyeInsidePortalOpening: within 0.5m of the portal plane AND point-in-opening), flood the neighbour with the current view. Guarded so an off-screen degenerate portal stays culled (no #95 blowup; over-include is mesh-frustum-culled at draw). Visual-verified: cellar ceiling now solid.

Band-aid for thin-cell-root coverage; likely superseded by the boom-stability + viewer-cell dead-zone + w=0 near-plane clip fix next session (reassess / maybe revert). 2 RED->GREEN tests; cyclic/hub termination guards unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-05 15:27:39 +02:00
parent 5f596f2d25
commit 9f95252d20
2 changed files with 127 additions and 8 deletions

View file

@ -145,18 +145,36 @@ public static class PortalVisibilityBuilder
// (ProjectToNdc preserves input winding; portal dat polygons may be CW). // (ProjectToNdc preserves input winding; portal dat polygons may be CW).
Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj); Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj);
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2}) ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F2},{v.Y:F2})"))}]"); if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2}) ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F2},{v.Y:F2})"))}]");
if (portalNdc.Length < 3) continue;
EnsureCcw(portalNdc);
// Intersect the portal opening with every polygon of the current cell's view.
var clippedRegion = new List<ViewPolygon>(); var clippedRegion = new List<ViewPolygon>();
foreach (var vp in currentView.Polygons) if (portalNdc.Length >= 3)
{ {
var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices); EnsureCcw(portalNdc);
if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped)); // Intersect the portal opening with every polygon of the current cell's view.
foreach (var vp in currentView.Polygons)
{
var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices);
if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped));
}
} }
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}"); if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
if (clippedRegion.Count == 0) continue; // portal not visible through this chain
// R1 void fix (2026-06-05): the projected+clipped region is empty — normally we cull the
// portal here. BUT if the eye is STANDING IN this portal's opening, the 2D projection has
// degenerated (the eye is in the doorway plane / within the near plane of the opening; the
// live capture saw the vestibule->room portal at D=0.16 m project to 0 verts). Retail's 3D
// portal clip imposes no constraint for a portal the eye is inside, so the neighbour is
// fully visible — substitute the CURRENT cell's view as the region so the flood reaches it
// (without this, rooting at a thin doorway cell drew only that cell -> the bluish void).
// EyeInsidePortalOpening (near-plane perp + point-in-opening) keeps a merely off-screen
// degenerate portal culled, so the visible set does not blow up (#95). Over-inclusion is
// otherwise safe: the neighbour mesh is frustum-culled per-vertex at draw time.
if (clippedRegion.Count == 0)
{
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
continue; // portal not visible through this chain, and the eye is not standing in it
foreach (var vp in currentView.Polygons)
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
}
var portal = cell.Portals[i]; var portal = cell.Portals[i];
@ -394,6 +412,67 @@ public static class PortalVisibilityBuilder
return best == float.MaxValue ? 0f : MathF.Sqrt(best); return best == float.MaxValue ? 0f : MathF.Sqrt(best);
} }
// "Eye standing in the opening": the eye is within this perpendicular distance of a portal's
// plane. At the cottage doorway the live capture measured D=0.16 m for the portal the chase
// camera was standing in; 0.5 m comfortably covers a doorway-standing eye while excluding portals
// the eye is merely facing from across a room (their projection is non-degenerate anyway).
private const float EyeStandingPerpDist = 0.5f;
/// <summary>
/// True when the camera eye is "standing in" <paramref name="localPoly"/>'s opening: within
/// <see cref="EyeStandingPerpDist"/> of the portal plane AND its perpendicular projection onto
/// that plane falls inside the portal polygon. This is the case where the 2D portal projection
/// degenerates to empty (the eye is in the doorway plane) yet the neighbour is genuinely visible
/// — retail's 3D portal clip imposes no constraint there. Used only as the gate that lets such a
/// portal flood its neighbour with the current view; a degenerate portal the eye is NOT inside
/// (off-screen / across the room) returns false and stays culled, so the visible set cannot blow up.
/// </summary>
private static bool EyeInsidePortalOpening(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 eyeWorld)
{
if (localPoly == null || localPoly.Length < 3) return false;
var p0 = Vector3.Transform(localPoly[0], worldTransform);
var p1 = Vector3.Transform(localPoly[1], worldTransform);
var p2 = Vector3.Transform(localPoly[2], worldTransform);
var n = Vector3.Cross(p1 - p0, p2 - p0);
float nl = n.Length();
if (nl < 1e-8f) return false; // degenerate polygon — no plane
n /= nl;
float perp = Vector3.Dot(n, eyeWorld - p0);
if (MathF.Abs(perp) > EyeStandingPerpDist) return false; // eye not close to the portal plane
// In-plane 2D basis (u along the first edge, v = n × u). Project the eye + every vertex into
// it (the perpendicular component drops out of the dot products) and run a point-in-polygon test.
var u = p1 - p0;
float ul = u.Length();
if (ul < 1e-8f) return false;
u /= ul;
var v = Vector3.Cross(n, u);
var rel = eyeWorld - p0;
var eye2 = new Vector2(Vector3.Dot(rel, u), Vector3.Dot(rel, v));
var poly2 = new Vector2[localPoly.Length];
for (int k = 0; k < localPoly.Length; k++)
{
var w = Vector3.Transform(localPoly[k], worldTransform) - p0;
poly2[k] = new Vector2(Vector3.Dot(w, u), Vector3.Dot(w, v));
}
return PointInPoly2D(eye2, poly2);
}
// Standard ray-crossing (even-odd) point-in-polygon test.
private static bool PointInPoly2D(Vector2 p, Vector2[] poly)
{
bool inside = false;
for (int i = 0, j = poly.Length - 1; i < poly.Length; j = i++)
{
var a = poly[i];
var b = poly[j];
if (((a.Y > p.Y) != (b.Y > p.Y)) &&
(p.X < (b.X - a.X) * (p.Y - a.Y) / (b.Y - a.Y) + a.X))
inside = !inside;
}
return inside;
}
/// <summary> /// <summary>
/// Distance-sorted work list for the portal BFS, ported from retail PView::cell_todo_list + /// Distance-sorted work list for the portal BFS, ported from retail PView::cell_todo_list +
/// InsCellTodoList (decomp:433183). Insertion keeps the list ordered so the NEAREST cell sits at /// InsCellTodoList (decomp:433183). Insertion keeps the list ordered so the NEAREST cell sits at

View file

@ -58,6 +58,46 @@ public class PortalVisibilityBuilderTests
$"OutsideView width {outsideWidth} should be a sliver, far less than full window {windowOnlyWidth}"); $"OutsideView width {outsideWidth} should be a sliver, far less than full window {windowOnlyWidth}");
} }
[Fact]
public void Build_EyeStandingInInteriorPortal_FloodsNeighbour()
{
// R1 void fix (2026-06-05): when the chase camera roots in a thin doorway cell and the eye is
// STANDING IN an interior portal opening, the live capture showed the vestibule->room portal at
// D=0.16 m projecting to 0 verts (proj=0), so the neighbour was wrongly culled (cells=1) and
// only the thin cell drew -> bluish void. Retail's 3D portal clip imposes no constraint for a
// portal the eye is inside, so the neighbour is fully visible. The flood MUST reach the neighbour.
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
cam.PortalPolygons.Add(Quad(0f, 0f, 0.3f, 0.3f, -0.03f)); // opening 3 cm in front — eye standing in it
var room = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
room.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f));
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = room };
var frame = Build(cam, all);
Assert.True(frame.CellViews.ContainsKey(0x0002),
"eye standing in the doorway must flood the neighbour (degenerate projection was culling it -> void)");
Assert.Contains(0x0002u, frame.OrderedVisibleCells);
}
[Fact]
public void Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion()
{
// Guard against the fix over-flooding: a portal whose opening the eye is NOT standing in (3 cm
// in front but 2 m to the SIDE) also projects degenerate, but the eye is OUTSIDE the opening, so
// it must stay culled — otherwise the eye-in-doorway fix would blow up the visible set (#95) by
// flooding every degenerate-projecting portal regardless of where the eye actually is.
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
cam.PortalPolygons.Add(Quad(2.0f, 0f, 0.3f, 0.3f, -0.03f)); // 2 m to the side — eye NOT in it
var room = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
room.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f));
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = room };
var frame = Build(cam, all);
Assert.False(frame.CellViews.ContainsKey(0x0002),
"a degenerate portal the eye is NOT standing in must stay culled (no over-inclusion / #95 blowup)");
}
[Fact] [Fact]
public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty() public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty()
{ {