Platformer raycast collision detection

I'm using Unity to create a 2D platformer. For collision detection, I'm using raycasting to detect any possible collisions with specific collision layers, and adjusting the desired delta x & y values as appropriate.

Here is my collision detection class.

public class RaycastCollisionDetection : IEntityCollisionDetection {
private BoxCollider _collider;
private Rect _collisionRect;
private GameObject _entityGo;

public bool OnGround { get; set; }
public bool SideCollision { get; set; }
public bool PlayerCollision { get; set; }

public void Init(GameObject entityGo) {
_collider = entityGo.GetComponent<BoxCollider>();
_entityGo = entityGo;
}

public Vector3 Move(Vector3 moveAmount, GameObject entityGo, float skin) {
float deltaX = moveAmount.x;
float deltaY = moveAmount.y;
Vector3 entityPosition = entityGo.transform.position;
// Resolve any possible collisions below and above the entity.
deltaY = YAxisCollisions(deltaY, Mathf.Sign(deltaX), entityPosition, skin);
// Resolve any possible collisions left and right of the entity.
// Check if our deltaX value is 0 to avoid unnecessary collision detection.
if (deltaX != 0) {
deltaX = XAxisCollisions(deltaX, entityPosition);
}
Vector3 finalTransform = new Vector2(deltaX, deltaY);
return finalTransform;
}

private float XAxisCollisions(float deltaX, Vector3 entityPosition) {
SideCollision = false;
PlayerCollision = false;
// It's VERY important that the entity's collider doesn't change
// shape during the game. This will cause irregular raycast hits
// and most likely cause things to go through layers.
// Ensure sprites use a fixed collider size for all frames.
_collisionRect = GetNewCollisionRect();
// Increase this value if you want the rays to start and end
// outside of the entity's collider bounds.
float margin = 0.0f;
int numOfRays = 4;
Vector3 rayStartPoint = new Vector3(_collisionRect.center.x,
_collisionRect.yMin + margin, entityPosition.z);
Vector3 rayEndPoint = new Vector3(_collisionRect.center.x,
_collisionRect.yMax - margin, entityPosition.z);
float distance = (_collisionRect.width / 2) + Mathf.Abs(deltaX);

for (int i = 0; i < numOfRays; ++i) {
float lerpAmount = (float) i / ((float) numOfRays - 1);
Vector3 origin = Vector3.Lerp(rayStartPoint, rayEndPoint, lerpAmount);
Ray ray = new Ray(origin, new Vector2(Mathf.Sign(deltaX), 0));
//Debug.DrawRay(ray.origin, ray.direction, Color.white);
RaycastHit hit;
// Bit shift the layers to tell Unity to NOT ignore them.
if (Physics.Raycast(ray, out hit, distance, 1 << _collisionMask) ||
Physics.Raycast(ray, out hit, distance, 1 << _playerMask)) {
PlayerCollision = true;
}
//Debug.DrawRay(ray.origin, ray.direction, Color.yellow);
deltaX = 0;
SideCollision = true;
break;
}
}

return deltaX;
}

private float YAxisCollisions(float deltaY, float dirX, Vector3 entityPosition, float skin) {
OnGround = false;
// It's VERY important that the entity's collider doesn't change
// shape during the game. This will cause irregular raycast hits
// and most likely cause things to go through layers.
// Ensure sprites use a fixed collider size for all frames.
_collisionRect = GetNewCollisionRect();
// Increase this value if you want the rays to start and end
// outside of the entity's collider bounds.
float margin = 0.0f;
int numOfRays = 4;
Vector3 rayStartPoint = new Vector3(_collisionRect.xMin + margin,
_collisionRect.center.y, entityPosition.z);
Vector3 rayEndPoint = new Vector3(_collisionRect.xMax - margin,
_collisionRect.center.y, entityPosition.z);
float distance = (_collisionRect.height / 2) + Mathf.Abs(deltaY);

for (int i = 0; i < numOfRays; ++i) {
float lerpAmount = (float) i / ((float) numOfRays - 1);
// If we are facing left, start the rays on the left side,
// else start the ray rays on the right side.
// This will help ensure precise castings on the corners.
Vector3 origin = dirX == -1
? Vector3.Lerp(rayStartPoint, rayEndPoint, lerpAmount)
: Vector3.Lerp(rayEndPoint, rayStartPoint, lerpAmount);
Ray ray = new Ray(origin, new Vector2(0, Mathf.Sign(deltaY)));
//Debug.DrawRay(ray.origin, ray.direction, Color.white);
RaycastHit hit;
// Bit shift the layers to tell Unity to NOT ignore them.
if (Physics.Raycast(ray, out hit, distance, 1 << _collisionMask) ||
Physics.Raycast(ray, out hit, distance, 1 << _playerMask)) {
Debug.DrawRay(ray.origin, ray.direction, Color.yellow);
OnGround = true;
deltaY = 0;
break;
}
}

return deltaY;
}

private Rect GetNewCollisionRect() {
return new Rect(
_collider.bounds.min.x,
_collider.bounds.min.y,
_collider.bounds.size.x,
_collider.bounds.size.y);
}
}


This works very well except for the y-axis collisions in YAxisCollisions. The origin of my ray is the center of my sprite. The direction of the ray is the direction the player is moving (up or down). The distance of the ray is half the height of the collision rectangle + the desired movement speed along the y-axis. As a result, collision is detected slightly above the actual collision layer.

Here's a video clip showing the behavior. YouTube: Overtime Y-Axis Collision Detection Bug

As you can see, collision is detected as the player is moving downward. The sprite stops its y movement a bit earlier then I would like (which makes sense since the distance of the ray is the desired movement amount of the player, not it's actual current position).

What I would like is for the collision to be detected at a much closer distance between the sprite and collision layer, but everything I've tried introduces other bugs; mainly the player can easily fall through the collision layers under the right circumstances. I'm sort of at wit's end on how to get the y-axis collision detection more precise.

Before you ask, yes, this is all being called in FixedUpdate :)

It looks like this is caused by the deltaY = 0; inside YAxisCollisions. This stops the sprite from moving downward at all, rather than allowing it to move exactly to the impact point.

To stop the sprite at the collision point, you'll need to respond to the information contained in the raycast hit point hit. This will contain the distance from the ray's origin to the point of impact. There are a couple of ways to do the math, but basically the Y value of the hit is

_collisionRect.center.y + hit.distance * ray.direction.y


You want move the sprite to place either the bottom or top edge at that value, so deltaY needs to be the difference between that hit y value and the current edge y:

deltaY = _collisionRect.center.y + hit.distance * ray.direction.y - _collisionRect.yMin;


or

deltaY = _collisionRect.center.y + hit.distance * ray.direction.y - _collisionRect.yMax;


Tracing the variables back to their sources will show that you can factor out the actual position of the collision rectangle and get this:

deltaY = (hit.distance - collisionRect.height / 2) * ray.direction.y;


You may want to do the same thing for the X, depending how you want the character to behave when colliding with walls.

(Caveats: Code not tested, I don't necessarily know what I'm talking about, etc.)

• Ah yes, this makes complete sense. The final solution ends up being float y = Mathf.Sign(deltaY) == -1 ? _collisionRect.yMin : _collisionRect.yMax; // Give a small amount of skin space to prevent snagging. float skinSpace = 0.005f; deltaY = (_collisionRect.center.y + hit.distance * ray.direction.y - y) + skinSpace; Mar 3 '14 at 19:35
• Oops, I didn't fully consider upward movement! Also, there's a further simplification you can make. I'll edit my answer. Mar 3 '14 at 19:47
• Yep, and I added a small amount of "skin space" between the layer and sprite to give a small space to prevent some snagging. Cool! Look forward to it. Mar 3 '14 at 19:48