[Learn OpenGL 번역] 4-2. 모델 불러오기 - Mesh
Mesh
모델 불러오기/Mesh
Assimp를 사용하여 응용 프로그램에 많은 여러가지 모델들을 불러올 수 있습니다. 하지만 불러온 모델은 Assimp 데이터 구조의 형식으로 저장됩니다. 우리가 최종적으로 원하는 것은 이 데이터를 OpenGL이 이해할 수 있는 포멧으로 변환시켜 오브젝트를 렌더링할 수 있도록 해주는 것입니다. 이전의 강좌에서 mesh는 그려질 수 있는 하나의 독립체라는 것을 배웠습니다. 이제 우리만의 mesh 클래스를 만들어봅시다.
Mesh가 최소한 어떠한 데이터들을 가지고 있어야하는지 정하기 위해 지금까지 배웠던 것을 생각해봅시다. mesh는 최소한 위치 벡터, 법선 벡터, 텍스처 좌표 벡터를 포함하고 있는 vertex들의 모음이 필요합니다. 또한 mesh는 인덱스를 사용하여 그리기 위한 index들을 포함할 수 있으며 텍스처 형태(diffuse/specular map)의 material 데이터도 포함할 수 있습니다.
이제 mesh 클래스에 대한 최소한의 요구사항을 설정하였으니 OpenGL에 vertex를 정의할 수 있습니다.
struct Vertex {
glm::vec3 Position;
glm::vec3 Normal;
glm::vec2 TexCoords;
};
우리는 각각의 vertex attribute들을 찾는 데에 사용할 수 있는 필요한 벡터들을
struct Texture {
unsigned int id;
string type;
};
텍스처의 id와 타입(예를 들어 diffuse 텍스처나 specular 텍스처)을 저장합니다.
vertex와 텍스처에 대해 실제로 이해했다면 우리는 mesh 클래스의 구조를 정의할 수 있습니다.
class Mesh {
public:
/* Mesh 데이터 */
vector<Vertex> vertices;
vector<unsigned int> indices;
vector<Texture> textures;
/* 함수 */
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures);
void Draw(Shader shader);
private:
/* 렌더 데이터 */
unsigned int VAO, VBO, EBO;
/* 함수 */
void setupMesh();
};
클래스가 그렇게 복잡하지 않다는 것을 알 수 있을 것입니다. 생성자에게 mesh의 필수적인 모든 데이터를 줍니다.
생성자 함수의 내용은 꽤 간단합니다. 간단히 클래스의 public 변수들을 해당 파라미터 변수로 설정해줍니다. 또한 생성자 내부에서
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures)
{
this->vertices = vertices;
this->indices = indices;
this->textures = textures;
setupMesh();
}
여기에 특별한 것은 없습니다. 이제
초기화
이 생성자 덕분에 우리는 렌더링에 사용할 수 있는 mesh 데이터의 목록을 가질 수 있습니다. 적절한 버퍼들을 설정하고 vertex attribute pointer를 통해 vertex shader layout을 지정해주어야 합니다. 이제 여러분은 이러한 개념에 어려움이 없어야 합니다.
void setupMesh()
{
glGenVertexArrays (1, &VAO);
glGenBuffers (1, &VBO);
glGenBuffers (1, &EBO);
glBindVertexArray (VAO);
glBindBuffer (GL_ARRAY_BUFFER, VBO);
glBufferData (GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW);
glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData (GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int),
&indices[0], GL_STATIC_DRAW);
// vertex positions
glEnable VertexAttribArray (0);
glVertexAttribPointer (0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
// vertex normals
glEnable VertexAttribArray (1);
glVertexAttribPointer (1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
// vertex texture coords
glEnable VertexAttribArray (2);
glVertexAttribPointer (2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));
glBindVertexArray (0);
}
이 코드는 여러분이 기대한 것과 많이 다르지 않습니다. 하지만
C++에서의 Struct의 속성들은 메모리의 위치가 순차적으로 저장됩니다. 즉, 우리가 struct 배열을 생성한다면 struct의 변수들이 순차적으로 정렬되어 array buffer에 필요한 float(실제로는 byte) 배열로 변환합니다. 예를 들어, 우리가
Vertex vertex;
vertex.Position = glm::vec3(0.2f, 0.4f, 0.6f);
vertex.Normal = glm::vec3(0.0f, 1.0f, 0.0f);
vertex.TexCoords = glm::vec2(1.0f, 0.0f);
// = [0.2f, 0.4f, 0.6f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f];
이 유용한 특성 덕분에 우리는
glBufferData (GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), vertices[0], GL_STATIC_DRAW);
물론 sizeof
연산자는 적절한 바이트 크기를 위해 struct에 사용할 수 있습니다. 이는 32
바이트(8
floats * 4
바이트)입니다.
Struct의 또다른 유용한 사용법은 offsetof(s,m)
라고 불리는 전처리기 지시문입니다. 이 것의 첫 번째 파라미터는 struct이고 두 번째 파라미터는 이 struct의 변수 이름입니다. 이 매크로는 struct의 시작지점으로부터 입력된 변수까지의 바이트 offset을 리턴합니다. 이는
glVertexAttribPointer (1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
이 offset은 이제 12
바이트(3
floats * 4
바이트)로 설정합니다. stride 파라미터는
이런식으로 struct를 사용하는 것은 읽기 좋은 코드로 만들어줄 뿐 아니라 구조를 쉽게 확장할 수 있도록 해줍니다. 우리가 또 다른 vertex attribute를 원한다면 간단히 struct에 추가하기만하면 렌더링이 깨지지 않고 정상적으로 동작합니다.
렌더링
이 문제를 해결하기 위해 특별한 네이밍 관습을 적용할 것입니다. 각 diffuse 텍스처는 texture_diffuseN
라고 이름을 붙이고 각 specular 텍스처는 texture_specularN
라고 이름을 붙입니다. 여기에서 N
은 1
부터 텍스처 sampler에 허용되는 최댓값 사이의 어떠한 숫자입니다. 우리가 3개의 diffuse 텍스처와 2개의 specular 텍스처를 가지고 있다고 해봅시다. 이들의 텍스처 sampler는 다음과 같이 불립니다.
uniform sampler2D texture_diffuse1;
uniform sampler2D texture_diffuse2;
uniform sampler2D texture_diffuse3;
uniform sampler2D texture_specular1;
uniform sampler2D texture_specular2;
이 네이밍 관습으로 인해 우리는 shader에서 텍스처 sampler를 있는만큼 모두 정의할 수 있습니다. 그리고 mesh가 실제로 텍스처들을 많이 가지고 있다고 하면 우리는 그들의 이름이 뭔지 알 수 있습니다. 이 네이밍 관습으로 인해 또한 하나의 mesh에 많은 양의 텍스처들을 처리할 수 있고 개발자는 간단히 적당한 sampler들을 정의해주기만 하면 이들을 마음껏 사용할 수 있습니다(더 적게 정의하여 바인딩과 uniform 호출을 낭비할지라도).
최종 드로잉 코드는 다음과 같습니다.
void Draw(Shader shader)
{
unsigned int diffuseNr = 1;
unsigned int specularNr = 1;
for(unsigned int i = 0; i < textures.size(); i++)
{
glActiveTexture (GL_TEXTURE0 + i); // 바인딩하기 전에 적절한 텍스처 유닛 활성화
// 텍스처 넘버(diffuse_textureN 에서 N) 구하기
string number;
string name = textures[i].type;
if(name == "texture_diffuse")
number = std::to_string(diffuseNr++);
else if(name == "texture_specular")
number = std::to_string(specularNr++);
shader.setFloat(("material." + name + number).c_str(), i);
glBindTexture (GL_TEXTURE_2D, textures[i].id);
}
glActiveTexture (GL_TEXTURE0);
// mesh 그리기
glBindVertexArray (VAO);
glDrawElements (GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
glBindVertexArray (0);
}
먼저 텍스처 타입마다 N 값을 계산하고 적절한 uniform 이름을 얻기 위해 이 N 값을 텍스처의 타입 문자열에 결합시킵니다. 그런 다음 적절한 sampler를 위치시키고 현재 활성화된 텍스처 유닛에 부합되는 위치 값을 주어주고 텍스처를 바인딩합니다. 이 것이
또한 우리는 "material."
문자열을 최종 uniform 이름에 추가하였습니다. 우리는 일반적으로 텍스처를 material struct에 저장하기 때문입니다(이는 구현에 따라 다를 수 있습니다).
string
으로 변환시키는 동시에 증가시킨다는 것을 알아두세요. C++에서의 증가 호출 variable++
는 variable을 리턴한 후 variable을 증가시키고 반면에 ++variable
은 먼저 variable을 증가시킨 후 그 값을 리턴합니다. 우리 경우에는 값이 std::to_string
에 넘어가고 그 후에 값이 증가하게 됩니다.
방금 정의한 이