diff --git a/src/AcDream.App/Rendering/PortalProjection.cs b/src/AcDream.App/Rendering/PortalProjection.cs index e34b5f6..902570a 100644 --- a/src/AcDream.App/Rendering/PortalProjection.cs +++ b/src/AcDream.App/Rendering/PortalProjection.cs @@ -1,15 +1,20 @@ // PortalProjection.cs // -// Phase A8.F: project a cell-local portal polygon to NDC screen space, clipping -// against the in-front-of-camera half-space (keep where w + z >= 0) so a portal -// straddling the camera does not invert under the perspective divide. This crossing -// excludes the eye (w = 0) and lands just in front of the near plane, so every kept -// vertex has w bounded away from zero and the divide is safe — no eye-singularity -// blow-up. The predicate is convention-agnostic: acdream's cameras build projection -// with Matrix4x4.CreatePerspectiveFieldOfView (NDC z in [0,1]); under a true GL -// [-1,1] matrix w + z = 0 is exactly the near plane. Either way the eye is excluded. -// Homogeneous form of the near-plane sidedness in retail PView::GetClip / -// ConstructView(CBldPortal) (decomp:432344 / 433832). +// Phase A8.F: project a cell-local portal polygon to NDC screen space, clipping against the +// IN-FRONT-OF-EYE half-space (keep where w > MinW) so a portal straddling the camera does not +// invert under the perspective divide, and the divide stays bounded away from the w=0 eye +// singularity. +// +// The clip is NEAR-INDEPENDENT on purpose. We only use the projected x/y for the visibility clip +// REGION, so a vertex in front of the eye is meaningful even if it is closer than the projection's +// near plane. acdream's cameras build projection with Matrix4x4.CreatePerspectiveFieldOfView (D3D +// convention, NDC z in [0,1]) and a 1.0 m near plane (RetailChaseCamera). The previous w+z>=0 +// predicate was the GL ([-1,1]) near-plane test; against the D3D matrix it discarded everything +// within ~0.5 m of the eye, so a doorway the chase camera was ~0.1 m from got clipped to empty -> +// the cell behind it was culled -> the cottage doorway "void" (2026-06-03). Clipping at the eye +// (w > MinW) keeps a portal you're standing in (it covers the screen) so the cell behind stays +// visible. Retail PView::GetClip / ConstructView(CBldPortal) (decomp:432344 / 433832) near-clip the +// portal poly likewise before projecting. using System.Collections.Generic; using System.Numerics; @@ -33,8 +38,10 @@ public static class PortalProjection foreach (var lp in localPoly) clip.Add(Vector4.Transform(new Vector4(lp, 1f), m)); - // Clip against the in-front-of-camera half-space (keep where w + z >= 0). - clip = ClipAgainstNearPlane(clip); + // Clip against the in-front-of-eye half-space (keep where w > MinW). Near-independent: + // see the file header — clipping at the projection's near plane culls portals the camera + // is standing in (the doorway "void"). + clip = ClipBehindEye(clip); if (clip.Count < 3) return System.Array.Empty(); // Perspective divide → NDC xy. @@ -47,16 +54,22 @@ public static class PortalProjection return ndc; } - // Sutherland-Hodgman against the in-front-of-camera half-space: keep where (w + z) >= 0. - private static List ClipAgainstNearPlane(List poly) + // Minimum clip-space w (≈ metres in front of the eye) to keep a vertex. Excludes the eye + // (w=0) singularity and the ~5 cm right at it (bounding the perspective divide), but is + // INTENTIONALLY far closer than the projection's 1.0 m near plane so a doorway the camera is + // standing in still projects and the cell behind it stays visible. See the file header. + private const float MinW = 0.05f; + + // Sutherland-Hodgman against the in-front-of-eye half-space: keep where w > MinW. + private static List ClipBehindEye(List poly) { var result = new List(poly.Count + 1); for (int i = 0; i < poly.Count; i++) { Vector4 cur = poly[i]; Vector4 prev = poly[(i + poly.Count - 1) % poly.Count]; - float dCur = cur.W + cur.Z; - float dPrev = prev.W + prev.Z; + float dCur = cur.W - MinW; + float dPrev = prev.W - MinW; bool curIn = dCur >= 0f; bool prevIn = dPrev >= 0f; diff --git a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs index eeb6c6e..18f4240 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs @@ -1,3 +1,4 @@ +using System; using System.Numerics; using AcDream.App.Rendering; using Xunit; @@ -84,4 +85,35 @@ public class PortalProjectionTests Assert.InRange(v.Y, -1.001f, 1.001f); } } + + [Fact] + public void Project_PortalEyeIsAlmostTouching_StaysVisibleOnScreen() + { + // Regression (2026-06-03 doorway "void"): the chase camera orbits to ~0.1 m from a + // doorway portal. With RetailChaseCamera's 1.0 m near plane and the old w+z>=0 GL + // near-clip — which, for a D3D CreatePerspectiveFieldOfView matrix, discards everything + // within ~0.5 m of the eye — the whole doorway was clipped to empty, so the room behind + // it was culled and rendered as a dark void (camera-orientation dependent; rotating away + // fixed it). The eye-clip must be near-INDEPENDENT: a portal you're standing in must + // still project (covering the screen) so the cell behind stays visible. + var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1.0f, 5000f); // RetailChaseCamera + var viewProj = view * proj; + + // A 2 m x 2 m doorway 0.1 m in front of the eye, facing it. + var doorway = new[] + { + new Vector3(-1f, -1f, -0.1f), new Vector3(1f, -1f, -0.1f), + new Vector3(1f, 1f, -0.1f), new Vector3(-1f, 1f, -0.1f), + }; + + var projected = PortalProjection.ProjectToNdc(doorway, Matrix4x4.Identity, viewProj); + Assert.True(projected.Length >= 3, + "a doorway 0.1 m from the eye must still project (was clipped to empty -> void)"); + + var viewport = CellView.FullScreen().Polygons[0].Vertices; + var onScreen = ScreenPolygonClip.Intersect(projected, viewport); + Assert.True(onScreen.Length >= 3, + "the cell behind a doorway you're standing in must stay visible (the void bug)"); + } }