눈팅하는 게임개발자 블로그

OpenGL Model Loading 4-3 Model 본문

공부한거/OpenGL

OpenGL Model Loading 4-3 Model

Palamore 2020. 12. 7. 21:00

원문 사이트

learnopengl.com/Model-Loading/Model

 

LearnOpenGL - Model

Model Model-Loading/Model Now it is time to get our hands dirty with Assimp and start creating the actual loading and translation code. The goal of this chapter is to create another class that represents a model in its entirety, that is, a model that conta

learnopengl.com

번역 사이트

heinleinsgame.tistory.com/23?category=757483

 

[Learn OpenGL 번역] 4-3. 모델 불러오기 - Model

Model 모델 불러오기/Model 이제 Assimp로 노가다를 할 시간입니다. 그리고 실제 로딩, 변환 코드를 생성할 것입니다. 이 강좌의 목표는 전체적인 모델(여러 mesh들을 가지고 있는)을 나타내는 또 다른

heinleinsgame.tistory.com

Model

이제 Assimp로 노가다를 할 시간이다.

그리고 실제로 로딩, 변환 코드를 생성한다.

이 강좌의 목표는 전체적인 모델(여러 mesh들을 가지고 있는)을 나타내는 또다른 클래스를 생성하는 것이다.

나무로 된 발코니, 타워, 수영장을 가지고 있는 집은 여전히 하나의 모델로서 로드될 수 있다.(씬 하나가 모델로서 로드될 수 있음.)

Assimp를 통해 모델을 로드하고 이를 이전에 생성한 Mesh 객체들로 변환한다.

 

Model 클래스의 구조는 다음과 같다.

class Model 
{
    public:
        /*  함수   */
        Model(char *path)
        {
            loadModel(path);
        }
        void Draw(Shader shader);	
    private:
        /*  Model 데이터  */
        vector<Mesh> meshes;
        string directory;
        /*  함수   */
        void loadModel(string path);
        void processNode(aiNode *node, const aiScene *scene);
        Mesh processMesh(aiMesh *mesh, const aiScene *scene);
        vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, 
                                             string typeName);
};

이 Model 클래스는 Mesh 객체들의 vector를 가지고 있고 생성자에서 파일의 위치를 요구한다.

그 다음 loadModel함수를 생성자에서 호출하여 파일을 불러온다. private 함수들은 assimp의 import 루틴의 일부분을

처리한다.

곧 이것들을 다루게 될 것이며, 또한 파일 경로의 디렉터리를 저장한다.

나중에 텍스처를 로드할 때 필요하기 때문이다.

Draw 함수는 특별한 것은 없고 기본적으로 반복문을 이용하여 각 mesh들의 Draw 함수를 호출시킨다.

void Draw(Shader shader)
{
    for(unsigned int i = 0; i < meshes.size(); i++)
        meshes[i].Draw(shader);
}  

 

Importing a 3D Model into OpenGL

모델을 불러오고 그것을 미리 선언해 둔 클래스 구조로서 변환하기 위해 먼저 Assimp의 적절한 헤더파일을 포함해야 한다.

#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>

호출할 첫 번째 함수는 loadModel 함수이고 이 함수는 생성자로부터 직접적으로 호출된다.

loadModel 함수 내부에서 scene객체라고 불리는 Assimp의 데이터 구조에 모델을 불러오기 위해 Assimp를 사용한다.

모델 불러오기의 첫 번째 강좌에서 이 객체가 Assimp 데이터 인터페이스의 루트 객체라고 설명했던 것을 기억하면,

scene 객체를 가지게 되면 불러온 모델이 가진 모든 데이터를 얻을 수 있다.

 

Assimp의 대단한 점은 모든 각기 다른 파일 포맷들을 불러오는 것에 대한 기술적인 상세사항들을 깔끔하게 추상화했다는 점이다.

Assimp::Importer importer;
const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs); 

먼저 Assimp 네임스페이스의 실제 Importer 객체를 선언한다.

그 다음 이 객체의 ReadFile 함수를 호출한다.

이 함수는 파일의 경로를 요구하고 두 번째 파라미터로 여러 post-processing(전처리) 옵션들을 받는다.

Assimp는 간단히 파일을 불러오는 것 외에도 불러온 데이터에 추가적인 계산/연산을 하는 여러 옵션들을 지정할 수 있도록 해준다.

aiProcess-Triangulate를 설정함으로써 Assimp에게 모델이 삼각형으로만 이루어지지 않았다면, 모델의 모든

primitive 도형들을 삼각형으로 변환하라고 지시해준다.

aiProcess-FlipUVs는 텍스처 좌표를 y축으로 뒤집어준다.

(텍스처 강좌에서 OpenGL에서 대부분의 이미지들이 y축을 중심으로 거꾸로 뒤집어진다는 특성이 있었다는 것을

기억하자, 이 문제를 전처리 옵션으로 간단히 해결할 수 있다.)

다음은 약간의 유용한 다른 옵션들이다.

aiProcess_GenNormals : 모델이 법선 벡터들을 가지고 있지 않다면 각 vertex에 대한 법선을 실제로 생성한다.

aiProcess_SplitLargeMeshes : 큰 mesh들을 여러 개의 작은 서브 mesh들로 나눈다.

렌더링을 할 때 vertex 수의 최댓값이 정해져 있다면 이를 줄일 때 유용하다.

aiPorcess_OptimizeMeshes : 반대로 여러 mesh들을 하나의 큰 mesh로 합친다.

최적화를 위해 드로잉 호출을 줄일 수 있다.

 

이외에도 Assimp는 훌륭한 전처리 지시어들을 제공하고 있다.

실제로 Assimp를 통해 모델을 불러오는 것은 굉장히 쉽다.

어려운 작업은 반환된 scene 객체를 사용하여 불러온 데이터를 Mesh 객체들의 배열로 변환하는 것이다.

완성된 loadModel 함수는 다음과 같다.

void loadModel(string path)
{
    Assimp::Importer import;
    const aiScene *scene = import.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);	
	
    if(!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode) 
    {
        cout << "ERROR::ASSIMP::" << import.GetErrorString() << endl;
        return;
    }
    directory = path.substr(0, path.find_last_of('/'));

    processNode(scene->mRootNode, scene);
}  

모델을 불러온 후 scene과 scene의 루트 노드가 null이 아닌지 확인하고 데이터가 불완전하다는 플래그가 세워져 있는지 확인한다.

이러한 에러 조건 중 어떠한 것이라도 만족한다면 importor의 GetErrorString함수를 통해 에러를 출력하고 리턴한다.

또한 주어진 파일 경로의 디렉터리 경로를 얻는다.

 

잘못된 것이 없다면 scene의 노드들을 처리하기 위해 첫 번째 노드를 재귀적으로 동작하는 processNode 함수로 전달한다.

각 노드는 자식들을 가지고 있을 것이기 때문에 먼저 노드를 처리하고 그 다음 계속해서 해당 노드의 모든 자식들을 처리한다.

이는 재귀적인 구조에 적합하므로 재귀적인 함수로서 정의한다.

재귀함수의 종료 조건은 모든 노드들이 처리되었을 때이다.

 

Assimp의 구조로부터 기억할 수 있듯이 각 노드는 mesh index들의 모음을 가지고 있다.

각 index는 scene 객체 내부의 특정한 mesh를 가리킨다.

따라서 이러한 mesh index들을 얻고 각 mesh들을 얻고 그 후 각 mesh들을 처리하고 나서

각 노드의 자식 노드들에게도 이 작업을 반복한다. 

processNode 함수의 내용은 다음과 같다.

void processNode(aiNode *node, const aiScene *scene)
{
    // 노드의 모든 mesh들을 처리(만약 있다면)
    for(unsigned int i = 0; i < node->mNumMeshes; i++)
    {
        aiMesh *mesh = scene->mMeshes[node->mMeshes[i]]; 
        meshes.push_back(processMesh(mesh, scene));			
    }
    // 그런 다음 각 자식들에게도 동일하게 적용
    for(unsigned int i = 0; i < node->mNumChildren; i++)
    {
        processNode(node->mChildren[i], scene);
    }
}  

우선 이 노드의 mesh index들을 확인하고 scene의 mMeshes 배열을 인덱싱하여 그에 따른 mesh들을 얻는다.

반환된 mesh는 processMesh 함수로 전달된다. 이 함수는 meshes vector에 저장할 수 있는 Mesh 객체를 리턴한다.

 

모든 mesh들이 처리되면 노드의 모든 자식들에게 반복하고 동일한 processNode 함수를 호출한다.

노드가 더 이상의 자식을 가지고 있지 않다면 이 함수는 실행을 멈춘다.

다음 단계는 실제로 Assimp 데이터를 처리하여 마지막 강좌에서 생성했던 Mesh 클래스 형태로 변환하는 것이다.

 

Assimp to Mesh

aiMesh 객체를 미리 만들어 두었던 mesh 객체로 변환하는 것은 그렇게 어려운 일이 아니다.

해야할 일은 각 mesh들의 관련된 속성들에 접근하여 객체에 저장하는 것이다.

processMesh 함수의 일반적인 구조는 다음과 같다.

Mesh processMesh(aiMesh *mesh, const aiScene *scene)
{
    vector<Vertex> vertices;
    vector<unsigned int> indices;
    vector<Texture> textures;

    for(unsigned int i = 0; i < mesh->mNumVertices; i++)
    {
        Vertex vertex;
        // vertex 위치, 법선, 텍스처 좌표를 처리
        ...
        vertices.push_back(vertex);
    }
    // indices 처리
    ...
    // material 처리
    if(mesh->mMaterialIndex >= 0)
    {
        ...
    }

    return Mesh(vertices, indices, textures);
}  

Mesh를 처리하는 것은 기본적으로 세 부분으로 이루어진다.

모든 vertex 데이터를 얻고, mesh의 indices를 얻고, 마지막으로 연관된 material 데이터를 얻는 것이다.

처리된 데이터는 하나의 3 벡터에 저장되고 함수를 호출한 곳으로 이 벡터가 리턴된다.

 

Vertex 데이터를 얻는 것은 꽤나 간단하다.

각 루프를 돌 때마다 vertices 배열에 삽입할 Vertex struct를 정의한다.

mesh에 존재하는 vertex의 갯수(mesh->mNumVertices로 얻을 수 있다)만큼 반복문을 실행한다.

그 다음 반복문 내부에서 모든 관련된 데이터로 이 struct를 채워 넣어야 한다.

vertex 위치는 다음과 같은 방법으로 수행할 수 있다.

glm::vec3 vector; 
vector.x = mesh->mVertices[i].x;
vector.y = mesh->mVertices[i].y;
vector.z = mesh->mVertices[i].z; 
vertex.Position = vector;

Assimp 데이터를 변환하기 위해 vec3의 자리표시자를 정의한다는 것을 유념해야 한다.

Assimp는 벡터, 행렬, 문자열 등을 자신들만의 데이터 타입으로 관리하고 glm의 데이터 타입으로 정상적으로 변환되지 않기 때문에 자리 표시자가 필요하다.

법선을 위한 작업도 특별한 것이 없다.

vector.x = mesh->mNormals[i].x;
vector.y = mesh->mNormals[i].y;
vector.z = mesh->mNormals[i].z;
vertex.Normal = vector;  

텍스처 좌표는 거의 비슷하지만 Assimp가 각 vertex마다 최대 8개의 텍스처를 허용한다.

지금은 하나의 텍스처만 사용하기 때문에 첫 번째 텍스처 좌표만 신경쓰면 된다.

또한 mesh가 실제로 텍스처 좌표를 가지고 있는지 확인해야 한다.(항상 가지고 있는 것이 아니기 때문)

if(mesh->mTextureCoords[0]) // mesh가 텍스처 좌표를 가지고 있는가?
{
    glm::vec2 vec;
    vec.x = mesh->mTextureCoords[0][i].x; 
    vec.y = mesh->mTextureCoords[0][i].y;
    vertex.TexCoords = vec;
}
else
    vertex.TexCoords = glm::vec2(0.0f, 0.0f);  

vertex struct는 이제 필요한 vertex 속성들로 완전히 채워졌다.

이 것을 vertices vector의 끝에 삽입할 수 있다.

이 처리는 mesh의 각 vertex 마다 수행된다.

 

Indices

Assimp의 인터페이스는 각 mesh들이 face의 배열을 가지고 있도록 정의하였다.

각 face들은 하나의 primitive를 나타낸다. 현재 설정은(aiProcess_Triangulate 옵션 때문에)항상 삼각형이다.

face는 어떤 순서로 vertex들을 그려야 하는지를 정의하는 indices를 가지고 있다.

그래서 모든 face에 대해 반복문을 돌려 모든 face의 indices를 indices vector에 저장해야 한다.

for(unsigned int i = 0; i < mesh->mNumFaces; i++)
{
    aiFace face = mesh->mFaces[i];
    for(unsigned int j = 0; j < face.mNumIndices; j++)
        indices.push_back(face.mIndices[j]);
}  

바깥의 루프가 끝나면 이제 glDrawElements 함수를 통해 Mesh를 그리기 위한 vertex, index 데이터가 완벽히 설정된 것이다.

하지만 mesh의 material 또한 처리해야 한다.

 

Material

노드와 마찬가지로 mesh는 오직 material 객체의 index만 가지고 있다.

실제 mesh의 material을 얻기 위해서는 scene의 mMaterial 배열을 인덱싱해야 한다.

mesh의 material index는 mMaterialIndex속성에 설정되어 있다.

이 속성으로 mesh가 실제로 material을 가지고 있는지 아닌지 확인할 수도 있다.

if(mesh->mMaterialIndex >= 0)
{
    aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];
    vector<Texture> diffuseMaps = loadMaterialTextures(material, 
                                        aiTextureType_DIFFUSE, "texture_diffuse");
    textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());
    vector<Texture> specularMaps = loadMaterialTextures(material, 
                                        aiTextureType_SPECULAR, "texture_specular");
    textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
}  

먼저 scene의 mMaterials 배열로부터 aiMaterial 객체를 얻는다.

그 다음 mesh의 diffuse, specular 텍스처들을 불러와야 한다.

material 객체는 내부적으로 각 텍스처 타입에 대한 텍스처 위치의 배열을 저장한다.

여러 텍스처 타입들은 aiTextureType_접두사로 분류된다.

여기서는 loadMaterialTextures함수를 사용할 것이다.

이 함수는 material에서 텍스처를 얻는다.

Texture struct의 vector를 리턴하고 이 것을 model의 textures vector의 끝에 저장한다.

 

loadMaterialTextures 함수는 주어진 텍스처 타입의 모든 텍스처 위치들에 대해 반복문을 돌리고 텍스처 파일의

위치를 얻은 다음 불러오고 텍스처를 생성하며 이 정보를 Vertex struct에 저장한다.

이는 다음과 같다.

vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
    vector<Texture> textures;
    for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
    {
        aiString str;
        mat->GetTexture(type, i, &str);
        Texture texture;
        texture.id = TextureFromFile(str.C_Str(), directory);
        texture.type = typeName;
        texture.path = str;
        textures.push_back(texture);
    }
    return textures;
}  

먼저 GetTextureCount 함수를 통해 해당 material에 저장된 텍스처의 갯수를 확인한다.

이 함수는 텍스처 타입 중 하나를 파라미터로 받는다.

그 다음 결과를 aiString에 저장하는 GetTexture 함수를 통해 각 텍스처 파일의 위치를 얻는다.

다음에 TextureFromFile 함수의 도움을 받는다. 이 함수는 텍스처를 불러오고 이 텍스처의 ID를 리턴한다.

이것으로 Assimp를 활용하여 모델을 불러오는 작업이 끝났다.

 

An Optimization

아직 완전히 끝난 것이 아니다. 최적화가 남아있다.

대부분의 scene들은 여러 mesh들에 여러가지 텍스처들을 재사용한다.

집이 하나 있다고 가정하고, 이 집의 벽이 화강암 텍스처로 이루어져 있다고 하자.

이 텍스처는 바닥, 천장, 계단, 테이블에도 적용된다.

텍스처를 불러오는 것은 비용이 많이 드는 연산이다.

현재 구현한 상태로는 각 mesh마다 새로운 텍스처가 불러와지고 생성되게 된다.

완전히 동일한 텍스처가 여러번 불러와지는 비효율이 발생하게 되는 것이다.

이는 보틀넥 현상이 쉽게 발생할 수 있다.

 

그래서 model 코드에 약간의 변형을 줄 것이다.

불러온 모든 텍스처들을 전역으로 저장하고 텍스처를 불러오고 싶을 때마다 먼저 그 텍스처가 이미

불러와져 있는지 확인한다.

이미 불러온 텍스처라면 이 텍스처를 가져오는 과정을 생략하여 많은 프로세싱 파워를 절약할 수 있다.

이러한 텍스처 비교를 가능하게 하기 위해 그들이 경로 또한 저장해야 한다.

struct Texture {
    unsigned int id;
    string type;
    string path;  // 다른 텍스처와 비교하기 위해 텍스처의 경로를 저장
};

그 다음 모델 클래스의 맨 위에 private 변수로 선언된 또 다른 vector에 불러온 모든 텍스처를 저장한다.

vector<Texture> textures_loaded;

그 다음 loadMaterialTextures 함수에서 텍스처 경로를 textures_loaded vector에 있는 모든 텍스처의 경로와 비교하여

현재 텍스처 경로가 다른 것들과 같은지를 확인한다.

같다면 텍스처를 불러오고 생성하는 부분을 생략하고 간단히 texture struct에 존재하는 것을 사용하기만 하면 된다.

함수는 다음과 같다.

vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
    vector<Texture> textures;
    for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
    {
        aiString str;
        mat->GetTexture(type, i, &str);
        bool skip = false;
        for(unsigned int j = 0; j < textures_loaded.size(); j++)
        {
            if(std::strcmp(textures_loaded[j].path.data(), str.C_Str()) == 0)
            {
                textures.push_back(textures_loaded[j]);
                skip = true; 
                break;
            }
        }
        if(!skip)
        {   // 텍스처가 이미 불러와져있지 않다면 불러옵니다.
            Texture texture;
            texture.id = TextureFromFile(str.C_Str(), directory);
            texture.type = typeName;
            texture.path = str.C_Str();
            textures.push_back(texture);
            textures_loaded.push_back(texture); // 불러온 텍스처를 삽입합니다.
        }
    }
    return textures;
}  

그리고 이제 다재다능한 모델 로딩 시스템이 완성되었다.

하지만 오브젝트를 빠르게 로드하기 위한 최적화 또한 필요하다.

 

No more Containers!

이제 진짜 아티스트들에 의해 만들어진 실제 모델을 사용해보자.

모든 텍스처 파일들과 모델 파일들은 동일한 디렉터리에 있어야 한다는 것을 유념해야 한다.

이제 코드에서 Model 객체를 선언하고 모델 파일의 위치를 전달한다.

그 다음 이 모델은 자동적으로 불러와지고(에러가 발생하지 않는다면)

게임 루프에서 Draw 함수를 사용하여 오브젝트가 그려져야 한다.

더 이상의 버퍼 할당과 attribute pointer과 렌더링 명령은 필요 없다.

그런 다음 간단한 shader(fragment shader가 오직 오브젝트의 diffuse texture 컬러만 출력하는)들을

생성했다면 결과는 다음과 같다.

이는 지금까지 사용해왔던 컨테이너들보다 좀 더 품질 높은 모델임이 분명하다.

Assimp를 사용하여 인터넷에 있는 아주 많은 모델들을 로드할 수 있다.

무료의 3D 모델들을 여러 파일 포맷으로 다운로드할 수 있는 웹사이트들이 꽤 많이 있다.

일부 모델들은 잘 로드되지 않을 수도 있다.

이 경우 텍스처 경로가 잘못되었거나 Assimp가 불러오지 못하는 파일 포맷으로 추출된 것이다.

 

 

 

'공부한거 > OpenGL' 카테고리의 다른 글

OpenGL Advanced 5-2 Stencil testing  (0) 2020.12.08
OpenGL Advanced 5-1 Depth testing  (0) 2020.12.08
OpenGL Model Loading 4-2 Mesh  (0) 2020.12.07
OpenGL Model Loading 4-1 Assimp  (0) 2020.12.07
OpenGL Lighting 3-6 Multiple Lights  (0) 2020.12.06