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:
parent
5f596f2d25
commit
9f95252d20
2 changed files with 127 additions and 8 deletions
|
|
@ -145,18 +145,36 @@ public static class PortalVisibilityBuilder
|
|||
// (ProjectToNdc preserves input winding; portal dat polygons may be CW).
|
||||
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 (portalNdc.Length < 3) continue;
|
||||
EnsureCcw(portalNdc);
|
||||
|
||||
// Intersect the portal opening with every polygon of the current cell's view.
|
||||
var clippedRegion = new List<ViewPolygon>();
|
||||
foreach (var vp in currentView.Polygons)
|
||||
if (portalNdc.Length >= 3)
|
||||
{
|
||||
var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices);
|
||||
if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped));
|
||||
EnsureCcw(portalNdc);
|
||||
// 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 (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];
|
||||
|
||||
|
|
@ -394,6 +412,67 @@ public static class PortalVisibilityBuilder
|
|||
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>
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -58,6 +58,46 @@ public class PortalVisibilityBuilderTests
|
|||
$"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]
|
||||
public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue