There were tears, there were shenanigans. By the end of it, there was a functional end product... but with more experience, it's time to look back on it with a more critical eye. For one thing, wouldn't it be amazing if you could just use standard Unity Rigidbody and Colliders to solve your character movement?
It is, in fact, possible, and without all the shaking and falling through mesh colliders that - at least for me - plagued my earlier attempts at solving with that method in the past.
Read on!
This is going to be about one single subtopic of the Character Controller theory; easily the most technical and - depending on how you approach it, most complex. Collision detection and resolution.
Now, obviously, we don't want characters running through walls or falling through floors. But the question of how to detect those walls and floors, and how to keep out of them, is a tricky one. In Unity, you're hampered mostly by the limitations of the Physics API, and in the end are left with two approaches:
1. 'Manual' collision detection and resolution.
2. Engine-based collision detection and resolution.
Where the 'Manual' approach is to collect all the local colliders yourself using OverlapSphere (or now, OverlapBox) and detect and resolve those collisions with your own code. This is the approach I applied previously and the approach taken by Ross's SuperCharacterController.
I make no joke, this approach is hard. Good for learning the mathematics behind intersections and geometry, but still hard. There is no built-in library for 'what is the closest point on this sphere/capsule/mesh/terrain/box', you will have to write them yourself. The mesh collision resolution in particular is a tricky one - in my previous article I made the foolishly inexperienced decision to try and resolve everything in world space, which meant transforming all the mesh's vertices into world space and- yeah (do your collision maths in the collider's local space, folks; there's so fewer transform operations).
But more than that, it demands you can read the data of any collision mesh from code. One mayor performance optimisation you can do in Unity is to not make your models read/writeable. You can see the problem here.
The alternative approach, and the one I suspect most people have tried, stared at in horror and desperately attempted something else with (I know I sure did), is to try and co-opt the Unity Rigidbody component to do the collision detection/resolution for you. And it can, as long as it is correctly set up, and your code interacts with it correctly.
The first thing is to do with the settings on your Rigidbody. Speaking generally, you'll probably want to disable rotation on all three axes (you can still rotate your character from code; this will just prevent the rigidbody trying to rotate them in response to collisions). The other things to note are the settings for Interpolation and Collision Detection. I have mine set to 'Interpolate' and 'Continuous Dynamic'.
Interpolation is pretty important because, as per the API documentation, it makes the rigidbody interpolate its position between FixedUpdates; if you've been having problems with 'jittery' characters, this will help you. Collision Detection I admittedly I am not completely certain is necessary, but a character controller seems important enough to warrant it; to explain the settings a little, 'Continuous' detection is more expensive, but helps prevent fast objects from passing through colliders, which has long been an issue plaguing my own attempts at a rigidbody character controller.
This feature is of particular importance where MeshColliders are involved, because a mesh is just an infinitely thin surface; there is no 'inside' or 'outside', there is only 'are colliding with the surface?'. They're the easiest to accidentally miss collisions on... and they're quite probably the most common collider used for level geometry. So it's a matter of some importance.
The other thing to handle correctly is how you change your rigidbody's position from code. This more than the Rigidbody's actual settings proved to be the real clincher for getting a rigidbody character controller to work; for one thing, the API is actually misleading.
If, like me, you went through the Rigidbody's scripting API documentation, you probably concluded that the best way to move your character controller was to calculate it's next position, then move the rigidbody with rigidbody.MovePosition(). A little something like this:
void FixedUpdate()
{
Vector3 input = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
Vector3 nextPosition = rigidbody.position + transform.TransformDirection(input) * speed * Time.deltaTime;
rigidbody.MovePosition(nextPosition);
}
In most cases you should not modify the velocity directly, as this can result in unrealistic behaviour. Don't set the velocity of an object every physics step, this will lead to unrealistic physics simulation. A typical example where you would change the velocity is when jumping in a first person shooter, because you want an immediate change in velocity.
As it transpires, using SetPosition does just that; it sets the position... and then lets the physics system panic, flail and try to handle it. You pass through walls, you visibly push 'into' colliders... it's a mess. It makes no attempt to check if it can move to that position. So what's the solution?
...As it happens, the solution is to ignore what the API says and use rigidbody.velocity instead.
void FixedUpdate()
{
Vector3 input = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
Vector3 delta = transform.TransformDirection(input) * speed;
rigidbody.velocity = delta;
}
Vector3 worldVelocity;
Vector3 lastPosition;
void Start()
{
lastPosition = transform.position; //this is necessary to prevent our starting velocity being 'from 0,0,0 to transform.position'
}
void FixedUpdate()
{
//verlet integration
worldVelocity = (rigidbody.position - lastPosition) / Time.deltaTime;
lastPosition = rigidbody.position;
//how much of our velocity is in line with the direction of gravity, ie 'how fast are we falling'
Vector3 fallingVelocity = Vector3.Project(worldVelocity, Physics.gravity);
Vector3 input = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
Vector3 delta = transform.TransformDirection(input) * speed;
delta += fallingVelocity + Physics.gravity * Time.deltaTime;
rigidbody.velocity = delta;
}
Finally, I'll address some common problems and 'gotchas':
My character is frozen in mid-air! The rigidbody won't move!
Most likely, this is one of two things: either you set your rigidbody to Kinematic, which you shouldn't, or you have an Animator component with root motion enabled. A Kinematic rigidbody will ignore its velocity and an Animator with root motion will actually overwrite it to match the root velocity of your current animation.
Ensure your rigidbody is not Kinematic and either disable Animator root motion or use OnAnimatorMove to combine it with your own calculated velocity. Admittedly my own character controller hasn't reached the stage where this is necessary yet (owing to a lack of animations needing root motion, to be specific), so I may have to get back to you on this one.
My character flies off into the stratosphere / moves at a crawl!
Time shenanigans! This is likely a result of not multiplying / multiplying too often by Time.deltaTime. It's actually a question of units. In short:
Velocity = meters per second
Acceleration = meters per second per second
Each 'per second' can be thought of as a multiplication by Time.deltaTime. All Time.deltaTime is is 'how long this frame is'; at 30 FPS your dT is 1/30 - somewhere around 0.03f - and typical values of Time.deltaTime are even smaller than that. So if you want to move 5 units per second, for a single frame update that's 5 * Time.deltaTime.
Where things get tricky is knowing when a value is already relative to deltaTime and when it is expected to be. rigibody.velocity for example takes a vector that is not relative to deltaTime; it's a true velocity value relative to 1 second. Hence, you'll notice, the Time.deltaTime gets dropped off the input vector calculation compared to setting the position every frame.
Accelerations meanwhile still require a single multiplication of Time.deltaTime; they're the rates of change in a given velocity for this frame. Hence, to apply acceleration under gravity, the fallingVelocity gets the added Physics.gravity * Time.deltaTime.
If you're moving far too quickly, you're either missing a multiplication by Time.deltaTime or possibly dividing by it when you should not. If you're moving far too slowly, you're applying Time.deltaTime more often than is needed.
My character flies off slopes / doesn't hog the ground correctly!
You'll definitely want some robust form of ground detection for your character controller as well. Assuming your method of ground detection can return information about the ground you're on (typically, via a RaycastHit structure), you can modify your velocity and input to hog the ground.
My own character controller, in its current iteration, looks like this:
using UnityEngine;
using System.Collections;
public class Mover : MonoBehaviour {
public PawnAnimator animator;
MoveInput input;
new Rigidbody rigidbody;
GroundTest groundTest;
public float speed = 1;
public Vector3 worldVelocity { get; private set; }
public Vector3 localVelocity { get; private set; }
Vector3 lastPosition;
void Awake()
{
input = GetComponent<MoveInput>();
rigidbody = GetComponent<Rigidbody>();
groundTest = GetComponent<GroundTest>();
animator = GetComponentInParent<PawnAnimator>();
}
void Start()
{
lastPosition = transform.position;
targetRotation = transform.rotation;
}
void UpdateVelocity()
{
worldVelocity = (transform.position - lastPosition) / Time.deltaTime;
lastPosition = transform.position;
localVelocity = transform.InverseTransformDirection(worldVelocity);
}
Vector3 moveDelta;
void ClampToGround()
{
Vector3 dir = groundTest.groundHit.point - transform.position;
dir = Vector3.Project(dir, transform.up);
moveDelta += dir;
}
void Move()
{
if (groundTest.isGrounded)
{
Vector3 forward = Vector3.ProjectOnPlane(transform.forward, groundTest.groundNormal).normalized * input.localInput.z * input.localInput.z * speed;
moveDelta = forward;
Vector3 gravity = Physics.gravity * Time.deltaTime;
moveDelta += gravity;
ClampToGround();
}
else
{
moveDelta = input.worldInput * speed;
Vector3 gravity = Vector3.Project(worldVelocity, Physics.gravity) + Physics.gravity * Time.deltaTime;
moveDelta += gravity;
}
}
Quaternion targetRotation;
public float turnRate = 100;
public float turningAngle;
void Turn()
{
if (input.hasInput) targetRotation = Quaternion.LookRotation(input.worldInput, transform.up);
turningAngle = Quaternion.Angle(rigidbody.rotation, targetRotation);
rigidbody.rotation = Quaternion.RotateTowards(rigidbody.rotation, targetRotation, Time.deltaTime*turnRate);
}
void FixedUpdate()
{
moveDelta = Vector3.zero;
UpdateVelocity();
groundTest.TestGround();
Move();
Turn();
rigidbody.velocity = moveDelta;
DebugEX.DrawArrow(rigidbody.position, rigidbody.position + rigidbody.velocity, 0.1f, Color.magenta);
if (animator) AnimationUpdate();
}
void AnimationUpdate()
{
animator.currentController.SetFloat("velocityX", localVelocity.x);
animator.currentController.SetFloat("velocityY", localVelocity.y);
animator.currentController.SetFloat("velocityZ", localVelocity.z);
animator.currentController.SetBool("isGrounded", groundTest.isGrounded);
animator.currentController.SetBool("isMoving", input.hasInput && (localVelocity.x+localVelocity.z) > 0.1f);
}
}
Hope this helps!