fix(render): Phase U.2b — resolve reciprocal portal by other_portal_id (retail 433557)
Code review caught a CRITICAL under-inclusion: ApplyReciprocalClip scanned for the first OtherCellId match, so a cell with two portals to the same neighbour clipped both near-side openings against the FIRST reciprocal polygon — hiding geometry through the second opening (real on Holtburg cellar cells 0x148<->0x149). Plumb the dat's OtherPortalId back-link through CellPortalInfo + BuildLoadedCell and index the reciprocal directly (retail arg2->other_portal_id, 433557). Skip (degrade to over-include) when the index is unresolvable — never clip against a guessed polygon. Adds a disjoint two-back- portal regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3916b2b23e
commit
65781f5768
7 changed files with 184 additions and 66 deletions
|
|
@ -30,8 +30,8 @@ public class CellVisibilityPortalPolygonsTests
|
|||
{
|
||||
Portals = new()
|
||||
{
|
||||
new CellPortalInfo(0xFFFF, 100, 0), // exit portal, has geometry
|
||||
new CellPortalInfo(0x0102, 101, 0), // inner portal, no geometry resolved
|
||||
new CellPortalInfo(0xFFFF, 100, 0, 0), // exit portal, has geometry
|
||||
new CellPortalInfo(0x0102, 101, 0, 0), // inner portal, no geometry resolved
|
||||
},
|
||||
ClipPlanes = new() { default, default },
|
||||
PortalPolygons = new()
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ public class PortalVisibilityBuilderTests
|
|||
new Vector3(cx + halfW, cy + halfH, z), new Vector3(cx - halfW, cy + halfH, z),
|
||||
};
|
||||
|
||||
// Full-height (y in [-0.9, 0.9]) quad spanning [minX, maxX] in X at plane z.
|
||||
// Used by the multi-back-portal fixture where the X span is the only thing
|
||||
// distinguishing the LEFT and RIGHT apertures.
|
||||
private static Vector3[] QuadX(float minX, float maxX, float z) => new[]
|
||||
{
|
||||
new Vector3(minX, -0.9f, z), new Vector3(maxX, -0.9f, z),
|
||||
new Vector3(maxX, 0.9f, z), new Vector3(minX, 0.9f, z),
|
||||
};
|
||||
|
||||
private static LoadedCell Cell(uint id, params CellPortalInfo[] portals) => new LoadedCell
|
||||
{
|
||||
CellId = id, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
|
||||
|
|
@ -33,9 +42,9 @@ public class PortalVisibilityBuilderTests
|
|||
[Fact]
|
||||
public void Builder_Cellar_WindowClippedToStairwell_NotFullWindow()
|
||||
{
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0));
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.1f, 1.0f, -3f)); // narrow stairwell
|
||||
var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0));
|
||||
var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 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 };
|
||||
|
||||
|
|
@ -52,7 +61,7 @@ public class PortalVisibilityBuilderTests
|
|||
[Fact]
|
||||
public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty()
|
||||
{
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0));
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 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 };
|
||||
|
|
@ -62,7 +71,7 @@ public class PortalVisibilityBuilderTests
|
|||
[Fact]
|
||||
public void Builder_CameraCellWithDirectExit_OutsideViewIsFullWindow()
|
||||
{
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0));
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0, 0, 1f, 1f, -6f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam };
|
||||
var frame = Build(cam, all);
|
||||
|
|
@ -74,11 +83,11 @@ public class PortalVisibilityBuilderTests
|
|||
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));
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 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));
|
||||
var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
ground.PortalPolygons.Add(Quad(0, 0, 1f, 1f, -6f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = ground };
|
||||
|
||||
|
|
@ -95,7 +104,7 @@ public class PortalVisibilityBuilderTests
|
|||
{
|
||||
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));
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(cwQuad);
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam };
|
||||
var frame = Build(cam, all);
|
||||
|
|
@ -110,9 +119,9 @@ public class PortalVisibilityBuilderTests
|
|||
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));
|
||||
var a = Cell(0x0001, new CellPortalInfo(0x0002, 0, 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));
|
||||
var b = Cell(0x0002, new CellPortalInfo(0x0001, 0, 0, 0), new CellPortalInfo(0xFFFF, 1, 0, 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 };
|
||||
|
|
@ -134,11 +143,11 @@ public class PortalVisibilityBuilderTests
|
|||
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));
|
||||
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 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));
|
||||
var b = Cell(B, new CellPortalInfo((ushort)C, 0, 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));
|
||||
var c = Cell(C, new CellPortalInfo(0xFFFF, 0, 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);
|
||||
|
|
@ -163,14 +172,14 @@ public class PortalVisibilityBuilderTests
|
|||
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));
|
||||
new CellPortalInfo((ushort)rooms[0], 0, 0, 0), new CellPortalInfo((ushort)rooms[1], 1, 0, 0),
|
||||
new CellPortalInfo((ushort)rooms[2], 2, 0, 0), new CellPortalInfo((ushort)rooms[3], 3, 0, 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
|
||||
var room = Cell(rooms[i], new CellPortalInfo((ushort)HUB, 0, 0, 0)); // links back to hub → cycle
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f - i));
|
||||
all[rooms[i]] = room;
|
||||
}
|
||||
|
|
@ -205,13 +214,14 @@ public class PortalVisibilityBuilderTests
|
|||
SyntheticReciprocalPair()
|
||||
{
|
||||
const uint A = 0x0001, B = 0x0002;
|
||||
// A's portal into B: wide opening (half-width 0.9) at z = -3.
|
||||
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0));
|
||||
// A's portal into B: wide opening (half-width 0.9) at z = -3. Its
|
||||
// reciprocal back-portal lives at index 0 in B (OtherPortalId = 0).
|
||||
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0));
|
||||
a.PortalPolygons.Add(Quad(0f, 0f, 0.9f, 0.9f, -3f));
|
||||
// B's reciprocal portal back to A: NARROW opening (half-width 0.3),
|
||||
// same height and plane, so it projects fully inside the near-side rect
|
||||
// but covers only ~1/3 of its width.
|
||||
var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0));
|
||||
var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0, 0));
|
||||
b.PortalPolygons.Add(Quad(0f, 0f, 0.3f, 0.9f, -3f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b };
|
||||
return (a, b, all);
|
||||
|
|
@ -253,7 +263,7 @@ public class PortalVisibilityBuilderTests
|
|||
// The reciprocal clip must no-op, so B's CellView equals the WIDE near-side
|
||||
// projection — proving degradation never under-includes (and never throws).
|
||||
const uint A = 0x0001, B = 0x0002;
|
||||
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0));
|
||||
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0));
|
||||
a.PortalPolygons.Add(Quad(0f, 0f, 0.9f, 0.9f, -3f));
|
||||
var b = Cell(B); // no portals at all → no back-portal to match
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b };
|
||||
|
|
@ -270,6 +280,90 @@ public class PortalVisibilityBuilderTests
|
|||
$"(got {area}, near-side {nearSideArea}) — degrade must not tighten or expand");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase U.2b CRITICAL — multiple portals to the SAME neighbour must each
|
||||
// resolve their OWN reciprocal back-portal (retail arg2->other_portal_id,
|
||||
// decomp:433557), NOT the first OtherCellId match. This is the real
|
||||
// Holtburg-cellar shape: cell 0x148 has two portals to 0x149 (poly 40, 41)
|
||||
// and 0x149 has two reciprocals back to 0x148. A scan-by-first-match clips
|
||||
// BOTH near-side openings against the FIRST reciprocal — if the apertures
|
||||
// are disjoint, the second opening's intersection underflows to empty and
|
||||
// its geometry is HIDDEN (under-inclusion). Direct-index resolution clips
|
||||
// each opening against the matching reciprocal, so both survive.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Camera in A looking down -Z. A has TWO portals to the SAME neighbour B,
|
||||
// with DISJOINT openings: a LEFT aperture (x in [-0.9,-0.3]) and a RIGHT
|
||||
// aperture (x in [0.3,0.9]). B has two reciprocals back to A, one matching
|
||||
// each side. A.Portals[0].OtherPortalId = 0 (B's LEFT reciprocal),
|
||||
// A.Portals[1].OtherPortalId = 1 (B's RIGHT reciprocal).
|
||||
//
|
||||
// Scan-by-first-match resolves BOTH A-portals to B.Portals[0] (LEFT),
|
||||
// so the RIGHT near-side region (x>0) intersects an x<0 reciprocal → empty
|
||||
// → B's CellView never reaches the RIGHT aperture. Direct-index keeps it.
|
||||
private static (LoadedCell camCell, LoadedCell neighbour, Dictionary<uint, LoadedCell> lookup)
|
||||
SyntheticMultiBackPortalPair()
|
||||
{
|
||||
const uint A = 0x0001, B = 0x0002;
|
||||
// A: portal[0] → B through the LEFT opening, portal[1] → B through the RIGHT opening.
|
||||
// The OtherPortalId back-links pick the matching reciprocal in B (0 = LEFT, 1 = RIGHT).
|
||||
var a = Cell(A,
|
||||
new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 0), // LEFT → B recip[0]
|
||||
new CellPortalInfo((ushort)B, PolygonId: 1, Flags: 0, OtherPortalId: 1)); // RIGHT → B recip[1]
|
||||
a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -3f)); // LEFT near-side aperture
|
||||
a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // RIGHT near-side aperture
|
||||
|
||||
// B: two reciprocals back to A. Index 0 covers the LEFT opening, index 1
|
||||
// the RIGHT opening — disjoint, same plane (z=-3) as the near side so each
|
||||
// reciprocal exactly overlaps its matching A aperture.
|
||||
var b = Cell(B,
|
||||
new CellPortalInfo((ushort)A, PolygonId: 0, Flags: 0, OtherPortalId: 0),
|
||||
new CellPortalInfo((ushort)A, PolygonId: 1, Flags: 0, OtherPortalId: 1));
|
||||
b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -3f)); // reciprocal[0] = LEFT
|
||||
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // reciprocal[1] = RIGHT
|
||||
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b };
|
||||
return (a, b, all);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_MultiplePortalsToSameNeighbour_EachResolvesOwnReciprocal()
|
||||
{
|
||||
var (camCell, neighbour, lookup) = SyntheticMultiBackPortalPair();
|
||||
var vp = ViewProj();
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
camCell, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, vp);
|
||||
|
||||
Assert.True(f.CellViews.ContainsKey(0x0002), "neighbour cell view should be populated");
|
||||
|
||||
// The RIGHT reciprocal opening (B.PortalPolygons[1]) projects to a region
|
||||
// with strictly positive NDC X. Geometry visible through the SECOND opening
|
||||
// survives ONLY if A.Portals[1] was clipped against B's RIGHT reciprocal
|
||||
// (index 1) rather than the LEFT one (index 0).
|
||||
var rightNdc = PortalProjection.ProjectToNdc(neighbour.PortalPolygons[1], Matrix4x4.Identity, vp);
|
||||
float rightMinX = float.MaxValue, rightMaxX = float.MinValue;
|
||||
foreach (var v in rightNdc) { if (v.X < rightMinX) rightMinX = v.X; if (v.X > rightMaxX) rightMaxX = v.X; }
|
||||
Assert.True(rightMinX > 0f, $"fixture sanity: RIGHT reciprocal must project to positive NDC X (got minX {rightMinX})");
|
||||
|
||||
// The neighbour CellView must extend into the RIGHT aperture. With the
|
||||
// scan-by-first-match bug both near-side openings clip against the LEFT
|
||||
// reciprocal (x<0), so the CellView's MaxX stays well left of rightMinX
|
||||
// and this assertion FAILS (the RIGHT opening's geometry is hidden).
|
||||
var bView = f.CellViews[0x0002];
|
||||
Assert.True(bView.MaxX >= rightMinX - 1e-4f,
|
||||
$"neighbour CellView MaxX {bView.MaxX} must reach the RIGHT reciprocal opening " +
|
||||
$"[{rightMinX}, {rightMaxX}] — under scan-by-first-match the second opening is clipped " +
|
||||
$"against the LEFT reciprocal and HIDDEN (under-inclusion bug #102 M-4).");
|
||||
|
||||
// And the LEFT aperture must still be present (the first opening was never
|
||||
// in question) — guards against a fix that accidentally drops the LEFT side.
|
||||
var leftNdc = PortalProjection.ProjectToNdc(neighbour.PortalPolygons[0], Matrix4x4.Identity, vp);
|
||||
float leftMinX = float.MaxValue;
|
||||
foreach (var v in leftNdc) if (v.X < leftMinX) leftMinX = v.X;
|
||||
Assert.True(bView.MinX <= leftMinX + 1e-4f,
|
||||
$"neighbour CellView MinX {bView.MinX} must still cover the LEFT reciprocal opening (minX {leftMinX})");
|
||||
}
|
||||
|
||||
// Signed-area-magnitude (shoelace) sum over a CellView's polygons in NDC.
|
||||
private static float CellViewArea(CellView view)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ public class BuildingLoaderTests
|
|||
CellId = 0xA9B40150u,
|
||||
Portals = new List<AcDream.App.Rendering.CellPortalInfo>
|
||||
{
|
||||
new(0xFFFF, 0, 0),
|
||||
new(0xFFFF, 0, 0, 0),
|
||||
},
|
||||
PortalPolygons = new List<Vector3[]>
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue