12
\$\begingroup\$

I noticed a problem in Unity's third person controller's ground check.

The ground check should detect whether or not the player is standing on the ground. It does so by sending out a ray beneath the player.

However, if the player stands on top and in middle of two boxes and there is a space between these boxes, then the ray shoots into the gap and the player thinks he is not in contact with the ground, which looks like this:

enter image description here

enter image description here

I am unable to move. You can clearly see that the ray is in the gap and thus the player animator airborne blend tree is active.

What is the best way to solve this problem?

I was thinking of shooting multiple rays, from the same origin but with different angles. And OnGround should only be true, if X% of these rays hit the "ground". Or is there a better way?

\$\endgroup\$
19
\$\begingroup\$

Multiple rays works just fine in most cases, as described in the other answer.

You can also use a wider check — like a spherecast or boxcast. These use the same concept as a raycast, but with a geometric primitive that has some volume, so it can't slip into narrower cracks than your character could fall through. It also catches the case Shadows In Rain mentions, where your character is standing on a narrow pipe that might get missed by a raycast on each side of it.

A trigger collider that protrudes just a tiny bit below the bottom of your character's collider can accomplish a similar task. Like the sphere of box cast it has some width to detect ground on either side of a gap. Here you'd use OnTriggerEnter to detect when this ground sensor has come into contact with the ground.

\$\endgroup\$
  • 2
    \$\begingroup\$ Excellent answer as always, but isn't this method "heavier" on performance? I suppose that this way Unity has to calculate intersections with the sphere/box cast and the ground, so.. aren't raycasts a more performant way to do this? \$\endgroup\$ – user100681 Dec 2 '17 at 13:36
  • 9
    \$\begingroup\$ Not strictly speaking. A spherecast is mathematically quite similar to a raycast - we can think of it as just a single travelling point, but with a "thickness" offset. In my profiling it costs only about an extra 30-50% to check a full sphere instead of a single ray on average. Which means that firing one sphere instead of two rays can be a net savings in performance of up to ~25%. It's unlikely to make a big difference either way for short checks you're performing only a few times a frame, but you can always validate this by profiling a couple of options. \$\endgroup\$ – DMGregory Dec 2 '17 at 13:47
  • \$\begingroup\$ Sphere check is definitely the way to go with a capsule collider on an avatar. \$\endgroup\$ – Stephan Dec 2 '17 at 19:20
  • \$\begingroup\$ Is there a debug function for this? e.g. like Debug.DrawLine? It is hard to visualize, I am unable to write the script. \$\endgroup\$ – Black Dec 3 '17 at 11:17
  • 1
    \$\begingroup\$ @Black we could always write our own visualizing routine using Debug.DrawLine as a building block. :) \$\endgroup\$ – DMGregory Dec 3 '17 at 13:58
14
\$\begingroup\$

I honestly think that the "multiple rays" approach is quite a good idea. I would not shoot them at angle though, instead i would kind of offset the rays, something like this:

enter image description here

The player is the blue stickman; The green arrows rapresent the additional rays, and the the orange points (RaycastHits) are the points where the two rays hit the boxes.

Ideally the two green rays should be positioned right under the feet of the player, in order to get the most precision to check if the player is grounded or not ;)

\$\endgroup\$
  • 7
    \$\begingroup\$ Won't work while standing on edges or thin objects (like pipes). It's basically brute-force version of same flawed approach. If you are going to use it anyways, make sure pawn slips from edges by sliding it towards origin of missed ray (for each of them, and only if there is at least few of them). \$\endgroup\$ – Shadows In Rain Dec 2 '17 at 15:49
  • 2
    \$\begingroup\$ You'll need at least 3 with this approach to prevent both rays from skipping into the crack if facing the "lucky" direction. \$\endgroup\$ – Stephan Dec 2 '17 at 19:18
  • 3
    \$\begingroup\$ In a PS2 game I worked on, I did 25 sphere casts downward each frame (in a 5x5 grid pattern under the player), just to determine where the ground was beneath the player. Perhaps that was a little absurd, but if we could afford to do it on a PS2, you can afford to use a few extra collision tests on modern machines. :) \$\endgroup\$ – Trevor Powell Dec 3 '17 at 9:51
  • \$\begingroup\$ @TrevorPowell yeah, when i said "heavier" on performance i meant """"heavier"""" because i knew that it was not gonna make a big impact on the game, but i still wanted to know what was the most efficent way to to this :) \$\endgroup\$ – user100681 Dec 3 '17 at 9:58
  • 2
    \$\begingroup\$ (In all honesty, I've never been able to use that many collision tests since; that PS2 game engine had crazy-fast raycasts/spherecasts, and I wish I knew how it managed that). But having lots and lots of spherecasts was great; it meant I could detect cliffs and other ground features, to be a bit smarter about what height the player should be standing at. \$\endgroup\$ – Trevor Powell Dec 3 '17 at 10:04
1
\$\begingroup\$

I think I solved it by changing Physics.Raycast to Physics.SphereCast in the script ThirdPersonCharacter.cs. But it still needs testing.

bool condition = Physics.SphereCast(
    m_Capsule.transform.position + m_Capsule.center + (Vector3.up * 0.1f),
    m_Capsule.height / 2,
    Vector3.down, 
    out hitInfo,
    m_GroundCheckDistance
);

I also had to comment out this line which was changing the m_GroundCheckDistance value, otherwise there was some weird sliding at some models:

    void HandleAirborneMovement()
    {
        // apply extra gravity from multiplier:
        Vector3 extraGravityForce = (Physics.gravity * m_GravityMultiplier) - Physics.gravity;
        m_Rigidbody.AddForce(extraGravityForce);

        //m_GroundCheckDistance = m_Rigidbody.velocity.y < 0 ? m_OrigGroundCheckDistance : 0.01f;
    }

And I changed m_GroundCheckDistance = 0.1f; to m_GroundCheckDistance = m_OrigGroundCheckDistance;:

    void HandleGroundedMovement(bool crouch, bool jump)
    {
        // check whether conditions are right to allow a jump:
        if (jump && !crouch && m_Animator.GetCurrentAnimatorStateInfo(0).IsName("Grounded"))
        {
            // jump!
            m_Rigidbody.velocity = new Vector3(m_Rigidbody.velocity.x, m_JumpPower, m_Rigidbody.velocity.z);
            m_IsGrounded = false;
            m_Animator.applyRootMotion = false;
            m_GroundCheckDistance = m_OrigGroundCheckDistance;
        }
    }

Whole Script:

using UnityEngine;

namespace UnityStandardAssets.Characters.ThirdPerson
{
    [RequireComponent(typeof(Rigidbody))]
    [RequireComponent(typeof(CapsuleCollider))]
    [RequireComponent(typeof(Animator))]
    public class ThirdPersonCharacter : MonoBehaviour
    {
        [SerializeField] float m_MovingTurnSpeed = 360;
        [SerializeField] float m_StationaryTurnSpeed = 180;
        [SerializeField] float m_JumpPower = 12f;
        [Range(1f, 4f)][SerializeField] float m_GravityMultiplier = 2f;
        [SerializeField] float m_RunCycleLegOffset = 0.2f; //specific to the character in sample assets, will need to be modified to work with others
        [SerializeField] float m_MoveSpeedMultiplier = 1f;
        [SerializeField] float m_AnimSpeedMultiplier = 1f;
        [SerializeField] float m_GroundCheckDistance = 0.1f;

        Rigidbody m_Rigidbody;
        Animator m_Animator;
        bool m_IsGrounded;
        float m_OrigGroundCheckDistance;
        const float k_Half = 0.5f;
        float m_TurnAmount;
        float m_ForwardAmount;
        Vector3 m_GroundNormal;
        float m_CapsuleHeight;
        Vector3 m_CapsuleCenter;
        CapsuleCollider m_Capsule;
        bool m_Crouching;


        void Start()
        {
            m_Animator = GetComponent<Animator>();
            m_Rigidbody = GetComponent<Rigidbody>();
            m_Capsule = GetComponent<CapsuleCollider>();
            m_CapsuleHeight = m_Capsule.height;
            m_CapsuleCenter = m_Capsule.center;

            m_Rigidbody.constraints = RigidbodyConstraints.FreezeRotationX | RigidbodyConstraints.FreezeRotationY | RigidbodyConstraints.FreezeRotationZ;
            m_OrigGroundCheckDistance = m_GroundCheckDistance;
        }

        public void Move(Vector3 move, bool crouch, bool jump)
        {

            // convert the world relative moveInput vector into a local-relative
            // turn amount and forward amount required to head in the desired
            // direction.
            if (move.magnitude > 1f) move.Normalize();

            move = transform.InverseTransformDirection(move);
            CheckGroundStatus();
            move = Vector3.ProjectOnPlane(move, m_GroundNormal);
            m_TurnAmount = Mathf.Atan2(move.x, move.z);
            m_ForwardAmount = move.z;

            ApplyExtraTurnRotation();

            // control and velocity handling is different when grounded and airborne:
            if (m_IsGrounded) {
                HandleGroundedMovement(crouch, jump);
            } else {
                HandleAirborneMovement();
            }

            ScaleCapsuleForCrouching(crouch);
            PreventStandingInLowHeadroom();

            // send input and other state parameters to the animator
            UpdateAnimator(move);


        }

        void ScaleCapsuleForCrouching(bool crouch)
        {
            if (m_IsGrounded && crouch)
            {
                if (m_Crouching) return;
                m_Capsule.height = m_Capsule.height / 2f;
                m_Capsule.center = m_Capsule.center / 2f;
                m_Crouching = true;
            }
            else
            {
                Ray crouchRay = new Ray(m_Rigidbody.position + Vector3.up * m_Capsule.radius * k_Half, Vector3.up);
                float crouchRayLength = m_CapsuleHeight - m_Capsule.radius * k_Half;
                if (Physics.SphereCast(crouchRay, m_Capsule.radius * k_Half, crouchRayLength, Physics.AllLayers, QueryTriggerInteraction.Ignore))
                {
                    m_Crouching = true;
                    return;
                }
                m_Capsule.height = m_CapsuleHeight;
                m_Capsule.center = m_CapsuleCenter;
                m_Crouching = false;
            }
        }

        void PreventStandingInLowHeadroom()
        {
            // prevent standing up in crouch-only zones
            if (!m_Crouching)
            {
                Ray crouchRay = new Ray(m_Rigidbody.position + Vector3.up * m_Capsule.radius * k_Half, Vector3.up);
                float crouchRayLength = m_CapsuleHeight - m_Capsule.radius * k_Half;
                if (Physics.SphereCast(crouchRay, m_Capsule.radius * k_Half, crouchRayLength, Physics.AllLayers, QueryTriggerInteraction.Ignore))
                {
                    m_Crouching = true;
                }
            }
        }

        void UpdateAnimator(Vector3 move)
        {
            // update the animator parameters
            m_Animator.SetFloat("Forward", m_ForwardAmount, 0.1f, Time.deltaTime);
            m_Animator.SetFloat("Turn", m_TurnAmount, 0.1f, Time.deltaTime);
            m_Animator.SetBool("Crouch", m_Crouching);
            m_Animator.SetBool("OnGround", m_IsGrounded);
            if (!m_IsGrounded) {
                m_Animator.SetFloat("Jump", m_Rigidbody.velocity.y);
            }

            // calculate which leg is behind, so as to leave that leg trailing in the jump animation
            // (This code is reliant on the specific run cycle offset in our animations,
            // and assumes one leg passes the other at the normalized clip times of 0.0 and 0.5)
            float runCycle =
                Mathf.Repeat(m_Animator.GetCurrentAnimatorStateInfo(0).normalizedTime + m_RunCycleLegOffset, 1);

            float jumpLeg = (runCycle < k_Half ? 1 : -1) * m_ForwardAmount;
            if (m_IsGrounded) {
                m_Animator.SetFloat("JumpLeg", jumpLeg);
            }

            // the anim speed multiplier allows the overall speed of walking/running to be tweaked in the inspector,
            // which affects the movement speed because of the root motion.
            if (m_IsGrounded && move.magnitude > 0) {
                m_Animator.speed = m_AnimSpeedMultiplier;
            } else {
                // don't use that while airborne
                m_Animator.speed = 1;
            }
        }

        void HandleAirborneMovement()
        {
            // apply extra gravity from multiplier:
            Vector3 extraGravityForce = (Physics.gravity * m_GravityMultiplier) - Physics.gravity;
            m_Rigidbody.AddForce(extraGravityForce);

            //m_GroundCheckDistance = m_Rigidbody.velocity.y < 0 ? m_OrigGroundCheckDistance : 0.01f;
        }

        void HandleGroundedMovement(bool crouch, bool jump)
        {
            // check whether conditions are right to allow a jump:
            if (jump && !crouch && m_Animator.GetCurrentAnimatorStateInfo(0).IsName("Grounded"))
            {
                // jump!
                m_Rigidbody.velocity = new Vector3(m_Rigidbody.velocity.x, m_JumpPower, m_Rigidbody.velocity.z);
                m_IsGrounded = false;
                m_Animator.applyRootMotion = false;
                //m_GroundCheckDistance = 0.1f;
            }
        }

        void ApplyExtraTurnRotation()
        {
            // help the character turn faster (this is in addition to root rotation in the animation)
            float turnSpeed = Mathf.Lerp(m_StationaryTurnSpeed, m_MovingTurnSpeed, m_ForwardAmount);
            transform.Rotate(0, m_TurnAmount * turnSpeed * Time.deltaTime, 0);
        }

        public void OnAnimatorMove()
        {
            // we implement this function to override the default root motion.
            // this allows us to modify the positional speed before it's applied.
            if (m_IsGrounded && Time.deltaTime > 0)
            {
                Vector3 v = (m_Animator.deltaPosition * m_MoveSpeedMultiplier) / Time.deltaTime;

                // we preserve the existing y part of the current velocity.
                v.y = m_Rigidbody.velocity.y;
                m_Rigidbody.velocity = v;
            }
        }

        void CheckGroundStatus()
        {
            RaycastHit hitInfo;

#if UNITY_EDITOR
            // helper to visualise the ground check ray in the scene view

            Debug.DrawLine(
                m_Capsule.transform.position + m_Capsule.center + (Vector3.up * 0.1f),
                m_Capsule.transform.position + (Vector3.down * m_GroundCheckDistance), 
                Color.red
            );

#endif
            // 0.1f is a small offset to start the ray from inside the character
            // it is also good to note that the transform position in the sample assets is at the base of the character
            bool condition = Physics.SphereCast(
                m_Capsule.transform.position + m_Capsule.center + (Vector3.up * 0.1f),
                m_Capsule.height / 2,
                Vector3.down, 
                out hitInfo,
                m_GroundCheckDistance
            );

            if (condition) {
                m_IsGrounded = true;
                m_GroundNormal = hitInfo.normal;
                m_Animator.applyRootMotion = true;

            } else {
                m_IsGrounded = false;
                m_GroundNormal = Vector3.up;
                m_Animator.applyRootMotion = false;
            }
        }
    }
}
\$\endgroup\$
0
\$\begingroup\$

Why don't use Unity's OnCollisionStay function?

Pros:

  • You don't have to create raycast.

  • It's more accurate than raycast: Raycast is a shoot-to-check method, if your raycast shooting is not enough coverage, then it leads to bug which is the reason why you asked this question. OnCollisionStay method literally checks if something is touching - it perfectly fit for the purpose checking if the player is touching the ground (or anything that the player can land on).

For code and demo, check this answer: http://answers.unity.com/answers/1547919/view.html

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.