feat(render): Phase U.2a — portal BFS ordering + fixpoint termination

PortalVisibilityFrame gains OrderedVisibleCells (closest-first). Replace the FIFO +
MaxReprocessPerCell cap with a distance-priority queue and a grow-watermark fixpoint
(retail InsCellTodoList 433183 / AddViewToPortals 433446) so cyclic dungeon graphs
converge without duplicate-cell blow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-30 16:22:06 +02:00
parent 3fc77be5de
commit d8807755ce
2 changed files with 165 additions and 23 deletions

View file

@ -21,6 +21,13 @@ public sealed class PortalVisibilityFrame
/// <summary>Per-cell accumulated clip region, keyed by full cell id (wire-in #2).</summary>
public Dictionary<uint, CellView> CellViews { get; } = new();
/// <summary>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.</summary>
public List<uint> OrderedVisibleCells { get; } = new();
/// <summary>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).</summary>
public Dictionary<uint, CellView> 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<uint, int>();
var queue = new Queue<LoadedCell>();
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<uint> { 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);
}
/// <summary>
/// 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; <see cref="PopNearest"/> 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).
/// </summary>
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;
}
}
}

View file

@ -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<uint, LoadedCell> 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<uint, LoadedCell> { [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<uint, LoadedCell> 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<uint, LoadedCell> { [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