Урок №28. Инстансинг в OpenGL

  Дмитрий Бушуев  | 

  Обновл. 24 Июл 2020  | 

 962

 ǀ   4 

В этом уроке мы рассмотрим инстансинг и его использование в OpenGL.

Инстансинг

Допустим, у нас есть сцена с большим количеством моделей, которые содержат один и тот же набор вершинных данных, но с различными глобальными преобразованиями. Взять, к примеру, сцену, заполненную листьями травы: каждый лист травы — это маленькая модель, состоящая всего из нескольких треугольников. Вы, вероятно, захотите отобразить довольно большое количество подобных объектов, и ваша сцена может заполниться тысячами или десятками тысяч листьев травы, которые предстоит рендерить в каждом кадре. Поскольку каждый лист — это всего лишь набор из нескольких треугольников, то при визуализации он будет отрисовываться практически мгновенно. Однако, тысячи вызовов рендеринга, которые вам придется при этом сделать, резко снизят производительность.

Если бы мы действительно визуализировали такое большое количество объектов, то в коде это выглядело бы примерно так:

При рисовании нескольких экземпляров (или «инстансов») модели, подобно вышеописанной, вы быстро упретесь в узкое место производительности из-за множественных вызовов функций отрисовки. По сравнению с рендерингом фактических вершин, указание графическому процессору визуализировать ваши вершинные данные с помощью таких функций, как glDrawArrays() или glDrawElements(), съедает довольно много ресурсов, так как прежде, чем OpenGL сможет нарисовать ваши вершинные данные, он должен будет сделать некоторые необходимые действия (например, сообщить графическому процессору, из какого буфера требуется считать данные, где найти атрибуты вершин — и всё это по относительно медленной шине CPU-GPU). Таким образом, даже если рендеринг ваших вершин происходит очень быстро, то отдача графическому процессору соответствующих команд рендеринга — нет.

Было бы гораздо удобнее, если бы мы могли единожды отправить данные на графический процессор, а затем скомандовать OpenGL нарисовать несколько объектов, используя вышеописанные данные с помощью лишь одного вызова функции отрисовки. И это всё можно сделать с помощью инстансинга!

Инстансинг — это метод, при котором мы рисуем сразу несколько экземпляров объекта с помощью одного вызова рендеринга, экономя на взаимодействии CPU -> GPU. Для рендеринга с помощью метода инстансинга всё, что нам нужно сделать — это изменить вызовы функций рендеринга glDrawArrays() и glDrawElements() на glDrawArraysInstanced() и glDrawElementsInstanced(), соответственно. Эти instanced-версии классических функций рендеринга принимают дополнительный параметр, называемый числом экземпляров, задающий количество экземпляров, которые необходимо отрендерить. Мы отправляем все необходимые данные на графический процессор один раз, а затем, с помощью одного вызова функции, указываем ему на то, как он должен визуализировать все эти экземпляры. После чего графический процессор визуализирует все экземпляры объекта без необходимости постоянного взаимодействия с центральным процессором.

Сама по себе описываемая функция — бесполезна. Визуализация одного и того же объекта тысячу раз — что нам это даёт? Ведь каждый из визуализируемых объектов визуализируется точно так же, как и предыдущий и, следовательно, в одном и том же месте; т.е. мы бы видели только один объект! По этой причине GLSL добавил в вершинный шейдер ещё одну встроенную переменную под названием gl_InstanceID.

При рисовании с помощью вызовов одной из функций instanced-рендеринга, переменная gl_InstanceID увеличивает своё значение для каждого визуализируемого экземпляра, начиная с 0. Например, если бы мы визуализировали 43-й экземпляр объекта, то переменная вершинного шейдера gl_InstanceID имела бы значение 42. Наличие уникального значения для каждого экземпляра объекта предполагает, что теперь мы можем, например, использовать массив значений координат, чтобы расположить каждый экземпляр объекта в нужных нам (в идеале, отличающихся) местах глобального пространства.

Чтобы получить представление об instanced-методе отображения объектов, мы рассмотрим простой пример, который всего одним вызовом рендеринга визуализирует 100 штук 2D-прямоугольников в нормализованных координатах устройства. А осуществим мы это через указание уникальной позиции каждого экземпляра прямоугольника путем индексирования uniform-массива из 100 векторов смещения. В результате получается аккуратно организованная сетка прямоугольников, заполняющих всё окно:

Каждый прямоугольник состоит из 2-х треугольников, общее число вершин при этом равно 6. Каждая вершина содержит 2D-вектор NDC положения и цветовой вектор. Ниже приведены вершинные данные, используемые для примера — треугольники достаточно малы, чтобы должным образом в количестве 100 штук поместиться на экране:

Окрашивание прямоугольников происходит во фрагментном шейдере, который получает цветовой вектор от вершинного шейдера и устанавливает его в качестве выходных данных:

Нового здесь ничего нет, а вот в вершинном шейдере появляются некоторые интересные вещи:

В нем мы определили uniform-массив offset, который содержит в общей сложности 100 векторов смещения. Внутри вершинного шейдера мы получаем вектор смещения для каждого экземпляра объекта, индексируя массив offset с помощью gl_InstanceID. Если бы мы сейчас с помощью instanced-метода нарисовали 100 прямоугольников, то получили бы 100 прямоугольников в различных местоположениях окна.

Теперь, прежде чем мы войдем в цикл рендеринга, нам нужно будет установить смещение координат, которые мы вычисляем во вложенном цикле for:

Как вы можете видеть, мы создаем набор из 100 векторов трансляций, содержащий вектор смещения для каждой ячейки сетки 10×10. В дополнение к созданию массива translations нам также потребуется перенести данные в uniform-массив вершинного шейдера:

В этом фрагменте мы преобразуем переменную-счетчик i цикла for в тип string, чтобы динамически создать строку для запроса местоположения uniform-переменной. Затем, для каждого элемента в uniform-массиве offsets, мы устанавливаем соответствующий вектор трансляции.

Теперь, когда все приготовления закончены, мы можем начать визуализацию прямоугольников. Для отображения объектов методом instanced-рендеринга мы вызываем функцию glDrawArraysInstanced() или glDrawElementsInstanced(). Поскольку мы не используем индексный буфер, то вызовем instanced-версию функции glDrawArrays():

Параметры функции glDrawArraysInstanced() точно такие же, как и у функции glDrawArrays(), за исключением последнего аргумента, который задает количество визуализируемых экземпляров. Поскольку мы хотим отобразить 100 прямоугольников, расположенных в ячейках сетки размером 10×10, то устанавливаем значение последнего аргумента функции равным 100. Теперь, запуск кода должен дать уже знакомое нам изображение 100 цветных прямоугольников.

Массивы и инстансинг


Хотя в предыдущем примере всё прекрасно работало, но всякий раз, когда мы визуализируем больше, чем 100 экземпляров объекта (что является довольно частым случаем), мы, в конечном итоге, достигнем предела количества uniform-данных, которые можно отправить шейдерам. Чтобы этого не случилось, воспользуемся instanced-массивами. instanced-массив определяется как атрибут вершины (позволяющий хранить гораздо больше данных), который обновляется не в каждой вершине, а в каждом экземпляре.

С помощью вершинных атрибутов в начале каждого запуска вершинного шейдера графический процессор будет извлекать следующий набор атрибутов, принадлежащих текущей вершине. Однако, при определении атрибута вершины в виде instanced-массива, вершинный шейдер обновляет содержимое атрибута вершины только для каждого экземпляра. Это позволяет нам использовать стандартные атрибуты вершин для каждой вершины и instanced-массив — для хранения данных, уникальных для каждого экземпляра.

Для наглядности мы возьмем предыдущий пример и преобразуем uniform-массив в instanced-массив. Нам придется обновить вершинный шейдер, добавив ещё один атрибут вершины:

Поскольку переменная gl_InstanceID нам больше не нужна, то можно напрямую использовать атрибут offset без предварительной индексации uniform-массива.

Из-за того, что instanced-массив является атрибутом вершины так же, как переменные position и color, то нам необходимо сохранить его содержимое в вершинном буфере и настроить указатель атрибута. Сначала мы сохраним массив translations (из предыдущего раздела) в новом буфере:

Затем установим указатель вершинного атрибута и задействуем сам атрибут:

Что делает этот код интересным, так это последняя строка, в которой мы вызываем функцию glVertexAttribDivisor(). Данная функция сообщает OpenGL, когда следует обновлять содержимое вершинного атрибута для перехода к следующему элементу. Первый параметр — это рассматриваемый атрибут вершины, а второй параметр — делитель атрибута. По умолчанию делитель атрибута равен 0, что позволяет OpenGL обновлять содержимое атрибута вершины при каждой итерации вершинного шейдера. Установив значение данного атрибута равным 1, мы сообщаем OpenGL, что хотим обновить содержимое вершинного атрибута, при визуализации нового экземпляра объекта. Установив значение равное 2, мы будем обновлять содержимое каждые 2 экземпляра и т.д. Установив значение делителя атрибута равным 1, мы фактически сообщаем OpenGL, что атрибут вершины, имеющий значение расположение атрибута равным 2, является instanced-массивом.

Если бы мы теперь снова визуализировали прямоугольники с помощью glDrawArraysInstanced(), то получили бы следующий результат:

Да, точно такой же результат, как и в предыдущем примере, но теперь с использованием instanced-массивов, благодаря чему мы имеем возможность передавать гораздо больше данных (столько, сколько позволяет память) в вершинный шейдер для отрисовки.

  Google Drive / Урок №28. Инстансинг в OpenGL — Исходный код №1

  GitHub / Урок №28. Инстансинг в OpenGL — Исходный код №1

Для забавы мы могли бы попробовать плавно уменьшить масштаб каждого прямоугольника от верхнего-правого угла до нижнего-левого, снова используя gl_InstanceID, потому что… а почему бы и нет?

В результате первые экземпляры прямоугольников рисуются чрезвычайно маленькими, и чем дальше мы продвигаемся в процессе рисования экземпляров, тем ближе значение gl_InstanceID приближается к 100 и, таким образом, тем больше прямоугольники восстанавливают свой первоначальный размер. Используя instanced-массивы вместе с gl_InstanceID, мы получаем следующую картину:

  Google Drive / Урок №28. Инстансинг в OpenGL — Исходный код №1а

  GitHub / Урок №28. Инстансинг в OpenGL — Исходный код №1а

Несмотря на то, что всё это весело и т.д., описываемые примеры на самом деле не являются хорошими примерами инстансинга. Да, они дают нам небольшой обзор того, как работает инстансинг, но инстансинг получает большую часть своей силы при рисовании огромного количества схожих объектов. По этой причине мы собираемся отправиться в космос.

Астероидное поле

Представьте себе сцену, когда у нас есть одна огромная планета, которая находится в центре большого кольца астероидов. Такое кольцо астероидов может содержать тысячи или десятки тысяч камней и, как следствие, сцена быстро станет недоступной для рендеринга даже на приличной видеокарте. Рассматриваемый сценарий оказывается особенно полезным для апробации метода instanced-рендеринга, так как все астероиды могут быть представлены с помощью одной модели. Затем каждый отдельный астероид получает свою вариацию из матрицы преобразования, уникальной для каждого астероида.

Чтобы продемонстрировать влияние instanced-рендеринга, мы сначала представим сцену астероидов, парящих вокруг планеты, без instanced-рендеринга. Сцена будет содержать большую модель планеты и большой набор астероидов, которые мы расположим вокруг планеты.

В нижеследующих примерах мы будем загружать модели объектов с помощью загрузчика моделей.

Для достижения нужного нам эффекта, создадим матрицу преобразования модели для каждого астероида. Сначала матрица преобразования перемещает астероид внутри кольца астероидов, затем мы добавим к смещению небольшое случайное значение, чтобы кольцо выглядело более естественным. При этом мы также применим операции произвольного масштабирования и поворота. Результатом этого является матрица преобразования, перемещающая вокруг планеты каждый астероид, а также придающая ему более естественный и уникальный вид по сравнению с другими астероидами:

Вышеописанный фрагмент кода может показаться немного пугающим, но в его основе мы всего-навсего преобразуем X и Z координаты астероида вдоль окружности с радиусом, определенным переменной radius, и, случайным образом, перемещаем каждый астероид дальше по кругу в интервале значений от -offset и до offset. На Y-координату мы будем оказывать меньшее влияние, чтобы создать более плоское кольцо астероидов. Затем применим преобразования масштаба и поворота и сохраним полученную матрицу преобразований в modelMatrices, размер которой равен amount. Таким образом, будет сгенерировано 1000 матриц модели — по одной на астероид.

После секций загрузки моделей планеты и астероида, а также компиляции набора шейдеров, итоговый код рендеринга примет примерно следующий вид:

Сначала мы рисуем модель планеты, немного переместив и отмасштабировав её, чтобы приспособить сцену, а затем рисуем несколько моделей астероидов, равных значению переменной amount, которую мы создали ранее. Однако, перед отрисовкой каждого астероида, для шейдера необходимо установить соответствующую матрицу преобразования модели.

В результате получается космическая сцена, где мы можем увидеть естественное кольцо астероидов вокруг планеты:

Эта сцена содержит в общей сложности 1001 вызов рендеринга на кадр, из которых 1000 — относятся к модели астероида.

  Google Drive / Урок №28. Инстансинг в OpenGL — Исходный код №2

  GitHub / Урок №28. Инстансинг в OpenGL — Исходный код №2

Как только мы начнем увеличивать это число, то быстро заметим, что сцена перестает работать плавно, и количество кадров, которые мы можем визуализировать за секунду, резко уменьшается. Другими словами, если устанавливаемое нами значение переменной amount приближается к 2000, то сцена начинает настолько медленно работать на нашем графическом процессоре, что становится трудно передвигаться.

Теперь давайте попробуем визуализировать ту же сцену, но на этот раз с помощью instanced-рендеринга. Сначала нам нужно немного настроить вершинный шейдер:

Мы больше не используем uniform-переменную модели, а вместо этого объявляем mat4 в качестве атрибута вершины, чтобы иметь возможность хранить instanced-массив матриц преобразований. Однако, когда мы в качестве атрибута вершины объявляем тип данных, размер которого больше, чем vec4, то всё начинает работать немного по-другому. Максимальный объем данных, разрешенный для атрибута вершины, равен vec4. Поскольку mat4 — это, в большинстве случаев, 4 переменные типа vec4, то мы должны зарезервировать 4 атрибута вершин для указанной матрицы. Т.к. мы назначили ему местоположение равное 3, то столбцы матрицы будут иметь расположение атрибутов вершин 3, 4, 5 и 6.

Затем мы должны установить значение для каждого из указателей атрибутов 4-х вершинных атрибутов и настроить их в виде instanced-массивов:

Обратите внимание, что мы немного схитрили, объявив VAOпеременную класса Mesh как public-переменную вместо private-переменной. Благодаря этому мы смогли получить доступ к вершинному массиву. Это не самое элегантное решение, лишь простая модификация, подходящая для нашего примера. Кроме этого момента, код должен быть понятен. По сути, мы объявляем то, как OpenGL должен интерпретировать буфер для каждого из атрибутов вершин матрицы, и что каждый из этих атрибутов вершин является instanced-массивом.

Далее мы снова берем VAO из массива meshes[] и, на этот раз, рисуем с помощью функции glDrawElementsInstanced():

Здесь мы визуализируем то же самое количество астероидов, что и в предыдущем примере, но, на этот раз, с помощью instanced-рендеринга. Результаты должны быть аналогичными, но как только мы увеличим количество астероидов, вы ощутите силу instanced-рендеринга. Без него мы смогли плавно визуализировать около 1 000-1 500 астероидов. С помощью instanced-рендеринга мы можем установить это значение равным 100 000. Это, учитывая, что модель астероида имеет 576 вершин, будет равно примерно 57 миллионам вершин, нарисованных в каждом кадре без значительного снижения производительности; и только с 2-мя вызовами функции отрисовки:

Это изображение было визуализировано с помощью 100 000 астероидов с радиусом 150.0f и смещением, равным 25.0f.

  Google Drive / Урок №28. Инстансинг в OpenGL — Исходный код №3

  GitHub / Урок №28. Инстансинг в OpenGL — Исходный код №3

Примечание: На разных компьютерах число астероидов в 100 000 штук может быть немного завышено, поэтому попробуйте настроить значения, пока не достигнете приемлемой частоты кадров.

Как вы можете видеть, при соответствующем типе окружения instanced-рендеринг может иметь огромное значение для возможностей отрисовки сцены в вашем приложении. По этой причине instanced-рендеринг обычно используется для отображения травы, флоры, частиц и подобных сцен. В основном, любая сцена с большим количеством повторяющихся форм может извлечь выгоду из instanced-рендеринга.


Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (4 оценок, среднее: 5,00 из 5)
Загрузка...

Комментариев: 4

  1. Аватар Сергей:

    То есть, я так понял для цвета, вершин нормалей и.т.д у нас один VBO , а для instanced массива еще один или как?

  2. Аватар Арбузик❤❤❤:

    Добрый день, кажется вышло недоразумение: в исходниках нет obj-файлов для моделей астероида и планеты. Я нашел их здесь: планета — https://learnopengl.com/data/models/planet.zip астероид — https://learnopengl.com/data/models/rock.zip

    1. Дмитрий Бушуев Дмитрий Бушуев:

      Добавил к исходникам на GitHub'е недостающие файлы. Чуть позже обновим и Google Drive…
      P.S.: Спасибо большое, что указали на ошибку.

      1. Аватар Арбузик❤❤❤:

        Пожалуйста 🙂

Добавить комментарий для Арбузик❤❤❤ Отменить ответ

Ваш E-mail не будет опубликован. Обязательные поля помечены *