Урок №27. Геометрические шейдеры в OpenGL

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

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

 793

Между этапами вершинного и фрагментного шейдеров существует ещё один дополнительный этап графического конвейера под названием геометрический шейдер.

Геометрические шейдеры

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

Давайте рассмотрим нижеследующий пример геометрического шейдера:

В начале необходимо объявить тип примитивов входных данных, которые мы получим от вершинного шейдера. Для этого перед ключевым словом in используется квалификатор layout. Аргументы данного квалификатора могут принимать любое из следующих значений:

   points — при визуализации примитивов GL_POINTS (1).

   lines — при визуализации примитивов GL_LINES или GL_LINE_STRIP (2).

   lines_adjacency GL_LINES_ADJACENCY или GL_LINE_STRIP_ADJACENCY (4).

   triangles GL_TRIANGLES, GL_TRIANGLE_STRIP или GL_TRIANGLE_FAN (3).

   triangles_adjacency GL_TRIANGLES_ADJACENCY или GL_TRIANGLE_STRIP_ADJACENCY (6).

Это практически весь список примитивов, которые мы можем использовать в вызовах функций рендеринга, подобных glDrawArrays(). Если бы мы решили визуализировать вершины в виде GL_TRIANGLES, то должны были бы выбрать аргумент triangles. Число в скобках определяет минимальное количество вершин, которое содержит отдельно взятый примитив.

Нам также нужно указать тип примитива, который будет выводиться геометрическим шейдером, с помощью квалификатора layout перед ключевым словом out. Как и в случае с входными данными, квалификатор выходных данных может принимать несколько различных значений типов примитивов:

   points

   line_strip

   triangle_strip

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

Геометрический шейдер также ожидает, что мы установим максимальное количество вершин, которые ему разрешено выводить (если вы превысите это число, OpenGL не будет рисовать дополнительные вершины), что мы также можем сделать внутри квалификатора layout ключевого слова out. Конкретно в этом случае мы собираемся отобразить объект line_strip с максимальным количеством вершин равным 2.

Примечание: Если вам интересно, что такое line_strip, то это — непрерывная линия (или «полоса»), связывающая вместе набор, состоящий из, как минимум, 2-х точек. Каждая дополнительная точка приводит к созданию нового участка линии между дополнительной и предыдущей точками. Ниже представлено изображение такой линии, построенной с использованием 5 точек:

Чтобы получить более содержательные для нас результаты, необходимо придумать какой-нибудь способ достучаться до выходных данных предыдущего шейдерного этапа. Можете не ломать голову — GLSL предоставляет нам встроенную переменную под названием gl_in, внутреннее представление которой (вероятно) будет иметь следующий вид:

В данном примере она объявлена в виде интерфейсного блока, содержащего несколько интересных переменных, из которых наиболее примечательной для нас является переменная gl_Position, содержащая вектор, определяемый нами в качестве выходного значения вершинного шейдера.

Обратите внимание, что переменная gl_in объявлена как массив, поскольку большинство примитивов, используемых при рендеринге, содержат более 1 вершины. Стоит отметить, что геометрический шейдер получает все вершины примитива в виде входных данных.

Используя вершинные данные, прошедшие этап вершинного шейдера, мы можем генерировать новые данные с помощью 2-х функций геометрического шейдера: EmitVertex() и EndPrimitive(). Геометрический шейдер ожидает, что вы создадите или отправите на вывод хотя бы один из примитивов, указанных в качестве выходных данных. В нашем случае мы хотим сгенерировать, по крайней мере, один примитив line_strip:

Каждый раз, когда мы вызываем функцию EmitVertex(), вектор, содержащийся в данный момент в gl_Position, добавляется к выходному примитиву. Затем, при вызове функции EndPrimitive(), все испускаемые для этого примитива вершины объединяются в указанный выходной примитив рендеринга. При многократном вызове функции EndPrimitive() после одного или нескольких вызовов EmitVertex(), может быть сгенерировано несколько примитивов. Конкретно в этом случае, генерируются две новые вершины, немного смещенные относительно исходного положения заданной вершины, а затем вызывается функция EndPrimitive(), объединяя две данные вершины в одну линию из 2-х вершин.

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

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

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

Использование геометрических шейдеров


Чтобы продемонстрировать использование геометрического шейдера, мы визуализируем одну небольшую сцену, в которой отобразим 4 точки на z-плоскости в нормализованных координатах устройства. Координаты точек будут следующими:

Вершинный шейдер должен нарисовать точки на z-плоскости, поэтому для начала мы создадим базовый вершинный шейдер:

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

Создадим VAO и VBO для вершинных данных точек, а затем визуализируем их с помощью функции glDrawArrays():

В результате получается сцена с 4-мя (трудно различимыми) зелеными точками:

А теперь оживим нашу маленькую сцену, добавив к ней магию геометрических шейдеров.

В целях обучения сначала мы создадим так называемый сквозной геометрический шейдер, который в качестве входных данных принимает примитив-точку и без изменений передает её следующему шейдеру:

Алгоритм вышеописанного геометрического шейдера не должен вызывать у вас каких-либо трудностей. Он просто выдает неизмененное положение вершины, которое получил в качестве входных данных, и генерирует примитив-точку.

Геометрический шейдер должен быть скомпилирован и связан с программой точно так же, как и вершинный/фрагментный шейдер, но на этот раз мы создадим шейдер, используя GL_GEOMETRY_SHADER в качестве типа шейдера:

Этот код компиляции шейдера такой же, как и у вершинного/фрагментного шейдера. Обязательно проверьте наличие ошибок компиляции/линкинга!

Если скомпилировать и запустить пример, то вы должны увидеть следующий результат:

Всё то же самое, что и без геометрического шейдера! Да, это может показаться вам немного скучным, но тот факт, что мы смогли нарисовать точки, означает, что геометрический шейдер — работает, так что теперь пришло время для более интересных вещей!

Строим дом

Рисование точек и линий не так уж интересно, поэтому мы снова проявим творческий подход и, используя геометрический шейдер, в месте расположения каждой точки нарисуем дом. Это можно сделать, установив тип выходных данных геометрического шейдера в triangle_strip и нарисовав в общей сложности три треугольника: два для квадратного дома и один для крыши.

triangle_strip — это более эффективный способ нарисовать треугольники с небольшим количеством вершин. После того, как будет нарисован первый треугольник, каждая последующая вершина создает ещё один треугольник (рядом с предыдущим): каждые 3 соседние вершины образуют треугольник. Если у нас есть в общей сложности 6 вершин, которые образуют triangle_strip, то мы получим следующие треугольники:

   (1,2,3)

   (2,3,4)

   (3,4,5)

   (4,5,6)

Для работы с примитивом triangle_strip необходимо задать, по крайней мере, 3 вершины. При этом будет сгенерировано N-2 треугольника; таким образом, используя 6 вершин, мы создали 6 - 2 = 4 треугольника. Обратите внимание на следующий рисунок:

Устанавливая тип выходных данных геометрического шейдера как triangle_strip, мы можем с лёгкостью нарисовать дом нужной нам формы, создав в необходимой последовательности 3 прилегающих друг к другу треугольника. На следующем рисунке показано, в каком порядке нам требуется нарисовать вершины, чтобы получить искомые треугольники (синяя точка используется в качестве примитива входных данных):

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

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

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

Обновленные вершинные данные приведены ниже:

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

А далее нам необходимо объявить тот же самый интерфейсный блок (но с другим именем интерфейса) в геометрическом шейдере:

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

Примечание: Нам не обязательно использовать интерфейсные блоки для передачи данных в геометрический шейдер. Мы могли бы написать и следующее:

Данный прием работает, если вершинный шейдер пересылает цветовой вектор в качестве переменной out vec3 outColor. Однако в шейдерах, подобных геометрическому, легче работать именно с интерфейсными блоками. На практике входные данные геометрических шейдеров могут быть довольно большими, и группировка их в один большой массив интерфейсного блока имеет гораздо больше смысла.

Мы также должны объявить выходной цветовой вектор для следующего этапа (фрагментного шейдера):

Поскольку фрагментный шейдер ожидает получить только отдельно взятый (интерполированный) цвет, то нет смысла пересылать несколько цветов. Таким образом, вектор fColor — это не массив, а один вектор. При испускании вершины она сохранит значение того цвета, которое в данный момент находилось в переменной fColor. Для раскрашивания домов перед испусканием вершины, мы заполним переменную fColor цветом из вершинного шейдера:

Все вновь созданные вершины будут иметь цвет, определяемый последним сохраненным значением цвета переменной fColor, равным цвету входной вершины, определенным в атрибутах вершины. Как вы можете видеть на скриншоте ниже, все дома раскрашены в свой уникальный цвет:

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

Результат:

  Google Drive / Урок №27. Геометрические шейдеры в OpenGL — Исходный код №1

  GitHub / Урок №27. Геометрические шейдеры в OpenGL — Исходный код №1

Используя геометрические шейдеры, вы можете получить креативный результат даже с самыми простыми примитивами. Поскольку фигуры динамически генерируются на сверхбыстром оборудовании вашего графического процессора, то это может быть намного мощнее, чем самостоятельное определение фигур в вершинных буферах. Геометрические шейдеры — это отличный инструмент для простых (часто повторяющихся) фигур, таких как: кубы в воксельном мире или листья травы на большом открытом поле.

Взрывающиеся объекты


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

Самое замечательное в данном эффекте геометрического шейдера то, что он работает на всех объектах, независимо от их сложности.

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

Используя векторное вычитание, мы получаем два вектора a и b, параллельные поверхности треугольника. Вычитание двух векторов друг из друга приводит к вектору, который является разницей этих двух векторов. Поскольку все 3 точки лежат в плоскости треугольника, вычитание любого из его векторов друг из друга приводит к вектору, параллельному плоскости. Обратите внимание, что если бы мы, при использовании векторного произведения на векторах a и b, поменяли бы их местами, то получили бы вектор нормали, указывающий в противоположном направлении. Другими словами, в векторном произведении (функция cross()) важен порядок следования векторов-сомножителей!

Теперь, когда мы знаем, как вычислить вектор нормали, мы можем создать функцию explode(), которая принимает данный нормальный вектор вместе с вектором положения вершины. Функция возвращает новый вектор, перемещающий вектор положения вдоль направления нормального вектора:

Сама функция не должна быть слишком сложной. Функция sin() получает в качестве аргумента uniform-переменную time и возвращает значение между -1.0 и 1.0. Поскольку мы не хотим взрывать объект вовнутрь, то преобразуем диапазон значений sin() в диапазон [0,1]. Затем, полученное значение используется для масштабирования вектора normal, а результирующий вектор direction добавляется к вектору положения.

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

Обратите внимание, что перед испусканием вершины мы также выводим соответствующие координаты текстуры.

Кроме того, не забудьте задать значение uniform-переменной time в вашем OpenGL-коде:

В результате получается 3D-модель, постоянно взрывающая свои вершины с течением времени, после чего она снова возвращается в нормальное состояние.

Посмотреть демо

  Google Drive / Урок №27. Геометрические шейдеры в OpenGL — Исходный код №2

  GitHub / Урок №27. Геометрические шейдеры в OpenGL — Исходный код №2

Визуализация векторов нормали

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

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

На этот раз мы создаем геометрический шейдер, который использует нормали вершин, поставляемые моделью, а не генерирует их самостоятельно. Чтобы приспособиться к масштабированию и вращениям (из-за матрицы вида и модели), мы преобразуем нормали с помощью нормальной матрицы. Геометрический шейдер получает свои векторы положения в виде координат пространства вида, поэтому мы также должны преобразовать координаты векторов нормалей в то же самое пространство. Всё это можно сделать в вершинном шейдере:

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

Содержание геометрических шейдеров, подобных этим, уже должно быть вам понятным. Обратите внимание, что мы умножаем вектор нормали на переменную MAGNITUDE, чтобы ограничить размер отображаемых нормальных векторов (в противном случае, они были бы слишком большими).

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

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

  Google Drive / Урок №27. Геометрические шейдеры в OpenGL — Исходный код №3

  GitHub / Урок №27. Геометрические шейдеры в OpenGL — Исходный код №3

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


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

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

Добавить комментарий

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