feat(physics): PhysicsDataCache + BSP sphere query

Load PhysicsBSP and PhysicsPolygons from GfxObj dats during streaming.
BSPQuery.SphereIntersectsPoly traverses the tree for collision detection.
Ported from decompiled FUN_00539270, cross-ref ACE BSPNode.sphere_intersects_poly.

- PhysicsDataCache: thread-safe ConcurrentDictionary-backed cache of GfxObjPhysics
  (BSP tree + polygon dict + vertex array) and SetupPhysics (capsule dimensions).
  CacheGfxObj/CacheSetup are idempotent — safe to call at every dat load site.
- BSPQuery.SphereIntersectsPoly: recursive BSP descent with bounding-sphere broad
  phase, leaf polygon test via existing CollisionPrimitives.SphereIntersectsPoly
  (FUN_00539500), and splitting-plane classification for internal nodes.
- GameWindow: _physicsDataCache populated at all GfxObj/Setup dat load sites
  (streaming worker path, live-spawn path, ApplyLoadedTerrain render-thread path).
- 6 new unit tests covering null node, bounding-sphere miss, leaf hit, no-contact,
  internal node recursion, and empty cache behaviour. All 447 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 23:28:39 +02:00
parent 0bec5d5296
commit 874d267117
4 changed files with 462 additions and 0 deletions

View file

@ -37,6 +37,11 @@ public sealed class GameWindow : IDisposable
// Phase B.3: physics engine — populated from the streaming pipeline.
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
// Task 4: physics data cache — BSP trees + collision shapes extracted from
// GfxObj/Setup dats during streaming. Populated on the worker thread;
// ConcurrentDictionary inside makes cross-thread access safe.
private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache = new();
// Step 4: portal-based interior cell visibility.
private readonly CellVisibility _cellVisibility = new();
@ -212,6 +217,8 @@ public sealed class GameWindow : IDisposable
if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
var playerSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(playerEntity.SourceGfxObjOrSetupId);
if (playerSetup is not null)
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup);
_playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f)
? playerSetup.StepUpHeight
: 2f; // default human step height
@ -581,6 +588,8 @@ public sealed class GameWindow : IDisposable
// Hydrate mesh refs from the Setup dat. This is the same code path
// used by the static scenery pipeline (see the Setup hydration above).
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.SetupTableId.Value);
if (setup is not null)
_physicsDataCache.CacheSetup(spawn.SetupTableId.Value, setup);
if (setup is null)
{
_liveDropReasonSetupDatMissing++;
@ -683,6 +692,7 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"live: [STATUE] resolve part={pi} GfxObj 0x{parts[pi].GfxObjId:X8} missing");
continue;
}
_physicsDataCache.CacheGfxObj(parts[pi].GfxObjId, partGfx);
if (isStatueDiag)
Console.WriteLine($"live: [STATUE] resolve part={pi} gfx=0x{parts[pi].GfxObjId:X8} surfaces={partGfx.Surfaces.Count}");
@ -724,6 +734,7 @@ public sealed class GameWindow : IDisposable
var mr = parts[partIdx];
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
@ -1147,8 +1158,11 @@ public sealed class GameWindow : IDisposable
// Single GfxObj stab — identity part transform.
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
if (gfx is not null)
{
_physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx);
meshRefs.Add(new AcDream.Core.World.MeshRef(
e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
}
}
else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
@ -1156,11 +1170,13 @@ public sealed class GameWindow : IDisposable
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null)
{
_physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup);
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
meshRefs.Add(mr);
}
}
@ -1258,6 +1274,7 @@ public sealed class GameWindow : IDisposable
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(spawn.ObjectId);
if (gfx is not null)
{
_physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx);
// Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain.
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat));
@ -1268,11 +1285,13 @@ public sealed class GameWindow : IDisposable
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.ObjectId);
if (setup is not null)
{
_physicsDataCache.CacheSetup(spawn.ObjectId, setup);
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
// Compose: part's own transform, then the spawn's scale.
meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform * scaleMat));
@ -1384,6 +1403,7 @@ public sealed class GameWindow : IDisposable
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
if (gfx is not null)
{
_physicsDataCache.CacheGfxObj(stab.Id, gfx);
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity));
}
@ -1393,11 +1413,13 @@ public sealed class GameWindow : IDisposable
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(stab.Id);
if (setup is not null)
{
_physicsDataCache.CacheSetup(stab.Id, setup);
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
meshRefs.Add(mr);
}
@ -1720,6 +1742,7 @@ public sealed class GameWindow : IDisposable
if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue;
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(meshRef.GfxObjId);
if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
_staticMesh.EnsureUploaded(meshRef.GfxObjId, subMeshes);
}
@ -2168,6 +2191,7 @@ public sealed class GameWindow : IDisposable
{
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(pe.SourceGfxObjOrSetupId);
if (setup is null) return;
_physicsDataCache.CacheSetup(pe.SourceGfxObjOrSetupId, setup);
// Build a minimal part template from the entity's current MeshRefs.
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[pe.MeshRefs.Count];