diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8cc0ce6..79f79c9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7139,7 +7139,21 @@ public sealed class GameWindow : IDisposable // Step 4: portal visibility — compute BEFORE the UBO upload so // the indoor flag drives the sun's intensity to zero for // dungeons (r13 §13.7). - var visibility = _cellVisibility.ComputeVisibility(camPos); + // Phase U.4c (2026-05-31): root indoor visibility at the PLAYER's cell, not the + // camera EYE. Retail's CellManager::ChangePosition (0x004559B0) tracks curr_cell by + // the player/physics position. The 3rd-person chase EYE drifts out of the player's + // cell (through interior walls into AABB gaps); FindCameraCell then can't place the + // eye and returns the STALE previous cell for its 3 grace frames, from which the + // doorway portal is "behind" the eye → culled → the exit cell + terrain + shells + // flap off. ACDREAM_PROBE_FLAP capture (2026-05-31): every flap frame is + // res=Grace eyeInRoot=n terrain=Skip; every good frame is eyeInRoot=Y. The eye is + // still used for the per-frame PROJECTION (envCellViewProj) — only the cell ROOT + + // portal-side test track the player. This mirrors the playerInsideCell lighting + // decision below, which already roots at the player for exactly this reason. + var visRootPos = (_playerMode && _playerController is not null) + ? _playerController.Position + : camPos; + var visibility = _cellVisibility.ComputeVisibility(visRootPos); bool cameraInsideCell = visibility?.CameraCell is not null; // Phase U.4 (2026-05-30): the [vis] probe moved DOWN to the unified @@ -7285,9 +7299,12 @@ public sealed class GameWindow : IDisposable HashSet? envCellShellFilter = null; // drawable visible cells (cellIdToSlot keys) if (clipRoot is not null) { + // Phase U.4c: side test + distance ordering use the PLAYER position (visRootPos, + // stable inside the cell); projection uses the eye's envCellViewProj (the screen + // view). See the visRootPos rationale at the ComputeVisibility call above. var pvFrame = PortalVisibilityBuilder.Build( clipRoot, - camPos, + visRootPos, id => _cellVisibility.TryGetCell(id, out var c) ? c : null, envCellViewProj); diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index 659add2..9d4fb02 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -365,59 +365,18 @@ public class PortalVisibilityBuilderTests } // ----------------------------------------------------------------------- - // Phase U.4c: the threshold "flap". A chain camera(C0) -> mid(C1) -> exit(C2) - // where the C0->C1 portal's clip plane sits just in front of the camera. - // BOTH poses are legitimately inside C0 and SHOULD see the exit window; they - // straddle the C0->C1 side-test boundary by a few cm. Pre-fix, the pose just - // behind the plane hard-culls C0->C1 (CameraOnInteriorSide), C2 is never - // reached, and OutsideView empties — the flap. The fix must keep the exit - // cell visible (OutsideView non-empty) at BOTH poses. + // Phase U.4c: an earlier synthetic flap test (Build_NearBoundaryIntermediatePortal) + // lived here. It modeled the doorway flap as a BUILDER side-test cull dropping the + // exit cell. The live ACDREAM_PROBE_FLAP capture (2026-05-31) DISPROVED that model: + // the real cause is the visibility ROOT being driven by the 3rd-person camera EYE — + // the eye drifts out of the player's cell, FindCameraCell returns a STALE cell for + // its grace frames, and the doorway is then culled as "behind" the eye. The fix is + // at the GameWindow integration level (root visibility at the PLAYER's cell: + // visRootPos), NOT in the builder — the builder's side test is correct and unchanged, + // so the old test asserted a non-bug and was removed rather than left red. The fix is + // validated by the visual gate + the [flap] probe (RenderingDiagnostics.ProbeFlapEnabled); + // see docs/research/2026-05-31-u4c-flap-characterization.md. // ----------------------------------------------------------------------- - private static Matrix4x4 ViewProjAt(Vector3 eye) - { - var view = Matrix4x4.CreateLookAt(eye, eye + new Vector3(0, 0, -1), Vector3.UnitY); - var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f); - return view * proj; - } - - private static (LoadedCell cam, Dictionary all) FlapChain() - { - const uint C0 = 0x0001, C1 = 0x0002, C2 = 0x0003; - // C0 -> C1 portal at z=-1, with a clip plane (normal +Z, InsideSide=0) at z=-1. - // dot = camZ + D; with D = 1 the plane is at camZ = -1: inside iff camZ >= -1 - eps. - var c0 = Cell(C0, new CellPortalInfo((ushort)C1, 0, 0, 0)); - c0.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -1f)); - c0.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = 1f, InsideSide = 0 }); - // C1 -> C2 (no clip plane → never culled), C2 has the exit window. - var c1 = Cell(C1, new CellPortalInfo((ushort)C2, 0, 0, 0)); - c1.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -4f)); - var c2 = Cell(C2, new CellPortalInfo(0xFFFF, 0, 0, 0)); - c2.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -7f)); - var all = new Dictionary { [C0] = c0, [C1] = c1, [C2] = c2 }; - return (c0, all); - } - - [Fact] - public void Build_NearBoundaryIntermediatePortal_ExitCellStaysVisibleAcrossPose() - { - var (cam, all) = FlapChain(); - Func lookup = id => all.TryGetValue(id, out var c) ? c : null; - - // Pose A: a few cm IN FRONT of the C0->C1 plane (camZ = -0.9 >= -1 → inside). - var poseA = new Vector3(0, 0, -0.9f); - var frameA = PortalVisibilityBuilder.Build(cam, poseA, lookup, ViewProjAt(poseA)); - - // Pose B: a few cm BEHIND it (camZ = -1.1 < -1 → pre-fix the side test culls C0->C1). - var poseB = new Vector3(0, 0, -1.1f); - var frameB = PortalVisibilityBuilder.Build(cam, poseB, lookup, ViewProjAt(poseB)); - - // The exit cell — and therefore OutsideView — must be present at BOTH poses. - Assert.False(frameA.OutsideView.IsEmpty, "pose A should see the exit window"); - Assert.False(frameB.OutsideView.IsEmpty, - "pose B (a few cm away) must ALSO see the exit window — this is the flap: " + - "an intermediate side-test flip must not drop the exit cell from the set"); - Assert.Contains(0x0003u, frameB.OrderedVisibleCells); - } // Signed-area-magnitude (shoelace) sum over a CellView's polygons in NDC. private static float CellViewArea(CellView view)