Creating A Unity ECS Character Controller

Welcome to part two in the OpenFall series, In this one as the name suggests, we are going to create an ECS version of the default unity character controller for our player. This series is for VR but if you clicked on this article expecting a non-VR solution have no fear, we are going to build the base system and then add in VR compatibility so you can follow along until that point. We are going to basically build a clone of the CameraRig script minus the step-up feature as we are going to add a more polished version of that in a later article.

Theory

I want you guys to understand how the code works before we build it so that you can use and modify it as you need. I am definitely open to advice if anyone can see any bugs that might arise. It works like this:

  1. MovementDelta is set by external system (equivalent of Move() function)
  2. check to see if we will clip through a wall and clamp the movement delta to prevent that
  3. move the controller by the delta
  4. check to see if we should slide down slopes or not and solve collision accordingly
  5. while doing all of this store the normals of all the things we have bounced off of so that we can use it in our movement script later

The reason that we set the movement vector from a different script is that as I said above this is meant to be used in the same way as the default Unity Character controller and useable with several different movement scripts, though with ecs we can’t call a function on it so the simple solution is to make sure our movement script runs before the controller and have it set a variable.

To stop us from clipping through walls all we have to do is make sure that the center of our capsule never crosses to the other side of a wall, a simple and reliable way to do this is to do a sphere cast with a small radius along our movement delta and if we hit anything we just move to the hit point instead of through the object. This ensures that no matter how large movement delta is our center will never be inside a collider. This of course would allow for our player to fit through small holes like barred windows if they were going at ludicrous speeds and the center passed perfectly through the hole, though of course, this would require perfect aim and ludicrous speed. But what are the odds that any player would figure that out? It’s not like there is an entire subset of the gaming community committed to ludicrous speeds and wall clipping. An easy way to avoid this is to make sure you have good geometry on your maps, along with using collision layers to only let small stuff through places players shouldn’t be.

And finally, we have collision handling, we are going to use two different methods for this. One allows us to slide down slopes and the other does not. The main one (slopes) works by checking all the colliders we are intersecting and moving us out of them, the reason this results in sliding is that it pushed out of objects according to the normal of the hit (the normal is not the normal of the surface but rather the normal of the part of the capsule touching the surface, this is useful for sharp corners as we don’t have to worry about being pushed in the wrong direction) here’s my bad drawing of how it works and why it allows sliding down slopes:

Red: initial position, blue: normal, green: final position

The other system is what we use when we don’t want to slide or have our entire game on ice, it takes the same input as the other system (allowing us to only do one CapsuleCheck for both) and then moves us straight up until we are not colliding with anything. Thus resulting in no sliding. But how do we choose which one to use? Simple, if we are colliding with anything that has a slope higher then our max slope value then we use the sliding one. This allows us to slide off walls when we collide with them and also down slopes that are too steep, otherwise, we use the other system that prevents sliding. This system also would allow us to add in a sliding feature to our game by setting the max angle to a negative number, causing us to always slide.

In each of these systems, we store the normal of every hit in a buffer for later use in our movement script, in that script we are going to loop over each normal we hit and project our velocity on the plane of each vector since we assume that every collision is with a static object it makes sense that every collision would completely cancel out all velocity in that direction.

The Code

I am going to build on the code I wrote in the last article for the XR rig and Input system, if you already know how to do those things you should be able to easily follow along.

As always with dots, the first thing we need to do is create the data struct for our script:

using Unity.Entities;
using Unity.Mathematics;
using Unity.Physics;
using UnityEngine;

[GenerateAuthoringComponent]
public struct CharacterControllerData : IComponentData
{
    public float radius; //(.25)
    public float height; //(2)
    public float skin; //distace out from our controller to check for objects we are touching, set to something small (0.01)
    public float maxAngle;//max angle we do not slide down (45)
    public bool onGround; //we read this but usually do not set.
    public float3 footOffset; //we are going to poison the collider by its base instead of it's center to save on some math and mental energy
    public float3 moveDelta; //distance to try to move, like the move() function
    public LayerMask layersToIgnore;//an easy way to set layers, use the getter below for the actual code

    //Some things to make our life much easer
    public float3 center => footOffset + new float3(0, height / 2, 0);
    public float3 vertexTop => footOffset + new float3(0, height - radius, 0);
    public float3 vertexBottom => footOffset + new float3(0, radius, 0);
    public float3 top => footOffset + new float3(0, height, 0);
    public CollisionFilter Filter
    {
        get
        {
            return new CollisionFilter()
            {
                BelongsTo = (uint)(~layersToIgnore.value),
                CollidesWith = (uint)(~layersToIgnore.value)
            };
        }
    }

There is one other datatype we need before we start writing our system, since we can’t store arrays or lists of any type in a component we need to create a buffer for our bounceNormals array:

[InternalBufferCapacity(7)]
public struct BounceNormals : IBufferElementData
{
    public float3 Value;
}

Now we just need to create an authoring script so that we can add the buffer to our Entity:

using UnityEngine;
using Unity.Entities;

public class BounceNormalsAuthoring : MonoBehaviour, IConvertGameObjectToEntity
{
    public void Convert(Entity entity, EntityManager entityManager, GameObjectConversionSystem conversionSystem)
    {
        entityManager.AddBuffer<BounceNormals>(entity);
    }
}

Add both the CharacterControllerData and BounceNormalsAuthoring script to the root of the ECS camera rig or player object, set the values of the character controller to what you like and we are ready to start coding our system. First, we need to create some math extensions to handle some basic stuff that Unity.Mathematics doesn’t handle yet.

public static class MathExtention
{
    public static float3 Project(this float3 vector, float3 normal)
    {
        normal = math.normalize(normal);
        float dist = math.dot(vector, normal);
        float3 projected = dist * normal;
        return projected;
    }
    public static float3 ProjectOnPlane(this float3 vector, float3 normal)
    {
        float3 projected = vector - vector.Project(normal);
        return projected;
    }
    public static float AngleFrom(this float3 vector1, float3 vector2)
    {
        return math.acos(math.dot(math.normalize(vector1), math.normalize(vector2)));
    }
    public static quaternion ProjectOnPlane(this quaternion rotation, float3 vector)
    {
        //I thought you might find this one interesting, the new quaternion has no way to convert itself to Euler angles so I made this function to get its rotation around an axis

        float3 flatRotatedVector = math.mul(rotation, new float3(0, 0, 1)).ProjectOnPlane(vector);
        float angle = 0;
        if (math.dot(flatRotatedVector, new float3(1, 0, 0)) > 0)
            angle = new float3(0, 0, 1).AngleFrom(flatRotatedVector);
        else
            angle = 2 * math.PI - new float3(0, 0, 1).AngleFrom(flatRotatedVector);
        if (!float.IsNaN(angle))
            return quaternion.AxisAngle(vector, angle);
        else
            return quaternion.identity;
    }
}

Now with that done we can write our character controller system:

using Unity.Entities;
using Unity.Jobs;
using Unity.Transforms;
using Unity.Mathematics;
using Unity.Physics;
using Unity.Collections;

public class CharacterControllerSystem : SystemBase
{
    protected override void OnUpdate()
    {

        var physicsWorldSystem = World.GetExistingSystem<Unity.Physics.Systems.BuildPhysicsWorld>();//get references before we can no longer access the main thread
        CollisionWorld collisionWorld = physicsWorldSystem.PhysicsWorld.CollisionWorld;
        EntityManager entityManager = EntityManager;
        //set collisionWorld to readOnly with .WithReadOnly() so it does not throw errors
        JobHandle controllerJob = Entities.WithReadOnly(collisionWorld).ForEach((ref CharacterControllerData characterController, ref Translation translation, ref DynamicBuffer<BounceNormals> bounceNormalsBuffer) =>
        {
            bounceNormalsBuffer.Clear();//reset buffer and convert to usable state
            DynamicBuffer<float3> bounceNormals = bounceNormalsBuffer.Reinterpret<float3>();

            Move(ref characterController, ref translation, ref bounceNormals, collisionWorld);//move 
            characterController.onGround = GetGrounded(bounceNormals, characterController);//update on ground

            characterController.moveDelta = float3.zero;//reset moveDelta
        }).ScheduleParallel(JobHandle.CombineDependencies(Dependency, physicsWorldSystem.GetOutputDependency()));//run on multiple cores
        controllerJob.Complete();
    }
    
    //we have to set all of the functions we use as static so that they are not reference types
    private static unsafe void Move(ref CharacterControllerData characterController, ref Translation translation, ref DynamicBuffer<float3> bounceNormals, in CollisionWorld collisionWorld) {
        //Create Collider
        var filter = characterController.Filter;
        CapsuleGeometry capsuleGeometry = new CapsuleGeometry() //create our collider
        {
            Vertex0 = characterController.vertexTop,
            Vertex1 = characterController.vertexBottom,
            Radius = characterController.radius
        };
        BlobAssetReference<Collider> capsuleCollider = CapsuleCollider.Create(capsuleGeometry, filter);
        //Create delta
        float3 delta = characterController.moveDelta;
        var hits = new NativeList<DistanceHit>(Allocator.Temp); 


        CheckForClipping(ref delta, ref translation, ref bounceNormals, characterController, collisionWorld);

        //move controller
        translation.Value += delta;

        //Solve collision
        ColliderDistanceInput collisionCheck = new ColliderDistanceInput()
        {
            Collider = (Collider*)capsuleCollider.GetUnsafePtr(),
            MaxDistance = 0,
            Transform = new RigidTransform(quaternion.identity, translation.Value)
        };

        hits.Clear();//clear hits so we can use it again
        if (collisionWorld.CalculateDistance(collisionCheck, ref hits))
        {
            var bounces = GetBounces(hits); //get the normal of all the hits
            if (CheckForWalls(bounces, characterController.maxAngle)) // use sliding or snapping
            {
                for(int i = 0; i < bounces.Length; i++)
                    bounceNormals.Add(bounces[i]);//add bounces
                translation.Value -= GetBounceNormal(bounces);//get a combination of all the hits
            }
            else
            {
                SnapToGround(hits, ref translation, ref characterController);
                bounceNormals.Add(new float3(0, -1, 0));//add a single bounce since we are on the ground;
            }

        }

        hits.Dispose();
    }
    private static unsafe bool CheckForClipping(ref float3 delta, ref Translation translation, ref DynamicBuffer<float3> bounces, in CharacterControllerData characterController, in CollisionWorld collisionWorld)
    {
        var geomitry = new SphereGeometry() //create collider
        {
            Radius = 0.01f
        };
        BlobAssetReference<Collider> collider = SphereCollider.Create( geomitry, characterController.Filter);

        var bodyCheckInput = new ColliderCastInput() //set up cast varibles
        {
            Collider = (Collider*)collider.GetUnsafePtr(),
            Orientation = quaternion.identity,
            Start = translation.Value + characterController.center,//we want to cast from the center of the capsuel 
            End = translation.Value + characterController.center + delta
        };

        if (collisionWorld.CastCollider(bodyCheckInput, out ColliderCastHit bodyHit)) //if we hit anything
        {
            bounces.Add(bodyHit.SurfaceNormal);//add to bounces
            delta *= bodyHit.Fraction;//set delta to the length of the raycast so we move directly to the hit point
            return true;
        }
        return false;
    }
    private static void SnapToGround(NativeList<DistanceHit> hits, ref Translation translation, ref CharacterControllerData characterController)
    {
        float maxHeight = 0;
        for (int i = 0; i < hits.Length; i++)//find the higest point
        {
            float3 delta = hits[i].Position - (translation.Value + characterController.footOffset);

            //we need to correct for the curve of the capsule
            float angle = math.acos(math.distance(delta.ProjectOnPlane(new float3(0, 1, 0)), float3.zero) / characterController.radius);
            float offset = characterController.radius - math.sin(angle) * characterController.radius;

            //find the higest corrected float;
            if (delta.y - offset > maxHeight)
            {
                maxHeight = delta.y - offset;
            }
        }
        translation.Value += new float3(0, maxHeight, 0);//set our transform to the higest point.
    }
    private static float3 GetBounceNormal(NativeArray<float3> bounces)
    {
        float3 maxDists = float3.zero;
        for(int i = 0; i < bounces.Length; i++) //combine all of the bounces to get the vector we need
        {
            float3 vector = bounces[i];
            maxDists = new float3()
            {
                x = (math.abs(maxDists.x) < math.abs(vector.x)) ? vector.x : maxDists.x,
                y = (math.abs(maxDists.y) < math.abs(vector.y)) ? vector.y : maxDists.y,
                z = (math.abs(maxDists.z) < math.abs(vector.z)) ? vector.z : maxDists.z
            };
        }
        return maxDists;
    }
    private static bool GetGrounded(in DynamicBuffer<float3> bounceNormals, in CharacterControllerData characterController)
    {
        for(int i = 0; i < bounceNormals.Length; i++)
        {
            if (bounceNormals[i].AngleFrom(new float3(0, -1, 0)) < math.radians(characterController.maxAngle)) //checks bounces to see if any were the ground
                return true;
        }
        return false;
    }
    private static NativeList<float3> GetBounces(NativeList<DistanceHit> hits)
    {
        var bounces = new NativeList<float3>(Allocator.Temp);
        for (int i = 0; i < hits.Length; i++)
        {
            bounces.Add(hits[i].SurfaceNormal * hits[i].Distance);//get all the normals from the hits
        }
        return bounces;
    }
    private static bool CheckForWalls(NativeArray<float3> bounces, float maxAngle)
    {
        for (int i = 0; i < bounces.Length; i++)
        {
            if (bounces[i].AngleFrom(new float3(0, -1, 0)) > math.radians(maxAngle))//are any of these a slope or wall? if so return true
                return true;
        }
        return false;
    }
}

Lot’s of code but it does the job well. Now if we run our scene nothing will happen, this is because we aren’t moving it yet. We need to write a movement system to tell the Character Controller System how to work. We are going to use the input system I built in the last article, not the ECS part, just the game controller part. This script uses the new input system to update a public static struct that stores all of our input data so that we can access and copy it easily before our system. We also need a way to get the rotation of our hands or head so that we can rotate our input by it. For this we need a simple component:

using Unity.Entities;

[GenerateAuthoringComponent]
public struct CameraRigData : IComponentData
{
    public Entity head;//camera
    public Entity leftHand;//left controller
    public Entity rightHand;//right controller
}

Add this to the same object with the character controller and set the references. Now we can work on the movement system. Since the goal of this series is making a Titanfall clone we want to base our movement code on the quake movement system to allow air strafing and bunny hopping, though how you are going to manage it in VR I don’t know, I may be starting a trend of rapid horizontal headbanging. If you want to do more research on how bunny hopping works (And see where I totally didn’t copy the code from) here is a good article on it. To get started we need to create the Data struct:

using Unity.Entities;
using Unity.Mathematics;

[GenerateAuthoringComponent]
public struct PlayerMovementData : IComponentData
{
    public float maxSpeed;//(2)
    public float acceletaion; // (10)
    public float airAcceleration; // (5)
    public float groundFriction; // (5)
    public float groundingDistance; // (0.1)
    public ControlType controlType; //What to use as the input rotation reference
    public float3 gravity;
    public float3 velocity;
    public enum ControlType
    {
        localToHead,
        localToHand
    }
}

Add this to the same objects as the CameraRigData and CharacterControllerData and set the values (If you haven’t noticed I’m putting suggestion values in the comments). Now for the actual system:

using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;

[UpdateBefore(typeof(CharacterControllerSystem))]//make sure we update before the controller
public class PlayerMovementSystem : SystemBase
{
    protected override void OnUpdate()
    {
        float deltaTime = Time.DeltaTime;
        EntityManager entityManager = EntityManager;
        var input = GameController.pilotInput;
        JobHandle job = Entities.ForEach((DynamicBuffer<BounceNormals> bounceNormalsBuffer, ref PlayerMovementData playerMovement, ref CharacterControllerData characterController, ref Translation translation, in CameraRigData cameraRig) =>
        {
            //We need to get the rotation of our controller so that we can change our input by it
            quaternion controlRotation = quaternion.identity;
            switch (playerMovement.controlType)
            {
                case PlayerMovementData.ControlType.localToHead:
                    controlRotation = entityManager.GetComponentData<LocalToWorld>(cameraRig.head).Rotation.ProjectOnPlane(new float3(0, 1, 0));
                    break;
                case PlayerMovementData.ControlType.localToHand:
                    controlRotation = entityManager.GetComponentData<LocalToWorld>(cameraRig.leftHand).Rotation.ProjectOnPlane(new float3(0, 1, 0));
                    break;
            }
            //update our velocity
            for (int i = 0; i < bounceNormalsBuffer.Length; i++)
                playerMovement.velocity = playerMovement.velocity.ProjectOnPlane(bounceNormalsBuffer[i].Value);
            
            //get rotated movement
            float3 movement = math.mul(controlRotation, new float3(input.movement.x, 0, input.movement.y));
            playerMovement.velocity += (playerMovement.gravity) * deltaTime;
            if (characterController.onGround)
            {
                //friction code
                float speed = math.distance(playerMovement.velocity, float3.zero);
                if (speed != 0) // To avoid divide by zero errors
                {
                    float drop = speed * playerMovement.groundFriction * deltaTime;
                    playerMovement.velocity *= math.max(speed - drop, 0) / speed;
                }
                
                //Add movement acceletation
                if (!movement.Equals(float3.zero))
                    playerMovement.velocity = ClampedAccelerate(math.normalize(movement), playerMovement.velocity, math.distance(movement, float3.zero) * playerMovement.acceletaion * deltaTime, playerMovement.maxSpeed);
                //apply grounding distance
                playerMovement.velocity = new float3(playerMovement.velocity.x, math.min(playerMovement.velocity.y, -playerMovement.groundingDistance), playerMovement.velocity.z);

                //apply jump force
                if(input.Jump)
                    playerMovement.velocity = new float3(playerMovement.velocity.x, math.max(playerMovement.velocity.y, math.sqrt(2 * playerMovement.jumpHeight * math.distance(float3.zero, playerMovement.gravity))), playerMovement.velocity.z);
            }
            else
            {
                //airstraph code
                if (!movement.Equals(float3.zero))
                {
                    playerMovement.velocity =  ClampedAccelerate(math.normalize(movement), playerMovement.velocity, math.distance(movement, float3.zero) * playerMovement.airAcceleration * deltaTime, playerMovement.maxSpeed);
                }
            }

            //tell character controller to move
            characterController.moveDelta += playerMovement.velocity * deltaTime;
        }).Schedule(Dependency);
        job.Complete();
    }
    private static float3 ClampedAccelerate(float3 direction, float3 currentVelocity, float acceleration, float maxSpeed)
    {

        //don't try to get me to explain this black magic
        float projectedSpeed = math.dot(currentVelocity, direction);

        if (projectedSpeed + acceleration > maxSpeed)
            acceleration = maxSpeed - projectedSpeed;

        return currentVelocity + direction * acceleration;
    }
}

Now you have a working movement system, one final step for the VR users, we just need to add this script to move the controller to match our head movements:

using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;

[UpdateBefore(typeof(CharacterControllerSystem))]
public class BodyColliderSystem : SystemBase
{
    protected override void OnUpdate()
    {
        EntityManager entityManager = EntityManager;
        Entities.ForEach((ref CharacterControllerData characterController, in LocalToWorld transform, in CameraRigData cameraRig) => {
            characterController.footOffset = (entityManager.GetComponentData<LocalToWorld>(cameraRig.head).Position - transform.Position).ProjectOnPlane(new float3(0,1,0));
        }).Run();
    }
}

We don’t need to add anything for this one, just paste it into your code and it will auto run. If you are lucky and understood all that (or know how to use ctr + c, ctr + v) you should now have a very robust quake/source/titanfall style character controller with some VR frosting:

Turn up the air acceleration and you can do some pretty impressive bunny hopping:

Liked it? Take a second to support WireWhiz on Patreon!
Become a patron at Patreon!