Unity

[Unity] IK를 이용한 자연스러운 경사 움직임

youcheachae 2025. 3. 6. 22:54

1인칭 플레이어 움직임을 만들 때 근본적인 문제점이 발생했다. 

플레이어가 어떻게 자연스럽게 경사를 올라가게 할 것인가? 

일반적인 애니메이션과 콜라이더를 사용하여 경사를 올라가면, 발이 다른 오브젝트의 매쉬를 뚫거나 발이 공중에 떠있게 될 것이다

실제로 경사를 올라갈 때 앞에 있는 발은 매쉬를 뚫고 들어가고, 뒤에 있는 발은 공중에 뜨게된다.

 

이를 해결하려면, 발에서 Ray를 쏴서 바닥과 발 사이의 각도를 구한 다음 발을 회전시켜야 할까?라고 생각하고 검색을 해보던 도중에, IK 기능을 발견했다. 

 

유튜브에 올라와 있는 강의를 참고해서 작성하였다

https://www.youtube.com/watch?v=rGB1ipH6DrM&t=133s

 


📝IK?                                                                                                           

 

일단. IK가 무엇인지 알아야 한다. 유니티 공식문서에 따르면 , 대부분의 애니메이션은 스켈레톤의 조인트 각도를 미리 정해진 값으로 회전하여 만든다. 자식 뼈대 (조인트)는 부모 뼈대 (조인트)의 회전에 따라 변하고, 이런 방식을 순 운동학 (FK)라고 한다.

 

하지만 이런 방법을 반대로 바라본다면 더욱 유용하다. 오브젝트를 기준으로 뼈대 (조인트) 값을 계산하여 적용하는 것이다. 예를 들어 플레이어가 다른 오브젝트를 건드리거나, 울퉁불퉁한 표면 위에 캐릭터의 두 발이 자연스럽게 밀착해 있도록 할 수 있다. 이러한 접근법을 역운동학 (IK)라고 한다. 

 

유니티 공식문서에 있는 IK 관련 이미지

 

IK를 적용하기 위해서는 아바타의 타입이 휴머노이드여야 한다.  


📝 사전준비                                                                                                           

1. 플레이어의 Animator의 Layer의 설정에서 IK Pass 옵션을 체크해준다 

2. 플레이어가 걸어갈 발판/경사로 등의 레이어를 Walkable로 지정해 준다 

 


📝구현                                                                                                            

PlayerAnimator.cs

public class PlayerAnimator : MonoBehaviour
{
    [Header("===Animator===")]
    [SerializeField] private Animator animator;

    [Range(0, 1f)]
    public float distanceToGround;

    public LayerMask layerMask;
    
    void Start()
    {
        animator = GetComponent<Animator>();
    }
    
    private void OnAnimatorIK(int layerIndex)
    {
    
    }
}

  void OnAnimatorIK(int layerIndex)

             애니메이션 IK(역운동학)을 설정하기 위한 콜백

              Animator Component가 내부 IK 시스템을 업데이트하기 직전에 호출

             콜백 안에 IK 목표 위치와 , 가중치를 설정하는 데 사용한다

 

private void OnAnimatorIK(int layerIndex)
    {
        // 애니메이터가 실행되는 매 프레임 실행
       if(animator != null) 
        {
            animator.SetIKPositionWeight(AvatarIKGoal.LeftFoot, 1f);
            animator.SetIKRotationWeight(AvatarIKGoal.LeftFoot, 1f);
            animator.SetIKPositionWeight(AvatarIKGoal.RightFoot, 1f);
            animator.SetIKRotationWeight(AvatarIKGoal.RightFoot, 1f);

animator.SetIKPositionWeight()

            공식문서에서는, IK 목표의 변환 가중치를 설정한다. ( 0 = IK전 원래 애니메이션에서 1 = 목표에서 )

            즉 가중치를 설정하여 원래 애니메이션과 , IK 조정 사이의 혼합정도를 결정한다.

  animator.SetIKRotationWeight()

            SetIKPositionWeight과 같이 회전에 대한 가중치를 설정한다.

🔖 가중치
가중치가 0일 때 : 원래 애니메이션이 100% 적용된다. IK는 전혀 영향을 주지 않는다
가중치가 1일 때 : 조정이 100% 적용된다. 원래 애니메이션은 무시된다
가중치가 0 ~ 1 사이일 때 : 원래 애니메이션과 IK 조정값이 해당 비율로 혼합된다

 

🔖  여기서 AvaterIkGoal은 Enum으로,
LeftFoot과 RightFoot은 Animator에 적용된 아바타의 
Left Leg의 Foot과 , Right Leg의 Foot를 의미한다. 

 

 

 

 // left foot 
 RaycastHit hit;
 Ray ray = new Ray(animator.GetIKPosition(AvatarIKGoal.LeftFoot) + Vector3.up, Vector3.down);
 
 Debug.DrawRay(ray.origin, ray.direction * (distanceToGround + 1f), Color.red);

 Ray 생성

             시작지점 :  아바타의 LeftFoot의 위치에서 1f 올라간 곳, 

             방향 : 시작지점에서 아래쪽 방향

 

  DrawRay로 레이를 그리면, 주황색 동그라미에서 시작하여 아래쪽으로 ray를 쏘고 있는 것을 확인할 수 있다. 

 

if (Physics.Raycast(ray, out hit, distanceToGround + 1f , LayerManager.Instance.IgnorePlayerLayer)) 
{

    if(hit.transform.gameObject.layer == LayerManager.Instance.WalkableLayerInt) 
    {
    	// 추가예정
    	// 플레이어 발 위치 , 회전 적용
    }
}

  Raycast

             플레이어를 제외한 오브젝트를 감지해야 하기 때문에 PlayerLayer을 제외한 레이어만 검사

             레이캐스트가 성공적으로 충돌체를 감지하면 , if문 실행 

 

해당 충돌체의 레이어가 'Walkable'이면?

             플레이어 발의 위치와 회전을 변경


(+) 레이어에 관해서 

layer을 검사할 땐 layerMask의 인덱스 값으로 검사해야 하기 때문에 GetMask를 사용해 준다

해당코드는 어디에 있던지 상관없다

[SerializeField] private LayerMask ignorePlayerLayer;   // 플레이어 레이어를 제외한 레이어
[SerializeField] private int walkableLayerInt;

void Start()
{
    walkableLayerInt = LayerMask.NameToLayer("Walkable");
}

PlayerLayer을 뺀 LayerMask 정보는 인스펙터 창에서 PlayerLayer을 제외한 다른 레이어들을 체크해주면 된다.


 

if (Physics.Raycast(ray, out hit, distanceToGround + 1f , LayerManager.Instance.IgnorePlayerLayer)) 
{
    if(hit.transform.gameObject.layer == LayerManager.Instance.WalkableLayerInt) 
    {
        Vector3 footPosition = hit.point;
        footPosition.y += distanceToGround;
        animator.SetIKPosition(AvatarIKGoal.LeftFoot, footPosition);
        animator.SetIKRotation(AvatarIKGoal.LeftFoot, Quaternion.LookRotation(transform.forward, hit.normal));
    }
}

 해당 충돌체의 레이어가 'Walkable'이면?

            발의 위치

                        hit.point(레이가 충돌한 정확한 지점)를 footPosition으로 사용

            footPosition.y += distanceToGround

                       발이 지면에 완전히 묻히지 않고 자연스럽게 위치하도록 약간의 오프셋을 주기 위함

                       현재 distanceToGround는 0.06f 정도로 설정되어있다

             발의 회전

                         Quaternion.LookRotation을 사용해서 플레이어가 바라보고 있는 방향으로,

                         hit의 법선 벡터와 일치하도록 hit.normal을 사용해서 회전값을 줌

 

전체코드

public class PlayerAnimator : MonoBehaviour
{
    [Header("===Animator===")]
    [SerializeField] private Animator animator;

    [Range(0, 1f)]
    public float distanceToGround;	// 0.06f

    void Start()
    {
        animator = GetComponent<Animator>();
    }

    #region Foot Ik
    

    private void OnAnimatorIK(int layerIndex)
    {
        // 애니메이터가 실행되는 매 프레임 실행

        if(animator != null) 
        {
            animator.SetIKPositionWeight(AvatarIKGoal.LeftFoot, 1f);
            animator.SetIKRotationWeight(AvatarIKGoal.LeftFoot, 1f);
            animator.SetIKPositionWeight(AvatarIKGoal.RightFoot, 1f);
            animator.SetIKRotationWeight(AvatarIKGoal.RightFoot, 1f);

            // left foot 
            RaycastHit hit;
            Ray ray = new Ray(animator.GetIKPosition(AvatarIKGoal.LeftFoot) + Vector3.up / 2, Vector3.down);

            Debug.DrawRay(ray.origin, ray.direction * (distanceToGround + 1f), Color.red);

            if (Physics.Raycast(ray, out hit, distanceToGround + 1f , LayerManager.Instance.IgnorePlayerLayer)) 
            {
                if(hit.transform.gameObject.layer == LayerManager.Instance.WalkableLayerInt) 
                {
                    Vector3 footPosition = hit.point;
                    footPosition.y += distanceToGround;
                    animator.SetIKPosition(AvatarIKGoal.LeftFoot, footPosition);
                    animator.SetIKRotation(AvatarIKGoal.LeftFoot, Quaternion.LookRotation(transform.forward, hit.normal));
                }
            }

            // 오른발
            ray = new Ray(animator.GetIKPosition(AvatarIKGoal.RightFoot) + Vector3.up, Vector3.down);

            if (Physics.Raycast(ray, out hit, distanceToGround + 1f, LayerManager.Instance.IgnorePlayerLayer))
            {              
                if (hit.transform.gameObject.layer == LayerManager.Instance.WalkableLayerInt)
                {

                    Vector3 footPosition = hit.point;
                    footPosition.y += distanceToGround;
                    animator.SetIKPosition(AvatarIKGoal.RightFoot, footPosition);
                    animator.SetIKRotation(AvatarIKGoal.RightFoot, Quaternion.LookRotation(transform.forward, hit.normal));
                }
            }
        }
    }
    
    #endregion

 


📝 결과                                                                                                              

IK를 적용하기 전 모습

IK를 적용한 후

 


 

 OnAnimatorIK

https://docs.unity3d.com/kr/530/ScriptReference/MonoBehaviour.OnAnimatorIK.html

 

MonoBehaviour-OnAnimatorIK(int) - Unity 스크립팅 API

Callback for setting up animation IK (inverse kinematics).

docs.unity3d.com

 

SetIKPositionWeight

https://docs.unity3d.com/kr/530/ScriptReference/Animator.SetIKPositionWeight.html

 

Animator-SetIKPositionWeight - Unity 스크립팅 API

Sets the translative weight of an IK goal (0 = at the original animation before IK, 1 = at the goal).

docs.unity3d.com

 

역운동학 (IK)

https://docs.unity3d.com/kr/530/Manual/InverseKinematics.html

 

역운동학(IK) - Unity 매뉴얼

대부분의 애니메이션은 스켈레톤의 조인트 각도를 미리 정해진 값으로 회전하여 만듭니다. 자식 조인트의 포지션은 부모의 회전에 따라 변하므로 조인트 체인의 끝 점은 체인에 포함된 각 조인

docs.unity3d.com

 


깃허브

https://github.com/kimYouChae/Sparta_OnlyUp