점프를 구현함에 있어 첫 번째로 봉착하게 되는 문제는 땅에 닿아있는 상태를 확인하는 겁니다. 저번 시간에 구현한 점프 기능은, 공중에서도 몇 번이고 점프가 가능하다는 문제가 있었죠. 이래서는 제대로 된 게임이 되기는 글러먹었습니다. 땅에 닿아있을 때만 점프가 가능하게 만들면 좋겠습니다. 물론 로직 자체는 어려운 게 아니죠.

// player can jump only if IsOnGround
if (Input.GetKeyDown(KeyCode.Space) && IsOnGround())
    {
        rb.velocity += Vector2.up * 10f;
    }

문제는 이걸 어떻게 확인하느냐는 겁니다. 다행스럽게도 여기에는 적지 않은 수의 해결방법이 존재합니다. 몇 가지 잘 알려진 방법을 골라 소개해드리겠습니다. 이 글에서는 편의상 다음 방식들로 구분하겠습니다.

  • cast
  • Threshold
  • IsTouching

일단 결론부터 말씀드리자면 IsTouching이 적어도 2D게임을 만든다면 최선의 방식이라는 겁니다. 이제부터 각 방법들의 원리를 소개하고, 어떤 장단이 있는지를 알아보죠.

cast

유니티가 제공하는 cast methods는 마치 레이저를 쏘듯 지정된 방향으로 계속 충돌 검사를 하는 기능입니다. 레이저를 얼마나 길게 쏠지는 설정이 가능하죠. 이를 이용하면 플레이어 바로 밑에 바닥이 있는지 알 수 있습니다. 이 cast는 어떤 모양의 레이저를 쏠 것인지에 따라 Raycast, Boxcast, Spherecast 등 다양한 바리에이션을 제공하지만 우리가 원하는 목적을 위해서는 Raycast (또는 귀퉁이에 올라서는 경우를 고려한다면 Boxcast까지) 정도만 써도 됩니다.

코드는 다음과 같이 작성하면 됩니다.

bool IsOnGroundUsingCast => Physics2D.Raycast(transform.position, Vector3.down, 2f, 1 << LayerMask.NameToLayer("Ground")); 

이렇게 작성된 프로퍼티 IsOnGroundUsingCast는 플레이어의 한 가운데로부터 아래 방향으로 최대 2f 길이의 레이저를 쏴서 (LayerGround로 설정된) 바닥이 있는지 검사합니다.

using-cast

이제 바닥에 닿아있을 때에만 이 값은 true를 반환합니다. 그럼 된 걸까요?

아쉽게도 이 방식에는 몇 가지 고려해야 할 점들이 있습니다.

일단 레이저를 너무 길게 쏴서는 안 된다는 겁니다. 플레이어가 점프한 지 얼마되지 않는 순간에도 레이저는 바닥을 감지하고 있을 가능성이 있습니다. 이때 빠르게 점프 버튼을 연타해버리면 어떻게 될까요?

rapid

또다른 한 가지 문제는 Unity가 제공하는 Effector2D를 함께 사용할 때 발생합니다.

Effector2D

플랫포머 게임을 할 때 빠지지 않는 게 점프해서 올라갈 수 있는 여러 층의 바닥들입니다. 이 바닥은 밑에서 점프해서 올라갈 때는 충돌판정이 되지 않지만 위에서 내려올 때는 제대로 착지할 수 있어야겠죠. 이 기능을 쉽게 구현할 수 있도록 도와주는 컴포넌트가 바로 Effector2D입니다.

Effector2D

플레이어가 플랫폼을 통과하는 것을 제대로 보기 위해 색깔을 좀 바꿨습니다. Platform Effector 2D 속성을 추가해주면 우리가 흔히 생각하는 플랫포머 게임에서의 그 점프해서 올라갈 수 있는 플랫폼이 되죠.

Effector2D

잘 작동하는군요!

문제는 이게, cast와는 함께 쓰기에는 까다로워진다는 겁니다. cast는 실제 플레이어가 바닥과 충돌하는지와는 관계없이 그냥 있는지만을 검사하기 때문에, 플레이어가 플랫폼 사이를 지나가고 있을 때조차 casttrue를 반환합니다. 그리고 이는 전혀 예상하지 못한 상황에서 Double Jump 문제를 만들어버리죠.

EffectorDoubleJump

Threshold

이 방식은 충돌판정을 활용하지는 않습니다. 대신 착지한 상태의 대상은 속도가 0이라는 단순한 특성에 초점을 맞춰 플레이어의 상태를 확인합니다.

활용하는 특성이 단순하므로 이를 구현하는 것도 꽤 심플합니다.

float threshold = 0.001f;
    
bool IsOnGroundUsingThreshold => rb.velocity.y > -threshold && rb.velocity.y < threshold;

일단 역치를 설정해줍니다. 게임 개발에 관심을 가질 정도로 프로그래밍을 하신 분들이라면 다들 아실테지만, Unity에서 모든 물리와 관련된 변수들은 float자료형을 쓰고, 이러한 부동소수점에의 동등비교는 아무 의미가 없죠. 대신 충분히 작은 역치를 잡고, 이 작은 범위 안에 있을 경우 0으로 치는 겁니다.

threshold

이제 플레이어의 위아래 방향 움직임이 없을 때에만 true를 반환하는군요. 그럼 된 걸까요?

안타깝게도, 이 방식에는 한 가지 치명적인 문제가 있습니다.

zero

플레이어의 수직 방향 속도가 0이 되는 순간은 착지해있을 때 뿐만이 아닙니다.

threshold_inflection

점프의 최정점에 도달하는 순간, 이 방식은 true를 반환할 수도 있습니다. 물론 역치가 충분히 작다면 이 순간은 무척이나 짧기 때문에 어지간해서는 별 문제가 되지 않을 수도 있습니다만, 운이 없다면 의도치 않게 Double Jump가 발생할 수 있다는 점은 고려해야겠죠.

IsTouching

이 방법은 cast방식처럼 별도의 충돌체를 쏘거나, threshold방식처럼 본질을 벗어나지도 않습니다. 플레이어 자체의 충돌판정을 이용하죠. 이 방식을 활용하기 위해서는 ContanctFilter2D 변수를 만들어둘 필요가 있습니다.

[SerializeField]
ContactFilter2D groundFilter;
    
bool IsOnGroundUsingIsTouching => rb.IsTouching(groundFilter);

코드 자체는 굉장히 짧고 단순합니다. 대신 ContactFilter를 설정해 주어야합니다.

contact_filter

여기까지 해두면 IsTouching을 활용할 준비는 다 된 겁니다.

threshold

이제 바닥과 충돌한 순간에만 true를 반환합니다.

IsTouching을 활용할 때의 가장 큰 장점은, 위 두 방식을 이용했을 때의 문제점들이 해결된다는 겁니다. effector2d로 인해 충돌이 무시된 경우 false를 반환하기 때문에 플랫폼 사이에서 의도치 않은 점프가 발생하지 않으며, 바닥에 거의 닿은 상태에서 cast 길이 때문에 true를 반환하지 않습니다. 또한 본질적인 충돌 자체를 판단하기 때문에 정점에서 true를 반환할 우려도 없습니다. Unity에서도 이 방식을 쓰라고 추천하니까, 플랫포머 게임을 만든다면 이 방식을 쓰는 것을 고려해 봅시다.

그럼 문제는 없는 걸까요? 물론 없는 건 아닙니다.

IsTouchingRigidbody2D의 내장 메소드입니다. 3D게임을 만든다면, 안타깝게도 이 방법을 쓸 수가 없습니다! 애초에 Unity의 충돌 판정 메소드들은 Unity에서 자체제작했다기보다는 NVIDIA가 만들어놓은 물리 기능들을 활용한 것에 가깝고, 3D를 위한 IsTouching 메소드는 포함되지 않았죠.

그도 그럴만한게, 충돌판정 자체가 상당히 비싼 메소드라서, 3D에서 이 기능을 매 프레임마다 함부로 돌렸다가는 실행 성능을 보장할 수가 없을 겁니다. 2D에서는 이 비용이 큰 문제가 되지 않지만 3D에서는 그렇지 않습니다. 하지만 3D에서도 IsTouching과 비슷하게 착지를 판단할 수 있는 방법은 있습니다. 이건 나중에 다뤄보죠.

(다음은 코드 전문입니다. 시험해보고 싶다면 써보도록 합시다.)

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

public class DemoJump : MonoBehaviour
{
    private enum OnGroundType
    {
        CAST,
        THRESHOLD,
        ISTOUCHING,
    }

    Rigidbody2D rb;
    [SerializeField]
    private float moveSpeed = 3;

    [SerializeField]
    private OnGroundType groundType = OnGroundType.CAST;

    [SerializeField]
    private float threshold = 0.001f;
    [SerializeField]
    private ContactFilter2D groundFilter;

    // Start is called before the first frame update
    void Start()
    {
        rb = GetComponent<Rigidbody2D>();
    }

    // Update is called once per frame
    void Update()
    {
        float h = Input.GetAxisRaw("Horizontal");
        Vector3 pos = transform.position;
        pos.x += h * moveSpeed * Time.deltaTime;
        transform.position = pos;

        if (Input.GetKeyDown(KeyCode.Space) && IsOnGround())
        {
            rb.velocity += Vector2.up * 10f;
        }
    }

    private bool IsOnGroundUsingCast => Physics2D.Raycast(transform.position, Vector3.down, 2f, 1 << LayerMask.NameToLayer("Ground")); 
    private bool IsOnGroundUsingThreshold => rb.velocity.y > -threshold && rb.velocity.y < threshold;
    private bool IsOnGroundUsingIsTouching => rb.IsTouching(groundFilter);

    private bool IsOnGround()
    {
        bool result = false;

        switch (groundType)
        {
            case OnGroundType.CAST:
                result = IsOnGroundUsingCast;
                Debug.DrawRay(transform.position, Vector3.down, Color.green, 0.1f);
                Debug.Log("is on ground using cast : " + result);
                break;
            case OnGroundType.THRESHOLD:
                result = IsOnGroundUsingThreshold;
                Debug.Log("is on ground using threshold(" + rb.velocity.y + ", " + threshold + ") : " + result);
                break;
            case OnGroundType.ISTOUCHING:
                result = IsOnGroundUsingIsTouching;
                Debug.Log("is on ground using is touching : " + result);
                break;
        }

        return result;
    }
}