Освещение в значительной степени влияет на реалистичность сцены. В этой статье мы рассмотрим основы освещения WebGL и проведем небольшое сравнение подходов в реализации освещения. По сути, здесь будет изложен материал седьмого урока, но «по-другому». Итак…
Мы видим объект, потому что он отражает свет (желтые стрелки на рисунке слева). Количество отраженного света зависит от направленности поверхности, которая выражена через нормаль (синие стрелки). Нормали, в свою очередь, позволяют нам определить направление отраженного света. Если отраженный свет не направлен в сторону камеры (как в случае с левой гранью), то поверхность будет темной.
В этот раз мы рассмотрим только направленное освещение — то есть освещение, источник которого не имеет координат и, соответственно, не находится внутри сцены; есть лишь направление освещение — прямо как свет Солнца, лучи которого для всех объектов имеют один угол падения.
Нормали
Нормаль — это вектор, который перпендикулярен поверхности, которую мы хотим осветить. Нормали определяют ориентацию поверхности и поэтому занимают центральное место при расчете освещения. Каждая вершина имеет нормаль. Предположим, что у нас есть треугольник с вершинами p0, p1 и p2. Нормаль в вершине p0 будет равна векторному произведению векторов v1 (p0, p1) и v2 (p0, p2). Графически это изображено на рисунке справа. Такие вычисления производятся для каждой вершины.
Но как будет выглядеть нормаль вершины, которая принадлежит нескольким поверхностям? В этом случае нормаль каждой поверхности внесет свой вклад в результирующую нормаль. Рассмотрим эту ситуацию. Предположим, что у нас есть вершина, одновременно принадлежащая поверхности #1 с вычисленной нормалью N1, и поверхности #2 с нормалью N2. Итоговая нормаль вершины в этом случае будет равняться сумме векторов нормалей N1 и N2. Графически эта ситуация изображена на рисунке слева. В итоге формулу для нормали вершины, которая принадлежит i поверхностям, можно определить как N = N1 + N2 + … + Ni.
С нормалями все понятно, можно переходить непосредственно к освещению. И хотя сейчас мы не будем рассматривать точечное освещение, цвет источника освещения и свойства материала, у нас все равно будет небольшая классификация освещения.
Модели отражения света
Модель отражения света определяет, каким образом будет вычисляться результирующий цвет. Моделей отражения довольно много, мы рассмотрим только две из них — модель Ламберта и модель Фонга.
Модель отражения по Ламберту
Эта модель базируется на (внезапно!) законе Ламберта. Такое освещение называют диффузным, и его смысл заключается в том, что падающий свет отражается во всех направлениях (а не только в одном направлении, как в случае расчета бликов, например). Сила освещения зависит исключительно от угла α между вектором падения света L и вектором нормали N. Максимальная сила света будет при перпендикулярном падении света на поверхность и будет убывать с увеличением угла α. Итоговый цвет равен скалярному произведению векторов N и -L (минус говорит о том, что вектор «разворачивается» и идет от поверхности материала к источнику освещения), умноженному на цвет материала. Сюда же добавляется умножение на цвет диффузного освещения, но в нашем случае мы будем использовать просто белый цвет и его можно игнорировать в уравнении:
F = Cm * (-L * N)
где Cm — цвет материала, а L и N — вектора направления освещения и нормали соответственно.
Модель отражения по Фонгу
Модель Фонга является более сложной по сравнению с моделью Ламберта и состоит из трех компонент: фоновое освещение, диффузное освещение и блики, что отражено на следующем изображении из википедии:

Фоновое освещение присутствует в любом уголке сцены и никак не зависит от каких-либо источников света. В реальной жизни сторона объекта, на которую не падают прямые солнечные лучи, все равно не будет полностью темной. За счет многократного отражения лучей света от окружающих объектов какая-то часть освещения попадет и на теневую сторону. В 3D графике рассчитывать все отраженные лучи — очень накладное дело, поэтому есть просто фоновое освещение, которое равномерно «освещает» каждый объект сцены со всех сторон.
Диффузное освещение — тот свет, который зависит от угла между вектором света и нормали и отражается от поверхности во всех направлениях. На эту роль отлично подходит рассмотренная нами ранее модель Ламберта.
Блики (на рисунке слева) имеют наибольшую силу, когда отраженный свет попадает точно в наблюдателя. Представьте себе зеркало. Свет будет ослепляющим, когда будет отражаться точно нам в глаза. Но стоит немного изменить наклон зеркала — и света не будет видно совсем. Как и в случае с зеркалом сила бликов тоже значительно зависит от угла α между вектором отражения света E и вектором наблюдателя R.
Формула для расчета бликов выглядит следующим образом:
F = Cm(R*E)n
где Cm — цвет материала, R — вектор наблюдателя (который соединяет точку, для которой мы рассчитываем цвет, и точку, в которой расположена камера), E — вектор отраженного света и, наконец, n — коэффициент яркости (а если цвет источника освещения отличается от белого, то нужно добавить еще один множитель). В случае, когда нормализованные вектора R и E совпадают, их скалярное произведение будет равняться единице (а единица в любой степени остается единицей) и яркость бликов будет максимальной. С увеличением угла α скалярное произведение векторов начинает стремиться к нулю, а коэффициент яркости дополнительно уменьшает значение произведения. Получается, что чем больше коэффициент яркости, тем меньше по площади и четче будут наши блики.
Методы интерполяции света
В то время, когда модель отражения света описывает, как будет рассчитываться свет на основании нормалей, материала и источника освещения, модель интерполяции света описывает, где будет происходить расчет.
Модель интерполяции Гуро
В модели интерполяции Гуро расчет цвета (с учетом освещения) происходит в вершинном шейдере — то есть итоговый цвет вычисляется для каждой вершины, но не для каждого пикселя. В итоге, так как вся работа по вычислению цвета происходит в вершинном шейдере, фрагментный шейдер принимает лишь одну переменную — итоговый цвет, который равномерно интерполируется между вершинами для каждого пикселя. Интерполяция производится автоматически и не требует никаких действий со стороны программиста.
Модель интерполяции Фонга
Да, Фонг разработал и модель отражения, и модель интерполяции. В этой модели цвет вычисляется для каждого пикселя. В итоге освещение получается более качественным и реалистичным, но и требует, соответственно, больше ресурсов со стороны видеокарты. Нормали по-прежнему рассчитываются в вершинном шейдере, затем равномерно интерполируются для использования в каждом пикселе вершинного шейдера. Кроме нормалей, все остальные вычисления для получения итогового цвета производятся в каждом пикселе, за счет чего и достигается высокое качество освещения.
Переходим к практике
Мы добавим освещение к тору, который мы выгрузили из Blender в прошлый раз. У нас есть две модели отражения и две модели интерполяции. Отсюда мы получаем четыре варианта освещения: Ламберт-Гуро, Ламберт-Фонг, Фонг-Гуро, Фонг-Фонг. Рассмотрим каждую из них по порядку.
Так как мы будем использовать разные наборы шейдеров, нам понадобится несколько программ. Подробнее об использовании программ можно почитать в тринадцатом уроке. В коде для текущего поста обратите внимание на функции createProgram
и changeProgram
, в них нет ничего сложного.
Модель отражения Ламберта + модель интерполяции Гуро
Это один из самых простых случаев освещения. Основная работа происходит в вершинном шейдере — его и рассмотрим:
attribute vec3 aVertexPosition;
attribute vec3 aVertexNormal;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
uniform mat3 uNMatrix;
uniform vec3 uColor;
varying vec4 vFinalColor;
// направление освещения
const vec3 vLightDirection = vec3(2.0, -1.0, -1.0);
void main(void) {
// получаем нормаль с учетом вращения фигуры
vec3 N = normalize(uNMatrix * aVertexNormal);
// нормализованный вектор освещения
vec3 L = normalize(vLightDirection);
// находим силу света по Ламберту
float lambertComponent = max(dot(N, -L), 0.0);
// получаем итоговый цвет из цвета объекта и освещения
vec3 diffuseLight = uColor * lambertComponent;
vFinalColor = vec4(diffuseLight, 1.0);
gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
}
lambertComponent
— это и есть искомый коэффициент освещения. На случай, когда произведение векторов возвращает отрицательное значение, компонент устанавливается в значение 0. После того, как мы рассчитали итоговый цвет с учетом освещения, мы передаем его во фрагментный шейдер в varying-переменной vFinalColor
. Код фрагментного шейдера в данном случае выглядит совсем просто:
varying vec4 vFinalColor;
void main(void) {
gl_FragColor = vFinalColor;
}
Модель отражения Ламберта + модель интерполяции Фонга
Отличие этой модели в том, что мы рассчитываем освещение в каждом пикселе. На этот раз вершинный шейдер получается простым и содержит лишь расчет нормалей, которые затем передаются во фрагментный шейдер в переменной vNormal
:
attribute vec3 aVertexPosition;
attribute vec3 aVertexNormal;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
uniform mat3 uNMatrix;
varying vec3 vNormal;
void main(void) {
// получаем нормаль с учетом вращения фигуры
vNormal = normalize(uNMatrix * aVertexNormal);
gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
}
Вершинный шейдер теперь содержит логику расчета освещения:
uniform vec3 uColor;
varying vec3 vNormal;
// направление освещения
const vec3 vLightDirection = vec3(2.0, -1.0, -1.0);
void main(void) {
// нормализованный вектор освещения
vec3 L = normalize(vLightDirection);
// нормализуем нормаль, переданную из вершинного шейдера
vec3 N = normalize(vNormal);
// находим силу света по Ламберту
float lambertComponent = max(dot(N, -L), 0.0);
// получаем итоговый цвет из цвета объекта и освещения
vec3 diffuseLight = uColor * lambertComponent;
gl_FragColor = vec4(diffuseLight, 1.0);
}
Длина вектора vNormal
в ходе интерполяции может не равняться единице, поэтому нужно нормализовать его перед использованием.
Хотя освещение рассчитывается для каждого пикселя, на нашем торе разница будет практически незаметной по сравнению с вершинным освещением — разве что граница света и тени станет немного четче. Разница между вершинным и попиксельным освещением будет хорошо заметна на модели отражения Фонга, о которой дальше и пойдет речь.
Модель отражения Фонга + модель интерполяции Гуро
Я добавил множество комментариев к вершинному шейдеру, чтобы была понятна каждая строка. Фоновое освещение установлено по нулям — то есть фактически у нас нет фонового освещения и неосвещенные участки будут черными. Можете изменить это значение и посмотреть, как будет выглядеть тор при этом. Код вершинного шейдера:
attribute vec3 aVertexPosition;
attribute vec3 aVertexNormal;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
uniform mat3 uNMatrix;
uniform vec3 uColor;
varying vec4 vFinalColor;
// направление освещения
const vec3 vLightDirection = vec3(2.0, -1.0, -1.0);
// блеск материала
const float shiness = 20.0;
void main(void) {
vec4 vertex = uMVMatrix * vec4(aVertexPosition, 1.0);
// фоновое освещение
vec3 ambientLight = vec3(0.0, 0.0, 0.0);
// блок расчета освещения по Ламберту - диффузное освещение
vec3 N = normalize(uNMatrix * aVertexNormal);
vec3 L = normalize(vLightDirection);
float lambertComponent = max(dot(N, -L), 0.0);
vec3 diffuseLight = uColor * lambertComponent;
// рассчитываем блики...
// так как наша камера в центре системы координат,
// вектор камеры - просто обратный вектор до точки
vec3 eyeVec = -vec3(vertex.xyz);
vec3 R = normalize(eyeVec);
// функция reflect рассчитает за нас отраженный вектор
vec3 E = reflect(L, N);
// сила бликов согласно формуле
float specular = pow(max(dot(E, R), 0.0), shiness);
// наконец, получаем последний компонент - блики
vec3 specularLight = uColor * specular;
// итоговый цвет = фоновое + диффузное + блики
vec3 sumColor = ambientLight + diffuseLight + specularLight;
vFinalColor = vec4(sumColor, 1.0);
gl_Position = uPMatrix * vertex;
}
Вот бы все было таким простым и понятным, как фрагментный шейдер в модели Гуро:
varying vec4 vFinalColor;
void main(void) {
gl_FragColor = vFinalColor;
}
Модель отражения Фонга + модель интерполяции Фонга
В вершинном шейдере мы рассчитываем нормали и положение вершины с учетом трансформаций, которые в дальнейшем пойдут во фрагментный шейдер. У нас нет возможности рассчитать эти значения во фрагментном шейдере, так как расчет использует атрибуты вершин aVertexNormal
и aVertexPosition
, которые доступны только в вершинном шейдере:
attribute vec3 aVertexPosition;
attribute vec3 aVertexNormal;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
uniform mat3 uNMatrix;
varying vec3 vNormal;
varying vec4 vVertex;
void main(void) {
// уже привычная нормаль
vNormal = normalize(uNMatrix * aVertexNormal);
// вершина с учетом трансформаций - можем получить только в вершинном шейдере
vVertex = uMVMatrix * vec4(aVertexPosition, 1.0);
gl_Position = uPMatrix * vVertex;
}
Ну и последний шейдер в этом посте — фрагментный:
precision mediump float;
uniform vec3 uColor;
varying vec4 vVertex;
varying vec3 vNormal;
varying vec4 vFinalColor;
// направление освещения
const vec3 vLightDirection = vec3(2.0, -1.0, -1.0);
const float shiness = 20.0;
void main(void) {
// фоновое освещение
vec3 ambientLight = vec3(0.0, 0.0, 0.0);
// блок расчета освещения по Ламберту - диффузное освещение
vec3 N = normalize(vNormal);
vec3 L = normalize(vLightDirection);
float lambertComponent = max(dot(N, -L), 0.0);
vec3 diffuseLight = uColor * lambertComponent;
// рассчитываем блики...
// так как наша камера в центре системы координат,
// вектор камеры - просто обратный вектор до точки
vec3 eyeVec = -vec3(vVertex.xyz);
vec3 R = normalize(eyeVec);
// функция reflect рассчитает за нас отраженный вектор
vec3 E = reflect(L, N);
// сила бликов согласно формуле
float specular = pow(max(dot(E, R), 0.0), shiness);
// наконец, получаем последний компонент - блики
vec3 specularLight = uColor * specular;
// итоговый цвет = фоновое + диффузное + блики
vec3 sumColor = ambientLight + diffuseLight + specularLight;
gl_FragColor = vec4(sumColor, 1.0);
}
В модели отражения Фонга очень хорошо заметно разницу между вершинным и попиксельным освещением. Сравните картинку слева, где расчет идет в вершинах согласно модели Гуро, и справа, где расчет идет в каждом пикселе:


Что получилось в итоге
Что ж, достаточно теории и картинок — пришло время посмотреть на результат. Выбирайте разные модели освещения и интерполяции и смотрите на эффект. Также обратите внимание на то, как меняются блики на темных и светлых цветах.
Ссылки:
> Нормаль в вершине p0 будет равна скалярному произведению векторов v1 (p0, p1) и v2 (p0, p2).
Векторному. Нормаль — это вектор.
Спасибо, конечно же векторному