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
여기서 소스코드 들고 빌드하자.
cmake 없으면 일단 다운받고 시작.
빌드 후에 code 폴더 내에 있는 lib, dll, pdb 를 들고, include 폴더를 들고오자.
lib 추가에 include folder 도 설정해놓으면 세팅 끝.
https://learnopengl.com/Model-Loading/Assimp
간단한 튜토리얼
예제로 좋은 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 에 대한 지식이 확실해질거라고 생각한다.
길고 긴 삽질이었다.