본문 바로가기
그래픽스

Asssimp 를 이용한 SkinningMesh and Skeletal Animation

by greenherb 2021. 8. 21.

사실 이번에 넣을 기능은 인터넷에서 쉽게 검색할 수 있는 스켈레탈 애니메이션과 다르지 않습니다.

다만 튜토리얼을 보고 마주친 한계점에 대해서 그리고 의문점에 대해 적어 보겠습니다. 구현하면서 뭔가 구조적으로 마음에안들고 성능도 안나와서.. 많이 고쳐야할 코드인것같아요

일단 기본적으로 참고한 튜토리얼은 아래 링크입니다. 아래 내용을 숙지했다는 가정하에 제가 구현하고 느낀점을 서술하도록하겠습니다.

https://learnopengl.com/Guest-Articles/2020/Skeletal-Animation

 

LearnOpenGL - Skeletal Animation

Skeletal Animation Guest-Articles/2020/Skeletal-Animation 3D Animations can bring our games to life. Objects in 3D world like humans and & animals feel more organic when they move their limbs to do certain things like walking, running & attacking. This tut

learnopengl.com

이전에 Assimp를 사용하여 모델을 물러올때 노드구조를 유지한다고 했었죠? 위에서 스켈레탈 튜토리얼을 보고 저도 구현하려고 했습니다. 

스켈레탈 애니메이션에 필요한 가정을 나열해보자면 

첫번째로 Assimp를 사용하여 정점데이터를 불러와야할것 입니다. 정점데이터를 불러오면 Assimp에서는 노드가 나뉘어져있고 노드에 해당하는 메쉬들이 있습니다. 그렇담 해당 메쉬에 접근하여 정점데이터를 가공할때 메쉬에서 접근할 수 있는 정보가 있는데요 바로 본(뼈대) 정보를 접근할 수 있습니다. 말그대로 해당 메쉬에 영향을 주는 본이름에 접근할 수 있으며 본은 이름과 오프셋행렬 그리고 가중치 값을 가지고있습니다. 가중치는 두개의 정보로 이루어져있는데 ID값과 가중치값입니다. ID값은 불러온 정점데이터중 (순서대로 불러왔다면) 한개의 정점을 가르키는 ID값이며 가중치는 해당 정점에 대한 뼈의 가중치 값입니다. 그렇담 생각해보면 현재 메쉬가 가지고있는 정점중 하나에게 영향을 미치는것이겠죠? 오프셋 행렬은 말그대로 본의 로컬좌표계로 이동시켜주는 행렬로 기존의 월드공간에있는 정점들을 뼈대 공간으로 이동시킵니다. 실제로 메쉬 스키닝을 하지않고 그냥 씬불러오듯이 모델을 불러오면 모델들이 T-pose (인간형의 경우 T자로 팔을벌리며 서있는 모습) 을 하고 있을것입니다. 이상태에서 오프셋행렬을 적용하면 뼈대 공간으로 이동하는데 보통 원점으로 다 이동합니다. 이럴경우 모든 메쉬가 한곳으로 모인 모습이겠죠?

 

스키닝을 적용하지않고 불러올경우

 

오프셋 행렬만 적용할경우

결국 본마다 영향을주는 정점이있고 해당정점에 가중치만큼 행렬을 곱해줘야하므로 우리는 본의 정보를 저장할 필요가 있습니다. 본은 이름도있고 우리가 저장해야할 정보는 본의 오프셋행렬과 본을 구별할 ID값을 해쉬맵에 저장하는게 편할것 같습니다.

이제 Assimp를 통해 루트노드부터 아래로 내려가면서 메쉬에대한 정보를 구성하고 메쉬에 접근하면서 본의 정보도 저장하면 메쉬에 영향을 미치는 모든 본을 구할것입니다. 이제 이렇게 스키닝에 사용될 본이 구해졌다면 해당 본에 애니메이션을 적용하기위해 Assimp 씬에서 애니메이션 클립에 접근하여 해당 클립이 어떤 노드/본에 대한 키값을 가지고있는지 확인해야합니다.

이때 구현하면서 중요한것이있는데요. 메쉬 스키닝에 사용되는 오프셋행렬을 가지는 뼈대는 아니지만 애니메이션에 계층구조로 해당 뼈대를 움직이기위해서 움직이는 노드들이 존재한다는걸 아셔야합니다. 저는 이 노드들을 제외하고 뼈대들로만 움직이려고하니 일부 애니메이션이 안먹는 구간이 생기더라구요.

사실 튜토리얼에서도 노드구조를 내려가면서 해당 노드의 트랜스폼을 계속 곱해가면서 최종 변환행렬을 만드는것을 보았습니다. 그리고 그 최종변환행렬을 현재뼈대에 해당하는 ID값을 참고하여 쉐이더에 전달할 뼈행렬들에대한 인덱스값으로 사용하고 정점들은 정점레이아웃에 같이 들어가는 BoneID를 인덱스로 사용하여 뼈행렬에 접근할테지요.

튜토리얼은 루트노드에서 노드를 내려가면서 그리고 그 노드의 이름을 현재 애니메이션 클립에서 노드이름으로 현재 시간에 해당하는 위치를 받아옵니다. 이런식으로 구성되었는데 저는 Entt 라이브러리 즉 엔티티컴포넌트시스템을 사용하고있으므로 노드구조를 루트노드에서 내려가서 렌더링하기에 좀 다른방법으로 진행했습니다.

Assimp를 이용해 정점데이터를 가공하면서 해당 모델이 애니메이션이있다면 메쉬를 가공하는데 그냥 MeshComponent가 아닌 DynamicMeshComponent 형식으로 해당 메쉬를 가공하고 노드에 컴포넌트를 할당합니다. 그리고 모델이 불러와지면서 거쳐가는 모든 노드들은 엔티티로 생성되구요

이럴경우 보통 루트노드는 메쉬컴포넌트를 가지지않습니다. 대부분의 모델들이 내부 노드에 메쉬를 가지고있더라구요. 이럴경우 후에 쉐이딩 렌더패스에서 스태틱 메쉬컴포넌트와 다이나믹 메쉬컴포넌트를 구별해서 렌더링해야할 필요가있으며 또한 우리가 뼈행렬들을 상수버퍼로 제공하는이상 각 다이나믹메쉬컴포넌트를 불러올때 해당하는 뼈행렬들을 입력하고 쉐이더에 전달해줄 의무가 있습니다.

이런구조에서는 튜토리얼에서처럼 그냥 VAO 만들고 바인딩하고 할 수 없습니다. 왜냐하면 모든 노드들이 객체화 되었기 때문이죠 아무래도 범용적인 관점에서 문제를 해결할 필요가 있었습니다.

모델을 불러오면 계층뷰에서 하나의 계층을 구성하고있을테고 그 계층안에서 어딘가에 있을 다이나믹메쉬컴포넌트를 들고있을 노드를 위해 뼈행렬들을 만들어야합니다.

결국 현재 계층뷰에있는 노드들의 위치에대한 업데이트가 프레임마다 이루어져야하며 두번째로 렌더링 패스에서 다이나믹메쉬컴포넌트를 들고있는 노드만 추려내서 렌더링할때 해당 다이나믹컴포넌트를 들고있는 노드가 자신에게 영향을 줄 (스키닝) 노드에 접근할 정보가 필요했죠.

그래서 모델을 불러올때 루트노드에서 내려가면서 객체생성과 동시에 해당 객체에대한 ID값과 이름을 저장하는 일종의 노드맵이 필요했습니다. 이래야 추후에 다이나믹메쉬를 초기화할때 어떤 노드가 해당메쉬에 영향을 주는지 알 수 있기 때문이죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Entity rootNode;
std::vector<std::pair<uint32_t,std::string>> nodes;
std::vector<Entity> meshEntity;
BoneStructure bonestructure;
bool HasAnim = scene->HasAnimations();
HasAnim = false;
if (HasAnim)
{
    rootNode = ProcessDynamicNodeEntity(path, scene->mRootNode, scene, pScene, parentEntity, meshEntity, nodes, bonestructure);
    for (int i = 0; i < meshEntity.size(); ++i)
    {
                meshEntity[i].GetComponent<DynamicMeshComponent>().Initialize(nodes);
    }
    rootNode.AddComponent<AnimatorComponent>().animator.Initialize(scene,nodes, bonestructure.m_OffsetMatMap, bonestructure.boneCount);
}
else
rootNode = ProcessNodeEntity(path, scene->mRootNode, scene, pScene,parentEntity);
cs

예를들어 위코드를 보면 ProcessDynamicNodeEntity 함수에 전달하는 파라매터를 보시길 바랍니다.. 엄청나게 더럽습니다.. 첫번쨰는 모델의 경로고 두번째는 루트노드 3번째는 aiScene변수 네번째는 현재씬의 정보 5번째는 부모객체 6번째는 다이나믹메쉬컴포넌트를 가지고있을 객체를 담을 벡터 7번째는 노드를 내려가면서 저장해둘 ID,String 페어값을 가지고있는 벡터 그리고 마지막은 해당 메쉬의 본정보를 담을 해쉬맵입니다.

솔직히 말하면.. 이렇게하는게 저의 최선이었습니다. 물론 더 좋은 방법도 있겠다만.. 노드를 내려가면서 접근해야하는 Assimp 구조상 노드에 접근하고 엔티티를 생성하기때문에 생성도중 다이나믹컴포넌트를 가질 엔티티에게 아직 완전하게 노드구조가 완성되지않았기에 어떤 노드가 영향을 줄 수 있는지에 대한 정보를 전달해 줄 수 없었습니다.

결국 위와같은형태로 노드구조가 완성되고 해당 구조에 정점을 가공하여 다이나믹메쉬컴포넌트를 가질 엔티티들을 따로 저장하고 동시에 노드를 만들면서 해당 객체에대한 정보를 저장하고 그리고 본구조도 저정하여 마지막에 나와서는 메쉬를 가지고있는 객체들을의 컴포넌트에 초기화를 진행시켜줍니다.

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
// DynamicMesh has a Node that effect mesh
struct DynamicMeshComponent
{
    Ref<VertexArray> vertexArray;
    //std::set<std::string> m_boneName;
    std::unordered_map<std::string, BoneInfo> m_OffsetMatrix;
    //std::vector<uint32_t> m_nodes;
    std::vector<std::pair<uint32_t, std::string>> m_nodes;
 
    std::string modelPath;
    DynamicMeshComponent() = default;
    //DynamicMeshComponent()
    void AddMesh(const std::string& meshName)
    {
        auto mesh = MeshLibrary::Load(meshName);
        if (mesh != nullptr)
            vertexArray = mesh;
        else
            QCAT_CORE_ERROR("There is no mesh name '{0}'", meshName);
    }
    void AddMesh(Ref<VertexArray>& vertexarray)
    {
            vertexArray= vertexarray;
    }
    void Initialize(std::vector<std::pair<uint32_t, std::string>>& nodes)
    {
        for (auto& element : nodes)
        {
            const std::string& nodename = element.second;
            uint32_t id = element.first;
            auto iter = m_OffsetMatrix.find(nodename);
            if (iter != m_OffsetMatrix.end())
            {
                    m_nodes.push_back(element);
            }
        }
    }
};
cs

다이나믹 메쉬컴포넌트엔 오프셋행렬과 그리고 노드의 ID값과 해당노드의 이름을 가지고있습니다. 초기화함수에서 위에서 준비된 모든 노드의 정보를 가지고있는 벡터를 넣어주면 해당 벡터를 순회하면서 가지고있는 오프셋행렬 (뼈정보) 에 해당하는 이름이있는지 확인합니다. 만약 뼈이름과 동일하다면 해당 노드의 정보는 ID값과 이름과 함께 m_Nodes 정보에 들어가게됩니다.

이 ID값은 엔티티의 고유 GUID 값을 의미하며 후에 해당 노드의 트랜스폼에 접근할때도 사용됩니다. 그리고 m_OffsetMatrix는 튜토리얼 코드중 가중치와 본ID를 정점에 입력하는 함수에서 해당 메쉬에 해당하는 뼈이름과 행렬을 저장한 것입니다.

이렇게 되기에 저런 더러운 코드가 만들어졌습니다.. 아무리 생각해봐도 저방법말고는 생각이 나지않더군요 마지막으로애니메이터를 초기화할때인데. 이때또 웃긴것이 애니메이션클립에 들어간 노드가 꼭 메쉬에서 접근하여 얻을 수 있는 뼈에 안들어가 있을 수 있다는것입니다. 그래서 애니메이션 클립을 초기화할때 현재 만들어진 뼈맵에 없는 노드의 이름이있을경우 새로추가하고 해당 노드들의 정보를 가진 애니메이터를 만들어야합니다.

애니메이터도 시간마다 현재 클립에따라서 노드들의 트랜스폼을 변화시켜줘야하기때문에 ID값을 가진 벡터를 들고있습니다.

사실 이경우 큰 문제는 계층뷰에서 중간노드를 지워버리면 어떻게 되나입니다. 물론 ID값으로 접근하기에 만약 지워져 ID맵에 없을경우 아무것도하지않도록해도되지만 유니티의경우는 원본의 중간노드를 지우려고할경우 프리팹화를 시킨후 지워야하더라구요. 지울경우 예상하신것처럼 메쉬가 쭉쭉 늘어나게됩니다.

그리고 모델을 불러오실떈 aiProcess_LimitBoneWeights 키워드를 꼭 넣으시길 바랍니다. 만약 정점에 영향받는 최대본을 4개로 하실경우 4개의 가중치의 합을 1로만들어주는 키워드로 일부 FBX 모델은 위 키워드 없이 불러올시 가중치합이 1이 안되서 메쉬가 찌그러지는 현상을 발견하실수 있어요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for (auto entity : dynamicview)
{
    auto& dynamicMeshComp = dynamicview.get<DynamicMeshComponent>(entity);
    auto& transofrmComp = dynamicview.get<TransformComponent>(entity);
    auto& nodes = dynamicMeshComp.m_nodes;
    auto& offsetMap = dynamicMeshComp.m_OffsetMatrix;
    for (int i = 0; i < nodes.size(); ++i)
    {
        auto& boneInfo = offsetMap[nodes[i].second];
        const glm::mat4& globalTransform = scene->GetEntityById(nodes[i].first).GetComponent<TransformComponent>().GetTransform();
        matrices.boneMatrix[boneInfo.id] = globalTransform* boneInfo.offsetMatrix;
    }
boneMatrixConstnatBuffer->SetData(&matrices, sizeof(BoneMatrix), 0);
cs

렌더링패스에서는 다이나믹메쉬를 가지고있는 엔티티를 렌더링할때 위에처럼 해당 메쉬를 렌더링할때 컴포넌트가 가지고있는 오프셋행렬과 해당 본에해당하는 노드의 ID값에 접근하여 트랜스폼을 얻어 최종변환행렬을 얻게됩니다.

이 노드의 트랜스폼을 업데이트하기위해 애니메이터는 매프레임마다 업데이트되고 그에따라 노드구조간의 트랜스폼변화로인한 노드업데이트로 이부분에서 많은 CPU사용량이 발견되는것 같습니다.

그리고 의문점은 정점쉐이더에서 스키닝을 진행할때인데요

 

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
VSOut VSMain(VSIn Input)
{
 
    float4 totalPosition = float4(0.0f, 0.0f, 0.0f, 0.0f);
    float3 totalNormal = float3(0.0f, 0.0f, 0.0f);
    float3 totalBitangent = float3(0.0f, 0.0f, 0.0f);
    float3 totalTangent = float3(0.0f, 0.0f, 0.0f);
 
    for (int i = 0; i < 4++i)
    {
        if (Input.boneIDs[i] == -1)
            continue;
        if (Input.boneIDs[i] >= 100)
        {
            totalPosition = float4(Input.pos, 1.0f);
            totalNormal = Input.normal;
            totalTangent = Input.tan;
            totalBitangent = Input.bitan;
            break;
        }
        float4 localPosition = mul(u_BoneMatrices[Input.boneIDs[i]], float4(Input.pos, 1.0f));
        
        float3x3 bonemat3x3 = (float3x3)u_BoneMatrices[Input.boneIDs[i]];
        float3 localNormal      = mul(bonemat3x3, Input.normal);
        float3 localTangent   = mul(bonemat3x3, Input.tan);
        float3 localBitangent = mul(bonemat3x3, Input.bitan);
 
        totalPosition += localPosition * Input.weights[i];
        totalNormal += localNormal * Input.weights[i];
        totalTangent += localTangent * Input.weights[i];
        totalBitangent += localBitangent * Input.weights[i];
    }
 
    VSOut vso;
    vso.id = u_ID;
    //matrix viewprojMat = mul(u_Projection, mul(u_View,u_Transform));
    matrix viewprojMat = mul(u_Projection,u_View);
 
    //float3x3 normalMat = (float3x3)transpose(u_invTransform);
    
    float3x3 normalMat = float3x3(1.0f,0.0f,0.0f,0.0f,1.0f,0.0f,0.0f,0.0f,1.0f);
 
    vso.pos = mul(viewprojMat, totalPosition);
    vso.normal = mul(normalMat, totalNormal);
 
    float3 T = normalize(mul(normalMat, totalTangent));
    float3 B = normalize(mul(normalMat, totalBitangent));
    float3 N = normalize(mul(normalMat, totalNormal));
cs

위코드에서 보다싶이 튜토리얼 쉐이딩코드는 위와같습니다. ID값이 -1이면 반복문을 넘어가고 100을넘을경우는 기존에 들어온 Pos값으로 그렇지않고 ID값이 있을경우 본행렬에서 ID값을 인덱스로 접근하고 해당 행렬을 곱한후 합산하여 총 위치값을 얻습니다.

근데 궁금한점은 노말값도 위치이동을 제외한 3x3행렬을 사용함으로써 노말스키닝이 이루어집니다. 저는 노말매핑을 위해 탄젠트와 바이탄젠트도 스키닝을 진행했는데요 , 보통 노말매핑이라면 모델의 역전치행렬을 곱하는것으로 알고있습니다. 비균등 스케일에대한 노말의 어긋남을 제거하기위해 역전치행렬을 곱하는것으로 알고있는데. 

저는 의문인것이 애니메이션에의해 계속바뀌는 트랜스폼이 스케일값도 모든 변화가 균등하게 변할지 궁금하더라구요. 혹여 애니메이션중에 X축으로 스케일이이루어져 Y,Z와 균등하지않게되는 애니메이션이있다면 위 코드를통한 노말매핑은 먹히지 않을겁니다.

철저하게 저코드는 모델행렬이 균등스케일이라고 가정하고 짜여진 코드에요 사실 normalMat 부분도 기존코드에는 모델의 역전치행렬이 들어있었습니다.

그래서 의문인점이 역전치행렬을 곱하지 않아도 문제는 없을까입니다.. 일단 확인한 바로는 아직까지 문제가 될만한 장면은 포착하지 못했습니다.

스키닝이 이루어진모습

스키닝이 이루어졌기때문에 영향을 주는 노드위치값을 바꿔버리면 저렇게 껌마냥 늘어납니다.

또 아직까지는 불안정해서 디버그모드에서 여러가지 애니메이션 오브젝트가 있으면 프레임이 아주 많이 떨어지더라구요.. 심지어 그림자도 적용하지않은 장면입니다.

애니메이션에따라 변하는 노드위치

위 이미지처럼 프레임마다 변하는 위치를 업데이트하여 아래에있는 노드들이 부모노드의 위치가 바뀌었음을 알게해야합니다. 이부분에서 최적화가 필요하다고 생각합니다만.. 아직 적절한 방법은 못찾겠네요. 현재로써는 모든 엔티티를 돌면서 부모가 있는지 체크하고 만약 부모가없다면 자식이 없을떄까지 내려가면서 부모행렬을 갱신해주는 방법인데 꽤 비효율적인것같습니다.

애니메이션 오브젝트위에 애니메이션오브젝트

이런식으로 어깨위에 고양이도 태워주시고.. 사실 이런 계층구조를 엔티티화하지않고 그냥 다이나믹메쉬컴포넌트를 하나 들고있는 엔티티하나에서 메쉬컴포넌트에서 모든 정보를 다루게하면 구현이 더 빨랐을겁니다. 보수도 간단하고 하지만 범용적으로 생각해봤을때는 별로인것같아서 위와같은 구조로 만드느라 성능과 시간을 버리게된것같습니다..

노드안에 메쉬들들고있는 노드

그리고 기존의 스태틱메쉬를 들고있는 노드의 위치를 바꾸면 스폰자씬처럼 이동하는것과달리 다이나믹메쉬는 해당 메쉬컴포넌트를 들고있다고해도 영향을 주면안됩니다 오로지 영향을주는 노드에의해서만 영향을 받아야하죠. RootNode의 위치를 바꾸거나 회전하는등의 영향을 받는것도 사실은 영향을주는 노드들이 루트노드의 자식노드라 영향을 받기 때문입니다. 실제 유니티에서는 스킨드메쉬렌더러를 들고있는 오브젝트의 위치를 바꿔도 메쉬의 위치가 바뀌진 않습니다.

사실 옆에 프레임수치를 보다싶이.. 아주 프레임이 박살이 났습니다. 물론 ImGui도 프레임을 줄이는데 한몫했겠지만.. 아마 이런 성능개선을 하기전까지는 다음 개발진척이 좀 느려질것같습니다..

여기서 그림자까지 추가하려면 쉐도우맵핑 패스에서 해당 애니메이션모델을 그리고 또 다시 쉐이딩패스에서 또다시 2번그려야합니다.. 이경우 상수버퍼도 패스가 나뉘어져있어서.. 두번 업로드될것이고.. 두번그리니 쉐이딩패스에서 모델을 2개그리는것 과 같은 프레임을 잡아먹겠지요..

그래서 애니메이션에 그림자를 어덯게 처리해야할지 곰곰히 생각해보다. 애니메이션 계산하는부분이나 스키닝 부분을 어떻게 좀 개선할 수 없을까 생각해보기도했습니다.

컴퓨트세이더를 사용하여 애니메이션 행렬들을 계산한다던지.. 여러모로 손이 많이갈것같네요.