Основы освещения

Освещение в значительной степени влияет на реалистичность сцены. В этой статье мы рассмотрим основы освещения 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);
    }

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

Вершинное освещение Попиксельное освещение


Что получилось в итоге

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

Ссылки:

Основы освещения
Метки:    

2 thoughts on “Основы освещения

  • 09.01.2017 at 19:11
    Permalink

    > Нормаль в вершине p0 будет равна скалярному произведению векторов v1 (p0, p1) и v2 (p0, p2).
    Векторному. Нормаль — это вектор.

    Ответить

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

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