feat(physics): cell-based ShadowObject collision

Register static entities into terrain cells during streaming.
Transition system queries nearby objects and runs BSP collision.
Player can no longer walk through trees and buildings.

- ShadowObjectRegistry: 24m×24m cell index, Register/Deregister/
  RemoveLandblock/GetNearbyObjects matching retail AC's approach
- PhysicsEngine: ShadowObjects property + DataCache wiring point;
  RemoveLandblock now also clears shadow objects; TryGetLandblockContext
  helper lets Transition resolve landblock id+offset for a world pos
- Transition.FindObjCollisions: queries registry, broad-phase sphere test,
  narrow-phase BSPQuery.SphereIntersectsPoly in object-local space,
  returns Slid on hit to redirect movement along the surface
- GameWindow.ApplyLoadedTerrainLocked: registers each static entity after
  physics BSP data is cached; selects radius from BSP bounding sphere or
  Setup.Radius; wires PhysicsDataCache into engine on OnLoad
- 16 new ShadowObjectRegistry unit tests, all 361 tests green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 11:05:09 +02:00
parent 246713e2cc
commit e2f0c8580e
5 changed files with 541 additions and 2 deletions

View file

@ -178,6 +178,10 @@ public sealed class GameWindow : IDisposable
private void OnLoad()
{
// Task 7: wire the physics data cache into the engine so Transition can
// run narrow-phase BSP tests during FindObjCollisions.
_physicsEngine.DataCache = _physicsDataCache;
_gl = GL.GetApi(_window!);
_input = _window!.CreateInput();
foreach (var kb in _input.Keyboards)
@ -1749,6 +1753,63 @@ public sealed class GameWindow : IDisposable
}
}
// Task 7: register static entities into the ShadowObjectRegistry so the
// Transition system can find and collide against them during movement.
// Only entities backed by a GfxObj with a physics BSP are registered —
// entities with no BSP (pure visual, no physics) are skipped.
//
// Radius source priority:
// 1. GfxObj: use the BSP root bounding sphere radius if available.
// 2. Setup: use Setup.Radius (the capsule radius) if available.
// 3. Fallback: 1.0m (conservative default for trees / small objects).
foreach (var entity in lb.Entities)
{
float bestRadius = 0f;
uint physicsGfxId = 0;
uint sourceId = entity.SourceGfxObjOrSetupId;
if ((sourceId & 0xFF000000u) == 0x01000000u)
{
// Direct GfxObj stab.
var cached = _physicsDataCache.GetGfxObj(sourceId);
if (cached?.BSP?.Root is not null)
{
physicsGfxId = sourceId;
bestRadius = cached.BoundingSphere?.Radius ?? 1f;
}
}
else if ((sourceId & 0xFF000000u) == 0x02000000u)
{
// Setup (multi-part building / creature proxy). Use the first
// part that has a physics BSP; register using Setup.Radius for
// the broad-phase sphere so the query covers the whole assembly.
var setupCached = _physicsDataCache.GetSetup(sourceId);
if (setupCached is not null && setupCached.Radius > 0f)
bestRadius = setupCached.Radius;
foreach (var meshRef in entity.MeshRefs)
{
var partCached = _physicsDataCache.GetGfxObj(meshRef.GfxObjId);
if (partCached?.BSP?.Root is not null)
{
physicsGfxId = meshRef.GfxObjId;
if (bestRadius <= 0f)
bestRadius = partCached.BoundingSphere?.Radius ?? 1f;
break; // register just the first physics part for MVP
}
}
}
if (physicsGfxId != 0)
{
float reg_radius = bestRadius > 0f ? bestRadius : 1f;
_physicsEngine.ShadowObjects.Register(
entity.Id, physicsGfxId,
entity.Position, reg_radius,
origin.X, origin.Y, lb.LandblockId);
}
}
// Register each stab as a plugin snapshot so the plugin host has
// visibility into the streaming world state.
foreach (var entity in lb.Entities)