메시
Assimp를 사용하면 다양한 모델을 애플리케이션에 불러올 수 있지만, 일단 불러온 모델은 모두 Assimp의 데이터 구조에 저장됩니다. 우리가 궁극적으로 원하는 것은 해당 데이터를 OpenGL이 이해할 수 있는 형식으로 변환하여 객체를 렌더링하는 것입니다. 이전 장에서 메시는 단일 드로잉 가능한 엔티티를 나타낸다는 것을 배웠으므로, 먼저 우리만의 메시 클래스를 정의해 보겠습니다.
지금까지 배운 내용을 복습하여 메시가 최소한 어떤 데이터를 포함해야 하는지 생각해 봅시다. 메시는 최소한 정점 집합을 필요로 하며, 각 정점은 위치 벡터, 법선 벡터, 텍스처 좌표 벡터를 포함해야 합니다. 또한, 인덱스 드로잉을 위한 인덱스와 텍스처 형태의 재질 데이터(디퓨즈/스페큘러 맵)도 포함해야 합니다.
이제 메시 클래스의 최소 요구 사항을 설정했으므로 OpenGL에서 정점을 정의할 수 있습니다.
struct Vertex {
glm::vec3 Position;
glm::vec3 Normal;
glm::vec2 TexCoords;
};
필요한 각 정점 속성은 Vertex라는 구조체에 저장합니다. Vertex 구조체 외에도 텍스처 데이터는 Texture 구조체에 정리하려고 합니다.
struct Texture {
unsigned int id;
string type;
};
텍스처의 ID와 유형(예: 디퓨즈 텍스처 또는 스페큘러 텍스처)을 저장합니다.
정점과 텍스처의 실제 표현 방식을 알았으니 이제 메쉬 클래스의 구조를 정의하기 시작할 수 있습니다.
class Mesh {
public:
// mesh data
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:
// render data
unsigned int VAO, VBO, EBO;
void setupMesh();
};
보시다시피, 이 클래스는 그다지 복잡하지 않습니다. 생성자에서 메시에 필요한 모든 데이터를 제공하고, setupMesh 함수에서 버퍼를 초기화한 다음, Draw 함수를 통해 메시를 그립니다. Draw 함수에 셰이더를 전달한다는 것에 유의하세요. 셰이더를 메시에 전달함으로써 그리기 전에 여러 유니폼 값을 설정할 수 있습니다 (예: 샘플러를 텍스처 유닛에 연결).
생성자의 함수 내용은 매우 간단합니다. 클래스의 공개 변수들을 생성자의 인자 변수들에 대응하도록 설정하고, 생성자 안에서 setupMesh 함수를 호출합니다.
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures)
{
this->vertices = vertices;
this->indices = indices;
this->textures = textures;
setupMesh();
}
여기서는 특별한 일이 일어나지 않습니다. 이제 setupMesh 함수를 자세히 살펴보겠습니다.
초기화
생성자 덕분에 이제 렌더링에 사용할 수 있는 방대한 메시 데이터 목록을 갖게 되었습니다. 적절한 버퍼를 설정하고 정점 속성 포인터를 통해 정점 셰이더 레이아웃을 지정해야 합니다. 이제 이러한 개념들은 익숙하시겠지만, 이번에는 구조체에 정점 데이터를 도입하면서 조금 더 복잡해졌습니다.
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
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
// vertex normals
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
// vertex texture coords
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));
glBindVertexArray(0);
}
코드는 예상했던 것과 크게 다르지 않지만, Vertex 구조체를 활용하여 몇 가지 작은 트릭을 사용했습니다.
C++에서 구조체는 메모리 레이아웃이 순차적이라는 훌륭한 속성을 가지고 있습니다. 즉, 구조체를 데이터 배열로 표현한다면, 구조체의 변수들만 순차적으로 저장되며, 이는 배열 버퍼에 필요한 float(실제로는 byte) 배열로 바로 변환됩니다. 예를 들어, 모든 변수가 채워진 Vertex 구조체의 메모리 레이아웃은 다음과 같습니다.
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];
이 유용한 속성 덕분에 우리는 Vertex 구조체의 큰 목록에 대한 포인터를 버퍼의 데이터로 직접 전달할 수 있으며, 이는 glBufferData가 인수로 기대하는 형식으로 완벽하게 변환됩니다.
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW);
물론 sizeof 연산자를 사용하여 구조체의 적절한 크기를 바이트 단위로 구할 수도 있습니다. 이 크기는 32바이트(float 8개 * 각 4바이트)입니다.
구조체를 활용하는 또 다른 훌륭한 방법은 전처리기 지시문인 offsetof(s,m)을 사용하는 것입니다. 이 지시문은 첫 번째 인수로 구조체를, 두 번째 인수로 구조체 내 변수 이름을 받습니다. 이 매크로는 구조체 시작 부분에서 해당 변수의 바이트 오프셋을 반환합니다. 이는 glVertexAttribPointer 함수의 오프셋 매개변수를 정의하는 데 매우 유용합니다.
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
이제 오프셋은 offsetof 매크로를 사용하여 정의되며, 이 경우 일반 벡터의 바이트 오프셋을 구조체의 일반 속성 바이트 오프셋(3개의 부동 소수점 값, 즉 12바이트)과 같게 설정합니다.
이와 같은 구조체를 사용하면 코드 가독성이 향상될 뿐만 아니라 구조체를 쉽게 확장할 수 있습니다. 정점 속성을 하나 더 추가하고 싶다면 구조체에 간단히 추가하기만 하면 되며, 구조체의 유연성 덕분에 렌더링 코드가 오류 없이 작동합니다.
렌더링
Mesh 클래스를 완성하기 위해 마지막으로 정의해야 할 함수는 Draw 함수입니다. 메시를 렌더링하기 전에 glDrawElements를 호출하기 전에 적절한 텍스처를 바인딩해야 합니다. 하지만 메시가 어떤 텍스처를 가지고 있는지(혹은 아무 텍스처도 가지고 있지 않은지) 그리고 어떤 유형의 텍스처를 가지고 있는지 처음부터 알 수 없기 때문에 이 과정은 다소 어렵습니다. 그렇다면 셰이더에서 텍스처 유닛과 샘플러를 어떻게 설정해야 할까요?
이 문제를 해결하기 위해 특정 명명 규칙을 가정하겠습니다. 각 디퓨즈 텍스처는 texture_diffuseN으로, 각 스페큘러 텍스처는 texture_specularN으로 명명해야 합니다. 여기서 N은 1부터 허용되는 최대 텍스처 샘플러 개수까지의 임의의 숫자입니다. 특정 메시에 디퓨즈 텍스처가 3개, 스페큘러 텍스처가 2개 있다고 가정하면, 해당 텍스처 샘플러는 다음과 같이 명명되어야 합니다.
uniform sampler2D texture_diffuse1;
uniform sampler2D texture_diffuse2;
uniform sampler2D texture_diffuse3;
uniform sampler2D texture_specular1;
uniform sampler2D texture_specular2;
이 규칙을 따르면 셰이더에서 원하는 만큼의 텍스처 샘플러를 정의할 수 있으며(OpenGL의 최대치까지), 메시가 실제로 (많은) 텍스처를 포함하는 경우 해당 텍스처의 이름을 알 수 있습니다. 이 규칙을 통해 단일 메시에서 원하는 만큼의 텍스처를 처리할 수 있으며, 셰이더 개발자는 적절한 샘플러를 정의하여 원하는 만큼의 텍스처를 자유롭게 사용할 수 있습니다.
이와 같은 문제에는 여러 가지 해결책이 있으며, 만약 이 특정 해결책이 마음에 들지 않는다면 창의력을 발휘하여 자신만의 접근 방식을 생각해내는 것은 당신에게 달려 있습니다.
결과적으로 그리기 코드는 다음과 같습니다.
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); // activate proper texture unit before binding
// retrieve texture number (the N in diffuse_textureN)
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.setInt(("material." + name + number).c_str(), i);
glBindTexture(GL_TEXTURE_2D, textures[i].id);
}
glActiveTexture(GL_TEXTURE0);
// draw mesh
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
}
먼저 각 텍스처 유형별로 N개의 구성 요소를 계산하고 이를 텍스처 유형 문자열에 연결하여 적절한 유니폼 이름을 얻습니다. 그런 다음 적절한 샘플러를 찾아 현재 활성화된 텍스처 유닛에 해당하는 위치 값을 부여하고 텍스처를 바인딩합니다. 이것이 바로 Draw 함수에 셰이더가 필요한 이유입니다.
또한 일반적으로 텍스처를 재질 구조체에 저장하기 때문에 결과 유니폼 이름에 "material."을 추가했습니다(구현에 따라 다를 수 있음).
확산광 및 반사광 카운터는 문자열로 변환하는 순간 증가된다는 점에 유의하십시오. C++에서 증가 연산자 variable++는 변수를 그대로 반환한 후 1씩 증가시키는 반면, ++variable은 먼저 변수를 1 증가시킨 후 원래 값을 반환합니다. 따라서 std::string에 전달되는 값은 초기 카운터 값입니다. 그 후 다음 라운드를 위해 값이 증가합니다.
Mesh 클래스의 전체 소스 코드는 여기에서 확인할 수 있습니다.
방금 정의한 Mesh 클래스는 앞 장에서 다룬 여러 개념들을 추상화한 것입니다. 다음 장에서는 여러 메쉬 객체를 담는 컨테이너 역할을 하고 Assimp의 로딩 인터페이스를 구현하는 모델을 만들 것입니다.