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

@ -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);