diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs new file mode 100644 index 0000000..9a75c04 --- /dev/null +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -0,0 +1,158 @@ +// PortalVisibilityBuilder.cs +// +// Phase A8.F: recursive portal-clip visibility (the builder). Port of retail +// PView::ConstructView (decomp:433750) -> ClipPortals (433572) -> AddViewToPortals +// (433446). Walks the portal graph from the camera cell, accumulating a per-cell +// screen-space CellView; exit portals union their clipped region into OutsideView. +// GL-free; unit-tested without a GPU context. +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// Per-frame output of the portal-frame BFS. +public sealed class PortalVisibilityFrame +{ + /// Screen region (NDC) where outdoor terrain/scenery may draw — exit portals + /// recursively clipped to their portal chain. The cellar-flap fix. + public CellView OutsideView { get; } = new(); + + /// Per-cell accumulated clip region, keyed by full cell id (wire-in #2). + public Dictionary CellViews { get; } = new(); + + /// Entry clip regions for other buildings reached through our portals, keyed by the + /// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5). + public Dictionary CrossBuildingViews { get; } = new(); +} + +public static class PortalVisibilityBuilder +{ + // Bound on neighbour re-processing (retail uses update_count timestamps). Portal graphs are + // small; this guards cycles while allowing multi-portal unions into the same neighbour. + private const int MaxReprocessPerCell = 4; + private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon + + /// Resolve a full cell id to its LoadedCell, or null if not loaded. + /// Optional: true if a cell id is in the camera building's cell + /// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of + /// continuing the in-building BFS. Pass null to treat all reachable cells as in-building. + public static PortalVisibilityFrame Build( + LoadedCell cameraCell, + Vector3 cameraPos, + Func lookup, + Matrix4x4 viewProj, + Func? buildingMembership = null) + { + var frame = new PortalVisibilityFrame(); + if (cameraCell == null) return frame; + + uint lbMask = cameraCell.CellId & 0xFFFF0000u; + + frame.CellViews[cameraCell.CellId] = CellView.FullScreen(); + var processCount = new Dictionary(); + var queue = new Queue(); + queue.Enqueue(cameraCell); + + while (queue.Count > 0) + { + var cell = queue.Dequeue(); + if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty) + continue; + + processCount.TryGetValue(cell.CellId, out int pc); + if (pc >= MaxReprocessPerCell) continue; + processCount[cell.CellId] = pc + 1; + + for (int i = 0; i < cell.Portals.Count; i++) + { + if (i >= cell.PortalPolygons.Count) continue; + var poly = cell.PortalPolygons[i]; + if (poly == null || poly.Length < 3) continue; + + // 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. + if (i < cell.ClipPlanes.Count && !CameraOnInteriorSide(cell, i, cameraPos)) + continue; + + // Project to NDC, then normalize to CCW for the CCW-only ScreenPolygonClip + // (ProjectToNdc preserves input winding; portal dat polygons may be CW). + Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj); + if (portalNdc.Length < 3) continue; + EnsureCcw(portalNdc); + + // Intersect the portal opening with every polygon of the current cell's view. + var clippedRegion = new List(); + foreach (var vp in currentView.Polygons) + { + var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices); + if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped)); + } + if (clippedRegion.Count == 0) continue; // portal not visible through this chain + + var portal = cell.Portals[i]; + + if (portal.OtherCellId == 0xFFFF) + { + // Exit portal -> outdoors visible through this (clipped) opening. + foreach (var cp in clippedRegion) frame.OutsideView.Add(cp); + continue; + } + + // TODO(A8.F): neighbour-side OtherPortalClip (decomp:433524) — also clip the + // interior portal against the neighbour's matching portal polygon. Not implemented + // here; add if multi-cell conformance shows over-inclusion. + uint neighbourId = lbMask | portal.OtherCellId; + + // Cross-building boundary: route to CrossBuildingViews, don't continue in-building BFS. + if (buildingMembership != null && !buildingMembership(neighbourId)) + { + var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId); + foreach (var cp in clippedRegion) xview.Add(cp); + continue; + } + + var neighbour = lookup(neighbourId); + if (neighbour == null) continue; + + // Union the clipped region into the neighbour's view; (re)enqueue if it grew. + var nview = GetOrCreate(frame.CellViews, neighbourId); + int before = nview.Polygons.Count; + foreach (var cp in clippedRegion) nview.Add(cp); + if (nview.Polygons.Count > before) + queue.Enqueue(neighbour); + } + } + + return frame; + } + + // Mirrors CellVisibility's portal-side test (InsideSide convention). + private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos) + { + var plane = cell.ClipPlanes[portalIndex]; + if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow + var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform); + float dot = Vector3.Dot(plane.Normal, localCam) + plane.D; + return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon; + } + + // Reverse vertex order in place if the polygon is wound clockwise (signed area < 0). + private static void EnsureCcw(Vector2[] poly) + { + float area2 = 0f; + for (int i = 0; i < poly.Length; i++) + { + var p = poly[i]; var q = poly[(i + 1) % poly.Length]; + area2 += p.X * q.Y - q.X * p.Y; + } + if (area2 < 0f) Array.Reverse(poly); + } + + private static CellView GetOrCreate(Dictionary map, uint key) + { + if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; } + return v; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs new file mode 100644 index 0000000..e51d48b --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class PortalVisibilityBuilderTests +{ + private static Matrix4x4 ViewProj() + { + var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f); + return view * proj; + } + + private static Vector3[] Quad(float cx, float cy, float halfW, float halfH, float z) => new[] + { + new Vector3(cx - halfW, cy - halfH, z), new Vector3(cx + halfW, cy - halfH, z), + new Vector3(cx + halfW, cy + halfH, z), new Vector3(cx - halfW, cy + halfH, z), + }; + + private static LoadedCell Cell(uint id, params CellPortalInfo[] portals) => new LoadedCell + { + CellId = id, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, + Portals = new List(portals), + }; + + private static PortalVisibilityFrame Build(LoadedCell cam, Dictionary all) + => PortalVisibilityBuilder.Build(cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, ViewProj()); + + [Fact] + public void Builder_Cellar_WindowClippedToStairwell_NotFullWindow() + { + var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0)); + cam.PortalPolygons.Add(Quad(0f, 0f, 0.1f, 1.0f, -3f)); // narrow stairwell + var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0)); + ground.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); // wide window + var all = new Dictionary { [0x0001] = cam, [0x0002] = ground }; + + var frame = Build(cam, all); + + Assert.False(frame.OutsideView.IsEmpty); + float outsideWidth = frame.OutsideView.MaxX - frame.OutsideView.MinX; + float windowOnlyWidth = PortalFrameTestHelper.ProjectedWidth( + new[] { new Vector3(-1, 0, -6), new Vector3(1, 0, -6) }, ViewProj()); + Assert.True(outsideWidth < windowOnlyWidth * 0.5f, + $"OutsideView width {outsideWidth} should be a sliver, far less than full window {windowOnlyWidth}"); + } + + [Fact] + public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty() + { + var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0)); + cam.PortalPolygons.Add(Quad(0, 0, 0.1f, 1f, -3f)); + var inner = Cell(0x0002); // no portals at all + var all = new Dictionary { [0x0001] = cam, [0x0002] = inner }; + Assert.True(Build(cam, all).OutsideView.IsEmpty); + } + + [Fact] + public void Builder_CameraCellWithDirectExit_OutsideViewIsFullWindow() + { + var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0)); + cam.PortalPolygons.Add(Quad(0, 0, 1f, 1f, -6f)); + var all = new Dictionary { [0x0001] = cam }; + var frame = Build(cam, all); + Assert.False(frame.OutsideView.IsEmpty); + Assert.True(frame.OutsideView.MaxX - frame.OutsideView.MinX > 0.3f); + } + + [Fact] + public void Builder_BackFacingPortal_NotTraversed() + { + // Portal to 0x0002, but its clip plane puts the camera (origin) on the OUTSIDE. + var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0)); + cam.PortalPolygons.Add(Quad(0, 0, 0.5f, 0.5f, -3f)); + cam.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = -1f, InsideSide = 0 }); + // dot = (0,0,1)·origin + (-1) = -1 < 0; InsideSide==0 requires dot >= -eps → camera OUTSIDE → skip. + var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0)); + ground.PortalPolygons.Add(Quad(0, 0, 1f, 1f, -6f)); + var all = new Dictionary { [0x0001] = cam, [0x0002] = ground }; + + var frame = Build(cam, all); + Assert.False(frame.CellViews.ContainsKey(0x0002)); // neighbour never reached + Assert.True(frame.OutsideView.IsEmpty); // its window never marked + } + + [Fact] + public void Builder_CwWoundExitPortal_OutsideRegionIsCcw() + { + // Exit portal authored CLOCKWISE — the builder must normalize to CCW so downstream stays valid. + var cwQuad = new[] + { + new Vector3(-1, -1, -6), new Vector3(-1, 1, -6), new Vector3(1, 1, -6), new Vector3(1, -1, -6), + }; + var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0)); + cam.PortalPolygons.Add(cwQuad); + var all = new Dictionary { [0x0001] = cam }; + var frame = Build(cam, all); + Assert.False(frame.OutsideView.IsEmpty); + var p = frame.OutsideView.Polygons[0].Vertices; + float area2 = 0f; + for (int i = 0; i < p.Length; i++) { var a = p[i]; var b = p[(i + 1) % p.Length]; area2 += a.X * b.Y - b.X * a.Y; } + Assert.True(area2 > 0f, "clipped OutsideView region should be CCW after winding normalization"); + } +} + +internal static class PortalFrameTestHelper +{ + public static float ProjectedWidth(Vector3[] worldSeg, Matrix4x4 vp) + { + var a = Vector4.Transform(new Vector4(worldSeg[0], 1f), vp); + var b = Vector4.Transform(new Vector4(worldSeg[1], 1f), vp); + return System.MathF.Abs(a.X / a.W - b.X / b.W); + } +}