diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index d64c097..1ec315a 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -21,6 +21,13 @@ public sealed class PortalVisibilityFrame /// Per-cell accumulated clip region, keyed by full cell id (wire-in #2). public Dictionary CellViews { get; } = new(); + /// Visible interior cells in the exact order they were first dequeued from the + /// distance-priority work list — closest-first (Phase U.2a). Mirrors retail's + /// PView::cell_draw_list, appended in PView::ConstructView (decomp:433783) as each cell pops + /// off the nearest-vertex-sorted cell_todo_list (InsCellTodoList 433183). Deduplicated: a cell + /// appears exactly once, on its first dequeue. The camera cell is always first. + public List OrderedVisibleCells { 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(); @@ -28,17 +35,6 @@ public sealed class PortalVisibilityFrame 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 @@ -66,9 +62,26 @@ public static class PortalVisibilityBuilder uint lbMask = cameraCell.CellId & 0xFFFF0000u; frame.CellViews[cameraCell.CellId] = CellView.FullScreen(); - var processCount = new Dictionary(); - var queue = new Queue(); - queue.Enqueue(cameraCell); + + // Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first; + // each cell carries the camera→nearest-portal-vertex distance that put it on the list + // (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The + // camera cell seeds at distance 0 (retail InsCellTodoList(this, arg2, 0f) at 433758) so it + // always pops first. + var todo = new CellTodoList(); + todo.Insert(cameraCell, 0f); + + // Fixpoint termination replacing the old MaxReprocessPerCell hard cap. This mirrors the + // retail portal_view slice offset 0x44 (last-incorporated view-poly watermark) vs 0x38 + // (current view_count) decision in AddViewToPortals (433446): a cell is INSERTED into the + // todo list exactly once — on first discovery (retail's ecx_5==0 branch calls + // InsCellTodoList; the ecx_5!=eax_2 growth branch calls AddToCell IN PLACE and never + // re-enqueues). Later growth into an already-discovered cell is unioned into its CellView but + // does NOT re-enqueue it — the `cell_view_done` guarantee (ConstructView sets it at 433784 + // the instant a cell is popped). Enqueue-once across the cell set is the hard termination + // guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The + // camera cell is pre-marked so a portal looping back to it can never re-enqueue it. + var seen = new HashSet { cameraCell.CellId }; bool pvDump = false; if (s_pvDump) @@ -100,15 +113,16 @@ public static class PortalVisibilityBuilder } } - while (queue.Count > 0) + while (todo.Count > 0) { - var cell = queue.Dequeue(); + var cell = todo.PopNearest(); 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; + // `seen` guarantees each cell is inserted into the todo list exactly once, so this single + // pop IS the cell's closest-first draw position (retail appends to cell_draw_list once per + // pop, 433783) — no per-pop dedup needed, OrderedVisibleCells stays distinct by construction. + frame.OrderedVisibleCells.Add(cell.CellId); for (int i = 0; i < cell.Portals.Count; i++) { @@ -177,12 +191,23 @@ public static class PortalVisibilityBuilder var neighbour = lookup(neighbourId); if (neighbour == null) continue; - // Union the clipped region into the neighbour's view; (re)enqueue if it grew. + // Union the clipped region into the neighbour's accumulated view. 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); + + // Insert the neighbour into the distance-priority list — but ONLY on first discovery + // (retail enqueues via InsCellTodoList solely in the ecx_5==0 branch; growth into an + // already-seen cell is handled in place, never by re-enqueue). `seen` is the + // enqueue-once / `cell_view_done` gate: a neighbour already discovered is never + // re-enqueued, which is what bounds cyclic & hub graphs. Distance = camera→nearest + // portal-opening vertex in world space (retail InitCell min-vertex distance, + // 432988-433004); derived from the portal geometry, so it works even when the cell's + // WorldPosition was never populated. + if (seen.Add(neighbourId)) + { + float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); + todo.Insert(neighbour, dist); + } } } @@ -219,4 +244,55 @@ public static class PortalVisibilityBuilder if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; } return v; } + + // Camera→nearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal + // min-distance loop retail runs in PView::InitCell (decomp:432988-433004) to key the todo list: + // it walks the portal's vertices, transforms each to world space, and keeps the smallest + // straight-line distance to the camera viewpoint. Keying on the portal opening (not the cell + // origin) is both retail-faithful and robust to cells whose WorldPosition was never populated. + private static float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos) + { + float best = float.MaxValue; + for (int i = 0; i < localPoly.Length; i++) + { + var world = Vector3.Transform(localPoly[i], worldTransform); + float d2 = Vector3.DistanceSquared(world, cameraPos); + if (d2 < best) best = d2; + } + return best == float.MaxValue ? 0f : MathF.Sqrt(best); + } + + /// + /// Distance-sorted work list for the portal BFS, ported from retail PView::cell_todo_list + + /// InsCellTodoList (decomp:433183). Insertion keeps the list ordered so the NEAREST cell sits at + /// the tail; removes the tail — giving closest-first traversal exactly + /// as ConstructView's pop-from-(cell_todo_num-1) does (433767-433769). The insertion only shifts + /// entries strictly farther than the newcomer (retail's flag test breaks on the first + /// not-greater entry), so ties preserve insertion order (stable, FIFO among equal distances). + /// + private sealed class CellTodoList + { + private readonly List<(LoadedCell Cell, float Distance)> _items = new(); + + public int Count => _items.Count; + + public void Insert(LoadedCell cell, float distance) + { + // Find the slot: scan from the tail (nearest) toward the head while existing entries are + // strictly nearer than `distance`, so the newcomer lands just ABOVE every entry that is + // farther-or-equal — i.e. nearest-at-tail order, FIFO on ties. + int idx = _items.Count; + while (idx > 0 && _items[idx - 1].Distance < distance) + idx--; + _items.Insert(idx, (cell, distance)); + } + + public LoadedCell PopNearest() + { + int last = _items.Count - 1; + var cell = _items[last].Cell; + _items.RemoveAt(last); + return cell; + } + } } diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index 5994b80..af275ec 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Numerics; using AcDream.App.Rendering; using Xunit; @@ -121,6 +122,71 @@ public class PortalVisibilityBuilderTests Assert.True(frame.OutsideView.Polygons.Count < 256, $"OutsideView poly count {frame.OutsideView.Polygons.Count} — termination/dedup regression guard"); } + + // ----------------------------------------------------------------------- + // Phase U.2a: ordered visible-cell list (closest-first) + grow-watermark + // fixpoint termination (replaces MaxReprocessPerCell hard cap). + // ----------------------------------------------------------------------- + + // Straight chain A -> B -> C, camera in A looking down -Z. Each onward portal + // is progressively farther in -Z so the camera-to-portal distance is monotonic, + // forcing the priority queue to dequeue A, then B, then C in that order. + private static (LoadedCell[] cells, Dictionary lookup) SyntheticChain() + { + const uint A = 0x0001, B = 0x0002, C = 0x0003; + var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0)); + a.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f)); // portal A->B at z=-2 (nearer) + var b = Cell(B, new CellPortalInfo((ushort)C, 0, 0)); + b.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -5f)); // portal B->C at z=-5 (farther) + var c = Cell(C, new CellPortalInfo(0xFFFF, 0, 0)); + c.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -8f)); // exit window + var all = new Dictionary { [A] = a, [B] = b, [C] = c }; + return (new[] { a, b, c }, all); + } + + [Fact] // closest-first ordering + public void Build_OrdersVisibleCells_ClosestFirst() + { + var (cells, lookup) = SyntheticChain(); + var f = PortalVisibilityBuilder.Build( + cells[0], Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj()); + Assert.Equal(new uint[] { 0x0001, 0x0002, 0x0003 }, f.OrderedVisibleCells.ToArray()); + } + + // Hub cell with 4 rooms, each room portal-linked BACK to the hub (a cycle on + // every spoke). A naive FIFO with no real fixpoint re-enqueues the hub once per + // returning spoke and the rooms once per hub re-process — the watermark must + // converge instead, bounding the visible set to {hub + 4 rooms} with no dupes. + private static (LoadedCell hub, Dictionary lookup) SyntheticCyclicHub() + { + const uint HUB = 0x0010; + uint[] rooms = { 0x0011, 0x0012, 0x0013, 0x0014 }; + // Hub has one portal to each room; rooms sit at distinct depths so ordering is deterministic. + var hub = Cell(HUB, + new CellPortalInfo((ushort)rooms[0], 0, 0), new CellPortalInfo((ushort)rooms[1], 1, 0), + new CellPortalInfo((ushort)rooms[2], 2, 0), new CellPortalInfo((ushort)rooms[3], 3, 0)); + for (int i = 0; i < 4; i++) + hub.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f - i)); // -2,-3,-4,-5 + var all = new Dictionary { [HUB] = hub }; + for (int i = 0; i < 4; i++) + { + var room = Cell(rooms[i], new CellPortalInfo((ushort)HUB, 0, 0)); // links back to hub → cycle + room.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f - i)); + all[rooms[i]] = room; + } + return (hub, all); + } + + [Fact] // cyclic graph terminates and bounds the visible set + public void Build_CyclicHub_TerminatesAndBounds() + { + var (hub, lookup) = SyntheticCyclicHub(); + var f = PortalVisibilityBuilder.Build( + hub, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj()); + Assert.True(f.OrderedVisibleCells.Count <= 5, + $"hub + 4 rooms expected, got {f.OrderedVisibleCells.Count} — fixpoint failed to converge"); + Assert.Equal(f.OrderedVisibleCells.Count, f.OrderedVisibleCells.Distinct().Count()); // no dup cells + } } internal static class PortalFrameTestHelper