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:
parent
246713e2cc
commit
e2f0c8580e
5 changed files with 541 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,19 @@ public sealed class PhysicsEngine
|
|||
/// <summary>Number of registered landblocks (diagnostic).</summary>
|
||||
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(
|
||||
TerrainSurface Terrain,
|
||||
IReadOnlyList<CellSurface> Cells,
|
||||
|
|
@ -43,9 +56,41 @@ public sealed class PhysicsEngine
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a previously registered landblock.
|
||||
/// Remove a previously registered landblock, including its shadow objects.
|
||||
/// </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>
|
||||
/// Sample the outdoor terrain Z at the given world-space XY position.
|
||||
|
|
|
|||
151
src/AcDream.Core/Physics/ShadowObjectRegistry.cs
Normal file
151
src/AcDream.Core/Physics/ShadowObjectRegistry.cs
Normal 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);
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
|
|
@ -430,6 +431,23 @@ public sealed class Transition
|
|||
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.
|
||||
if (transitState == TransitionState.OK)
|
||||
{
|
||||
|
|
@ -586,6 +604,90 @@ public sealed class Transition
|
|||
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)
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue