Урок №11. Базовое освещение в OpenGL

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

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

 2107

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

Модель освещения Фонга

Освещение в OpenGL основано на использовании упрощенных моделей, которые гораздо легче обсчитывать, и при этом выглядящее относительно похожими на реальные процессы. Данные модели освещения основаны на физических законах света в том виде, в каком мы его понимаем. Одна из таких моделей носит название модель освещения Фонга (англ. «Phong lighting model»). Основными строительными блоками модели освещения Фонга являются следующие 3 компонента:

   Фоновое/Окружающее освещение (англ. «Ambient lighting») — даже когда вокруг всё темным-темно, где-то в мире всё равно находится ещё один какой-то свет (луна, огонёк вдали и пр.), поэтому объекты почти никогда не бывают полностью темными. Для имитации данного процесса мы используем константу окружающего освещения, которая всегда придает объекту некоторый цвет.

   Рассеянное освещение (англ. «Diffuse lighting») — имитирует направленное воздействие источника света на объект. Это самый визуально значимый компонент модели освещения. Чем больше объект обращен к источнику света, тем ярче он становится.

   Отраженное/Зеркальное освещение (англ. «Specular Lighting») — имитирует яркое пятно света, которое появляется на блестящих предметах. Зеркальные блики больше склонны принимать цвет источника света, нежели цвет объекта.

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

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

Фоновое освещение


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

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

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

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

Результат примерно следующий:


Рассеянное освещение

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

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

Возможно, вы помните из урока о трансформациях в OpenGL, что чем меньше угол между двумя единичными векторами, тем больше скалярное произведение приближено к значению 1. Когда угол между обоими векторами равен 90 градусам, скалярное произведение равно 0. То же самое относится и к обозначенному на картинке углу θ: чем больше угол θ, тем меньшее влияние свет должен оказывать на цвет фрагмента.

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

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

Итак, что же нам нужно для расчета рассеянного освещения:

   Нормальный вектор — вектор, перпендикулярный поверхности вершины.

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

Нормальные векторы


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

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

Поскольку мы добавили дополнительные данные в массив вершин, то нам необходимо обновить вершинный шейдер куба:

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

Так как в каждой вершине мы будем использовать только первые 3 значения типа float и игнорировать другие 3 значения, то нам достаточно только обновить параметр шага до 6-кратного размера float, и всё!

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

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

Осталось только объявить соответствующую входную переменную во фрагментном шейдере:

Вычисление цвета рассеянного освещения

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

А затем обновлять uniform-переменную в цикле рендеринга (или снаружи цикла, так как она не меняется в каждом кадре). Мы используем вектор lightPos, объявленный в предыдущем уроке, как местоположение источника рассеянного света:

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

И наконец, добавим соответствующую входную переменную во фрагментный шейдер:

Данная входная переменная будет интерполирована из 3-х векторов положения треугольника в мировом пространстве, чтобы сформировать вектор FragPos, который является мировым положением для каждого фрагмента. Теперь, когда все необходимые переменные установлены, мы можем начать расчеты освещения.

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

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

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

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

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

Если ваше приложение (и шейдеры) скомпилированы успешно, то вы должны увидеть что-то вроде следующего:

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

  Google Drive / Урок №11. Базовое освещение — Исходный код №1

  GitHub / Урок №11. Базовое освещение — Исходный код №1

Одна последняя вещь


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

Прежде всего, нормальные векторы являются векторами только направления и не определяют положение в пространстве. Во-вторых, они не имеют однородной координаты (w-компонента координат вершины). Это означает, что операции трансляции не должны оказывать никакого влияния на данные векторы. Поэтому, если мы хотим перемножить нормальные векторы с матрицей модели, необходимо взять верхнюю левую матрицу 3×3 матрицы модели, тем самым отбрасывая оставшуюся её часть, отвечающую за транслирование объекта (обратите внимание, что мы можем пойти по другому пути, установив w-компоненту вектора нормали равную значению 0 и уже затем произвести умножение на матрицу 4×4).

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

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

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

Нормальную матрицу определяют как «транспонирование матрицы, обратной к матрице размера 3×3, составленной из верхней левой части матрицы модели». Да, это определение выглядит ужасным, и, если вы действительно не понимаете, что оно означает, не волнуйтесь; мы ещё не обсуждали обратные и транспонированные матрицы. Обратите внимание, что большинство других источников определяют нормальную матрицу как производную от матрицы модель-вид (это объединение матрицы модели и матрицы вида. Матрица вида определяет положение (расположение и ориентацию) камеры, в то время как матрица модели определяет положение примитивов, которые вы собираетесь нарисовать), но поскольку мы работаем в мировом пространстве (а не в пространстве окна просмотра), то мы будем выводить её из матрицы модели.

В вершинном шейдере мы можем генерировать нормальную матрицу, используя для этого функции inverse() (взятия обратной матрицы) и transpose() (транспонирования), которые работают с любым типом матриц. Обратите внимание, что мы приводим матрицу к матрице размера 3×3, чтобы убедиться, что она теряет свои свойства трансляции и что её можно умножить с вектором нормали vec3:

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

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

Отраженное освещение

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

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

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

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

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

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

и

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

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

Обратите внимание, что мы используем отрицательный вектор -lightDir (противоположный вектору lightDir). Функция reflect() ожидает, что первый вектор будет направлен от источника света к положению фрагмента, но вектор lightDir в настоящее время указывает в обратном направлении: от фрагмента к источнику света (это зависит от порядка вычитания, когда мы ранее вычисляли вектор lightDir). Чтобы удостовериться, что мы получили правильный вектор отражения, меняем его направление, ставя знак - перед lightDir. Вторым аргументом должен быть нормальный вектор, поэтому мы передаём нормализованный вектор norm.

Затем нам нужно вычислить отраженную составляющую. Это достигается с помощью следующей формулы:

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

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

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

  Google Drive / Урок №11. Базовое освещение — Исходный код №2

  GitHub / Урок №11. Базовое освещение — Исходный код №2

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

Когда модель освещения Фонга реализуется в вершинном шейдере, она называется затенением по Гуро вместо затенения по Фонгу. Обратите внимание, затенение по Фонгу дает гораздо более плавные результаты освещения.

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

Упражнения


Задание №1

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

Ответ №1

Задание №2

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

Задание №3

Сделайте затенение по Фонгу в пространстве окна просмотра вместо мирового пространства.

Ответ №3

Задание №4

Используйте затенение по Гуро вместо затенения по Фонгу. Если вы всё сделаете правильно, то освещение куба должно немного отличаться (особенно зеркальные блики):

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

Ответ №4

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

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

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

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