Урок №14. Источники света в OpenGL

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

  Обновл. 6 Сен 2021  | 

 13090

 ǀ   5 

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

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

Направленный свет

Когда источник света находится далеко, лучи света, исходящие от него, идут почти параллельно друг другу. Можно считать, что все световые лучи исходят из одного и того же направления, независимо от того, где находится объект и/или зритель. Свет от источника света, расположенного бесконечно далеко от объекта, называют направленным светом (англ. «directional light»), поскольку все его световые лучи, падающие на объект, имеют одно и то же направление; оно не зависит от местоположения источника света.

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

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

Можно смоделировать направленный свет, определив вектор направления света вместо вектора положения источника света. Шейдерные вычисления остаются в основном теми же за исключением того, что на этот раз мы непосредственно используем вектор направления света direction вместо вычисления вектора lightDir через вектор положения источника света position:

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

Затем полученный вектор lightDir используется, как и прежде, в вычислениях рассеянной и отраженной составляющих.

Чтобы наглядно продемонстрировать, что направленный свет оказывает одинаковое воздействие на различные объекты, мы обратимся к сцене «контейнерной вечеринки», указанной в конце Урока №8. Системы координат в OpenGL. В случае если вы её пропустили, мы определили 10 различных позиций контейнера:

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

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

Отступление

Мы уже не первый раз передаем векторы положения и направления света как параметры типа vec3, но некоторые люди предпочитают, чтобы все векторы были определены как vec4. При определении векторов положения как vec4 важно задать значение 1.0 для их w-компоненты, чтобы потом к ним корректно применялись операции трансляции и проекции. Однако, при определении вектора направления как vec4 мы не хотим, чтобы применение операции трансляции подействовало и на него (поскольку все они по своей сути просто представляют направления, не более того), поэтому мы определяем их w-компоненту, равную 0.0.

Тогда векторы направления можно представить в следующем виде: vec4(-0.2f, -1.0f, -0.3f, 0.0f). В данном случае подобное представление можно использовать для простой проверки типов света: если w-компонента равна 1.0, то мы работаем с вектором положения света, а если w-компонента равна 0.0, то мы работаем с вектором направления света; поэтому, исходя из вышесказанного, можно скорректировать свои вычисления следующим образом:

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

Направленный свет (продолжение)

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

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

Точечные источники света


Направленный свет отлично подходит для использования в качестве глобального освещения, озаряющего всю сцену, но при этом, мы также хотим, чтобы по всей сцене были разбросаны несколько точечных источников освещения (англ. «point lights»). Точечный свет — это источник света с заданным где-то в глобальном пространстве положением, светящий во всех направлениях, при этом его лучи света исчезают с увеличением расстояния. К таким точечным источникам света можно отнести обычные лампочки или факелы.

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

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

Затухание

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

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

где d — это расстояние от фрагмента до источника света. Далее, для вычисления величины затухания, мы определяем 3 (настраиваемых) параметра: константа Kc, линейный член Kl и квадратичный член Kq.

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

   Линейный член умножается на значение расстояния, тем самым линейно уменьшая интенсивность.

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

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

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

Выбор правильных значений


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

Расстояние Константа Линейный член Квадратичный член
7 1.0 0.7 1.8
13 1.0 0.35 0.44
20 1.0 0.22 0.20
32 1.0 0.14 0.07
50 1.0 0.09 0.032
65 1.0 0.07 0.017
100 1.0 0.045 0.0075
160 1.0 0.027 0.0028
200 1.0 0.022 0.0019
325 1.0 0.014 0.0007
600 1.0 0.007 0.0002
3250 1.0 0.0014 0.000007

Как вы можете видеть, значение постоянного члена Kc для всех вариантов таблицы установлено на уровне 1.0. Линейный член Kl для покрытия больших расстояний задается довольно маленьким значением, а величина квадратичного члена Kq задается при этом еще меньшим значением. Попробуйте немного поэкспериментировать с этими значениями, чтобы увидеть их эффект в вашей реализации. В нашем окружении расстояния от 32 до 100 обычно достаточно для большинства источников света.

Реализация затухания

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

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

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

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

Затем мы используем вычисленное значение затухания в расчетах освещения, умножая его на фоновый, рассеянный и отраженный цвета:

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

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

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

Мы можем видеть, что прямо сейчас освещены только передние контейнеры, а ближайший из них — самый яркий. Ящики в задней части сцены не освещены вообще, так как они находятся слишком далеко от источника света.

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

Прожектор


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

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

Рассмотрим этот рисунок детально:

   LightDir — это вектор, указывающий направление от фрагмента к источнику света.

   SpotDir — это направление, вдоль которого направлен прожектор.

   ϕ (Фи) — это угол отсечки, определяющий радиус прожектора. Всё, что находится за пределами этого угла, не освещается прожектором.

   θ (Тета) — это угол между вектором LightDir и вектором SpotDir. Значение угла θ должно быть меньше значения угла ϕ, чтобы находиться в границах прожектора.

Итак, нам нужно вычислить скалярное произведение (возвращающее косинус угла между двумя единичными векторами) между вектором LightDir и вектором SpotDir и сравнить его с углом отсечки ϕ. Теперь, когда вы понимаете, что такое прожектор, мы создадим его в форме фонарика.

Фонарик

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

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

Далее мы передаем соответствующие значения в шейдер:

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

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

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

Возможно, вы задаетесь вопросом, почему в условии if(theta > light.cutOff) используется знак сравнения > вместо знака <. Разве угол θ не должен быть меньше значения угла отсечки света ϕ, чтобы находиться внутри светового конуса прожектора? Это верно, но не забывайте, что значения углов представлены соответствующими значениями их косинусов: угол в θ градусов представлен значением косинуса данного угла, равным 1.0, а угол 90 градусов представлен значением косинуса соответствующего угла, равным 0.0. Ниже на картинке вы можете это увидеть:

Обратите внимание, что чем ближе значение косинуса к 1.0, тем меньше его угол. Теперь понятно, почему угол θ (переменная theta) должен быть больше, чем угол отсечки ϕ (light.cutOff). Значение отсечки в настоящее время установлено как cos(12.5 градусов) = 0.976, поэтому значения cos(θ), лежащие между 0.976 и 1.0, приведут к тому, что фрагмент будет освещен, находясь внутри светового конуса прожектора.

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

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

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

Сглаженные/Мягкие края освещения

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

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

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

где ϵ (эпсилон) — это разность косинусов между углами внутреннего (ϕ) и внешнего (γ) конусов (ϵ=ϕ−γ). Тогда итоговое значение I будет являться интенсивностью света прожектора в текущем фрагменте.

Немного трудно представить себе, как на самом деле работает эта формула, поэтому давайте исследуем её с несколькими примерами значений:

θ θ в градусах φ (внутренняя обрезка) φ в градусах γ (внешняя обрезка) γ в градусах ε Ι
0.87 30 0.91 25 0.82 35 0.91 — 0.82 = 0.09 (0.87 − 0.82) / 0.09 = 0.56
0.9 26 0.91 25 0.82 35 0.91 — 0.82 = 0.09 (0.9 − 0.82) / 0.09 = 0.89
0.97 14 0.91 25 0.82 35 0.91 — 0.82 = 0.09 (0.97 − 0.82) / 0.09 = 1.67
0.83 34 0.91 25 0.82 35 0.91 — 0.82 = 0.09 (0.83 − 0.82) / 0.09 = 0.11
0.64 50 0.91 25 0.82 35 0.91 — 0.82 = 0.09 (0.64 − 0.82) / 0.09 = -2.0
0.966 15 0.9978 12.5 0.953 17.5 0.9978 — 0.953 = 0.0448 (0.966 − 0.953) / 0.0448 = 0.29

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

Теперь у нас есть значение интенсивности освещенности, которое является отрицательным, если фрагмент находится вне светового конуса прожектора; больше 1.0 — если фрагмент находится внутри внутреннего конуса; и все оставшиеся значения для случаев, когда фрагменты находятся между кромками внутреннего и внешнего световых конусов. Если мы правильно зафиксируем значения, то нам больше не понадобятся условия if/else во фрагментном шейдере, и мы сможем просто умножить компоненты света на вычисленное значение интенсивности:

Обратите внимание, что мы используем функцию clamp(), которая фиксирует диапазон значений своего первого аргумента между значениями 0.0 и 1.0. Это гарантирует, что значения интенсивности не окажутся за пределами диапазона [0, 1].

Убедитесь, что вы добавили переменную outerCutOff в структуру Light и задали в приложении её uniform-значение. Для следующего изображения был использован внутренний угол отсечки 12.5 и внешний угол отсечки 17.5:

  GitHub / Урок №14. Источники света в OpenGL — Исходный код №4

О-о-оо, это уже намного лучше. Такой тип фонарика/прожектора идеально подходит для хоррор-игр.

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

Упражнения

Попробуйте поэкспериментировать со всеми различными типами света и их фрагментными шейдерами. Попробуйте инвертировать некоторые векторы и/или использовать < вместо >. Попытайтесь объяснить получившиеся визуальные эффекты.

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

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

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

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

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

  2. noname:

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

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

      Добрый день.
      Не замечал у себя такого. Какие ОС и IDE/компилятор у вас используются?

    2. ElementaryUnit:

      Если проблема все еще актуальна, то у тебя наверное свет рассчитывается не в мировых координатах, а в координатах камеры (по крайней мере у меня так было). В таком случае все сделай то же самое, только в мировых координатах, или можешь передать в light.position — vec3(0.0f, 0.0f, 0.0f) (так как позиция камеры есть центром координат), а в light.direction — vec3(0.0f, 0.0f, -1.0f) (все так же из-за того, что наша камера есть центром координат, и когда мы двигаем камеру, то направление в нас должно быть всегда одно и то же, а camera.Front изменяет её).

    3. Grave18:

      Если все считается в пространстве view, то обязательно надо домножить на view матрицу, и внимательно смотреть, чтоб cam.Front был c w = 0.0f или vec3.

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

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