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:
parent
b282c69f28
commit
1969c55823
7 changed files with 215 additions and 113 deletions
|
|
@ -5381,7 +5381,7 @@ public sealed class GameWindow : IDisposable
|
|||
BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform);
|
||||
|
||||
// Cache CellStruct physics BSP for indoor collision.
|
||||
_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform);
|
||||
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,18 +230,15 @@ public sealed class PhysicsEngine
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a position's CellId. Tries indoor EnvCell containment first
|
||||
/// (via <see cref="PhysicsDataCache.TryFindContainingCell"/>); falls back
|
||||
/// to outdoor terrain landcell resolution.
|
||||
/// Resolve a position's CellId. Falls back to outdoor terrain landcell
|
||||
/// resolution or trusts an already-indoor fallbackCellId.
|
||||
///
|
||||
/// <para>
|
||||
/// Indoor walking Phase D (2026-05-19) extended this to fix #84 + #85:
|
||||
/// previously the function only resolved outdoor cells, so a player
|
||||
/// geometrically inside an EnvCell stayed in outdoor-landcell range and
|
||||
/// the indoor cell-BSP collision branch never fired. The indoor
|
||||
/// containment check promotes the player's CellId to the matched
|
||||
/// EnvCell, which lets <see cref="Transition.FindEnvCollisions"/>'s
|
||||
/// indoor branch (gated on cellLow >= 0x0100) take effect.
|
||||
/// Phase D (2026-05-19) previously used an AABB containment check
|
||||
/// (<c>TryFindContainingCell</c>) to promote the player into an indoor
|
||||
/// EnvCell. Phase 2 (2026-05-19) removes that AABB shortcut; the
|
||||
/// portal-graph <c>CellTransit</c> traversal (next subagent) replaces it
|
||||
/// with retail-faithful BSP point-in-cell tests.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -256,12 +253,6 @@ public sealed class PhysicsEngine
|
|||
if (fallbackCellId == 0)
|
||||
return 0;
|
||||
|
||||
// Phase D: indoor-cell-containment check. If the player's worldPos
|
||||
// is geometrically inside a cached EnvCell, return that cell's full
|
||||
// id — overrides any prior outdoor CellId the caller passed in.
|
||||
if (DataCache is not null && DataCache.TryFindContainingCell(worldPos, out var indoorId))
|
||||
return indoorId;
|
||||
|
||||
// Pre-existing: if the caller already passes an indoor CellId AND
|
||||
// the player isn't in any cached EnvCell, trust the caller. This
|
||||
// preserves behaviour for indoor cells whose physics hasn't been
|
||||
|
|
|
|||
45
src/AcDream.Core/Physics/PortalInfo.cs
Normal file
45
src/AcDream.Core/Physics/PortalInfo.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 2 (2026-05-19). Portal connection between two
|
||||
/// EnvCells. Each <see cref="CellPhysics"/> carries a list of these,
|
||||
/// mirroring retail's <c>CCellStruct.portals</c> array.
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="OtherCellId"/> is a low-16 cell index (combined with the
|
||||
/// owning landblock prefix at lookup time) or <c>0xFFFF</c> to mean
|
||||
/// "exit to outdoor world" (the player crosses this portal to leave
|
||||
/// the building).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="PolygonId"/> indexes the OWNING cell's
|
||||
/// <see cref="CellPhysics.PortalPolygons"/> dict (the visible-polygon
|
||||
/// table, NOT <see cref="CellPhysics.Resolved"/> which holds physics
|
||||
/// polys).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="PortalSide"/> decodes bit 2 of <see cref="Flags"/>:
|
||||
/// <c>(Flags & 2) == 0</c> → portal's polygon normal points INTO
|
||||
/// the owning cell (so dist > 0 in cell-local space means "outside
|
||||
/// the cell, beyond the portal"). Used in <c>find_transit_cells</c>'s
|
||||
/// load-hint path for unloaded neighbours.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly struct PortalInfo
|
||||
{
|
||||
public PortalInfo(ushort otherCellId, ushort polygonId, ushort flags)
|
||||
{
|
||||
OtherCellId = otherCellId;
|
||||
PolygonId = polygonId;
|
||||
Flags = flags;
|
||||
}
|
||||
|
||||
public ushort OtherCellId { get; }
|
||||
public ushort PolygonId { get; }
|
||||
public ushort Flags { get; }
|
||||
|
||||
/// <summary>Bit 2 of <see cref="Flags"/>. See struct docstring.</summary>
|
||||
public bool PortalSide => (Flags & 2) == 0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue