Россия и Беларусь начали и продолжают войну против целого народа Украины!

Урок №33. Всенаправленные карты теней в OpenGL

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

  Обновл. 12 Ноя 2021  | 

 4999

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

Динамические тени

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

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

Карта глубины, которая нам нужна, требует рендеринга сцены во всех окружающих точечный источник света направлениях, и поэтому обычная 2D-карта глубины не будет работать; что, если вместо нее использовать кубическую карту? Поскольку кубическая карта может хранить полные данные окружения, задаваемого 6 гранями, то можно визуализировать всю сцену на каждой из граней кубической карты и затем использовать её для выборки значений глубины окружающего точечного источника света пространства.

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

Генерация кубической карты глубины


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

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

Для этого нам потребуется создать кубическую карту:

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

И не забыть установить соответствующие параметры текстуры:

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

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

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

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

Преобразование светового пространства

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

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

Здесь важно отметить параметр «сектора обзора» функции glm::perspective(), который мы определяем значением в 90 градусов. Установив данное значение, мы убеждаемся, что сектор обзора достаточно велик, чтобы заполнить отдельно взятую грань кубической карты так, чтобы все грани правильно прилегали друг к другу своими краями.

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

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

Данные матрицы преобразования будут переданы шейдерам, которые произведут рендеринг значений глубины в кубическую карту.

Рендеринг шейдеров

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

Геометрический шейдер отвечает за преобразование всех вершин мирового пространства в координаты 6 различных световых пространств. По этой причине вершинный шейдер просто преобразует координаты вершин в мировое пространство и направляет их в геометрический шейдер:

Геометрический шейдер в качестве входных данных будет принимать 3 вершины треугольника и uniform-массив матриц преобразования светового пространства. Он отвечает за преобразование координат вершин в световые пространства; вот тут уже становится интереснее.

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

Обозначенный геометрический шейдер относительно прост. Мы берем в качестве входных данных треугольник, а в качестве выходных данных — 6 треугольников (6 * 3 =18 вершин). В функции main() мы перебираем 6 граней кубической карты, указывая каждую грань в качестве выходных данных, сохраняя целочисленное значение граней в переменной gl_Layer. Затем мы генерируем выходные треугольники, преобразуя каждую входную вершину мирового пространства в соответствующее световое пространство путем перемножения FragPos с матрицей преобразования светового пространства грани. Обратите внимание, что мы также отправили результирующую переменную FragPos во фрагментный шейдер для вычисления значения глубины.

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

Фрагментный шейдер принимает в качестве входных данных фрагменты из геометрического шейдера, вектор положения света и значение дальней плоскости пирамиды видимости. Здесь мы берем расстояние между фрагментом и источником света, сопоставляем его с диапазоном [0,1] и записываем его как значение глубины фрагмента.

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

Всенаправленные карты теней

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

Здесь функция RenderScene() визуализирует несколько ящиков, рассеянных вокруг источника света, находящегося в центре сцены, в большой кубической комнате.

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

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

Код модели освещения Блинна-Фонга фрагментного шейдера точно такой же, как и раньше:

Есть несколько тонких различий: код освещения тот же, но теперь у нас есть uniform-переменная типа samplerCube, и функция ShadowCalculation() принимает в качестве аргумента позицию текущего фрагмента вместо позиции фрагмента в световом пространстве. Также мы добавили переменную far_plane пирамиды видимости источника света, которая нам понадобится позже.

Самое большое различие заключается в содержании функции ShadowCalculation(), которая теперь производит выборку значений глубины не из 2D-текстуры, а из кубической карты. Давайте разберем шаг за шагом данную функцию.

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

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

Значение переменной closestDepth в настоящее время находится в диапазоне [0,1], поэтому мы сначала преобразуем его обратно в диапазон [0, far_plane], умножив на значение far_plane:

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

В результате будет возвращено значение глубины в том же (или большем) диапазоне, что и closestDepth.

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

Функция ShadowCalculation() приобретает следующий вид:

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

  GitHub / Урок №33. Всенаправленные карты теней в OpenGL — Исходный код №1

Визуализация кубической карты буфера глубины

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

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

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

Процентно-приближенная фильтрация (PCF) и Всенаправленные карты теней


Поскольку всенаправленные карты теней основаны на тех же принципах, что и традиционные карты теней, то они имеют те же артефакты, зависящие от разрешения. Если вы приблизите изображение, то снова увидите неровные края. Процентно-приближенная фильтрация (сокр. «PCF» от англ. «Percentage-Closer Filtering») позволяет нам сгладить эти неровные края путем фильтрации нескольких выборок вокруг положения фрагмента и усреднения результатов.

Если мы возьмем тот же простой PCF-фильтр из предыдущего урока и добавим третье измерение, то получим:

Этот код не сильно отличается от традиционного кода теневых карт. Мы динамически вычисляем смещения текстурных координат и добавляем их для каждой оси на основе фиксированного числа выборок. Для каждой выборки мы повторяем исходный процесс в направлении смещения от заданной выборки и усредняем результаты в конце.

Тени теперь выглядят более мягкими и гладкими и дают более правдоподобные результаты:

Однако, если мы установим значение выборок samples, равным 4.0, то получим в общей сложности по 64 выборки на каждый фрагмент, что очень много!

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

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

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

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

Результаты обновленного PCF-алгоритма дают столь же хорошие, если не лучшие, результаты «мягких» теней:

  GitHub / Урок №33. Всенаправленные карты теней в OpenGL — Исходный код №2

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

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

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

   Multipass Shadow Mapping With Point Lights — статья по всенаправленным картам теней от ogldev.


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

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

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

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