- Home /
FIXED: How can I stop a collider from tunneling?
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.
A long shot alongside everyone else's long shots: can you tweak the Skin Width on the Character Controller?
If you are using character controller and rigidbody, try turning off rigidbody gravity and making it kinematic.
Nope, not using a CharacterController, I'm afraid. Just a kinematic Rigidbody component so I can do everything myself.
Answer by Burning-North · May 21, 2014 at 11:19 PM
BOOYAH.
I managed to solve the problem with a little trial-and-error this afternoon, and I figured it would be best to post an answer to my own question instead of just deleting it. This solution goes back to the fake sphere cast I used earlier, but it's now far more robust and I haven't been able to break it after several hours of testing. As always, I'm sure there will be edge cases where it falls apart, but for my needs this is sufficient:
// Use a small value to compensate for floating point error
float collisionError = 0.001f;
// Calculate the desired movement vector
Vector3 move = playerVelocity * dTime;
// Store the result of the collider cast
RaycastHit hit;
// Check continuously until the collision is resolved
while(Physics.SphereCast(playerTransform.position, playerHitRadius,
move.normalized, out hit, move.magnitude))
{
// Calculate the vector toward the contact point
Vector3 contact = move.normalized * hit.distance;
// Calculate the penetration depth
Vector3 penetration = move - contact;
// Project the penetration vector onto the surface
penetration -= hit.normal * Vector3.Dot(penetration, hit.normal);
// Build the new movement vector
move = contact + penetration;
// Project the velocity onto the surface
playerVelocity -= hit.normal * Vector3.Dot(playerVelocity, hit.normal);
// Push the movement and velocity vectors slightly away from the surface
move += hit.normal * collisionError;
playerVelocity += hit.normal * collisionError;
}
// Update the player's position
playerTransform.position += move;
It's not a very elegant solution, I'll admit. Especially in the last lines of the while() loop, where I'm deflecting the movement and velocity vectors away from the surface by a tiny amount to compensate for floating point error. This is necessary to prevent tunneling, but after the adjustment the vectors are no longer colinear. For my game this doesn't matter, but it'll likely cause issues with more realistic simulations.
Theoretically, this technique should work with capsules and/or Rigidbody.SweepTest(), but so far I've still encountered bugs using those versions. Frankly, my game works fine with just sphere collisions anyway, so I'll probably leave it at that.
Many thanks to everyone who's offered their thoughts (including those on Twitter)!
EDIT #1: Haha, there is an edge case that breaks my solution! However, it only happens when the player object falls into a V-shaped groove between two horizontal colliders. Since this only seems to happen on horizontal grooves and not in the corners of rooms, I can live with it. My level design isn't likely to have many of those, and if it does I'm sure I'll be able to duct-tape my way out of it. :)
Answer by torsigurdson · Sep 14, 2018 at 04:04 PM
And you set the rigidbody to continuous collision detection? Otherwise you might want to changed the fixed time step in the TimeManager.
Your answer
Follow this Question
Related Questions
Making a bubble level (not a game but work tool) 1 Answer
Keep Horizontal Momentum after Jump 2 Answers
Player is jittery when it collides with wall (C# 3D) 0 Answers
Objects not colliding twice 0 Answers