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>
200 lines
9.6 KiB
C#
200 lines
9.6 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
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<CellPortalInfo>(portals),
|
|
};
|
|
|
|
private static PortalVisibilityFrame Build(LoadedCell cam, Dictionary<uint, LoadedCell> 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<uint, LoadedCell> { [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<uint, LoadedCell> { [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<uint, LoadedCell> { [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<uint, LoadedCell> { [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<uint, LoadedCell> { [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");
|
|
}
|
|
|
|
[Fact]
|
|
public void Builder_CyclicGraph_TerminatesWithBoundedPolys()
|
|
{
|
|
// A <-> B cycle; B also has an exit window. Must terminate and not blow up.
|
|
var a = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0));
|
|
a.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -3f));
|
|
var b = Cell(0x0002, new CellPortalInfo(0x0001, 0, 0), new CellPortalInfo(0xFFFF, 1, 0));
|
|
b.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -2f)); // back to A
|
|
b.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); // exit window
|
|
var all = new Dictionary<uint, LoadedCell> { [0x0001] = a, [0x0002] = b };
|
|
|
|
var frame = Build(a, all); // must return (no infinite loop)
|
|
Assert.False(frame.OutsideView.IsEmpty);
|
|
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
|
|
{
|
|
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);
|
|
}
|
|
}
|