С помощью библиотеки Assimp мы можем загрузить в наше приложение самые разнообразные модели, но стоит учесть, что после загрузки все они будут сохранены в структурах данных Assimp. А это значит, что для визуализации наших загруженных объектов, нам необходимо будет преобразовать эти данные в формат, который был бы понятен для OpenGL. Из предыдущего урока мы узнали, что меш (англ. «mesh») — это минимальная единица отрисовки объекта, поэтому предлагаю начать с определения собственного класса Mesh.
Создание класса Mesh
Давайте немного подумаем и определимся, какие переменные для этого потребуются. Как минимум, у нас должен быть набор вершин, в котором каждая вершина включает в себя информацию о векторе положения, векторе нормали и векторе текстурных координат. Меш также должен содержать индексы для индексированной отрисовки и данные о материалах, представляемых в виде текстур (диффузные/зеркальные карты).
Теперь, когда мы установили минимальные требования для создаваемого Mesh-класса, мы можем определить вершину в OpenGL:
1 2 3 4 5 |
struct Vertex { glm::vec3 Position; glm::vec3 Normal; glm::vec2 TexCoords; }; |
Каждый из необходимых атрибутов вершины хранится в структуре, называемой Vertex
. Ну и аналогичным образом можно организовать хранение текстурных данных в виде структуры Texture
:
1 2 3 4 |
struct Texture { unsigned int id; string type; }; |
В ней у нас будут храниться идентификатор текстуры и её тип (например, диффузная или зеркальная).
Имея текущие представления вершины и текстуры, мы можем приступить к описанию Mesh-класса:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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-объекта мы передаем конструктору класса все необходимые данные, затем инициализируем буферы в функции setupMesh() и в конце отрисовываем меш с помощью функции Draw(). Обратите внимание, функция Draw() принимает шейдер в качестве своего параметра; благодаря этому до момента отрисовки мы можем установить несколько uniform-переменных (например, связать сэмплеры с соответствующими текстурными юнитами).
Рассмотрим реализацию конструктора. Я думаю с этим у вас не должно возникнуть проблем, т.к. мы просто присваиваем значения аргументов конструктора соответствующим переменным класса, а затем вызываем функцию setupMesh():
1 2 3 4 5 6 7 8 |
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures) { this->vertices = vertices; this->indices = indices; this->textures = textures; setupMesh(); } |
Собственно, комментировать особо то и нечего, поэтому давайте углубимся внутрь функции setupMesh().
Инициализация
Благодаря конструктору у нас теперь есть большой объем Mesh-данных, которые мы можем использовать в процессе визуализации. Для этого необходимо настроить соответствующие буферы и установить с помощью указателей атрибутов вершин layout-параметр вершинного шейдера. Ранее мы уже обсуждали реализацию подобного алгоритма, но на этот раз он немного усложнен введением представления вершинных данных в качестве переменной типа структура:
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 |
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); // Координаты вершин glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0); // Нормали вершин glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal)); // Текстурные координаты вершин glEnableVertexAttribArray(2); glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords)); glBindVertexArray(0); } |
Данный код почти не отличается от того, что мы встречали на предыдущих уроках. Но при этом для работы со структурой Vertex
было задействовано несколько небольших трюков.
В языке C++ структуры имеют одно очень важное свойство — их расположение в памяти является последовательным. То есть, если представить структуру в виде массива данных, то он состоял бы только из внутренних переменных структуры, размещаемых в памяти друг за другом. Тем самым, структура напрямую трансформировалась бы в массив данных типа float, который мы задействовали бы в качестве буферного массива. Ниже представлен наглядный пример того, как данные такой структуры могут быть расположены в памяти:
1 2 3 4 5 |
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(), который она от нас ожидает получить:
1 |
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW); |
Естественно, оператор sizeof также может быть применен к структуре для вычисления её размера в байтах. В результате у нас должно получиться 32 байта (8 float * 4 байта
).
Еще одна немаловажная особенность применения структур — это использование директивы препроцессора offsetof(s,m)
, которая принимает в качестве своего первого аргумента структуру, а в качестве второго — имя её внутренней переменной. Макрос возвращает смещение (в байтах) данной переменной относительно начала структуры. Это идеально подходит для определения параметра смещения в функции glVertexAttribPointer():
1 |
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal)); |
Теперь смещение определяется с помощью макроса offsetof(), который в этом случае задает смещение (в байтах) вектора нормали, равное смещению (в байтах) атрибута нормали, состоящего из 3 значений типа float (получается 12 байт).
Использование такой структуры не только придает более аккуратный вид нашему коду, но и позволяет легко её расширять. Если нам потребуется еще один атрибут вершины, то мы сможем добавить его в структуру, не сломав при этом код рендеринга.
Рендеринг
Последняя функция, которую нам нужно определить, чтобы завершить создание класса — это функция Draw(). Перед рендерингом меша и непосредственным вызовом функции glDrawElements(), мы должны привязать соответствующие текстуры. Однако это может быть несколько затруднительно, поскольку мы с самого начала не знаем, сколько и какого типа текстур имеет меш (если вообще имеет). Итак, как же нам в шейдерах задать сэмплеры и текстурные юниты?
Чтобы решить эту проблему, мы примем определенное соглашение об именовании переменных: каждая диффузная текстура будет называться texture_diffuseN
, а каждая зеркальная текстура — texture_specularN
, где N
— это любое число в диапазоне от 1 до максимально разрешенного значения числа текстурных сэмплеров. Допустим, у нас есть 3 диффузные и 2 зеркальные текстуры для конкретного меша, тогда их текстурные сэмплеры должны называться так:
1 2 3 4 5 |
uniform sampler2D texture_diffuse1; uniform sampler2D texture_diffuse2; uniform sampler2D texture_diffuse3; uniform sampler2D texture_specular1; uniform sampler2D texture_specular2; |
В соответствии с этим соглашением мы можем определить в шейдерах столько сэмплеров текстур, сколько позволяет OpenGL, и, если меш действительно содержит так много текстур, мы будем знать, как они будут называться. В соответствии с этим соглашением мы можем обрабатывать любое количество текстур отдельно взятого меша, и разработчику шейдеров ничего не помешает использовать столько текстур, сколько он захочет, определяя при этом соответствующие сэмплеры.
Примечание: Существует большое множество решений подобных задач, и, если вам не нравится конкретно этот способ, вы можете проявить творческий подход и придумать свой собственный метод.
В результате всех описываемых манипуляций код функции отрисовки принимает следующий вид:
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 |
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); // активируем соответствующий текстурный юнит перед привязкой // Получаем номер текстуры (значение N в 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.setFloat(("material." + name + number).c_str(), i); glBindTexture(GL_TEXTURE_2D, textures[i].id); } glActiveTexture(GL_TEXTURE0); // Отрисовываем меш glBindVertexArray(VAO); glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0); glBindVertexArray(0); } |
Сначала мы вычисляем N
-й компонент для каждого типа текстуры и объединяем его со строкой, содержащей тип текстуры, чтобы получить имя соответствующей uniform-переменной. Затем мы находим соответствующий сэмплер, присваиваем ему значение местоположения текущего активного текстурного юнита и связываем текстуру. Это также является причиной того, почему в функции Draw() нам нужен шейдер.
Мы также добавили приставку "material."
к полученному имени uniform-переменной, потому что наши текстуры хранятся в структуре material
(стоит заметить, что в зависимости от реализации, данный момент может отличаться).
Примечание: Обратите внимание, что мы увеличиваем счетчики диффузного и зеркального компонентов в тот момент, когда преобразуем их в строку.
Полный исходный код класса Mesh
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 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 |
#ifndef MESH_H #define MESH_H #include <glad/glad.h> // содержит все объявления OpenGL-типов #include <glm/glm.hpp> #include <glm/gtc/matrix_transform.hpp> #include <shader.h> // shader.h идентичен файлу shader_s.h #include <string> #include <vector> using namespace std; struct Vertex { // Позиция glm::vec3 Position; // Нормаль glm::vec3 Normal; // Текстурные координаты glm::vec2 TexCoords; // Касательный вектор glm::vec3 Tangent; // Вектор бинормали (вектор, перпендикулярный касательному вектору и вектору нормали) glm::vec3 Bitangent; }; struct Texture { unsigned int id; string type; string path; }; class Mesh { public: // Данные меша vector<Vertex> vertices; vector<unsigned int> indices; vector<Texture> textures; unsigned int VAO; // Конструктор Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures) { this->vertices = vertices; this->indices = indices; this->textures = textures; // Теперь, когда у нас есть все необходимые данные, устанавливаем вершинные буферы и указатели атрибутов setupMesh(); } // Рендеринг меша void Draw(Shader &shader) { // Связываем соответствующие текстуры unsigned int diffuseNr = 1; unsigned int specularNr = 1; unsigned int normalNr = 1; unsigned int heightNr = 1; for(unsigned int i = 0; i < textures.size(); i++) { glActiveTexture(GL_TEXTURE0 + i); // перед связыванием активируем нужный текстурный юнит // Получаем номер текстуры (номер N в 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++); // конвертируем unsigned int в строку else if(name == "texture_normal") number = std::to_string(normalNr++); // конвертируем unsigned int в строку else if(name == "texture_height") number = std::to_string(heightNr++); // конвертируем unsigned int в строку // Теперь устанавливаем сэмплер на нужный текстурный юнит glUniform1i(glGetUniformLocation(shader.ID, (name + number).c_str()), i); // и связываем текстуру glBindTexture(GL_TEXTURE_2D, textures[i].id); } // Отрисовываем меш glBindVertexArray(VAO); glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0); glBindVertexArray(0); // Считается хорошей практикой возвращать значения переменных к их первоначальным значениям glActiveTexture(GL_TEXTURE0); } private: // Данные для рендеринга unsigned int VBO, EBO; // Инициализируем все буферные объекты/массивы void setupMesh() { // Создаем буферные объекты/массивы glGenVertexArrays(1, &VAO); glGenBuffers(1, &VBO); glGenBuffers(1, &EBO); glBindVertexArray(VAO); // Загружаем данные в вершинный буфер glBindBuffer(GL_ARRAY_BUFFER, VBO); // Самое замечательное в структурах то, что расположение в памяти их внутренних переменных является последовательным. // Смысл данного трюка в том, что мы можем просто передать указатель на структуру, и она прекрасно преобразуется в массив данных с элементами типа glm::vec3 (или glm::vec2), который затем будет преобразован в массив данных типа float, ну а в конце – в байтовый массив 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); // Устанавливаем указатели вершинных атрибутов // Координаты вершин glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0); // Нормали вершин glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal)); // Текстурные координаты вершин glEnableVertexAttribArray(2); glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords)); // Касательный вектор вершины glEnableVertexAttribArray(3); glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Tangent)); // Вектор бинормали вершины glEnableVertexAttribArray(4); glVertexAttribPointer(4, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Bitangent)); glBindVertexArray(0); } }; #endif |
Вышеопределенный класс Mesh является абстракцией для многих вещей, которые мы обсуждали на первых уроках. На следующем уроке мы создадим класс модели Model, который будет играть роль контейнера для нескольких Mesh-объектов, и реализуем с помощью Assimp интерфейс загрузки моделей.
А для чего мы делаем glActiveTexture(GL_TEXTURE0); после цикла в функции Draw?
Что бы для глобального контекста OpenGL текущий активный (ожидающий привязки текстуры) текстурный юнит был №0 (номер ноль)
Что бы если мы где то в коде дальше после вызова метода
void Draw(Shader shader) использовали glBindTexture(…) и у нас было бы ожидаемое поведение т.е. мы имели бы контекст OpenGL в стандартном ожидаемом состоянии (состоянии по умолчанию) — когда активный текстурный юнит = 0