diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index dd636a04..b1fac06b 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -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(); - 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; + + /// + /// True when the camera eye is "standing in" 's opening: within + /// 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. + /// + 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; + } + /// /// 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 diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index a648a072..18aae05e 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -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 { [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 { [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() {