사람 같은 생명체의 애니메이션은 비디오 게임뿐 아니라 컴퓨터 그래픽스 전반에서 매우 중요합니다. 캐릭터를 움직이는 핵심 기법 중 하나가 스켈레탈 애니메이션(skeletal animation, 골격 애니메이션)입니다. 물론 용도가 캐릭터 애니메이션에만 한정되지는 않습니다. 이 기법의 가치를 이해하는 가장 쉬운 방법은 다른 방법과 비교하는 것이므로, 먼저 대안을 살펴보겠습니다.
로봇처럼 사람 형태의 생명체 모델을 만들었다고 하겠습니다. 어떻게 움직일까요? 체스 말처럼 취급해서 전자레인지용 청어 샌드위치 상자나 다른 단단한 물체를 옮기듯 통째로 이동시킬 수는 있습니다. 하지만 그렇게 해서는 전혀 그럴듯하지 않습니다. 생명체는 관절 구조(articulation)를 가집니다.
서로 연결되어 움직일 수 있는 여러 부분으로 이루어졌다는 뜻입니다. 이런 생명체를 애니메이션하는 가장 단순한 방법은 모델을 왼쪽 아래팔, 왼쪽 위팔, 왼쪽 넓적다리, 왼쪽 정강이, 왼발, 몸통, 머리 등 서로 연결된 부분의 계층으로 나누고 그 계층을 움직이는 것입니다. 초기 사례로 다이어 스트레이츠의 「Money for Nothing」 뮤직비디오가 있습니다.
더 최근 사례로는 최초의 《Tomb Raider》 같은 거의 모든 PlayStation 2 게임을 들 수 있습니다. 여기서 공통점은 각 부분이 여전히 강체라는 것입니다. 각 부분 자체는 휘거나 구부러지지 않습니다. 따라서 아무리 능숙하게 캐릭터를 움직여도 여전히 로봇처럼 보입니다.
스켈레탈 애니메이션의 아이디어는 실제 부품의 계층을 가상의 본(bone, 뼈) 계층으로 바꾸는 것입니다. 모델의 각 정점을 하나 이상의 본과 연결합니다. 각 본은 정점에 영향을 주지만, 그 본 하나가 정점의 위치를 전적으로 결정하지는 않습니다. 정점이 본 하나에만 연결되어 있다면 그 본에 대한 상대적 위치가 항상 일정합니다.
이런 정점을 강체 정점(rigid vertex)이라고 하며, 최초의 라라 크로프트 모델에 있는 정점과 똑같이 동작합니다. 그러나 일반적인 경우에는 여러 본이 한 정점에 영향을 줍니다. 아티스트는 어떤 본이 어떤 정점에 영향을 주는지 지정해야 합니다. 이 과정을 스키닝(skinning)이라고 하며(원주 10-18), 이 정보가 지정된 모델을 스키닝된 모델(skinned model)이라고 합니다.
본 여러 개가 한 정점에 영향을 주면 애니메이터는 정점마다 각 본의 영향력을 서로 다르게 배분할 수 있습니다. 짐작할 수 있듯이 상당히 손이 많이 가는 작업입니다. 스킨 가중치의 초안을 빠르게 만들어 주는 자동화 도구도 있지만, 잘 스키닝된 캐릭터를 만들려면 전문 지식과 시간이 필요합니다.
애니메이션된 정점의 위치를 구하려면 그 정점에 영향을 주는 모든 본을 순회한다. 각 본에 대해, 정점이 그 본에 강체로 붙어 있다고 가정했을 때의 위치를 계산한다. 그런 다음 이 위치들의 가중평균을 최종 정점 위치로 삼는다.
원주 10-18: 리깅(rigging)이라는 말도 들어 보았을 것입니다. 다만 이 말은 더 넓은 작업 범위를 가리킬 수 있습니다. 예를 들어 리거는 애니메이션 작업을 돕지만 렌더링에 직접 사용되지는 않는 별도의 조작 장치를 만드는 경우가 많습니다.
그림 10.34 — 스키닝된 두 정점
그림 10.34. 스키닝된 두 정점.
예를 보겠습니다. 그림 10.34는 로봇의 팔꿈치 근처에 있는 스키닝된 정점 두 개를 보여 줍니다. 파란 점과 초록 점은 정점이 해당 본에 강체로 붙어 있다면 놓일 위치입니다. 청록색 점은 스키닝된 정점입니다. 이 점이 메시의 표면에 계속 붙어 있다는 점에 주목하겠습니다.
오른쪽, 즉 어깨에 더 가까운 정점에는 위팔 본이 약 60%, 아래팔 본이 약 40%의 영향을 줍니다. 팔을 구부리면 이 정점이 파란색 강체 정점에 더 가깝게 머무르는 것을 볼 수 있습니다. 반대로 손에 가까운 정점에는 아래팔 본이 약 80%, 위팔 본이 약 20%의 영향을 주는 것으로 보이며, 그래서 초록색 강체 정점에 더 가깝게 머무릅니다.
따라서 스켈레탈 애니메이션을 구현하는 단순한 전략은 다음과 같습니다. 각 정점에 영향을 주는 본의 목록을 유지합니다. 보통 한 정점에 영향을 줄 수 있는 본의 수에 제한을 두며, 흔히 네 개로 제한합니다. 각 본에 대해서는 그 본의 지역 축을 기준으로 한 정점 위치와 그 본의 가중치를 알고 있습니다.
임의의 자세를 취한 모델의 스키닝된 정점 위치를 계산하려면, 각 본마다 본 좌표 공간에서 모델링 좌표 공간으로 변환하는 행렬이 필요합니다. 이 변환 행렬들을 시간에 따라 바꾸면 캐릭터가 움직이는 것처럼 보입니다.
코드 10.7은 이 기본 기법을 보여 줍니다. 뒤에 필요할 것을 고려하여 정점 법선도 포함했습니다. 법선도 정점 위치와 같은 방식으로 처리하지만 행렬의 평행이동 부분은 버립니다. 이론적으로 위치와 법선을 변환할 때 같은 행렬을 사용해서는 안 됩니다. 행렬에 비균일 확대·축소나 전단(기울임)이 포함되어 있다면 10.4.2절에서 설명한 대로 역전치 행렬을 써야 한다는 점을 기억하겠습니다.
하지만 실제로는 행렬 두 벌을 계산하여 GPU로 보내는 비용이 너무 크므로, 효율을 위해 이 오차를 무시하거나 애초에 비균일 확대·축소을 피합니다. 균일 크기 변환은 대체로 괜찮습니다. 어차피 법선을 다시 정규화해야 하기 때문입니다. 범프 매핑용 기저 벡터도 흔히 이 처리 과정에 포함됩니다.
그러나 법선과 매우 비슷하게 처리하므로 여기서는 생략합니다.
// 한 정점에 영향을 줄 수 있는 본의 최대 개수를 제한한다.
const int kMaxBonesPerVertex = 4;
// 골격 모델의 정점을 나타낸다.
struct SkinnedVertex {
// 이 정점에 영향을 주는 본의 수
int boneCount;
// 어떤 본들이 이 정점에 영향을 주는가?
// 본 목록을 가리키는 인덱스들이다.
int boneIndex[kMaxBonesPerVertex];
// 본 가중치. 합은 반드시 1이어야 한다.
float boneWeight[kMaxBonesPerVertex];
// 본 공간에서 표현한 정점의 위치와 법선
Vector3 posInBoneSpace[kMaxBonesPerVertex];
Vector3 normalInBoneSpace[kMaxBonesPerVertex];
};
// 렌더링에 사용할 형태의 정점을 나타낸다.
struct Vertex {
Vector3 pos;
Vector3 normal;
};
// 스키닝된 정점의 위치와 법선을 계산한다.
void computeSkinnedVertices(
int vertexCount, // 스키닝할 정점의 수
const SkinnedVertex *inSkinVertList, // 입력 정점 목록
const Matrix4x3 *boneToModelList, // 각 본의 위치와 방향
Vertex *outVertList // 출력 결과를 저장할 곳
) {
// 모든 정점을 순회한다.
for (int i = 0; i < vertexCount; ++i) {
const SkinnedVertex &s = inSkinVertList[i];
Vertex &d = outVertList[i];
// 이 정점에 영향을 주는 모든 본을 순회하여 가중평균을 구한다.
d.pos.zero();
d.normal.zero();
for (int j = 0; j < s.boneCount; ++j) {
// 변환 행렬을 찾는다.
const Matrix4x3 &boneToModel
= boneToModelList[s.boneIndex[j]];
// 본 공간에서 모델 공간으로 변환하고
// 이 본의 기여분을 더한다.
// 행렬 곱셈을 수행하는 오버로드된 벡터 * 행렬 연산자를 쓴다.
d.pos += s.posInBoneSpace[j] * boneToModel
* s.boneWeight[j];
// 아핀 변환의 평행이동 부분을 무시하고 정점을
// 물체 공간으로 '회전'시킨다. 법선은 '점'이 아니라
// '벡터'이므로 평행이동하지 않는다.
d.normal += boneToModel.rotate(s.normalInBoneSpace[j])
* s.boneWeight[j];
}
// 법선을 반드시 정규화한다.
d.normal.normalize();
}
}
C++
복사
코드 10.7. 정점 스키닝을 위한 단순한 전략.
이 책의 다른 코드 예제와 마찬가지로, 이 코드의 목적은 원리를 설명하는 것이지 실제 최적화 방법을 보여 주는 것이 아닙니다. 실제로 위의 스키닝 계산은 대개 하드웨어의 정점 셰이더에서 수행합니다. 10.11.5절에서 그 방법을 보여 줄 것입니다. 하지만 아직 이야기할 이론이 많이 남았으므로 당분간 구현 세부 사항보다 원리에 집중하겠습니다. 방금 제시한 기법은 이해하기 쉽지만 알고리즘 구조를 바꾸는 중요한 최적화가 가능합니다. 실제로는 조금 다른 기법을 씁니다.
그 최적화는 잠시 뒤 살펴보기로 하고, 우선 한 걸음 물러나 본 공간 좌표, 즉 코드 10.7의 posInBoneSpace와 normalInBoneSpace 멤버 변수들이 처음에 어디서 왔는지 생각해 보겠습니다. “간단하잖아요. Maya에서 바로 내보내면 되죠!”라고 답할 수도 있습니다. 그렇다면 Maya는 그 값을 어떻게 구했을까요?
답은 바인딩 포즈(binding pose)입니다. 홈 포즈(home pose)라고도 하는 바인딩 포즈는 본들을 어떤 기본 위치에 배치한 자세를 말합니다. 아티스트는 캐릭터 메시를 만들 때 다른 모델을 만들 때와 마찬가지로 본이나 스키닝 데이터가 전혀 없는 메시부터 만듭니다.
이 과정에서 캐릭터가 바인딩 포즈를 취하도록 제작합니다. 그림 10.35는 바인딩 포즈를 취한 스키닝 모델과 그 모델의 애니메이션에 사용되는 골격을 함께 보여 줍니다. 본은 실제로 좌표 공간일 뿐이며, 자체적인 기하 형상을 갖지 않는다는 점을 기억하겠습니다. 그림에서 보이는 형상은 시각화를 돕기 위한 것일 뿐입니다.
그림 10.35 왼쪽 — 바인딩 포즈의 로봇 모델
그림 10.35 오른쪽 — 모델을 움직이는 본
그림 10.35. 바인딩 포즈의 로봇 모델(왼쪽)과 모델을 움직이는 본(오른쪽).
메시가 완성되면(원주 10-19) 리깅합니다. 즉 본의 계층인 골격(skeleton)을 만들고, 정점이 적절한 본과 연결되도록 스키닝 데이터를 편집합니다. 이 과정에서 리거는 본을 여러 극단적인 각도로 구부려 모델이 이런 비틀림에 얼마나 잘 반응하는지 미리 확인합니다.
관절이 찌그러지지 않도록 가중치를 적절히 설정했는가? 여기서 캐릭터 모델러와 리거 양쪽의 실력과 경험이 드러납니다. 우리에게 중요한 점은 Maya가 본 조작에 반응하여 새로운 정점 위치를 계속 계산하면서도, 각 정점이 골격에 연결되기 전 바인딩 포즈에서 가졌던 원래 모델링 공간 좌표를 보존한다는 것입니다.
모든 것은 그 원래 정점 위치에서 출발합니다.
따라서 정점의 본 공간 좌표를 구하려면, 바인딩 포즈에서 그 정점이 가진 모델링 공간 좌표부터 시작합니다. 바인딩 포즈에서 각 본의 위치와 방향도 알고 있습니다. 이 위치와 방향을 바탕으로 정점 위치를 모델링 공간에서 본 공간으로 변환하면 됩니다.
지금까지가 메시 스키닝의 원리적인 큰 그림입니다. 이제 최적화를 살펴보겠습니다. 핵심 아이디어는 정점에 영향을 주는 각 본을 기준으로 위치를 모두 저장하는 대신, 각 정점의 바인딩 포즈 위치만 저장하는 것입니다. 그러면 메시를 렌더링할 때 각 본마다 본 공간에서 모델 공간으로 가는 행렬을 가지는 대신, 원래 바인딩 공간의 좌표를 현재 포즈의 모델링 공간으로 변환하는 행렬을 가집니다.
다시 말해 이 행렬은 바인딩 포즈에서 현재 포즈로 본의 위치와 방향이 바뀌는 효과를 정점에 적용합니다. 코드 10.8이 이 방법을 보여 줍니다.
원주 10-19: 물론 이것은 이상적인 작업 순서입니다. 실제로는 리깅 뒤에도 메시를 수정해야 하는 경우가 많습니다. 경험 많은 캐릭터 모델러는 리깅에 필요한 사항을 미리 고려할 수 있지만, 더 자연스럽게 구부러지도록 메시를 조정해야 할 수도 있습니다. 물론 리깅과 전혀 상관없는 미적인 이유로 수정해야 할 때도 흔합니다. 특히 임원이나 포커스 그룹이 관여하면 더욱 그렇습니다.
// 한 정점에 영향을 줄 수 있는 본의 최대 개수를 제한한다.
const int kMaxBonesPerVertex = 4;
// 골격 모델의 정점을 나타낸다.
struct SkinnedVertex {
// 이 정점에 영향을 주는 본의 수
int boneCount;
// 어떤 본들이 이 정점에 영향을 주는가?
// 본 목록을 가리키는 인덱스들이다.
int boneIndex[kMaxBonesPerVertex];
// 본 가중치. 합은 반드시 1이어야 한다.
float boneWeight[kMaxBonesPerVertex];
// 모델 공간에서 표현한 바인딩 포즈의 정점 위치와 법선
Vector3 pos;
Vector3 normal;
};
// 렌더링에 사용할 형태의 정점을 나타낸다.
struct Vertex {
Vector3 pos;
Vector3 normal;
};
// 스키닝된 정점의 위치와 법선을 계산한다.
void computeSkinnedVertices(
int vertexCount, // 스키닝할 정점의 수
const SkinnedVertex *inSkinVertList, // 입력 정점 목록
const Matrix4x3 *boneTransformList, // 바인딩 포즈에서 현재 포즈로
Vertex *outVertList // 출력 결과를 저장할 곳
) {
// 모든 정점을 순회한다.
for (int i = 0; i < vertexCount; ++i) {
const SkinnedVertex &s = inSkinVertList[i];
Vertex &d = outVertList[i];
// 이 정점에 영향을 주는 모든 본을 순회하여
// 이 정점을 위한 혼합 '행렬'을 구한다.
Matrix4x3 blendedMat;
blendedMat.zero();
for (int j = 0; j < s.boneCount; ++j) {
blendedMat += boneTransformList[s.boneIndex[j]]
* s.boneWeight[j];
}
// 혼합 행렬로 위치와 법선을 변환한다.
d.pos = s.pos * blendedMat;
d.normal = blendedMat.rotate(s.normal);
// 법선을 반드시 정규화한다.
d.normal.normalize();
}
}
C++
복사
코드 10.8. 더 최적화된 정점 스키닝 전략.
이 방법은 sizeof(SkinnedVertex)가 줄어들어 GPU로 보내야 하는 데이터의 양을 크게 줄입니다. 정점마다 수행할 계산도 줄어들며, 특히 기저 벡터가 있을 때 효과가 큽니다. 대신 GPU에 행렬을 넘기기 전에 행렬을 조금 더 가공해야 합니다.
지금까지 단순한 스키닝의 기본 아이디어를 설명했습니다. 물론 격투 게임이나 스포츠 게임처럼 가능한 한 충실하게 캐릭터를 표현할 가치가 있고, 이를 위해 사용할 계산 자원과 인적 자원도 충분한 상황에서는 더 고급 기법을 적용할 수 있습니다. 예를 들어 팔을 구부릴 때 이두근을 부풀게 하거나, 무게가 실려 공룡의 발이 땅을 더 세게 누를 때 발의 살이 옆으로 눌려 퍼지도록 할 수 있습니다.
스키닝을 좌표 변환으로 이해하기
본은 메시 내부의 막대기 모양 그 자체가 아니라 움직이는 좌표계입니다. 한 정점이 두 본의 영향을 받는다면, 각 본에 완전히 붙어 있다고 가정한 위치를 먼저 만든 뒤 가중평균합니다. 따라서 60%와 40%는 본의 회전각을 그 비율로 더한다는 뜻이 아닙니다.
flowchart LR A["바인딩 포즈의 정점"] --> B["위팔 본 변환"] A --> C["아래팔 본 변환"] B --> D["위치에 0.6 곱하기"] C --> E["위치에 0.4 곱하기"] D --> F["더해서 최종 정점 위치"] E --> F
Mermaid
복사
원문의 PlayStation 2와 최초 Tomb Raider를 연결하는 예시는 그대로 번역했지만, 플랫폼 세대가 섞인 서술입니다. 알고리즘의 비교는 ‘각 부품이 강체로 따로 움직이는 모델’과 ‘하나의 메시가 여러 본의 가중치를 받아 변형되는 모델’에 초점을 맞추면 됩니다.







