From 0cc561c4d0f7de61727cc8a2ecff0dc6204b5129 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 3 Jun 2026 13:42:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(render):=20doorway=20void=20=E2=80=94=20por?= =?UTF-8?q?tal=20near-clip=20was=20near-dependent=20(eye-clip=20instead)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cottage doorway 'void' (dark cell + floating entities while the chase camera looks through the opening): PortalProjection.ProjectToNdc clipped portals on w+z>=0 — the GL [-1,1] near-plane test — but acdream's camera builds its projection with D3D-convention Matrix4x4.CreatePerspectiveFieldOfView and a 1.0 m near plane (RetailChaseCamera). Against that matrix w+z>=0 discards everything within ~0.5 m of the eye, so when the camera orbits to ~0.1 m from a doorway portal the near edge is clipped, the far edge projects off-screen ([flap] showed p->0171 D=0.10 proj=4 clip=0 ndc Y=-3.5..-6.6), the room behind is culled (vis=1) and only the tiny vestibule shell draws -> dark void. Rotating away moved the eye off the portal -> vis=5 -> room rendered. Fix: clip against the EYE (w > MinW, MinW=0.05 m), near-INDEPENDENT — a portal you're standing in still projects (covers the screen) so the cell behind stays visible. We only use the projected x/y for the visibility clip region, so keeping vertices in front of the near plane is correct. Matches retail PView::GetClip near-clipping the portal before project. RED->GREEN regression test (doorway 0.1 m from a near=1.0 eye); 177 App tests green; the existing straddling ±50 bound still holds. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/PortalProjection.cs | 45 ++++++++++++------- .../Rendering/PortalProjectionTests.cs | 32 +++++++++++++ 2 files changed, 61 insertions(+), 16 deletions(-) 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)"); + } }