Освещение в реальном мире — это чрезвычайно сложный процесс, зависящий от большого количества факторов. Причем не все из них можно рассчитать на компьютере из-за ограниченной вычислительной мощности последнего.
Модель освещения Фонга
Освещение в OpenGL основано на использовании упрощенных моделей, которые гораздо легче вычислять, и при этом они выглядят относительно похожими на реальные процессы. Данные модели освещения основаны на физических законах света в том виде, в каком мы его понимаем. Одна из таких моделей носит название модель освещения Фонга (англ. «Phong lighting model»). Основными строительными блоками модели освещения Фонга являются следующие 3 компонента:
Фоновое/Окружающее освещение (англ. «Ambient lighting») — даже когда вокруг всё темным-темно, где-то в мире все равно находится еще один какой-то источник света (луна, огонек вдали и пр.), поэтому объекты почти никогда не бывают полностью темными. Для имитации данного процесса мы используем константу окружающего освещения, которая всегда придает объекту некоторый цвет.
Рассеянное освещение (англ. «Diffuse lighting») — имитирует направленное воздействие источника света на объект. Это самый визуально значимый компонент модели освещения. Чем больше объект обращен к источнику света, тем ярче он становится.
Отраженное/Зеркальное освещение (англ. «Specular lighting») — имитирует яркое пятно света, которое появляется на блестящих предметах. Зеркальные блики больше склонны принимать цвет источника света, нежели цвет объекта.
Ниже вы можете увидеть, как данные компоненты освещения выглядят сами по себе и в сочетании друг с другом:
Чтобы создавать визуально интересные сцены, нам необходимо сымитировать, по крайней мере, все 3 вышеуказанные компоненты освещения. Начнем с самого простого — с фонового освещения.
Фоновое освещение
Свет обычно исходит не от одного, а от множества источников света, расположенных вокруг нас, даже если они не видны сразу. Одним из свойств света является то, что он может распространяться и отражаться во многих направлениях, достигая мест, которые непосредственно не видны; таким образом, свет может отражаться на других поверхностях и оказывать косвенное влияние на освещение объекта. Алгоритмы, которые учитывают данные моменты, называются алгоритмами глобального освещения, но при этом их расчет является довольно-таки сложным и ресурсозатратным процессом.
Поскольку мы не поклонники сложных и дорогостоящих алгоритмов, то начнем с использования очень упрощенной модели глобального освещения, а именно — с фонового освещения. Мы будем использовать небольшой постоянный (светлый) цвет, который добавим к конечному результирующему цвету фрагментов объекта, создавая таким образом эффект присутствия некоторого мягкого падающего света, даже если источника направленного света нет.
Добавить фоновое освещение к сцене очень просто. Мы берем цвет света, умножаем его на небольшой константный коэффициент фонового освещения, затем умножаем результат на цвет объекта и используем его в качестве цвета фрагмента в шейдере объекта куба:
1 2 3 4 5 6 7 8 |
void main() { float ambientStrength = 0.1; vec3 ambient = ambientStrength * lightColor; vec3 result = ambient * objectColor; FragColor = vec4(result, 1.0); } |
Если бы вы сейчас запустили программу, то заметили бы, что первый этап освещения успешно применяется к объекту. Объект достаточно темный, но не полностью, так как задействовано окружающее освещение (обратите внимание, что световой куб не оказывает никакого влияния, потому что мы используем другой шейдер).
Результат примерно следующий:
Рассеянное освещение
Рассеянное освещение само по себе не дает интересных результатов, но оно, тем не менее, оказывает значительное визуальное воздействие на объект. Рассеянное освещение придает объекту тем бОльшую яркость, чем ближе его фрагменты расположены к световым лучам, исходящим от источника света. Чтобы получить лучшее представление о рассеянном освещении, взгляните на следующее изображение:
Слева мы видим источник света с лучом, направленным на одиночный фрагмент объекта. Нам нужно определить, под каким углом луч света падает на фрагмент. Если световой луч перпендикулярен поверхности объекта, то свет оказывает наибольшее воздействие. Для определения угла между лучом света и фрагментом мы используем нечто, называемое нормальным вектором (или «нормалью»), то есть вектор, перпендикулярный поверхности фрагмента (на рисунке он изображен в виде желтой стрелки); к более подробному рассмотрению данного вектора мы вернемся чуть позже. Затем угол между двумя векторами можно легко вычислить с помощью скалярного произведения.
Возможно, вы помните из урока о трансформациях в OpenGL, что чем меньше угол между двумя единичными векторами, тем больше скалярное произведение приближено к значению 1
. Когда угол между обоими векторами равен 90 градусам, то скалярное произведение равно 0
. То же самое относится и к обозначенному на картинке углу θ
: чем больше угол θ
, тем меньшее влияние свет должен оказывать на цвет фрагмента.
Примечание: Обратите внимание, что для получения (только) косинуса угла между обоими векторами мы будем работать с единичными векторами (длиной 1
), поэтому нам нужно убедиться, что все векторы нормализованы, иначе скалярное произведение возвратит больше, чем просто значение косинуса угла.
Полученное таким образом скалярное произведение возвращает число, которое мы можем использовать для вычисления влияния света на цвет фрагмента, в результате чего мы будем иметь фрагменты, освещенность которых зависит от их ориентации к свету.
Для расчета рассеянного освещения нам потребуются:
Нормальный вектор — вектор, перпендикулярный поверхности вершины.
Направленный световой луч — вектор направления, который является вектором разности между положением света и положением фрагмента. Чтобы вычислить этот световой луч, нам нужен вектор положения света и вектор положения фрагмента.
Нормальные векторы
Нормальный вектор — это (единичный) вектор, перпендикулярный поверхности вершины. Поскольку вершина сама по себе не имеет поверхности (это всего лишь отдельно взятая точка в пространстве), то для получения нормального вектора мы сначала используем соседствующие с этой вершины, чтобы вычислить поверхность заданной вершины. Мы можем использовать небольшой трюк, чтобы вычислить нормальные векторы для всех вершин куба с помощью векторного произведения, но поскольку 3D-куб не является объектом сложной формы, то мы можем просто вручную добавить их в данные вершин. Обновленный массив данных вершин:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
float vertices[] = { -0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f, -0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f, -0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f, -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, -0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, -0.5f, 0.5f, 0.5f, -1.0f, 0.0f, 0.0f, -0.5f, 0.5f, -0.5f, -1.0f, 0.0f, 0.0f, -0.5f, -0.5f, -0.5f, -1.0f, 0.0f, 0.0f, -0.5f, -0.5f, -0.5f, -1.0f, 0.0f, 0.0f, -0.5f, -0.5f, 0.5f, -1.0f, 0.0f, 0.0f, -0.5f, 0.5f, 0.5f, -1.0f, 0.0f, 0.0f, 0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.5f, 0.5f, -0.5f, 1.0f, 0.0f, 0.0f, 0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 0.0f, 0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 0.0f, 0.5f, -0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, -0.5f, -0.5f, -0.5f, 0.0f, -1.0f, 0.0f, 0.5f, -0.5f, -0.5f, 0.0f, -1.0f, 0.0f, 0.5f, -0.5f, 0.5f, 0.0f, -1.0f, 0.0f, 0.5f, -0.5f, 0.5f, 0.0f, -1.0f, 0.0f, -0.5f, -0.5f, 0.5f, 0.0f, -1.0f, 0.0f, -0.5f, -0.5f, -0.5f, 0.0f, -1.0f, 0.0f, -0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, -0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, -0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 0.0f }; |
Попробуйте представить себе, что нормали действительно являются векторами, перпендикулярными поверхности каждой плоскости куба (напоминаю, что куб состоит из 6 плоскостей).
Поскольку мы добавили дополнительные данные в массив вершин, то нам необходимо обновить вершинный шейдер куба:
1 2 3 4 |
#version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; ... |
Теперь, когда мы добавили по вектору нормали к каждой из вершин и обновили вершинный шейдер, мы также должны обновить и указатели атрибутов вершин. Обратите внимание, что куб источника света использует тот же самый массив вершин для своих вершинных данных, но шейдер лампы не использует вновь добавленные векторы нормали. Нам не нужно обновлять шейдеры лампы или конфигурации атрибутов, но мы должны, по крайней мере, изменить указатели атрибутов вершин, чтобы отразить изменение размера нового массива вершин:
1 2 |
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); |
Так как в каждой вершине мы будем использовать только первые 3 значения типа float и игнорировать другие 3 значения, то нам достаточно обновить параметр шага до 6-кратного размера float, и всё!
Примечание: Использование вершинных данных, которые не полностью задействуются шейдером лампы, может выглядеть неэффективным. Но они уже сохранены в памяти графического процессора, когда мы загружали данные ящика. Поэтому нам не нужно хранить новые данные в памяти графического процессора. Благодаря этому описанный способ фактически является более эффективным по сравнению с выделением нового VBO специально для лампы.
Все вычисления освещения выполняются во фрагментном шейдере, поэтому нам нужно перенаправить векторы нормалей из вершинного шейдера во фрагментный шейдер. Давайте сделаем это:
1 2 3 4 5 6 7 |
out vec3 Normal; void main() { gl_Position = projection * view * model * vec4(aPos, 1.0); Normal = aNormal; } |
Осталось только объявить соответствующую входную переменную во фрагментном шейдере:
1 |
in vec3 Normal; |
Вычисление цвета рассеянного освещения
Теперь у нас есть по нормальному вектору для каждой вершины, но нет вектора положения света и вектора положения фрагмента. Поскольку положение света является одиночной статической переменной, то мы можем объявить её как uniform-переменную во фрагментном шейдере:
1 |
uniform vec3 lightPos; |
А затем обновлять uniform-переменную в цикле рендеринга (или снаружи цикла, так как она не меняется в каждом кадре). Мы используем вектор lightPos
, объявленный на предыдущем уроке, как местоположение источника рассеянного света:
1 |
lightingShader.setVec3("lightPos", lightPos); |
Тогда последнее, что нам нужно — это фактическое положение фрагмента. Мы собираемся делать все расчеты освещения в мировом пространстве, поэтому необходимо сначала расположить в нем вершину. Мы можем сделать это, умножив атрибут положения вершины только на матрицу модели (а не на матрицы вида и проекции), чтобы преобразовать его в координаты мирового пространства. Это легко можно выполнить в вершинном шейдере, поэтому давайте объявим выходную переменную и вычислим её координаты в мировом пространстве:
1 2 3 4 5 6 7 8 9 |
out vec3 FragPos; out vec3 Normal; void main() { gl_Position = projection * view * model * vec4(aPos, 1.0); FragPos = vec3(model * vec4(aPos, 1.0)); Normal = aNormal; } |
И, наконец, добавим соответствующую входную переменную во фрагментный шейдер:
1 |
in vec3 FragPos; |
Данная входная переменная будет интерполирована из 3 векторов положения треугольника в мировом пространстве, чтобы сформировать вектор FragPos
, который является мировым положением для каждого фрагмента. Теперь, когда все необходимые переменные установлены, мы можем начать расчеты освещения.
Первое, что нам необходимо вычислить — это вектор направления между источником света и положением фрагмента. Из предыдущего урока мы уже знаем, что вектор направления света — это вектор разности между вектором положения света и вектором положения фрагмента. Мы можем легко вычислить эту разницу, вычитая оба вектора друг из друга. Мы также хотим убедиться, что все соответствующие векторы в итоге будут иметь вид единичных векторов, поэтому мы нормализуем как нормальный вектор, так и результирующий вектор направления:
1 2 |
vec3 norm = normalize(Normal); vec3 lightDir = normalize(lightPos - FragPos); |
Примечание: При расчете освещения мы обычно не заботимся о величинах векторов или о их положениях; нас интересуют только их направления. А раз так, то почти все вычисления выполняются с помощью единичных векторов, поскольку это упрощает большинство математических операций (например, получение скалярного произведения). Поэтому при выполнении расчетов освещения проверьте, что вы всегда нормализуете соответствующие векторы, чтобы убедиться, что они действительно являются единичными векторами. Забыть нормализовать вектор — это очень распространенная ошибка.
Далее нам нужно рассчитать воздействие рассеянного света на текущий фрагмент, взяв скалярное произведение между вектором norm
и вектором lightDir
. Затем полученное значение умножается на цвет света, чтобы создать составляющую рассеянного освещения, в результате чего получается, что чем больше угол между обоими векторами, тем более темной будет составляющая рассеянного освещения:
1 2 |
float diff = max(dot(norm, lightDir), 0.0); vec3 diffuse = diff * lightColor; |
Если угол между обоими векторами больше 90 градусов, то результат скалярного произведения фактически станет отрицательным, и мы получим отрицательную составляющую рассеивания. По этой причине мы используем функцию max(), которая возвращает наибольший из двух своих параметров, чтобы убедиться, что компонент рассеивания (и, следовательно, цвета) никогда не станет отрицательным. Освещение для отрицательных цветов на самом деле не определено, поэтому лучше держаться подальше от этого, если только вы не один из тех эксцентричных художников.
Теперь, когда у нас есть как фоновый компонент, так и компонент рассеивания, мы добавляем оба цвета друг к другу и затем умножаем результат на цвет объекта, чтобы получить выходной цвет полученного фрагмента:
1 2 |
vec3 result = (ambient + diffuse) * objectColor; FragColor = vec4(result, 1.0); |
Если ваше приложение (и шейдеры) скомпилированы успешно, то вы должны увидеть что-то вроде следующего:
Вы можете видеть, что при рассеянном освещении куб снова становится похожим на настоящий. Попробуйте визуализировать векторы нормали в своей голове и переместите камеру вокруг куба, чтобы увидеть, что чем больше угол между нормалью и вектором направления света, тем темнее становится фрагмент.
GitHub / Урок №11. Базовое освещение — Исходный код №1
Еще одна вещь
На предыдущем уроке мы переместили нормальный вектор непосредственно из вершинного шейдера во фрагментный шейдер. Однако все вычисления во фрагментном шейдере выполняются в мировом пространстве, поэтому не следует ли нам также преобразовать координаты векторов нормалей в координаты мирового пространства? В принципе да, но простое умножение нормального вектора с матрицей модели в этом случае нам не подойдет.
Прежде всего, нормальные векторы являются векторами только направления и не определяют положение в пространстве. Во-вторых, они не имеют однородной координаты (w-компонента координат вершины). Это означает, что операции трансляции не должны оказывать никакого влияния на данные векторы. Поэтому, если мы хотим перемножить нормальные векторы с матрицей модели, необходимо взять верхнюю левую матрицу 3×3 матрицы модели, тем самым отбрасывая оставшуюся её часть, отвечающую за транслирование объекта (обратите внимание, что мы можем пойти по другому пути, установив w-компоненту вектора нормали, равную значению 0
, и уже затем произвести умножение на матрицу 4×4).
Во-вторых, если бы матрица модели выполняла неоднородное масштабирование, вершины были бы изменены таким образом, что вектор нормали больше не был бы перпендикулярен поверхности. На следующем рисунке показано влияние такой матрицы модели (с неоднородным масштабированием) на вектор нормали:
Всякий раз, когда мы применяем неоднородное масштабирование, то векторы нормали больше не перпендикулярны соответствующей поверхности, что искажает освещение.
Примечание: Однородное масштабирование изменяет только величину нормали, которую легко можно исправить нормализацией, а не её направление.
Трюк исправления данного поведения заключается в использовании другой матрицы модели, специально адаптированной для нормальных векторов. Эта матрица называется нормальной матрицей и использует несколько линейных алгебраических операций, чтобы устранить эффект неправильного масштабирования нормальных векторов.
Нормальную матрицу определяют как «транспонирование матрицы, обратной к матрице размера 3×3, составленной из верхней левой части матрицы модели». Да, это определение выглядит ужасным, и, если вы действительно не понимаете, что оно означает, не волнуйтесь; мы еще не обсуждали обратные и транспонированные матрицы. Обратите внимание, что большинство других источников определяют нормальную матрицу как производную от матрицы модель-вид, но поскольку мы работаем в мировом пространстве (а не в пространстве окна просмотра), то мы будем выводить её из матрицы модели.
Примечание: Матрица модель-вид — это объединение матрицы модели и матрицы вида. Матрица вида определяет положение (расположение и ориентацию) камеры, в то время как матрица модели определяет положение примитивов, которые вы собираетесь нарисовать.
В вершинном шейдере мы можем генерировать нормальную матрицу, используя для этого функции inverse() (взятия обратной матрицы) и transpose() (транспонирования), которые работают с любым типом матриц. Обратите внимание, что мы приводим матрицу к матрице размера 3×3, чтобы убедиться, что она теряет свои свойства трансляции и что её можно умножить на вектор нормали vec3
:
1 |
Normal = mat3(transpose(inverse(model))) * aNormal; |
Примечание: Процесс нахождения обратных матриц является дорогостоящей операцией для шейдеров, поэтому везде, где это возможно, старайтесь избегать выполнения операций подобного типа, поскольку они должны выполняться на каждой вершине вашей сцены. В целях обучения — это нормально, но для эффективного приложения вы, скорее всего, захотите вычислить нормальную матрицу на процессоре и затем отправить её шейдерам через uniform-переменную перед этапом рисования (так же, как и матрицу модели).
В разделе о рассеянном освещении освещение было прекрасным, потому что мы не делали никакого масштабирования на объекте, так что не было необходимости использовать нормальную матрицу, и мы могли бы просто умножить нормали с помощью матрицы модели. Однако, если вы выполняете неоднородное масштабирование, очень важно произвести умножение ваших нормальных векторов на нормальную матрицу.
Отраженное освещение
Если вы еще не устали от всех этих разговоров об освещении, то мы можем перейти к рассмотрению заключительной части модели освещения Фонга, добавив зеркальные блики.
Подобно рассеянному освещению, отраженное освещение основано на векторе направления света и нормальных векторах объекта, но на этот раз оно также зависит и от направления взгляда (например, с какого направления зритель смотрит на фрагмент). Отраженное освещение основано на отражательной способности поверхностей. Если мы думаем о поверхности объекта как о зеркале, то отраженное освещение является самым сильным там, где мы видим свет, отраженный на поверхности. Вы можете увидеть этот эффект на следующем рисунке:
Сначала вычисляется вектор отражения, показывая направление света относительно нормального вектора. Затем мы вычисляем угол между этим вектором отражения и направлением взгляда. Чем меньше угол между ними, тем сильнее воздействие отраженного света. Результирующий эффект заключается в том, что мы видим небольшую подсветку, когда смотрим на направление света, отраженного через поверхность.
Вектор вида — это единственная дополнительная переменная, необходимая нам для отраженного освещения, которую мы можем рассчитать, используя положение зрителя в мировом пространстве и положение фрагмента. Затем мы вычисляем интенсивность отражения, умножаем её на цвет света и добавляем к компонентам фонового и рассеянного освещений.
Примечание: Мы решили проводить наши расчеты освещения в мировом пространстве, но большинство людей, как правило, предпочитают делать это в пространстве окна просмотра. Преимущество пространства окна просмотра заключается в том, что позиция зрителя всегда находится в точке (0,0,0)
, таким образом получая позицию зрителя без каких-либо затрат. Однако я нахожу расчет освещения в мировом пространстве более интуитивно понятным для нашего обучения. Если вы все еще хотите рассчитать освещение в пространстве окна просмотра, то необходимо преобразовать все соответствующие векторы с помощью матрицы вида (не забудьте также изменить нормальную матрицу).
Чтобы получить координаты зрителя в мировом пространстве, мы просто используем вектор положения объекта камеры (который, конечно же, является зрителем). Итак, давайте добавим еще одну uniform-переменную к фрагментному шейдеру и передадим соответствующий вектор положения камеры туда же:
1 |
uniform vec3 viewPos; |
и
1 |
lightingShader.setVec3("viewPos", camera.Position); |
Теперь, когда у нас есть все необходимые переменные, мы можем рассчитать интенсивность отражения. Сначала мы определяем значение интенсивности отражения, чтобы придать отраженному свечению цвет средней яркости, иначе оно будет преобладать:
1 |
float specularStrength = 0.5; |
Если бы мы установили это значение равным 1.0f
, то получили бы действительно яркий компонент отражения, который будет слишком большим для кораллового куба. На следующем уроке мы поговорим о правильной настройке всех этих интенсивностей освещения и о том, как они влияют на внешний вид объектов. Далее мы вычисляем вектор направления взгляда и соответствующий вектор отражения вдоль нормальной оси:
1 2 |
vec3 viewDir = normalize(viewPos - FragPos); vec3 reflectDir = reflect(-lightDir, norm); |
Обратите внимание, что мы используем отрицательный вектор -lightDir
(противоположный вектору lightDir
). Функция reflect() ожидает, что первый вектор будет направлен от источника света к положению фрагмента, но вектор lightDir
в настоящее время указывает в обратном направлении: от фрагмента к источнику света (это зависело от порядка вычитания, когда мы ранее вычисляли вектор lightDir
). Чтобы удостовериться, что мы получили правильный вектор отражения, меняем его направление, ставя знак -
перед lightDir
. Вторым аргументом должен быть нормальный вектор, поэтому мы передаем нормализованный вектор norm
.
Затем нам нужно вычислить отраженную составляющую. Это достигается с помощью следующей формулы:
1 2 |
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32); vec3 specular = specularStrength * spec * lightColor; |
Сначала мы вычисляем скалярное произведение между направлением вида и направлением отражения (и удостоверяемся, что оно неотрицательное), а затем возводим его в 32-ю степень. Данное значение является значением блеска свечения. Чем выше значение блеска объекта, тем в большей степени он отражает свет, а не рассеивает его вокруг, и тем меньше становится свечение самого объекта. Ниже вы можете увидеть изображение, которое показывает визуальное воздействие различных значений блеска:
Мы не хотим, чтобы отраженная составляющая была слишком отвлекающей, поэтому остановили свой выбор на значении 32
. Единственное, что остается сделать — это добавить данный компонент к компонентам фонового и рассеивающего освещений, а затем умножить совокупный результат на цвет объекта:
1 2 |
vec3 result = (ambient + diffuse + specular) * objectColor; FragColor = vec4(result, 1.0); |
Теперь мы рассчитали все компоненты освещения модели освещения Фонга. Исходя из нашей точки обзора, мы должны увидеть что-то вроде следующего:
GitHub / Урок №11. Базовое освещение — Исходный код №2
Раньше для реализации модели освещения Фонга разработчики использовали вершинный шейдер. Преимущество использования освещения в вершинном шейдере заключается в том, что оно намного эффективнее, так как обычно существует гораздо меньше вершин по сравнению с фрагментами, поэтому (дорогостоящие) вычисления освещения выполняются реже. Однако результирующее значение цвета в вершинном шейдере является результирующим цветом освещения только этой вершины, а значения цвета окружающих фрагментов являются результатом интерполированных цветов освещения. В результате освещение было не очень реалистичным, если не использовать большое количество вершин:
Когда модель освещения Фонга реализуется в вершинном шейдере, она называется затенением по Гуро вместо затенения по Фонгу.
Заключение
К этому времени вы уже должны начать понимать, насколько мощны шейдеры. Имея в своем распоряжении небольшой объем информации, шейдеры могут вычислить, как освещение влияет на цвета фрагментов любого выбранного объекта. На следующих уроках мы гораздо глубже рассмотрим, что можно сделать с моделью освещения.
Упражнения
Задание №1
На текущий момент источник света — это скучный статический объект без движения. Попробуйте перемещать источник света по сцене с течением времени, используя функцию sin(), либо cos(). Наблюдение за изменением освещения с течением времени даст вам хорошее представление о том, как работает модель освещения Фонга.
Ответ №1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
int main() { [...] // Цикл рендеринга while(!glfwWindowShouldClose(window)) { // Покадровая логика времени float currentFrame = glfwGetTime(); deltaTime = currentFrame - lastFrame; lastFrame = currentFrame; // Обработка ввода processInput(window); // Очищаем цветовой буфер glClearColor(0.1f, 0.1f, 0.1f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // Изменяем значения положения источника света с течением времени (на самом деле, это можно сделать в любом месте цикла рендеринга, но попробуйте сделать это, например, перед использованием положения источника света) lightPos.x = 1.0f + sin(glfwGetTime()) * 2.0f; lightPos.y = sin(glfwGetTime() / 2.0f) * 1.0f; // Устанавливаем uniform-переменные, рисуем объекты [...] // glfw: обмен содержимым front- и back-буферов. Отслеживание событий ввода/вывода (была ли нажата/отпущена кнопка, перемещен курсор мыши и т.п.) glfwSwapBuffers(window); glfwPollEvents(); } } |
Задание №2
Поиграйте с параметрами силы фонового, рассеянного и отраженного компонентов освещения и посмотрите, как они влияют на результат. Также поэкспериментируйте с коэффициентами блеска. Попытайтесь понять, почему определенные значения имеют соответствующий визуальный результат.
Задание №3
Сделайте затенение по Фонгу в пространстве окна просмотра вместо мирового пространства.
Ответ №3
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
// Вершинный шейдер #version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; out vec3 FragPos; out vec3 Normal; out vec3 LightPos; uniform vec3 lightPos; // определяем uniform-переменную в вершинном шейдере и передаем lightPos пространства окна просмотра во фрагментный шейдер. lightPos сейчас находится в мировом пространстве uniform mat4 model; uniform mat4 view; uniform mat4 projection; void main() { gl_Position = projection * view * model * vec4(aPos, 1.0); FragPos = vec3(view * model * vec4(aPos, 1.0)); Normal = mat3(transpose(inverse(view * model))) * aNormal; LightPos = vec3(view * vec4(lightPos, 1.0)); // конвертируем положение света в мировом пространстве в положение света в пространстве окна просмотра } // Фрагментный шейдер #version 330 core out vec4 FragColor; in vec3 FragPos; in vec3 Normal; in vec3 LightPos; // дополнительная переменная, поскольку нам нужно положение источника света в пространстве окна просмотра uniform vec3 lightColor; uniform vec3 objectColor; void main() { // Фоновое освещение float ambientStrength = 0.1; vec3 ambient = ambientStrength * lightColor; // Рассеянное освещение vec3 norm = normalize(Normal); vec3 lightDir = normalize(LightPos - FragPos); float diff = max(dot(norm, lightDir), 0.0); vec3 diffuse = diff * lightColor; // Отраженное освещение float specularStrength = 0.5; vec3 viewDir = normalize(-FragPos); // зритель всегда находится в точке (0,0,0) в пространстве окна просмотра, поэтому viewDir равен (0,0,0) - позиция => -позиция vec3 reflectDir = reflect(-lightDir, norm); float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32); vec3 specular = specularStrength * spec * lightColor; vec3 result = (ambient + diffuse + specular) * objectColor; FragColor = vec4(result, 1.0); } |
Задание №4
Используйте затенение по Гуро вместо затенения по Фонгу. Если вы всё сделаете правильно, то освещение куба должно немного отличаться (особенно зеркальные блики):
Попытайтесь понять, почему это выглядит так странно.
Ответ №4
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
// Вершинный шейдер #version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; out vec3 LightingColor; // результирующий цвет из вычислений освещения uniform vec3 lightPos; uniform vec3 viewPos; uniform vec3 lightColor; uniform mat4 model; uniform mat4 view; uniform mat4 projection; void main() { gl_Position = projection * view * model * vec4(aPos, 1.0); // Затенение по Гуро vec3 Position = vec3(model * vec4(aPos, 1.0)); vec3 Normal = mat3(transpose(inverse(model))) * aNormal; // Фоновое освещение float ambientStrength = 0.1; vec3 ambient = ambientStrength * lightColor; // Рассеянное освещение vec3 norm = normalize(Normal); vec3 lightDir = normalize(lightPos - Position); float diff = max(dot(norm, lightDir), 0.0); vec3 diffuse = diff * lightColor; // Отраженное освещение float specularStrength = 1.0; // устанавливаем выше, чтобы лучше показать эффект затенения по Гуро vec3 viewDir = normalize(viewPos - Position); vec3 reflectDir = reflect(-lightDir, norm); float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32); vec3 specular = specularStrength * spec * lightColor; LightingColor = ambient + diffuse + specular; } // Фрагментный шейдер #version 330 core out vec4 FragColor; in vec3 LightingColor; uniform vec3 objectColor; void main() { FragColor = vec4(LightingColor * objectColor, 1.0); } /* Итак, что же мы видим? ----- Вы можете увидеть (для себя или на предоставленном изображении) четкое различие между двумя треугольниками в передней части куба. Эта «полоса» видна из-за интерполяции фрагментов. Из примера изображения видно, что справа вверху вершина передней грани куба освещена зеркальными бликами. Так как верхняя правая вершина нижнего правого треугольника освещена, а другие 2 вершины треугольника - нет, то яркие значения интерполируются на другие 2 вершины. Аналогично, то же самое происходит и для верхнего левого треугольника. Поскольку цвета промежуточного фрагмента не передаются напрямую от источника света, а являются результатом интерполяции, то освещение на промежуточных фрагментах и верхнем левом и нижнем правом треугольниках сильно отличаются по яркости, что приводит к появлению видимой полосы между обоими треугольниками. Этот эффект станет более очевидным при использовании более сложных фигур. */ |
"Однако, если вы выполняете неоднородное масштабирование, очень важно произвести умножение ваших нормальных векторов на нормальную матрицу."
Что так же важно, кубы начинают правильно освещаться, если им было предано вращение через model-матрицу, что логочно, так как в противном случае нормали бы не поворачивались всед за кубом.
Здравствуйте, на гитхабе Урок №11. Базовое освещение. Во фрагментном шейдере куба (файл 2.2.basic_lighting.fs) строка 27:
Почему lightDir взят со знаком "-"? Это случайно не ошибка, это ведь по идее инвертирует блик на обратную сторону куба и блик будет отображаться на неосвещённой стороне куба?
Выше можно найти ответ на ваш вопрос:
"Обратите внимание, что мы используем отрицательный вектор -lightDir (противоположный вектору lightDir). Функция reflect() ожидает, что первый вектор будет направлен от источника света к положению фрагмента, но вектор lightDir в настоящее время указывает в обратном направлении: от фрагмента к источнику света (это зависит от порядка вычитания, когда мы ранее вычисляли вектор lightDir). Чтобы удостовериться, что мы получили правильный вектор отражения, меняем его направление, ставя знак — перед lightDir. "
Да, я это понимаю, но поэтому и спрашиваю, если я оставляю -lightDir отрицательным, то блик отображается там где его быть не должно, то есть ровно в противоположном месте тому где он должен быть. От этого и возникает непонимание!
У меня — наоборот. Если я изменяю с -lightDir на lightDir, то блик начинает отображаться только на дальней относительно источника света грани. А вот с исходным -lightDir как раз всё нормально.