Hello guys! I am currently working on a rigidbody FPS controller for a game. I plan to release it for free when it’s finished, but I am having a few problems maybe some of you can help with.
First of all, why use a rigidbody over a character controller? Well, it just feels better. When you play a game like Halo, how do the physics feel? Jumping and moving around feels great and very natural! You feel like part of the world. How about games like CoD? Very basic. No physical interaction. Hey, that’s great and all, but what if we want a very physical feel? Then rigidbody it is!
Before embarking on creating my own rigidbody controller, I tested out the community’s ordinary character controllers and the assets that come with Unity. They worked great, but each had a few problems. Off the top of my head, a lot did not support slight air control. They either supported none, or had full speed air control. This wasn’t too big of a deal, as I could modify the code to support it. The main problem was sliding down surfaces. If I jumped off a cliff, I would fall at the speed of gravity. However, when I slid down a hill which was too steep to stand on, my character would slide much faster than gravity. Almost twice as fast. Not cool.
The first big problem I encountered with my rigidbody controller stems from the physical nature of it. For example, if you run up a ramp, you will be launched up into the air when you stop, or reach the top. Or when you run from a flat surface onto a ramp, you will be launched forwards and possibly miss the ramp entirely! So I implemented what I call “fudging”.
// fudging
if (groundedLastFrame && doJump != 2)
{
RaycastHit hit;
if (Physics.Raycast(transform.position, Vector3.down, out hit, halfPlayerHeight + (rigidbody.velocity.magnitude * Time.deltaTime), ~0)
&& Vector3.Dot(hit.normal, Vector3.up) > 0.5f)
{
rigidbody.AddForce(new Vector3(0.0f, -hit.distance, 0.0f), ForceMode.VelocityChange);
groundedLastFrame = true;
}
}
So this basically says, “if I was grounded last frame and I didn’t just jump, raycast downwards and see if there’s a surface I can stand on beneath me.” The distance of the raycast is calculated by the player’s velocity. So essentially, this is an extra downward force keeping the player on the ground. It works decently – there is some jittering when stopping on a slope, but it works nicely. It’s a bit ugly, though. Anyone have any better ideas?
Another problem: running into an angled wall which is too steep to climb causes jitter from the player bouncing up and down against it :S. This should be fixed once I finish acceleration
Anyone can take this code and do with as they please. If you make some changes, you should post them here so the code gets better for us all!
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
[RequireComponent(typeof(CapsuleCollider))]
[RequireComponent(typeof(Rigidbody))]
public class CrankyRigidBodyController : MonoBehaviour
{
[Tooltip("How fast the player moves.")]
public float MovementSpeed = 7.0f;
[Tooltip("Units per second acceleration")]
public float AccelRate = 20.0f;
[Tooltip("Units per second deceleration")]
public float DecelRate = 20.0f;
[Tooltip("Acceleration the player has in mid-air")]
public float AirborneAccel = 5.0f;
[Tooltip("The velocity applied to the player when the jump button is pressed")]
public float JumpSpeed = 7.0f;
[Tooltip("Extra units added to the player's fudge height... if you're rocketting off ramps or feeling too loosely attached to the ground, increase this. If you're being yanked down to stuff too far beneath you, lower this.")]
public float FudgeExtra = 0.5f; // extra height, can't modify this during runtime
[Tooltip("Maximum slope the player can walk up")]
public float MaximumSlope = 45.0f;
private bool grounded = false;
// temp vars
private float inputX;
private float inputY;
private Vector3 movement;
private float acceleration; // or deceleration
// keep track of falling
private bool falling;
private float fallSpeed;
// jump state var:
// 0 = hit ground since last jump, can jump if grounded = true
// 1 = jump button pressed, try to jump during fixedupdate
// 2 = jump force applied, waiting to leave the ground
// 3 = jump was successful, haven't hit the ground yet (this state is to ignore fudging)
private byte doJump;
private Vector3 groundNormal; // average normal of the ground i'm standing on
private bool touchingDynamic; // if we're touching a dynamic object, don't prevent idle sliding
private bool groundedLastFrame; // was i grounded last frame? used for fudging
private List<GameObject> collisions; // the objects i'm colliding with
private Dictionary<int, ContactPoint[]> contactPoints; // all of the collision contact points
// temporary calculations
private float halfPlayerHeight;
private float fudgeCheck;
private float bottomCapsuleSphereOrigin; // transform.position.y - this variable = the y coord for the origin of the capsule's bottom sphere
private float capsuleRadius;
void Awake()
{
movement = Vector3.zero;
grounded = false;
groundNormal = Vector3.zero;
touchingDynamic = false;
groundedLastFrame = false;
collisions = new List<GameObject>();
contactPoints = new Dictionary<int, ContactPoint[]>();
// do our calculations so we don't have to do them every frame
CapsuleCollider capsule = (CapsuleCollider)collider;
halfPlayerHeight = capsule.height * 0.5f;
fudgeCheck = halfPlayerHeight + FudgeExtra;
bottomCapsuleSphereOrigin = halfPlayerHeight - capsule.radius;
capsuleRadius = capsule.radius;
PhysicMaterial controllerMat = new PhysicMaterial();
controllerMat.bounciness = 0.0f;
controllerMat.dynamicFriction = 0.0f;
controllerMat.staticFriction = 0.0f;
controllerMat.bounceCombine = PhysicMaterialCombine.Minimum;
controllerMat.frictionCombine = PhysicMaterialCombine.Minimum;
capsule.material = controllerMat;
// just in case this wasn't set in the inspector
rigidbody.freezeRotation = true;
}
void FixedUpdate()
{
// check if we're grounded
RaycastHit hit;
grounded = false;
groundNormal = Vector3.zero;
foreach (ContactPoint[] contacts in contactPoints.Values)
for (int i = 0; i < contacts.Length; i++)
if (contacts[i].point.y <= rigidbody.position.y - bottomCapsuleSphereOrigin && Physics.Raycast(contacts[i].point + Vector3.up, Vector3.down, out hit, 1.1f, ~0) && Vector3.Angle(hit.normal, Vector3.up) <= MaximumSlope)
{
grounded = true;
groundNormal += hit.normal;
}
if (grounded)
{
// average the summed normals
groundNormal.Normalize();
if (doJump == 3)
doJump = 0;
}
else if (doJump == 2)
doJump = 3;
// get player input
inputX = Input.GetAxis("Horizontal");
inputY = Input.GetAxis("Vertical");
// limit the length to 1.0f
float length = Mathf.Sqrt(inputX * inputX + inputY * inputY);
if (length > 1.0f)
{
inputX /= length;
inputY /= length;
}
if (grounded && doJump != 3)
{
if (falling)
{
// we just landed from a fall
falling = false;
this.DoFallDamage(Mathf.Abs(fallSpeed));
}
// align our movement vectors with the ground normal (ground normal = up)
Vector3 newForward = transform.forward;
Vector3.OrthoNormalize(ref groundNormal, ref newForward);
Vector3 targetSpeed = Vector3.Cross(groundNormal, newForward) * inputX * MovementSpeed + newForward * inputY * MovementSpeed;
length = targetSpeed.magnitude;
float difference = length - rigidbody.velocity.magnitude;
// avoid divide by zero
if (Mathf.Approximately(difference, 0.0f))
movement = Vector3.zero;
else
{
// determine if we should accelerate or decelerate
if (difference > 0.0f)
acceleration = Mathf.Min(AccelRate * Time.deltaTime, difference);
else
acceleration = Mathf.Max(-DecelRate * Time.deltaTime, difference);
// normalize the difference vector and store it in movement
difference = 1.0f / difference;
movement = new Vector3((targetSpeed.x - rigidbody.velocity.x) * difference * acceleration, (targetSpeed.y - rigidbody.velocity.y) * difference * acceleration, (targetSpeed.z - rigidbody.velocity.z) * difference * acceleration);
}
if (doJump == 1)
{
// jump button was pressed, do jump
movement.y = JumpSpeed - rigidbody.velocity.y;
doJump = 2;
}
else if (!touchingDynamic && Mathf.Approximately(inputX + inputY, 0.0f) && doJump < 2)
// prevent sliding by countering gravity... this may be dangerous
movement.y -= Physics.gravity.y * Time.deltaTime;
rigidbody.AddForce(new Vector3(movement.x, movement.y, movement.z), ForceMode.VelocityChange);
groundedLastFrame = true;
}
else
{
// not grounded, so check if we need to fudge and do air accel
// fudging
if (groundedLastFrame && doJump != 3 && !falling)
{
// see if there's a surface we can stand on beneath us within fudgeCheck range
if (Physics.Raycast(transform.position, Vector3.down, out hit, fudgeCheck + (rigidbody.velocity.magnitude * Time.deltaTime), ~0) && Vector3.Angle(hit.normal, Vector3.up) <= MaximumSlope)
{
groundedLastFrame = true;
// catches jump attempts that would have been missed if we weren't fudging
if (doJump == 1)
{
movement.y += JumpSpeed;
doJump = 2;
return;
}
// we can't go straight down, so do another raycast for the exact distance towards the surface
// i tried doing exsec and excsc to avoid doing another raycast, but my math sucks and it failed horribly
// if anyone else knows a reasonable way to implement a simple trig function to bypass this raycast, please contribute to the thead!
if (Physics.Raycast(new Vector3(transform.position.x, transform.position.y - bottomCapsuleSphereOrigin, transform.position.z), -hit.normal, out hit, hit.distance, ~0))
{
rigidbody.AddForce(hit.normal * -hit.distance, ForceMode.VelocityChange);
return; // skip air accel because we should be grounded
}
}
}
// if we're here, we're not fudging so we're defintiely airborne
// thus, if falling isn't set, set it
if (!falling)
falling = true;
fallSpeed = rigidbody.velocity.y;
// air accel
if (!Mathf.Approximately(inputX + inputY, 0.0f))
{
// note, this will probably malfunction if you set the air accel too high... this code should be rewritten if you intend to do so
// get direction vector
movement = transform.TransformDirection(new Vector3(inputX * AirborneAccel * Time.deltaTime, 0.0f, inputY * AirborneAccel * Time.deltaTime));
// add up our accel to the current velocity to check if it's too fast
float a = movement.x + rigidbody.velocity.x;
float b = movement.z + rigidbody.velocity.z;
// check if our new velocity will be too fast
length = Mathf.Sqrt(a * a + b * b);
if (length > 0.0f)
{
if (length > MovementSpeed)
{
// normalize the new movement vector
length = 1.0f / Mathf.Sqrt(movement.x * movement.x + movement.z * movement.z);
movement.x *= length;
movement.z *= length;
// normalize our current velocity (before accel)
length = 1.0f / Mathf.Sqrt(rigidbody.velocity.x * rigidbody.velocity.x + rigidbody.velocity.z * rigidbody.velocity.z);
Vector3 rigidbodyDirection = new Vector3(rigidbody.velocity.x * length, 0.0f, rigidbody.velocity.z * length);
// dot product of accel unit vector and velocity unit vector, clamped above 0 and inverted (1-x)
length = (1.0f - Mathf.Max(movement.x * rigidbodyDirection.x + movement.z * rigidbodyDirection.z, 0.0f)) * AirborneAccel * Time.deltaTime;
movement.x *= length;
movement.z *= length;
}
// and finally, add our force
rigidbody.AddForce(new Vector3(movement.x, 0.0f, movement.z), ForceMode.VelocityChange);
}
}
groundedLastFrame = false;
}
}
void Update()
{
// check for input here
if (groundedLastFrame && Input.GetButtonDown("Jump"))
doJump = 1;
}
void DoFallDamage(float fallSpeed) // fallSpeed will be positive
{
// do your fall logic here using fallSpeed to determine how hard we hit the ground
Debug.Log("Hit the ground at " + fallSpeed.ToString() + " units per second");
}
void OnCollisionEnter(Collision collision)
{
// keep track of collision objects and contact points
collisions.Add(collision.gameObject);
contactPoints.Add(collision.gameObject.GetInstanceID(), collision.contacts);
// check if this object is dynamic
if (!collision.gameObject.isStatic)
touchingDynamic = true;
// reset the jump state if able
if (doJump == 3)
doJump = 0;
}
void OnCollisionStay(Collision collision)
{
// update contact points
contactPoints[collision.gameObject.GetInstanceID()] = collision.contacts;
}
void OnCollisionExit(Collision collision)
{
touchingDynamic = false;
// remove this collision and its associated contact points from the list
// don't break from the list once we find it because we might somehow have duplicate entries, and we need to recheck groundedOnDynamic anyways
for (int i = 0; i < collisions.Count; i++)
{
if (collisions[i] == collision.gameObject)
collisions.RemoveAt(i--);
else if (!collisions[i].isStatic)
touchingDynamic = true;
}
contactPoints.Remove(collision.gameObject.GetInstanceID());
}
public bool Grounded
{
get
{
return grounded;
}
}
public bool Falling
{
get
{
return falling;
}
}
public float FallSpeed
{
get
{
return fallSpeed;
}
}
public Vector3 GroundNormal
{
get
{
return groundNormal;
}
}
}