Rigidbody FPS Controller

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! :slight_smile:

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;
        }
    }
}
3 Likes

Edited the code in the original post. Air acceleration is working, so long as you don’t use a huge value for it. I will eventually correct my math (probably tomorrow), but regardless – the feature is working correctly.

I fixed the old issue where the player would slowlllly slide down a slope while idle on it. If you use this, make sure you mark static geometry as static, or else my code will have to be modified. To prevent sliding, the controller essentially cancels gravity while you are idle on static geometry. On non-static geometry, it is not canceled so it interacts correctly with other rigidbodies. I understand this is kind of a dirty hack, but hey, works perfect for my purpose.

Next: I half implemented acceleration. It still builds up even while running into a wall. I’ll fix this either tomorrow or in the next few days.

It feels totally usable now at least! Once I fully implement acceleration, jittering against unwalkable slopes will be significantly reduced if not eliminated.

Please give me feedback and let me know what you guys think!

EDIT: Air acceleration is almost done. Going to bed now – will work on it tomorrow.

Very cool. going for a run, wanna check this out when I get back. Thanks for the post, well commented & clean.

Alright, this thing just got crazy! Fully implemented air acceleration, ground acceleration, fixed jittering against slopes and a lot of jittering related to fudging, added fall damage and fixed a few bugs and mistakes from earlier versions. Added some public properties to expose stuff you may need for animation. Probably forgetting a few things, but that’s the jist of it anyways.

After a headache inducing day, I realized ContactPoint.normal is not the surface normal, but the normal of contact. This required a hefty dosage of Raycasts which makes me really unhappy with the code, but alas, it still works and with, in my opinion, great performance.

This controller feels awesome right now! I am extremely happy with it. If you tested my earlier version, make sure you test the new one. It is a million times smoother. In my tests, I am using the following settings:

Capsule Collider

Radius: 0.5
Height: 2
Direction: Y-Axis

Rigidbody

Mass: 150
Drag: 0
Angular Drag: 0.05
Use Gravity: yes
Is Kinematic: no
Interpolate: None
Collision Detection: Discrete

Cranky Rigid Body Controller

Movement Speed: 7
Accel Rate: 20
Decel Rate: 20
Airborne Accel: 5
Jump Speed: 7
Fudge Extra: 0.5
Maximum Slope: 50

Fudge Extra is really important to the feel of your game, so make sure you set it up!

Anyways, I updated the code in the original post, so you can grab it there. Sorry if my code is a mess! It’s been a long day :S. Let me know if you have any questions.

You sir are a madman… got to test this out a bit later. You got a really great handle on this stuff. Props and respect.:sunglasses:

Glad it looks like I know what I’m doing, haha!

Hello, I am still new to Unity for some things…I think the controller is great, just wondering if it is possible to make a controller like this, but with instant air control, so you can jump and in the middle of the air turn, say 90º degrees instantly (or almost instantly).

I know the way rigidbody works, via acceleration, makes this a special work… I just wonder if it’s possible

You can directly set velocity with RigidBody.velocity as opposed to using addForce()

Just change the airborne acceleration inspector value to the same as the acceleration value. Have both set really high.

Note that this is an old version of my controller. It has bugs. I plan to sell my new version on the asset store eventually.

Very nice, thanks! Any updates there?

I have updated the script to the newest version of unity(5.6) for those who still want to use it.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;


[RequireComponent(typeof(CapsuleCollider))]
[RequireComponent(typeof(Rigidbody))]

public class PlayerMover : 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.")]
    // Extra height, can't modify this during runtime
    public float FudgeExtra = 0.5f;
    [Tooltip("Maximum slope the player can walk up")]
    public float MaximumSlope = 45.0f;

    private bool grounded = false;

    //Unity Components
    private Rigidbody rb;
    private Collider coll;

    // Temp vars
    private float inputX;
    private float inputY;
    private Vector3 movement;

    // Acceleration or deceleration
    private float acceleration;

    /*
     * 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;

    // Average normal of the ground i'm standing on
    private Vector3 groundNormal;

    // If we're touching a dynamic object, don't prevent idle sliding
    private bool touchingDynamic;

    // Was i grounded last frame? used for fudging
    private bool groundedLastFrame;

    // The objects i'm colliding with
    private List<GameObject> collisions;

    // All of the collision contact points
    private Dictionary<int, ContactPoint[]> contactPoints;

    /*
     * 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()
    {
        rb = GetComponent<Rigidbody>();
        coll = GetComponent<Collider>();

        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)coll;
        Debug.Log(capsule);
        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
        rb.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 <= rb.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 - rb.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 - rb.velocity.x) * difference * acceleration, (targetSpeed.y - rb.velocity.y) * difference * acceleration, (targetSpeed.z - rb.velocity.z) * difference * acceleration);
            }

            if (doJump == 1) {
                // jump button was pressed, do jump     
                movement.y = JumpSpeed - rb.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;

            rb.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 + (rb.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)) {
                        rb.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 = rb.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 + rb.velocity.x;
                float b = movement.z + rb.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(rb.velocity.x * rb.velocity.x + rb.velocity.z * rb.velocity.z);
                        Vector3 rigidbodyDirection = new Vector3(rb.velocity.x * length, 0.0f, rb.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
                    rb.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;
        }
    }
}
4 Likes

Thank you for this, the only thing I need is the name of the script to attach it to my player, please respond and thank you. P.S. I’m using the first one posted. Thanks again.

The name is CrankyRigidBodyController, but you can edit the name.
And cranky, thanks for this, you are a madman.

I modified the code to make it work with the new input system (with a PlayerInput component).

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(CapsuleCollider))]
[RequireComponent(typeof(Rigidbody))]
[RequireComponent(typeof(PlayerInput))]

public class PlayerMover : 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")]
    // 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.
    // Thid can't be modified during runtime
    public float FudgeExtra = 0.5f;
    [Tooltip("Maximum slope the player can walk up")]
    public float MaximumSlope = 45.0f;

    private bool _isGrounded = false;
    public bool IsGrounded { get => _isGrounded; }

    //Unity Components
    private Rigidbody _rigidbody;
    private CapsuleCollider _capsuleCollider;

    // Temp vars
    private float _inputX;
    private float _inputY;
    private Vector2 _movementInput;
    private Vector3 _movementVector;

    // Acceleration or deceleration
    private float _acceleration;

    /*
     * Keep track of falling
     */
    private bool _isFalling;
    public bool IsFalling { get => _isFalling; }

    private float _fallSpeed;
    public float FallSpeed { get => _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 _jumpState;

    // Average normal of the ground i'm standing on
    private Vector3 _groundNormal;
    public Vector3 GroundNormal { get => _groundNormal; }

    // If we're touching a dynamic object, don't prevent idle sliding
    private bool _touchingDynamic;

    // Was i grounded last frame? used for fudging
    private bool _groundedLastFrame;

    // The objects i'm colliding with
    private List<GameObject> _collisions;

    // All of the collision contact points
    private Dictionary<int, ContactPoint[]> _contactPoints;

    /*
     * 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()
    {
        _rigidbody = GetComponent<Rigidbody>();
        _capsuleCollider = GetComponent<CapsuleCollider>();

        _movementVector = Vector3.zero;

        _isGrounded = 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
        Debug.Log(_capsuleCollider);
        _halfPlayerHeight = _capsuleCollider.height * 0.5f;
        _fudgeCheck = _halfPlayerHeight + FudgeExtra;
        _bottomCapsuleSphereOrigin = _halfPlayerHeight - _capsuleCollider.radius;
        _capsuleRadius = _capsuleCollider.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;
        _capsuleCollider.material = controllerMat;

        // just in case this wasn't set in the inspector
        _rigidbody.freezeRotation = true;
    }

    void FixedUpdate()
    {
        // check if we're grounded
        RaycastHit hit;
        _isGrounded = 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)
                {
                    _isGrounded = true;
                    _groundNormal += hit.normal;

                }
            }
        }

        if (_isGrounded)
        {
            // average the summed normals
            _groundNormal.Normalize();

            if (_jumpState == 3)
                _jumpState = 0;
        }
        else if (_jumpState == 2)
            _jumpState = 3;

        // get player input
        _inputX = _movementInput.x;
        _inputY = _movementInput.y;

        // limit the length to 1.0f
        float length = 0;

        if (_isGrounded && _jumpState != 3)
        {
            if (_isFalling)
            {
                // we just landed from a fall
                _isFalling = 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))
                _movementVector = 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;
                _movementVector = (targetSpeed - _rigidbody.velocity) * difference * _acceleration;
            }

            if (_jumpState == 1)
            {
                // jump button was pressed, do jump  
                _movementVector.y = JumpSpeed - _rigidbody.velocity.y;
                _jumpState = 2;
            }
            else if (!_touchingDynamic && Mathf.Approximately(_inputX + _inputY, 0.0f) && _jumpState < 2)
                // prevent sliding by countering gravity... this may be dangerous
                _movementVector.y -= Physics.gravity.y * Time.deltaTime;

            _rigidbody.AddForce(_movementVector, ForceMode.VelocityChange);
            _groundedLastFrame = true;
        }
        else
        {
            // not grounded, so check if we need to fudge and do air accel

            // fudging
            if (_groundedLastFrame && _jumpState != 3 && !_isFalling)
            {
                // 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 (_jumpState == 1)
                    {
                        _movementVector.y += JumpSpeed;
                        _jumpState = 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 thread!
                    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 (!_isFalling)
                _isFalling = 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
                _movementVector = 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 = _movementVector.x + _rigidbody.velocity.x;
                float b = _movementVector.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(_movementVector.x * _movementVector.x +
                            _movementVector.z * _movementVector.z);
                        _movementVector.x *= length;
                        _movementVector.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(_movementVector.x * rigidbodyDirection.x +
                            _movementVector.z * rigidbodyDirection.z, 0.0f)) * AirborneAccel * Time.deltaTime;
                        _movementVector.x *= length;
                        _movementVector.z *= length;
                    }

                    // and finally, add our force
                    _rigidbody.AddForce(new Vector3(_movementVector.x, 0.0f, _movementVector.z),
                        ForceMode.VelocityChange);
                }
            }

            _groundedLastFrame = false;
        }
    }

    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 (_jumpState == 3)
            _jumpState = 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 void OnMove(InputAction.CallbackContext context)
    {
        _movementInput = context.ReadValue<Vector2>();
    }

    public void OnJump(InputAction.CallbackContext context)
    {
        if (_groundedLastFrame)
            _jumpState = 1;
    }
}
1 Like

Line #316 generates errors if instanced gameObjects are encountered. I am not an adept enough programmer to resolve this… My thoughts to fix it are as follows, but I don’t know how to implement it.

Before the contactPoints.Add… should there be code to determine if this collision has already been added, maybe based off of it’s position in the world.

My errors state… Argument Exception: An item with the same key has already been added.

Example: I have a terrain with trees and their colliders enabled. I also have a prefab gameObject called rock. I have multiple trees and rocks place in the scene. The first tree I contact generates an error. The first rock does not, however the second rock will.

Hopefully someone can help me get this fixed. This controller is solid, and fast. Very nice work!!

Thanks,

Please don’t necro-post to dead old threads.

Instead, start your own… it’s FREE and that’s what the forum rules say you must do.

Before you post, read this:

How to report your problem productively in the Unity3D forums:

http://plbm.com/?p=220

How to understand errors in general:

https://forum.unity.com/threads/assets-mouselook-cs-29-62-error-cs1003-syntax-error-expected.1039702/#post-6730855

If you post a code snippet, ALWAYS USE CODE TAGS:

How to use code tags: https://discussions.unity.com/t/481379

1 Like

Thx to the guy that made the script BIG thx it works very good