From 6b4e7569a3fbceffd553eaa714403618dab86654 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 14 Apr 2026 16:32:41 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics):=20transform=20collision=20normals?= =?UTF-8?q?/offsets=20from=20local=E2=86=92world=20space?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BSP collision detection runs in object-local space, but the collision response (normals, push offsets) was being applied directly to world-space SpherePath without rotating back to world space. For rotated objects (trees, rocks, buildings), this caused the push direction to be wrong — pushing the player sideways or into the object instead of away from it. Added localToWorld quaternion parameter to FindCollisions and all helper methods (StepSphereDown, CollideWithPt, NegPolyHitDispatch). All normals and offsets are now transformed via Vector3.Transform(v, localToWorld) before being applied to SpherePath, matching ACE's path.LocalSpacePos.LocalToGlobalVec() pattern. Indoor cell collision uses Quaternion.Identity (cell-local = world). Object collision passes obj.Rotation. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.Core/Physics/BSPQuery.cs | 92 +++++++++++++-------- src/AcDream.Core/Physics/TransitionTypes.cs | 6 +- 2 files changed, 63 insertions(+), 35 deletions(-) diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 7d7a37b..fceddb6 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1036,8 +1036,11 @@ public static class BSPQuery Transition transition, CollisionSphere checkPos, Vector3 up, - float scale) + float scale, + Quaternion localToWorld = default) { + if (localToWorld == default) localToWorld = Quaternion.Identity; + var path = transition.SpherePath; var collisions = transition.CollisionInfo; @@ -1053,16 +1056,18 @@ public static class BSPQuery if (changed && polyHit is not null) { + // ACE: path.LocalSpacePos.LocalToGlobalVec(adjusted) * scale var adjusted = validPos.Center - checkPos.Center; - var offset = adjusted * scale; + var offset = Vector3.Transform(adjusted, localToWorld) * scale; path.AddOffsetToCheckPos(offset); + var worldNormal = Vector3.Transform(polyHit.Plane.Normal, localToWorld); collisions.SetContactPlane( - new Plane(polyHit.Plane.Normal, polyHit.Plane.D * scale), + new Plane(worldNormal, polyHit.Plane.D * scale), path.CheckCellId, false); path.WalkableValid = true; - path.WalkablePlane = new Plane(polyHit.Plane.Normal, polyHit.Plane.D * scale); + path.WalkablePlane = new Plane(worldNormal, polyHit.Plane.D * scale); path.WalkableAllowance = PhysicsGlobals.FloorZ; return TransitionState.Adjusted; @@ -1157,13 +1162,17 @@ public static class BSPQuery Vector3 curPos, ResolvedPolygon hitPoly, Vector3 contactPoint, - float scale) + float scale, + Quaternion localToWorld = default) { + if (localToWorld == default) localToWorld = Quaternion.Identity; + var obj = transition.ObjectInfo; var path = transition.SpherePath; var collisions = transition.CollisionInfo; - var collisionNormal = hitPoly.Plane.Normal; + // ACE: path.LocalSpacePos.LocalToGlobalVec(hitPoly.Plane.Normal) + var collisionNormal = Vector3.Transform(hitPoly.Plane.Normal, localToWorld); if (!obj.State.HasFlag(ObjectInfoState.PerfectClip)) { @@ -1179,7 +1188,8 @@ public static class BSPQuery collisions.SetCollisionNormal(collisionNormal); var adjusted = validPos.Center - checkPos.Center; - var offset = adjusted * scale; + // ACE: path.LocalSpacePos.LocalToGlobalVec(adjusted) * scale + var offset = Vector3.Transform(adjusted, localToWorld) * scale; path.AddOffsetToCheckPos(offset); return TransitionState.Adjusted; @@ -1197,11 +1207,14 @@ public static class BSPQuery private static TransitionState NegPolyHitDispatch( SpherePath path, ResolvedPolygon hitPoly, - bool stepUp) + bool stepUp, + Quaternion localToWorld = default) { + if (localToWorld == default) localToWorld = Quaternion.Identity; path.NegPolyHit = true; path.NegStepUp = stepUp; - path.NegCollisionNormal = hitPoly.Plane.Normal; + // ACE: path.LocalSpacePos.LocalToGlobalVec(hitPoly.Plane.Normal) + path.NegCollisionNormal = Vector3.Transform(hitPoly.Plane.Normal, localToWorld); return TransitionState.OK; } @@ -1336,6 +1349,12 @@ public static class BSPQuery /// Previous center of the primary sphere in local space. /// Up vector in object-local space (usually Vector3.UnitZ). /// Scale factor for the collision object. + /// + /// Rotation that transforms vectors from object-local space back to world space. + /// ACE: path.LocalSpacePos.LocalToGlobalVec(). For indoor cells with identity + /// transform, pass Quaternion.Identity. For rotated objects, pass the object's + /// rotation quaternion. + /// public static TransitionState FindCollisions( PhysicsBSPNode? root, Dictionary resolved, @@ -1344,9 +1363,12 @@ public static class BSPQuery DatReaderWriter.Types.Sphere? localSphere1, Vector3 localCurrCenter, Vector3 localSpaceZ, - float scale) + float scale, + Quaternion localToWorld = default) { if (root is null) return TransitionState.OK; + // Default quaternion (0,0,0,0) → treat as identity + if (localToWorld == default) localToWorld = Quaternion.Identity; var path = transition.SpherePath; var collisions = transition.CollisionInfo; @@ -1359,13 +1381,15 @@ public static class BSPQuery var movement = sphere0.Center - localCurrCenter; + // Helper: transform a local-space vector to world space. + // ACE: path.LocalSpacePos.LocalToGlobalVec(v) + Vector3 L2W(Vector3 v) => Vector3.Transform(v, localToWorld); + // ---------------------------------------------------------------- // Path 1: Placement or Ethereal → sphere_intersects_solid - // ACE: if (path.InsertType == InsertType.Placement || path.ObstructionEthereal) // ---------------------------------------------------------------- if (path.InsertType == InsertType.Placement || obj.Ethereal) { - // clearCell=true unless BuildingCheck && HitsInteriorCell (not exposed here). const bool clearCell = true; if (SphereIntersectsSolidInternal(root, resolved, sphere0, clearCell)) @@ -1380,7 +1404,6 @@ public static class BSPQuery // ---------------------------------------------------------------- // Path 2: CheckWalkable → hits_walkable - // ACE: if (path.CheckWalkable) return check_walkable(path, localSphere, scale); // ---------------------------------------------------------------- if (path.CheckWalkable) { @@ -1389,16 +1412,15 @@ public static class BSPQuery // ---------------------------------------------------------------- // Path 3: StepDown → step_sphere_down - // ACE: if (path.StepDown) return step_sphere_down(transition, localSphere, scale); // ---------------------------------------------------------------- if (path.StepDown) { - return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale); + return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale, localToWorld); } // ---------------------------------------------------------------- // Path 4: Collide → find_walkable (land on surface) - // ACE: RootNode.find_walkable(path, validPos, ref hitPoly, movement, Z, ref changed) + // ACE transforms offset and plane normal from local→global // ---------------------------------------------------------------- if (path.Collide) { @@ -1411,15 +1433,18 @@ public static class BSPQuery if (changed && hitPoly is not null) { - var offset = (validPos.Center - sphere0.Center) * scale; - path.AddOffsetToCheckPos(offset); + // ACE: var offset = LocalToGlobalVec(validPos.Center - localSphere.Center) * scale + var localOffset = validPos.Center - sphere0.Center; + var worldOffset = L2W(localOffset) * scale; + path.AddOffsetToCheckPos(worldOffset); + var worldNormal = L2W(hitPoly.Plane.Normal); collisions.SetContactPlane( - new Plane(hitPoly.Plane.Normal, hitPoly.Plane.D * scale), + new Plane(worldNormal, hitPoly.Plane.D * scale), path.CheckCellId, false); path.WalkableValid = true; - path.WalkablePlane = new Plane(hitPoly.Plane.Normal, hitPoly.Plane.D * scale); + path.WalkablePlane = new Plane(worldNormal, hitPoly.Plane.D * scale); path.WalkableAllowance = PhysicsGlobals.FloorZ; return TransitionState.Adjusted; @@ -1429,7 +1454,7 @@ public static class BSPQuery // ---------------------------------------------------------------- // Path 5: Contact — sphere_intersects_poly + step_sphere_up / slide - // ACE: if (obj.State.HasFlag(ObjectInfoState.Contact)) + // ACE transforms collision normal from local→global before step_up/slide // ---------------------------------------------------------------- if (obj.State.HasFlag(ObjectInfoState.Contact)) { @@ -1439,7 +1464,8 @@ public static class BSPQuery if (SphereIntersectsPolyInternal(root, resolved, sphere0, movement, ref hitPoly0, ref contact0)) { - return StepSphereUp(transition, hitPoly0!.Plane.Normal); + var worldNormal = L2W(hitPoly0!.Plane.Normal); + return StepSphereUp(transition, worldNormal); } if (sphere1 is not null) @@ -1450,16 +1476,14 @@ public static class BSPQuery if (SphereIntersectsPolyInternal(root, resolved, sphere1, movement, ref hitPoly1, ref contact1)) { - return SlideSphere(transition, hitPoly1!.Plane.Normal); + var worldNormal = L2W(hitPoly1!.Plane.Normal); + return SlideSphere(transition, worldNormal); } - // ACE checks hitPoly1 != null and hitPoly0 != null after the calls - // (the traversal may record a hitPoly even when pos_hits_sphere - // returned false, because the movement dot filtered it out). if (hitPoly1 is not null) - return NegPolyHitDispatch(path, hitPoly1, false); + return NegPolyHitDispatch(path, hitPoly1, false, localToWorld); if (hitPoly0 is not null) - return NegPolyHitDispatch(path, hitPoly0, true); + return NegPolyHitDispatch(path, hitPoly0, true, localToWorld); } return TransitionState.OK; @@ -1467,7 +1491,7 @@ public static class BSPQuery // ---------------------------------------------------------------- // Path 6: Default — sphere_intersects_poly → collide_with_pt / land - // ACE: RootNode.sphere_intersects_poly(localSphere, movement, ref hitPoly, ref cp) || hitPoly != null + // ACE transforms normals from local→global // ---------------------------------------------------------------- { ResolvedPolygon? hitPoly0 = null; @@ -1482,14 +1506,13 @@ public static class BSPQuery { return CollideWithPt(root, resolved, transition, sphere0, localCurrCenter, - hitPoly0!, contact0, scale); + hitPoly0!, contact0, scale, localToWorld); } - var normal = hitPoly0!.Plane.Normal; + var worldNormal = L2W(hitPoly0!.Plane.Normal); path.WalkableAllowance = PhysicsGlobals.LandingZ; - // ACE: path.SetCollide(collisionNormal) — sets Collide=true + stores normal. path.Collide = true; - collisions.SetCollisionNormal(normal); + collisions.SetCollisionNormal(worldNormal); return TransitionState.Adjusted; } @@ -1503,7 +1526,8 @@ public static class BSPQuery if (hit1 || hitPoly1 is not null) { - collisions.SetCollisionNormal(hitPoly1!.Plane.Normal); + var worldNormal = L2W(hitPoly1!.Plane.Normal); + collisions.SetCollisionNormal(worldNormal); return TransitionState.Collided; } } diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index b6a500f..2b30b34 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -745,6 +745,9 @@ public sealed class Transition var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot); // Use the retail 6-path dispatcher with pre-resolved polygons. + // Pass the object's rotation so collision responses (normals, + // offsets) are transformed from object-local back to world space. + // ACE: path.LocalSpacePos.LocalToGlobalVec() result = BSPQuery.FindCollisions( physics.BSP.Root, physics.Resolved, @@ -753,7 +756,8 @@ public sealed class Transition localSphere1, localCurrCenter, localSpaceZ, - 1.0f); // scale = 1.0 for object geometry + 1.0f, // scale = 1.0 for object geometry + obj.Rotation); // local→world rotation } else {