Finding the last grounded position of a character controller

I figured I’d start a new thread since the scope of the problem has changed enough that it merits a new thread. As with the last thread, I’ll include the full source code at the bottom of this post.

I’m working on an first-person shooter character motor using Unity’s character controller. To keep the character grounded, I’m using “playerVelocity.y = -controller.stepOffset / Time.deltaTime;” on line 274 (with “controller” being a reference to the default character controller included in Unity).

Unfortunately, this has a side effect that I just can’t seem to overcome. When the player walks off a ledge, they’re briefly (for one frame) travelling at a value equal to “-controller.stepOffset / Time.deltaTime;”. I’ve tried numerous ways to get around this, but nothing seems to work. It’s this one-frame hiccup that’s causing me a lot of grief.

It seems like this should be a simple fix, but I just can’t find the problem at all.

edit: whoops, hit post instead of preview.

I’ve tried assigning a variable “wasGrounded” in my update loop, but if I put it at the bottom, after the character controller has moved, it doesn’t seem to assign properly. See:

    void Update () {
        if (!isDead) {
            cameraRotX -= player.GetAxisRaw ("LookVertical") * mouseXSensitivity * Time.deltaTime;
            cameraRotY += player.GetAxisRaw ("LookHorizontal") * mouseYSensitivity * Time.deltaTime;
        }

        if (cameraRotX < -90f) {
            cameraRotX = -90f;
        } else if (cameraRotX > 90f) {
            cameraRotX = 90f;
        }

        transform.rotation = Quaternion.Euler (0f, cameraRotY, 0f);
        FPSCamera.transform.rotation = Quaternion.Euler (cameraRotX, cameraRotY, 0f);

        QueueJump ();
        if (controller.isGrounded && !isGrappling && !isDead) {
            GroundMove ();
        } else if (!controller.isGrounded && !isGrappling && !isDead) {
            AirMove ();
        }

        if (isDead) {
            playerVelocity = new Vector3 (0f,0f,0f);
        }

        if (!controller.isGrounded && wasGrounded && playerVelocity.y <= 0f) {
            playerVelocity.y = 0f;
        }

        wasGrounded = controller.isGrounded;

        controller.Move (playerVelocity * Time.deltaTime);
    }

If I keep it above controller.Move, it gives me the downward hiccup. If I put it below, it doesn’t trigger at all.

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

[RequireComponent(typeof(CharacterController))]
public class FPSController : MonoBehaviour {

    [HideInInspector]
    public int playerID;
    private Player player;
    private CharacterController controller;
    [HideInInspector]
    public Camera FPSCamera;

    [Header("Input Settings")]
    public float mouseXSensitivity = 30f;
    public float mouseYSensitivity = 30f;

    [Header("Movement Settings")]
    public float friction = 6f;
    public float moveSpeed = 7f;
    public float runAcceleration = 14f;
    public float runDecceleration = 10f;
    public float airAcceleration = 2f;
    public float airDecceleration = 2f;
    public float airControl = 0.3f;
    public float strafeSpeed = 1f;
    public float strafeAcceleration = 50f;
    public float jumpSpeed = 8.0f;
    public float moveScale = 1.0f;
    [HideInInspector]
    public float wishSpeedGlobal = 2f;

    [HideInInspector]
    public bool isGrappling = false;
    [HideInInspector]
    public bool isDead = false;

    [Header("Audio Settings")]
    public AudioClip[] jumpSounds;
    AudioSource audio;

    private float cameraRotX = 0f;
    private float cameraRotY = 0f;

    private Vector3 moveDirection = Vector3.zero;
    private Vector3 moveDirectionNorm = Vector3.zero;
    [HideInInspector]
    public Vector3 playerVelocity = Vector3.zero;
    private float playerFriction = 0f;

    private bool wishJump = false;

    private bool wasGrounded = false;

    private Vector3 contactPoint;

    public class Cmd {
        public float forwardMove;
        public float rightMove;
        public float upMove;
    }

    protected Cmd cmd;

    void Start () {
        player = ReInput.players.GetPlayer (playerID);
        controller = GetComponent<CharacterController> ();
        FPSCamera = GameObject.FindGameObjectWithTag ("PlayerCamera").GetComponent<Camera> ();

        Cursor.visible = false;
        Cursor.lockState = CursorLockMode.Locked;

        cmd = new Cmd();

        audio = GetComponent<AudioSource> ();
    }

    public float cmdScale() {
        float max;
        float total;

        max = Mathf.Abs(cmd.forwardMove);
        if (Mathf.Abs(cmd.rightMove) > max)
            max = Mathf.Abs(cmd.rightMove);
        if (max <= 0f)
            return 0f;

        total = Mathf.Sqrt(cmd.forwardMove * cmd.forwardMove + cmd.rightMove * cmd.rightMove);
        return moveSpeed * max / (moveScale * total);
    }

    void PlayJumpSound() {
        if (audio.isPlaying) {
            return;
        }

        audio.clip = jumpSounds [UnityEngine.Random.Range (0, jumpSounds.Length)];
        audio.Play();
    }

    //Movement
    void SetMovementDir() {
        cmd.forwardMove = player.GetAxisRaw ("MoveForward");
        cmd.rightMove = player.GetAxisRaw ("Strafe");
    }

    void QueueJump() {
        if (player.GetButtonDown ("Jump") && !wishJump) {
            wishJump = true;
        }

        if (player.GetButtonUp ("Jump")) {
            wishJump = false;
        }
    }

    void Accelerate(Vector3 wishDir, float wishSpeed, float accel) {
        float addSpeed;
        float accelSpeed;
        float currentSpeed;

        currentSpeed = Vector3.Dot (playerVelocity, wishDir);
        addSpeed = wishSpeed - currentSpeed;

        if (addSpeed <= 0f) {
            return;
        }

        accelSpeed = accel * Time.deltaTime * wishSpeed;

        if (accelSpeed > addSpeed) {
            accelSpeed = addSpeed;
        }

        playerVelocity.x += accelSpeed * wishDir.x;
        playerVelocity.z += accelSpeed * wishDir.z;
    }

    void AirControl(Vector3 wishDir, float wishSpeed) {
        float zSpeed;
        float speed;
        float dot;
        float k;

        if (cmd.forwardMove == 0f || wishSpeed == 0f) {
            return;
        }

        zSpeed = playerVelocity.y;
        playerVelocity.y = 0f;

        speed = playerVelocity.magnitude;
        playerVelocity.Normalize ();

        dot = Vector3.Dot (playerVelocity, wishDir);
        k = 32f;
        k *= airControl * dot * dot * Time.deltaTime;

        if (dot > 0f) {
            playerVelocity = new Vector3 (playerVelocity.x * speed * wishDir.x * k,
                playerVelocity.y * speed * wishDir.y * k,
                playerVelocity.z * speed * wishDir.z * k);

            playerVelocity.Normalize ();
        }

        playerVelocity *= speed;
        playerVelocity.y = zSpeed;  
    }

    void AirMove() {
        Vector3 wishDir;
        float wishVelocity = airAcceleration;
        float accel;

        float scale = cmdScale ();

        SetMovementDir ();

        wishDir = new Vector3 (cmd.rightMove, 0f, cmd.forwardMove);
        wishDir = transform.TransformDirection (wishDir);
        wishDir.Normalize ();
        moveDirection = wishDir;

        float wishSpeed = wishDir.magnitude;
        wishSpeed *= moveSpeed;

        float wishSpeed2 = wishSpeed;
        if (Vector3.Dot (playerVelocity, wishDir) < 0f) {
            accel = airDecceleration;
        } else {
            accel = airAcceleration;
        }

        if (cmd.forwardMove == 0f && cmd.rightMove != 0f) {
            if (wishSpeed > wishSpeedGlobal) {
                wishSpeed = wishSpeedGlobal;
            }
            accel = strafeAcceleration;

            Accelerate (wishDir, wishSpeed, accel);

            if (airControl != 0f) {
                AirControl (wishDir, wishSpeed2);
            }
        } else {
            Accelerate (wishDir, wishSpeed, accel);
        }

        if ((controller.collisionFlags == CollisionFlags.Above) == true) {
            if (playerVelocity.y > 0) {
                playerVelocity.y = 0f;
            }
        }

        playerVelocity.y += Physics.gravity.y * Time.deltaTime;
    }

    void ApplyFriction (){
        Vector3 vec = playerVelocity;
        float speed;
        float newSpeed;
        float control;
        float drop;

        vec.y = 0f;
        speed = vec.magnitude;

        drop = 0f;

        if (controller.isGrounded) {
            control = speed < runDecceleration ? runDecceleration : speed;
            drop = control * friction * Time.deltaTime;
        }

        newSpeed = speed - drop;
        playerFriction = newSpeed;

        if (newSpeed < 0f) {
            newSpeed = 0f;
        }
        if (speed > 0f) {
            newSpeed /= speed;
        }

        playerVelocity.x *= newSpeed;
        playerVelocity.z *= newSpeed;
    }

    void GroundMove() {
        Vector3 wishDir;

        if (!wishJump) {
            ApplyFriction ();
        }

        float scale = cmdScale ();

        SetMovementDir ();

        wishDir = new Vector3 (cmd.rightMove, 0f, cmd.forwardMove);
        wishDir = transform.TransformDirection (wishDir);
        wishDir.Normalize ();
        moveDirection = wishDir;

        float wishSpeed = wishDir.magnitude;
        wishSpeed *= moveSpeed;

        Accelerate (wishDir, wishSpeed, runAcceleration);

        playerVelocity.y = -controller.stepOffset / Time.deltaTime;

        if (wishJump) {
            playerVelocity.y = jumpSpeed;
            wishJump = false;
            PlayJumpSound();
        }
    }

    void Update () {
        if (!isDead) {
            cameraRotX -= player.GetAxisRaw ("LookVertical") * mouseXSensitivity * Time.deltaTime;
            cameraRotY += player.GetAxisRaw ("LookHorizontal") * mouseYSensitivity * Time.deltaTime;
        }

        if (cameraRotX < -90f) {
            cameraRotX = -90f;
        } else if (cameraRotX > 90f) {
            cameraRotX = 90f;
        }

        transform.rotation = Quaternion.Euler (0f, cameraRotY, 0f);
        FPSCamera.transform.rotation = Quaternion.Euler (cameraRotX, cameraRotY, 0f);

        QueueJump ();
        if (controller.isGrounded && !isGrappling && !isDead) {
            GroundMove ();
        } else if (!controller.isGrounded && !isGrappling && !isDead) {
            AirMove ();
        }

        if (isDead) {
            playerVelocity = new Vector3 (0f,0f,0f);
        }

        if (!controller.isGrounded && wasGrounded && playerVelocity.y <= 0f) {
            playerVelocity.y = 0f;
        }

        wasGrounded = controller.isGrounded;

        controller.Move (playerVelocity * Time.deltaTime);
    }
}

Bump, anyone?

Okay, so I’m still fighting with this and I think I’m reaching a point where I might be overengineering a solution, but…

What if I use a simple velocity calculation to predict where to originate a spherecast based on the movement and use THAT as the grounded state. Will this work at all or am I barking up the wrong tree entirely?

edit: It sorta works? You now slide off the edge of cliffs just fine, but you can’t jump when you’re right on the edge because you’re technically counted as not grounded, so I’ll need to add a special condition for jumping, which may involve rewriting how jumps work entirely. Here’s what I’ve got now:

    bool isGrounded () {
        Vector3 origin = transform.position + controller.velocity * Time.deltaTime;
        origin.y = origin.y - controller.height / 2f + controller.radius;
        Ray ray = new Ray (origin, Vector3.down);

        if (Physics.SphereCast (ray, controller.radius, groundCheckDistance, ignorePlayer)) {
            return true;
        }

        Debug.DrawRay (ray.origin, Vector3.down, Color.red);

        return false;
    }

    void Update () {
        if (!isDead) {
            cameraRotX -= player.GetAxisRaw ("LookVertical") * mouseXSensitivity * Time.deltaTime;
            cameraRotY += player.GetAxisRaw ("LookHorizontal") * mouseYSensitivity * Time.deltaTime;
        }

        if (cameraRotX < -90f) {
            cameraRotX = -90f;
        } else if (cameraRotX > 90f) {
            cameraRotX = 90f;
        }

        transform.rotation = Quaternion.Euler (0f, cameraRotY, 0f);
        FPSCamera.transform.rotation = Quaternion.Euler (cameraRotX, cameraRotY, 0f);

        QueueJump ();
        if (isGrounded() && !isGrappling && !isDead) {
            GroundMove ();
        } else if (!isGrounded() && !isGrappling && !isDead) {
            AirMove ();
        }

        if (isDead) {
            playerVelocity = new Vector3 (0f,0f,0f);
        }

        if (!isGrounded() && wasGrounded && playerVelocity.y <= 0f) {
            playerVelocity.y = 0f;
        }


        if (!controller.isGrounded) {
            previousVelocity = controller.velocity.y;
        }
        Headbob (previousVelocity);

        controller.Move (playerVelocity * Time.deltaTime);

        wasGrounded = controller.isGrounded;
    }

Edit 2: Now there’s a NEW bug, argh. When going over the first step of a slope, it thinks you’re not grounded, so it causes a bounce that can sometimes repeat.

Edit 3: With some tweaking, I got it to work:

    bool isGrounded () {
        float internalCheckDistance = groundCheckDistance;
        Vector3 origin = transform.position + controller.velocity * Time.deltaTime;
        origin.y = origin.y - controller.height / 2f + controller.radius;
        Ray ray = new Ray (origin, Vector3.down);

        if (jumpGrounded ()) {
            groundCheckDistance = controller.stepOffset;
        }

        if (Physics.SphereCast (ray, controller.radius, groundCheckDistance, ignorePlayer)) {
            return true;
        }

        return false;
    }

Edit whatever: Now there’s an issue jumping up slopes I need to fix. If it’s not one thing, it’s another, I swear.

And the full code:

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

[RequireComponent(typeof(CharacterController))]
public class FPSController : MonoBehaviour {

    [HideInInspector]
    public int playerID;
    private Player player;
    private CharacterController controller;
    [HideInInspector]
    public Camera FPSCamera;

    [Header("Input Settings")]
    public float mouseXSensitivity = 30f;
    public float mouseYSensitivity = 30f;

    [Header("Movement Settings")]
    public float friction = 6f;
    public float moveSpeed = 7f;
    public float runAcceleration = 14f;
    public float runDecceleration = 10f;
    public float airAcceleration = 2f;
    public float airDecceleration = 2f;
    public float airControl = 0.3f;
    public float strafeSpeed = 1f;
    public float strafeAcceleration = 50f;
    public float jumpSpeed = 8.0f;
    public float moveScale = 1.0f;
    public float terminalVelocity = 20f;
    public float headbobThreshold = 15f;
    public float headbobLength = 1f;
    public float cameraMaxDistanceBob = 0.5f;
    public AnimationCurve headbobShape;
    private float headbobTimer = 0f;
    private bool isBobbing = false;
    private float previousVelocity = 0f;
    [HideInInspector]
    public float wishSpeedGlobal = 2f;

    [Header("Ground Detection Settings")]
    private Vector3 contactPoint;
    private float groundCheckDistance = 0.1f;
    public LayerMask ignorePlayer;
    private bool wasGrounded = false;

    [HideInInspector]
    public bool isGrappling = false;
    [HideInInspector]
    public bool isDead = false;

    [Header("Audio Settings")]
    public AudioClip[] jumpSounds;
    AudioSource audio;

    private float cameraRotX = 0f;
    private float cameraRotY = 0f;

    private Vector3 moveDirection = Vector3.zero;
    private Vector3 moveDirectionNorm = Vector3.zero;
    [HideInInspector]
    public Vector3 playerVelocity = Vector3.zero;
    private float playerFriction = 0f;

    private bool wishJump = false;

    public class Cmd {
        public float forwardMove;
        public float rightMove;
        public float upMove;
    }

    protected Cmd cmd;

    void Start () {
        player = ReInput.players.GetPlayer (playerID);
        controller = GetComponent<CharacterController> ();
        FPSCamera = GameObject.FindGameObjectWithTag ("PlayerCamera").GetComponent<Camera> ();

        Cursor.visible = false;
        Cursor.lockState = CursorLockMode.Locked;

        cmd = new Cmd();

        audio = GetComponent<AudioSource> ();
        ignorePlayer = ~(ignorePlayer);
    }

    public float cmdScale() {
        float max;
        float total;

        max = Mathf.Abs(cmd.forwardMove);
        if (Mathf.Abs(cmd.rightMove) > max)
            max = Mathf.Abs(cmd.rightMove);
        if (max <= 0f)
            return 0f;

        total = Mathf.Sqrt(cmd.forwardMove * cmd.forwardMove + cmd.rightMove * cmd.rightMove);
        return moveSpeed * max / (moveScale * total);
    }

    void PlayJumpSound() {
        if (audio.isPlaying) {
            return;
        }

        audio.clip = jumpSounds [UnityEngine.Random.Range (0, jumpSounds.Length)];
        audio.Play();
    }

    //Movement
    void SetMovementDir() {
        cmd.forwardMove = player.GetAxisRaw ("MoveForward");
        cmd.rightMove = player.GetAxisRaw ("Strafe");
    }

    void QueueJump() {
        if (player.GetButtonDown ("Jump") && !wishJump) {
            wishJump = true;
        }

        if (player.GetButtonUp ("Jump")) {
            wishJump = false;
        }
    }

    void Accelerate(Vector3 wishDir, float wishSpeed, float accel) {
        float addSpeed;
        float accelSpeed;
        float currentSpeed;

        currentSpeed = Vector3.Dot (playerVelocity, wishDir);
        addSpeed = wishSpeed - currentSpeed;

        if (addSpeed <= 0f) {
            return;
        }

        accelSpeed = accel * Time.deltaTime * wishSpeed;

        if (accelSpeed > addSpeed) {
            accelSpeed = addSpeed;
        }

        playerVelocity.x += accelSpeed * wishDir.x;
        playerVelocity.z += accelSpeed * wishDir.z;
    }

    void AirControl(Vector3 wishDir, float wishSpeed) {
        float zSpeed;
        float speed;
        float dot;
        float k;

        if (cmd.forwardMove == 0f || wishSpeed == 0f) {
            return;
        }

        zSpeed = playerVelocity.y;
        playerVelocity.y = 0f;

        speed = playerVelocity.magnitude;
        playerVelocity.Normalize ();

        dot = Vector3.Dot (playerVelocity, wishDir);
        k = 32f;
        k *= airControl * dot * dot * Time.deltaTime;

        if (dot > 0f) {
            playerVelocity = new Vector3 (playerVelocity.x * speed * wishDir.x * k,
                playerVelocity.y * speed * wishDir.y * k,
                playerVelocity.z * speed * wishDir.z * k);

            playerVelocity.Normalize ();
        }

        playerVelocity *= speed;
        playerVelocity.y = zSpeed;  
    }

    void AirMove() {
        Vector3 wishDir;
        float wishVelocity = airAcceleration;
        float accel;

        float scale = cmdScale ();

        SetMovementDir ();

        wishDir = new Vector3 (cmd.rightMove, 0f, cmd.forwardMove);
        wishDir = transform.TransformDirection (wishDir);
        wishDir.Normalize ();
        moveDirection = wishDir;

        float wishSpeed = wishDir.magnitude;
        wishSpeed *= moveSpeed;

        float wishSpeed2 = wishSpeed;
        if (Vector3.Dot (playerVelocity, wishDir) < 0f) {
            accel = airDecceleration;
        } else {
            accel = airAcceleration;
        }

        if (cmd.forwardMove == 0f && cmd.rightMove != 0f) {
            if (wishSpeed > wishSpeedGlobal) {
                wishSpeed = wishSpeedGlobal;
            }
            accel = strafeAcceleration;

            Accelerate (wishDir, wishSpeed, accel);

            if (airControl != 0f) {
                AirControl (wishDir, wishSpeed2);
            }
        } else {
            Accelerate (wishDir, wishSpeed, accel);
        }

        if ((controller.collisionFlags == CollisionFlags.Above) == true) {
            if (playerVelocity.y > 0) {
                playerVelocity.y = 0f;
            }
        }

        playerVelocity.y += Physics.gravity.y * Time.deltaTime;
        if (-playerVelocity.y > terminalVelocity) {
            playerVelocity.y = -terminalVelocity;
        }
    }

    void ApplyFriction (){
        Vector3 vec = playerVelocity;
        float speed;
        float newSpeed;
        float control;
        float drop;

        vec.y = 0f;
        speed = vec.magnitude;

        drop = 0f;

        if (isGrounded()) {
            control = speed < runDecceleration ? runDecceleration : speed;
            drop = control * friction * Time.deltaTime;
        }

        newSpeed = speed - drop;
        playerFriction = newSpeed;

        if (newSpeed < 0f) {
            newSpeed = 0f;
        }
        if (speed > 0f) {
            newSpeed /= speed;
        }

        playerVelocity.x *= newSpeed;
        playerVelocity.z *= newSpeed;
    }

    void GroundMove() {
        Vector3 wishDir;

        if (!wishJump) {
            ApplyFriction ();
        }

        float scale = cmdScale ();

        SetMovementDir ();

        wishDir = new Vector3 (cmd.rightMove, 0f, cmd.forwardMove);
        wishDir = transform.TransformDirection (wishDir);
        wishDir.Normalize ();
        moveDirection = wishDir;

        float wishSpeed = wishDir.magnitude;
        wishSpeed *= moveSpeed;

        Accelerate (wishDir, wishSpeed, runAcceleration);
  
        playerVelocity.y = -controller.stepOffset / Time.deltaTime;
    }

    float remap(float input, float min1, float max1, float min2, float max2)
    {
        return min2 + (input-min1)*(max2-min2)/(max1-min1);
    }

    void Headbob (float downwardVelocity){
        if (controller.isGrounded && downwardVelocity < -headbobThreshold) {
            isBobbing = true;
        }

        if (isBobbing) {
            headbobTimer += Time.deltaTime / headbobLength;
            if (headbobTimer > headbobLength) {
                isBobbing = false;
            }
        }

        if (!isBobbing) {
            headbobTimer = 0f;
        }
    }

    void BumpDetection () {

    }

    bool isGrounded () {
        float internalCheckDistance = groundCheckDistance;
        Vector3 origin = transform.position + controller.velocity * Time.deltaTime;
        origin.y = origin.y - controller.height / 2f + controller.radius;
        Ray ray = new Ray (origin, Vector3.down);

        if (jumpGrounded ()) {
            groundCheckDistance = controller.stepOffset;
        }

        if (Physics.SphereCast (ray, controller.radius, groundCheckDistance, ignorePlayer)) {
            return true;
        }

        return false;
    }

    bool jumpGrounded() {
        Vector3 origin = transform.position;
        origin.y = origin.y - controller.height / 2f + controller.radius;
        Ray ray = new Ray (origin, Vector3.down);

        if (Physics.SphereCast (ray, controller.radius, groundCheckDistance, ignorePlayer)) {
            return true;
        }

        return false;
    }

    void Update () {
        if (!isDead) {
            cameraRotX -= player.GetAxisRaw ("LookVertical") * mouseXSensitivity * Time.deltaTime;
            cameraRotY += player.GetAxisRaw ("LookHorizontal") * mouseYSensitivity * Time.deltaTime;
        }

        if (cameraRotX < -90f) {
            cameraRotX = -90f;
        } else if (cameraRotX > 90f) {
            cameraRotX = 90f;
        }

        transform.rotation = Quaternion.Euler (0f, cameraRotY, 0f);
        FPSCamera.transform.rotation = Quaternion.Euler (cameraRotX, cameraRotY, 0f);

        QueueJump ();
        if (isGrounded() && !isGrappling && !isDead) {
            GroundMove ();
        } else if (!isGrounded() && !isGrappling && !isDead) {
            AirMove ();
        }

        if (jumpGrounded () && !isGrappling && !isDead) {
            if (wishJump) {
                playerVelocity.y = jumpSpeed;
                wishJump = false;
                PlayJumpSound();
            }
        }

        if (isDead) {
            playerVelocity = new Vector3 (0f,0f,0f);
        }

        if (!isGrounded() && wasGrounded && playerVelocity.y <= 0f) {
            playerVelocity.y = 0f;
        }
          

        if (!controller.isGrounded) {
            previousVelocity = controller.velocity.y;
        }
        Headbob (previousVelocity);

        controller.Move (playerVelocity * Time.deltaTime);

        wasGrounded = controller.isGrounded;
    }
}

Okay, so I had an idea on how to fix my current problem with slopes, but I’m not sure how to implement it.

Basically, the problem now is that I’m not calculating isGrounded() as accurately as I could. What I’m doing right now is basically

  • Take the predicted position next frame and set that as the origin point of the spherecast, with an offset accounting for the character controller’s height
  • Construct a ray that basically amounts to (predictedPosition, -vector3.up)
  • Cast a spherecast downward where the distance is either set to the stepOffset variable (0.5f), or the groundCheckDistance variable (0.1f).
  • The stepOffset variable is used when the controller is actually grounded (based on a spherecast projected from the actual position) while the groundCheckDistance one is used when the player isn’t grounded at all.

Unfortunately, this isn’t the best solution, as it leads to the player bouncing a lot if they land from a jump when going down slopes. I think this is because the isGrounded() spherecast is located inside the collider I want to check against. So I think I have part of a solution, but I have no idea how to implement it.

  • When the player is ACTUALLY grounded, project the origin of the spherecast forward based on the surface normal of what the player is standing on. (??? How ???)
  • Project down as normal.

If I’m thinking correctly, this should alleviate bouncing down hills when coming out of a jump, but I’ve no idea how to accomplish step one here.

I did some more rewrites and was able to solve the jumping up hills problem I was having with three lines of code, so hopefully the fix for bouncing down hills is just as simple? :wink: I simply made it so that the raycast groundCheckDistance is 0 when the player has a positive y velocity and that seemed to do the trick. Any suggestions for the downhill bounces though?

Update:

    void Update () {
        if (!isDead) {
            cameraRotX -= player.GetAxisRaw ("LookVertical") * mouseXSensitivity * Time.deltaTime;
            cameraRotY += player.GetAxisRaw ("LookHorizontal") * mouseYSensitivity * Time.deltaTime;
        }

        if (cameraRotX < -90f) {
            cameraRotX = -90f;
        } else if (cameraRotX > 90f) {
            cameraRotX = 90f;
        }

        transform.rotation = Quaternion.Euler (0f, cameraRotY, 0f);
        FPSCamera.transform.rotation = Quaternion.Euler (cameraRotX, cameraRotY, 0f);

        QueueJump ();
        if (isGrounded() && !isGrappling && !isDead) {
            GroundMove ();
        } else if (!isGrounded() && !isGrappling && !isDead) {
            AirMove ();
        }

        if (isGrounded() && !isGrappling && !isDead) {
            if (wishJump) {
                playerVelocity.y = jumpSpeed;
                wishJump = false;
                PlayJumpSound();
            }
        }

        if (isDead) {
            playerVelocity = new Vector3 (0f,0f,0f);
        }

        if (!isGrounded() && wasGrounded && playerVelocity.y <= 0f) {
            playerVelocity.y = 0f;
        }
           
        if (!controller.isGrounded) {
            previousVelocity = controller.velocity.y;
        }
        Headbob (previousVelocity);

        controller.Move (playerVelocity * Time.deltaTime);

        wasGrounded = controller.isGrounded;
    }

IsGrounded:

    bool isGrounded () {
        float internalCheckDistance = groundCheckDistance;
        Vector3 origin = transform.position + controller.velocity * Time.deltaTime;
        origin.y = origin.y - controller.height / 2f + controller.radius;
        Vector3 direction = -transform.up;

        Ray ray = new Ray (origin, direction);

        if (controller.isGrounded) {
            groundCheckDistance = controller.stepOffset;
        }

        if (!controller.isGrounded && controller.velocity.y > 0f) {
            groundCheckDistance = 0f;
        }

        if (Physics.SphereCast (ray, controller.radius, groundCheckDistance, ignorePlayer)) {
            return true;
        }

        return false;
    }

And the full source:

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

[RequireComponent(typeof(CharacterController))]
public class FPSController : MonoBehaviour {

    [HideInInspector]
    public int playerID;
    private Player player;
    private CharacterController controller;
    [HideInInspector]
    public Camera FPSCamera;

    [Header("Input Settings")]
    public float mouseXSensitivity = 30f;
    public float mouseYSensitivity = 30f;

    [Header("Movement Settings")]
    public float friction = 6f;
    public float moveSpeed = 7f;
    public float runAcceleration = 14f;
    public float runDecceleration = 10f;
    public float airAcceleration = 2f;
    public float airDecceleration = 2f;
    public float airControl = 0.3f;
    public float strafeSpeed = 1f;
    public float strafeAcceleration = 50f;
    public float jumpSpeed = 8.0f;
    public float moveScale = 1.0f;
    public float terminalVelocity = 20f;
    public float headbobThreshold = 15f;
    public float headbobLength = 1f;
    public float cameraMaxDistanceBob = 0.5f;
    public AnimationCurve headbobShape;
    private float headbobTimer = 0f;
    private bool isBobbing = false;
    private float previousVelocity = 0f;
    [HideInInspector]
    public float wishSpeedGlobal = 2f;

    [Header("Ground Detection Settings")]
    public float groundCheckDistance = 0.1f;
    private Vector3 contactPoint;
    public LayerMask ignorePlayer;
    private bool wasGrounded = false;

    [HideInInspector]
    public bool isGrappling = false;
    [HideInInspector]
    public bool isDead = false;

    [Header("Audio Settings")]
    public AudioClip[] jumpSounds;
    AudioSource audio;

    private float cameraRotX = 0f;
    private float cameraRotY = 0f;

    private Vector3 moveDirection = Vector3.zero;
    private Vector3 moveDirectionNorm = Vector3.zero;
    [HideInInspector]
    public Vector3 playerVelocity = Vector3.zero;
    private float playerFriction = 0f;

    private bool wishJump = false;

    public class Cmd {
        public float forwardMove;
        public float rightMove;
        public float upMove;
    }

    protected Cmd cmd;

    void Start () {
        player = ReInput.players.GetPlayer (playerID);
        controller = GetComponent<CharacterController> ();
        FPSCamera = GameObject.FindGameObjectWithTag ("PlayerCamera").GetComponent<Camera> ();

        Cursor.visible = false;
        Cursor.lockState = CursorLockMode.Locked;

        cmd = new Cmd();

        audio = GetComponent<AudioSource> ();
        ignorePlayer = ~(ignorePlayer);
    }

    public float cmdScale() {
        float max;
        float total;

        max = Mathf.Abs(cmd.forwardMove);
        if (Mathf.Abs(cmd.rightMove) > max)
            max = Mathf.Abs(cmd.rightMove);
        if (max <= 0f)
            return 0f;

        total = Mathf.Sqrt(cmd.forwardMove * cmd.forwardMove + cmd.rightMove * cmd.rightMove);
        return moveSpeed * max / (moveScale * total);
    }

    void PlayJumpSound() {
        if (audio.isPlaying) {
            return;
        }

        audio.clip = jumpSounds [UnityEngine.Random.Range (0, jumpSounds.Length)];
        audio.Play();
    }

    //Movement
    void SetMovementDir() {
        cmd.forwardMove = player.GetAxisRaw ("MoveForward");
        cmd.rightMove = player.GetAxisRaw ("Strafe");
    }

    void QueueJump() {
        if (player.GetButtonDown ("Jump") && !wishJump) {
            wishJump = true;
        }

        if (player.GetButtonUp ("Jump")) {
            wishJump = false;
        }
    }

    void Accelerate(Vector3 wishDir, float wishSpeed, float accel) {
        float addSpeed;
        float accelSpeed;
        float currentSpeed;

        currentSpeed = Vector3.Dot (playerVelocity, wishDir);
        addSpeed = wishSpeed - currentSpeed;

        if (addSpeed <= 0f) {
            return;
        }

        accelSpeed = accel * Time.deltaTime * wishSpeed;

        if (accelSpeed > addSpeed) {
            accelSpeed = addSpeed;
        }

        playerVelocity.x += accelSpeed * wishDir.x;
        playerVelocity.z += accelSpeed * wishDir.z;
    }

    void AirControl(Vector3 wishDir, float wishSpeed) {
        float zSpeed;
        float speed;
        float dot;
        float k;

        if (cmd.forwardMove == 0f || wishSpeed == 0f) {
            return;
        }

        zSpeed = playerVelocity.y;
        playerVelocity.y = 0f;

        speed = playerVelocity.magnitude;
        playerVelocity.Normalize ();

        dot = Vector3.Dot (playerVelocity, wishDir);
        k = 32f;
        k *= airControl * dot * dot * Time.deltaTime;

        if (dot > 0f) {
            playerVelocity = new Vector3 (playerVelocity.x * speed * wishDir.x * k,
                playerVelocity.y * speed * wishDir.y * k,
                playerVelocity.z * speed * wishDir.z * k);

            playerVelocity.Normalize ();
        }

        playerVelocity *= speed;
        playerVelocity.y = zSpeed;   
    }

    void AirMove() {
        Vector3 wishDir;
        float wishVelocity = airAcceleration;
        float accel;

        float scale = cmdScale ();

        SetMovementDir ();

        wishDir = new Vector3 (cmd.rightMove, 0f, cmd.forwardMove);
        wishDir = transform.TransformDirection (wishDir);
        wishDir.Normalize ();
        moveDirection = wishDir;

        float wishSpeed = wishDir.magnitude;
        wishSpeed *= moveSpeed;

        float wishSpeed2 = wishSpeed;
        if (Vector3.Dot (playerVelocity, wishDir) < 0f) {
            accel = airDecceleration;
        } else {
            accel = airAcceleration;
        }

        if (cmd.forwardMove == 0f && cmd.rightMove != 0f) {
            if (wishSpeed > wishSpeedGlobal) {
                wishSpeed = wishSpeedGlobal;
            }
            accel = strafeAcceleration;

            Accelerate (wishDir, wishSpeed, accel);

            if (airControl != 0f) {
                AirControl (wishDir, wishSpeed2);
            }
        } else {
            Accelerate (wishDir, wishSpeed, accel);
        }

        if ((controller.collisionFlags == CollisionFlags.Above) == true) {
            if (playerVelocity.y > 0) {
                playerVelocity.y = 0f;
            }
        }

        playerVelocity.y += Physics.gravity.y * Time.deltaTime;
        if (-playerVelocity.y > terminalVelocity) {
            playerVelocity.y = -terminalVelocity;
        }
    }

    void ApplyFriction (){
        Vector3 vec = playerVelocity;
        float speed;
        float newSpeed;
        float control;
        float drop;

        vec.y = 0f;
        speed = vec.magnitude;

        drop = 0f;

        if (isGrounded()) {
            control = speed < runDecceleration ? runDecceleration : speed;
            drop = control * friction * Time.deltaTime;
        }

        newSpeed = speed - drop;
        playerFriction = newSpeed;

        if (newSpeed < 0f) {
            newSpeed = 0f;
        }
        if (speed > 0f) {
            newSpeed /= speed;
        }

        playerVelocity.x *= newSpeed;
        playerVelocity.z *= newSpeed;
    }

    void GroundMove() {
        Vector3 wishDir;

        if (!wishJump) {
            ApplyFriction ();
        }

        float scale = cmdScale ();

        SetMovementDir ();

        wishDir = new Vector3 (cmd.rightMove, 0f, cmd.forwardMove);
        wishDir = transform.TransformDirection (wishDir);
        wishDir.Normalize ();
        moveDirection = wishDir;

        float wishSpeed = wishDir.magnitude;
        wishSpeed *= moveSpeed;

        Accelerate (wishDir, wishSpeed, runAcceleration);
   
        playerVelocity.y = -controller.stepOffset / Time.deltaTime;
    }

    float remap(float input, float min1, float max1, float min2, float max2)
    {
        return min2 + (input-min1)*(max2-min2)/(max1-min1);
    }

    void Headbob (float downwardVelocity){
        if (isGrounded() && downwardVelocity < -headbobThreshold) {
            isBobbing = true;
        }

        if (isBobbing) {
            headbobTimer += Time.deltaTime / headbobLength;
            if (headbobTimer > headbobLength) {
                isBobbing = false;
            }
        }

        if (!isBobbing) {
            headbobTimer = 0f;
        }
    }

    void BumpDetection () {

    }

    bool isGrounded () {
        float internalCheckDistance = groundCheckDistance;
        Vector3 origin = transform.position + controller.velocity * Time.deltaTime;
        origin.y = origin.y - controller.height / 2f + controller.radius;
        Vector3 direction = -transform.up;

        Ray ray = new Ray (origin, direction);

        if (controller.isGrounded) {
            groundCheckDistance = controller.stepOffset;
        }

        if (!controller.isGrounded && controller.velocity.y > 0f) {
            groundCheckDistance = 0f;
        }

        if (Physics.SphereCast (ray, controller.radius, groundCheckDistance, ignorePlayer)) {
            return true;
        }

        return false;
    }

    void Update () {
        if (!isDead) {
            cameraRotX -= player.GetAxisRaw ("LookVertical") * mouseXSensitivity * Time.deltaTime;
            cameraRotY += player.GetAxisRaw ("LookHorizontal") * mouseYSensitivity * Time.deltaTime;
        }

        if (cameraRotX < -90f) {
            cameraRotX = -90f;
        } else if (cameraRotX > 90f) {
            cameraRotX = 90f;
        }

        transform.rotation = Quaternion.Euler (0f, cameraRotY, 0f);
        FPSCamera.transform.rotation = Quaternion.Euler (cameraRotX, cameraRotY, 0f);

        QueueJump ();
        if (isGrounded() && !isGrappling && !isDead) {
            GroundMove ();
        } else if (!isGrounded() && !isGrappling && !isDead) {
            AirMove ();
        }

        if (isGrounded() && !isGrappling && !isDead) {
            if (wishJump) {
                playerVelocity.y = jumpSpeed;
                wishJump = false;
                PlayJumpSound();
            }
        }

        if (isDead) {
            playerVelocity = new Vector3 (0f,0f,0f);
        }

        if (!isGrounded() && wasGrounded && playerVelocity.y <= 0f) {
            playerVelocity.y = 0f;
        }
           
        if (!controller.isGrounded) {
            previousVelocity = controller.velocity.y;
        }
        Headbob (previousVelocity);

        controller.Move (playerVelocity * Time.deltaTime);

        wasGrounded = controller.isGrounded;
    }
}