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;
// 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(