Освещение

Это пока черновой вариант. Материал будет дорабатываться в течение семестра.

Модели освещения

Мы можем видеть предметы в реальном мире благодаря освещению: свет отражается от предметов и попадает на сетчатку глаза. В 3D-графике освещение также важно: оно играет ключевую роль в создании реалистичного изображения.

Однако из-за ограничений метода растеризации освещение не возможно рассчитать «в лоб». Приходится разбивать освещение на составные части: прямое освещение источниками света, непрямое освещение (переотражения), тени. Каждая часть рассчитывается по-своему.

В данной главе рассмотрим прямое освещение источниками света.

Источники света

В реальном мире источники света могут иметь довольно сложную форму: направленные (Солнце), точечные (лампочки), линейные (люминесцентные лампы), площадные (светофор, экран монитора) и т.д.

Самые простые для моделирования источники света - направленные и точечные. Направленный источник описывается вектором направления, лучи света движутся параллельно. Точечный источник описывается координатами центра, лучи света движутся из центра во все стороны.

Цвет точки модели $C_p$ собирают из 3х составляющих:

$C_a$ - освещение окружающим светом (ambient)

$C_d$ - диффузное освещение (diffuse)

$C_s$ - бликовое освщение (specular)

Разберем их подробнее.

Освещение окружающим светом

На точку объекта падают не только прямые лучи света от источника света, но и лучи, переотраженные от стен, потолка и других объектов. Учитывать эти лучи можно в разных приближениях. В первом приближении используют константный свет, который характеризуется цветом $L_a$, вектором из 3х компонент $(r, g, b)$.

Часть этого света объект отражает, часть - поглощает. Коэффициенты отражения 3х компонент $(r, g, b)$ задаются вектором $K_a$.

Итого, получаем цвет точки:

Умножение векторов производится поэлементно.

Диффузное освещение

Прямое освещение источником света разбивается на 2 компоненты: диффузную и бликовую, которые соответствуют 2м разным физическим процессам.

При диффузном освещении свет поглощается объектом и переизлучается во все стороны. Интенсивность поглощенного и переизлученного света определяется косинусом угла между нормалью к поверхности и направлением на источник света. Это закон Ламберта (1760).

Здесь:

  • $L_d$ - цвет падающего диффузного света
  • $K_d$ - коэффициенты отражения диффузного света
  • $n$ - нормаль к повехности в точке (единичный вектор)
  • $l$ - направление на источник света (единичный вектор)
  • dot - скалярное произведение векторов

Бликовое (зеркальное) освещение

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

Здесь:

  • $L_s$ - цвет падающего бликового света
  • $K_s$ - коэффициенты отражения бликового света
  • $v$ - направление на виртуальную камеру (единичный вектор)
  • $r$ - отраженный вектор (единичный вектор)
  • $s$ - shininess - скалярный коэффициент, определяющий размер блика

В 1977 Блинн немного доработал модель Фонга. Он предложил ввести полувектор - средний вектор между направлением на виртуальную камеру и направлением на источник света:

Можно заметить, что угол между полувектором $h$ и нормалью $n$ пропорционален углу между $v$ и $r$. Поэтому формулу Фонга можно переписать так:

Затухание

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

Здесь:

  • $d$ - расстояние от точки до источника света
  • $a_0$, $a_1$, $a_2$ - некие коэффициенты

Параметры

Все параметры этой модели можно разбить на логические части.

Параметры источника света:

  • $lp$ - положение точечного источника света
  • $L_a$ - цвет окружающего света
  • $L_d$ - цвет падающего диффузного света
  • $L_s$ - цвет падающего бликового света
  • $a_0$, $a_1$, $a_2$ - коэффициенты затухания

Здравый смысл подсказывает, что $L_d$ и $L_s$ должны быть равны. Но для большей гибкости настройки их разделяют.

В основной программе это можно записать так:

struct LightInfo
{
    glm::vec3 position;
    glm::vec3 ambient;
    glm::vec3 diffuse;
    glm::vec3 specular;
    float a0;
    float a1;
    float a2;
};

LightInfo light;

На языке GLSL так:

struct LightInfo
{
    vec3 pos;
    vec3 La;
    vec3 Ld;
    vec3 Ls;
    float a0;
    float a1;
    float a2;
};

uniform LightInfo light;

Инициализация юниформ-переменной будет выглядеть так:

GLint uniformLoc;

//Переводим положение источника света из мировой СК в СК виртуальной камеры
glm::vec3 lightPosCamSpace = glm::vec3(camera.viewMatrix * glm::vec4(light.position, 1.0));

uniformLoc = glGetUniformLocation(programId, "light.pos");
glUniform3fv(uniformLoc, 1, glm::value_ptr(lightPosCamSpace));

uniformLoc = glGetUniformLocation(programId, "light.La");
glUniform3fv(uniformLoc, 1, glm::value_ptr(light.ambient));

uniformLoc = glGetUniformLocation(programId, "light.Ld");
glUniform3fv(uniformLoc, 1, glm::value_ptr(light.diffuse));

uniformLoc = glGetUniformLocation(programId, "light.Ls");
glUniform3fv(uniformLoc, 1, glm::value_ptr(light.specular));

uniformLoc = glGetUniformLocation(programId, "light.a0");
glUniform1f(uniformLoc, light.a0);

uniformLoc = glGetUniformLocation(programId, "light.a1");
glUniform1f(uniformLoc, light.a1);

uniformLoc = glGetUniformLocation(programId, "light.a2");
glUniform1f(uniformLoc, light.a2);

Параметры материала:

  • $K_a$ - коэффициенты отражения окружающего света
  • $K_d$ - коэффициенты отражения диффузного света
  • $K_s$ - коэффициенты отражения бликового света
  • $s$ - shininess - скалярный коэффициент, определяющий размер блика

Здравый смысл подсказывает, что $K_a$ и $K_d$ должны быть равны. Но для большей гибкости настройки их разделяют.

struct MaterialInfo
{
    glm::vec3 ambient;
    glm::vec3 diffuse;
    glm::vec3 specular;
    float shininess;
};

MaterialInfo material;

На языке GLSL так:

struct MaterialInfo
{
    vec3 Ka;
    vec3 Kd;
    vec3 Ks;
    float s;
};

uniform MaterialInfo material;

Инициализация юниформ-переменной будет выглядеть так:

GLint uniformLoc;

uniformLoc = glGetUniformLocation(programId, "material.Ka");
glUniform3fv(uniformLoc, 1, glm::value_ptr(material.ambient));

uniformLoc = glGetUniformLocation(programId, "material.Kd");
glUniform3fv(uniformLoc, 1, glm::value_ptr(material.diffuse));

uniformLoc = glGetUniformLocation(programId, "material.Ks");
glUniform3fv(uniformLoc, 1, glm::value_ptr(material.specular));

uniformLoc = glGetUniformLocation(programId, "material.s");
glUniform1f(uniformLoc, material.shininess);

Модели интерполяции

Применять вышеприведенную формулу к полигональным 3D-моделям можно по-разному. Рассмотрим несколько моделей интерполяции. Их ещё называют моделями затенения (shading).

Плоское затенение (flat shading)

Эта модель уже вошла в историю. Для каждого треугольника берется нормаль и подставляется в формулу освещения. Треугольник заливается получившимся цветом.

Количество рассчетов пропорционально количеству треугольников. Минус метода - видны стыки между гранями.

Этот метод не реализуется «в лоб» на OpenGL, т.к. грани не обладают собственными атрибутами. Тем не менее плоское затенение можно смоделировать разными способами:

  • Всем вершинам треугольника назначить одинаковые нормали
  • Вычислить нормаль к треугольнику в геометрическом шейдере
  • Использовать метод интерполяции flat для входных во фрагментный шейдер переменных

Модель интерполяции Гуро (Gouraud shading) или повершинное освещение

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

  • В вершинном шейдере нормаль вершины подставляется в формулу освещения
  • Полученный цвет передается на выход из вершинного шейдера
  • На вход фрагментному шейдеру поступает уже интерполированный цвет
  • Фрагментный шейдер копирует цвет на выход

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

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

Преобразуем координаты вершины:

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

Осталось преобразовать только нормаль. Увы, в общем случае нормаль нельзя преобразывать с помощью матрицы $M_{view} * M_{model}$, иначе может нарушиться свойство перпендикулярности. Для нормалей используется отдельная матрица:

В основной программе её можно проинициализировать так:

glm::mat3 normalToCameraMatrix = glm::transpose(glm::inverse(glm::mat3(camera.viewMatrix * modelMatrix)));

GLint uniformLoc = glGetUniformLocation(programId, "normalToCameraMatrix");
glUniform3fv(uniformLoc, 1, glm::value_ptr(normalToCameraMatrix));

Итого, код вычисления цвета будет выглядеть так:

#version 330

uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;

uniform mat3 normalToCameraMatrix;

struct LightInfo
{
    vec3 pos;
    vec3 La;
    vec3 Ld;
    vec3 Ls;
    float a0;
    float a1;
    float a2;
};
uniform LightInfo light;

struct MaterialInfo
{
    vec3 Ka;
    vec3 Kd;
    vec3 Ks;
    float s;
};
uniform MaterialInfo material;

layout(location = 0) in vec3 vertexPosition;
layout(location = 1) in vec3 vertexNormal;

out vec3 color;

void main()
{
    vec4 posCamSpace = viewMatrix * modelMatrix * vec4(vertexPosition, 1.0);
    vec3 normalCamSpace = normalize(normalToCameraMatrix * vertexNormal);

    vec3 lightDirCamSpace = normalize(light.pos - posCamSpace.xyz);

    float distance = length(light.pos - posCamSpace.xyz);
    float attenuation = 1.0 / (light.a0 + light.a1 * distance + light.a2 * distance * distance);

    float NdotL = max(dot(normalCamSpace, lightDirCamSpace), 0.0);

    color = light.La * material.Ka + light.Ld * material.Kd * NdotL * attenuation;

    if (NdotL > 0.0)
    {
        vec3 viewDirection = normalize(-posCamSpace.xyz);
        vec3 halfVector = normalize(lightDirCamSpace + viewDirection);

        float blinnTerm = max(dot(normalCamSpace, halfVector), 0.0);
        blinnTerm = pow(blinnTerm, material.shininess);

        color += light.Ls * material.Ks * blinnTerm * attenuation;
    }

    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition, 1.0);
}

Модель интерполяции Фонга (Phong shading) или пофрагментное освещение

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

В модели Фонга расчет освещения происходит во фрагментном шейдере:

  • Нормаль вершины передается на выход из вершинного шейдера (преобразованная в другую СК)
  • На вход фрагментному шейдеру поступает интерполированная нормаль
  • Во фрагментном шейдере нормаль подставляется в формулу освещения и вычисляется цвет

Сравнение двух моделей интерполяции:

Сравнение двух моделей интерполяции

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

#version 330

uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;

uniform mat3 normalToCameraMatrix;

layout(location = 0) in vec3 vertexPosition;
layout(location = 1) in vec3 vertexNormal;

out vec3 normalCamSpace;
out vec4 posCamSpace;

void main()
{
    posCamSpace = viewMatrix * modelMatrix * vec4(vertexPosition, 1.0);
    normalCamSpace = normalize(normalToCameraMatrix * vertexNormal);

    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition, 1.0);
}

Код фрагментного шейдера:

#version 330

struct LightInfo
{
    vec3 pos;
    vec3 La;
    vec3 Ld;
    vec3 Ls;
    float a0;
    float a1;
    float a2;
};
uniform LightInfo light;

struct MaterialInfo
{
    vec3 Ka;
    vec3 Kd;
    vec3 Ks;
    float s;
};
uniform MaterialInfo material;

in vec3 normalCamSpace;
in vec4 posCamSpace;

out vec4 fragColor;

void main()
{
    vec3 normal = normalize(normalCamSpace);

    vec3 lightDirCamSpace = normalize(light.pos - posCamSpace.xyz);

    float distance = length(light.pos - posCamSpace.xyz);
    float attenuation = 1.0 / (light.a0 + light.a1 * distance + light.a2 * distance * distance);

    float NdotL = max(dot(normal, lightDirCamSpace), 0.0);

    vec3 color = light.La * material.Ka + light.Ld * material.Kd * NdotL * attenuation;

    if (NdotL > 0.0)
    {
        vec3 viewDirection = normalize(-posCamSpace.xyz);
        vec3 halfVector = normalize(lightDirCamSpace + viewDirection);

        float blinnTerm = max(dot(normal, halfVector), 0.0);
        blinnTerm = pow(blinnTerm, material.shininess);

        color += light.Ls * material.Ks * blinnTerm * attenuation;
    }

    fragColor = vec4(color, 1.0);
}

Карта нормалей

Существует метод normal mapping, который позволяет добавить к 3D-модели больше мелких деталей без увеличения числа полигонов. Идея в том, чтобы во фрагментном шейдере немного отклонять интерполированную нормаль на основе данных из специальной текстуры, которая называется картой нормалей.

Компоненты цвета, прочитанного из текстуры, лежат в диапазоне $[0;1]$. Чтобы из цвета декодировать нормаль, нужно компоненты перевести в диапазон $[-1;1]$.

vec3 color = ...
vec3 normal = color * 2.0 - 1.0;

Подробное описание этой метода в отдельной главе.

Множество источников свет

Если в сцене имеется несколько источников света, то свет от них суммируется. Чем больше источников, тем больше вычислений. Существует несколько методов оптимизации рендеринга для большого числа источников света. Они делятся на 2 вида: forward rendering и deferred rendering. Подробное описание этих методов будут в отдельных главах.

В простейшем случае можно передать параметры нескольких источников света в шейдер с помощью массива, и суммировать освещение в цикле:

struct LightInfo
{
    vec3 pos;
    vec3 La;
    vec3 Ld;
    vec3 Ls;
    float a0;
    float a1;
    float a2;
};
uniform LightInfo light[3]; //3 источника света