2020년 10월 30일 금요일

Assimp 사용법

1. 배경설명 

Assimp 는 FBX 를 사용하기 위한 Open Assets Import Library 다.

일단 나는 fbx 필요해서 다운받는다.


https://ryanking13.github.io/2018/03/24/png-structure.html

png 코드 정리된거


https://github.com/assimp/assimp/tree/v4.1.0

여기서 소스코드 들고 빌드하자.


https://cmake.org/download/ 

cmake 없으면 일단 다운받고 시작.


빌드 후에 code 폴더 내에 있는 lib, dll, pdb 를 들고, include 폴더를 들고오자.

lib 추가에 include folder 도 설정해놓으면 세팅 끝.


https://learnopengl.com/Model-Loading/Assimp

간단한 튜토리얼


https://www.mixamo.com/#/

예제로 좋은 3D 파일. 

animation 도 같이 있어서 bone 매칭 안해도 되서 좋다.


아래의 코드에서 ai 가 붙은건 assimp 가 쓰는거고, 없는건 내가 만든 자료형이니 주의하자.

Assimp 내의 행렬은 Column 중심 ( Matrix * column = column) 임을 주의하자


2. Scene


튜토리얼에 나와있듯, Assimp 는 위와 같은 구성을 가진 Scene 을 파일에서 가져온다.

Mesh, Material, Root Node, Animation 이 그렇다. 

일단 초기화는 아래처럼 하면 된다.

1
2
3
4
5
6
7
8
9
_importer.SetPropertyFloat(AI_CONFIG_GLOBAL_SCALE_FACTOR_KEY, 0.1f);
 
const aiScene* scene = _importer.ReadFile(s_path,
        aiProcess_CalcTangentSpace |
        aiProcess_Triangulate |
        aiProcess_JoinIdenticalVertices |
        aiProcess_SortByPType | aiProcess_FlipUVs | aiProcess_LimitBoneWeights | 
        aiProcess_GlobalScale
);
cs

Assimp::Importer 로 파일을 로드한다.

관련 변수는 Importer 소멸자가 다 지워준다.


aiProcess_ 로 시작하는 flag 는 주석만 봐도 대충 하는 일을 알 수 있다.

이중에 주의할 것은 aiProcess_GlobalScale 이다.

이거 사용하면 위의 SetPropertyFloat 함수로 조정한 값으로 기본 scale 을 변경할 수 있다.

기본은 cm 가 아니라 m 단위로 로드하기 때문에 모델이 엄청커서 적용하는게 편하다.



3. Skeleton

1
2
3
4
5
6
7
8
9
skeleton = std::make_shared<Skeleton>(_context);       
 
for (int i = 0; i < scene->mNumMeshes; i++)
{
    aiMesh* mesh_data = scene->mMeshes[i];
    LoadSkeletonMap(mesh_data, skeleton);
}
 
LoadSkeleton_Recursive(scene->mRootNode, skeleton->GetRoot(), skeleton);
cs

mesh_data 들에 있는 bone 들의 사전이 될 bone_map 를 먼저 만들자.

skeleton 은 그러한 bone_map 이 저장될 내가 만든 클래스이다.

bone_map 은 그 후에 mRootNode 와 같은 tree 구조로 재구성될 것이다.

assimp 에서 변수를 얻기 위해선 mNum~~ 로 갯수를 얻어 인덱싱하는 것을 기억하자.


1. BoneMap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool Fbx_Importer::LoadSkeletonMap(aiMesh* mesh_data, std::shared_ptr<class Skeleton> skeleton)
{
    if (mesh_data->HasBones())
    {
        for (int b_i = 0; b_i < mesh_data->mNumBones; b_i++)
        {
            auto& bone_data = mesh_data->mBones[b_i];
            
            Matrix offset;
            memcpy(&offset, &(bone_data->mOffsetMatrix), sizeof(Matrix));
     
            skeleton->AddBone(offset, std::string(bone_data->mName.C_Str()));
        }
    }
 
    return true;
}
cs

bone 에는 mWeights, offset, mName 이 있다.


offset 은 각 메쉬를 bone space 로 변환하는 행렬고 꼭 필요한 것이다.

이때 offset 은 부모 노드로부터의 상대적인게 아니라 root 노드로부터 바로 자신의 bone space 로의 변환이다. 아래에서 다시 설명하겠지만 mTransform 과 대조됨을 주의하자.

mName 은 Scene 의 mRootNode 를 root 로 한 tree 의 node 와 1대1 매치가 되고 이 이름이 아니면 매치시킬 방법이 없다. 

mWeights 는 mesh 에서 다시 확인할 것이다.


우선 중요한 offset, name 을 차곡차곡 vector 에 저장해야한다.

나는 위 3가지를 멤버변수로 가지는 structer 를 생성해 vector 에 차곡차곡 쌓는 함수로 AddBone 함수를 만들었다.

이때 mName 이 중복되는 경우도 있는데, 이건 같은 bone 을 의미하는 것이므로 이왕이면 중복되지 않게 추가하자.

저장된 bone 은 skeleton 에 저장시켰다. 


2. Bone Frame

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bool Fbx_Importer::LoadSkeleton_Recursive(const aiNode* node_data, Bone& node, std::shared_ptr<Skeleton> skeleton)
{
    auto bone = skeleton->GetBone(std::string(node_data->mName.C_Str()));
    node.index = bone.index;
    node.offset = bone.offset;
    node.name = bone.name;
 
    if (bone.index >= 0)
        memcpy(&node.local, &(node_data->mTransformation), sizeof(Matrix));
 
    for (int i = 0; i < node_data->mNumChildren; i++)
        LoadSkeleton_Recursive(node_data->mChildren[i], node.AddChild(), skeleton);
 
    return true;
}
cs

방금 만든 Bone Map 의 mName을 통해서 Bone 을 mRootNode 의 트리처럼 재구성한다.

bone->AddChild() 함수와 node_data->mChildren[i] 의 대칭이 이를 잘 보여준다.

이렇게 재구축하면서 기존 bone 의 offset, mName, bone index 에 더해 mTransformation 을 추가하면 완전한 bone frame 을 만들 수 있다.

이렇게 만들어진 bone frame 으로 transform 을 재구축하면 원하는 모델을 표현할 수 있다.


이때 우리의 BoneMap 에 없는 NodeName 이 존재할 수가 있다.

이들은 mesh 에 영향을 주진 않지만 bone 의 위치 등을 상대적으로 바꿀 수 있다.

그러므로 이러한 bone 이 아닌 node 도 포함해서 tree 를 만들어야 한다.

 

3. Offset 과 mTransform

만약 우리가 mesh 의 local space 에서 bone space, 나아가 world space 로의 변환행렬을 구하려면 어떻게 해야할까? 

mRootNode 를 필두로 한 aiNode 에서 우리가 관심있었던 것은 각 노드 속의 mTransformation 이라는 행렬로, 부모 노드로부터의 상대적인 변환행렬이다.

전에 얻은 offset 은 mRootNode 로부터 자신의 bone space 로의 변환행렬이다.

그러므로, mTransformation 을 부모노드까지 재귀적으로 곱해서 만든 bone->world 행렬의 앞에 mesh의 local -> bone 변환 행렬offset 이 한번만 곱해져 있어도,

mesh의 local space -> bone space -> world space 의 변환행렬을 구할 수 있다.


3. Mesh

1
2
3
4
5
6
7
8
9
10
for (int i = 0; i < scene->mNumMeshes; i++)
{
    mesh = std::make_shared<SkeletalMesh>(_context);
    aiMesh* mesh_data = scene->mMeshes[i];

    mesh->Set_Material_Index(mesh_data->mMaterialIndex);
 
    LoadVertices(mesh_data, mesh);
    LoadIndices(mesh_data, mesh);
}
cs

mesh 안에는 vertices, indices, bones 가 있고 하나하나 살펴볼 것이다.


mesh_data->mMaterialIndex 를 주의깊게 보자.

material 은 여러개 있을 수 있는데, 이 mesh_data 에 어떤 material 이 적용될 지는 이 인덱스가 결정해준다. 꼭 따로 저장해주자.


이때 bone 이 좀 까다롭다는걸 먼저 언급한다.

한 대상이 여러 mesh 로 구성되기도 하는데, 그 때 그 안의 bone 은 따로 각각 인덱스가 매겨지기 때문이다.

앞에서 만든 boneMap 은 이를 위해서 있는 것으로, 자세한건 아래를 보자.


1. 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
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
bool Fbx_Importer::LoadVertices(aiMesh* mesh_data, std::shared_ptr<SkeletalMesh> mesh, std::shared_ptr<Skeleton> skeleton)
{
    auto& vertices = mesh->GetVertices();
    int vertex_count = mesh_data->mNumVertices;
    vertices.reserve(vertex_count);
    vertices.resize(vertex_count);
 
    for (int i = 0; i < vertex_count; i++)
    {
        aiVector3D v_pos = mesh_data->mVertices[i];
        aiVector3D v_normal = mesh_data->mNormals[i];
        aiVector3D v_tangent = mesh_data->mTangents[i];
        aiVector3D v_bitangent = mesh_data->mBitangents[i];
        aiVector3D v_uv;
        if (mesh_data->HasTextureCoords(0))
            v_uv = mesh_data->mTextureCoords[0][i];
 
        auto& v = vertices[i];        
        v.pos = { v_pos.x, v_pos.y, v_pos.z };    // z, y 바꾸기
        v.normal = { v_normal.x, v_normal.y, v_normal.z };
        v.tangent = { v_tangent.x, v_tangent.y, v_tangent.z };
        v.binormal = { v_bitangent.x, v_bitangent.y, v_bitangent.z };
        v.uv = { v_uv.x, v_uv.y }; 
    }
 
    if (mesh_data->HasBones())
    {
        for (int b_i = 0; b_i < mesh_data->mNumBones; b_i++)
        {
            auto& bone = mesh_data->mBones[b_i];
            int bone_index = skeleton->GetBone(bone->mName.C_Str()).index;
 
            for (int w_i = 0; w_i < bone->mNumWeights; w_i++)
            {
                if (vertex_count <= bone->mWeights[w_i].mVertexId)  // outof index
                    return false;
    
                auto& v = vertices[bone->mWeights[w_i].mVertexId];
                for (int k = 0; k < 4; k++)
                {
                    if (v.bone_weight[k] == 0)
                    {
                        v.bone_index[k] = bone_index;
                        v.bone_weight[k] = bone->mWeights[w_i].mWeight;  // mixamo, mWeight sum is 8 but don't need normalization
                        break;
                    }
                }
            }
        }
    }
 
    for (int i = 0; i < vertex_count; i++)
    {
        auto& v = vertices[i];
        float sum = v.bone_weight[0+ v.bone_weight[1+ v.bone_weight[2+ v.bone_weight[3];     
 
        v.bone_weight[0/= sum;
        v.bone_weight[1/= sum;
        v.bone_weight[2/= sum;
        v.bone_weight[3/= sum;     
    }
 
    return true;
}
cs

길어보이지만 그냥 데이터 들고오는 거다.


위에서 들고온 mesh_data 에서 mNumVertices 를 들고 mVertices 에서 하나씩 인덱싱한다.

그리고 필요한 값을 들고온다.


문제는 Bone 관련 데이터인데, bone 도 나머지랑 똑같이 들고온다.

bone 은 각각 자신이 영향을 주는 vertex index 랑 weight 를 mWeights 에 가지고 있다.

그러니까 위처럼 mWeights 를 들고와서 거기 안에 있는 mVertexID 를 꺼낸다.

그걸로 위에서 만든 Vertices 를 인덱싱하고, 우리가 필요한 정보를 꺼내면 된다.


이때 for 문에 있는 b_i 를 쓰지 않고 skeleton 에서 bone index 를 가져와서 vertex 에 저장하는 것을 주의깊게 보라.

bone index 는 우리가 만든 bone map 에서 mName 을 key 로 사용해 우리의 bone index 로 대체되는 것이다.


위에는 혹시 몰라 bone weight 를 정규화까지 했다.

보통은 필요없는데, mixamo model 이 가끔 weight 합이 8이라는 소리가 있어서...


2. Index

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool Fbx_Importer::LoadIndices(aiMesh* mesh_data, std::shared_ptr<class SkeletalMesh> mesh)
{
    auto& indices = mesh->GetIndices();
    int face_count = mesh_data->mNumFaces;
 
    indices.reserve(face_count * 3);
    indices.resize(face_count * 3);
 
    for (int i_face = 0; i_face < face_count; i_face++)
    {
        auto& face = mesh_data->mFaces[i_face];
        indices[i_face * 3 + 0= face.mIndices[0];
        indices[i_face * 3 + 1= face.mIndices[1];
        indices[i_face * 3 + 2= face.mIndices[2];
    }
 
    return true;
}
cs

face 가 중요한데, 이건 폴리곤의 한 면을 말한다. 

이 면이 꼭 삼각형일 필요는 없으므로 face 를 이루는 mNumIndices 같은 정보가 face 에 있다.

하지만 scene 만들때 aiProcess_Triangulate 를 넣었으면  face 는 3개의 정점으로 구성된다.

그럼 face 안에 있는 index 는 3개 뿐이므로, 위처럼 face의 mIndices 에서 3개씩 뽑는다.



4. Material

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
bool Fbx_Importer::LoadMaterial(const aiScene* scene, aiMaterial* material_data, std::shared_ptr<class Material> material)
{
    auto mgr = _context->GetSubsystem<ResourceManager>();  // texture 저장용
 
    aiString path;
    aiTextureType type = aiTextureType::aiTextureType_NONE;
    int count = -1;
 
    material->Set_MaterialName(FileSystem::ToWString(material_data->GetName().C_Str()));
 
    for (int i = 0; i < 18; i++)
    {
        type = static_cast<aiTextureType>(i);
        count = material_data->GetTextureCount(type);
        path = "";
        if (count > 0)
        {
            std::shared_ptr<Texture> texture = std::make_shared<Texture>(_context);
 
            material_data->Get(AI_MATKEY_TEXTURE(type, 0), path);
            auto tex_data = scene->GetEmbeddedTexture(path.C_Str());
            LoadTexture(tex_data, texture);
 
            mgr->RegisterResource<Texture>(texture, texture->GetPath());
            material->Set_Texture(texture->GetPath(), static_cast<Material::Type_Texture>(type));
        }
    }
 
    return true;
}
cs

material 도 scene 에서 vertices 나 indices 에서 그랬듯이 빼내주자.


material 에서 우리가 주목할 것은 texture 와 그 타입이다.

diffuse, specular, normal 등 온갖 종류마다 그에따른 텍스쳐가 있는데. 

aiTextureType 의 18가지의 Texture 타입에 대한 enum class 가 바로 그 타입이다.


그걸 빼내는 함수는 Get + scene->EmbeddedTexture(), 또는 GetTexture 이다. 

Get + scene->EmbeddedTexture() 은 구버전으로 호환을 위해 아직 있다.


각각의 타입에 맞추어서  를 통해 aiTexture 를 뽑고,

그 결과는 aiTexture 로 따로 변환을 해 각 타입에 맞추어 내가 쓸 material class 에 저장한다.


1. Texture

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
bool Fbx_Importer::LoadTexture(const aiTexture* tex_data, std::shared_ptr<class Texture>& texture)
{
    uint width = tex_data->mWidth;
    uint height = tex_data->mHeight;
 
    if (height == 0)  // decoded data 
    {
        std::string extension = L'.' + std::string(tex_data->achFormatHint);
        std::string texName = FileSystem::GetIntactFileNameFromPath(FileSystem::string(tex_data->mFilename.C_Str()));
 
        FileStream stream;
 
        // 얘만 resource Manager 안거쳐서 Reletive_Basis 를 넣어야하지만, 나머진 넣으면 안됨. 특히 바로 아래부분.
        std::ofstream out;
        out.open(Relative_BasisW + _basePath + texName + extension, std::ios::out | std::ios::binary);
        {
            if (out.fail())
            {
                LOG_ERROR("Fail To Open \"%s\" for Writing", (Relative_BasisW + _basePath + texName + extension).c_str());
                out.close();
                return false;
            }
            out.write(reinterpret_cast<const char*>(tex_data->pcData), tex_data->mWidth);
        }
        out.close();
 
        texture = _context->GetSubsystem<ResourceManager>()->GetResource<Texture>( _basePath + texName + extension);
        
        return true;
    }
    else
    { 
    ...
    {
}
cs

aiTexture 는 어떻게 저장하는가?

aiTexture 는 fbx 등의 파일 내부에 있을 수 있고 밖에 따로 있을 수 있다.


후자의 경우는 text_data->mFileName 에서 경로를 따서 FreeImage 등의 Lib 를 통해 읽으면 되므로 편한 경우이다. 

전자의 경우는 height == 0 이 될 때로, tex_data->pcData 에 원하는 파일확장자에 따른 파일이 binary 형태로 그대로 있다. 

이때 width 은 파일의 byte 단위 크기를 의미하게 된다.


확장자는 png 가 많은데, 혹시 BIT 가 아닌 이런 확장자라면 주의해야한다. 

왜냐하면 BIT 처럼 각 byte 가 하나의 channel 을 의미하는게 아니기 때문이다.


위에선 나중에 별도의 프로그램으로 로드시킬려고 그냥 통째로 저장시켰다.

확장자는 tex_data->achFormatHint 에 있고,

파일경로는 괴랄하기 때문에 파일 이름만 따로 떼서, 내가 설정한 경로에 붙여서 저장시켰다.


5. Animtaion

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
for (int i = 0; i < scene->mNumAnimations; i++)
{
    auto animation_data = scene->mAnimations[i];
 
    animation->Set_Duration(animation_data->mDuration);
    animation->Set_MsPerTic(1000.0f / animation_data->mTicksPerSecond);
    animation->Init_Channels(skeleton->GetBonesNumber()); //채널 갯수 초기화
          
    for (int j = 0; j < animation_data->mNumChannels; j++)
    {
        auto channel_data = animation_data->mChannels[j];
        auto node = skeleton->GetBone(channel_data->mNodeName.C_Str());
 
        auto& channel = animation->Get_Channel(node.index);
        channel.bLoop = channel_data->mPostState == aiAnimBehaviour::aiAnimBehaviour_REPEAT;
 
        auto& keys = channel.Init_Keys(channel_data->mNumPositionKeys); // Key 갯수 초기화
        
        for (int i_pos = 0; i_pos < channel_data->mNumPositionKeys; i_pos++)
        {
            auto& key = keys[i_pos];
            auto pos = channel_data->mPositionKeys[i_pos];
            auto rot = channel_data->mRotationKeys[i_pos];
            auto scl = channel_data->mScalingKeys[i_pos];
            key.pos = Vector3(pos.mValue.x, pos.mValue.y, pos.mValue.z);
            key.scale = Vector3(scl.mValue.x, scl.mValue.y, scl.mValue.z);
            key.rot = Quaternion(rot.mValue.x, rot.mValue.y, rot.mValue.z, rot.mValue.w);
            key.duration = pos.mTime;
        }
    }
}
cs

총 3개의 파트로 구성된다.

Animation 구하기 -> Channel 구하기 -> Key 구하기.


Animation 은 애니메이션의 종류로 보통 하나 들어있다.

mDuration 은 총 틱의 갯수가, mTickPerSecond 는 말그대로 초당 틱 갯수를 의미한다.

만약 mDuration = 41, mTickPerSecond = 30 이면 1000 / 30 ms * 41 초의 애니메이션이란 말이다.


Channel 은 bone 과 그대로 매치가 되는 것으로, 위에서도 bone map 에서 mNodeName 를 key 로 사용해 index 를 뽑았다.

왜 이렇게 했냐면, 내가 저장할 channels 의 인덱스를 bone index 와 똑같이 하기 위해서다.

꼭 이렇게 할 필요는 없지만 mNodeName 과 Bone, 그리고 mRootNode 부터의 Node 들 세가지가 모두 이름으로 매칭이 되는 것을 기억하자.

mPostState 는 마지막 틱 후에 다시 돌아갈건지 등의 행동이 enum 으로 지정되어 있다.


Key 는 Bone 혹은 Node 별 특정 시점의 SRT 변환 값을 갖고 있다.

time 은 보통은 현재 key 가 처음부터 몇번째 틱인지 들어있다. 

그래서 보통은 frame 의 인덱스랑 같다.  


반복시 end index -> start index 의 보간은 필요없다.


6. 마무리


뭔가 긴데 확실히 복잡하다.

하지만 한번 스스로 구현해보면 3D Model 에 대한 지식이 확실해질거라고 생각한다.


길고 긴 삽질이었다.

List