Model
- 이제 Assimp를 사용해야한다.
- 실제 로딩, 변환 코드를 생성할 것이다.
- 여러 메시를 가지는 전체적인 모델을 나타내는 클래스를 만들것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Model
{
public:
Model(char *path)
{
loadModel(path);
}
void Draw(Shader &shader);
private:
// model data
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루틴의 일부분을 처리한다.- 나중에 텍스처를 로드할 때를 위해
directory
변수가 있음을 주의해라
- 그런 다음,
Draw
함수는 특별한 것은 없고, 기본적으로 반복문을 이용하여mesh
들의Draw
함수를 호출시킨다.
1
2
3
4
5
void Draw(Shader &shader)
{
for(unsigned int i = 0; i < meshes.size(); i++)
meshes[i].Draw(shader);
}
Importing a 3D model into OpenGL
- 모델을 가져와서 우리 자신의 구조로 변환하기 위해서는, 적절히 Assimp의 헤더를 포함시켜야한다.
1
2
3
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>
먼저 우리가 호출하는 첫 함수는 생성자에서 직접 호출되는
loadModel
이다.- 이 함수내부에서는
scene
객체라는 Assimp의 데이터 구조에 모델을 로드해야한다. - 이 객체는 Assimp 데이터 인터페이스의 루트 객체이다.
scene
객체를 통해 원하는 모든 데이터를 얻을 수 있다.
- 이 함수내부에서는
Assimp는 다양한 파일 포멧들을 불러올 수 있게 추상화한 라이브러리이다.
1
2
Assimp::Importer importer;
const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
먼저 Importer 객체를 선언해야한다.
- 이객체의 ReadFile 함수를 호출한다.
- 이 함수를 호출할 때 파일의 경로, 전처리 옵션들을 지정할 수 있다.
전처리 옵션
- 볼러온 데에터에 추가적인 계산/연산
aiProcess_Triangulate
: Assimp에게 모델이 삼각형으로만 이루어지지 않았다면, 모든 primitive 도형들을 삼각형으로 변환하라고 말해준다.aiProcess-FlipUVs
: 텍스처 좌표를 y축으로 뒤집어준다.(OpenGL에서 대부분의 이미지들은 y축을 중심으로 거꾸로되어 있음.)aiProcess_GenNormals
: 모델이 법선 벡터들을 가지고 있지 않다면, 각 vertex에 대한 법선을 생성aiProcess_SplitLargeMeshes
: 큰 메쉬를 더 작은 하위 메시로 나눔. 렌더링에 최대 정점이 허용되고 더 작은 메쉬만 처리할 수 있는 경우에 유용.aiProcess_OptimizeMeshes
: 여러 메시들을 하나의 큰 메시로 합침. 최적화를 위해 드로잉 호출을 줄임.
Assimp는 많은 훌륭한 전처리 옵션을 제공함.
- 어려운 작업은 반환된 scene객체를 사용하여 불러온 데이터를
Mesh
객체들의 배열로 변환하는것. - 완성된
loadModel
함수는 다음과 같다.
- 어려운 작업은 반환된 scene객체를 사용하여 불러온 데이터를
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
의 노드들을 처리하기 위해, 첫 번째 노드(루트노드)를 재귀함수인processNode
로 전달한다.- 각 노드는 자식들을 가지고 있기 때문에, 먼저 노드를 처리하고, 그런 다음 모든 자식들을 처리한다.
- 이는 재귀적인 구조에 적합하므로, 재귀함수로 정의한것.
- 종료조건은 모든 노드들을 처리되었을 때 만족한다.
각 노드는
mesh
index들의 모음을 가지고 있다.- 각
index
는scene
객체 내부의 특정한mesh
를 가리킨다. - 이러한 인덱스들을 얻고 각
mesh
들을 얻고 그 후 각mesh
들을 처리한다. - 그리고 나서 자식들도 이러한 작업을 반복한다.
processNode
함수의 내용은 다음과 같다.
- 각
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void processNode(aiNode *node, const aiScene *scene)
{
// process all the node's meshes (if any)
for(unsigned int i = 0; i < node->mNumMeshes; i++)
{
aiMesh *mesh = scene->mMeshes[node->mMeshes[i]];
meshes.push_back(processMesh(mesh, scene));
}
// then do the same for each of its children
for(unsigned int i = 0; i < node->mNumChildren; i++)
{
processNode(node->mChildren[i], scene);
}
}
mesh 인덱스를 확인하고, scene의
mMeshes
배열에 접근하여, mesh들을 얻는다.- 반환된 mesh는
processMesh
함수로 전달된다. - 이 함수는 meshes vector에 저장할 수 있는
Mesh
객체를 리턴한다.
- 반환된 mesh는
- 모든 mesh들이 처리되면, 자식노드들을 처리한다.
지금 당장은 이런 시스템을 사용하지는 않지만, 일반적으로 mesh 데이터들에 관한 추가적인 관리를 위해서 권장된다.
- 이러한 노드 관계는 모델을 만든 아티스트들에 의해 정의된다.
- 다음 단계는
Assimp
데이터를 처리하여,Mesh
클래스 형태로 변환하는 것
Assimp to Mesh
- 각
mesh
들의 관련된 속성들에 접근하여 자신만의 객체에 저장하는 것.processMesh
함수의 일반적인 구조는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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;
// process vertex positions, normals and texture coordinates
// [...]
vertices.push_back(vertex);
}
// process indices
// [...]
// process material
if(mesh->mMaterialIndex >= 0)
{
// [...]
}
return Mesh(vertices, indices, textures);
}
Mesh
를 처리하는 것은 기본적으로 3부분.- 모든 vertex 데이터를 얻고.
- mesh의 indices를 얻고
- 마지막으로 연관된 material 데이터를 얻는것.
Vertex 데이터 얻기
- 각 루프를 돌때마다 vertices 배열에 삽입할
Vertex struct
를 정의한다.- mesh에 존재하는 vertex의 갯수만큼 반복문을 실행한다.
- 그런 다음 반복문 내부에서 모든 관련된 데이터로 이
struct
를 채워넣어야한다.
1
2
3
4
5
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만의 데이터 타입과 호환 안될 가능성이 크므로)법선은 다음과같다.
1
2
3
4
vector.x = mesh->mNormals[i].x;
vector.y = mesh->mNormals[i].y;
vector.z = mesh->mNormals[i].z;
vertex.Normal = vector;
- 텍스처 좌표는 다음과 같다.
- Assimp는 모델이 꼭지점당 최대 8개의 다른 텍스처 좌표를 허용한다.
- 지금은 하나의 텍스처만 사용하기 때문에, 첫 번째만 신경쓰면됨.
mesh
가 실제로 텍스처 좌표를 가지고 있는지 확인해야한다.(항상 가지고 있는것이 아니므로)
1
2
3
4
5
6
7
8
9
if(mesh->mTextureCoords[0]) // does the mesh contain texture coordinates?
{
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에 저장해야한다.
1
2
3
4
5
6
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
함수로 그릴 수 있지만, material 또한 처리해야한다.
Material
- 노드와 마찬가지로
mesh
는 오직material
객체의 index만 가지고 있다.- 앞에서처럼
mMaterial
배열을 인덱싱해야한다. mesh
의 material index는mMaterialIndex
속성에 설정되어 있다.- 이 속성으로
mesh
가 실제로material
을 가지고 있는지 아닌지 확인 가능.
- 앞에서처럼
1
2
3
4
5
6
7
8
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());
}
궁금점: 퐁모델이 아닌경우에도 셰이더에 diffuse 와 specular?
scene의 mMaterials 배열로부터
aiMaterial
객체를 얻는다.- 그리고,
mesh
의diffuse
,specular
텍스처들을 로드한다. - material 객체는 내부적으로 각 텍스처 타입에 대한 texture location 배열을 저장한다.
- 여러 텍스처 타입들은
aiTexture Type_
접두사로 분류된다.
- 그리고,
loadMaterialTextures
: 이 함수는 material에서 텍스처를 얻는다.- texture struct의 vector를 리턴하고, 이 것을 model의
textures vector
의 끝에 저장한다. - 주어진 텍스처 타입의 모든 texture location을 순회하며, 텍스처 파일의 위치를 얻은 다음 로드하고 텍스처를 생성하며, 이 정보를
Vertex struct
에 저장한다. GetTextureCount
함수를 통해 이 material에 저장된 해당하는 텍스처의 타입의 수를 확인한다.GetTexture
함수를 통해 각 텍스처 파일의 위치를 얻는다.TextureFromFile
함수로 텍스처를 불러오고 텍스처 아이디를 얻는다.
- texture struct의 vector를 리턴하고, 이 것을 model의
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
unsigned int TextureFromFile(const char *path, const string &directory, bool gamma = false);
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;
}
TextureFromFile
은 다음과 같다.
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
unsigned int TextureFromFile(const char *path, const string &directory, bool gamma)
{
string filename = string(path);
filename = directory + '/' + filename;
unsigned int textureID;
glGenTextures(1, &textureID);
int width, height, nrComponents;
unsigned char *data = stbi_load(filename.c_str(), &width, &height, &nrComponents, 0);
if (data)
{
GLenum format;
if (nrComponents == 1)
format = GL_RED;
else if (nrComponents == 3)
format = GL_RGB;
else if (nrComponents == 4)
format = GL_RGBA;
glBindTexture(GL_TEXTURE_2D, textureID);
glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
stbi_image_free(data);
}
else
{
std::cout << "Texture failed to load at path: " << path << std::endl;
stbi_image_free(data);
}
return textureID;
}
model 파일의 텍스처 파일 경로가 model 파일과 동일하다고 가정했다. 간단히 텍스처 위치 문자열과(loadModel 함수로 얻은) 디렉터리 문자열을 결합하여 완전한 텍스처 경로를 얻을 수 있다. (GetTexture함수에 디렉터리 문자열이 필요한 이유)
그러므로 만약 텍스처 위치에 대해서 절대 경로를 사용하는 모델일 경우, 위 코드는 일부 기기에서 작동하지 않을 수 있다.(로컬 경로를 사용하기 위해 파일을 수정해야함)
An optimization
- 대부분의
scene
들은 여러mesh
들에 여러가지 텍스처들을 재사용한다. - 집같은 경우 벽, 바닥, 천장, 계단, 테이블 등에 같은 텍스처를 사용할 수 있음.
- 텍스처를 로드하는것은 비용이 많이 드는 연산이다.
현재 구현한 상태로는 각
mesh
마다 새로운 텍스처가 로드되고 생성된다.- 즉, 같은 텍스처가 여러번 로드 => 병목현상이 쉽게 발생할 수 있다.
- 따라서 불러온 모든 텍스처들을 전역으로 저장하고, 텍스처를 불러오고 싶을때마다 그 텍스처가 이미 로드됬는지 확인한다.
- 이를 위해 경로 또한 저장해야한다.
1
2
3
4
5
struct Texture {
unsigned int id;
string type;
string path; // we store the path of the texture to compare with other textures
};
- 그다음, 모델 클래스의 맨 위에 private 변수로 텍스처 vector를 선언하자
1
vector<Texture> textures_loaded;
- 그다음,
loadMaterialTextures
함수에서 텍스처 경로를textures_loaded vector
에 있는 모든 텍스처의 경로롸 비교하여 현재 텍스처 경로가 다른 것들과 같은지 확인한다.- 같으면 텍스처 로드 생성 생략하고 기존꺼 넣는다.
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
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)
{ // if texture hasn't been loaded already, load it
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); // add to loaded textures
}
}
return textures;
}
Assimp의 일부 버전들은 IDE 디버그 버전이나 모드를 사용할 때 model을 꽤 느리게 로드하는 경향이 있다. 그러므로 로딩하는 시간이 느리다면, 릴리즈 버전으로 테스트해보자.
No more containers!
- 이제 실제 모델을 사용해보자.
- free3d 에서 다양한 모델들을 얻을 수 있음.
- 주의: 절대경로인 경우 상대 경로로 텍스처 파일들을 수정해야함.
- 이때 모든 텍스처 파일들과 모델 파일들은 동일한 디렉터리에 있어야한다.
이 모델은 diffuse, specular, normal maps이 연결된
.mtl
과 함께.obj
파일로 export된 것이다.- 이제 코드에서 Model 객체를 선언하고, 모델 파일의 경로를 전달한다.
- 그런 다음, 이 모델은 로드되고, 게임 루프에서
Draw
함수를 사용하여 오브젝트를 그려야한다. - 더 이상의 버퍼 할당과 속성 포인터 및 렌더링 명령이 필요하지는 않다.
- fragment shader가 diffuse texture 컬러만 출력하면 결과는 다음과 같다.
- 그런 다음, 이 모델은 로드되고, 게임 루프에서
- 이제 두 개의 point light와 specular map 들을 사용하면 다음과 같은 결과를 얻을 수 있다.
- Assimp를 사용하여 많은 모델들을 로드할 수 있다.
- 일부 모델들은 잘 로드되지 않을 수 있는데, 이 경우 텍스처 경로가 잘못되었거나 Assimp가 불러오지 못하는 파일 포멧으로 추출된것.