저자: DarkFlameMaster
언젠가는 이 거짓된 하늘을 반드시 찢어버리고 말겠어!
최근 언리얼 엔진의 스켈레탈 애니메이션 관련 지식을 공부하고 있는데, 가르치면서 배운다는 마음으로 여기에 애니메이션 관련 글을 써 보면서 배운 걸 바로바로 풀어내 보려 한다.
발 IK는 애니메이션 시스템의 신뢰도를 판단하는 중요한 기준 중 하나로, 기본적이지만 결코 만만치 않은 주제다. IK에는 다양한 알고리즘이 있고, 발 IK 역시 여러 가지 서로 다른 구현 방식이 존재한다(예를 들어 어쌔신 크리드는 예측 기반의 FootIK 방식을 채택하고 있다). 이 글은 ALSV4의 방식을 기반으로 삼아, 더 많은 가능성을 탐색해 보려 한다.
좋아요, 관심글, 스크랩, 댓글로 이 글이 필요한 더 많은 사람들에게 닿을 수 있게 해 주세요. 여러분의 응원은 저에게 큰 의미가 될 뿐 아니라, 이 글이 더 널리 퍼지는 데도 도움이 됩니다.
2022.9.2 업데이트: 3.2절 회전 오프셋의 수학적 유도 과정을 추가했다.
0.머리말
지식인(즈후)에는 이미 많은 선배 고수들이 발 IK의 구체적인 처리 과정을 자세히 설명해 놓았고, 어떤 훌륭한 글은 발 레이캐스트 관련 벡터 계산까지 그림으로 하나하나 풀어서 설명해 주기도 했다……

이미 이렇게 훌륭한 선례들이 있는데, 필자의 얕은 지식으로는 그보다 더 나은 글을 쓸 능력이 없다고 생각한다.
이 시리즈 글의 애초 취지를 돌이켜 보면, 이 글들은 "무엇인가"가 아니라 "왜인가"에 답하려는 시도였다. 그래서 이 글은 기본적으로 발 IK의 구체적인 구현 방법—블루프린트 연결, 벡터 계산, 변수 설정 등—은 다루지 않을 것이다. 대신 좀 더 높은 관점에서 FootIK 방식의 선택 문제를 바라보는 데 집중하려 한다.
필자는 아직 취업을 하지 않은 상태라, 최대한 엄밀하게 쓰려고는 했지만 아무래도 식견이 짧아 모든 면을 다 다루기는 어려웠다. 잘못되거나 빠진 부분이 있다면 독자 여러분의 따끔한 지적을 부탁드린다.
1.사전 지식
1.1 애니메이션 리타겟팅과 IK
애니메이션 리타겟팅이란, 한 세트의 스켈레톤에 만들어진 애니메이션 리소스를 다른 유사한 스켈레톤에 적용해, 애니메이션 리소스를 재사용하고 비용을 절감하기 위한 기술이다. UE4 에디터에서 구체적으로 어떻게 애니메이션 리타겟팅을 하는지는 이 글의 핵심이 아니다(UE5.0 이후로는 이 기능 자체가 폐기되고, 훨씬 강력한 업그레이드 버전으로 대체되었기 때문이다). 잘 모르는 독자라면 직접 검색해서 공부해 보길 권한다.
스켈레톤 구조는 비슷하지만 비율 차이가 큰 두 모델(예: 한쪽은 땅딸막하고 한쪽은 키가 크고 마른 경우)에서, 만약 두 모델이 동일한 스켈레톤을 그대로 공유한다면 뚱뚱한 모델은 억지로 늘어나 버리고, 키 큰 모델은 반대로 눌려 뭉쳐 버리게 된다.
(스크린샷 출처는 언리얼 엔진의 구버전 애니메이션 리타겟팅|언리얼 엔진 문서 (unrealengine.com)인데, 지금 가서 봐도 이 이미지들은 이미 로드되지 않는다.)

그래서 우리는 애니메이션 리타겟팅을 사용할 필요가 있고, 리타겟팅할 때 어떤 뼈들을 모델의 늘어남에 따라 함께 늘어나게 할지(정확히는, 모델이 늘어나는 만큼 관절이 그에 맞춰 이동하는 것이다. 예를 들어 새 모델의 팔뚝이 원본 애니메이션 모델의 팔뚝보다 길다면, 손목 관절은 더 많이 이동해야 하고, 결과적으로 팔뚝의 "뼈"가 길어진 것처럼 보인다), 어떤 뼈는 원본 애니메이션과 동일하게 유지할지, 어떤 뼈는 모델의 비율에 맞춰 스케일링할지를 결정하게 된다……
이것이 바로 스켈레톤의 이동(Translation) 리타겟팅 옵션이다.

일반적으로 루트 본, IK 본, 무기 본은 애니메이션(Animation) 모드를 사용한다. 골반은 스케일 애니메이션(Animation Scaled) 모드를 사용해 올바른 높이에 위치하도록 한다. 그 외 나머지 뼈들은 전부 스켈레톤(Skeleton) 모드를 사용한다. 여기서는 가장 자주 쓰이는 이 세 가지 이동 리타겟팅 모드를 하나씩 소개해 보겠다.
애니메이션(Animation) 모드: 이 뼈는 새 모델이 어떻게 생겼든 무시하고, 애니메이션 속 원본 뼈의 이동값을 충실하게 그대로 따른다. IK 뼈들을 전부 애니메이션 모드로 설정한다는 것은, 리타겟팅 시 추가적인 관절 이동이 발생하지 않고 원래의 움직임을 그대로 유지한다는 뜻이다. 이 변하지 않는 성질을 이용해서, 우리는 손과 발을 항상 원래의 위치에 고정시킬 수 있다. 이 특성은 관절을 고정된 위치에 락(lock)해야 하는 요구를 아주 잘 만족시켜 주며, 씬과의 상호작용에서 자주 쓰인다.
스케일 애니메이션(Animation Scaled) 모드: 애니메이션 속 원본 뼈의 이동값을 사용해 자신의 이동값을 설정하되, 원본 모델과 새 모델의 비율에 맞춰 이 이동값을 스케일링한다. 골반이 스케일 애니메이션 모드를 사용하면 캐릭터가 움직일 때 땅 위에 올바르게 서 있을 수 있다. 만약 애니메이션 모드를 사용한다면 땅딸막한 캐릭터의 골반은 위로 끌려 올라가고, 키 큰 캐릭터의 골반은 아래로 끌려 내려가게 된다.
스켈레톤(Skeleton) 모드: 애니메이션의 뼈 이동값을 무시하고, 곧바로 해당 모델(즉 새 모델)의 뼈 이동값을 그대로 사용한다. 대부분의 나머지 뼈들이 이 옵션을 사용한다. 주의할 점은, 만약 원본 애니메이션이 루피의 "고무 주먹" 혹은 "통배권"처럼 손목 관절을 앞으로 크게 이동시킨 동작이라면, 손목 관절을 여전히 스켈레톤 모드로 두었을 때 리타겟팅된 새 애니메이션에서는 손목 관절이 애니메이션의 이동값을 따라가지 않아서, 원하는 결과를 얻지 못하게 된다는 것이다.
여기서 독자 여러분에게 작은 생각거리 하나를 남겨 본다. 골반은 왜 "스케일 애니메이션" 이동 모드를 사용해야 할까?

【위 이미지 대화 내용 · 한국어 번역】
저자: 맞아.
질문자: 그럼 스케일 애니메이션(缩放动画) 모드는요?
질문자: 글에서는 캐릭터가 지면에 올바르게 서 있을 수 있게 해 준다고 쓰여 있던데, 그 정도 기능이라면 스켈레톤(骨骼) 모드의 이동으로도 할 수 있는 것 아닌가요?
저자: (에디터 스크린샷 2장) 누워 있는 상태에서 일어서는 이 애니메이션을 한번 봐 봐.
저자: 대부분의 뼈는 자기 자신의 이동값(평행이동)이 움직일 일이 거의 없어.
(15시간 전)
저자: 하지만 골반은 예외야. 골반은 root에서 곧바로 "자라난" 뼈 하나가 캐릭터 몸 한가운데에 꽂혀 있는 셈이라서.
저자: 달리기 애니메이션에서조차, 캐릭터 몸의 위아래 출렁임을 표현하려면 골반이라는 이 뼈를 위아래로 늘였다 줄였다 할 수밖에 없어.
저자: 다시 말해, 이동량이 끊임없이 늘었다 줄었다 변한다는 거지.
저자: 그래서 골반은 고정불변의 이동량을 쓸 수가 없어. 골반의 이동량은 계속해서 변하는 값이거든.
(15시간 전)
질문자: 이해했어요. 골반의 이동값이 변한다고 말씀하시니까 바로 이해됐네요.
리타겟팅 이야기는 이쯤 하고, 이제 IK와 리타겟팅의 관계에 대해 이야기해 보겠다.
IK라는 기술을 적용하려면, 먼저 아티스트가 DCC 툴에서 IK 스켈레톤 트리를 만들어 놓고, IK를 적용하고 싶은 부위의 IK 뼈가 몸속 정상적인 뼈와 함께 움직이도록 만들어 놓아야 한다는 전제가 필요하다.【1】
(Foot_L 뼈는 골반(Pelvis)에 속해 있고, ik_foot_l은 ik_foot_l 자신에게 속해 있어서, 이 둘은 서로 다른 두 개의 스켈레톤 서브트리 아래에 있다.【2】)

위아래 두 이미지를 보면, 이 두 뼈가 위치한 곳은 똑같다.(월드 스페이스 상의 좌표가 같다는 뜻이지, 스켈레톤 트리 상의 위치가 같다는 뜻은 아니다.)

이제 문 여는 애니메이션을 하나 만든다고 가정해 보자. 원본 애니메이션은 평범한 사람이 문을 여는 동작이고, 이걸 거인족 캐릭터에게 리타겟팅하려고 한다. 이때 IK 스켈레톤 트리의 이동 리타겟팅 옵션을 전부 애니메이션 모드로 설정하는 것의 의미가 드러난다. IK 뼈의 움직임은 여전히 원래의 높이를 그대로 유지하기 때문에, 새 애니메이션의 손 뼈를 다시 원래의 높이(즉 문 손잡이의 높이)에 맞춰 고정할 수 있게 된다.
【여담】
【1】IK 뼈를 왜 굳이 고정하려는 뼈와 같은 곳에 두고, 함께 움직이면서도 변환값이 같도록 설계하는 걸까? 어떤 뼈를 자신과 위치가 똑같은 IK 뼈에 고정하는 것이 무슨 의미가 있을까?
답: 단순히 봤을 때는 별 의미가 없어 보일 수 있다. 하지만 애니메이션 리타겟팅 후에는, 고정하려는 뼈가 모델 크기 차이 때문에 늘어나게 되지만(정확히는 관절이 이동하는 것이다), IK 뼈의 움직임은 여전히 변하지 않는다. 이때 IK 뼈는 "원본 애니메이션이 실제로 어떤 모습이었는지, 올바른 고정 위치가 어디인지"를 알려주는 역할을 하게 되어, 새로운 뼈가 여전히 우리가 원하는 위치에 고정될 수 있게 해 준다.
【2】IK 뼈와 발 뼈는 왜 굳이 두 개의 서로 다른 스켈레톤 서브트리에 두어야 할까? 둘 다 골반에 속하면 안 될까?
답: 만약 IK 뼈와 발 뼈가 둘 다 골반에 속한다면, 골반이나 허벅지 같은 뼈가 늘어날 때 IK 뼈도 함께 늘어나 버려서, 설령 자신은 애니메이션의 이동값을 그대로 유지한다 해도 최종적으로 도달하는 위치가 달라져 버린다.(그렇게 되면 거인족이 문을 열 때 손이 문 손잡이를 정확히 잡지 못하게 될 수도 있다.)그래서 IK 뼈는 독립된 트리를 이루어야, 트리의 뿌리부터 전부 애니메이션 이동 모드로 유지될 수 있다.
이런 설계의 디테일은 실제 작업 과정에서 정리되어 나온 것임을 알 수 있다. 겉보기엔 애니메이션 표현상의 문제처럼 보이는 것의 답이, 사실은 리소스 재사용 과정에서 생기는 문제에서 찾아야 한다는 점이 흥미롭다. 그러니 이 글을 읽고 있는 독자가 아직 취업 전이고, ALSV4를 공부하는 과정에서 이해되지 않는 설계가 있어도 조급해할 필요는 없다. 어떤 설계 철학은 실제로 업무 프로세스에 참여해 봐야만 몸으로 체감할 수 있는 것들이니까.
1.2 가상 뼈(Virtual Bone)
주: 가상 뼈에 대한 설명은, 아래 글을 보는 것을 더 추천한다.
(76 封私信 / 38 条消息) UE4 UE5 骨骼动画 高级运动系统 手部IK 虚拟骨骼 - 知乎

【위 이미지 설명 · 한국어 번역】
가상 뼈 개요 (Virtual Bone Overview)
가상 뼈는 다른 뼈의 변환(Transform)을 따르지만 서로 다른 본 스페이스(Bone Space)에 위치하는 뼈입니다. 대부분의 경우, 이는 가상 뼈가 루트 본(Root Bone)의 자식 노드가 되지만 손이나 발 같은 말단 본(End Bone)의 뒤를 따르게 됨을 의미합니다.
다시 말해, 가상 뼈는 해당 목표 본을 향하는 모든 관절의 전방 운동학(FK) 계층 구조에 따른 추가적인 영향을 받지 않고, 목표 본을 직접 따라갈 수 있습니다. 이 기능을 사용해 역운동학(IK) 시스템을 설정하고 캐릭터의 손발이 가상 뼈를 따르도록 만들면, 복잡한 애니메이션 시스템에서 발생하는 '슬라이딩 현상(Foot Sliding/Swimming)' 및 기타 손발 떨림 문제를 해결할 수 있습니다.
앞 절에서 IK 뼈는 독립된 트리를 이루고, 리타겟팅 시 애니메이션 모드를 선택하기 때문에 IK 뼈의 움직임은 원본 애니메이션의 방식을 완전히 유지한다고 이야기했다. 캐릭터가 씬 속 고정된 위치의 소품(예: 문)과 상호작용해야 할 때는 이 방식이 아주 유용하다. 손 뼈를 원본 애니메이션의 IK 뼈가 가리키는 위치로 끌어가기만 하면 되니까.
하지만 소품이 캐릭터 몸에 붙어 있는 경우, 예를 들어 활을 들고 있는 경우는 다르다. 만약 이 애니메이션을 거인(5m) 모델에 리타겟팅했는데, 우리의 IK 뼈는 표준 신장(1.8m)의 애니메이션을 그대로 따르기 때문에 여전히 1.8m 높이에서 움직인다면, 손 뼈를 그냥 IK 뼈에 바인딩해 버리면 손이 갑자기 아래로 확 끌려가 버릴 것이다. 우리가 원하는 건 거인이 거인의 가슴 높이(5m)에서 활을 드는 것이지, 1.8m 높이에서 활을 드는 게 아니다. 이런 상황에서는 단순히 IK 뼈만 적용하는 것으로는 우리의 요구를 만족시킬 수 없다.
그래서 이번 절의 주인공인 가상 뼈가 등장하게 된다.
가상 뼈란, 임의의 어떤 뼈(이를 시작 뼈라고 부른다)에서 또 다른 임의의 뼈(이를 목표 뼈라고 부른다)를 가리키는 뼈이며, 그 자신의 변환값은 시작 뼈와 목표 뼈 사이의 차이값으로 계산되는 뼈다. 이 값은 이 두 뼈의 변환 차이를 알려준다. 그리고 가상 뼈의 초기 위치는 목표 뼈와 일치한다【보충】(물론 modify bone으로 위치를 수정하지 않는 한).
따라서 시작 뼈에 가상 뼈의 변환값을 더하면, 시작 뼈를 목표 뼈와 같은 위치로(정확히는 같은 변환값을 갖도록) 이동시킬 수 있다.
IK 뼈와 비교해 보면, IK 뼈의 방식은 "set", 즉 값을 대입하는 것이고, 가상 뼈의 방식은 "add", 즉 차이값을 더하는 것이다. 거인의 손 뼈에 변환 차이값을 더해줌으로써, 손도 올바른 위치를 잡을 수 있게 해 주는 것이다.
그리고 가상 뼈에는 이동 리타겟팅 옵션이 존재하지 않는다(아래 그림 참고). 이는 가상 뼈가 오직 원본 애니메이션의 데이터만으로 자신의 값을 계산하기 때문이다(가상 뼈는 두 뼈 사이의 변환값을 실시간으로 계산한다). 이 점에서는, 애니메이션 이동 리타겟팅 모드를 선택한 IK 트리와 어느 정도 비슷하다고 할 수 있다.

여기서 이런 의문이 들 수 있다. "가상 뼈가 원본 애니메이션의 데이터로 값을 계산해 낸다면, 1.8m 애니메이션에서 계산된 손바닥과 활 사이의 거리가, 5m 거인의 손바닥과 활 사이의 거리와 같을 수 있을까?"
오, 핵심을 짚었다. 1.1절의 【2】에서, IK 뼈는 골반 등 다른 부모 뼈의 이동 영향을 받지 않도록 별도의 서브트리에 두어야 한다고 했다. 그렇다면 여기서, 거인의 가상 뼈 값이 신장에 맞는 스케일링을 갖도록 하려면 어떻게 해야 할까? 답은 물론 정반대로, 가상 뼈를 해당 스켈레톤 트리 안에 넣는 것이다.【3】

가상 뼈와 IK 둘 사이에는 우열이 없고, 오직 어떤 상황에 어느 쪽을 쓰는 게 더 적합한가의 문제만 있을 뿐이다. 판단 기준은 위에서 말한 것처럼, 고정하려는 소품이 캐릭터와 함께 움직이는지, 아니면 씬 속의 고정된 위치에 있는지를 보면 된다.
【보충】생각해 볼 것: 가상 뼈는 왜 시작 뼈의 자식 뼈로 존재하며, 컴포넌트 스페이스에서의 초기 위치가 목표 뼈와 일치할까?
답은, 가상 뼈 자신의 위치값(즉 LocalLoction)이 목표 뼈 - 시작 뼈이기 때문이다. 이는 일반 뼈와 다를 바 없다. 뼈 A 아래에 자식 뼈 A1을 새로 만들었을 때 위치값이 0이면 A와 겹치는 것과 같은 이치로, 위치값을 뼈 A와 뼈 B의 컴포넌트 스페이스 상 차이값으로 설정하면 A1의 위치는 자연스럽게 B와 겹치게 된다.
그렇다면 리타겟팅 후에도 가상 뼈가 목표 뼈와 위치가 겹칠까? 반드시 그렇지는 않고, 일반적으로는 겹치지 않는다.
위 내용을 이해했다면 답을 알 수 있다. 가상 뼈는 원본 애니메이션 속 목표 뼈와 시작 뼈의 변환 차이값을 수집하는데, 리타겟팅된 애니메이션에서는 목표 뼈와 시작 뼈의 변환 차이값이 원본 애니메이션의 것과 분명히 차이가 나게 된다. 그래서 이때 목표 뼈에 "원본 애니메이션 속 변환 차이값"을 더해봤자, 지금의 목표 뼈 위치에 도달할 수는 없다.
【3】스켈레톤 뷰에서 가상 뼈의 부모 뼈를 드래그하거나 스케일링해도, 왜 가상 뼈의 값이 변하는 게 보이지 않을까? 가상 뼈는 정말로 리타겟팅 과정에서 부모 뼈의 스케일링을 따라 함께 스케일링될까?
답: 1. 디테일 패널에 표시되는 가상 뼈의 변환값은 전부 로컬 변환값(즉 자기 자신의 좌표계 기준)이다. 드래그할 때 바꾸는 건 부모 뼈의 로컬 변환값이지, 가상 뼈 자신의 로컬 변환값이 아니다.
부모 뼈의 변환값을 바꾸면, 가상 뼈는 컴포넌트 스페이스 좌표계, 월드 씬 스페이스 좌표계에서의 변환값만 변할 뿐, 디테일 패널에 표시되는 자기 자신의 좌표계 기준 변환값은 당연히 변하지 않는다.
- 가상 뼈 계산에 사용되는 값은 시작 뼈와 목표 뼈의 컴포넌트 스페이스 상 변환값이므로, 시작 뼈와 목표 뼈가 컴포넌트 스페이스에서 스케일링되기만 하면 가상 뼈의 변환값도 그에 따라 스케일링된다. 이 부분에 관심 있는 독자는 아래 첨부한 소스 코드를 참고해도 좋다. (아래 세 번째 코드 블록에서 계산 시 ComponentSpaceTransforms를 사용하는 것을 명확히 볼 수 있다.)
bool USkeleton::AddNewVirtualBone(const FName SourceBoneName, const FName TargetBoneName, FName& NewVirtualBoneName)
{
for (const FVirtualBone& SSBone : VirtualBones)
{
if (SSBone.SourceBoneName == SourceBoneName &&
SSBone.TargetBoneName == TargetBoneName)
{
return false;
}
}
Modify();
VirtualBones.Add(FVirtualBone(SourceBoneName, TargetBoneName));
NewVirtualBoneName = VirtualBones.Last().VirtualBoneName;
RegenerateVirtualBoneGuid();
//가상 뼈 변경 이벤트를 처리하는 진입점. HandleVirtualBoneChanges();
return true;
}
void USkeleton::HandleVirtualBoneChanges()
{
const bool bRebuildNameMap = false;
ReferenceSkeleton.RebuildRefSkeleton(this, bRebuildNameMap);
//스켈레탈 메시를 순회하면서, 현재 가상 뼈 변경이 발생한 스켈레탈 메시를 찾아 해당 스켈레톤 오브젝트의 RebuildRefSkeleton 함수를 호출한다. for (TObjectIterator<USkeletalMesh> ItMesh; ItMesh; ++ItMesh)
{
USkeletalMesh* SkelMesh = *ItMesh;
if (SkelMesh->GetSkeleton() == this)
{
// also have to update retarget base pose SkelMesh->GetRefSkeleton().RebuildRefSkeleton(this, bRebuildNameMap);
//이하 코드 생략………………
void FReferenceSkeleton::RebuildRefSkeleton(const USkeleton* Skeleton, bool bRebuildNameMap)
{
if (bRebuildNameMap)
{
//On loading FinalRefBone data wont exist but NameToIndexMap will and will be valid RebuildNameToIndexMap();
}
//가상 뼈의 개수를 얻는다. 다음 줄의 배열 확장에 사용된다. const int32 NumVirtualBones = Skeleton ? Skeleton->GetVirtualBones().Num() : 0;
//여기서 새 배열을 만들어, 가상 뼈 추가가 완료된 새로운 뼈 정보를 저장한다. //RawRefBoneInfo에는 원래의 뼈 정보들이 저장되어 있으며, 이를 새 배열에 복사한 뒤 //추가로 NumVirtualBones 만큼의 공간을 더 확보해서 새로운 가상 뼈 정보를 저장한다. FinalRefBoneInfo = TArray<FMeshBoneInfo>(RawRefBoneInfo, NumVirtualBones);
//마찬가지. FinalRefBonePose = TArray<FTransform>(RawRefBonePose, NumVirtualBones);
FinalNameToIndexMap = RawNameToIndexMap;
//가상 뼈 관련 정보를 캐싱하는 두 개의 배열로, 가상 뼈를 베이크할 때 사용된다. //여기서는 이번에 필요한 공간만큼 비워두고, 아래 루프에서 새로운 가상 뼈 정보를 추가해 넣을 것이다. 자세한 설명은 생략. RequiredVirtualBones.Reset(NumVirtualBones);
UsedVirtualBoneData.Reset(NumVirtualBones);
if (NumVirtualBones > 0)
{
TArray<uint8> ComponentSpaceFlags;
ComponentSpaceFlags.AddZeroed(RawRefBonePose.Num());
ComponentSpaceFlags[0] = 1;
//해당 스켈레톤의 컴포넌트 스페이스 기준 포즈(즉 모든 뼈 변환값의 집합)를 얻는다. 아래 루프에서 뼈 자신의 인덱스(index)를 이용해 이 배열에서 자신의 변환 데이터를 찾게 된다. TArray<FTransform> ComponentSpaceTransforms = TArray<FTransform>(RawRefBonePose);
//모든 가상 뼈를 순회한다 for (int32 VirtualBoneIdx = 0; VirtualBoneIdx < NumVirtualBones; ++VirtualBoneIdx)
{
//뼈는 에디터에서는 트리 형태로 표시되지만, 코드 상에서는 실제로 배열 형태를 사용한다. 다들 처음 자료구조를 배울 때 배열로 구현한 트리를 본 적이 있을 것이다( //(우쿤다오차오뒤 선배는 이렇게 하는 목적이 순회 속도를 높이고 메모리 배치를 연속적으로 만들기 위함일 것이라 보았다.) //그래서 새로 추가된 가상 뼈는 당연히 배열의 맨 끝에 놓이며, 그 인덱스는 원본 배열의 원소 개수에 자신이 몇 번째 가상 뼈인지를 더한 값이 된다. const int32 ActualIndex = VirtualBoneIdx + RawRefBoneInfo.Num();
//인덱스를 이용해 가상 뼈의 관련 정보(즉 시작 뼈가 누구인지, 가리키는 목표 뼈가 누구인지 등)를 얻는다. const FVirtualBone& VB = Skeleton->GetVirtualBones()[VirtualBoneIdx];
//가상 뼈의 시작 뼈 인덱스를 얻는다. //만약 이 가상 뼈(가상A라 하자)의 시작 뼈가 또 다른 가상 뼈(가상B라 하자)라면 //가상A의 시작 뼈 = 가상B의 목표 뼈로 설정한다. 만약 가상A의 시작 뼈가 가상 뼈가 아니라면 그냥 정상적으로 처리한다. const int32 SourceIndex = GetRawSourceBoneIndex(Skeleton, VB.SourceBoneName);
//이 가상 뼈의 부모 뼈와 목표 뼈 인덱스를 얻는다. const int32 ParentIndex = FindBoneIndex(VB.SourceBoneName);
const int32 TargetIndex = FindBoneIndex(VB.TargetBoneName);
if(ParentIndex != INDEX_NONE && TargetIndex != INDEX_NONE)
{
FinalRefBoneInfo.Add(FMeshBoneInfo(VB.VirtualBoneName, VB.VirtualBoneName.ToString(), ParentIndex));
//시작 뼈와 목표 뼈의 인덱스를 이용해 대응되는 뼈 정보를 찾고, 그 변환값을 얻는다. const FTransform TargetCS = GetComponentSpaceTransform(ComponentSpaceFlags, ComponentSpaceTransforms, *this, TargetIndex);
const FTransform SourceCS = GetComponentSpaceTransform(ComponentSpaceFlags, ComponentSpaceTransforms, *this, SourceIndex);
//가상 뼈의 변환값은 바로 이 시점에, 시작 뼈와 목표 뼈 사이 변환값의 차이로 계산되어 얻어진다. FTransform VBTransform = TargetCS.GetRelativeTransform(SourceCS);
const int32 NewBoneIndex = FinalRefBonePose.Add(VBTransform);
FinalNameToIndexMap.Add(VB.VirtualBoneName) = NewBoneIndex;
//가상 뼈 정보를 캐싱하는 두 배열에 이번 가상 뼈 정보를 추가한다. RequiredVirtualBones.Add(NewBoneIndex);
UsedVirtualBoneData.Add(FVirtualBoneRefData(NewBoneIndex, SourceIndex, TargetIndex));
}
}
}
}
FTransform VBTransform = TargetCS.GetRelativeTransform(SourceCS);
//주석을 보면 알 수 있듯이, 가상 뼈를 계산할 때 사용하는 "Other" 뼈는 인자로 전달된 SourceCS, 즉 시작 뼈이고, "this" 뼈는 목표 뼈다.template<typename T>
TTransform<T> TTransform<T>::GetRelativeTransform(const TTransform<T>& Other) const
{
// A * B(-1) = VQS(B)(-1) (VQS (A)) // // Scale = S(A)/S(B) // Rotation = Q(B)(-1) * Q(A) // Translation = 1/S(B) *[Q(B)(-1)*(T(A)-T(B))*Q(B)] // where A = this, B = Other
2.전통적이지만 권장하지 않는 IK 방식
바이두나 빌리빌리에서 UE4의 발 IK를 검색해 보면 관련 튜토리얼을 꽤 많이 볼 수 있다. 하지만 자세히 살펴보면, 그중 많은 방식이 계산된 데이터를 발 뼈에 직접 적용하는 방식임을 알 수 있다. 이런 방식의 스켈레톤 트리에는 전용 IK 서브트리도 없고, 가상 뼈도 쓰이지 않는다.
소규모 개인 프로젝트에서 이런 방식을 쓰는 건 물론 문제가 없다. mixamo에서 다운로드한 스켈레톤들은 애초에 전용 IK 트리가 없으니까(우리가 직접 IK 스켈레톤 트리를 추가해 줄 수도 없는 노릇이고). 하지만 ALSV4는 왜 이렇게 간단한 방식을 쓰지 않는 걸까?
【생각해 볼 것】왜 계산 결과를 그대로 foot 뼈에 modify bone으로 수정하는 방식을 권장하지 않을까? 왜 굳이 별도의 ik 서브트리를 추가해야 할까? 혹은, ik 뼈가 존재하는 의미는 무엇일까?
여기서는 앞서 말했던 "리타겟팅 후에도 고정 위치가 정확하도록 유지한다"는 이유가 통하지 않는다. 왜냐하면 문 여는 애니메이션 속 문 손잡이처럼, 캐릭터 모델 대비 상대적인 위치가 정해져 있지 않은 경우(모델의 1/2 지점일 수도, 1/4 지점일 수도, 심지어 모델의 머리보다 높을 수도 있다)에는 원본 애니메이션이 주는 문 손잡이의 위치 정보에 의존해야만 문을 정확히 열 수 있기 때문이다. 반면 발 IK가 구현하려는, 땅과 맞닿는 기능에서는 땅이 모델 대비 상대적으로 위치한 곳이 항상 고정되어 있다 - 바로 모델의 바닥이다. 우리는 원본 애니메이션의 정보에 그다지 의존할 필요가 없다.
그렇다면, 왜 굳이 foot과 변환값이 완전히 동일한 ik_foot을 하나 더 만드는, 다소 쓸데없어 보이는 일을 하는 걸까?
답: 여기서 샤쟈오 선생님의 답변을 인용해 본다
@虾饺好吃
그것의 가장 중요한 의미는, 실제로 스킨된 foot 뼈가 바뀌더라도 이것 자체는 바뀌지 않는다는 데 있다. 이렇게 하면 제작 과정에서 현재 foot의 원래 위치를 명확히 알 수 있고, knee를 더 합리적인 위치에 놓는 데도 도움이 된다. 만약 foot만 있거나, 가상 뼈를 foot 아래에 두었다면, IK 처리 후에는 원래의 애니메이션 키프레임 정보를 다시 얻을 수 없게 된다.
그리고
@一捆稻草垛
님의 답변도 인용해 본다
먼저, ik 뼈는 참조점 역할을 할 뿐 런타임에 데이터를 수정하지 않는다. 이는 애니메이션의 오프라인 데이터가 런타임에서도 언제든 올바르게 사용될 수 있도록 보장해 준다. 그다음으로, 가상 뼈를 매개체로 삼음으로써 한편으로는 실시간 수정 데이터와 원본 오프라인 데이터의 분리를 보장하고, 다른 한편으로는 여러 번 modify를 호출함으로써 발생할 수 있는 떨림(지터) 문제를 줄여 준다.
3.ALSV4의 IK 방식과 그 문제점
ALSV4는 "값 대입" 방식이 아니라 "증분(오프셋 누적)" 방식의 IK를 사용한다.【4】
여기서는 ALSV4의 IK 아이디어를 간단히만 소개한다(어차피 이 글의 목적이 "무엇인가"를 설명하는 데 있지 않기도 하고, 만약 설명한다면 이미 충분히 긴 이 글이 두 편으로 나눠야 할 만큼 길어질 것이기 때문이다).
간단히 말하면, 발 뼈에서 위쪽(발이 땅에 파묻히는 경우를 고려)과 아래쪽(발이 허공에 뜨는 경우를 고려)으로 각각 레이(정확히는 발 위쪽 일정 거리에서 발 아래쪽 일정 거리까지)를 쏴서 탐지하고, 레이의 충돌 결과를 통해 올바르게 걸을 수 있는 지면 위치와 경사도를 찾아낸 뒤, 합리적인 이동량과 회전 오프셋을 계산해서 이를 이중 본 IK 방식으로 발 IK에 적용하는 것이다.
여기서는 헷갈리기 쉬운 몇 가지 포인트를 중점적으로 짚어 보겠다.
3.1 이동 오프셋값 계산
첫 번째는 이동 오프셋값의 계산인데, 필자 개인적으로는 ALSV4 원본만큼 복잡한 계산 방식이 굳이 필요하지 않을 수도 있다고 본다. 원래의 로직을 다음과 같이 단순화할 수 있다.

실제 테스트에서 이런 방식으로도 발이 지면에 올바르게 붙는 효과를 동일하게 낼 수 있음이 확인되었다. ALSV4 작성자가 이 부분에서 굳이 더 복잡하게 만든 것인지, 아니면 Foot Height라는 변수를 도입한 것이 리소스 재사용 과정에서 필자가 아직 파악하지 못한 어떤 역할을 하는 것인지는 지금으로서는 알 수 없다.
3.2 회전 오프셋값 계산(유도 과정 포함)
두 번째는 회전 오프셋값의 계산이다.
여기서는 직교 분해의 개념을 적용해서, 3차원 공간 속 임의 방향의 경사면을 XOZ 평면을 따라 위로 향하는 성분과 YOZ 평면을 따라 위로 향하는 성분, 두 개로 분해한 뒤, 원래 경사면의 법선값을 이용해 분해된 두 경사면의 경사도를 구한다. 이를 이용해 발을 위아래로 들어 올리거나 내리고, 좌우로 기울이게 된다.
유도 과정을 보고 싶지 않다면 아래 부분은 건너뛰고 3.3절로 바로 가도 좋다.

손으로 직접 그려서 그림과 글씨가 좀 지저분한 점은 양해 부탁드린다.

그림에서 보듯, 이것은 왼손 좌표계에서의 평범한 경사면이다. Z축 방향을 따라 내려간다. 경사면의 폭을 계속 압축해서 삼각형에 가깝게 근사시킬 수 있다.

압축하고 나면 삼각형 OBA를 얻게 된다. 이제 이 경사면을 XOZ 평면과 YOZ 평면으로 분해하고 싶다. 어떻게 해야 할까? Z축은 절대 움직일 수 없는데, OA가 바로 Z축과 겹치기 때문이다. 그러니 분해에 쓸 수 있는 건 OB뿐이다. OB를 XY축을 따라 직교 분해해 보자.

그러면 삼각형 OByA, OBxA를 얻게 된다. 그리고 다음을 통해:

x축을 도는 방향과 y축을 도는 방향이 서로 반대라는 것을 알 수 있다. Y축을 중심으로 회전할 때는 시계 방향으로 도는 것이라서, ALSV4에서 그 (-1)을 곱해야 하는 것이다. 또한 XOZ 경사면에 딱 맞추려면 이 경사도만큼 Y축을 중심으로 회전시켜야 한다. 그래서 ALSV4에서 XZ의 아크탄젠트로 Y의 회전값을 계산하고, YZ의 아크탄젠트로 X의 회전값을 계산하는 것을 볼 수 있다.

그러니 지금 시급한 것은 각 OByA, 각 OBxA의 값을 구하는 것이다.

2차원 좌표계로 단순화해서, 분해된 경사면의 법선 벡터를 n이라 하고 n=(x,z)라 하자. 엇각 등 간단한 각도 변환 지식을 이용하면 tan=x/z(마찬가지로 tan=y/z)임을 쉽게 알 수 있다.
주의할 점은, 위 그림처럼 XOZ 경사면과 YOZ 경사면에서 법선 벡터의 각 좌표 성분이 전부 양수인 경우, 아크탄젠트 함수로 계산된 각도 역시 전부 양수가 된다는 것이다. 그런데 Y축을 중심으로 회전하는 방향은 시계 방향이므로, 입력값은 반드시 음수여야 한다. 그래서 계산된 Y축 회전값에 -1을 곱해 주어야 한다.
3.3 발 뼈 오프셋값의 보간
세 번째는 발 뼈 오프셋값(사실상 골반 오프셋값이기도 하다)에 대한 보간이다.

이 보간의 원래 취지는, 높이 변화가 매우 잦은 경우(예: 굴곡이 심하고 평탄하지 않은 지면, 단높이가 낮은 계단 등)에 IK 시스템이 골반을 계속 위아래로 잡아당겨서 캐릭터도 따라서 위아래로 떨리는 것을 방지하기 위한 것이다. (다만 ALSV4의 작성자는 영리하게도 데모 맵에서 이런 자기 문제를 드러내는 장면을 설계하지 않았다.) 이것이 바로 이번 절에서 말하고자 하는, ALSV4 발 IK의 주된 문제점이다.

하지만 안타깝게도 이 보간으로는 문제를 해결할 수 없다. 우선 원작자가 처음 설계한 보간 속도(30, 15)가 너무 빨라서, 떨림 현상을 개선하는 데 거의 아무런 역할도 하지 못한다. 게다가 보간 속도를 5 이하로 낮춰서 떨림 문제를 개선한다 해도, 이번에는 새로운 문제가 생긴다—캐릭터의 발과 골반이 바닥에서 떠오르거나, 허공 상태에서 내려오는 데 일정한 시간이 걸리게 되는 것이다. 캐릭터가 계단을 오르내리는 등의 움직임 상태에서는 크게 눈에 띄지 않지만, 일단 캐릭터가 멈춰 서면 땅속에서 서서히 떠오르는 발을 보고 눈썰미 있는 플레이어라면 바로 어색함을 알아챌 수 있다. (그러니 애니메이션 표현의 사실성 요구가 그리 높지 않다면, 이것도 나름 하나의 임시방편은 될 수 있다.)

3.4 오프셋 데이터를 구체적인 뼈에 적용하는 것에 대하여
가상 뼈 VB ik_foot_Offset은 ik_foot_l에서 ik_foot_l 자기 자신을 가리킨다. 이 가상 뼈들은 전부 ALSV4에서 이름이 새로 바뀌었기 때문에, 원래 어느 시작 뼈에서 어느 목표 뼈를 가리키는지는 에디터의 디테일 패널에서는 알 수 없다. 먼저 결론만 간단히 이야기하고, 빠르게 판단하는 방법을 알려주겠다.
1.VB foot_target_l은 ik_foot_root에서 ik_foot_l을 가리킨다
2.VB ik_knee_target_l은 ik_foot_l에서 calf_l을 가리킨다
3.VB ik_foot_Offset은 ik_foot_l에서 ik_foot_l 자기 자신을 가리킨다—즉 자기 자신을 가리키는 셀프 참조다.【7 왜 ik_foot_l이 Foot_L을 가리키게 하지 않을까?】

판단하는 방법 하나는, 이미 어느 정도 짐작이 섰다면 직접 검증해 보는 것이다. 예를 들어 ik_foot_l을 우클릭해서, 자기 자신을 가리키는 가상 뼈를 새로 추가해 보려고 시도하면, 추가해도 새 가상 뼈가 생기지 않는 걸 볼 수 있는데, 이는 이미 이런 구조를 가진 가상 뼈가 실제로 존재하기 때문이다. 반대로 ik_foot_l에서 Foot_L을 가리키는 가상 뼈를 추가해 보면 성공적으로 추가되는데, 이는 ALS에서 이런 가상 뼈가 원래는 존재하지 않았다는 뜻이다.
다른 하나는 아무 짐작도 없이 순전히 찍어서 한참을 헤매는 방법이다. 하지만 우리는 이미 1.2절에서 가상 뼈가 자신의 변환값을 계산하는 소스 코드를 봤으니(변환값 계산 시 사용하는 것이 바로 시작 뼈와 목표 뼈의 컴포넌트 스페이스 변환 차이값이다), C++에서 가상 뼈 배열을 얻어서 그 안에 저장된 시작 뼈와 목표 뼈 정보를 출력해 보는 것도 그리 어려운 일은 아니다. 여기서는 자세히 다루지 않겠다.
그리고, 실제로 앞서 계산해 낸 오프셋 데이터를 적용하는 부분을 보면, 최종적으로 발 오프셋 데이터를(증분 형태로, 즉 Add to Exist로) 적용받는 것은 가상 뼈이지, ik 뼈가 아니다. ik 뼈는 앞서 발 락(lock) 데이터만 적용받았을 뿐인데, 왜 그럴까?

여기서 1.2절의 지식이 필요하다. 위에서 가상 뼈의 초기 위치는 목표 뼈와 일치한다고 했다. 따라서 가상 뼈에 증분을 더해 준 뒤, 가상 뼈를 이중 본 IK의 이펙터로 삼아서 발 뼈도 가상 뼈가 있는 위치로 이동하게 만들면, 간접적으로 발 뼈에 오프셋 증분을 적용하는 목적을 달성하게 되는 것이다.
그리고, 이 가상 뼈는 ik_foot_l에서 자기 자신을 가리킨다고 했으니, ik 뼈가 발 락 변환 정보를 적용받으면 가상 뼈도 함께 그 값을 적용받게 된다. (가상 뼈는 시작 뼈의 자식 뼈로서, 위치 = 목표 뼈이기 때문이다. 목표 뼈도 자기 자신인 경우라면, 위치 역시 자연스럽게 자기 자신과 같아진다.)【6】
그래서 이런 방식으로, 우리는 발 락과 발이 지면에 붙는 정보를 모두 가상 뼈 VB ik_foot_Offset 안에 담을 수 있게 된다.
여기까지 읽은 독자라면 이런 의문이 들 수 있다. 우리는 위에서 ik 뼈가 애니메이션 데이터의 이중화된 정보 역할을 해서 애니메이션의 오프라인 데이터가 런타임에도 항상 올바르게 사용될 수 있도록 보장한다고 하지 않았나? 그런데 왜 여기서는 감히 이것에 대고 Modify bone을 직접 쓰는 걸까?
이 질문에 답하기 전에 작은 지식 포인트 하나를 소개해야 한다.(어쩌면 이건 1절의 사전 지식에 넣었어야 했을지도? 하지만 1절이 이미 너무 길어서 그냥 여기서 하겠다.)
여기에는 뼈 수정의 "매 프레임 리셋" 특성이 관련되어 있다.【5】 다들 알다시피, 변수의 값을 수정한 뒤 더 이상 건드리지 않으면 그 값은 그대로 유지된다. 하지만 UE의 애니메이션 시스템에서는 뼈와 커브에 대한 수정이 변수와는 다르게 동작한다. 뼈나 커브의 값을 수정한 뒤, 다음 프레임에서 거치는 노드 경로 중에 이전 프레임에서 그 값을 수정했던 노드를 다시 거치지 않는다면, 그 값은 수정된 상태를 유지하지 않고 기본 상태로 리셋되어 버린다.
간단한 테스트 블루프린트를 하나 작성해 보자. TestBool은 키보드 Y키로 조작되는데, Y를 누르면 참, 떼면 거짓이 된다. 값이 참일 때는 출력 투명도가 1이 되고, 거짓일 때는 0이 된다. 이 투명도가 머리 뼈에 이동량을 더할지 말지를 결정한다.

이에 대응해서, Tick 함수 안에도 비슷한 구조로 변수를 수정하는 로직을 하나 작성해 본다. TestBool이 참일 때는 증분을 더하고, 거짓일 때는 더하지 않는다.

예상할 수 있듯, 변수에 더해진 증분값은 매 프레임 계속 누적되며, 다음 프레임에서 더하지 않더라도 이미 더해진 값은 그대로 유지된다. 반면 뼈의 값에 더해진 증분은 몇 프레임이 지나든 딱 한 번만 더해지고, 다음 프레임에 더하지 않으면 바로 리셋되어 버린다.
그림에서 보듯:

이제 우리는 이 질문에 답할 수 있다—왜 우리는 원래 수정되어서는 안 될 ik 뼈의 정보를 감히 수정하는 걸까?

보다시피, 우리에게는 수정의 투명도를 제어하는 FootLockAlpha라는 변수가 있어서, 발 락 상태가 아닌 한 IK 뼈의 변환값에 대한 수정은 다시 원래대로 돌아가게 된다. FootIK는 ALSV4에서 항상 켜져 있는 상태인데, 만약 발 뼈 오프셋 증분 데이터를 IK 뼈에 적용해 버리면 IK 뼈가 항상 정상 위치에서 벗어난 채로 남아 있게 되어, 원본 애니메이션 데이터를 보존하는 역할을 하지 못하게 된다. 반면 FootLock은 항상 켜져 있는 게 아니라, 캐릭터가 멈췄을 때만 켜진다. 그러니 걱정할 필요가 없는 것이다.
【4】왜 증분식 IK 방식을 채택하는 걸까?
여기서 @一捆稻草垛님이 P1 P2 ALS의 자주 쓰이는 커브 상세 해석(1) IK와 락스텝 - 즈후에서 남긴 한 대목을 인용해 본다.
이 오프셋 알고리즘은 애니메이션 전체 과정에서 IK를 항상 켜 둘 수 있게 해 준다—발이 전통적인 IK처럼 땅에 고정되어 들어 올릴 수 없게 되는 게 아니라, 실시간으로 계산된 오프셋 값을 애니메이션에 적용해서, 복잡한 지면 상황에 따라 실시간으로 오프셋을 조정하며 매 걸음이 정확히 땅에 떨어지도록 만들어 준다.
만약 우리가 "값 대입" 방식을 채택한다면 이렇게 될 것이다:
매 순간마다 현재 위치한 지면에 맞춰 발 뼈가 어떻게 변환되어야 지면에 붙는지를 계산하고, 그 계산 결과를 발 뼈의 변환값에 직접 대입한다—그 결과, 애니메이션이 어떻게 표현되든 계산 결과가 항상 발을 땅에 "눌러 붙이는" 식이 되어 버려서, 발이 아예 들리지 않게 되어 버린다.
이런 상황을 피하려면 "지금 왼발/오른발이 땅에 닿아야 하는 순간인가"를 판단해야 한다. 예를 들어 왼발이 곧 땅에 닿으려 할 때만 왼발의 IK를 켜서 왼발이 지면에 붙게 하고, 이때 오른발이 허공에 있다면 오른발의 IK는 꺼서 오른발이 지면에 고정되지 않도록 해야 한다.
당연히 이런 방식은, 캐릭터가 지면과 접촉하는 모든 애니메이션에서 두 개의 커브를 유지 관리해야 하고, 이 두 커브의 값으로 캐릭터의 발이 지금 땅에 닿았는지를 나타내야 함을 의미한다. 그리고 캐릭터가 지면에 있는 동안에는 이 두 커브의 값을 매 순간 가져와서, 그 값에 따라 IK를 계속 켰다 껐다 해야 한다.
반면 "오프셋값을 더한다"는 증분식 방식을 사용한다면, 즉:
지금 발이 평지 위에 가만히 놓여 있다고 가정하고, 우리의 계산 대상은 바로 이 정지된 표준 발이다. 이 발이 지금 위치한 지면에 딱 맞게 붙으려면 몇 도를 회전하고, 얼마만큼 이동해야 하는지를 계산해 낸다. 이 값이 바로 "오프셋값"이다.
그런 다음 이 오프셋값을 지금 애니메이션에 의해 제어되고 있는, 움직이는 상태의 발에 더해 준다. 이렇게 하면 캐릭터가 움직이든 멈춰 있든, 발은 그저 "평지에서 경사면으로 가는 보정값 하나가 추가된 것"에 불과하게 된다.
【5】매 프레임 리셋에 대하여: 이 특성 역시 필자가 샤쟈오 선생님의 글을 공부하고 나서야 비로소 깨달은 것이다. 샤쟈오 선생님이 제시한 "커브 값이 매 프레임 리셋된다"는 특성은 필자에게 큰 깨달음을 주었고, 이를 계기로 뼈의 변환값 역시 매 프레임 리셋된다는 것을 알게 되었다.

【위 이미지 설명 · 한국어 번역】
커브(Curve)의 특성: 보간 가능성 및 매 프레임 리셋
- 애니메이션 에셋에서 명시적으로 참조되지 않더라도, 기본값만 정의해 두고 애니메이션 블루프린트에서 값을 수정하는 경우, 커브(Curve) 관련 값들은 단순히 일반 변수처럼 이해해서는 안 됩니다. 일반 변수와 가장 큰 차이점은 바로 보간 가능(Interpolatable)하다는 점과 매 프레임 리셋(Reset Every Frame)된다는 점에 있습니다.
- 보간 가능(Interpolatable): 서로 다른 애니메이션이나 상태의 계산 결과로 나온 커브 값이 일치하지 않을 때, 상태 전환(트랜지션)이 즉각적으로 끊기는 게 아니라 부드럽게 이어지는 과정이라면, 동일한 이름을 가진 각각의 커브는 상태기(State Machine)나 애니메이션 커브의 현재 값을 기반으로 가중치에 맞춰 최종 커브 값을 블렌딩합니다.
- 매 프레임 리셋(Reset Every Frame): 이 개념은 다소 특별합니다. 만약 이전 프레임의 AnimGraph 흐름이 특정 Modify Curve 노드를 거쳤지만, 다음 프레임에서는 이 Modify Curve 노드를 거치지 않는다면, 애니메이션 블루프린트 흐름이 끝날 때 해당 커브의 값은 이전 프레임에서 수정된 결과가 아니라 원래 지정된 기본값(Default Value)으로 리셋됩니다. 이 점은 값이 계속 유지되는 일반 변수와 매우 다릅니다.
예를 들어, ALS 안의 Als_AnimBP -> BaseLayer -> Main Movement States를 예로 들면, Grounded와 Jump 상태기는 위 이미지와 같습니다. 캐릭터가 공중으로 뛰어오를 때 Grounded에서 Jump 상태로 전환됩니다.
사실 이 특성은 이해하기 어렵지 않다. AnimGraph는 블루프린트와는 달리, 최종 출력 포즈에서 시작해서 애니메이션 그래프의 왼쪽 노드들을 향해 한 층 한 층 재귀적으로 거슬러 올라가고, 왼쪽 노드들이 반환하는 포즈를 통해 최종적으로 최종 포즈(OutPose)를 조합해서 계산해 낸다. 이 과정은 매 프레임마다 처음부터 다시 이루어진다.

그리고 최종 포즈의 초기값은 바로 뼈의 기본 포즈다(즉 아무 노드도 놓지 않았을 때의 출력값이다). 다시 말해 포즈는 초기 포즈에서 시작해서 매 프레임마다 새로 계산되는 것이다.
우리는 매 프레임마다 이 기본 포즈에서 출발해서 그 노드들을 하나씩 재귀적으로 호출하게 되는데, 이번 프레임에 머리를 앞으로 이동시키는 함수가 호출되면 머리가 앞으로 이동하고, 다음 프레임에 호출되지 않으면 초기 포즈는 이 함수를 거치지 않으므로 자연히 머리의 이동도 일어나지 않는다. 그리고 바로 포즈가 매 프레임 새로 계산되고 이전 프레임의 계산 결과를 저장해 두지 않기 때문에, 블루프린트 그래프에서처럼 변수 값이 매 프레임 누적되는 일이 일어나지 않는 것이다.
【6】예시로는 덧셈과 뺄셈을 사용해서 설명했지만, 사실 가상 뼈의 변환값 계산은 이렇게 간단하지 않다. 관심 있는 독자는 아래 코드를 살펴봐도 좋다. A는 목표 뼈, B는 시작 뼈이고, S는 스케일, Q는 쿼터니언으로 표현된 회전, T는 이동을 의미한다. -1은 쿼터니언의 역연산일 것이다.
template<typename T>
TTransform<T> TTransform<T>::GetRelativeTransform(const TTransform<T>& Other) const
{
// A * B(-1) = VQS(B)(-1) (VQS (A)) // // Scale = S(A)/S(B) // Rotation = Q(B)(-1) * Q(A) // Translation = 1/S(B) *[Q(B)(-1)*(T(A)-T(B))*Q(B)] // where A = this, B = Other
【7】설령 ik_foot_l이 Foot_L을 가리키게 하더라도, 실제로 돌려 보면 별 차이가 없다고 느껴질 수도 있다. 하지만 이는 IK 뼈가 존재하는 본래 취지—올바른 오프라인 애니메이션 데이터를 보존한다는 목적—에 어긋난다. 만약 이 가상 뼈가 Foot_L을 가리키게 한다면, 런타임에 Foot_L 뼈에 좋지 않은 오프셋이 생겼을 때 이 가상 뼈도 덩달아 그 오프셋에 휩쓸리게 되고, 결국 발 IK의 오프셋값도 함께 어긋나 버려서 발이 올바른 위치에 자리 잡지 못하게 된다.
그러므로, 이 가상 뼈가 존재하는 의미는 그저 하나의 "변수" 역할을 하며 발 IK의 오프셋값을 저장해 두는 데 있을 뿐이니, ik_foot_l 뼈 위에서 자기 자신을 가리키게 하는 것만으로도 우리의 목적을 달성하면서, 동시에 실제 뼈로부터 간섭받지 않고 ik 뼈 애니메이션 데이터의 "순수성"을 유지할 수 있게 되는 것이다.
4.더 멀리 내다보기
요 며칠은 엘든 링을 하느라 게을러져서 업데이트를 못 했다……
3절에서 우리는 ALSV4 발 IK의 주된 문제점 중 하나가, 계단을 오르내리거나 울퉁불퉁한 지면 위에서 심한 떨림이 발생한다는 점이라고 이야기했다. 이번 절에서는 이 떨림을 극복할 수 있는 몇 가지 방식을 소개하려 한다:
4.1 예측 기반 발 IK
유비소프트는 2016년 GDC에서 어쌔신 크리드에 적용된 발 IK 방식—예측 기반 발 IK—를 공유한 바 있다.
전통적인 발 IK는 지형에 맞춰 수동적으로 적응하는 방식이기 때문에, 지형이 유별난 경우에는 떨림이 생기는 걸 피하기 어렵다. 그래서 이 방식의 아이디어는 정반대로 접근해서, 수동적인 지형 적응을 하는 대신 능동적으로 발이 떨어질 지점을 예측하고, 그 지점의 지형에 맞춰 착지 포즈를 미리 계산해 내는 것이다.
이 방식에 대해서는 작년에 이미 즈후(知乎)에서 한 고수님이 UE4로 구현하는 예측 기반 FootIK - 즈후라는 해설 글을 올린 바 있고, 며칠 전에는 줘자유(左加右)님이 이 방식을 프로젝트에 실제로 적용해 본 경험을 정리한 《어쌔신 크리드》 예측 기반 FootIK 방식 분석과 UE4 구현 - 즈후라는 글을 또 하나 올렸다. 그러므로 구체적인 구현 아이디어와 디테일은 여기서 다시 다루지 않겠다. 관심 있는 독자는 이 두 글을 참고해서 공부해 보길 바란다.
장점: 전통적인 발 IK처럼 지형에 대한 수동적인 적응을 정상적인 걷기 애니메이션 위에 억지로 얹는 방식에서, 예측을 통해 매 걸음의 착지 지점을 정밀하게 제어하는 방식으로 진화했다. 이 덕분에 계단을 오를 때 정말로 한 걸음에 한 계단씩 오를 수 있게 되며, 전통적인 IK처럼 한 걸음에 여러 계단을 "미끄러지듯" 올라가 버리는 일이 없어진다.
단점: 발이 떨어질 지점을 예측하는 것은 발을 드는 순간에 레이를 쏘아 지형 충돌을 감지하는 방식으로 이뤄지는데, 동시에 발이 작은 장애물을 넘어갈 때는 미리 계획된 경로를 따라 발 뼈를 이동시켜야 하므로, 애니메이션 리소스 안에 좌우 두 개의 커브를 유지 관리하면서 발이 지금 어디까지 들려 있는지를 나타내야 한다. 커브 값에 이렇게 높은 정밀도를 요구하다 보면 커브가 블렌딩될 때 오염되기 쉽고, 블렌드 스페이스나 싱크 그룹 등의 문제로 인해 커브 값의 블렌딩 오류가 발생하기도 쉽다. 결국 예측 타이밍에 오차가 생기게 된다.

어찌됐든 유비소프트가 이미 몇 년 전에 성공적으로 구현해 냈다는 사실 자체가, 이 방식이 충분히 실현 가능한 잠재력을 지니고 있다는 것을 증명해 준다. 커브 오염 문제에 대해서는 현재로서는 애니메이션 노티파이(Notify)로 대체해서 해결하는 것이 비교적 유력한 방안으로 보인다.

【위 이미지 대화 내용 · 한국어 번역】
DarkFlameMaster: 커브 대신 애니메이션 노티파이(Animation Notify)를 사용하는 방식은 어떨까요? 제 생각에 걷기나 달리기 애니메이션의 경우, 매번 발을 들어 올려 착지할 때까지의 시간 간격은 고정되어 있으므로, 착지까지 얼마나 남았는지 판단하기 위해 굳이 커브 값을 쓸 필요는 없을 것 같습니다. 예를 들어 걷기에서 발을 들어 올려 착지할 때까지 1초가 걸리고 달리기는 0.5초가 걸린다면, 걷기-달리기 블렌드 스페이스(Blend Space)를 사용할 때 속도 값을 매핑하여 현재 속도 기준의 '1회 발 들어 올림~착지 시간'을 매핑해내는 것입니다. 그런 다음 애니메이션 노티파이 이벤트를 처리하는 곳에 타이머를 두어, 매 프레임 이 계산된 시간에서 DeltaTime을 계속 빼나가고, 동시에 이 프레임 캐릭터의 방향과 남은 착지 시간을 기반으로 이번 프레임의 예측 착지 지점을 계산하는 식입니다.
개인적으로는 이 방식이 커브 오염(Curve Pollution) 문제를 어느 정도 회피할 수 있는 방안이 아닐까 싶은데, 고수님의 의견이 궁금합니다. 🤔
좌가우(左加右) [저자]: 이 방안은 꽤 실현 가능성이 높다고 생각합니다. 발을 들어 올리는 시점(Lift-off)의 이벤트만 성공적으로 받아오면 되겠네요.
4.2 PowerIK
이것은 예전에 Epic이 인수한 강력한 IK 플러그인으로, 스프링 댐핑, 모션 틸트, 관성 시뮬레이션, 생체역학 및 무게중심 시뮬레이션 등 다방면의 디테일 보정을 통해 울퉁불퉁한 지형에서의 떨림 문제를 극복할 수 있다. 수많은 IK 알고리즘 중에서 PowerIK가 사용하는 것은 생체 시뮬레이션 방향의 아이디어다. 흥미로운 점은, 이 플러그인을 만든 개발자 역시 Epic에 합류했다는 것인데, UE5의 FullBodyIK도 바로 이 사람이 만든 것이다—다만 FullBodyIK는 물리 제약 시스템을 기반으로 만들어졌다(어디선가 NTR스러운 아름다움이 있다).
ALSV4의 다소 조촐한 IK 구현은 IK 하나만 전문적으로 파고든 이 플러그인과 비교하면 확실히 초라해 보인다. 안타깝게도 PowerIK 플러그인은 국내(중국)에서는 비교적 기초적인 튜토리얼 정도만 찾아볼 수 있을 뿐, 소스 코드나 구현 아이디어에 대한 자세한 해설은 찾아보기 어렵다(어쩌면 더 이상 손댈 게 없다는 것 자체가 기능이 성숙했다는 증거일지도 모르겠다). 필자도 당분간은 이걸 깊이 연구할 여력이 없다. PowerIK 플러그인의 사용법을 배우고 싶은 독자는 이 튜토리얼을 참고하면 된다: https://www.bilibili.com/video/BV1Ah411y78K?p=4
단점: 파라미터가 매우 많다. 높은 자유도와 커스터마이징을 가능하게 해 주긴 하지만, 동시에 사용자에게도 적지 않은 요구 사항을 부과한다. 사용자는 각 파라미터가 움직임 표현에 어떤 영향을 미치는지 아주 명확히 알고 있어야 하며, 그렇지 않으면 파라미터 튜닝이 매우 고통스러운 작업이 되어 버린다.
결어(필자가 늘 그렇듯 남기는 잡담)
즈후에 첫 글을 올린 지 어느덧 딱 1년이 지났다. 이 글은 최근 몇 편의 글 중에서도 가장 공을 많이 들인 글이라, 마침 시기도 잘 맞아떨어졌으니 1주년 기념작으로 삼기로 했다(웃음). 이 글의 표지와 결어 부분에 쓰인 애니메이션도 바로 내가 2021년 8월에 정주행했던 《흑의 계약자》다.
4절에서는 원래 예전에 썼던 가비지 컬렉션 글처럼 이미 존재하는 여러 IK 방식들을 나란히 비교해 보고 싶었다. 하지만 아쉽게도 스켈레탈 애니메이션 쪽은 아무래도 꽤 세분화된 분야라, 인터넷에서 찾을 수 있는 각종 IK 방식에 대한 해설 글이나 영상이 각종 프로그래밍 언어나 엔진이 채택한 GC 방식에 대한 자료만큼 많지가 않았다. 게다가 나 자신의 내공도 부족해서, 순전히 내 경험만으로 "폭넓게 이것저것 인용"하기가 어려웠다. 그래서 이 글의 4절은 내가 예상했던 수준에 이르지 못했다. 온 힘을 다해 써내긴 했지만 그래도 그다지 만족스럽지는 않다. "1주년 기념작"치고는 작은 아쉬움이 남는 부분이다.
반년 뒤로 다가온 인턴 때문에, 나는 앞으로 몇 달 동안 알고리즘과 C++ 등의 지식을 복습하느라 바쁠 것 같고, 이곳저곳 회사 면접을 보러 다니며 마음 졸이는 데도 많은 시간을 쓰게 될 것 같다……. 아, 요즘 같은 IT 취업 빙하기에는, 그저 내가 결국 인턴이든 정규직이든 자리를 하나 구할 수 있기를 기도할 뿐이다.
그러니 별일 없다면, 이 글이 당분간 내가 쓰는 마지막 ALSV4 관련 글이 될 것 같다. (물론 인턴을 하고 나면 ALSV4와 애니메이션 파이프라인에 대해 분명 더 깊은 깨달음을 얻게 될 테니, 그때는 새로 배운 것들을 짬을 내서 꼭 업데이트하겠다.) 다른 시리즈 글들의 업데이트도 그에 맞춰 좀 느려질 것 같다, 복습할 시간을 확보하기 위해서다. 이 자리를 빌려 독자 여러분께 미리 죄송하다는 말씀을 드린다.
그 밖에도, 이 글이 세상에 나올 수 있었던 데에는 많은 열정 넘치는 고수님들의 도움이 있었다. 이 자리를 빌려 다시 한번 감사드린다.
치엔창 님과 훠위 대붕님께 감사드린다. 두 분이 내 글의 오류를 짚어 주셨다.
PS: 겸사겸사 여기서 필자가 막 개설한 UE 스켈레탈 애니메이션 교류방도 살짝 홍보해 본다: 916274600, 여러 고수님들의 방문과 가르침을 환영한다.
참고 자료:
언리얼4 심층 해설 기술팀 교류회: 스켈레톤, IK 뼈, 가상 뼈의 원리와 역할, 두 가지 IK 방식 - bilibili
Advanced Locomotion System의 Foot IK는 대체 무엇을 하는가 - 즈후
[UE4/UE5 애니메이션 시스템 제대로 다루기>응용편>기능 모듈] Foot IK 시스템(ALS V4 구현 방식 상세 해설) - 즈후
P1 P2 ALS의 자주 쓰이는 커브 상세 해석(1) IK와 락스텝 - 즈후
입력과 출력의 예술—UE4 스켈레탈 애니메이션 파이프라인 프레임워크 - 즈후
원문
(76 封私信 / 36 条消息) UE4 UE5 骨骼动画 高级运动系统 脚部IK - 知乎
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [번역] UE4 UE5 골격 애니메이션 고급 로코모션 시스템: 점프 (0) | 2026.07.23 |
|---|---|
| [번역] UE4 UE5 골격 애니메이션 고급 로코모션 시스템: 제자리 회전 (0) | 2026.07.23 |
| [번역] UE4 UE5 골격 애니메이션 고급 로코모션 시스템: 8방향 이동 (0) | 2026.07.22 |
| [INDEX] ANIMATION SYSTEM (0) | 2026.07.21 |
| [번역] UE4/UE5 골격 애니메이션 고급 로코모션 시스템: 걷기/달리기 블렌드 기초 (0) | 2026.07.21 |