fix(physics): traverse prepared indoor portal topology

This commit is contained in:
Erik 2026-07-27 00:02:44 +02:00
parent b12d94047c
commit 4d095be286
9 changed files with 400 additions and 5 deletions

View file

@ -290,4 +290,88 @@ public class CameraCornerSealReplayTests
"Viewer sweep through a verified-open doorway path was obstructed or cut short " +
"(camera would wrongly pull in at openings) for: " + string.Join(", ", failures));
}
[Fact]
public void HoltburgCottageExit_ViewerSweep_AdvancesFromAlcoveToOutdoor()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
var graphCache = new PhysicsDataCache();
var preparedCache = PhysicsDataCache.CreateProduction();
for (uint low = 0x013Fu; low <= 0x0150u; low++)
{
uint cellId = ConformanceDats.HoltburgLandblock | low;
ConformanceDats.LoadEnvCell(
dats,
graphCache,
cellId);
CellPhysics graphCell = Assert.IsType<CellPhysics>(
graphCache.GetCellStruct(cellId));
FlatCellCollisionAsset prepared =
FlatCollisionAssetBuilder.FlattenCell(graphCell);
var datCell = Assert.IsType<DatReaderWriter.DBObjs.EnvCell>(
dats.Get<DatReaderWriter.DBObjs.EnvCell>(cellId));
preparedCache.CacheCellStruct(
cellId,
datCell,
graphCell.WorldTransform,
prepared.Structure,
prepared.Topology);
}
var graphEngine = new PhysicsEngine { DataCache = graphCache };
var preparedEngine = new PhysicsEngine { DataCache = preparedCache };
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < heightTable.Length; i++)
heightTable[i] = -1000f;
foreach (PhysicsEngine engine in new[] { graphEngine, preparedEngine })
{
engine.AddLandblock(
ConformanceDats.HoltburgLandblock,
new TerrainSurface(heights, heightTable),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f);
}
// Connected capture indoor-visibility-20260726-220007. The player is
// just inside the narrow 0x0150 vestibule, facing into the cottage.
// Its chase camera is behind it, outside the open doorway. Retail's
// viewer CTransition crosses 0x0150's 0xFFFF portal and carries the
// outdoor landcell as viewer_cell.
var player = new Vector3(132.70f, 16.80f, 94.00f);
var pivot = player + new Vector3(0f, 0f, PivotHeight);
var eye = new Vector3(131.30f, 19.97f, 96.25f);
var graphPivot = graphEngine.AdjustPosition(0xA9B40150u, pivot);
var preparedPivot = preparedEngine.AdjustPosition(0xA9B40150u, pivot);
Assert.True(graphPivot.found);
Assert.True(preparedPivot.found);
Assert.Equal(0xA9B40150u, graphPivot.cellId);
Assert.Equal(graphPivot.cellId, preparedPivot.cellId);
ResolveResult graphResult = SweepViewer(
graphEngine,
pivot,
eye,
0xA9B40150u);
ResolveResult preparedResult = SweepViewer(
preparedEngine,
pivot,
eye,
0xA9B40150u);
_out.WriteLine(FormattableString.Invariant(
$"graph=0x{graphResult.CellId:X8} prepared=0x{preparedResult.CellId:X8} graphPos=({graphResult.Position.X:F3},{graphResult.Position.Y:F3},{graphResult.Position.Z:F3}) preparedPos=({preparedResult.Position.X:F3},{preparedResult.Position.Y:F3},{preparedResult.Position.Z:F3})"));
Assert.Equal(0xA9B40029u, graphResult.CellId);
Assert.Equal(graphResult.CellId, preparedResult.CellId);
Assert.Equal(graphResult.Position, preparedResult.Position);
}
}

View file

@ -16,6 +16,7 @@ public class CellTransitFindTransitCellsSphereTests
Root = new CellBSPNode
{
// Local x >= 0 is inside this synthetic cell.
Type = DatReaderWriter.Enums.BSPNodeType.BPIn,
SplittingPlane = new Plane(new Vector3(1f, 0f, 0f), 0f),
PosNode = leaf,
}
@ -28,6 +29,7 @@ public class CellTransitFindTransitCellsSphereTests
// Portal poly at local x=2.5 (right wall), normal +X.
var portalPolyA = new ResolvedPolygon
{
Id = 10,
Vertices = new[]
{
new Vector3(2.5f, -2.5f, 0f),
@ -54,6 +56,24 @@ public class CellTransitFindTransitCellsSphereTests
};
}
private static CellPhysics PrepareCell(CellPhysics graphCell, uint sourceId)
{
FlatCellCollisionAsset prepared =
FlatCollisionAssetBuilder.FlattenCell(graphCell);
return new CellPhysics
{
SourceId = sourceId,
WorldTransform = graphCell.WorldTransform,
InverseWorldTransform = graphCell.InverseWorldTransform,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
FlatPhysicsBsp = prepared.Structure.PhysicsBsp,
FlatContainmentBsp = prepared.Structure.ContainmentBsp,
FlatPortalPolygons = prepared.Structure.PortalPolygons,
FlatTopology = prepared.Topology,
Portals = graphCell.Portals,
};
}
[Fact]
public void SphereInsideCellA_NearPortal_AddsCellB()
{
@ -216,4 +236,73 @@ public class CellTransitFindTransitCellsSphereTests
Assert.True(exitOutside);
}
[Fact]
public void PreparedExitPortal_UsesIndexedPlaneWithoutGraphDictionary()
{
const uint cellId = 0xA9B40100u;
CellPhysics graphCell = MakeCellWithPortalAtRightWall(
Matrix4x4.Identity,
otherCellId: 0xFFFF,
flags: 0);
CellPhysics preparedCell = PrepareCell(graphCell, cellId);
var cache = PhysicsDataCache.CreateProduction();
cache.RegisterCellStructForTest(cellId, preparedCell);
var candidates = new HashSet<uint>();
CellTransit.FindTransitCellsSphere(
cache,
preparedCell,
cellId,
new Vector3(2.0f, 0f, 2.5f),
sphereRadius: 0.5f,
candidates,
out bool exitOutside);
Assert.Null(preparedCell.PortalPolygons);
Assert.True(exitOutside);
}
[Fact]
public void PreparedLoadedNeighbor_UsesFlatContainmentWithoutGraphObjects()
{
const uint cellAId = 0xA9B40100u;
const uint cellBId = 0xA9B40101u;
CellPhysics graphCellA = MakeCellWithPortalAtRightWall(
Matrix4x4.Identity,
otherCellId: 0x0101,
flags: 2);
var cellBTransform = Matrix4x4.CreateTranslation(
new Vector3(5f, 0f, 0f));
Matrix4x4.Invert(cellBTransform, out Matrix4x4 cellBInverse);
var graphCellB = new CellPhysics
{
WorldTransform = cellBTransform,
InverseWorldTransform = cellBInverse,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = SinglePlaneCellBsp(),
};
CellPhysics preparedCellA = PrepareCell(graphCellA, cellAId);
CellPhysics preparedCellB = PrepareCell(graphCellB, cellBId);
var cache = PhysicsDataCache.CreateProduction();
cache.RegisterCellStructForTest(cellAId, preparedCellA);
cache.RegisterCellStructForTest(cellBId, preparedCellB);
var candidates = new HashSet<uint>();
CellTransit.FindTransitCellsSphere(
cache,
preparedCellA,
cellAId,
new Vector3(4.75f, 0f, 2.5f),
sphereRadius: 0.5f,
candidates,
out bool exitOutside);
Assert.Null(preparedCellA.PortalPolygons);
Assert.Null(preparedCellB.CellBSP);
Assert.Contains(cellBId, candidates);
Assert.False(exitOutside);
}
}

View file

@ -425,6 +425,13 @@ public sealed class FlatBspQueryDifferentialTests
0x8A02_016Eu,
0x8A02_017Au,
0xA9B4_013Fu,
0xA9B4_0150u,
0xA9B4_0159u,
0xA9B4_015Au,
0xA9B4_0161u,
0xA9B4_0162u,
0xA9B4_0164u,
0xA9B4_0166u,
})
{
var cache = new PhysicsDataCache();

View file

@ -32,6 +32,13 @@ public sealed class FlatCollisionInstalledDatTests
0x8A02_016Eu, // Facility Hub corridor
0x8A02_017Au, // asymmetric adjoining corridor
0xA9B4_013Fu, // Holtburg cottage
0xA9B4_0150u, // Holtburg cottage exterior vestibule
0xA9B4_0159u, // Holtburg inn south room
0xA9B4_015Au, // Holtburg inn ground-floor connector
0xA9B4_0161u, // Holtburg inn upper-floor connector
0xA9B4_0162u, // Holtburg inn main room / stairwell
0xA9B4_0164u, // Holtburg inn exterior vestibule
0xA9B4_0166u, // Holtburg inn upper floor
})
{
var cache = new PhysicsDataCache();
@ -118,6 +125,7 @@ public sealed class FlatCollisionInstalledDatTests
foreach (uint gfxObjId in new[]
{
0x0100_0A2Bu, // Holtburg cottage shell
0x0100_0C17u, // Holtburg inn shell
0x0100_0AC5u, // outdoor stair/ramp fixture
0x0100_44B5u, // multipart door shell
})