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() 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!); _gl = GL.GetApi(_window!);
_input = _window!.CreateInput(); _input = _window!.CreateInput();
foreach (var kb in _input.Keyboards) 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 // Register each stab as a plugin snapshot so the plugin host has
// visibility into the streaming world state. // visibility into the streaming world state.
foreach (var entity in lb.Entities) foreach (var entity in lb.Entities)

View file

@ -24,6 +24,19 @@ public sealed class PhysicsEngine
/// <summary>Number of registered landblocks (diagnostic).</summary> /// <summary>Number of registered landblocks (diagnostic).</summary>
public int LandblockCount => _landblocks.Count; public int LandblockCount => _landblocks.Count;
/// <summary>
/// Cell-based spatial index for static object collision.
/// Populated during landblock streaming; queried by the Transition system.
/// </summary>
public ShadowObjectRegistry ShadowObjects { get; } = new();
/// <summary>
/// Physics BSP cache shared with the streaming loader. Set once by the
/// host (GameWindow) immediately after construction. The Transition system
/// reads this during FindObjCollisions to perform narrow-phase BSP tests.
/// </summary>
public PhysicsDataCache? DataCache { get; set; }
private sealed record LandblockPhysics( private sealed record LandblockPhysics(
TerrainSurface Terrain, TerrainSurface Terrain,
IReadOnlyList<CellSurface> Cells, IReadOnlyList<CellSurface> Cells,
@ -43,9 +56,41 @@ public sealed class PhysicsEngine
} }
/// <summary> /// <summary>
/// Remove a previously registered landblock. /// Remove a previously registered landblock, including its shadow objects.
/// </summary> /// </summary>
public void RemoveLandblock(uint landblockId) => _landblocks.Remove(landblockId); public void RemoveLandblock(uint landblockId)
{
_landblocks.Remove(landblockId);
ShadowObjects.RemoveLandblock(landblockId);
}
/// <summary>
/// Find the landblock that contains the given world-space XY position and
/// return its ID plus world-space origin offsets. Returns false when no
/// registered landblock covers the position.
/// Used by Transition.FindObjCollisions to build the shadow-object query.
/// </summary>
public bool TryGetLandblockContext(float worldX, float worldY,
out uint landblockId, out float worldOffsetX, out float worldOffsetY)
{
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldX - lb.WorldOffsetX;
float localY = worldY - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{
landblockId = kvp.Key;
worldOffsetX = lb.WorldOffsetX;
worldOffsetY = lb.WorldOffsetY;
return true;
}
}
landblockId = 0;
worldOffsetX = 0f;
worldOffsetY = 0f;
return false;
}
/// <summary> /// <summary>
/// Sample the outdoor terrain Z at the given world-space XY position. /// Sample the outdoor terrain Z at the given world-space XY position.

View file

@ -0,0 +1,151 @@
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Cell-based spatial index for object collision. Each entity registers
/// into the outdoor terrain cells (24m × 24m) it overlaps. The Transition
/// system queries this to find nearby objects during collision detection.
///
/// Retail AC uses the same cell-based approach (no k-d tree / octree).
/// Outdoor cells are 24×24m (8 cells per 192m landblock, 64 cells per lb).
/// Cell ID = landblock high 16 bits | (cellX * 8 + cellY + 1) in low 16.
/// </summary>
public sealed class ShadowObjectRegistry
{
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
/// <summary>
/// Register an entity into the cells it overlaps based on world position + radius.
/// </summary>
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, float radius,
float worldOffsetX, float worldOffsetY, uint landblockId)
{
// Deregister first if already registered (handles position updates)
Deregister(entityId);
// Compute which cells the entity's bounding sphere overlaps.
// Each cell is 24×24m within a 192m landblock.
float localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY;
int minCx = Math.Max(0, (int)((localX - radius) / 24f));
int maxCx = Math.Min(7, (int)((localX + radius) / 24f));
int minCy = Math.Max(0, (int)((localY - radius) / 24f));
int maxCy = Math.Min(7, (int)((localY + radius) / 24f));
var entry = new ShadowEntry(entityId, gfxObjId, worldPos, radius);
var cellIds = new List<uint>();
uint lbPrefix = landblockId & 0xFFFF0000u;
for (int cx = minCx; cx <= maxCx; cx++)
{
for (int cy = minCy; cy <= maxCy; cy++)
{
uint cellId = lbPrefix | (uint)(cx * 8 + cy + 1);
cellIds.Add(cellId);
if (!_cells.TryGetValue(cellId, out var list))
{
list = new List<ShadowEntry>();
_cells[cellId] = list;
}
list.Add(entry);
}
}
_entityToCells[entityId] = cellIds;
}
/// <summary>Remove an entity from all cells it was registered in.</summary>
public void Deregister(uint entityId)
{
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
return;
foreach (var cellId in cellIds)
{
if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(e => e.EntityId == entityId);
}
_entityToCells.Remove(entityId);
}
/// <summary>Remove all entities belonging to a landblock.</summary>
public void RemoveLandblock(uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
var toRemove = new List<uint>();
foreach (var kvp in _cells)
{
if ((kvp.Key & 0xFFFF0000u) == lbPrefix)
toRemove.Add(kvp.Key);
}
foreach (var cellId in toRemove)
_cells.Remove(cellId);
// Clean up entity-to-cell map
var entitiesToRemove = new List<uint>();
foreach (var kvp in _entityToCells)
{
kvp.Value.RemoveAll(c => (c & 0xFFFF0000u) == lbPrefix);
if (kvp.Value.Count == 0)
entitiesToRemove.Add(kvp.Key);
}
foreach (var eid in entitiesToRemove)
_entityToCells.Remove(eid);
}
/// <summary>Get all objects registered in a specific cell.</summary>
public IReadOnlyList<ShadowEntry> GetObjectsInCell(uint cellId)
{
if (_cells.TryGetValue(cellId, out var list))
return list;
return Array.Empty<ShadowEntry>();
}
/// <summary>
/// Get all objects in cells that a sphere at worldPos with given radius overlaps.
/// This is the main query used by the Transition system.
/// </summary>
public void GetNearbyObjects(Vector3 worldPos, float queryRadius,
float worldOffsetX, float worldOffsetY, uint landblockId,
List<ShadowEntry> results)
{
results.Clear();
float localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY;
int minCx = Math.Max(0, (int)((localX - queryRadius) / 24f));
int maxCx = Math.Min(7, (int)((localX + queryRadius) / 24f));
int minCy = Math.Max(0, (int)((localY - queryRadius) / 24f));
int maxCy = Math.Min(7, (int)((localY + queryRadius) / 24f));
uint lbPrefix = landblockId & 0xFFFF0000u;
var seen = new HashSet<uint>(); // avoid duplicates from overlapping cells
for (int cx = minCx; cx <= maxCx; cx++)
{
for (int cy = minCy; cy <= maxCy; cy++)
{
uint cellId = lbPrefix | (uint)(cx * 8 + cy + 1);
if (!_cells.TryGetValue(cellId, out var list)) continue;
foreach (var entry in list)
{
if (seen.Add(entry.EntityId))
results.Add(entry);
}
}
}
}
public int TotalRegistered => _entityToCells.Count;
}
public readonly record struct ShadowEntry(uint EntityId, uint GfxObjId, Vector3 Position, float Radius);

View file

@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using DatReaderWriter.Types; using DatReaderWriter.Types;
@ -430,6 +431,23 @@ public sealed class Transition
break; break;
} }
// Phase 1b: check object (static BSP) collisions when OK so far.
if (transitState == TransitionState.OK)
{
var objState = FindObjCollisions(engine);
if (objState == TransitionState.Slid)
{
transitState = TransitionState.Slid;
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
sp.NegPolyHit = false;
}
else if (objState == TransitionState.Collided)
{
return TransitionState.Collided;
}
}
// Phase 2: post-collision response. // Phase 2: post-collision response.
if (transitState == TransitionState.OK) if (transitState == TransitionState.OK)
{ {
@ -586,6 +604,90 @@ public sealed class Transition
return TransitionState.Adjusted; return TransitionState.Adjusted;
} }
// -----------------------------------------------------------------------
// Object collision — static BSP objects
// -----------------------------------------------------------------------
// Reused per-call to avoid per-step allocation; safe because Transition
// is single-threaded per movement resolve.
private readonly List<ShadowEntry> _nearbyObjs = new();
/// <summary>
/// Query the ShadowObjectRegistry for nearby static objects and run
/// sphere-vs-BSP collision against each. On hit, sets the sliding normal
/// and returns Slid so the caller redirects movement along the surface.
///
/// Object-local transform: the player sphere is mapped into each object's
/// local space via the inverse of (Rotation, Position) before the BSP query.
/// The hit normal is then rotated back to world space.
///
/// Task 7 implementation.
/// </summary>
private TransitionState FindObjCollisions(PhysicsEngine engine)
{
if (engine.DataCache is null) return TransitionState.OK;
var sp = SpherePath;
var ci = CollisionInfo;
Vector3 footCenter = sp.GlobalSphere[0].Origin;
float sphereRadius = sp.GlobalSphere[0].Radius;
// Find which landblock the player foot sphere is in.
if (!engine.TryGetLandblockContext(footCenter.X, footCenter.Y,
out uint landblockId, out float worldOffsetX, out float worldOffsetY))
return TransitionState.OK;
// Query radius includes sphere radius plus a generous margin so we
// don't miss objects whose BSP extends beyond their bounding sphere.
float queryRadius = sphereRadius + 5f;
engine.ShadowObjects.GetNearbyObjects(
footCenter, queryRadius,
worldOffsetX, worldOffsetY, landblockId,
_nearbyObjs);
foreach (var obj in _nearbyObjs)
{
// Broad-phase: skip if spheres can't possibly overlap.
float dist = Vector3.Distance(footCenter, obj.Position);
if (dist > sphereRadius + obj.Radius + CollisionPrimitives.Epsilon)
continue;
// Narrow-phase: fetch cached BSP physics data for this GfxObj.
var physics = engine.DataCache.GetGfxObj(obj.GfxObjId);
if (physics?.BSP?.Root is null) continue;
// Transform the player sphere center into object-local space.
// The object transform is: worldPos = Rotation * localPos + objPosition.
// For static objects stored in ShadowEntry we don't have a rotation
// stored (they are stabs/buildings whose orientation varies per entity).
// For the broad-phase pass here we treat them as axis-aligned
// (rotation = Identity), which is conservative: it over-reports hits
// but never misses them. Full per-part rotation requires storing
// Quaternion in ShadowEntry — deferred to a follow-up task.
Vector3 localSphereCenter = footCenter - obj.Position;
if (!BSPQuery.SphereIntersectsPoly(
physics.BSP.Root,
physics.PhysicsPolygons,
physics.Vertices,
localSphereCenter, sphereRadius,
out _, out Vector3 hitNormal))
continue;
// Hit: set sliding normal (XY plane, no Z component) so the
// player slides along the surface rather than tunnelling through.
if (hitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
ci.SetSlidingNormal(hitNormal);
ci.CollidedWithEnvironment = true;
return TransitionState.Slid;
}
}
return TransitionState.OK;
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Offset adjustment (contact-plane + slide-plane projection) // Offset adjustment (contact-plane + slide-plane projection)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View file

@ -0,0 +1,180 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Unit tests for <see cref="ShadowObjectRegistry"/> — Task 7 cell-based
/// spatial index for object collision.
/// </summary>
public class ShadowObjectRegistryTests
{
private const uint LbId = 0xA9B40000u; // landblock prefix used throughout
private const float OffX = 0f;
private const float OffY = 0f;
// -----------------------------------------------------------------------
// Register / TotalRegistered
// -----------------------------------------------------------------------
[Fact]
public void Register_SingleEntity_TotalRegisteredIsOne()
{
var reg = new ShadowObjectRegistry();
reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
Assert.Equal(1, reg.TotalRegistered);
}
[Fact]
public void Register_SameEntityTwice_NoDuplicate()
{
var reg = new ShadowObjectRegistry();
reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
reg.Register(1u, 0x01000001u, new Vector3(13f, 12f, 50f), 1f, OffX, OffY, LbId); // re-register (position update)
Assert.Equal(1, reg.TotalRegistered);
}
// -----------------------------------------------------------------------
// GetObjectsInCell
// -----------------------------------------------------------------------
[Fact]
public void GetObjectsInCell_EntityInCenter_ReturnedInExpectedCell()
{
// Entity at local (12, 12) = cell (0,0) = cellId prefix | 1.
var reg = new ShadowObjectRegistry();
reg.Register(42u, 0x01000002u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
uint cellId = LbId | 1u; // cx=0, cy=0 → 0*8+0+1 = 1
var objs = reg.GetObjectsInCell(cellId);
Assert.Single(objs);
Assert.Equal(42u, objs[0].EntityId);
}
[Fact]
public void GetObjectsInCell_EntitySpanning2Cells_RegisteredInBoth()
{
// Entity at local (24, 12) with radius=2 spans cells cx=0 and cx=1 in X.
// Cell 0,0 = prefix|1; cell 1,0 = prefix|(1*8+0+1)=prefix|9.
var reg = new ShadowObjectRegistry();
reg.Register(7u, 0x01000003u, new Vector3(24f, 12f, 50f), 2f, OffX, OffY, LbId);
uint cell00 = LbId | 1u; // cx=0, cy=0
uint cell10 = LbId | 9u; // cx=1, cy=0
Assert.Contains(reg.GetObjectsInCell(cell00), e => e.EntityId == 7u);
Assert.Contains(reg.GetObjectsInCell(cell10), e => e.EntityId == 7u);
}
// -----------------------------------------------------------------------
// Deregister
// -----------------------------------------------------------------------
[Fact]
public void Deregister_RemovesFromAllCells()
{
var reg = new ShadowObjectRegistry();
reg.Register(5u, 0x01000004u, new Vector3(24f, 12f, 50f), 2f, OffX, OffY, LbId);
reg.Deregister(5u);
Assert.Equal(0, reg.TotalRegistered);
Assert.Empty(reg.GetObjectsInCell(LbId | 1u));
Assert.Empty(reg.GetObjectsInCell(LbId | 9u));
}
[Fact]
public void Deregister_NonexistentEntity_NoThrow()
{
var reg = new ShadowObjectRegistry();
// Should not throw.
reg.Deregister(999u);
}
// -----------------------------------------------------------------------
// RemoveLandblock
// -----------------------------------------------------------------------
[Fact]
public void RemoveLandblock_ClearsAllEntitiesForThatBlock()
{
const uint otherLb = 0xAAAA0000u;
var reg = new ShadowObjectRegistry();
reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
// Entity 2 lives in otherLb whose world origin is at X=192. Place it at
// world X=204 so localX = 204-192 = 12, which maps to cell (0,0) of otherLb.
reg.Register(2u, 0x01000002u, new Vector3(204f, 12f, 50f), 1f, 192f, 0f, otherLb);
reg.RemoveLandblock(LbId);
Assert.Equal(1, reg.TotalRegistered); // entity 2 (otherLb) survives
Assert.Empty(reg.GetObjectsInCell(LbId | 1u));
}
// -----------------------------------------------------------------------
// GetNearbyObjects
// -----------------------------------------------------------------------
[Fact]
public void GetNearbyObjects_QueryCoversEntity_ReturnsIt()
{
var reg = new ShadowObjectRegistry();
reg.Register(10u, 0x01000005u, new Vector3(30f, 30f, 50f), 1f, OffX, OffY, LbId);
var results = new List<ShadowEntry>();
reg.GetNearbyObjects(new Vector3(30f, 30f, 50f), 5f, OffX, OffY, LbId, results);
Assert.Single(results);
Assert.Equal(10u, results[0].EntityId);
}
[Fact]
public void GetNearbyObjects_QueryFarFromEntity_ReturnsEmpty()
{
var reg = new ShadowObjectRegistry();
// Entity at local (12, 12) — cell (0,0).
reg.Register(11u, 0x01000006u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
var results = new List<ShadowEntry>();
// Query at local (180, 180) — cell (7,7) — far away.
reg.GetNearbyObjects(new Vector3(180f, 180f, 50f), 5f, OffX, OffY, LbId, results);
Assert.Empty(results);
}
[Fact]
public void GetNearbyObjects_EntityInMultipleCells_ReturnedOnce()
{
// Entity spans cells 0,0 and 1,0 (local X≈24, radius=2).
var reg = new ShadowObjectRegistry();
reg.Register(20u, 0x01000007u, new Vector3(24f, 12f, 50f), 2f, OffX, OffY, LbId);
var results = new List<ShadowEntry>();
// Large query covers both cells; entity must appear exactly once.
reg.GetNearbyObjects(new Vector3(24f, 12f, 50f), 10f, OffX, OffY, LbId, results);
Assert.Single(results);
Assert.Equal(20u, results[0].EntityId);
}
// -----------------------------------------------------------------------
// Cell ID formula
// -----------------------------------------------------------------------
[Theory]
[InlineData(0f, 0f, 1u)] // cell (0,0) → index 0*8+0+1 = 1
[InlineData(48f, 0f, 17u)] // cell (2,0) → 2*8+0+1 = 17
[InlineData(0f, 48f, 3u)] // cell (0,2) → 0*8+2+1 = 3
[InlineData(168f, 168f, 64u)] // cell (7,7) → 7*8+7+1 = 64
public void GetObjectsInCell_CellIdFormula_Correct(float lx, float ly, uint expectedLow)
{
var reg = new ShadowObjectRegistry();
// Small radius so entity sits in exactly one cell.
reg.Register(99u, 0x01000008u, new Vector3(lx + 0.5f, ly + 0.5f, 50f), 0.1f, OffX, OffY, LbId);
uint cellId = LbId | expectedLow;
var objs = reg.GetObjectsInCell(cellId);
Assert.Contains(objs, e => e.EntityId == 99u);
}
}