From e2f0c8580ed1d85eb319a645c70ee5b88d112d4c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 14 Apr 2026 11:05:09 +0200 Subject: [PATCH] feat(physics): cell-based ShadowObject collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/AcDream.App/Rendering/GameWindow.cs | 61 ++++++ src/AcDream.Core/Physics/PhysicsEngine.cs | 49 ++++- .../Physics/ShadowObjectRegistry.cs | 151 +++++++++++++++ src/AcDream.Core/Physics/TransitionTypes.cs | 102 ++++++++++ .../Physics/ShadowObjectRegistryTests.cs | 180 ++++++++++++++++++ 5 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 src/AcDream.Core/Physics/ShadowObjectRegistry.cs create mode 100644 tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3adb7bd..304b396 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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) diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 9cdb173..49c6e89 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -24,6 +24,19 @@ public sealed class PhysicsEngine /// Number of registered landblocks (diagnostic). public int LandblockCount => _landblocks.Count; + /// + /// Cell-based spatial index for static object collision. + /// Populated during landblock streaming; queried by the Transition system. + /// + public ShadowObjectRegistry ShadowObjects { get; } = new(); + + /// + /// 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. + /// + public PhysicsDataCache? DataCache { get; set; } + private sealed record LandblockPhysics( TerrainSurface Terrain, IReadOnlyList Cells, @@ -43,9 +56,41 @@ public sealed class PhysicsEngine } /// - /// Remove a previously registered landblock. + /// Remove a previously registered landblock, including its shadow objects. /// - public void RemoveLandblock(uint landblockId) => _landblocks.Remove(landblockId); + public void RemoveLandblock(uint landblockId) + { + _landblocks.Remove(landblockId); + ShadowObjects.RemoveLandblock(landblockId); + } + + /// + /// 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. + /// + 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; + } /// /// Sample the outdoor terrain Z at the given world-space XY position. diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs new file mode 100644 index 0000000..e75861f --- /dev/null +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -0,0 +1,151 @@ +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// 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. +/// +public sealed class ShadowObjectRegistry +{ + private readonly Dictionary> _cells = new(); + private readonly Dictionary> _entityToCells = new(); // for deregistration + + /// + /// Register an entity into the cells it overlaps based on world position + radius. + /// + 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 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(); + _cells[cellId] = list; + } + list.Add(entry); + } + } + + _entityToCells[entityId] = cellIds; + } + + /// Remove an entity from all cells it was registered in. + 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); + } + + /// Remove all entities belonging to a landblock. + public void RemoveLandblock(uint landblockId) + { + uint lbPrefix = landblockId & 0xFFFF0000u; + var toRemove = new List(); + + 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(); + 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); + } + + /// Get all objects registered in a specific cell. + public IReadOnlyList GetObjectsInCell(uint cellId) + { + if (_cells.TryGetValue(cellId, out var list)) + return list; + return Array.Empty(); + } + + /// + /// Get all objects in cells that a sphere at worldPos with given radius overlaps. + /// This is the main query used by the Transition system. + /// + public void GetNearbyObjects(Vector3 worldPos, float queryRadius, + float worldOffsetX, float worldOffsetY, uint landblockId, + List 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(); // 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); diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 25ab0ab..9bc07a3 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -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 _nearbyObjs = new(); + + /// + /// 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. + /// + 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) // ----------------------------------------------------------------------- diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs new file mode 100644 index 0000000..af869c1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -0,0 +1,180 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Unit tests for — Task 7 cell-based +/// spatial index for object collision. +/// +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(); + 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(); + // 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(); + // 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); + } +}