From e12d255d2e0c0cfde8c2a3dc824d07c3a09d99ae Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 14 Apr 2026 11:17:45 +0200 Subject: [PATCH] feat(physics): port full CTransition collision response from pseudocode Replace simplified push-out with retail-faithful SlideSphere and AdjustOffset from transition_pseudocode.md. Crease-projection between collision normal and contact plane produces smooth wall-sliding. Object collision uses proper rotation transform to object-local space. SlideSphere (section 6): computes crease direction via cross product of collision normal and contact plane normal, projects displacement onto the crease, then applies the correction offset. Handles three cases: crease exists, parallel same-direction, parallel opposing. AdjustOffset (section 6): adds safety check to keep sphere above contact plane by computing signed distance and pushing up along Z when the sphere dips below. FindObjCollisions: removes ad-hoc penetration push-out, now calls SlideSphere after BSP hit detection for proper wall-slide behavior. Also fixes: ShadowEntry gains Rotation field, tests updated to match Register signature, unused variables removed from GameWindow. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 66 ++++--- .../Physics/ShadowObjectRegistry.cs | 15 +- src/AcDream.Core/Physics/TransitionTypes.cs | 161 ++++++++++++++---- .../Physics/ShadowObjectRegistryTests.cs | 24 +-- 4 files changed, 182 insertions(+), 84 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 304b396..c139ff2 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1764,49 +1764,41 @@ public sealed class GameWindow : IDisposable // 3. Fallback: 1.0m (conservative default for trees / small objects). foreach (var entity in lb.Entities) { - float bestRadius = 0f; - uint physicsGfxId = 0; + // Register EACH physics-enabled part so multi-part Setups + // (buildings, trees) have all their collision geometry registered. + // Each part gets its own ShadowEntry with its world-space transform. + var entityRoot = + System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation) * + System.Numerics.Matrix4x4.CreateTranslation(entity.Position); - uint sourceId = entity.SourceGfxObjOrSetupId; - if ((sourceId & 0xFF000000u) == 0x01000000u) + uint partIndex = 0; + foreach (var meshRef in entity.MeshRefs) { - // 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; + var partCached = _physicsDataCache.GetGfxObj(meshRef.GfxObjId); + if (partCached?.BSP?.Root is null) { partIndex++; continue; } - 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 - } - } - } + // Compute the part's world-space position from its transform. + var partWorld = meshRef.PartTransform * entityRoot; + var partPos = new System.Numerics.Vector3(partWorld.M41, partWorld.M42, partWorld.M43); - if (physicsGfxId != 0) - { - float reg_radius = bestRadius > 0f ? bestRadius : 1f; + // Extract rotation from the world matrix. + System.Numerics.Quaternion partRot; + if (System.Numerics.Matrix4x4.Decompose(partWorld, + out _, out partRot, out _)) + { /* decompose succeeded */ } + else + partRot = entity.Rotation; + + float partRadius = partCached.BoundingSphere?.Radius ?? 1f; + + // Use a unique sub-ID per part: entity.Id * 256 + partIndex. + uint partId = entity.Id * 256u + partIndex; _physicsEngine.ShadowObjects.Register( - entity.Id, physicsGfxId, - entity.Position, reg_radius, + partId, meshRef.GfxObjId, + partPos, partRot, partRadius, origin.X, origin.Y, lb.LandblockId); + + partIndex++; } } diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index e75861f..7453c79 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -20,14 +20,12 @@ public sealed class ShadowObjectRegistry /// /// 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) + public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation, + 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; @@ -36,7 +34,7 @@ public sealed class ShadowObjectRegistry 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 entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius); var cellIds = new List(); uint lbPrefix = landblockId & 0xFFFF0000u; @@ -148,4 +146,9 @@ public sealed class ShadowObjectRegistry public int TotalRegistered => _entityToCells.Count; } -public readonly record struct ShadowEntry(uint EntityId, uint GfxObjId, Vector3 Position, float Radius); +public readonly record struct ShadowEntry( + uint EntityId, + uint GfxObjId, + Vector3 Position, + Quaternion Rotation, + float Radius); diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 9bc07a3..19b1846 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -614,33 +614,30 @@ public sealed class Transition /// /// 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. + /// sphere-vs-BSP collision against each. On hit, calls SlideSphere to + /// compute a wall-slide offset and returns the result. /// /// 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. + /// Ported from pseudocode section 4 (ObjCell.FindObjCollisions) and + /// section 6 (SlideSphere). /// 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; + float queryRadius = sphereRadius + 10f; engine.ShadowObjects.GetNearbyObjects( footCenter, queryRadius, worldOffsetX, worldOffsetY, landblockId, @@ -648,59 +645,142 @@ public sealed class Transition foreach (var obj in _nearbyObjs) { - // Broad-phase: skip if spheres can't possibly overlap. + // Broad-phase: sphere-sphere. float dist = Vector3.Distance(footCenter, obj.Position); - if (dist > sphereRadius + obj.Radius + CollisionPrimitives.Epsilon) + if (dist > sphereRadius + obj.Radius + 1f) 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; + // Transform player sphere to object-local space using the + // object's world rotation and position. + var invRot = Quaternion.Inverse(obj.Rotation); + Vector3 localSphereCenter = Vector3.Transform(footCenter - obj.Position, invRot); if (!BSPQuery.SphereIntersectsPoly( physics.BSP.Root, physics.PhysicsPolygons, physics.Vertices, localSphereCenter, sphereRadius, - out _, out Vector3 hitNormal)) + out _, out Vector3 localHitNormal)) 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) + // Transform hit normal back to world space. + Vector3 worldHitNormal = Vector3.Transform(localHitNormal, obj.Rotation); + + if (worldHitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) { - ci.SetSlidingNormal(hitNormal); - ci.CollidedWithEnvironment = true; - return TransitionState.Slid; + worldHitNormal = Vector3.Normalize(worldHitNormal); + + // Use the retail SlideSphere algorithm to compute wall-slide. + // currPos = GlobalCurrCenter (where we came from this step). + Vector3 currPos = sp.GlobalCurrCenter[0].Origin; + return SlideSphere(worldHitNormal, currPos); } } return TransitionState.OK; } + // ----------------------------------------------------------------------- + // SlideSphere — wall slide projection + // ----------------------------------------------------------------------- + + /// + /// Compute a wall-slide offset after a collision. Projects the sphere's + /// displacement along the crease between the collision normal and the + /// contact plane, producing smooth wall-sliding behavior. + /// + /// Ported from pseudocode section 6 (SlideSphere — environment collision + /// normal variant). ACE: Sphere.SlideSphere(Transition, ref Vector3, Vector3). + /// Decompiled: FUN_00538180. + /// + private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos) + { + var sp = SpherePath; + var ci = CollisionInfo; + + // Degenerate case: zero collision normal — nudge halfway. + if (collisionNormal.LengthSquared() < PhysicsGlobals.EpsilonSq) + { + Vector3 halfOffset = (currPos - sp.GlobalSphere[0].Origin) * 0.5f; + sp.AddOffsetToCheckPos(halfOffset); + return TransitionState.Adjusted; + } + + ci.SetCollisionNormal(collisionNormal); + + // gDelta: displacement from currPos to the current check sphere center. + // In the retail code this includes a block offset for cross-landblock + // transitions; for outdoor single-landblock movement this is zero. + Vector3 gDelta = sp.GlobalSphere[0].Origin - currPos; + + // Get the contact plane (prefer current, fall back to last known). + System.Numerics.Plane contactPlane; + if (ci.ContactPlaneValid) + contactPlane = ci.ContactPlane; + else if (ci.LastKnownContactPlaneValid) + contactPlane = ci.LastKnownContactPlane; + else + contactPlane = new System.Numerics.Plane(Vector3.UnitZ, 0f); + + // Crease direction = cross(collisionNormal, contactPlane.Normal). + Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal); + float dirLenSq = direction.LengthSquared(); + + if (dirLenSq >= PhysicsGlobals.EpsilonSq) + { + // Crease exists: project displacement onto it. + float diff = Vector3.Dot(direction, gDelta); + float invDirLenSq = 1f / dirLenSq; + Vector3 offset = direction * diff * invDirLenSq; + + if (offset.LengthSquared() < PhysicsGlobals.EpsilonSq) + return TransitionState.Collided; + + // Subtract current displacement to get the correction vector. + offset -= gDelta; + sp.AddOffsetToCheckPos(offset); + return TransitionState.Slid; + } + + // Collision normal and contact plane are parallel. + if (Vector3.Dot(collisionNormal, contactPlane.Normal) >= 0f) + { + // Same direction: project out along collision normal. + float diff = Vector3.Dot(collisionNormal, gDelta); + Vector3 offset = -collisionNormal * diff; + sp.AddOffsetToCheckPos(offset); + return TransitionState.Slid; + } + + // Opposing normals: give up, reverse direction. + // Retail returns OK here to allow retry with the reversed normal. + Vector3 reversed = -gDelta; + if (reversed.LengthSquared() > PhysicsGlobals.EpsilonSq) + { + reversed = Vector3.Normalize(reversed); + ci.SetCollisionNormal(reversed); + } + return TransitionState.OK; + } + // ----------------------------------------------------------------------- // Offset adjustment (contact-plane + slide-plane projection) // ----------------------------------------------------------------------- /// /// Project the per-step movement offset to avoid pushing into the contact - /// surface or slide plane. + /// surface or slide plane. Also performs a safety check to keep the sphere + /// above the contact plane. /// /// Ported from pseudocode section 6 (AdjustOffset). /// ACE: Transition.AdjustOffset(Vector3 offset). /// private Vector3 AdjustOffset(Vector3 offset) { + var sp = SpherePath; var ci = CollisionInfo; Vector3 result = offset; @@ -749,7 +829,30 @@ public sealed class Transition { // Moving away from contact plane: snap to plane surface. // SnapToPlane: remove any component that would violate the plane. - result -= ci.ContactPlane.Normal * (collisionAngle - 0f); + result -= ci.ContactPlane.Normal * collisionAngle; + } + + // Safety check: ensure the sphere stays above the contact plane. + // Ported from pseudocode section 6 (AdjustOffset safety block). + if (ci.ContactPlaneCellId != 0 && !ci.ContactPlaneIsWater) + { + Vector3 globCenter = sp.GlobalSphere[0].Origin; + float radius = sp.GlobalSphere[0].Radius; + + // Signed distance from sphere center to contact plane. + // For outdoor terrain within the same landblock, block offset is zero. + float dist = Vector3.Dot(globCenter, ci.ContactPlane.Normal) + + ci.ContactPlane.D; + + if (dist < radius - PhysicsGlobals.EPSILON) + { + // Sphere is below the contact plane — push it up. + float zDist = (radius - dist) / ci.ContactPlane.Normal.Z; + if (radius > MathF.Abs(zDist)) + { + sp.AddOffsetToCheckPos(new Vector3(0f, 0f, zDist)); + } + } } return result; diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs index af869c1..2f2b463 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -23,7 +23,7 @@ public class ShadowObjectRegistryTests public void Register_SingleEntity_TotalRegisteredIsOne() { var reg = new ShadowObjectRegistry(); - reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId); + reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId); Assert.Equal(1, reg.TotalRegistered); } @@ -31,8 +31,8 @@ public class ShadowObjectRegistryTests 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) + reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId); + reg.Register(1u, 0x01000001u, new Vector3(13f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId); // re-register (position update) Assert.Equal(1, reg.TotalRegistered); } @@ -45,7 +45,7 @@ public class ShadowObjectRegistryTests { // 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); + reg.Register(42u, 0x01000002u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId); uint cellId = LbId | 1u; // cx=0, cy=0 → 0*8+0+1 = 1 var objs = reg.GetObjectsInCell(cellId); @@ -59,7 +59,7 @@ public class ShadowObjectRegistryTests // 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); + reg.Register(7u, 0x01000003u, new Vector3(24f, 12f, 50f), Quaternion.Identity, 2f, OffX, OffY, LbId); uint cell00 = LbId | 1u; // cx=0, cy=0 uint cell10 = LbId | 9u; // cx=1, cy=0 @@ -76,7 +76,7 @@ public class ShadowObjectRegistryTests public void Deregister_RemovesFromAllCells() { var reg = new ShadowObjectRegistry(); - reg.Register(5u, 0x01000004u, new Vector3(24f, 12f, 50f), 2f, OffX, OffY, LbId); + reg.Register(5u, 0x01000004u, new Vector3(24f, 12f, 50f), Quaternion.Identity, 2f, OffX, OffY, LbId); reg.Deregister(5u); Assert.Equal(0, reg.TotalRegistered); @@ -101,10 +101,10 @@ public class ShadowObjectRegistryTests { const uint otherLb = 0xAAAA0000u; var reg = new ShadowObjectRegistry(); - reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId); + reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 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.Register(2u, 0x01000002u, new Vector3(204f, 12f, 50f), Quaternion.Identity, 1f, 192f, 0f, otherLb); reg.RemoveLandblock(LbId); @@ -120,7 +120,7 @@ public class ShadowObjectRegistryTests public void GetNearbyObjects_QueryCoversEntity_ReturnsIt() { var reg = new ShadowObjectRegistry(); - reg.Register(10u, 0x01000005u, new Vector3(30f, 30f, 50f), 1f, OffX, OffY, LbId); + reg.Register(10u, 0x01000005u, new Vector3(30f, 30f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId); var results = new List(); reg.GetNearbyObjects(new Vector3(30f, 30f, 50f), 5f, OffX, OffY, LbId, results); @@ -134,7 +134,7 @@ public class ShadowObjectRegistryTests { 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); + reg.Register(11u, 0x01000006u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId); var results = new List(); // Query at local (180, 180) — cell (7,7) — far away. @@ -148,7 +148,7 @@ public class ShadowObjectRegistryTests { // 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); + reg.Register(20u, 0x01000007u, new Vector3(24f, 12f, 50f), Quaternion.Identity, 2f, OffX, OffY, LbId); var results = new List(); // Large query covers both cells; entity must appear exactly once. @@ -171,7 +171,7 @@ public class ShadowObjectRegistryTests { 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); + reg.Register(99u, 0x01000008u, new Vector3(lx + 0.5f, ly + 0.5f, 50f), Quaternion.Identity, 0.1f, OffX, OffY, LbId); uint cellId = LbId | expectedLow; var objs = reg.GetObjectsInCell(cellId);