너무 많이 건너 뛴 거 같긴 하지만…
- 오늘은 스키닝에 대해 이야기를 나누고 구현을 직접 살펴보려 합니다.
- 그래픽스에 어느정도 친숙하신 분들은 주제가 갑자기 점프한 느낌이 들 수 있을거라 생각은 합니다.
- 실제 스키닝을 구현하려면 3D파일 포맷을 임포팅 하고… 좌표계를 변환하여 스켈레톤 구조를 임포팅하고… 텍스쳐를 에셋 로딩 하고… 렌더러의 기본 셰이딩 모델을 제작하고… 정말 생각보다 많은 일을 해야하거든요.
- 그런데 이러한 과정 모두 건너뛰고 스키닝부터 이야기 해보려 합니다.
- 그 이유는… 그냥 제가 하고싶기 때문입니다. 하하하.
- 반쯤은 농담이고, 스키닝을 먼저 다루는 큰 이유중 하나는 인터넷에 스키닝 관련된 자료가 얼마 없다는 것을 느껴서입니다.
- 스키닝을 직접 구현해보기 위해 인터넷을 뒤지면서 느낀건 비교적 오래된 자료들만 남아있는 편이었으며 이를 직접 구현하는 과정에 대한 이야기는 많이 있어도 스키닝 그 자체가 무엇인지에 대한 이야기가 잘 없다는 것이었습니다.
- 이러한 부분에 있어 많은 분들의 궁금증(?)을 해결해보고자 스키닝에 대해 먼저 다루고자 합니다.
와! 버추얼 파이터!
출처: https://tvtropes.org/pmwiki/pmwiki.php/VideoGame/VirtuaFighter
- 위 게임은 버추얼 파이터라는 아주 오래 전부터 이어내려온 격투게임 시리즈입니다.
- 당시에는 많은 분들이 오락실에서 처음으로 해당 게임을 통해 3d게임을 접했고 문화충격을 받은 신문물이었다고 하더라구요.
- 그리고 게임의 역사만큼이나 비주얼적으로 크게 발전해온 것을 볼 수 있습니다.
- 그런데, 위에 제가 빨간색으로 박스친 부분들의 캐릭터들이 뭔가 눈에 띄지 않으신가요?
- 캐릭터들의 관절부위가 이상하죠? 팔꿈치가 분리돼있고 손목이 부자연스럽게 떨어져 있잖아요?
- 스키닝이란 사람과 같이 “피부”와 “관절”이 있는 물체들을 더욱 자연스럽게 렌더링하기 위한 기법이라 보시면 됩니다.
강체와 스키닝
- 모델러가 모델을 모델링하면 해당 모델은 “강체”라고 볼 수 있습니다. 강체란 어떠한 힘을 받아도 변하지 않는 가상의 물체입니다.
- 그런데 생각해보면 세상의 많은 물체들, 특히 인간은 강체가 아니거든요? 돌, 통나무 같은 친구들은 게임이라는 가상의 세계에서 아무리 때려부셔도 부숴지지 않는다고 정하고 대충 넘어간다고 하더라도 인간은 그러면 안되잖아요?
- 팔을 구부리면 피부는 그 만큼 늘어나고 줄어들어야 하고 사람의 키가 커진다고 허리가 끊기면 어색할거 아니에요?
- 위 버추얼 파이터가 좋은 예시입니다. 빨간색 사각형으로 표시된 모델들은 피부가 늘어나지 않고 모두 관절을 다 따로 분리해서 하나하나의 강체로 그리고 있기 때문에 렌더링 시 어색함이 있는 것이죠.
- 이와 같이 강체가 가지는 한계를 해결하기 위한 렌더링 기술이 스키닝이라 보시면 됩니다.
스키닝의 구성요소
- 스키닝을 이해하기 위해 스키닝의 구성요소를 먼저 살펴보죠
스켈레톤
- 우선 알아야 할 부분은 관절 즉 “스켈레톤”입니다
- 스키닝이 된 모든 모델들은 스켈레톤을 가지고 있습니다. 그리고 이러한 스켈레톤의 모양은 보통 실제 인간관절 구조를 담습하고 있는 형태이죠.
- 골반에는 척추가 붙어있고 척추에는 팔이 붙어있고 팔에는 손목 손가락… 그리고 각 부모 관절에 대해 상대적인 위치가 어디인지에 대한 골격구조의 원본이 바로 스켈레톤이라고 보시면 됩니다.
애니메이션
- 3D에서 애니메이션은 보통 위 스켈레톤 객체의 움직임을 의미합니다.
- 보통 KeyFrame방식으로 애니메이션 에셋을 제작하고 활용합니다.
- 가령 걷기라는 애니메이션이 있다고 해봅시다. 0번째 프레임에는 손목이 팔꿈치 대비 어느 위치에 어느 각도로 있는지… 1번째 프레임에는 척추가 골반 대비 어느 위치 어느 각도에 있는지… 와 같은 값들을 모두 저장하면 사람의 관절의 움직임을 표현할 수 있게 됩니다.
- 이를 통해 게임에서 캐릭터가 앞으로 걸어나가는 순간에 걷기라는 애니메이션을 반복 재생해 캐릭터의 움직임을 표현할 수 있습니다.
- 이젠 관절부위에 피부만 덫씌워주면 되는 것이죠.
스키닝
- 스켈레톤, 애니메이션과는 다르게 스키닝은 관절이 아닌 피부, 즉 모델의 각 정점에 저장되는 데이터입니다.
- 보통 강체라고 하더라도 모델이 가지는 각 정점에는 위치값, 노말값, UV값과 같은 다양한 값들이 저장되는데 스키닝 되는 모델에 한해 해당 정보들과 같이 저장되는 데이터가 바로 모델의 스키닝 데이터입니다.
- 스키닝 데이터는 해당 피부가 어느 관절에 붙어있냐의 값을 저장합니다.
- 가령 팔뚝의 피부를 스키닝한다고 하면 팔뚝은 팔꿈치라는 관절에 붙어있다는 식으로 명시를 해주죠.
- 그런데 피부는 하나의 관절에만 붙어있지 않습니다. 가령 하나의 팔뚝이더라도 피부가 손목에 가까우면 피부는 어느정도 손목을 따라 움직이고 팔꿈치에 가까우면 피부는 어느정도 팔꿈치 관절에 따라 움직입니다.
- 때문에 각 정점은 단순히 본인이 따라가야할 하나의 관절이 아니라 본인이 붙어있는 여러개의 관절들을 몇 퍼센트만큼 따라가야 하는가를 저장합니다.
- 가령 팔뚝이라고 한다면 팔꿈치 관절, 손목 이라는 두 개의 관절에 대해 관절의 배열 인덱스와 따라가야 할 가중치를 저장해주는 형태가 되겠지요.
스키닝의 원리
- 위 스키닝의 구성요소 3가지를 이해하고 있다면 스키닝의 원리는 비교적 간단합니다.
- 스키닝에서의 정점에서 스켈레톤의 트랜스폼을 빼주고 애니메이션의 트랜스폼을 더해주면 완성이거든요!
- 너무 대충 설멸했군요 그러면 좀 더 자세히 알아보죠.

- 위 그림은 캐릭터의 원본 메시와 원본 스켈레톤 구조입니다.
- 그리고 위 그림은 애니메이션에서의 특정 프레임의 관절의 위치입니다

- 위에 빨간 원으로 표시된 원본 스켈레톤 위의 정점(피부)를 애니메이션 관절 쪽으로 옮겨주려면 어떻게 해야할까요?
- 위에서 말했죠? “스켈레톤의 트랜스폼을 빼주고 애니메이션의 프레임을 더해준다”
- 정점에서 정점이 붙어있는 스켈레톤의 위치값의 역행렬을 곱해줍니다. 그러면 관절에 상대적인 정점의 위치가 나옵니다.
- 그리고 결과값으로 나온 상대 위치를 애니메이션 데이터의 관절 위치의 행렬값에 곱해줍니다.
- 그러면 정점의 원본 위치가 애니메이팅된 관절의 위치로 옮겨지는 것이죠.
- 물론 위에 말했듯 스키닝된 정점은 하나의 관절에만 메달려있지 않기 때문에 메달려있는 각 관절 구조들의 트랜스폼을 가중치이 따라 블렌딩하여 계산해줘야 합니다.
스키닝의 구현
- 스키닝을 실제 구현하는 코드 전문을 살피는건 쉽지 않습니다.
- 사실 스키닝에서 진짜 어려운 부분은 사실 FBX와 같은 3D 파일 포맷으로부터 골격 구조와 스키닝 데이터 애니메이션 데이터를 임포팅하고 렌더링과 GPU에서 사용 가능하도록 가공하는 작업이거든요.
- 그래서 이해가 비교적 쉬운 셰이더 코드의 실제 구현부분만 살펴보도록 하겠습니다.
#ifdef ENABLE_SKINNING
struct Joint
{
float4x4 PosMatrix;
float4x4 RotMatrix;
};
StructuredBuffer<Joint> SkeletonJointInverse : register(t3);
StructuredBuffer<Joint> CurrentJoint : register(t4);
#endif
.
.
.
const static unsigned int INVALID_IDX = -1;
.
.
.
PS_INPUT VS(VS_INPUT input)
{
PS_INPUT output = (PS_INPUT) 0;
#ifdef ENABLE_SKINNING
int i = 0;
float4 PosAcc = float4(0,0,0,0);
for (i = 0; i < 4; i++)
{
if (input.jointIndices[i] == INVALID_IDX)
{
break;
}
float4 PosItem = mul(input.Pos, SkeletonJointInverse[input.jointIndices[i]].PosMatrix);
PosItem = mul(PosItem, CurrentJoint[input.jointIndices[i]].PosMatrix);
PosAcc += (input.jointWeights[i] * PosItem);
}
input.Pos = PosAcc;
matrix skinRotMat = ZERO_MATRIX;
for (i = 0; i < 4; i++)
{
if (input.jointIndices[i] == INVALID_IDX)
{
break;
}
skinRotMat += mul(SkeletonJointInverse[input.jointIndices[i]].RotMatrix, CurrentJoint[input.jointIndices[i]].RotMatrix) * input.jointWeights[i];
}
input.Normal = mul(input.Normal, skinRotMat);
input.Tangent = mul(input.Tangent, skinRotMat);
#endif
output.Pos = mul(input.Pos, WMatrix);
output.Pos = mul(output.Pos, VPMatrix);
output.Normal = mul(input.Normal, RotMatrix);
output.Tangent = mul(input.Tangent, RotMatrix);
output.UV0 = input.UV0;
output.UV1 = input.UV1;
output.WorldPos = mul(input.Pos, WMatrix);
return output;
}
https://github.com/jellypower/FBXRenderer/blob/master/FBXRenderer/Resource/Shader/SSDefaultPbr.fxh
완성된 스키닝 애니메이션
https://www.youtube.com/watch?v=Q3c1bU0dWhs
- 위 영상은 실제 제가 구현한 스키닝된 캐릭터의 움직임입니다.
https://github.com/jellypower/FBXRenderer
- 코드의 전문은 깃허브에 올려놨습니다. 관심 있으신 분들은 한 번 살펴 보시는 것도
도움이 될 수 있겠네요.