// 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 { // Per-cell re-processing bound. NOTE: this cap is the actual termination mechanism for // cyclic portal graphs, NOT merely a safety net — the re-enqueue-on-growth guard below is // a near-no-op because CellView.Add never dedupes, so a cell almost always "grows". Retail // instead converges via an update_count / set_view(...,i) slice watermark (decomp: AddToCell // 433050 esi[0x11], InitCell timestamp, AddViewToPortals 433446). Consequences vs retail: // (a) a cell reachable through >4 contributing portals under-counts; (b) duplicate polygons // accumulate on cyclic/multi-path graphs (correctness survives — stencil marks are idempotent // — but it is per-frame cost). Both bite only on dungeon-scale cyclic/hub graphs; a cottage // cellar is a short chain where each cell is visited once. The faithful fixpoint port is filed // as a fast-follow (docs/ISSUES.md) before A8.F is relied on for dungeons. private const int MaxReprocessPerCell = 4; private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon // TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the // local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell. private static readonly bool s_pvDump = Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1"; private static readonly Dictionary s_pvDumpCount = new(); /// 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; // Interior portals never cross landblocks (same invariant as CellVisibility.GetVisibleCells); // building-boundary crossings are handled separately via the buildingMembership escape hatch. uint lbMask = cameraCell.CellId & 0xFFFF0000u; frame.CellViews[cameraCell.CellId] = CellView.FullScreen(); var processCount = new Dictionary(); var queue = new Queue(); queue.Enqueue(cameraCell); bool pvDump = false; if (s_pvDump) { lock (s_pvDumpCount) { s_pvDumpCount.TryGetValue(cameraCell.CellId, out int dc); if (dc < 2) { s_pvDumpCount[cameraCell.CellId] = dc + 1; pvDump = true; } } if (pvDump) { Console.WriteLine($"[pv-dump] camCell=0x{cameraCell.CellId:X8} portals={cameraCell.Portals.Count} polyLists={cameraCell.PortalPolygons.Count} vp[M11={viewProj.M11:F3} M22={viewProj.M22:F3} M33={viewProj.M33:F3} M34={viewProj.M34:F3} M43={viewProj.M43:F3} M44={viewProj.M44:F3}]"); // Camera-cell portal census (A8.F triage 2026-05-29): report, for EVERY // portal, the exact inputs the BFS guards read — BEFORE the guards run, so // a portal the loop silently `continue`s past is still visible here. An // empty OUTSIDEVIEW can then be traced to the precise gate: polyLen<3 (empty // polygon from BuildLoadedCell), interiorSide=false (camera back-facing the // portal — a legitimately-empty result, not a bug), or (if both OK) a // downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines. for (int ci = 0; ci < cameraCell.Portals.Count; ci++) { int plen = ci < cameraCell.PortalPolygons.Count ? (cameraCell.PortalPolygons[ci]?.Length ?? -1) : -2; bool hasPlane = ci < cameraCell.ClipPlanes.Count; bool interiorSide = !hasPlane || CameraOnInteriorSide(cameraCell, ci, cameraPos); var n = hasPlane ? cameraCell.ClipPlanes[ci].Normal : Vector3.Zero; Console.WriteLine($"[pv-dump] CAMPORTAL[{ci}] other=0x{cameraCell.Portals[ci].OtherCellId:X4} polyLen={plen} hasPlane={hasPlane} interiorSide={interiorSide} planeN=({n.X:F3},{n.Y:F3},{n.Z:F3})"); } } } 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; bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF; // 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)) { if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}"); 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 (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2}) ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F2},{v.Y:F2})"))}]"); 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 (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}"); if (clippedRegion.Count == 0) continue; // portal not visible through this chain var portal = cell.Portals[i]; if (portal.OtherCellId == 0xFFFF) { if (pvDump) { Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} clipPolys={clippedRegion.Count}"); Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]"); Console.WriteLine($"[pv-dump] ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F3},{v.Y:F3})"))}]"); foreach (var cp in clippedRegion) Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]"); } // 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); } } if (pvDump) Console.WriteLine($"[pv-dump] OUTSIDEVIEW polys={frame.OutsideView.Polygons.Count} bfsCellViews={frame.CellViews.Count} crossBldg={frame.CrossBuildingViews.Count}"); 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; } }