Horizontal Gravity with Character Controller

Hi,

I’m currently trying to create a character controller based off of the new third-person controller from the new standard assets package released by Unity. My goal is to be able to change the direction of gravity into all four directions on demand (up, down, left, right). I’ve got the up and down directions working as intended, but I’ve reached my limits trying to figure out what I’m doing wrong with horizontal gravity.

In my frustration, my code is a little unoptimized and a bit of a mess. I would appreciate any help in trying to identify what I’m doing wrong or what I’m missing here. I’ve got the gravitational force working as intended but I’m missing something to produce the correct walking ability and animation. Here’s a video of how it looks and the code as well.

pzhn4f

using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(PlayerInput))]
public class PlayerController : MonoBehaviour
{
    [Header("Player")]
    [Tooltip("Move speed of the character in m/s")]
    public float MoveSpeed = 2.0f;
    [Tooltip("Sprint speed of the character in m/s")]
    public float SprintSpeed = 5.335f;
    [Tooltip("How fast the character turns to face movement direction")]
    [Range(0.0f, 0.3f)]
    public float RotationSmoothTime = 0.12f;
    [Tooltip("Acceleration and deceleration")]
    public float SpeedChangeRate = 10.0f;

    [Space(10)]
    [Tooltip("The height the player can jump")]
    public float JumpHeight = 1.2f;

    [Space(10)]
    [Tooltip("Time required to pass before being able to jump again. Set to 0f to instantly jump again")]
    public float JumpTimeout = 0.50f;
    [Tooltip("Time required to pass before entering the fall state. Useful for walking down stairs")]
    public float FallTimeout = 0.15f;

    [Header("Player Grounded")]
    [Tooltip("If the character is grounded or not. Not part of the CharacterController built in grounded check")]
    public bool Grounded = true;
    [Tooltip("Useful for rough ground")]
    public float GroundedOffset = -0.14f;
    [Tooltip("The radius of the grounded check. Should match the radius of the CharacterController")]
    public float GroundedRadius = 0.28f;
    [Tooltip("What layers the character uses as ground")]
    public LayerMask GroundLayers;

    [Header("Cinemachine")]
    [Tooltip("The follow target set in the Cinemachine Virtual Camera that the camera will follow")]
    public GameObject CinemachineCameraTarget;
    [Tooltip("How far in degrees can you move the camera up")]
    public float TopClamp = 70.0f;
    [Tooltip("How far in degrees can you move the camera down")]
    public float BottomClamp = -30.0f;
    [Tooltip("Additional degress to override the camera. Useful for fine tuning camera position when locked")]
    public float CameraAngleOverride = 0.0f;
    [Tooltip("For locking the camera position on all axis")]
    public bool LockCameraPosition = false;

    // player
    private float _speed;
    private float _animationBlend;
    private float _targetRotation = 0.0f;
    private float _rotationVelocityX;
    private float _rotationVelocityY;
    private float _rotationVelocityZ;
    private float _verticalVelocity;
    private float _horizontalVelocity;

    // timeout deltatime
    private float _jumpTimeoutDelta;
    private float _fallTimeoutDelta;

    // animation IDs
    private int _animIDSpeed;
    private int _animIDGrounded;
    private int _animIDJump;
    private int _animIDFreeFall;
    private int _animIDMotionSpeed;

    private Animator _animator;
    private CharacterController _controller;
    private PlayerControllerInput _input;
    private GameObject _mainCamera;

    private const float _threshold = 0.01f;

    private bool _hasAnimator;

    private Vector3 _desiredRotationAngles;
    private byte _rotationAxis = 1;

    private void Awake()
    {
        // get a reference to our main camera
        if (_mainCamera == null)
        {
            _mainCamera = GameObject.FindGameObjectWithTag("MainCamera");
        }
    }

    private void Start()
    {
        _hasAnimator = TryGetComponent(out _animator);
        _controller = GetComponent<CharacterController>();
        _input = GetComponent<PlayerControllerInput>();

        AssignAnimationIDs();

        // reset our timeouts on start
        _jumpTimeoutDelta = JumpTimeout;
        _fallTimeoutDelta = FallTimeout;

        _desiredRotationAngles = new Vector3(0.0f, 90.0f, 0.0f);
    }

    private void Update()
    {
        _hasAnimator = TryGetComponent(out _animator);

        JumpAndGravity();
        GroundedCheck();
        Move();
    }

    private void AssignAnimationIDs()
    {
        _animIDSpeed = Animator.StringToHash("Speed");
        _animIDGrounded = Animator.StringToHash("Grounded");
        _animIDJump = Animator.StringToHash("Jump");
        _animIDFreeFall = Animator.StringToHash("FreeFall");
        _animIDMotionSpeed = Animator.StringToHash("MotionSpeed");
    }

    private void GroundedCheck()
    {
        if (_input.Rotate)
        {
            Grounded = false;
            _controller.Move(_input.Gravity.normalized * Time.deltaTime * 1.5f);
            return;
        }

        // set sphere position, with offset
        Vector3 spherePosition = new Vector3(transform.position.x, transform.position.y - GroundedOffset, transform.position.z);
        Grounded = Physics.CheckSphere(spherePosition, GroundedRadius, GroundLayers, QueryTriggerInteraction.Ignore);

        // update animator if using character
        if (_hasAnimator)
        {
            _animator.SetBool(_animIDGrounded, Grounded);
        }
    }

    private void Move()
    {
        // set target speed based on move speed, sprint speed and if sprint is pressed
        float targetSpeed = SprintSpeed;

        // a simplistic acceleration and deceleration designed to be easy to remove, replace, or iterate upon

        // note: Vector2's == operator uses approximation so is not floating point error prone, and is cheaper than magnitude
        // if there is no input, set the target speed to 0
        if (_input.Move == Vector2.zero) targetSpeed = 0.0f;

        // a reference to the players current horizontal velocity
        float currentHorizontalSpeed = new Vector3(_controller.velocity.x, 0.0f, _controller.velocity.z).magnitude;

        float speedOffset = 0.1f;
        float inputMagnitude = _input.AnalogMovement ? _input.Move.magnitude : 1f;

        // accelerate or decelerate to target speed
        if (currentHorizontalSpeed < targetSpeed - speedOffset || currentHorizontalSpeed > targetSpeed + speedOffset)
        {
            // creates curved result rather than a linear one giving a more organic speed change
            // note T in Lerp is clamped, so we don't need to clamp our speed
            _speed = Mathf.Lerp(currentHorizontalSpeed, targetSpeed * inputMagnitude, Time.deltaTime * SpeedChangeRate);

            // round speed to 3 decimal places
            _speed = Mathf.Round(_speed * 1000f) / 1000f;
        }
        else
        {
            _speed = targetSpeed;
        }
        _animationBlend = Mathf.Lerp(_animationBlend, targetSpeed, Time.deltaTime * SpeedChangeRate);

        // normalise input direction
        Vector3 inputDirection = new Vector3(_input.Move.x, 0.0f, _input.Move.y).normalized;

        // note: Vector2's != operator uses approximation so is not floating point error prone, and is cheaper than magnitude
        // if there is a move input rotate player when the player is moving
        if (_input.Move != Vector2.zero)
        {
            if (_rotationAxis == 0)
            {
                // Movement rotates on X axis
                _targetRotation = inputDirection.x > 0 ? 90.0f : -90.0f;
                float rotationX = Mathf.SmoothDampAngle(transform.eulerAngles.x, _targetRotation, ref _rotationVelocityX, RotationSmoothTime);
                float rotationZ = Mathf.SmoothDampAngle(transform.eulerAngles.z, _desiredRotationAngles.z, ref _rotationVelocityZ, RotationSmoothTime * 2.0f);

                // Rotate to face input direction relative to camera position
                transform.rotation = Quaternion.Euler(rotationX, 0.0f, rotationZ);
            }
            else if (_rotationAxis == 1)
            {
                // Movement rotates on Y axis
                _targetRotation = inputDirection.x > 0 ? 90.0f : -90.0f;

                float rotationY = Mathf.SmoothDampAngle(transform.eulerAngles.y, _targetRotation, ref _rotationVelocityY, RotationSmoothTime);
                float rotationZ = Mathf.SmoothDampAngle(transform.eulerAngles.z, _desiredRotationAngles.z, ref _rotationVelocityZ, RotationSmoothTime * 2.0f);

                // Rotate to face input direction relative to camera position
                transform.rotation = Quaternion.Euler(0.0f, rotationY, rotationZ);
            }
        }
        else
        {
            if (_rotationAxis == 0)
            {
                // Movement rotates on X axis
                float rotationZ = Mathf.SmoothDampAngle(transform.eulerAngles.z, _desiredRotationAngles.z, ref _rotationVelocityZ, RotationSmoothTime * 2.0f);
                Quaternion expectedRotation = Quaternion.Euler(transform.rotation.eulerAngles.x, 0.0f, rotationZ);

                if (!transform.rotation.Equals(expectedRotation))
                    transform.rotation = expectedRotation;
            }
            else if (_rotationAxis == 1)
            {
                float rotationZ = Mathf.SmoothDampAngle(transform.eulerAngles.z, _desiredRotationAngles.z, ref _rotationVelocityZ, RotationSmoothTime * 2.0f);
                Quaternion expectedRotation = Quaternion.Euler(0.0f, transform.rotation.eulerAngles.y, rotationZ);

                if (!transform.rotation.Equals(expectedRotation))
                    transform.rotation = expectedRotation;
            }
        }

        if (_rotationAxis == 0)
        {
            Vector3 targetDirection = Quaternion.Euler(_targetRotation, 0.0f, _desiredRotationAngles.z) * Vector3.forward;

            // move the player
            _controller.Move(targetDirection.normalized * (_speed * Time.deltaTime) + new Vector3(_horizontalVelocity, _verticalVelocity, 0.0f) * Time.deltaTime);
        }
        else if (_rotationAxis == 1)
        {
            Vector3 targetDirection = Quaternion.Euler(0.0f, _targetRotation, _desiredRotationAngles.z) * Vector3.forward;

            // move the player
            _controller.Move(targetDirection.normalized * (_speed * Time.deltaTime) + new Vector3(_horizontalVelocity, _verticalVelocity, 0.0f) * Time.deltaTime);
        }


        // update animator if using character
        if (_hasAnimator)
        {
            _animator.SetFloat(_animIDSpeed, _animationBlend);
            _animator.SetFloat(_animIDMotionSpeed, inputMagnitude);
        }
    }

    private void JumpAndGravity()
    {
        Vector3 gravityNormal = _input.Gravity.normalized;

        if (gravityNormal.Equals(Vector3.up))
        {
            // Movement rotation axis is Y
            _desiredRotationAngles = new Vector3(0.0f, 90.0f, 180.0f);
            _rotationAxis = 1;
        }

        if (gravityNormal.Equals(Vector3.down))
        {
            // Movement rotation axis is Y
            _desiredRotationAngles = new Vector3(0.0f, 90.0f, 0.0f);
            _rotationAxis = 1;
        }

        if (gravityNormal.Equals(Vector3.right))
        {
            // Movement rotation axis is X
            _desiredRotationAngles = new Vector3(90.0f, 0.0f, 90.0f);
            _rotationAxis = 0;
        }

        if (gravityNormal.Equals(Vector3.left))
        {
            // Movement rotation axis is X
            _desiredRotationAngles = new Vector3(90.0f, 0.0f, -90.0f);
            _rotationAxis = 0;
        }

        if (Grounded)
        {
            // reset the fall timeout timer
            _fallTimeoutDelta = FallTimeout;

            // update animator if using character
            if (_hasAnimator)
            {
                _animator.SetBool(_animIDJump, false);
                _animator.SetBool(_animIDFreeFall, false);
            }

            if (gravityNormal.Equals(Vector3.up))
            {
                if (_verticalVelocity > 0.0f)
                {
                    _verticalVelocity = 2.0f;
                    _horizontalVelocity = 0.0f;
                }
            }

            if (gravityNormal.Equals(Vector3.down))
            {
                if (_verticalVelocity < 0.0f)
                {
                    _verticalVelocity = -2.0f;
                    _horizontalVelocity = 0.0f;
                }
            }

            if (gravityNormal.Equals(Vector3.right))
            {
                if (_horizontalVelocity > 0.0f)
                {
                    _horizontalVelocity = 2.0f;
                    _verticalVelocity = 0.0f;
                }
            }

            if (gravityNormal.Equals(Vector3.left))
            {
                if (_horizontalVelocity < 0.0f)
                {
                    _horizontalVelocity = -2.0f;
                    _verticalVelocity = 0.0f;
                }
            }

            // Jump
            if (_input.Jump && _jumpTimeoutDelta <= 0.0f)
            {
                // the square root of H * -2 * G = how much velocity needed to reach desired height
                float jumpVelocity = -_input.Gravity.normalized.y * Mathf.Sqrt(JumpHeight * -2.0f * -10.0f);

                if (_rotationAxis == 0)
                    _horizontalVelocity = jumpVelocity;
                else
                    _verticalVelocity = jumpVelocity;

                // update animator if using character
                if (_hasAnimator)
                {
                    _animator.SetBool(_animIDJump, true);
                }
            }

            // jump timeout
            if (_jumpTimeoutDelta >= 0.0f)
            {
                _jumpTimeoutDelta -= Time.deltaTime;
            }
        }
        else
        {
            // reset the jump timeout timer
            _jumpTimeoutDelta = JumpTimeout;

            // fall timeout
            if (_fallTimeoutDelta >= 0.0f)
            {
                _fallTimeoutDelta -= Time.deltaTime;
            }
            else
            {
                // update animator if using character
                if (_hasAnimator)
                {
                    _animator.SetBool(_animIDFreeFall, true);
                }
            }

            // if we are not grounded, do not jump
            _input.Jump = false;
        }

        // apply gravity over time if under terminal (multiply by delta time twice to linearly speed up over time)
        if (_rotationAxis == 0)
        {
            _horizontalVelocity += _input.Gravity.x * Time.deltaTime;
            _verticalVelocity = 0.0f;
        }
        else
        {
            _verticalVelocity += _input.Gravity.y * Time.deltaTime;
            _horizontalVelocity = 0.0f;
        }
    }

    private void OnDrawGizmosSelected()
    {
        Color transparentGreen = new Color(0.0f, 1.0f, 0.0f, 0.35f);
        Color transparentRed = new Color(1.0f, 0.0f, 0.0f, 0.35f);

        if (Grounded) Gizmos.color = transparentGreen;
        else Gizmos.color = transparentRed;

        // when selected, draw a gizmo in the position of, and matching radius of, the grounded collider
        Gizmos.DrawSphere(new Vector3(transform.position.x, transform.position.y - GroundedOffset, transform.position.z), GroundedRadius);
    }
}

Thanks :slight_smile:

The CharacterController operates under the assumption that up = Vector3.up at all times, as you can see here:

Despite being rotated, the capsule collider hasn’t changed. It uses that assumption in a lot of the internal logic as well, like the slope limit and step offset.

I don’t think there’s a way for you to make this work without switching to a rigidbody based controller.

One thing you could do, which may or may not be in poor taste, is rotating the entire world to solve your problem, instead of trying to use CharacterController in different directions.

I can’t believe I didn’t notice this was an issue. I guess I won’t be able to use the built-in CharacterController for my purposes.

I’m fine with using a Rigidbody based controller but haven’t made one before. I also have no idea how to get animations working, similar to how they are now, with a Rigidbody controller. I’ve been trying to search online but haven’t found anything that seems to show how to do that.

Do you know of any resources or anything that could help get me started in the right direction for this? Would really appreciate it:)

This is one of my biggest sore spots with Unity. Especially since under the hood PhysX (the physics engine for 3d used by Unity) has support for this very thing.

Yeah after it was pointed out here I ended up finding that thread. I guess it’s been a few years but nothing has come of it yet, even though they made a new standard assets package using the controller.

What have you done as a solution or workaround for this?