Programming/컴퓨터그래픽스 (DX 11)

[DirectX 11] 3D 모델 로드하기 - Static Mesh(정적 메시)

양양줘 2025. 10. 29. 11:49

 

지금까지는 직접 vertex와 index 데이터를 만들어

큐브를 그리고 텍스처 맵핑과 라이트 계산을 했다.

이제는 실제 게임 개발에 사용할

3D 모델들을 로드해서 렌더링해봐야 한다.

 

StaticMesh, Skeletal Mesh(Rigid, Skinned)

이렇게 총 3가지를 정리할 예정이며

assimp 라이브러리를 사용한다.

 

이번글은 3D 모델 로드 1단계

정적 메시(Static Mesh) FBX 화면 띄우기이다!!!

 

 

씬 그래프 (Scene Graph)


3D 모델을 화면에 띄우기 위해, 우선 3D 모델들이 어떤식으로 저장되어있는지에 대해 이해하고 있어야한다. 씬 그래프(Scene Graph)3D 그래픽스에서 씬(Scene)을 구성하는 모든 오브젝트(카메라, 조명, 메시, 스켈레톤 등)를 부모-자식 관계로 연결한 트리(Tree)구조 또는 DAG(Directed Acyclic Graph) 형태의 구조를 말한다. 씬 그래프의 기본 단위는 노드(Node)이며, 각 노드는 자신의 로컬 변환(Local Transform) 을 가지고 부모 노드의 변환을 계승하여 계층적 변환(Hierarchical Transform) 을 수행한다. OBJ, FBX, glTF 등의 3D 파일 포맷은 모두 이러한 씬 그래프 구조를 표현하거나 지원하기 위한 데이터 포맷이다. 3D 데이터를 읽어들일때 이 트리구조를 고려하여 데이터를 읽어와야한다.

 

씬 그래프의 노드(Node)

 

씬 그래프에서 노드로 저장되는 것은 기본적으로 Transformation(position, rotation, scale) 을 가지는 객체들이다. 즉, '월드 공간 상에서 어떤 위치에 존재해야 하는가' 를 정의해야 하는 모든 객체가 씬 그래프의 노드로 표현된다. 예를 들어 사람 모델을 씬그래프로 표현한다면 머리, 목, 몸통 등 각 파트가 각각의 노드로 구성되며 최상위 객체인 골반(pelvis)가 최상위 부모 노드가 되어 계층 구조를 형성된다.

[Pelvis]
 ├── Spine
 │     └── Head
 │           ├── LeftEye
 │           └── RightEye
 ├── LeftLeg
 │     └── LeftFoot
 └── RightLeg
       └── RightFoot

 

 

 

 

서브 메시(Sub Mesh)


3D 편집기에서 만든 하나의 메시는 하나의 Transformation을 요구하고, 씬그래프에서 하나의 노드로 표현될 것이다. 하지만 이것을 DirectX에서 assimp로 모델을 로드했을때 하나의 노드가 하나의 메시라고는 장담할 수 없다. 3D 편집기에서 하나의 메시에는 여러개의 Material이 사용될 수 있다. 하지만 DirectX에서 멀티 머티리얼을 하나의 메시에 적용하는 것은 복잡하기 때문에 메시를 서브메시(SubMesh 또는 Mesh Section)로 나누어 처리한다. 즉, 서브메시(SubMesh)는 하나의 메시에 여러개의 머티리얼이 적용되었을 경우, 메시에서 머티리얼과 1:1로 대응되되도록 나눈 부분 메시를 의미한다. 예를 들어 큐브 메시의 각 면마다 다른 머티리얼을 적용해서 총 6개의 머티리얼을 사용했다면 큐브 메시를 6개의 서브 메시로 나누어 처리하게 된다. 앞으로 사용할 assimp 라이브러리는 메시를 로드할때 멀티 머티리얼 모델을 자동으로 여러 aiMesh 즉, SubMesh로 나누어 처리한다.

Mesh: CubeMesh
 ├─ Submesh 0
 │    ├─ Indices: [0,1,2,...]
 │    └─ Material: Material_Red
 ├─ Submesh 1
 │    ├─ Indices: [6,7,8,...]
 │    └─ Material: Material_Blue
 └─ Submesh 2 ...

 

 

Assimp에서 로드된 모델은 씬 그래프 구조를 따르며, Node를 순회하면서 각 Mesh를 처리하게 된다. 이때 하나의 Node에 하나의 Mesh가 담겨 있다면 단일 머티리얼 메시이고, 여러 개의 Mesh가 담겨 있다면 멀티 머티리얼 메시이다. 이러한 이유로 3D 메시 데이터는 보통 서브메시(SubMesh) 단위로 저장된다. 하지만 서브메시는 Node 단위가 아니기 때문에, Mesh를 처리할 때 해당 Mesh가 속한 부모 Node의 인덱스를 함께 저장해 두어야 한다. 그래야 이후 부모-자식 관계에 따라 올바른 Model Space 변환을 적용할 수 있다.

 

-> Static Mesh는 부모-자식 관계가 필요 없요 없지만 Skeletal Mesh는 필요하다!!

 

 

 

Assimp(Open Asset Import Library) 라이브러리


Assimp 라이브러리는 3D 모델 파일(fbx, obj 등)을 로드하는데 사용되는 라이브러리이다. Assimp는 파일 형식에 관계 없이 모든 모델 데이터를 Assimp의 일반화된 데이터 구조에 로드하기 때문에 Assimp로 모델을 로드하면 Assimp의 구조체를 통해 원하는 데이터를 편하게 파싱해서 사용할 수 있다. 진짜 너무너무 편리하다!!!

 

Assimp로 모델을 읽었을때 저장되는 구조는 위 사진과 같다. Assimp는 모델 파일을 로드하면 하나의 Scene객체에 모두 저장한다. Scene은 모든 데이터를 담는 컨테이너 역할로 크게 씬그래프 트리구조의 루트노드, 메시배열, 머티리얼 배열 데이터가 담겨있다. 우리가 위에서 정리한 씬그래프의 표현이 RootNode로 부터 시작되는 트리 구조에 저장되어있으며, Mesh와 Material데이터는 따로 저장되어 Node에서 이를 참조하고 있는 방식이다. DirectX에서 모델을 읽으려면 우선 Scene객체에 파일을 로드하고, 객체로부터 멤버값에 접근하여 데이터를 파싱하면 된다. 이번 글은  Static Mesh 로딩에 필요한 데이터만 처리하므로 Animation은 다루지 않는다. (먼저 말하자면 모델에 애니메이션이 있는경우 Scene객체에 aiAnimation 배열이 추가된다) 

 

 

Static Mesh 처리를 위한 Assimp 구조체
static mesh는 assimp에서 어떤 구조로 저장될까?

 

1. aiScene (씬)

aiScene객체에는 노드, 메시, 머티리얼 등 모델 파일에 대한 모든 데이터가 저장되어있다. 모델을 로드하면 scene객체 안에 모든 데이터가 담기게 되고, 우리는 RootNode부터 aiNode를 순회하며 메시와 머티리얼 값을 저장하면 되는 것이다.

aiScene
 ├─ mMeshes[] --------> aiMesh (정점, 인덱스, 법선, UV, mMaterialIndex)
 ├─ mMaterials[] -----> aiMaterial (색상, 텍스처, 반사도 등)
 └─ mRootNode --------> aiNode (트리 구조: 위치, 회전, 자식노드, 메시 참조)

struct aiScene {
    // 트리 계층 구조의 루트 노드
    aiNode*       mRootNode;

    // 메시(Mesh) 배열 및 개수
    aiMesh**      mMeshes;
    unsigned int  mNumMeshes;

    // 머티리얼(Material) 배열 및 개수
    aiMaterial**  mMaterials;
    unsigned int  mNumMaterials;

    // 애니메이션(Animation) 배열 및 개수
    aiAnimation** mAnimations;
    unsigned int  mNumAnimations;

    ...
};

 

 

2. aiNode (노드)

aiNode는 우리가 씬그래프에서 표현했던 Node과 같은 개념이다. 즉, Transformation을 요구하는 객체들이 하나의 aiNode로 저장되며 각자 자신의 변환 행렬을 가지고 있다. 이 aiNode는 단일 머티리얼일 경우 하나의 aiMesh를 가지며, 멀티 머티리얼일 경우 서브 메시로 나뉘어져 여러 aiMesh를 가지게 된다. mMeshes에 이 노드를 그리는데 필요한 서브 메시 데이터들이 저장되어있다. 우리는 RootNode부터 Node를 순회하며 서브메시인 aiMesh를 처리해야한다.

struct aiNode {
    aiString mName;                // 노드 이름
    aiMatrix4x4 mTransformation;   // 이 노드의 변환 행렬 (위치, 회전, 스케일)
    unsigned int mNumMeshes;       // 이 노드가 가진 메시 개수
    unsigned int* mMeshes;         // 메시 인덱스 배열 (scene->mMeshes 참조)
    unsigned int mNumChildren;     // 자식 노드 개수
    aiNode** mChildren;            // 자식 노드 포인터 배열
};

 

 

3. aiMesh (서브메시)

aiMesh는 assimp관점에서 서브메시라고 생각하면 된다. 즉 머리티얼과 1:1로 대응되는 메시이다. aiMesh에는 DirectX의 vertex buffer와 index buffer에 대응되는 실제 도형 데이터와 해당 메시에 적용할 머티리얼의 인덱스가 저장되어있다. 한가지 유의할 점은 aiMesh는 계층 구조가 없기 때문에 aiNode단계에서의 부모-자식 관계를 저장하려면 aiNode 처리시에 부모 index를 따로 저장해두어야 한다. static mesh의 경우 부모 자식 계층이 필요 없지만 후에 정리할 skeletal mesh에서는 이 작업이 필요하다.

struct aiMesh {
    unsigned int mNumVertices;             // 정점 개수
    aiVector3D* mVertices;                 // 정점 데이터 (Local Space)
    aiVector3D* mNormals;                  // 법선 벡터 (Local Space)
    aiVector3D* mTextureCoords[8];         // 텍스처 좌표(UV) (Local Space)
    unsigned int mNumFaces;                
    aiFace* mFaces;                        // 인덱스 정보
    unsigned int mMaterialIndex;           // 머티리얼 인덱스
    // 기타.. (Bone 정보는 추후에..)
};

 

 

4. aiMaterial (머티리얼)

aiMaterial에는 메시가 어떤 색상, 텍스처, 광택을 가지는지에 대한 머티리얼 정보가 저장되어있다. DirectX에서 셰이더에 넘겨줄 텍스처 리소스나 상수 버퍼(ConstantBuffer)에 해당하는 정보들이다. 우리는 이 aiMaterial에 어떤 텍스처들이 저장되어있는지 확인하고, 텍스처의 파일이름을 추출해서 SRV를 생성하면 된다. 이때 또 유의할 점이 머티리얼의 텍스처들이 따로 이미지로 저장되어 있는지, 아니면 3D 모델 파일에 내장되어 있는지를 확인해야한다. 만약 내장된 텍스처(aiTeuxter)가 있다면 머티리얼 처리 단계에서 텍스처를 추출해 따로 저장해주는 것이 편하다.

struct aiMaterial {
    // 속성(property)들을 배열로 관리
    unsigned int mNumProperties;         // property 개수
    aiMaterialProperty** mProperties;    // key-value 배열
    unsigned int mNumAllocated;          // 내부 사용
};

struct aiMaterialProperty {
    aiString mKey;            // 속성 이름 (예: "diffuse", "specular", "shininess")
    unsigned int mSemantic;   // semantic index (예: 텍스처 채널 번호)
    unsigned int mIndex;      // 속성 인덱스
    unsigned int mDataLength; // 데이터 길이 (byte)
    unsigned int mType;       // 데이터 타입 (float, int, string 등)
    void* mData;              // 실제 값 (float[4], aiString 등)
};

// 속성 종류
// aiTextureType_DIFFUSE
// aiTextureType_SPECULAR
// aiTextureType_NORMALS
// aiTextureType_EMISSIVE

 

 

5. aiTexture (내장된 텍스처)

 보통 텍스처는 따로 이미지를 저장하여 머티리얼에 저장되어있는 파일 경로대로 로드하여 사용하지만, fbx파일 안에 텍스처가 내장되어있는 경우가 있다. 이럴경우 텍스처는 aiTexture로 저장되어 aiScene의 멤버로 소유하게 된다. 내장된 텍스처가 있는 경우를 대비하여 머티리얼 처리 단계에서 aiScene::GetEmbeddedTexture(const char* filename)으로 텍스처가 내부에 있는지 확인하고, 만약 텍스처가 내장되어있다면 텍스처를 파일로 저장해두어야 한다.

// 머티리얼의 Diffuse텍스처가 scene에 내장되어있는지 확인
if (material->GetTexture(aiTextureType_DIFFUSE, 0, &filepath) == AI_SUCCESS)
{
    std::string filename = fs::path(filepath.C_Str()).filename().string();
    SaveEmbeddedTextureIfExists(scene, directory, filename);
}

// 내장된 텍스처가 있다면 텍스처를 따로 저장
void ModelLoder::SaveEmbeddedTextureIfExists(const aiScene* scene, const string& directory, const string& filename)
{
    const aiTexture* embedded = scene->GetEmbeddedTexture(filename.c_str());

    if (embedded&& embedded->mHeight == 0)
    {
        std::string tmpPath = directory + filename;
        std::ofstream file(tmpPath, std::ios::binary);

        if (file.is_open())
        {
            file.write(reinterpret_cast<const char*>(embedded->pcData), embedded->mWidth);
            file.close();
        }
    }
}

 

 

 

 

 

 

정적 메시 (Static Mesh)


정적 메시(Static Mesh)애니메이션이 없고 vertex의 위치가 고정되어있는 메시를 말한다. 애니메이션이 없는 메시의 경우 vertex의 위치는 변하지 않고 항상 원점을 기준으로 배치된다. 때문에 부모-자식 관계를 유지할 필요가 없다. assimp로 노드 순회시에 각 노드는 Transform 변환 행렬을 가지고 있으며 각 aiMesh에는 local Space 기준 즉, 자신이 속해있던 노드의 원점을 기준으로 한 vertex 데이터들을 가지고 있다. 이때 StaticMesh는 부모-자식 관계를 유지할 필요가 없으므로 로드할 때 부모 Transform을 누적 변환하여 각 데이터들을 Model Space로 저장할 수 있다. 이때 사용하는 것이 바로 aiProcess_PreTransformVertices 플래그이다.

 

aiProcess_PreTransformVertices

 

aiProcess_PreTransformVertices플래그를 사용하면 assimp가 모든 노드의 부모-자식 tranform을 미리 vertex에 계산해준다. 즉, 각 aiMesh에 저장되어있는 local space기준 좌표를 model space 기준 좌표로 변환하여 저장해준다. 하지만 부모-자식 구조 정보는 사라지기 때문에 계층 기반 애니메이션의 활용은 불가능하다. 때문에 aiProcess_PreTransformVertices옵션은 Static Mesh 처리에만 가능한 옵션이다.

원래 Mesh: CubeMesh (Vertex 24, Faces 12)
Materials: Red, Blue

// 1. 원래 메시가 가진 Vertex/Index 데이터를 그대로 사용
// 2. 메시 안에서 머티리얼별로 필요한 부분만 추출
// 3. 머티리얼 1개당 하나의 메시(Submesh)로 나눔
Assimp 처리 후:
Mesh 0 → Faces with Material Red → 머티리얼 Red 적용
Mesh 1 → Faces with Material Blue → 머티리얼 Blue 적용

 

 

assimp로 StaticMesh 로드하고 렌더링하기

 

하단 깃허브 링크의 8_FBX 프로젝트에서 확인할 수 있다. ModelImporter와 StaticMesh, StaticSubMesh를 확인하면 된다.

 

 

 

 

 


 

 

GitHub - wooj22/DirectX3D11_Study: DirectX3D11 공부용 레포지토리입니다.

DirectX3D11 공부용 레포지토리입니다. Contribute to wooj22/DirectX3D11_Study development by creating an account on GitHub.

github.com

 

LearnOpenGL - Assimp

Assimp Model-Loading/Assimp In all the scenes so far we've been extensively playing with our little container friend, but over time, even our best friends can get a little boring. In bigger graphics applications, there are usually lots of complicated and i

learnopengl.com