NOTE: I found a solution after fumbling around for another day, and I’ve posted the answer below. I’m leaving my original question here in its entirety, but you’ll probably only find the code at the bottom of any use.
So, I have a player object that uses a kinematic Rigidbody and a CapsuleCollider for movement, and so far I’ve been completely unable to prevent it from tunneling through my level geometry. The biggest problem cases are corners - or any place where the player can touch multiple colliders simultaneously - but quite often it’ll just fall through the floor of its own accord.
Initially, I used only CapsuleCastAll for collision detection, but when that didn’t work I added a kinematic Rigidbody component and tried SweepTestAll instead. Still no dice.
The thing is, I’m pretty sure I know what’s going wrong. As I’ve been told, the value returned by RaycastHit.distance is lossy and prone to floating-point error. This means that even after you resolve a collision, you may still be intersecting with the other collider by some infinitesimal amount, which means the next sweep test will miss and you’ll pass through it.
I tried to solve this problem by moving the player away by a tiny additional amount after resolving each collision. I started with a multiple of float.Epsilon, which is usually sufficient for this kind of thing. When that wasn’t enough, I kept increasing it until I reached a (comparatively) huge value of 0.00001f, which still can’t compensate for what looks like severe floating-point error in the sweep test.
Here’s what I have at the moment. “playerVelocity” is a class member that stores the current velocity, and “playerRigidbody” is just a cached GetComponent call.
// Calculate the intended movement and new position
Vector3 move = playerVelocity * dTime;
Vector3 newPos = playerRigidbody.position + move;
// Sweep the rigid body
RaycastHit[] allHits = playerRigidbody.SweepTestAll(move.normalized, move.magnitude);
// Resolve all collisions
foreach(RaycastHit hit in allHits)
{
// Use a small value to compensate for floating point error
float collisionEpsilon = 0.00001f;
// Calculate the penetration depth
Vector3 penetration = move - (move.normalized * hit.distance);
// Move the new position out of the collider along its surface normal
newPos -= hit.normal * Vector3.Dot(penetration, hit.normal);
// Move the new position away by a tiny amount in case of FP error
newPos += hit.normal * collisionEpsilon;
// Project the velocity onto the collision plane
playerVelocity -= hit.normal * Vector3.Dot(playerVelocity, hit.normal);
}
// Move the player
playerRigidbody.MovePosition(newPos);
I feel like there’s something really simple and stupid that I’ve missed, but I’m just not seeing it. Even a huge epsilon value like 0.1f - which causes sickening judder - doesn’t seem to be enough to keep the player collider out of tunneling distance, and eventually I always end up plummeting through the abyss anyway.
Removing my own movement code and just doing everything through Rigidbody.AddForce calls does conveniently solve the problem, but unfortunately this is a networked multiplayer game, and I need to write my own movement in order to support client-side prediction and all those other features that don’t play nice with Unity’s physics implementation.
I’m going to feel awful if I have to build my silly little game in Unreal just because of this minor shortcoming. It’d be like swatting a fruit fly with a hand grenade.
Anyone have any ideas? Am I just being really stupid and sleep-deprived? Thanks!
EDIT #1: I woke up this morning and tried a different solution, just to make sure I’m not doing something terribly dumb with my collision resolution code. I completely removed both the Rigidbody and the CapsuleCollider, and ran the same code with a fake SphereCastAll instead:
// Calculate the intended movement and new position
Vector3 move = playerVelocity * dTime;
Vector3 newPos = playerTransform.position + move;
// Sweep a fake sphere
RaycastHit[] allHits = Physics.SphereCastAll(playerTransform.position, 0.75f, move.normalized, move.magnitude);
// Resolve all collisions
foreach(RaycastHit hit in allHits)
{
// Use a small value to compensate for floating point error
float collisionEpsilon = 0.00001f;
// Calculate the penetration depth
Vector3 penetration = move - (move.normalized * hit.distance);
// Move the new position out of the collider along its surface normal
newPos -= hit.normal * Vector3.Dot(penetration, hit.normal);
// Move the new position away by a tiny amount in case of FP error
newPos += hit.normal * collisionEpsilon;
// Project the velocity onto the collision plane
playerVelocity -= hit.normal * Vector3.Dot(playerVelocity, hit.normal);
}
// Move the player
playerTransform.position = newPos;
To my surprise, this code is rock-solid. I haven’t been able to break it all morning. Which is great, but I want to use a capsule for my player if possible. So I replaced SphereCastAll with CapsuleCastAll, and once again the player started passing through walls:
// Fake capsule endpoints and radius
Vector3 point1 = playerTransform.position + (Vector3.up * 0.35f);
Vector3 point2 = playerTransform.position - (Vector3.up * 0.35f);
float radius = 0.4f;
// Sweep the fake capsule
RaycastHit[] allHits = Physics.CapsuleCastAll(point1, point2, radius, move.normalized, move.magnitude);
Out of curiosity, I went back to my original Rigidbody.SweepTestAll code, only this time I used a SphereCollider instead of a capsule. Lo and behold, it ran perfectly.
So now I know that my collision code works fine, and the values returned by SweepTestAll are indeed correct when using a sphere collider. But somehow, using a capsule instead of a sphere breaks Unity’s sweep test, and that’s where my tunneling bug is coming from.
So my question is now this: why would a sweep test with a capsule collider return such a flaky result? So far as I can tell, this is an issue within Unity itself, but if anyone knows a way to code around it I’d be grateful.
EDIT #2: Upon further testing, I’ve discovered that SweepTestAll can still tunnel with a sphere collider, but it happens far less often, and generally only in certain areas. Nonetheless, I’m running out of ideas and I’m seriously considering building this game in a different engine. There has got to be something I’m missing, but I’m coming up empty.
EDIT #3:
Here’s the code I’m currently using. I ditched SphereCastAll because I realized I may have been resolving collisions with occluded surfaces that shouldn’t have needed resolving at all, and now I’m using a distance-based sequential approach:
// Use a small value to compensate for floating point error
float collisionEpsilon = 0.00001f;
// Store the player's position and attempted movement
Vector3 pos = playerTransform.position;
Vector3 move = playerVelocity * Time.fixedDeltaTime;
// Attempt to move as long as there is still distance to travel
while(move.magnitude > collisionEpsilon)
{
// Store the result of the sphere cast
RaycastHit hit;
// If the sphere cast hits a collider...
if(Physics.SphereCast(pos, 1.0f, move.normalized, out hit, move.magnitude))
{
// ...create a new vector to move to the contact point
Vector3 contact = move.normalized * hit.distance;
// Subtract this from the remaining distance to travel
move -= contact;
// Update the target position
pos += contact;
// Move away from the surface in case of FP error
pos += hit.normal * collisionEpsilon;
// Project the remaining movement onto the surface
move -= hit.normal * Vector3.Dot(move, hit.normal);
// Project velocity onto the surface
playerVelocity -= hit.normal * Vector3.Dot(playerVelocity, hit.normal);
}
// If there's been no collision...
else
{
// ...move the full distance
pos += move;
// The move is now complete
move = Vector3.zero;
}
}
// Apply the new position
playerTransform.position = pos;
So far, this code works almost all the time, but it still tunnels on angled surfaces, and I have no clue what to do now. Any ideas are welcome at this point.