Урок №38. Отложенное затенение в OpenGL

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

  Обновл. 18 Янв 2022  | 

 6128

 ǀ   1 

На этом уроке мы рассмотрим отложенное затенение в OpenGL.

Отложенное затенение

Способ освещения, который мы использовали в наших сценах до этого момента, называется прямым рендерингом (или «прямым затенением»). Его суть заключается в визуализации и освещении объекта с учетом всех источников света. Данные расчеты необходимо выполнять индивидуально для каждого объекта в сцене. Хотя описанный подход и кажется довольно легким в понимании своей сути и реализации, но при этом он также является и довольно тяжелым в плане производительности, так как для каждого визуализируемого объекта необходимо перебирать все источники света! К тому же, метод прямого рендеринга имеет тенденцию тратить много времени на выполнение фрагментных шейдеров в сложных сценах (например, когда несколько объектов покрывают один и тот же пиксель экрана).

Отложенное затенение (или «отложенный рендеринг») направлено на преодоление этих проблем путем радикального изменения способа визуализации объектов. Это дает нам несколько новых возможностей в плане значительной оптимизации сцен, содержащих большое количество источников света, позволяя визуализировать сотни (или даже тысячи) источников света с приемлемой частотой кадров. Следующее изображение представляет собой сцену с 1847 точечными источниками света, визуализированными с помощью метода отложенного затенения (изображение любезно предоставлено Hannes Nevalainen); то, что было бы невозможно при использовании метода прямого рендеринга:

Отложенное затенение основано на идее, что мы откладываем большую часть тяжелого рендеринга (например, освещение) на более позднюю стадию. Метод состоит из двух проходов (этапов): в первом проходе, называемом геометрическим, мы визуализируем сцену один раз и извлекаем из объектов все виды геометрической информации (векторы положения, векторы цвета, векторы нормалей и/или значения зеркальной составляющей), которые сохраняем в коллекции текстур, называемой G-буфером. Затем геометрическая информация сцены, хранящаяся в G-буфере, будет использоваться для (более сложных) расчетов освещения.

Ниже приведено содержимое G-буфера отдельно взятого кадра:

Далее, для текстур из G-буфера наступает вторая фаза вычислений, называемая проходом освещения, в котором мы визуализируем прямоугольную область (далее — «экранный прямоугольник»), олицетворяющую собой экран монитора (или его часть), и вычисляем освещение сцены для каждого фрагмента, используя геометрическую информацию, хранящуюся в G-буфере; пиксель за пикселем мы перебираем G-буфер. Вместо того, чтобы проходить с каждым объектом весь путь от вершинного шейдера до фрагментного шейдера, мы отодвигаем вычисления над его фрагментами на более позднюю стадию. Расчеты освещения остаются точно такими же, но на этот раз мы берем все необходимые входные переменные из соответствующих текстур G-буфера (и некоторых uniform-переменных), а не из вершинного шейдера.

Следующее изображение прекрасно иллюстрирует процесс отложенного затенения:

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

Однако рассматриваемый способ имеет и некоторые недостатки, поскольку G-буфер требует от нас хранения относительно большого количества данных сцены в его цветовых буферах. Из-за этого идет повышенный расход памяти, т.к. информация о сцене (например, векторы позиции) требуют вычислений более высокой степени точности. Еще один недостаток заключается в том, что вышеупомянутый способ не поддерживает смешивание (так как у нас есть информация только о самом верхнем фрагменте), и больше не работает MSAA. Для решения данных вопросов есть несколько обходных путей, которые мы рассмотрим в конце этого урока.

Заполнение G-буфера (в геометрическом проходе) является не слишком затратной в плане вычислений операцией, поскольку мы храним информацию об объекте (такую, как: позиция, цвет, нормали) непосредственно во фреймбуфере. А если еще задействовать метод одновременного рендеринга в несколько целевых объектов (сокр. «MRT» от англ. «Multiple Render Targets»), то можно сделать всё это за один проход рендеринга.

G-буфер


G-буфер — это собирательный термин для всех текстур, задействованных на этапе прохода освещения и хранящих в себе информацию, так или иначе связанную с освещением. Давайте воспользуемся этим моментом, чтобы кратко рассмотреть все данные, необходимые нам для освещения фрагмента при использовании метода прямого рендеринга:

   3D-вектор позиции (в координатах мирового пространства) для вычисления (интерполированной) переменной позиции фрагмента (в дальнейшем используемой переменными lightDir и viewDir);

   вектор диффузного цвета (RGB), также известный как альбедо;

   3D-вектор нормали для определения наклона поверхности;

   переменная типа float интенсивности зеркальной составляющей;

   все векторы позиции источника света и его цвета;

   вектор позиции игрока или наблюдателя.

Задействовав вышеописанные переменные (каждого фрагмента), мы можем вычислить уже привычное нам освещение (Блинна-) Фонга. Цвета источника света и его позицию, а также позицию направления взгляда наблюдателя можно настроить с помощью uniform-переменных. Если мы каким-то образом можем передать те же самые данные в проход отложенного затенения, то и сможем вычислить те же самые световые эффекты (даже если визуализируем фрагменты 2D-прямоугольника).

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

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

Данные по каждому фрагменту, которые мы должны сохранить, это:

   вектор позиции;

   вектор нормали;

   цветовой вектор;

   значение интенсивности отражения.

На этапе геометрического прохода нам нужно визуализировать все объекты сцены и сохранить эти компоненты в G-буфере. Для этого мы снова можем воспользоваться методом одновременного рендеринга в несколько целевых объектов.

Для геометрического прохода нам нужно будет инициализировать переменную фреймбуфера с названием gBuffer, которая имеет несколько подключенных цветовых буферов и один объект рендербуфера глубины. Для хранения позиций и нормалей объекта мы будем использовать переменную текстуры повышенной точности (каждый компонент будет иметь 16- или 32-битное значение типа float). Для альбедо и значений зеркальной составляющей хватит и стандартной точности (каждый компонент — 8-битное значение). Обратите внимание, что мы используем GL_RGBA16F вместо GL_RGB16F, поскольку графические процессоры обычно предпочитают 4-компонентные форматы графических данных 3-компонентным форматам из-за выравнивания байтов; в противном случае некоторые драйверы могут не завершить построение фреймбуфера.

Поскольку мы задействуем метод одновременного рендеринга в несколько текстур, то с помощью glDrawBuffers должны явно указать OpenGL, в какой из связанных с GBuffer цветовых буферов мы хотели бы рендерить. Также интересно отметить, что здесь мы объединяем данные о цвете и интенсивности отражения в одной RGBA-текстуре; это избавляет нас от необходимости объявлять дополнительную текстуру цветового буфера.

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

Поскольку мы используем несколько целевых объектов рендеринга, то спецификатор layout сообщает OpenGL, в какой цветовой буфер активного фреймбуфера мы будем производить рендеринг. Обратите внимание, что мы не храним значение типа float интенсивности отражения в отдельной текстуре, поскольку мы можем выбрать для этого одну из уже имеющихся текстур цветового буфера.

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

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

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

Этап прохода освещения

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

Для этапа прохода освещения мы собираемся визуализировать экранный 2D-прямоугольник (напоминает эффект постобработки) и для каждого пикселя будем производить дорогостоящий вызов фрагментного шейдера освещения:

Перед рендерингом мы связываем все соответствующие текстуры G-буфера, а также посылаем в шейдер все задействованные при вычислении освещения uniform-переменные.

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

Шейдер освещения принимает три uniform-переменные текстур, представляющие собой G-буфер и содержащие все данные, которые мы сохранили на этапе геометрического прохода. Если мы сэмплируем их с текстурными координатами текущего фрагмента, то получим точно такие же значения фрагмента, как если бы мы визуализировали геометрию напрямую. Обратите внимание, что мы извлекаем как цвет Albedo, так и интенсивность Specular из одной текстуры gAlbedoSpec.

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

Запуск простой демоверсии с общим количеством небольших источников освещения 32 шт. дает следующий результат:

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

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

Совместное использование отложенного и прямого рендеринга


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

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

Что нам нужно сделать, так это сначала скопировать информацию о глубине, сохраненную на этапе геометрического прохода, в буфер глубины заданного по умолчанию фреймбуфера, и только затем визуализировать световые кубы. Таким образом, фрагменты световых кубов визуализируются только тогда, когда они находятся поверх ранее визуализированной геометрии.

Мы можем скопировать содержимое фреймбуфера в содержимое другого фреймбуфера с помощью функции glBlitFramebuffer(). Функция glBlitFramebuffer() позволяет скопировать заданную пользователем область одного фреймбуфера в заданную пользователем область другого фреймбуфера.

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

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

  GitHub / Урок №38. Отложенное затенение в OpenGL — Исходный код №1

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

Увеличиваем количество источников света

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

Обычно, когда мы визуализируем фрагмент в большой освещенной сцене, мы вычисляем вклад каждого источника света в сцене, независимо от его расстояния до фрагмента. Большая часть света от этих источников никогда не достигнет фрагмента, так зачем тратить ресурсы на данные вычисления?

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

Хитрость данного подхода заключается в определении размера или радиуса светового объема источника света.

Вычисление светового объема или радиуса источника света

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

Где:

   I — интенсивность источника света;

   Kc — постоянный коэффициент затухания;

   Kl — линейный коэффициент затухания;

   Kq — квадратичный коэффициент затухания;

   d — расстояние между источником и освещаемым фрагментом.

Нам нужно решить это уравнение для случая, когда переменная Flight равна 0.0. Однако это уравнение никогда не достигнет значения 0.0 (источник перестанет быть источником света), поэтому оно не имеет решения. Следовательно, мы можем решить уравнение не для случая 0.0, а для значения яркости, близкого к 0.0, но все еще воспринимаемого как темное. Значение яркости 5/256 было бы приемлемо для демонстрационной сцены данного урока; разделенное на 256, поскольку 8-битный фреймбуфер по умолчанию может отображать не более 256 различных значений.

Примечание: Используемая функция затухания в видимом диапазоне значений является темной. Если бы мы ограничили её еще меньшим значением, чем 5/256, то световой объем стал бы слишком большим и, следовательно, менее эффективным. Пока пользователь не видит внезапного отключения источника света на границах его объема, всё будет в порядке. Конечно, это всегда зависит от типа сцены; более высокий порог яркости приводит к меньшим объемам света и, следовательно, лучшей эффективности, но может создавать заметные артефакты на границах объема.

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

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

С этого момента идет решение уравнения:

Последнее уравнение представляет собой квадратное уравнение вида ax2 + bx + c = 0, решение которого мы можем записать как:

Это дает нам общее уравнение, которое позволяет вычислить x, т.е. радиус светового объема для источника света, заданного постоянным, линейным и квадратичным параметрами:

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

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

  GitHub / Урок №38. Отложенное затенение в OpenGL — Исходный код №2

Как на самом деле используются световые объемы

Фрагментный шейдер, показанный выше, на самом деле не работает на практике, он только иллюстрирует то, как мы можем использовать световой объем для уменьшения вычислений освещения. Реальность такова, что ваш GPU и GLSL довольно плохо оптимизируют циклы и операции ветвления. Причина этого заключается в том, что выполнение шейдеров на графическом процессоре очень сильно склоняется в сторону параллельных вычислений, и большинство архитектур требуют, чтобы для большого количества потоков использовался один шейдерный код. Часто это означает, что выполняющийся шейдер вычисляет все ветви оператора if, чтобы гарантировать одинаковую работу шейдера для рассматриваемой группы потоков, что делает нашу предыдущую оптимизацию проверки радиуса совершенно бесполезной; мы все равно вычисляем освещение для всех источников света!

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

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

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

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

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

   отложенное освещение;

   отложенное затенение на основе тайлов.

Они еще более эффективны при рендеринге большого количества света, а также позволяют относительно эффективно использовать MSAA.

Отложенный рендеринг vs. Прямой рендеринг


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

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

Стоит отметить, что в основном все эффекты, которые могут быть достигнуты с помощью прямого рендеринга, также могут быть реализованы в контексте отложенного рендеринга; это часто требует лишь небольших изменений. Например, если мы хотим использовать карту нормалей в отложенном рендеринге, то мы должны будем изменить шейдеры геометрического прохода, чтобы отдавать в координатах мирового пространства нормаль, извлеченную из карты нормалей (используя TBN-матрицу), вместо нормали поверхности; вычисления освещения в проходе освещения вообще не нужно менять. Также, если вы хотите, чтобы работал эффект параллакса, вам нужно будет сначала немного изменить координаты текстуры в геометрическом проходе, прежде чем отбирать диффузные, зеркальные и нормальные текстуры объекта. Как только вы поймете идею отложенного рендеринга, вам будет не так уж трудно проявить творческий подход.

Дополнительные ресурсы

   Tutorial 35: Deferred Shading. Part 1 — статья об отложенном затенении от OGLDev.

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

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

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

  1. Сергей Федоров:

    Ух… вроде бы что-то получилось.
    Пришлось повозиться с конвейером фреймбуферов (хотел задействовать в сцене также эффект теней и эффект свечения)
    Результат https://youtu.be/q22h1XFttFE

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

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