From 485e44d1637a4e05338de6864954271919b13d9f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 9 Jun 2026 13:06:57 +0200 Subject: [PATCH] =?UTF-8?q?fix(render):=20R-A2b=20=E2=80=94=20cull=20back?= =?UTF-8?q?=20portal=20like=20retail=20(InitCell=20side=20test),=20kill=20?= =?UTF-8?q?the=20indoor=20flap=20cycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinned (flap-sidechk.log): the indoor doorway flap is a 0171<->0173 flood cycle. Back portals show camInterior=False (our side test already agrees with retail) but were traversed when eyeIn=True because the side-cull had an bypass (added 2026-06-05 for the void). Within 1.75m of a doorway that bypass kept the BACK portal alive -> mutual re-contribution -> re-enqueue churn (maxPop=16) -> eye-sensitive flood depth -> grey flap + dropped floor. Fix (Option B1): drop the bypass from the side-cull in Build + BuildFromExterior so back portals cull by the side test alone, exactly like retail PView::InitCell (:432962, no eye-in-opening bypass). The forward-portal clip-empty void rescue is a SEPARATE branch and is untouched (Build_EyeStandingInInteriorPortal_FloodsNeighbour stays green). New RED->GREEN test Build_BackFacingPortal_EyeStandingInOpening_StillCulled; full App suite 218 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/PortalVisibilityBuilder.cs | 17 +++++++----- .../Rendering/PortalVisibilityBuilderTests.cs | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index ebf70c94..e2c78f79 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -216,12 +216,15 @@ public static class PortalVisibilityBuilder bool sideAllowed = true; - // Portal-side test: only traverse a portal the camera is on the interior side of - // (mirrors CellVisibility.GetVisibleCells + retail's 'seen' flag). Culls back-facing - // portals so we never feed a degenerate/wrong-facing projection downstream. + // Portal-side test (retail PView::InitCell side test, decomp:432962): only traverse a portal + // the camera is on the INTERIOR side of. Retail culls the back-facing portal (the doorway just + // flooded through) by this test ALONE — there is NO eye-in-opening bypass. R-A2b: the old + // `&& !eyeInsideOpening` bypass let a back portal within 1.75 m through, forming the + // 0171<->0173 flood cycle -> re-enqueue churn -> the doorway flap (pinned in flap-sidechk.log: + // back portals show camInterior=False eyeIn=True). The forward-portal clip-empty void rescue + // (below, the `clippedRegion.Count == 0` branch) is a SEPARATE path and stays. if (i < cell.ClipPlanes.Count - && !CameraOnInteriorSide(cell, i, cameraPos) - && !eyeInsideOpening) + && !CameraOnInteriorSide(cell, i, cameraPos)) { sideAllowed = false; trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side eyeIn={eyeInsideOpening}"); @@ -483,9 +486,9 @@ public static class PortalVisibilityBuilder continue; // already outdoors; exterior terrain was drawn by the caller. bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos); + // R-A2b: cull back portals by the side test alone (no eye-in-opening bypass) — see Build(). if (i < cell.ClipPlanes.Count - && !CameraOnInteriorSide(cell, i, cameraPos) - && !eyeInsideOpening) + && !CameraOnInteriorSide(cell, i, cameraPos)) continue; var clippedRegion = ClipPortalAgainstView( diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index 111b34ba..6ff081dd 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -249,6 +249,33 @@ public class PortalVisibilityBuilderTests Assert.True(frame.OutsideView.IsEmpty); // its window never marked } + [Fact] + public void Build_BackFacingPortal_EyeStandingInOpening_StillCulled() + { + // R-A2b (Option B1, the flap fix): retail PView::InitCell (:432962) culls a back-facing portal by + // the side test REGARDLESS of eye proximity — there is NO eye-in-opening bypass. The live pin + // (flap-sidechk.log, 2026-06-09) showed the back portal 0173->0171 with camInterior=False but + // eyeIn=True (eye within 1.75 m of the shared doorway); the OLD `&& !eyeInsideOpening` side-cull + // bypass let it through -> the 0171<->0173 flood cycle -> re-enqueue churn -> the doorway flap. + // A back-facing portal the eye is STANDING IN must stay culled (the forward-portal clip-empty void + // rescue, tested by Build_EyeStandingInInteriorPortal_FloodsNeighbour, is a separate path and stays). + var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); + cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -1f)); // 1 m in front -> eyeInsideOpening = True + // ClipPlane puts the eye on the EXIT side (camInterior = False), like the back portal of a doorway + // just crossed: Normal·origin + D = 1 > 0, InsideSide==1 wants dot<=eps -> not interior. + cam.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = 1f, InsideSide = 1 }); + var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0)); + ground.PortalPolygons.Add(Quad(0f, 0f, 1f, 1f, -6f)); + var all = new Dictionary { [0x0001] = cam, [0x0002] = ground }; + + var frame = Build(cam, all); + + Assert.False(frame.CellViews.ContainsKey(0x0002), + "a back-facing portal (camInterior=False) must stay culled even when the eye is standing in its " + + "opening (eyeInsideOpening=True) — retail's side test has no bypass; the bypass WAS the flap cycle"); + Assert.DoesNotContain(0x0002u, frame.OrderedVisibleCells); + } + [Fact] public void Builder_CwWoundExitPortal_OutsideRegionIsCcw() {