게임공장
[Learn OpenGL 번역] 4-3. 모델 불러오기 - Model 본문
Model
모델 불러오기/Model
이제 Assimp로 노가다를 할 시간입니다. 그리고 실제 로딩, 변환 코드를 생성할 것입니다. 이 강좌의 목표는 전체적인 모델(여러 mesh들을 가지고 있는)을 나타내는 또 다른 클래스를 생성하는 것입니다. 나무로 된 발코니, 타워, 수영장을 가지고 있는 집은 여전히 하나의 모델로 로드될 수 있습니다. 우리는 Assimp를 통해 모델을 로드하고 이 것을 이전 강좌에서 생성한 여러
더이상 고민하지 않고 여러분에게
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);
};
이
void Draw(Shader shader)
{
for(unsigned int i = 0; i < meshes.size(); i++)
meshes[i].Draw(shader);
}
OpenGL에 3D 모델을 불러오기
모델을 불러오고 그 것을 우리만의 구조로 변환하기 위해서 먼저 Assimp의 적절한 헤더파일을 포함해야합니다.
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>
우리가 호출할 첫 번째 함수는
Assimp의 대단한 점은 모든 각기 다른 파일 포멧들을 불러오는 것에 대한 기술적인 상세사항들을 깔끔하게 추상화했다는 점입니다.
Assimp::Importer importer;
const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
먼저 Assimp 네임스페이스의 실제
- aiProcess_GenNormals : 모델이 법선 벡터들을 가지고 있지 않다면 각 vertex에 대한 법선을 실제로 생성합니다.
- aiProcess_SplitLargeMeshes : 큰 mesh들을 여러개의 작은 서브 mesh들로 나눕니다. 렌더링이 허용된 vertex 수의 최댓값을 가지고 있을 때 유용하고 오직 작은 mesh들만 처리할 수 있습니다.
- aiProcess_OptimizeMeshes : 반대로 여러 mesh들을 하나의 큰 mesh로 합칩니다. 최적화를 위해 드로잉 호출을 줄일 수 있습니다.
&nbps; Assimp는 많은 훌륭한 전처리 지시어들을 제공하고 여기에서 모두 찾을 수 있습니다. 실제로 Assimp를 통해 모델을 불러오는 것은 (보시다시피) 놀라울정도로 쉽습니다. 어려운 작업은 반환된 scene 객체를 사용하여 불러온 데이터를 Mesh
객체들의 배열로 변환하는 것입니다.
완성된
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이 아닌지 확인하고 데이터가 불완전하다는 플래그가 세워져있는지 확인합니다. 이러한 에러 조건 중 어떠한 것이라도 만족한다면 importer의
잘못된게 없다면 scene의 노드들을 처리하기 위해 첫 번째 노드(루트 노드)를 재귀적으로 동작하는
Assimp의 구조로부터 기억할 수 있듯이 각 노드는 mesh index들의 모음을 가지고 있습니다. 각 index는 scene 객체 내부의 특정한 mesh를 가리킵니다. 따라서 이러한 mesh index들을 얻고 각 mesh들을 얻고 그 후 각 mesh들을 처리하고 나서 각 노드의 자식 노드들에게도 이 작업을 반복합니다.
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는
모든 mesh들이 처리되면 노드의 모든 자식들에게 반복하고 동일한
이러한 시스템의 사용 사례는 자동차 mesh를 변환할 때 모든 자식(engine mesh, steering wheel mesh, tire mesh)들 또한 변환하는 것을 생각할 수 있습니다. 이러한 시스템은 부모-자식 관계를 사용하여 쉽게 생성할 수 있습니다.
하지만 지금 당장 우리는 이러한 시스템을 사용하지는 않습니다. 하지만 일반적으로 mesh 데이터들에 관한 추가적인 관리를 위해서 권장됩니다. 이러한 노드 관계는 모델을 만든 아티스트들에 의해 정의됩니다.
다음 단계는 실제로 Assimp 데이터를 처리하여 마지막 강좌에서 생성했던
Assimp에서 Mesh로
aiMesh
객체를 우리의 mesh 객체로 변환하는 것은 그렇게 어렵지 않습니다. 우리가 해야할 일은 각 mesh들의 관련된 속성들에 접근하여 우리만의 객체에 저장하는 것입니다.
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 배열에 삽입할 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]);
}
바깥의 루프가 끝나면 이제
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_
접두사로 분류됩니다. 우리는
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;
}
먼저 aiString
에 저장하는
어떠한 모델들은 그들의 텍스처 위치에 대해서 여전히 절대 경로를 사용합니다. 이는 일부 기기에서 작동하지 않을 수 있습니다. 이 경우에 텍스처에 대한 로컬 경로를 사용하기 위해 파일을 수작업으로 수정해야 합니다.
그리고 이것으로 Assimp를 사용하여 모델을 불러오는 작업이 끝났습니다.
최적화
아직 완전히 끝난것이 아닙니다. 우리가 원하는 큰 최적화(필수적인 것은 아닙니다)가 남아있기 때문이죠. 대부분의 scene들은 여러 mesh들에 여러가지 텍스처들을 재사용합니다. 다시 집을 생각해보세요. 이 집은 벽이 화강암 텍스처로 이루어져있습니다. 이 텍스처는 바닥, 천장, 계단, 테이블에도 적용됩니다. 텍스처를 불러오는 것은 비용이 많이 드는 연산입니다. 현재 우리가 구현한 상태로는 각 mesh 마다 새로운 텍스처가 불러와지고 생성됩니다. 완전히 동일한 텍스처가 여러번 불러와짐에도 불구하고 말이죠. 이는 보틀넥 현상이 쉽게 발생할 수 있습니다.
그래서 우리는 model 코드에 약간의 변형을 줄 것입니다. 불러온 모든 텍스처들을 전역으로 저장하고 텍스처를 불러오고 싶을때마다 먼저 그 텍스처가 이미 불러와졌는지 확인합니다. 이미 불러온 텍스처라면 이 텍스처를 가져오고 전체적인 텍스처를 불러오는 과정은 생략하여 많은 프로세싱 파워를 절약할 수 있습니다. 이러한 텍스처 비교를 가능하게 하기 위해서 그들의 경로 또한 저장해야합니다.
struct Texture {
unsigned int id;
string type;
string path; // 다른 텍스처와 비교하기 위해 텍스처의 경로를 저장
};
그런 다음 모델 클래스의 맨 위에 private 변수로 선언된 또 다른 vector에 불러온 모든 텍스처를 저장합니다.
vector<Texture> textures_loaded;
그런 다음
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;
}
그리고 이제 우리는 다재다능한 모델 로딩 시스템을 가지고 있습니다. 하지만 오브젝트를 빠르게 로드하기 위한 최적화 또한 필요합니다.
최적화된
더이상 컨테이너가 아닙니다!
이제 진짜 아티스트들에 의해 만들어진 회전하는 실제 모델을 사용해봅시다. 많은 돈을 쓰고 싶지 않기 이번엔 Crytek의 게임인 Crysis
에서 사용된 nanosuit 모델을 사용할 것입니다(tf3dm.com에서 다운로드할 수 있고 다른 모델들도 사용될 수 있습니다). 이 모델은 .obj
파일로 추출되었습니다. 또한 모델의 diffuse, specular, normal map(이 것은 나중에 다룰 것입니다)을 포함하는 .mtl
파일도 함께 존재합니다. 불러오기 쉽게 (약간 수정된) 모델을 여기에서 다운받을 수 있습니다. 모든 텍스처 파일들과 모델 파일들은 동일한 디렉터리에 있어야한다는 것을 알아두세요.
이제 코드에서
전체 소스 코드는 여기에서 확인할 수 있습니다.
좀 더 창의적이게 만들 수 있습니다. 두 개의 point light와 specular map들을 사용하면 놀라운 결과를 얻을 수 있습니다.
이는 지금까지 사용해왔던 컨테이너들보다 좀 더 품질 높은 모델임에 분명합니다. Assimp를 사용하여 여러분은 인터넷에 있는 아주 많은 모델들을 로드할 수 있습니다. 무료의 3D 모델들을 여러 파일 포멧으로 다운로드할 수 있는 웹사이트들이 꽤 많이 있습니다. 일부 모델들은 잘 로드되지 않을 수 있습니다. 이 경우 텍스처 경로가 잘못되었거나 Assimp가 불러오지 못하는 파일 포멧으로 추출된 것입니다.
'OpenGL' 카테고리의 다른 글
[Learn OpenGL 번역] 5-2. 고급 OpenGL - Stencil testing (0) | 2018.08.05 |
---|---|
[Learn OpenGL 번역] 5-1. 고급 OpenGL - Depth testing (0) | 2018.08.02 |
[Learn OpenGL 번역] 4-2. 모델 불러오기 - Mesh (0) | 2018.07.24 |
[Learn OpenGL 번역] 4-1. 모델 불러오기 - Assimp (0) | 2018.07.18 |
[Learn OpenGL 번역] 3-6. 조명 - Multiple lights (0) | 2018.07.17 |