feat(physics): Phase 2 — wire CellBSP + Portals into CellPhysics

Adds PortalInfo struct and extends CellPhysics with CellBSP (third BSP
for point-in-cell tests, typed CellBSPTree from DatReaderWriter),
Portals (from envCell.CellPortals), PortalPolygons (resolved
cellStruct.Polygons — portals reference visible polys, not
PhysicsPolygons), and VisibleCellIds (populated for future use;
envCell.VisibleCells is List<UInt16>, not Dictionary).

Deletes CellPhysics.LocalAabbMin/Max and PhysicsDataCache.TryFindContainingCell
— Phase D's AABB shortcut is gone. CacheCellStruct's AABB compute
removed; the [cell-cache] diagnostic updated with portal/visible counts
instead.

CacheCellStruct signature gains an EnvCell parameter (one call site in
GameWindow.cs:5384 updated). ResolveOutdoorCellId drops the
TryFindContainingCell call; portal-graph CellTransit replaces it next.

ResolveOutdoorCellIdTests object initializers had the deleted AABB
properties stripped temporarily so the build stays green; the file gets
replaced wholesale in the next commit (CellTransit integration). Those
2 AABB-containment tests continue to fail (they were pre-broken on this
branch); no new failures introduced.

Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-19 16:52:20 +02:00
parent b282c69f28
commit 1969c55823
7 changed files with 215 additions and 113 deletions

View file

@ -128,8 +128,8 @@ public sealed class PhysicsDataCache
/// (indoor room geometry). No-ops if the id is already cached or the
/// CellStruct has no physics BSP.
/// </summary>
public void CacheCellStruct(uint envCellId, CellStruct cellStruct,
Matrix4x4 worldTransform)
public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell,
CellStruct cellStruct, Matrix4x4 worldTransform)
{
if (_cellStruct.ContainsKey(envCellId)) return;
if (cellStruct.PhysicsBSP?.Root is null) return;
@ -138,23 +138,27 @@ public sealed class PhysicsDataCache
var resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray);
// Indoor walking Phase D (2026-05-19): compute a tight local AABB from
// the resolved polygon vertices. Computed once at cache time so the
// per-frame TryFindContainingCell check only does AABB point tests.
var aabbMin = new Vector3(float.MaxValue);
var aabbMax = new Vector3(float.MinValue);
foreach (var (_, poly) in resolved)
// Visible polygons — portals reference these (NOT PhysicsPolygons).
var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray);
// Portal list from envCell.CellPortals.
var portals = new System.Collections.Generic.List<PortalInfo>(envCell.CellPortals.Count);
foreach (var p in envCell.CellPortals)
{
if (poly.Vertices is null) continue;
foreach (var v in poly.Vertices)
{
if (v.X < aabbMin.X) aabbMin.X = v.X;
if (v.Y < aabbMin.Y) aabbMin.Y = v.Y;
if (v.Z < aabbMin.Z) aabbMin.Z = v.Z;
if (v.X > aabbMax.X) aabbMax.X = v.X;
if (v.Y > aabbMax.Y) aabbMax.Y = v.Y;
if (v.Z > aabbMax.Z) aabbMax.Z = v.Z;
}
portals.Add(new PortalInfo(
otherCellId: p.OtherCellId,
polygonId: p.PolygonId,
flags: (ushort)p.Flags));
}
// VisibleCells set — populated for future use; not consulted this phase.
// envCell.VisibleCells is List<UInt16> per the DatReaderWriter shape — iterate directly, no .Keys.
var visibleCellIds = new System.Collections.Generic.HashSet<uint>();
if (envCell.VisibleCells is not null)
{
uint lbPrefix = envCellId & 0xFFFF0000u;
foreach (var lowId in envCell.VisibleCells)
visibleCellIds.Add(lbPrefix | lowId);
}
_cellStruct[envCellId] = new CellPhysics
@ -165,8 +169,11 @@ public sealed class PhysicsDataCache
WorldTransform = worldTransform,
InverseWorldTransform = inverseTransform,
Resolved = resolved,
LocalAabbMin = aabbMin,
LocalAabbMax = aabbMax,
// ── Phase 2 portal fields ──
CellBSP = cellStruct.CellBSP,
Portals = portals,
PortalPolygons = portalPolygons,
VisibleCellIds = visibleCellIds,
};
if (PhysicsDiagnostics.ProbeCellCacheEnabled)
@ -175,11 +182,6 @@ public sealed class PhysicsDataCache
int bspRootPolyCount = root?.Polygons?.Count ?? 0;
bool bspRootHasChildren = root?.PosNode is not null || root?.NegNode is not null;
// Recursive walk: count total leaf poly references + how many of
// those poly IDs are absent from the resolved dict. If
// bspTotalLeafPolys == 0 the BSP has no collidable polys at all.
// If bspUnmatchedIds > 0 the BSP references IDs we didn't resolve
// (data-deserialization quirk hypothesis).
int bspTotalLeafPolys = 0;
int bspUnmatchedIds = 0;
if (root is not null)
@ -208,14 +210,10 @@ public sealed class PhysicsDataCache
: System.FormattableString.Invariant(
$"bsphere=({bs.Origin.X:F2},{bs.Origin.Y:F2},{bs.Origin.Z:F2}) r={bs.Radius:F2}");
// World origin = cellTransform * (0,0,0,1). Tells us where this cell
// sits in world coordinates, so we can cross-check whether the
// player's worldPos actually lies inside the AABB when transformed
// back to local.
var worldOrigin = Vector3.Transform(Vector3.Zero, worldTransform);
Console.WriteLine(System.FormattableString.Invariant(
$"[cell-cache] envCellId=0x{envCellId:X8} physicsPolyCount={cellStruct.PhysicsPolygons?.Count ?? 0} resolvedCount={resolved.Count} bspTotalLeafPolys={bspTotalLeafPolys} bspUnmatchedIds={bspUnmatchedIds} {bsStr} aabbMin=({aabbMin.X:F2},{aabbMin.Y:F2},{aabbMin.Z:F2}) aabbMax=({aabbMax.X:F2},{aabbMax.Y:F2},{aabbMax.Z:F2}) worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2})"));
$"[cell-cache] envCellId=0x{envCellId:X8} physicsPolyCount={cellStruct.PhysicsPolygons?.Count ?? 0} resolvedCount={resolved.Count} bspTotalLeafPolys={bspTotalLeafPolys} bspUnmatchedIds={bspUnmatchedIds} {bsStr} portalCount={portals.Count} visibleCells={visibleCellIds.Count} cellBspRoot={(cellStruct.CellBSP?.Root is null ? "null" : "ok")} worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2})"));
}
}
@ -224,7 +222,7 @@ public sealed class PhysicsDataCache
/// and compute the face plane. Matches ACE's Polygon constructor which calls
/// make_plane() and resolves Vertices from VertexIDs at load time.
/// </summary>
private static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
internal static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
Dictionary<ushort, DatReaderWriter.Types.Polygon> polys,
VertexArray vertexArray)
{
@ -291,53 +289,6 @@ public sealed class PhysicsDataCache
/// </summary>
public IReadOnlyCollection<uint> CellStructIds => (IReadOnlyCollection<uint>)_cellStruct.Keys;
/// <summary>
/// Indoor walking Phase D (2026-05-19). Returns the full id of the first
/// cached EnvCell whose local AABB contains <paramref name="worldPos"/>,
/// or false if no cached EnvCell contains it. Used by
/// <see cref="PhysicsEngine.ResolveOutdoorCellId"/> to promote the player's
/// CellId to an indoor EnvCell when the player is geometrically inside one.
///
/// <para>
/// AABBs are pre-computed in <see cref="CacheCellStruct"/> from each
/// cell's resolved polygon vertices, transformed into local space via
/// <see cref="CellPhysics.InverseWorldTransform"/>. Iteration is O(N) over
/// cached cells; N is bounded by the streaming radius (~80 cells at
/// radius 4).
/// </para>
///
/// <para>
/// Local AABB is a tight bound around the cell's geometry. EnvCells in
/// Holtburg are roughly room-sized cuboids; the local AABB is therefore
/// a reasonable proxy for "is the player in this cell." For cells with
/// concave shapes or non-room geometry, the AABB will over-approximate;
/// this only matters if two cells' AABBs overlap and the player is in
/// the overlap region (rare in practice; if it becomes an issue, switch
/// to a BSP point-in-cell test).
/// </para>
/// </summary>
public bool TryFindContainingCell(Vector3 worldPos, out uint envCellId)
{
foreach (var (id, cp) in _cellStruct)
{
// Guard: if the AABB was never populated (no vertices in the cell),
// LocalAabbMin stays at float.MaxValue — the containment test will
// always fail, so we skip the cell silently.
if (cp.LocalAabbMin.X == float.MaxValue) continue;
var local = Vector3.Transform(worldPos, cp.InverseWorldTransform);
if (local.X >= cp.LocalAabbMin.X && local.X <= cp.LocalAabbMax.X &&
local.Y >= cp.LocalAabbMin.Y && local.Y <= cp.LocalAabbMax.Y &&
local.Z >= cp.LocalAabbMin.Z && local.Z <= cp.LocalAabbMax.Z)
{
envCellId = id;
return true;
}
}
envCellId = 0;
return false;
}
/// <summary>
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
/// Intended for unit-test fixtures that construct synthetic BSP trees
@ -438,21 +389,38 @@ public sealed class CellPhysics
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
/// <summary>
/// Indoor walking Phase D (2026-05-19). Local-space AABB minimum corner,
/// computed from the resolved polygon vertices at <see cref="PhysicsDataCache.CacheCellStruct"/>
/// time. Initialized to <c>float.MaxValue</c> so that
/// <see cref="PhysicsDataCache.TryFindContainingCell"/> silently skips
/// cells with no vertex data.
/// </summary>
public Vector3 LocalAabbMin { get; init; } = new Vector3(float.MaxValue);
// ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ───────
/// <summary>
/// Indoor walking Phase D (2026-05-19). Local-space AABB maximum corner,
/// computed from the resolved polygon vertices at <see cref="PhysicsDataCache.CacheCellStruct"/>
/// time. Initialized to <c>float.MinValue</c> so that
/// <see cref="PhysicsDataCache.TryFindContainingCell"/> silently skips
/// cells with no vertex data.
/// The cell BSP used for <see cref="BSPQuery.PointInsideCellBsp"/>
/// (point-in-cell tests). Separate tree from <see cref="BSP"/>
/// (collision) and from the renderer's drawing-BSP.
/// Source: <c>cellStruct.CellBSP</c> at cache time.
/// Nullable: cells without a CellBSP cannot participate in portal
/// containment and are skipped by <see cref="CellTransit"/>.
/// </summary>
public Vector3 LocalAabbMax { get; init; } = new Vector3(float.MinValue);
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }
/// <summary>
/// Portal connections to neighbouring cells, in cell-local space.
/// Default: empty list. Source: <c>envCell.CellPortals</c>.
/// </summary>
public IReadOnlyList<PortalInfo> Portals { get; init; } = System.Array.Empty<PortalInfo>();
/// <summary>
/// Resolved VISIBLE polygons (from <c>cellStruct.Polygons</c>),
/// keyed by polygon id. Distinct from <see cref="Resolved"/> which
/// holds <c>PhysicsPolygons</c>. Portal lookup via
/// <see cref="PortalInfo.PolygonId"/> resolves through this dict.
/// Nullable when the cell has no visible polys (rare).
/// </summary>
public Dictionary<ushort, ResolvedPolygon>? PortalPolygons { get; init; }
/// <summary>
/// The full cell ids visible from this cell (with landblock prefix).
/// Populated from <c>envCell.VisibleCells</c> at cache time. Unused
/// this phase; reserved for the optional <c>find_cell_list</c>
/// visibility filter.
/// </summary>
public IReadOnlySet<uint> VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet<uint>();
}