티스토리 뷰

앞선 포스팅에서 스켈레톤 애니메이션의 개념과 구현에 필요한 수학적 지식을 알아보았습니다.

이번 포스팅에서는 직접 코드를 이용해서 스켈레톤 애니메이션을 구현해보겠습니다.

 

우선 결과 영상은 다음과 같습니다.

Animation 예시

Windows 환경에서 개발하였고 C++, OpenGL, Assimp를 이용해서 구현했습니다. DAE 파일은 본문 맨 아래에 표기한 웹사이트에서 가져왔습니다.

 

Overview

먼저 전체 과정을 Overview 해봅시다.

총 3개의 과정으로 이루어집니다.

첫 번째는 DAE 파일을 로드하는 부분입니다. DAE 파일에는 Mesh의 정점 정보, Animation 정보 및 정점과 Bone을 연결시켜주는 정보 등을 담고 있습니다.

 

두 번째는 시간에 따라 각 관절의 형태를 선택하는 부분입니다. 애니메이션은 정점에 영향을 주는 Bone들의 움직임으로 표현됩니다. DAE 파일에는 특정 시간에 Bone의 형태(position, rotation, scale)등의 정보가 저장되어 있습니다. 이 정보를 잘 이용하면 모든 시간에서의 Bone의 형태를 불러올 수 있습니다.

 

세 번째는 관절의 형태에 따라 정점들의 위치를 계산하고 랜더링하는 부분입니다. 앞 과정을 통해 정점과 Bone의 연결정보를 알았고 각 시간대에 Bone의 형태를 알았습니다. 마지막으로는 이 정보들을 이용하여 각 정점의 위치를 계산하여 랜더링하면 됩니다.

 

 

DAE 파일 로드

본격적으로 코드를 보기 앞서 어떤 데이터가 DAE 파일에 저장되어 있는지 알아봅시다. 지금부터 설명할 데이터 형식은 모두 Assimp라는 라이브러리를 이용해서 DAE 파일을 읽었을 때의 모습입니다. 다른 라이브러리를 이용하거나 직접 파싱을 구현할 경우 데이터의 구조나 이름이 다를 수 있습니다.

Assimp의 Scene, Mesh, Node, Animation

먼저 Assimp를 이용해서 Data를 읽으면 모든 정보는 Scene에 담기게 됩니다.

이번 예시에서 주의 깊게 봐야하는 Data는 Mesh, Node와 Animation입니다.

Mesh는 배열 형태로 저장되어 있고 각 Mesh는 정점 정보를 담고 있습니다. 예시에서는 1개의 Mesh만 존재합니다.

Node는 Tree 형태로 저장되어 있고 Scene를 이용하면 Root Node에 접근할 수 있고 각 자식 Node는 Children 배열을 이용하여 접근할 수 있습니다. Node를 이용하면 간접적으로 Mesh에 담긴 정점 배열에 계층 구조를 만들 수 있습니다. Node는 Mesh와 Animation을 이어주는 역할을 합니다.

Animation은 배열 형태로 저장되어 있고 Key Frame에서의 Bone 형태 정보를 가지고 있습니다. 하나의 Animation은 하나의 모션이라고 보시면 됩니다. 예를 들어 캐릭터가 달리는 모션은 하나의 Animation 객체에 저장되어 있습니다. 예시에서는 1개의 Animation만 존재합니다.

 

먼저 Mesh와 Node의 관계를 생각해봅시다.

 

Mesh과 Node의 관계

예시에서 Mesh는 1개라고 했습니다. 각 Mesh는 Vertex(정점) 배열과 Bone 배열을 가지고 있습니다.

Vertex 배열의 각 원소는 Mesh의 정점들의 위치, 노말, 텍스쳐 UV 좌표 등을 가지고 있습니다.

주의해야할 점은 Vertex에 담긴 위치 정보가 Local 좌표계에서 정의된 정점이라는 점입니다. 이 부분은 나중에 Animation을 다룰 때 중요하게 여겨집니다.

Bone은 영향을 끼치는 Vertex들의 ID를 가지고 있습니다. 또 각 Vertex에 얼마만큼의 영향을 끼치는지에 대한 가중치 값을 가지고 있습니다.

앞 포스팅에서 Bone은 계층 구조를 가지고 있다고 했습니다. 하지만 사실 Assimp를 이용해 Load를 하면 Bone 자체는 배열 형식으로 저장되어 있습니다. Bone에 계층 구조를 만드는 것은 Bone의 각 객체와 1대 1 맵핑이 되는 Node입니다.

Data가 이런식으로 존재하는 정확한 이유는 아마 계층 구조가 Bone 외에 다른 곳에서도 쓰일 가능성이 있기 때문이 아닐까 싶습니다.

 

Mesh의 Bone들과 Vertex를 연결하는 코드는 아래와 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int numBones = 0;
for (int i = 0; i < mesh->mNumBones; i++)
{
    unsigned int boneIndex = numBones++;
 
    for (int j = 0; j < mesh->mBones[i]->mNumWeights; j++)
    {
        unsigned int vertexId = mesh->mBones[i]->mWeights[j].mVertexId;
        float weight = mesh->mBones[i]->mWeights[j].mWeight;
 
        // 정점은 최대 8개의 Bone의 영향을 받게 됨
        // 2개의 4차원 벡터를 이용하여 값을 저장
        for (int k = 0; k < 8; k++)
        {
            // 벡터의 인덱스
            unsigned int vectorId = k / 4;
            // 각 벡터의 원소 인덱스
            unsigned int elementId = k % 4;
            // push_back 효과를 구현
            if (vertices[vertexId].boneWeights[vectorId][elementId] == 0.0f)
            {
                vertices[vertexId].boneIds[vectorId][elementId] = boneIndex;
                vertices[vertexId].boneWeights[vectorId][elementId] = weight;
                break;
            }
        }
    }
}
cs

이 코드는 미리 정의한 vertices 배열에 Assimp에서 읽은 Bone 정보를 채워넣는 코드입니다.

for문 먼저 보면 mesh의 Bone 개수만큼 순회를 하고 각 Bone 마다 영향을 끼치는 정점의 개수(mNumWeights)만큼 순회를 합니다.

13라인에 8번 순회를 하는 것은 정점 별로 영향을 받는 Bone의 Index를 순차적으로 채우기 위함입니다.

20라인을 보면 weight 값이 0이면 값을 채워넣고 아니면 그냥 넘어가는데 디폴트 값이 0이기 때문입니다.

 

이렇게 채워진 vertices 정보는 나중에 Rendering할 때 Vertex Shader로 넘어가게 됩니다.

 

위 코드에서 Node는 사용되지 않았다는 점을 눈여겨 보시기 바랍니다. 아직까지는 단순히 Mesh의 Vertex와 Bone의 관계를 정의하는 부분이므로 Bone의 암시적인 계층 구조(Node의 구조에 의해서 정의된) 정보는 필요하지 않습니다.

 

 

시간에 따라 각 관절의 형태 선택

다음은 시간에 따른 관절의 형태를 선택하는 코드입니다. 

AnimationComponent라는 class를 만들었고 class의 멤버변수로 Assimp에서 제공하는 scene data를 갖고 있습니다.

ExtractBoneTransform이라는 public 멤버 함수를 만들어서 특정 시간(animationTime)과 특정 애니메이션 인덱스(animationIndex)를 인자로 받으면 관절의 형태를 뜻하는 Bone Transform 배열을 리턴하도록 구현하였습니다.

Bone Transform 배열을 리턴하기 전에 boneInfos라는 배열을 거치게 되는데 boneInfos란 bone의 최종 Transform과 offsetTransform을 저장하는 BoneInfo 타입 구조체입니다. 

boneOffset Transform은 Bone space에서 정의된 정점을 Model space에서 정의되도록 만드는 Transformation입니다.

나중에 Bone Transform을 쉽게 정의하기 위해 사용되며 DAE 파일에서 추출할 수 있습니다.

1
2
3
4
5
struct BoneInfo
{
    mat4 boneOffset;
    mat4 finalTransform;
};
cs

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 
// 특정 시간(animationTime)과 특정 애니메이션 인덱스(animationIndex)를 넘겨주면
// 각 Bone의 형태를 Matrix 형식으로 리턴하는 함수
// 매 프레임마다 호출됨
vector<mat4> AnimationComponent::ExtractBoneTransforms(float animationTime, const int animationIndex)
{
    // 애니메이션이 계속 반복되도록 fmod 연산을 취함
    animationTime = fmod(animationTime, scene->mAnimations[animationIndex]->mDuration);
    // root node와 단위 행렬을 인자로 넘겨주면 재귀 호출을 통하여 boneInfos에 데이터를 저장하는 함수
    ReadNodeHierarchy(animationTime, scene->mRootNode, mat4(1.0f));
 
    for (int i = 0; i < scene->mMeshes[0]->mNumBones; i++)
    {
        // boneTransforms는 vector<mat4> 타입으로 크기는 Bone의 개수와 같음
        boneTransforms[i] = boneInfos[i].finalTransform;
    }
    return boneTransforms;
}
cs

Bone의 Transform을 구하는 로직은 대부분 10라인에 있는 ReadNodeHierarchy에 들어있습니다.

ReadNodeHierarchy는 내부적으로 재귀 호출하여 boneInfos 배열의 finalTransform이라는 변수에 데이터를 채웁니다.

ReadNodeHierarchy 함수 호출이 끝나고 나면 멤버 변수인 boneTransforms 배열에 데이터를 채우고 리턴합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 재귀 호출을 통해 자식 노드로 내려가면서 각각 매칭된 boneTransformation을 저장하는 함수
void AnimationComponent::ReadNodeHierarchy(
    float animationTime,
    const aiNode* node,
    const mat4& parentTransform)
{
    string nodeName(node->mName.data);
 
    const aiAnimation* animation = scene->mAnimations[0];
    mat4 nodeTransform = ToMat4(node->mTransformation);
 
    const aiNodeAnim* nodeAnim = FindNodeAnim(animation, nodeName);
 
    // 애니메이션 정보가 있는 node라면
    if (nodeAnim)
    {
        // 주어진 key frame의 정보와 animationTime 정보를 이용해 interpolation을 하고 값을 저장
        const aiVector3D& scaling = CalcInterpolatedValueFromKey(animationTime, nodeAnim->mNumScalingKeys, nodeAnim->mScalingKeys);
        mat4 scalingM = scale(mat4(1.0f), vec3(scaling.x, scaling.y, scaling.z));
 
        const aiQuaternion& rotationQ = CalcInterpolatedValueFromKey(animationTime, nodeAnim->mNumRotationKeys, nodeAnim->mRotationKeys);
        mat4 rotationM = toMat4(quat(rotationQ.w, rotationQ.x, rotationQ.y, rotationQ.z));
 
        const aiVector3D& translation = CalcInterpolatedValueFromKey(animationTime, nodeAnim->mNumPositionKeys, nodeAnim->mPositionKeys);
        mat4 translationM = translate(mat4(1.0f), vec3(translation.x, translation.y, translation.z));
 
        nodeTransform = translationM * rotationM * scalingM;
    }
 
    // globalTransform은 bone space에서 정의되었던 정점들을 model space에서 정의되도록 함
    // parentTransform은 부모 bone space에서 정의되었던 정점들을 model space에서 정의되도록 함
    // nodeTransform은 bone space에서 정의되었던 정점들을 부모 bone space에서 정의되도록 함, 
    // 혹은 부모 bone space를 기준으로 한 일종의 변환
    mat4 globalTransform = parentTransform * nodeTransform;
 
    // bone이 있는 노드에 대해서만 bone Transform을 저장
    // boneMap은 map<string, int> 타입으로 bone의 이름과 index 저장
    if (boneMap.find(nodeName) != boneMap.end())
    {
        unsigned int boneIndex = boneMap[nodeName];
        boneInfos[boneIndex].finalTransform =
            ToMat4(scene->mRootNode->mTransformation) *
            globalTransform *
            // boneOffset은 model space에서 정의되었던 정점들을 bone space에서 정의되도록 만드는 것
            boneInfos[boneIndex].boneOffset;
    }
 
    // 모든 자식 노드에 대해 재귀 호출
    for (int i = 0; i < node->mNumChildren; i++)
        ReadNodeHierarchy(animationTime, node->mChildren[i], globalTransform);
}
cs

ReadNodeHierarchy는 크게 3부분으로 나뉩니다. 

각각은 nodeAnim을 찾는 부분, key frame 사이를 보간하여 특정 시간의 Node의 Transformation을 구하는 부분, 구한 Transformation을 이용해 Bone Transformation을 설정하고 자식 Node에게 전파하는 부분입니다.

 

7~12라인은 노드의 이름 정보를 이용해 nodeAnim을 찾는 부분입니다. aiAnimation 객체는 Channel이라는 이름을 가진 aiNodeAnim 타입의 배열을 가지고 있습니다.

이름이 좀 헷갈리게 되어있지만 nodeAnim과 Channel은 같은 것이고 Channel(nodeAnim)은 하나의 Node의 key frame들을 가지고 있는 객체라고 보시면 됩니다. 

아래는 Node와 Animation의 관계도입니다. 아래 보시는 것처럼 각 Channel은 Node와 맵핑되어 있고 각 Channel은 key frame들의 scale, translation, rotation 정보를 가지고 있습니다.

Animation과 Node의 관계

nodeAnim을 찾는 함수 FindNodeAnim 함수는 다음과 같습니다.

1
2
3
4
5
6
7
aiNodeAnim* AnimationComponent::FindNodeAnim(const aiAnimation* animation, const string nodeName)
{
    for (int i = 0; i < animation->mNumChannels; i++)
        if (animation->mChannels[i]->mNodeName.data == nodeName)
            return animation->mChannels[i];
    return nullptr;
}
cs

15~28라인은 key frame 사이를 보간하여 특정 시간의 Node의 Transformation을 구하는 부분입니다.

key frame이란 애니메이션을 표현하는 frame들 중 몇 개만 추려서 만든 frame들을 뜻합니다.

animation의 모든 frame들을 만들기에는 메모리 낭비도 심하고 시간도 오래 걸리기 때문에 key frame만 만들고 자연스러운 움직임을 위해 그 사이의 값들은 보간해서 얻어냅니다.

translation과 scale은 vector 형태로 되어있고 방향을 뜻하는 rotation은 quaternion 형태로 되어있습니다. 

값을 보간하는 함수는 아래와 같이 정의되어 있습니다.

vector과 quaternion은 보간 방식이 다르기 때문에 함수를 overloading하여 구현했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
 
aiVector3D AnimationComponent::CalcInterpolatedValueFromKey(float animationTime, const int numKeys, const aiVectorKey* const vectorKey) const
{
    aiVector3D ret;
    if (numKeys == 1)
    {
        ret = vectorKey[0].mValue;
        return ret;
    }
 
    unsigned int keyIndex = FindKeyIndex(animationTime, numKeys, vectorKey);
    unsigned int nextKeyIndex = keyIndex + 1;
 
    assert(nextKeyIndex < numKeys);
 
    float deltaTime = vectorKey[nextKeyIndex].mTime - vectorKey[keyIndex].mTime;
    float factor = (animationTime - (float)vectorKey[keyIndex].mTime) / deltaTime;
 
    assert(factor >= 0.0f && factor <= 1.0f);
 
    const aiVector3D& startValue = vectorKey[keyIndex].mValue;
    const aiVector3D& endValue = vectorKey[nextKeyIndex].mValue;
 
    ret.x = startValue.x + (endValue.x - startValue.x) * factor;
    ret.y = startValue.y + (endValue.y - startValue.y) * factor;
    ret.z = startValue.z + (endValue.z - startValue.z) * factor;
 
    return ret;
}
 
aiQuaternion AnimationComponent::CalcInterpolatedValueFromKey(float animationTime, const int numKeys, const aiQuatKey* const quatKey) const
{
    aiQuaternion ret;
    if (numKeys == 1)
    {
        ret = quatKey[0].mValue;
        return ret;
    }
 
    unsigned int keyIndex = FindKeyIndex(animationTime, numKeys, quatKey);
    unsigned int nextKeyIndex = keyIndex + 1;
 
    assert(nextKeyIndex < numKeys);
 
    float deltaTime = quatKey[nextKeyIndex].mTime - quatKey[keyIndex].mTime;
    float factor = (animationTime - (float)quatKey[keyIndex].mTime) / deltaTime;
 
    assert(factor >= 0.0f && factor <= 1.0f);
 
    const aiQuaternion& startValue = quatKey[keyIndex].mValue;
    const aiQuaternion& endValue = quatKey[nextKeyIndex].mValue;
    aiQuaternion::Interpolate(ret, startValue, endValue, factor);
    ret = ret.Normalize();
 
    return ret;
}
 
unsigned int AnimationComponent::FindKeyIndex(const float animationTime, const int numKeys, const aiVectorKey* const vectorKey) const
{
    assert(numKeys > 0);
    for (int i = 0; i < numKeys - 1; i++)
        if (animationTime < (float)vectorKey[i + 1].mTime)
            return i;
    assert(0);
}
 
unsigned int AnimationComponent::FindKeyIndex(const float animationTime, const int numKeys, const aiQuatKey* const quatKey) const
{
    assert(numKeys > 0);
    for (int i = 0; i < numKeys - 1; i++)
        if (animationTime < (float)quatKey[i + 1].mTime)
            return i;
    assert(0);
}
 
cs

각각의 vector와 quaternion은 matrix로 변환되고 TRS 변환을 통해 Node Transform(Bone Transform)이 완성됩니다.

이 Node Transform은 bone space에서 정의되었던 정점들을 부모 bone space에서 정의되도록 하는 변환임과 동시에 부모 bone space에서 정의된 정점들에게 취하는 변환입니다.

후자가 좀 더 직관적입니다.

예를 들어 설명하면 상완의 좌표계에서 특정 정점들에 변환을 취해서 전완에 위치하도록 만드는 것이지요.

 

ReadNodeHierarchy 함수의 34~50라인은 구한 Transformation을 이용해 Bone Transformation을 설정하고 자식 Node에게 전파하는 부분입니다. 

일단 38라인에서 boneMap을 살펴보는 이유는 모든 Bone은 Node이지만 모든 Node가 Bone은 아니기 때문입니다.

어찌되었든 Bone인 Node라고 가정을 하고 Bone의 FinalTransform에 어떤 값이 들어갈지 생각해봅시다.

 

Final Transform = Root Node의 Transform * Global Transform * Bone Offset Transform으로 구성됩니다.

Global Transform = Parent Transform * Node Transform이므로 

결국 Final Transform = Root Node의 Transform * Parent Transform * Node Transform * Bone Offset Transform입니다.

여기서 가장 마지막에 곱해지는 Root Node의 Transform은 DAE 파일을 생성한 프로그램의 좌표계와 OpenGL의 좌표계를 맞춰주는 Transform이므로 Animation을 구현할 때는 딱히 신경쓰지 않아도 됩니다.

나머지 Transform을 순차적으로 보면 먼저 Bone Offset Transform을 통해 정점을 Bone space에서 정의되도록 합니다.

그 후 Node Transform을 통해 해당 Bone의 해당 시간에서의 형태를 취하고

마지막으로는 Parent Transform을 통해 다시 Model space에서 정의되도록 합니다.

여기서 중요한 점은 Node Transform을 통해 해당 Bone의 형태를 손쉽게 바꿀 수 있다는 점입니다.

또 Global Transform을 따로 저장하는 이유는 자식 노드에 Parent Transform으로 보내주기 위함입니다.

 

 

관절의 형태에 따라 정점들의 위치를 계산하고 랜더링

마지막은 Shader를 이용해 Rendering하는 코드입니다.

Bone과 정점의 연결 관계, 특정 시간에서의 모든 Bone의 형태를 알고 있기 때문에 각 정점의 특정 시간 때의 위치를 구하는 것은 식은 죽 먹기입니다. 

정점이 여러 개의 Bone에 영향을 받을 때는 각 Bone의 Transform을 선형 조합하므로써 최종 Bone Transform를 얻어낼 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#version 430 core
 
layout (location = 0) in vec3 vertexPos;
layout (location = 1) in vec3 vertexNormal;
layout (location = 2) in vec2 vertexTexCoord;
// tangent와 bitangent 이번 예시에서는 쓰이지 않음
layout (location = 3) in vec3 vertexTangent;
layout (location = 4) in vec3 vertexBitangent;
// 정점은 최대 8개의 bone의 영향을 받음, ivec4 타입 크기 2의 배열을 이용함
layout (location = 5) in ivec4 vertexBoneIds[2];
layout (location = 7) in vec4 vertexBoneWeights[2];
 
const int MAX_BONES = 64;
 
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
uniform mat4 bones[MAX_BONES];
 
out vec3 worldNormal;
out vec3 worldPos;
out vec3 viewPos;
out vec2 texCoord;
 
void main()
{
    mat4 boneTransform = mat4(1.0);
    
    // 애니메이션 정보가 있다면
    if(vertexBoneWeights[0][0!= 0.0)
    {
        boneTransform = bones[vertexBoneIds[0][0]] * vertexBoneWeights[0][0];
        boneTransform += bones[vertexBoneIds[0][1]] * vertexBoneWeights[0][1];
        boneTransform += bones[vertexBoneIds[0][2]] * vertexBoneWeights[0][2];
        boneTransform += bones[vertexBoneIds[0][3]] * vertexBoneWeights[0][3];
        boneTransform += bones[vertexBoneIds[1][0]] * vertexBoneWeights[1][0];
    }
    // 애니메이션 정보가 없다면 boneTransform은 Identity Matrix
 
    vec3 modelPos = vec3(boneTransform * vec4(vertexPos, 1.0));
 
    // normal은 방향 벡터이므로 vec4(vertexNormal, 0.0f)을 이용해 translate 성분 제거
    // 또한 scale 영향을 받지 않으므로 transpose와 inverse를 이용해 scale 성분 제거
    worldNormal = vec3(transpose(inverse(model)) * vec4(vertexNormal, 0.0));
    worldNormal = normalize(worldNormal);
 
    worldPos = vec3(model * vec4(modelPos, 1.0));
    viewPos = vec3(view * vec4(worldPos, 1.0));
    texCoord = vertexTexCoord;
 
    gl_Position = projection * vec4(viewPos, 1.0);
}
cs

지금까지 긴 여정을 통해 컴퓨터 그래픽스에서 스켈레톤 애니메이션을 구현하는 과정을 알아보았습니다.

코드 양이 많아보이지만 부분 부분 나눠서 분석해보면 딱히 많지 않습니다. 

하지만 Transformation에 대한 확실한 이해가 없으면 코드 이해도 어렵고 응용하기도 어렵습니다.

따라서 기왕이면 시간 투자해서 Transformation에 대해 확실히 잡고 가는 것이 좋을거 같습니다.

 

 

참고한 자료는 아래와 같습니다.

코드 참고: 

http://ogldev.atspace.co.uk/www/tutorial38/tutorial38.html

 

fbx 파일 참고:

https://www.youtube.com/watch?v=f3Cr8Yx3GGA

https://drive.google.com/drive/folders/0B4_SgVGfVtFWVUN5MGF6SWpta00

 

 

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함