So as a bunch of people have noticed, the stock Character Controller in Unity has a number of flaws. Erik Ross has done an excellent series of posts on the subject, to be found here, which served as a very helpful reference for implementing my own.
I could waffle on about it, but naturally others have already put it best, from the excellent Mr. Ross to the Unity Manual itself. A Character Controller is, essentially, a custom collider intended for characters, and the implementation of their character physics. Which has a tendency to deviate from real physics, by like, a lot. Unless you're playing QWOP of course.
A Character Controller needs to meet a variety of criteria:
It must respond to input and collisions (usually, this involves sliding along walls when pushed against them, rather than stopping completely).
Slope limits / step heights are a very common feature - allowing the controller to move up on small ledges (aka 'stairs') and preventing people walking up cliffsides.
Have a very robust grounded/not-grounded detection. Nobody wants to suddenly flicker in and out of their falling state on solid ground.
More than anything, it must prevent the player from reaching places they're not supposed to, such as getting outside of the level. Whilst a part of that is sensible collider placement and level construction, a character controller that can phase or break into other colliders is not remotely doing its job.
Any other features become a lot more dependent on what gameplay it is exactly you're seeking to build, but those are the basics.
Why a Custom Character Controller?
Put simply, there are number of limitations to the stock Controller. As Ross notes, it has none of the nicer features such as ground hugging, and I've found it's isGrounded property to be extremely unreliable a lot of the time. Biggest flaw of all though, from my perspective, is that it is axially locked to the y up axis. Your character has to always be in that orientation; it does not support being rotated or operating on any other axis. Given the gameplay I have in mind (think the level design of Psychonauts, where 'up' and 'down' become impressively arbitrary) requires that not to be the case... well, you can see where there might be a bit of a problem here.
Collision Handling
The core to any Controller is how it detects and handles collisions. Without this part, it really can't do anything.
Mr. Ross covers the basic theory on his blog; I'm mostly here to discuss my own implementation and some of the flaws I ran into in the process.
The core problem is that Unity's Physics API is somewhat... hamstrung. The 'Check' functions will only return a hilariously useless 'yes' or 'no' answer to if there is a collision, with no other information. The Cast functions have a habit of not registering collisions with anything at all at times, CapsuleCast is notoriously broken (if the end point is above the start point, it will fail completely for no adequate reason) and all of the functions do not check for a collision at the origin, meaning they will miss any collider their test starts inside. Rather bad, for catching those moments when your player is gallivanting around inside a wall. There is also a mysterious lack of any 'Sweep/Test Box' functions, which confuses me no end. There aren't even any AABB tests...
Ultimately, the only way around it was to bite the bullet and start writing my own collision detection algorithms. The first task in any collision detection is finding out what colliders are close by: fortunately the API does have a function for this in OverlapSphere. This will return an array of all the colliders intersecting the given sphere... and is the only function that will do that, meaning you're stuck with a sphere. In my implementation, I simply estimate a 'best guess' radius from the controllers height/radius parameters, where I want to move and my current velocity, to grab all the local colliders I want to test against at the start of my collisions resolution phase. Making this sphere too big will result in testing against more colliders than necessary, but making it too small could result in missed collisions: bad. Ultimately, I erred on the side of bigger, simply because the number of colliders close to a given point at any given time isn't likely to be all that high, and we can filter out unwanted ones (for example, small physics debris) simply using layermasks anyway.
void CollectLocalColliders(){
//guess where we'll probably be next frame and get the middle
Vector3 sphereOrigin = transform.position + deltaMovement + velocity*Time.deltaTime;
sphereOrigin += transform.up * height * 0.5f;
//we basically 'guess' how much we need to grab wildly
float sphereRadius = height*0.5f + skinDepth + velocity.magnitude *Time.deltaTime;
localColliders = new List<Collider>(Physics.OverlapSphere(sphereOrigin, sphereRadius, mask));
}
Fortunately, testing for intersections with Spheres is a relatively simple thing: most geometric primitives have an easy way to find the closest point on their surface to a given point (the sphere origin), and from there it's simply a matter of checking if the distance from the sphere origin to the closest point on the surface of the primitive is less than the radius of the sphere (though check if the sphere's origin is inside the primitive before declaring there is no collision, or you'll find the colliders will behave as though they're 'hollow'). Other 'gotchas' include failing to account for the scaling of the collider being tested against, and the intersections with MeshColliders.
With this in mind, I used the 'stacked spheres' representation for my controller, as opposed to a capsule (which to be fair is just a 'closest point on the collider's surface to a line' rather than 'to a point', but this is what I started with and it hasn't proved broken yet...), to keep things simple. It supports any number of spheres, heights and radii:
public static bool SphereIntersect(MeshCollider m, Vector3 origin, float radius, out Vector3 intersection, out Vector3 normal, out bool isInside){
//first, we do a quick check to see if the sphere is actually within the mesh's bounds
float sqrRadius = radius*radius;
intersection = normal = Vector3.zero;
isInside = false; //we can't test for that after all
if(!m.bounds.Contains(origin)){
Vector3 cp = m.ClosestPointOnBounds(origin);
if((cp-origin).sqrMagnitude > sqrRadius) return false;
}
Transform mT = m.transform;
//following that, we test if any of the vertices of the mesh passing through our sphere (and put them in world space whilst we're there)
Vector3[] verts = m.sharedMesh.vertices;
for(int i = 0; i < verts.Length; i++){
verts[i] = mT.TransformPoint(verts[i]);
if(TestInSphere(verts[i], origin, radius)) {
normal = mT.TransformDirection(m.sharedMesh.normals[i]);
intersection = verts[i];
return true;
}
}
//finally, the triangle tests... *shudder*
int[] tris = m.sharedMesh.triangles;
int triCount = tris.Length / 3;
for(int i = 0; i < triCount; i++){
Vector3 a, b, c;
a = verts[tris[(i*3)]];
b = verts[tris[(i*3) + 1]];
c = verts[tris[(i*3) + 2]];
Vector3 nrm = GetPlaneNormalFromPoints(a,b,c);
Vector3 cp = ClosestPointOnPlane(a, nrm, origin);
if(!TestInSphere(cp, origin, radius)) continue;
intersection = cp;
normal = nrm;
if(PointLiesInTriangle(cp, a,b,c)) {
return true;
}
//edge intersection tests
if(SphereIntersectLine(a,b,origin,radius, out intersection)) return true;
if(SphereIntersectLine(b,c,origin,radius, out intersection)) return true;
if(SphereIntersectLine(a,c,origin,radius, out intersection)) return true;
}
return false;
}
public static bool SphereIntersectLine(Vector3 l0, Vector3 l1, Vector3 sOrigin, float sRadius, out Vector3 cp){
cp = ClosestPointOnLine(l0, l1, sOrigin, true);
return TestInSphere(cp, sOrigin, sRadius);
}
public static bool SphereIntersectLine(Vector3 l0, Vector3 l1, Vector3 sOrigin, float sRadius){
Vector3 cpLine = ClosestPointOnLine(l0, l1, sOrigin, true);
return TestInSphere(cpLine, sOrigin, sRadius);
}
public static bool TestInSphere(Vector3 p, Vector3 origin, float radius){
return (p-origin).sqrMagnitude < radius*radius;
}
Another 'gotcha' I ran right into is in determining the direction to push the controller to get 'out' of the collision. My initial, naive approach was to go for the vector from the point of intersection to the origin of the sphere, but this has a snag. Given we're using multiple stacked spheres as our colliding body, it then becomes very easy for the spheres to start disagreeing about the correct direction. Resulting in things like this:
At current, I now have a character controller that will happily move around the scene, not get stuck in things and generally behave as I want it to. Hurray!
It's by no means finished yet (or I'd have posted up the package); there are a few other features it needs to support first (most notably: it has a habit of trying to move 'up' sheer walls because the contact normals are elevated slightly resulting in jittering between grounded and non-grounded), but it works with a minimum of issues, with all the flaws I'd caught so far fixed. It is, in other words, at the stage where I can add on more features.
Testing with arbitrary axis have so far gone untried, simply because I so busy solving all the collisions problems first.
Wish me luck!