fix(physics): swept-sphere collision prevents penetration

Restructure FindObjCollisions to compute collision ALONG the movement
path instead of at the final position:

BSP: movement-aware SphereIntersectsPoly with front-face culling
  (dot(movement, normal) < 0). Only detects faces the sphere is
  approaching, matching retail Polygon.pos_hits_sphere.

Cylinder: quadratic ray-cylinder intersection computes parametric
  contact time t. If t < 1.0, sphere is rewound to the contact point.

Both: find the EARLIEST collision (minimum t), rewind sphere to
  contact point + small epsilon along normal, then SlideSphere.

This prevents the "walking into walls" penetration (BUG-005).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 12:23:10 +02:00
parent 3997839d1a
commit 8e1230c53b
2 changed files with 176 additions and 39 deletions

View file

@ -1057,4 +1057,89 @@ public static class BSPQuery
return SphereIntersectsPoly(node.NegNode, polygons, vertices,
sphereCenter, sphereRadius, out hitPolyId, out hitNormal);
}
/// <summary>
/// Movement-aware sphere-BSP intersection with front-face culling.
/// Only detects collisions against polygon faces the sphere is moving TOWARD
/// (dot(movement, normal) &lt; 0). This prevents detecting collisions after
/// the sphere has already penetrated past a face.
///
/// Matches retail Polygon.pos_hits_sphere behavior.
/// </summary>
public static bool SphereIntersectsPoly(
PhysicsBSPNode? node,
Dictionary<ushort, Polygon> polygons,
VertexArray vertices,
Vector3 sphereCenter,
float sphereRadius,
Vector3 movement,
out ushort hitPolyId,
out Vector3 hitNormal)
{
hitPolyId = 0;
hitNormal = Vector3.Zero;
if (node is null) return false;
// Broad phase: bounding sphere
{
float dist = Vector3.Distance(sphereCenter, node.BoundingSphere.Origin);
if (dist > sphereRadius + node.BoundingSphere.Radius + movement.Length() + 0.1f)
return false;
}
if (node.Type == BSPNodeType.Leaf)
{
foreach (var polyIdx in node.Polygons)
{
if (!polygons.TryGetValue(polyIdx, out var poly)) continue;
if (!TryGetPolyPlane(poly, vertices, out var polyPlane, out var polyVerts))
continue;
// Front-face culling: only collide if moving TOWARD this face.
// This is the critical retail behavior from Polygon.pos_hits_sphere.
if (Vector3.Dot(movement, polyPlane.Normal) >= 0f)
continue; // moving away from or parallel to face
if (CollisionPrimitives.SphereIntersectsPoly(
polyPlane, polyVerts, sphereCenter, sphereRadius, out _))
{
hitPolyId = polyIdx;
hitNormal = polyPlane.Normal;
return true;
}
// Also check at the end-of-movement position.
Vector3 endCenter = sphereCenter + movement;
if (CollisionPrimitives.SphereIntersectsPoly(
polyPlane, polyVerts, endCenter, sphereRadius, out _))
{
hitPolyId = polyIdx;
hitNormal = polyPlane.Normal;
return true;
}
}
return false;
}
// Internal node: same split logic
float splitDist = Vector3.Dot(node.SplittingPlane.Normal, sphereCenter)
+ node.SplittingPlane.D;
float reach = sphereRadius + movement.Length();
if (splitDist >= reach)
return SphereIntersectsPoly(node.PosNode, polygons, vertices,
sphereCenter, sphereRadius, movement, out hitPolyId, out hitNormal);
if (splitDist <= -reach)
return SphereIntersectsPoly(node.NegNode, polygons, vertices,
sphereCenter, sphereRadius, movement, out hitPolyId, out hitNormal);
if (SphereIntersectsPoly(node.PosNode, polygons, vertices,
sphereCenter, sphereRadius, movement, out hitPolyId, out hitNormal))
return true;
return SphereIntersectsPoly(node.NegNode, polygons, vertices,
sphereCenter, sphereRadius, movement, out hitPolyId, out hitNormal);
}
}