Уроки по OpenGL с сайта OGLDev


Урок 18 - Рассеянное освещение

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

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

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

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

Для подсчета интенсивности света в рассеянной модели мы собираемся просто использовать косинус угла между направлением света и нормалью к поверхности (в то время как закон Ламберта относится к более общему понятию "прямо пропорциональности"). Рассмотрим следующее изображение:

Мы видим 4 луча света, которые падают на поверхность под разными углами. Нормаль нарисована зеленым и выходит из поверхности. Луч А имеет наибольшую силу. Угол между А и нормалью - 0 градусов, а косинус 0 - 1. Это значит, что после того, как мы умножим интенсивность света (3 канала от 0 до 1) на цвет поверхности, мы умножим на 1. Мы не можем получить ничего лучше с рассеянным светом. Луч В падает на поверхность под углом от 0 до 90 градусов. Это значит, что угол между В и нормалью так же от 0 до 90, и косинус этого угла от 0 до 1. Мы изменим результат произведения выше на косинус этого угла, что означает, что интенсивность света будет несколько меньше, чем у луча А.

Другая ситуация у лучей С и D. С касается поверхности, то есть угол равен 0. Тогда угол между нормалью и С равен 90 градусам, косинус 0. В итоге С не имеет влияния на освещение поверхности совсем! Угол между D и нормалью тупой, это означает, что косинус будет отрицательным числом от 0 до -1. Результат тот же, что и для С - никакого эффекта.

Из этих рассуждений можно вынести важный вывод - для того, что бы свет добавлял яркости поверхности, он должен падать под углом, разность между которым и нормалью к поверхности будет больше либо равна 0 и до (не включительно!) 90 градусам.

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

Так как нормаль одинакова на всей поверхности полигона, достаточно подсчитать рассеянный свет в вершинном шейдере. Все 3 вершины в треугольнике будут иметь одинаковый цвет и это и будет цвет треугольника. Хотя, это не очень хорошо. Мы будем иметь набор полигонов, у которых каждый будет иметь цвет, немного отличающийся от соседних, и будет четко видна граница цвета на краях полигонов. Но это можно исправить.

Трюк в использовании термина "вершинной нормали". Вершинная нормаль - это среднее значении всех нормалей треугольников, в которых содержится вершина. Вместо вершинного шейдера, который будет рассчитывать рассеянный свет, мы передадим нормаль вершины в качестве атрибута в фрагментный шейдер и ничего более. Растеризатор получит 3 различные нормали и ему потребуется интерполировать их. Фрагментный шейдер будет вызван для каждого пикселя с указанной для него нормалью. Мы можем подсчитать рассеянный свет на уровне пикселя используя указанную нормаль. В результате получим эффект света, который плавно меняется по поверхности треугольника и по соседним треугольникам. Эта техника известна как Затенение. Вот как нормаль вершины выглядит после интерполяции:

Последнее о чем осталось беспокоиться, это координаты, в которых мы собираемся рассчитывать рассеянный свет. Вершины и их нормали указаны в пространстве локальных координат, а преобразования полностью в вершинном шейдере для обрезания пространства матрицей WVP, которая поставляется в шейдер. Хотя, указание направления света в мировом пространстве наиболее логический шаг. В конце концов, направление света - это результат некоторого источника света, который находится где-то в мире (хоть солнце и находится где-то в "мире", но все равно далеко-далеко) и проливает свет в каком-то направлении. Поэтому нам требуется преобразовать нормали в мировое пространство перед вычислениями.

Прямиком к коду!

lighting_technique.h:25

struct DirectionalLight
{
    Vector3f Color;
    float AmbientIntensity;
    Vector3f Direction;
    float DiffuseIntensity;
};

Это новая структура направленного света. Появились 2 новых члена: направление в виде 3 вектора, указываемое в мировом пространстве, и интенсивность как вещественное число (будет использоваться так же, как и фоновая интенсивность).

lighting_technique.cpp:22

#version 330

layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;

uniform mat4 gWVP;
uniform mat4 gWorld;

out vec2 TexCoord0;
out vec3 Normal0;

void main()
{
    gl_Position = gWVP * vec4(Position, 1.0);
    TexCoord0 = TexCoord;
    Normal0 = (gWorld * vec4(Normal, 0.0)).xyz;
}

Это обновленный вершинный шейдер. У нас добавился новый атрибут вершины - нормаль, которую будет поставлять приложение. К тому же, матрица мировых преобразований будет поставляться отдельно, помимо матрицы WVP. Вершинный шейдер преобразовывает с ее помощью нормаль. Обратим внимание на то, как преобразовывается 4вектор, полученный умножением матрицы 4х4 на 4вектор, обратно в 3вектор (…).xyz. Эта возможность языка GLSL называется "мошенничеством" (swizzling) и дает великолепную гибкость в работе с векторами. Например, если у вас 3-х мерный вектор v(1,2,3), то вы можете написать: vec4 n = v.zzyy и вектор n будет содержать (3,3,2,2). Вспомним, что нам требуется увеличить нормаль до 4-х элементов, 4-й будет 0. Это сведет эффект преобразований в 4 столбце на нет. Причина в том, что вектор не может быть перемещен как точка. Он может быть только вращаться и изменяться в размерах.

lighting_technique.cpp:42

#version 330

in vec2 TexCoord0;
in vec3 Normal0;

out vec4 FragColor;

struct DirectionalLight
{
    vec3 Color;
    float AmbientIntensity;
    float DiffuseIntensity;
    vec3 Direction;
};

Это начало фрагментного шейдера. Теперь он получает интерполированное значение нормали, которая была преобразована в вершинном шейдере в мировое пространство. Структура DirectionalLight была увеличена для совпадение с аналогом в коде C++ и содержит новые атрибуты света.

lighting_technique.cpp:60

void main()
{
    vec4 AmbientColor = vec4(gDirectionalLight.Color, 1.0f) *
                gDirectionalLight.AmbientIntensity;

Никаких изменений в подсчете фонового цвета. Мы вычисляем и храним его для формулы ниже.

    float DiffuseFactor = dot(normalize(Normal0), -gDirectionalLight.Direction);

Это суть расчетов рассеянного света. Мы вычисляем косинус угла между вектором света и нормалью через их скалярное произведение. Здесь нужно обратить внимание на 3 момента:

  1. Нормаль, полученная из вершинного шейдера, нормируется перед вычислениями. Это происходит из-за того, что вектор интерполяции может изменить свою длину и перестать быть единичным вектором.
  2. Направление света требуется обратить. Если вы задумаетесь, то поймете, что свет, который падает на поверхность под прямым углом, противоположен нормали, то есть, угол между ними 180 градусов. Обратив направление света в этом случае мы получим вектор, который эквивалентен нормали. Тогда угол между ними будет 0 градусов, чего мы и добивались.
  3. Вектор света не нормирован. Но это будет пустой тратой ресурсов GPU, если мы будем нормировать его снова и снова для каждого пикселя. Вместо этого мы будем передавать уже нормированный вектор.
    vec4 DiffuseColor;

    if (DiffuseFactor > 0){
    DiffuseColor = vec4(gDirectionalLight.Color, 1.0f) * gDirectionalLight.DiffuseIntensity *
               DiffuseFactor;
    }
    else{
        DiffuseColor = vec4(0, 0, 0, 0);
    }

Здесь мы вычисляем условие рассеивания, которое полагается на цвет света, рассеянную интенсивность и направление света. Если коэффициент рассеивания отрицательный или равен 0, то свет падает под тупым углом (либо "сбоку", либо "сзади"). В данном случае рассеянный свет никак не влияет на цвет, поэтому его значения будут (0,0,0,0). Если угол больше 0, мы вычисляем цвет рассеивания как произведение основного цвета света на интенсивность рассеивания и уменьшаем на коэффициент рассеивания. Если угол между светом и нормалью равен 0, то коэффициент равен 1, что даст наибольшую яркость.

    FragColor = texture2D(gSampler, TexCoord0.xy) * (AmbientColor + DiffuseColor);
}

Это итог расчетов света. Мы суммируем фоновый и рассеянный коэффициенты и умножаем результат на цвет, который получаем их текстуры. Теперь вы видите, что даже если рассеянный свет не падает на поверхность (зависит от направления), фоновый по прежнему будет светить, если, конечно, он есть.

lighting_technique.cpp:144

void LightingTechnique::SetDirectionalLight(const DirectionalLight& Light)
{
    glUniform3f(m_dirLightLocation.Color, Light.Color.x, Light.Color.y, Light.Color.z);
    glUniform1f(m_dirLightLocation.AmbientIntensity, Light.AmbientIntensity);
    Vector3f Direction = Light.Direction;
    Direction.Normalize();
    glUniform3f(m_dirLightLocation.Direction, Direction.x, Direction.y, Direction.z);
    glUniform1f(m_dirLightLocation.DiffuseIntensity, Light.DiffuseIntensity);
}

Эта функция назначает параметры направленного света в шейдере. Она была расширена для охвата вектора направления и интенсивности рассеивания. Заметим, что вектор направления нормируется перед передачей в шейдер. Класс The LightingTechnique извлекает адреса uniform-переменных из шейдера так же, как и для матрицы. Добавилась функция для отправления матрицы мировых преобразований. Все это очень рутинно и знакомо, нет необходимости приводить код здесь. Смотрите исходники для подробностей.

main.cpp:35

struct Vertex
{
    Vector3f m_pos;
    Vector2f m_tex;
    Vector3f m_normal;

    Vertex() {}

    Vertex(Vector3f pos, Vector2f tex)
    {
        m_pos    = pos;
        m_tex    = tex;
        m_normal = Vector3f(0.0f, 0.0f, 0.0f);
    }
};

Обновленная структура Vertex теперь включает нормали. Она автоматически инициализируется в 0 через конструктор и мы добавили функцию, которая просканирует все вершины и подсчитает нормали.

main.cpp:197

void CalcNormals(const unsigned int* pIndices, unsigned int IndexCount, Vertex* pVertices, unsigned int VertexCount)
{
    for (unsigned int i = 0 ; i < IndexCount ; i += 3) {
        unsigned int Index0 = pIndices[i];
        unsigned int Index1 = pIndices[i + 1];
        unsigned int Index2 = pIndices[i + 2];
        Vector3f v1 = pVertices[Index1].m_pos - pVertices[Index0].m_pos;
        Vector3f v2 = pVertices[Index2].m_pos - pVertices[Index0].m_pos;
        Vector3f Normal = v1.Cross(v2);
        Normal.Normalize();

        pVertices[Index0].m_normal += Normal;
        pVertices[Index1].m_normal += Normal;
        pVertices[Index2].m_normal += Normal;
    }

    for (unsigned int i = 0 ; i < VertexCount ; i++) {
            pVertices[i].m_normal.Normalize();
    }
}

Эта функция принимает массив индексов, получает вершины треугольников, полагаясь на них, и вычисляет нормали. В первом цикле мы только набираем нормали для каждой тройки вершин. Для каждого треугольника она вычисляется как векторное произведение двух сторон, которые получаются из вершин треугольника. Перед добавлением нормаль в массив, ее нормируем. Причина в том, что результат векторного произведения не обязательно единичный вектор. Во втором цикле мы сканируем массив вершин напрямую (больше нам не нужны индексы) и нормируем нормаль каждой вершины. Эта операция равносильна усреднению суммы нормалей и выдает нам нормаль единичной длины. Функция вызывается до того, как создать буфер вершин, что бы буфер нормалей был создан с другими атрибутами одновременно.

main.cpp:131

    const Matrix4f& WorldTransformation = p.GetWorldTrans();
    m_pEffect->SetWorldMatrix(WorldTransformation);
    ...
    glEnableVertexAttribArray(2);
    ...
    glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)20);
    ...
    glDisableVertexAttribArray(2);

Два существенных изменения в цикле рендера. Класс конвейера получил новую функцию, которая предоставляет матрицу мировых преобразований (отдельно от матрицы WVP). Матрица мировых преобразований вычисляется как произведение матрицы масштабирования на вращения и, наконец, перемещения. Мы включаем и выключаем 3 атрибут вершины и указываем смещение нормалей внутри вершинного буфере. Смещение равно 20, так как перед нормалью позиция (12 байт) и координаты текстуры (8 байт).

Для завершения демо, которое мы видели на привью, мы должны указать интенсивность рассеивания и направление света. Это сделано в конструкторе класса Main. Интенсивность рассеивания равна 0 и направление слева направо. Фоновая интенсивность была уменьшена до 0, для усиления эффекта рассеянного света. Вы можете изменить параметры через кнопки 'z' и 'x' для управления интенсивностью рассеивания (аналогично 'a' и 's' из предыдущего урока для фоновой интенсивности).

powered byDisqus