fix(render): R-A2b — cull back portal like retail (InitCell side test), kill the indoor flap cycle

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-09 13:06:57 +02:00
parent 89a2032c8e
commit 485e44d163
2 changed files with 37 additions and 7 deletions

View file

@ -216,12 +216,15 @@ public static class PortalVisibilityBuilder
bool sideAllowed = true; bool sideAllowed = true;
// Portal-side test: only traverse a portal the camera is on the interior side of // Portal-side test (retail PView::InitCell side test, decomp:432962): only traverse a portal
// (mirrors CellVisibility.GetVisibleCells + retail's 'seen' flag). Culls back-facing // the camera is on the INTERIOR side of. Retail culls the back-facing portal (the doorway just
// portals so we never feed a degenerate/wrong-facing projection downstream. // 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 if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos) && !CameraOnInteriorSide(cell, i, cameraPos))
&& !eyeInsideOpening)
{ {
sideAllowed = false; sideAllowed = false;
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side eyeIn={eyeInsideOpening}"); 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. continue; // already outdoors; exterior terrain was drawn by the caller.
bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos); 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 if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos) && !CameraOnInteriorSide(cell, i, cameraPos))
&& !eyeInsideOpening)
continue; continue;
var clippedRegion = ClipPortalAgainstView( var clippedRegion = ClipPortalAgainstView(

View file

@ -249,6 +249,33 @@ public class PortalVisibilityBuilderTests
Assert.True(frame.OutsideView.IsEmpty); // its window never marked 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<uint, LoadedCell> { [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] [Fact]
public void Builder_CwWoundExitPortal_OutsideRegionIsCcw() public void Builder_CwWoundExitPortal_OutsideRegionIsCcw()
{ {