Assimp를 활용한 FBX 모델 파싱과 구조화
외부 3D 모델 파일인 FBX 포맷을 게임 엔진에서 직접 활용 가능한 형태로 변환하기 위한 전체적인 과정
핵심적으로 활용되는 것이 바로 Assimp (Open Asset Import Library) 라는 외부 라이브러리
강의 목표
1. FBX 모델이 내부적으로 어떤 구조로 구성되어 있는지를 파악하고,
2. Assimp를 활용해 FBX 데이터를 메모리로 로드한 후,
3. 트리 계층 구조로 파싱하여 각 노드별 정보를 추출하며,
4. 데이터를 자신만의 커스텀 포맷(asMesh, asMaterial 등)으로 변환해 저장/활용할 수 있는 기반 만듦
FBX는 단순히 정점과 인덱스로 구성된 메시 파일이 아니라, 다양한 머티리얼, 텍스처, 조명, 애니메이션, 계층 구조 등의 정보를 포함.
필요한 정보만 뽑아내고 효율적으로 사용할 수 있도록 정리하는 작업이 필수임
수업 진행 과정
향후 애니메이션, 본 스키닝, 최적화된 로딩 구조 등과도 밀접한 연관을 갖는 기초 작업
FBX는 단순히 정점(Vertex)과 인덱스(Index) 정보만 담긴 메시 파일이 아님, 복잡한 형태의 트리 구조
트리(Tree) 구조의 계층화된 노드 안에 포함.
FBX : 부모-자식 관계를 갖는 트랜스폼 구조를 기본으로 한 계층적 3D 모델 데이터
aiScene – 전체 FBX 파일을 대표하는 루트 컨테이너aiNode – 계층구조를 표현하는 노드. 이름, 트랜스폼, 자식 노드를 포함aiMesh – 메시 데이터(정점, 인덱스, UV, 노멀 등)aiMaterial – 텍스처 경로, 색상 정보 등 머티리얼 속성aiMatrix4x4 – 트랜스폼 행렬(SRT 기반)(Tree Structure)
[aiScene]
└── aiNode(Root)
├── aiNode(Head)
│ ├── aiNode(Eye_L)
│ └── aiNode(Eye_R)
└── aiNode(Body)
└── aiNode(Arm)
WorldTransform = ParentWorldTransform * LocalTransform;
FBX 파일을 그대로 실시간으로 불러오면 다음과 같은 문제가 발생
필요한 정보만 골라내고, 그것을 커스텀 정의한 구조체(asMaterial, asMesh 등) 에 담아 커스텀 포맷으로 변환함
이후부터는 이 커스텀 포맷만 빠르게 로드하면 되기 때문에, 로딩 속도와 구조적인 관리 측면에서 모두 유리함
| 개념 | 설명 |
|---|---|
| FBX | 3D 모델, 트랜스폼, 애니메이션, 조명 등 복합 요소 포함 |
| Assimp | 다양한 포맷을 지원하는 범용 3D 모델 로더 |
| aiScene | FBX 전체 구조의 루트. 메시, 노드, 머티리얼 포함 |
| aiNode | 트리 구조의 노드. 이름, 변환행렬, 자식 노드 포함 |
| aiMesh | 실제 그리기 위한 메시 정보(정점, 노멀, UV 등) |
| aiMaterial | 텍스처 경로, 색상 등 시각적 정보를 담는 객체 |
| Local ↔ World 좌표계 | 부모 트랜스폼과의 행렬 곱으로 절대 위치 계산 |
| Custom Format | asMesh, asMaterial 등 커스텀 구조체로 파싱 및 저장 |
Autodesk가 만든 3D 에셋 포맷
단순 메시(Vertex, Index)뿐만 아니라, 애니메이션, 머티리얼, 텍스처, 본, 라이트, 카메라 등 다양한 요소들이 함께 포함되어 있는 복합 구조 포맷
계층 구조(Hierarchy) 를 바탕으로 씬(Scene)을 구성
FBX 모델은 단일 메시가 아니라 여러 노드들이 부모-자식 관계로 구성된 트리 구조
각 노드는 Transform 행렬을 포함하며, 부모 노드를 기준으로 한 상대 좌표(로컬 좌표)를 가짐
즉, 각 노드를 파싱할 때는 재귀적으로 순회(Traversal) 하며, 부모 좌표계를 누적 곱해서 월드 좌표계를 계산해야 함
ReadFile()을 호출해 .fbx 파일을 메모리에 올리며, 결과로 aiScene*을 반환함Assimp가 FBX를 로드했을 때 만들어지는 최상위 객체
내부에는 다음과 같은 구성 요소가 존재
mRootNode : 씬의 루트 노드mMeshes[] : 메시 리스트mMaterials[] : 머티리얼 리스트씬 내에서 실제 트리 구조를 이루는 핵심 단위
내부 구성:
mName: 노드 이름mTransformation: 해당 노드의 Transform 행렬 (SRT)mMeshes[]: 이 노드에 포함된 메시 인덱스들mChildren[]: 자식 노드 배열이 구조를 재귀적으로 순회하여 모든 메시를 탐색해야 함
4x4 변환 행렬로, 각 노드의 SRT (Scale, Rotation, Translation) 담고 있음
좌표 변환 시, 부모의 월드 행렬과 곱하여 월드 트랜스폼 계산에 사용됨
하나의 실제 메시 정보를 담고 있는 구조체
내부 구성:
mVertices[]: 정점 정보(Position)mNormals[]: 노멀 벡터mTextureCoords[]: UV 좌표mFaces[]: 인덱스 정보 (삼각형 면)mMaterialIndex: 해당 메시가 사용하는 머티리얼의 인덱스메시가 사용하는 머티리얼 정보를 가지고 있음
내부 구성:
aiTextureType_DIFFUSE, aiTextureType_NORMALS 등)aiMaterial->Get(AI_MATKEY_NAME, aiString&) 으로 추출 가능Assimp에서 사용하는 전용 문자열 타입
C++의 std::string과 유사하지만, 내부 구조가 달라서 반드시 .C_Str()을 통해 C 스타일 문자열로 변환해서 사용
트랜스폼을 구성하는 기본 요소
FBX의 각 노드(aiNode)는 이 SRT 정보로부터 행렬(aiMatrix4x4) 를 구성
이 행렬을 기준으로 부모로부터의 상대적 위치를 표현
Assimp 구조체(aiScene, aiNode, aiMesh 등)를 그대로 사용하는 것은 비효율적
따라서, 필요한 정보만 추려 asMesh, asMaterial 같은 커스텀 구조체를 만듦
이를 기반으로 .mesh, .skel 같은 자체 이진 포맷으로 저장
자체 이진 포맷의 목적 :
UnityChan 같은 에셋을 보면 .fbx 모델 안에 Mesh, Material, Light, Camera 정보가 포함
이 모델들은 하나의 평면 구조가 아니라, 트리 형태의 계층 구조로 구성
본격적인 FBX 파싱을 시작하기 전에, 우리는 이 구조를 코드로 재귀 순회할 수 있어야 함
Local ↔ World Transform
WorldTransform = ParentWorld * LocalTransform;
FBX는 각 노드마다 자기 자신만의 로컬 행렬을 가지고 있음
부모의 월드 행렬에 자신의 로컬 행렬을 곱해 나가야 최종 월드 행렬이 됨.
이 구조를 처리하려면 트리 구조 순회 + 행렬 곱이 함께 이루어져야 함.
헤더와 라이브러리 연결
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>
#ifdef _DEBUG
#pragma comment(lib, "Assimp/assimp-vc143-mtd.lib")
#else
#pragma comment(lib, "Assimp/assimp-vc143-mt.lib")
#endif
Importer.hpp: FBX 등 3D 파일을 읽기 위한 진입점scene.h: aiScene, aiMesh, aiNode 등 구조 정의postprocess.h: 후처리 옵션 제공Converter
class Converter
{
public:
Converter();
~Converter();
void ReadAssetFile(wstring file);
private:
wstring _assetPath = L"../Resources/Assets/";
shared_ptr<Assimp::Importer> _importer;
const aiScene* _scene;
};
Converter::Converter()
{
_importer = make_shared<Assimp::Importer>();
}
Converter::~Converter() {}
_importer는 FBX 파일을 읽고 내부 구조(aiScene)를 메모리에 올려주는 핵심 객체ReadAssetFilevoid Converter::ReadAssetFile(wstring file)
{
wstring fileStr = _assetPath + file;
auto p = std::filesystem::path(fileStr);
assert(std::filesystem::exists(p)); // 파일 존재 확인
_scene = _importer->ReadFile(
Utils::ToString(fileStr),
aiProcess_ConvertToLeftHanded |
aiProcess_Triangulate |
aiProcess_GenUVCoords |
aiProcess_GenNormals |
aiProcess_CalcTangentSpace
);
assert(_scene != nullptr);
}
ConvertToLeftHanded: DirectX 좌표계에 맞게 좌수계 변환Triangulate: 모든 면을 삼각형으로 변환GenUVCoords: UV 좌표 자동 생성GenNormals: 법선 벡터 생성CalcTangentSpace: 탄젠트 벡터 계산ParseNode()
void Converter::ParseNode(aiNode* node, const aiMatrix4x4& parentTransform)
{
aiMatrix4x4 localTransform = node->mTransformation;
aiMatrix4x4 worldTransform = parentTransform * localTransform;
string nodeName = node->mName.C_Str();
for (UINT i = 0; i < node->mNumMeshes; i++)
{
UINT meshIndex = node->mMeshes[i];
aiMesh* mesh = _scene->mMeshes[meshIndex];
// 정점, 인덱스, 머티리얼 매핑 작업 예정
}
for (UINT i = 0; i < node->mNumChildren; i++)
ParseNode(node->mChildren[i], worldTransform);
}
ReadMaterial()
void Converter::ReadMaterial()
{
uint32 materialCount = _scene->mNumMaterials;
for (uint32 i = 0; i < materialCount; i++)
{
asMaterial material;
aiMaterial* srcMaterial = _scene->mMaterials[i];
aiString str;
srcMaterial->Get(AI_MATKEY_NAME, str);
material.name = str.C_Str();
aiColor3D color;
if (srcMaterial->Get(AI_MATKEY_COLOR_AMBIENT, color) == AI_SUCCESS)
material.ambient = Color(color.r, color.g, color.b, 1.0f);
if (srcMaterial->Get(AI_MATKEY_COLOR_DIFFUSE, color) == AI_SUCCESS)
material.diffuse = Color(color.r, color.g, color.b, 1.0f);
if (srcMaterial->Get(AI_MATKEY_COLOR_SPECULAR, color) == AI_SUCCESS)
material.specular = Color(color.r, color.g, color.b, 1.0f);
if (srcMaterial->Get(AI_MATKEY_COLOR_EMISSIVE, color) == AI_SUCCESS)
material.emissive = Color(color.r, color.g, color.b, 1.0f);
aiString texturePath;
if (srcMaterial->GetTexture(aiTextureType_DIFFUSE, 0, &texturePath) == AI_SUCCESS)
material.diffuseFile = texturePath.C_Str();
if (srcMaterial->GetTexture(aiTextureType_SPECULAR, 0, &texturePath) == AI_SUCCESS)
material.specularFile = texturePath.C_Str();
if (srcMaterial->GetTexture(aiTextureType_NORMALS, 0, &texturePath) == AI_SUCCESS)
material.normalFile = texturePath.C_Str();
_materials.push_back(material);
}
}
ReadMesh()
void Converter::ReadMesh()
{
uint32 meshCount = _scene->mNumMeshes;
for (uint32 i = 0; i < meshCount; i++)
{
asMesh mesh;
aiMesh* srcMesh = _scene->mMeshes[i];
mesh.name = srcMesh->mName.C_Str();
mesh.mesh = srcMesh;
mesh.boneIndex = -1;
for (uint32 v = 0; v < srcMesh->mNumVertices; v++)
{
VetexType vertex;
auto& pos = srcMesh->mVertices[v];
vertex.position = Vec3(pos.x, pos.y, pos.z);
if (srcMesh->HasTextureCoords(0))
vertex.uv = Vec2(srcMesh->mTextureCoords[0][v].x, srcMesh->mTextureCoords[0][v].y);
if (srcMesh->HasNormals())
vertex.normal = Vec3(srcMesh->mNormals[v].x, srcMesh->mNormals[v].y, srcMesh->mNormals[v].z);
if (srcMesh->HasTangentsAndBitangents())
vertex.tangent = Vec3(srcMesh->mTangents[v].x, srcMesh->mTangents[v].y, srcMesh->mTangents[v].z);
mesh.vertices.push_back(vertex);
}
for (uint32 f = 0; f < srcMesh->mNumFaces; f++)
{
aiFace& face = srcMesh->mFaces[f];
for (uint32 k = 0; k < face.mNumIndices; k++)
mesh.indicies.push_back(face.mIndices[k]);
}
mesh.materialName = _materials[srcMesh->mMaterialIndex].name;
_meshes.push_back(mesh);
}
}
왜 커스텀 포맷으로 바꿔?
| 이유 | 설명 |
|---|---|
| 로딩 속도 | FBX → 수 초, Custom Format → 수 밀리초 |
| 불필요한 정보 제거 | 카메라, 라이트, 애니메이션 등 제거 가능 |
| 렌더링 최적화 | 정점 버퍼, 인덱스 버퍼 미리 계산 가능 |
.mesh, .model, .mat 파일 등으로 나누어 직렬화하는 방식 사용struct VertexTextureNormalTangentBlendData
{
Vec3 position = { 0, 0, 0 };
Vec2 uv = { 0, 0 };
Vec3 normal = { 0, 0, 0 };
Vec3 tangent = { 0, 0, 0 };
Vec4 blendIndicies = { 0,0,0,0 }; // 애니메이션 스키닝용 본 인덱스
Vec4 blendWeights = { 0,0,0,0 }; // 애니메이션 스키닝용 가중치
};
struct asMaterial
{
string name;
Color ambient;
Color diffuse;
Color specular;
Color emissive;
string diffuseFile;
string specularFile;
string normalFile;
};
name: 머티리얼 이름(aiMaterial의 AI_MATKEY_NAME)
ambient, diffuse, specular, emissive: 컬러 정보
diffuseFile, specularFile, normalFile: 각각의 텍스처 경로
Assimp에서 제공하는 aiMaterial 구조체에서 해당 키를 통해 값을 추출
struct asMesh
{
string name;
aiMesh* mesh; // 원본 메시 포인터
vector<VetexType> vertices;
vector<uint32> indicies;
int32 boneIndex; // 계층 구조 상에서 부모 본 인덱스
string materialName; // 연결된 머티리얼 이름
};
| 항목 | 설명 |
|---|---|
| aiMaterial → asMaterial | 머티리얼 이름, 색상, 텍스처 경로 추출 |
| aiMesh → asMesh | 정점/인덱스 정보 추출 및 머티리얼 연결 |
| aiScene → 계층 순회 | 메시 포함 노드를 순회하면서 메시 파싱 |
FBX는 단순히 정점(Vertex)과 인덱스(Index) 정보만 담긴 메시 파일 X
내부에는 계층 구조(Hierarchy)를 기반으로 애니메이션, 본(Bone), 조명, 카메라, 머티리얼, 텍스처 등 수많은 데이터가 트리 형태로 얽혀 있음
따라서 이 구조를 파싱하려면 단순 반복이 아닌 재귀적 트리 순회(Tree Traversal)가 필수
트랜스폼 누적 방식은 나중에 본 스키닝 애니메이션 처리에도 반드시 필요한 핵심 로직
파싱과 변환은 우리가 직접
Assimp는 다양한 3D 포맷(FBX, OBJ 등)을 지원하는 훌륭한 로더지만, 우리가 사용하는 렌더링 시스템이나 엔진 구조와 직접적으로 맞지 않음
Assimp로 로딩한 결과물은 aiScene → aiNode → aiMesh → aiMaterial 등의 구조로 메모리에 올라가지만, 이걸 그대로 사용할 수는 없음
결국 Assimp에서 추출한 데이터는 반드시 우리가 정의한 구조체(asMaterial, asMesh 등) 로 파싱하여 엔진 친화적인 커스텀 포맷으로 변환해 저장해야 함
FBX 로딩 후 구조체 매핑 흐름은 다음과 같음
FBX → Assimp::Importer → aiScene 메모리 구조
└→ aiNode (계층 구조 순회)
└→ aiMesh (정점, 인덱스, 텍스처 좌표, 노멀, 탄젠트)
└→ aiMaterial (색상, 텍스처 파일 경로 등)
↓
우리의 구조체 asMesh, asMaterial, VertexTextureNormalTangentBlendData 등에 파싱
↓
커스텀 포맷(.mesh, .mat 등)으로 저장
특히, 정점 구조체 VertexTextureNormalTangentBlendData는 일반 메시뿐 아니라 애니메이션 본 스키닝까지 고려한 확장성 있는 구조
텍스처 경로, 색상 정보는 aiMaterial에서 반드시 파싱해야 하며, 누락 시 렌더링 오류로 이어질 수 있음