feat(render): Phase A8.F — PortalVisibilityBuilder recursive portal-clip BFS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c665f3eef3
commit
0ed462cb62
2 changed files with 275 additions and 0 deletions
158
src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
Normal file
158
src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>Per-frame output of the portal-frame BFS.</summary>
|
||||||
|
public sealed class PortalVisibilityFrame
|
||||||
|
{
|
||||||
|
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
|
||||||
|
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
|
||||||
|
public CellView OutsideView { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Per-cell accumulated clip region, keyed by full cell id (wire-in #2).</summary>
|
||||||
|
public Dictionary<uint, CellView> CellViews { 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
|
||||||
|
/// <param name="buildingMembership">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.</param>
|
||||||
|
public static PortalVisibilityFrame Build(
|
||||||
|
LoadedCell cameraCell,
|
||||||
|
Vector3 cameraPos,
|
||||||
|
Func<uint, LoadedCell?> lookup,
|
||||||
|
Matrix4x4 viewProj,
|
||||||
|
Func<uint, bool>? 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<uint, int>();
|
||||||
|
var queue = new Queue<LoadedCell>();
|
||||||
|
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<ViewPolygon>();
|
||||||
|
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<uint, CellView> map, uint key)
|
||||||
|
{
|
||||||
|
if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; }
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue