fix(physics): transform collision normals/offsets from local→world space

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 16:32:41 +02:00
parent 874bcc8690
commit 6b4e7569a3
2 changed files with 63 additions and 35 deletions

View file

@ -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
/// <param name="localCurrCenter">Previous center of the primary sphere in local space.</param>
/// <param name="localSpaceZ">Up vector in object-local space (usually Vector3.UnitZ).</param>
/// <param name="scale">Scale factor for the collision object.</param>
/// <param name="localToWorld">
/// 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.
/// </param>
public static TransitionState FindCollisions(
PhysicsBSPNode? root,
Dictionary<ushort, ResolvedPolygon> 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;
}
}

View file

@ -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
{