На этом уроке мы рассмотрим инстансинг и его использование в OpenGL.
Инстансинг
Допустим, у нас есть сцена с большим количеством моделей, которые содержат один и тот же набор вершинных данных, но с различными глобальными преобразованиями. Взять, к примеру, сцену, заполненную листьями травы: каждый лист травы — это маленькая модель, состоящая всего из нескольких треугольников. Вы, вероятно, захотите отобразить довольно большое количество подобных объектов, и ваша сцена может заполниться тысячами или десятками тысяч листьев травы, которые предстоит рендерить в каждом кадре. Поскольку каждый лист — это всего лишь набор из нескольких треугольников, то при визуализации он будет отрисовываться практически мгновенно. Однако, тысячи вызовов рендеринга, которые вам придется при этом сделать, резко снизят производительность.
Если бы мы действительно визуализировали такое большое количество объектов, то в коде это выглядело бы примерно так:
1 2 3 4 5 |
for(unsigned int i = 0; i < amount_of_models_to_draw; i++) { DoSomePreparations(); // связываем VAO, связываем текстуры, устанавливаем uniform-переменные и т.д. glDrawArrays(GL_TRIANGLES, 0, amount_of_vertices); } |
При рисовании нескольких экземпляров (или «инстансов») модели, подобно вышеописанной, вы быстро упретесь в узкое место — низкая производительность из-за множественных вызовов функций отрисовки. По сравнению с рендерингом фактических вершин, указание графическому процессору визуализировать ваши вершинные данные с помощью таких функций, как glDrawArrays() или glDrawElements(), съедает довольно много ресурсов, так как прежде, чем OpenGL сможет нарисовать ваши вершинные данные, он должен будет сделать некоторые необходимые действия (например, сообщить графическому процессору, из какого буфера требуется считать данные, где найти атрибуты вершин — и всё это по относительно медленной шине CPU-GPU). Таким образом, даже если рендеринг ваших вершин происходит очень быстро, то отдача графическому процессору соответствующих команд рендеринга — нет.
Было бы гораздо удобнее, если бы мы могли единожды отправить данные на графический процессор, а затем скомандовать OpenGL нарисовать несколько объектов, используя вышеописанные данные с помощью лишь одного вызова функции отрисовки. И это всё можно сделать с помощью инстансинга!
Инстансинг (англ. «instancing») — это метод, при котором мы рисуем сразу несколько экземпляров объекта с помощью одного вызова рендеринга, экономя на взаимодействии CPU -> GPU. Для рендеринга с помощью метода инстансинга всё, что нам нужно сделать — это изменить вызовы функций рендеринга glDrawArrays() и glDrawElements() на glDrawArraysInstanced() и glDrawElementsInstanced(), соответственно. Эти instanced-версии классических функций рендеринга принимают дополнительный параметр, называемый числом экземпляров, задающий количество экземпляров, которые необходимо отрендерить. Мы отправляем все необходимые данные на графический процессор один раз, а затем, с помощью одного вызова функции, указываем ему на то, как он должен визуализировать все эти экземпляры. После чего графический процессор визуализирует все экземпляры объекта самостоятельно — без постоянного взаимодействия с центральным процессором.
Сама по себе описываемая функция — бесполезна. Визуализация одного и того же объекта тысячу раз — что нам это дает? Ведь каждый из визуализируемых объектов визуализируется точно так же, как и предыдущий, и, следовательно, в одном и том же месте; т.е. мы видели бы только один объект! По этой причине GLSL добавил в вершинный шейдер еще одну встроенную переменную под названием gl_InstanceID
.
При рисовании с помощью вызовов одной из функций instanced-рендеринга переменная gl_InstanceID
увеличивает свое значение для каждого визуализируемого экземпляра, начиная с 0
. Например, если бы мы визуализировали 43-й экземпляр объекта, то переменная вершинного шейдера gl_InstanceID
имела бы значение 42
. Наличие уникального значения для каждого экземпляра объекта предполагает, что теперь мы можем, например, использовать массив значений координат, чтобы расположить каждый экземпляр объекта в нужных нам (в идеале, отличающихся) местах глобального пространства.
Чтобы получить представление об instanced-методе отображения объектов, мы рассмотрим простой пример, который всего одним вызовом рендеринга визуализирует 100 штук 2D-прямоугольников в нормализованных координатах устройства. А осуществим мы это через указание уникальной позиции каждого экземпляра прямоугольника путем индексирования uniform-массива из 100 векторов смещения. В результате получается аккуратно организованная сетка прямоугольников, заполняющих всё окно:
Каждый прямоугольник состоит из двух треугольников, общее число вершин при этом равно 6. Каждая вершина содержит 2D-вектор NDC положения и цветовой вектор. Ниже приведены вершинные данные, используемые для примера — треугольники достаточно малы, чтобы должным образом в количестве 100 штук поместиться на экране:
1 2 3 4 5 6 7 8 9 10 |
float quadVertices[] = { // координаты // цвет -0.05f, 0.05f, 1.0f, 0.0f, 0.0f, 0.05f, -0.05f, 0.0f, 1.0f, 0.0f, -0.05f, -0.05f, 0.0f, 0.0f, 1.0f, -0.05f, 0.05f, 1.0f, 0.0f, 0.0f, 0.05f, -0.05f, 0.0f, 1.0f, 0.0f, 0.05f, 0.05f, 0.0f, 1.0f, 1.0f }; |
Окрашивание прямоугольников происходит во фрагментном шейдере, который получает цветовой вектор от вершинного шейдера и устанавливает его в качестве выходных данных:
1 2 3 4 5 6 7 8 9 |
#version 330 core out vec4 FragColor; in vec3 fColor; void main() { FragColor = vec4(fColor, 1.0); } |
Нового здесь ничего нет, а вот в вершинном шейдере появляются некоторые интересные вещи:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#version 330 core layout (location = 0) in vec2 aPos; layout (location = 1) in vec3 aColor; out vec3 fColor; uniform vec2 offsets[100]; void main() { vec2 offset = offsets[gl_InstanceID]; gl_Position = vec4(aPos + offset, 0.0, 1.0); fColor = aColor; } |
В нем мы определили uniform-массив offset
, который содержит в общей сложности 100 векторов смещения. Внутри вершинного шейдера мы получаем вектор смещения для каждого экземпляра объекта, индексируя массив offset
с помощью gl_InstanceID
. Если бы мы сейчас с помощью instanced-метода нарисовали 100 прямоугольников, то получили бы 100 прямоугольников в различных местоположениях окна.
Теперь, прежде чем мы войдем в цикл рендеринга, нам нужно будет установить смещение координат, которые мы вычисляем во вложенном цикле for:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
glm::vec2 translations[100]; int index = 0; float offset = 0.1f; for(int y = -10; y < 10; y += 2) { for(int x = -10; x < 10; x += 2) { glm::vec2 translation; translation.x = (float)x / 10.0f + offset; translation.y = (float)y / 10.0f + offset; translations[index++] = translation; } } |
Как вы можете видеть, мы создаем набор из 100 векторов трансляций, содержащий вектор смещения для каждой ячейки сетки 10×10. В дополнение к созданию массива translations
нам также потребуется перенести данные в uniform-массив вершинного шейдера:
1 2 3 4 5 |
shader.use(); for(unsigned int i = 0; i < 100; i++) { shader.setVec2(("offsets[" + std::to_string(i) + "]")), translations[i]); } |
В этом фрагменте мы преобразуем переменную-счетчик i
цикла for в тип string, чтобы динамически создать строку для запроса местоположения uniform-переменной. Затем для каждого элемента в uniform-массиве offsets
мы устанавливаем соответствующий вектор трансляции.
Теперь, когда все приготовления закончены, мы можем начать визуализацию прямоугольников. Для отображения объектов методом instanced-рендеринга мы вызываем функцию glDrawArraysInstanced() или glDrawElementsInstanced(). Поскольку мы не используем индексный буфер, то вызовем instanced-версию функции glDrawArrays():
1 2 |
glBindVertexArray(quadVAO); glDrawArraysInstanced(GL_TRIANGLES, 0, 6, 100); |
Параметры функции glDrawArraysInstanced() точно такие же, как и у функции glDrawArrays(), за исключением последнего аргумента, который задает количество визуализируемых экземпляров. Поскольку мы хотим отобразить 100 прямоугольников, расположенных в ячейках сетки размером 10×10, то устанавливаем значение последнего аргумента функции, равным 100
. Теперь запуск кода должен дать уже знакомое нам изображение 100 цветных прямоугольников.
Массивы и инстансинг
Хотя в предыдущем примере всё прекрасно работало, но всякий раз, когда мы визуализируем больше, чем 100 экземпляров объекта (что является довольно частым случаем), мы, в конечном итоге, можем достигнуть предела количества uniform-данных, которые можно отправить шейдерам. Чтобы этого не случилось, воспользуемся instanced-массивами. instanced-массив определяется как атрибут вершины (позволяющий хранить гораздо больше данных), который обновляется не в каждой вершине, а в каждом экземпляре.
С помощью вершинных атрибутов в начале каждого запуска вершинного шейдера графический процессор будет извлекать следующий набор атрибутов, принадлежащих текущей вершине. Однако, при определении атрибута вершины в виде instanced-массива, вершинный шейдер обновляет содержимое атрибута вершины только для каждого экземпляра. Это позволяет нам использовать стандартные атрибуты вершин для каждой вершины и instanced-массив для хранения уникальных данных каждого экземпляра.
Для наглядности мы возьмем предыдущий пример и преобразуем uniform-массив в instanced-массив. Нам придется обновить вершинный шейдер, добавив еще один атрибут вершины:
1 2 3 4 5 6 7 8 9 10 11 12 |
#version 330 core layout (location = 0) in vec2 aPos; layout (location = 1) in vec3 aColor; layout (location = 2) in vec2 aOffset; out vec3 fColor; void main() { gl_Position = vec4(aPos + aOffset, 0.0, 1.0); fColor = aColor; } |
Поскольку переменная gl_InstanceID
нам больше не нужна, то можно напрямую использовать атрибут offset
без предварительной индексации uniform-массива.
Из-за того, что instanced-массив является атрибутом вершины так же, как переменные position
и color
, то нам необходимо сохранить его содержимое в вершинном буфере и настроить указатель атрибута. Сначала мы сохраним массив translations
(из предыдущего раздела) в новом буфере:
1 2 3 4 5 |
unsigned int instanceVBO; glGenBuffers(1, &instanceVBO); glBindBuffer(GL_ARRAY_BUFFER, instanceVBO); glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec2) * 100, &translations[0], GL_STATIC_DRAW); glBindBuffer(GL_ARRAY_BUFFER, 0); |
Затем установим указатель вершинного атрибута и задействуем сам атрибут:
1 2 3 4 5 |
glEnableVertexAttribArray(2); glBindBuffer(GL_ARRAY_BUFFER, instanceVBO); glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0); glBindBuffer(GL_ARRAY_BUFFER, 0); glVertexAttribDivisor(2, 1); |
Что делает этот код интересным, так это последняя строка, в которой мы вызываем функцию glVertexAttribDivisor(). Данная функция сообщает OpenGL, когда следует обновлять содержимое вершинного атрибута для перехода к следующему элементу. Первый параметр — это рассматриваемый атрибут вершины, а второй параметр — делитель атрибута. По умолчанию делитель атрибута равен 0
, что позволяет OpenGL обновлять содержимое атрибута вершины при каждой итерации вершинного шейдера. Установив значение данного атрибута равным 1
, мы сообщаем OpenGL, что хотим обновить содержимое вершинного атрибута при визуализации нового экземпляра объекта. Установив значение, равным 2
, мы будем обновлять содержимое каждые 2 экземпляра и т.д. Установив значение делителя атрибута, равным 1
, мы фактически сообщаем OpenGL, что атрибут вершины, имеющий значение location
, равное 2
, является instanced-массивом.
Если бы мы теперь снова визуализировали прямоугольники с помощью glDrawArraysInstanced(), то получили бы следующий результат:
Да, точно такой же результат, как и в предыдущем примере, но теперь с использованием instanced-массивов, благодаря чему мы имеем возможность передавать гораздо больше данных (столько, сколько позволяет память) в вершинный шейдер для отрисовки.
GitHub / Урок №28. Инстансинг в OpenGL — Исходный код №1
Для забавы мы могли бы попробовать плавно уменьшить масштаб каждого прямоугольника от верхнего-правого угла до нижнего-левого, снова используя gl_InstanceID
, потому что… а почему бы и нет?
1 2 3 4 5 6 |
void main() { vec2 pos = aPos * (gl_InstanceID / 100.0); gl_Position = vec4(pos + aOffset, 0.0, 1.0); fColor = aColor; } |
В результате первые экземпляры прямоугольников рисуются чрезвычайно маленькими, и чем дальше мы продвигаемся в процессе рисования экземпляров, тем ближе значение gl_InstanceID
приближается к 100, и, таким образом, тем больше прямоугольники восстанавливают свой первоначальный размер. Используя instanced-массивы вместе с gl_InstanceID
, мы получаем следующую картину:
GitHub / Урок №28. Инстансинг в OpenGL — Исходный код №1а
Несмотря на то, что всё это весело и т.д., описываемые примеры на самом деле не являются хорошими примерами инстансинга. Да, они дают нам небольшой обзор того, как работает инстансинг, но инстансинг получает большую часть своей силы при рисовании огромного количества схожих объектов. По этой причине мы собираемся отправиться в космос.
Астероидное поле
Представьте себе сцену, когда у нас есть одна огромная планета, которая находится в центре большого кольца астероидов. Такое кольцо астероидов может содержать тысячи или десятки тысяч камней, и, как следствие, сцена быстро станет недоступной для рендеринга даже на приличной видеокарте. Рассматриваемый сценарий оказывается особенно полезным для апробации метода instanced-рендеринга, так как все астероиды могут быть представлены с помощью одной модели. Затем каждый отдельный астероид получает свою вариацию из матрицы преобразования, уникальной для каждого астероида.
Чтобы продемонстрировать влияние instanced-рендеринга, мы сначала представим сцену астероидов, парящих вокруг планеты, без instanced-рендеринга. Сцена будет содержать большую модель планеты и большой набор астероидов, которые мы расположим вокруг планеты.
В следующих примерах мы будем загружать модели объектов с помощью загрузчика моделей.
Для достижения нужного нам эффекта мы создадим матрицу преобразования модели для каждого астероида. Сначала матрица преобразования перемещает астероид внутри кольца астероидов, затем мы добавим к смещению небольшое случайное значение, чтобы кольцо выглядело более естественным. При этом мы также применим операции произвольного масштабирования и поворота. Результатом этого будет являться матрица преобразования, перемещающая вокруг планеты каждый астероид, а также придающая ему более естественный и уникальный вид по сравнению с другими астероидами:
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 |
unsigned int amount = 1000; glm::mat4 *modelMatrices; modelMatrices = new glm::mat4[amount]; srand(glfwGetTime()); // инициализируем начальное рандомное значение float radius = 50.0; float offset = 2.5f; for(unsigned int i = 0; i < amount; i++) { glm::mat4 model = glm::mat4(1.0f); // 1. Трансляция: смещение вдоль окружности со значением радиуса в пределах отрезка [-offset, offset] float angle = (float)i / (float)amount * 360.0f; float displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset; float x = sin(angle) * radius + displacement; displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset; float y = displacement * 0.4f; // уменьшаем высоту пояса астероидов displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset; float z = cos(angle) * radius + displacement; model = glm::translate(model, glm::vec3(x, y, z)); // 2. Масштабирование: коэффициент масштабирования от 0.05 до 0.25f float scale = (rand() % 20) / 100.0f + 0.05; model = glm::scale(model, glm::vec3(scale)); // 3. Поворот: добавляем рандомный поворот вокруг случайно выбранного вектора оси float rotAngle = (rand() % 360); model = glm::rotate(model, rotAngle, glm::vec3(0.4f, 0.6f, 0.8f)); // 4. Теперь добавляем к списку матриц modelMatrices[i] = model; } |
Вышеописанный фрагмент кода может показаться немного пугающим, но в его основе мы всего-навсего преобразуем x- и z-координаты астероида, двигая его вдоль окружности с радиусом, определенным переменной radius
, или, проще говоря, случайным образом перемещаем каждый астероид дальше по кругу в интервале значений от -offset
и до offset
. На y-координату мы будем оказывать меньшее влияние, чтобы создать более плоское кольцо астероидов. Затем применим преобразования масштаба и поворота и сохраним полученную матрицу преобразований в modelMatrices
, размер которой равен amount
. Таким образом, будет сгенерировано 1000 матриц модели — по одной на астероид.
После секций загрузки моделей планеты и астероида, а также компиляции набора шейдеров, итоговый код рендеринга примет примерно следующий вид:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// Отрисовка планеты shader.use(); glm::mat4 model = glm::mat4(1.0f); model = glm::translate(model, glm::vec3(0.0f, -3.0f, 0.0f)); model = glm::scale(model, glm::vec3(4.0f, 4.0f, 4.0f)); shader.setMat4("model", model); planet.Draw(shader); // Отрисовка астероидов for(unsigned int i = 0; i < amount; i++) { shader.setMat4("model", modelMatrices[i]); rock.Draw(shader); } |
Сначала мы рисуем модель планеты, немного переместив и отмасштабировав её, чтобы приспособить сцену, а затем рисуем несколько моделей астероидов, количество которых равно значению переменной amount
, которую мы создали ранее. Однако, перед отрисовкой каждого астероида для шейдера необходимо установить соответствующую матрицу преобразования модели.
В результате получается космическая сцена, где мы можем увидеть естественное кольцо астероидов вокруг планеты:
Эта сцена содержит в общей сложности 1001 вызов рендеринга на кадр, из которых 1000 — относятся к модели астероида.
GitHub / Урок №28. Инстансинг в OpenGL — Исходный код №2
Как только мы начнем увеличивать это число, то быстро заметим, что сцена перестает работать плавно, и количество кадров, которые мы можем визуализировать за секунду, резко уменьшается. Другими словами, если устанавливаемое нами значение переменной amount
приближается к 2000, то сцена начинает настолько медленно работать на нашем графическом процессоре, что становится трудно передвигаться.
Теперь давайте попробуем визуализировать ту же сцену, но на этот раз с помощью instanced-рендеринга. Сначала нам нужно немного настроить вершинный шейдер:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#version 330 core layout (location = 0) in vec3 aPos; layout (location = 2) in vec2 aTexCoords; layout (location = 3) in mat4 instanceMatrix; out vec2 TexCoords; uniform mat4 projection; uniform mat4 view; void main() { gl_Position = projection * view * instanceMatrix * vec4(aPos, 1.0); TexCoords = aTexCoords; } |
Мы больше не используем uniform-переменную модели, а вместо этого объявляем mat4 в качестве атрибута вершины, чтобы иметь возможность хранить instanced-массив матриц преобразований. Однако, когда мы в качестве атрибута вершины объявляем тип данных, размер которого больше, чем vec4, то всё начинает работать немного по-другому. Максимальный объем данных, разрешенный для атрибута вершины, равен vec4. Поскольку mat4 — это, в большинстве случаев, 4 переменные типа vec4, то мы должны зарезервировать 4 атрибута вершин для указанной матрицы. Т.к. мы назначили ему местоположение, равное 3, то столбцы матрицы будут иметь расположение атрибутов вершин: 3, 4, 5 и 6.
Затем мы должны установить значение для каждого из указателей атрибутов 4 вершинных атрибутов и настроить их в виде instanced-массивов:
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 |
unsigned int buffer; glGenBuffers(1, &buffer); glBindBuffer(GL_ARRAY_BUFFER, buffer); glBufferData(GL_ARRAY_BUFFER, amount * sizeof(glm::mat4), &modelMatrices[0], GL_STATIC_DRAW); for(unsigned int i = 0; i < rock.meshes.size(); i++) { unsigned int VAO = rock.meshes[i].VAO; glBindVertexArray(VAO); // Вершинные атрибуты std::size_t vec4Size = sizeof(glm::vec4); glEnableVertexAttribArray(3); glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)0); glEnableVertexAttribArray(4); glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(1 * vec4Size)); glEnableVertexAttribArray(5); glVertexAttribPointer(5, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(2 * vec4Size)); glEnableVertexAttribArray(6); glVertexAttribPointer(6, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(3 * vec4Size)); glVertexAttribDivisor(3, 1); glVertexAttribDivisor(4, 1); glVertexAttribDivisor(5, 1); glVertexAttribDivisor(6, 1); glBindVertexArray(0); } |
Обратите внимание, что мы немного схитрили, объявив VAO–переменную класса Mesh как public-переменную вместо private-переменной. Благодаря этому мы смогли получить доступ к вершинному массиву. Это не самое элегантное решение, лишь простая модификация, подходящая для нашего примера. Кроме этого момента, код должен быть понятен. По сути, мы объявляем то, как OpenGL должен интерпретировать буфер для каждого из атрибутов вершин матрицы, и что каждый из этих атрибутов вершин является instanced-массивом.
Далее мы снова берем VAO из массива meshes[]
и, на этот раз, рисуем с помощью функции glDrawElementsInstanced():
1 2 3 4 5 6 7 8 9 |
// Рисуем метеориты instanceShader.use(); for(unsigned int i = 0; i < rock.meshes.size(); i++) { glBindVertexArray(rock.meshes[i].VAO); glDrawElementsInstanced( GL_TRIANGLES, rock.meshes[i].indices.size(), GL_UNSIGNED_INT, 0, amount ); } |
Здесь мы визуализируем то же самое количество астероидов, что и в предыдущем примере, но на этот раз с помощью instanced-рендеринга. Результаты должны быть аналогичными, но, как только мы увеличим количество астероидов, вы ощутите силу instanced-рендеринга. Без него мы смогли плавно визуализировать около 1000-1500 астероидов. С помощью instanced-рендеринга мы можем установить это значение равным 100 000. Это, учитывая то, что модель астероида имеет 576 вершин, будет равно примерно 57 миллионам вершин, нарисованным в каждом кадре без значительного снижения производительности; и только с 2-мя вызовами функции отрисовки:
Это изображение было визуализировано с помощью 100 000 астероидов с радиусом 150.0f
и смещением, равным 25.0f
.
GitHub / Урок №28. Инстансинг в OpenGL — Исходный код №3
Примечание: Для относительно «слабых» компьютеров число астероидов в 100 000 штук может быть слишком большим, поэтому попробуйте настроить их количество, пока не достигнете приемлемой частоты кадров.
Как вы можете видеть, при соответствующем типе окружения instanced-рендеринг может иметь огромное значение для возможностей отрисовки сцены в вашем приложении. По этой причине instanced-рендеринг обычно используется для отображения травы, флоры, частиц и подобных сцен. В основном, любая сцена с большим количеством повторяющихся форм может извлечь выгоду из instanced-рендеринга.
То есть, я так понял для цвета, вершин нормалей и.т.д у нас один VBO , а для instanced массива еще один или как?
Да, все правильно. VBO, хранящий цвета, вершины, нормали и т.д., отвечает за характеристики одного объекта, а VBO для инстансинга хранит в себе смещения (в данном случае) нескольких объектов в мировом пространстве, что с их характеристиками никак не связано.
Добрый день, кажется вышло недоразумение: в исходниках нет obj-файлов для моделей астероида и планеты. Я нашел их здесь: планета — https://learnopengl.com/data/models/planet.zip астероид — https://learnopengl.com/data/models/rock.zip
Добавил к исходникам на GitHub'е недостающие файлы. Чуть позже обновим и Google Drive…
P.S.: Спасибо большое, что указали на ошибку.
Пожалуйста 🙂