May 14, 2020

Математика примитивного освещения в OpenGL

Как и обещал, сегодня поговорим об освещении, затронем математику освещения и напишем собственный шейдер. Здесь будут примеры кода на C++ вместе с моим классом шейдеров, который я залил прямиком в гитхаб. Правда, там есть какая-то проблема, которую не могу исправить, но буду рад, если вы её подскажете или даже исправите.


Итак, начнём с векторов. Вектор — направленный отрезок прямой, характеризующийся величиной и направлением. Но наши вектора имеют три компоненты, соответственно, (x, y, z), хотя в условиях OpenGL может содержать ещё и четвёртую гомогенную компоненту w, но она нужна лишь в вопросе с умножением матриц, которые здесь рассматривать мы не будем. Все векторы, которые у нас будут, мы собираемся нормализовать. Нормализация — приведение вектора к единичному размеру. Вот пример нормализации:

Проще говоря, это выглядит так:

Теперь нам потребуется скалярное произведение векторов, но так как они нормализованы, то скалярное произведение двух векторов равно косинусу угла между ними. Косинус угла уменьшается при увеличении этого самого угла. Запомните это, нам пригодится.

Виды освещения

Мы рассмотрим три типа освещения:

  • Фоновое освещение (ambient) — освещение, благодаря которому ничего не будет полностью тёмным.
  • Рассеянное освещение (diffuse) — освещение от направленного источника света
  • Зеркальные блики (specular) — блик, появляющийся на зеркальной поверхности поверхности

Я не буду рассматривать случаи, в который источник света имеет другой цвет, потому что из-за этого станет чуть больше кода и больше uniform-переменных, хотя сделать это довольно просто.

Фоновое освещение

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

void main()
{
	float ambientRatio = 0.1; //коэффициент фонового освещения
	vec3 ambient = ambientRatio * vec3(1.0, 1.0, 1.0); //умножаем этот коэффициент на цвет освещения
	Color = vec4(ambient * objectColor, 1.0); //получаем конечный цвет
}

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

Рассеянное освещение

Оно немного сложнее, но разберёмся по порядку. Итак, мы поговорили о скалярном произведении векторов, которое нам очень сильно пригодится. Так как мы только имитируем, и то, наше освещение довольно простое, то интенсивность мы будем измерять как раз этим методом. Наша интенсивность зависит от угла падения света на поверхность, вот такую картинку я нашёл в гугле, описывающую это:

Что за вектор N? Это вектор нормали к поверхности. Не бойтесь, сейчас всё поясню. Вектор нормали — всего лишь вектор, перпендикулярный к поверхности, как мы его получим? Очень просто, нам нужно добавить соответствующие числа в буфер (о буферах следующий пост).

float positions[] = {
	0.0f, 0.0f, 0.0f,	0.0f, 0.0f, -1.0f,	0.0f, 1.0f, 0.5f,
	1.0f, 0.0f, 0.0f,	0.0f, 0.0f, -1.0f,	0.0f, 1.0f, 0.5f,
	1.0f, 1.0f, 0.0f,	0.0f, 0.0f, -1.0f,	0.0f, 1.0f, 0.5f,
	0.0f, 1.0f, 0.0f,	0.0f, 0.0f, -1.0f,	0.0f, 1.0f, 0.5f
};

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

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

Что ж, когда мы разобрались с теорией, перейдём к практике. В вершинном шейдере опишем входные данные:

layout (location = 0) in vec4 positions;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec3 color;

layout (binding = 0) uniform MVP
{
	mat4 proj;
	mat4 camera;
	mat4 model;
};

Первые три вещи вам уже знакомы, думаю, а вот uniform MVP выглядит странно, не находите? Всё дело в том, что это uniform-блок, с ним дольше работать, нужно создавать отдельный буфер, но учитывая, что его можно использовать с нескольких шейдерах разом, оно того стоит, однако, вы можете использовать и просто uniform-переменые как обычно:

uniform mat4 proj; //матрица проекции
uniform mat4 camera; //матрица камеры
uniform mat4 model; //матрица модели

Но пока нам потребуется из этого набора лишь матрица модели. Теперь определим выходные переменные:

out vec3 Color; //цвет
out vec3 Normal; //нормали
out vec3 FragPos; //позиция объекта

Зачем нам отдельная переменная FragPos, если есть positions? У нас есть матрица модели и вектор позиций, друг без друга они не имеют значения, поэтому определяем FragPos:

FragPos = vec3(model * position);

Матричное умножение я разбирал в статье о нейронной сети.

После этого мы можем определить переменную Normal:

Normal = normalize(normal); //на всякий случай нормализуем

И наконец, gl_Position, но я покажу лучше весь код разом.

#version 460 core
layout (location = 0) in vec4 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec3 color;

layout (binding = 0) uniform MVP
{
	mat4 proj;
	mat4 camera;
	mat4 model;
};

out vec3 Color;
out vec3 Normal;
out vec3 FragPos;

void main()
{
	FragPos = vec3(model * position);
	Normal = normalize(normal);
	gl_Position = proj * camera * model * position;
	Color = color;
}

Вершинный шейдер готов, переходим к фрагментарному шейдеру.

Определяем вектор цвета на выход:

layout (location = 0) out vec4 color;

И векторы на вход:

in vec3 Color;
in vec3 Normal;
in vec3 FragPos;
uniform vec3 lightPos;

Отлично, определили все переменные, теперь можно писать код самого освещения.

Для начала, определим вектор луча света, равный разности позиции источника освещения и объекта, на который освещение падает.

vec3 lightDir = normalize(lightPos - FragPos); //сразу нормализуем вектор
vec3 normal = Normal;

Теперь вычисляем коэффициент рассеянного света при помощи функции скалярного произведения dot:

float diff = max(dot(normal, lightDir), 0.0);

Функция max позволит нашему значению быть не меньше нуля. Создаём вектор diffuse:

vec3 diffuse = diff * vec3(1.0, 1.0, 1.0); //умножаем на цвет источника света

И, наконец, вычисляем конечный цвет объекта:

color = vec4((ambient + diffuse) * Color, 1.0);

Весь код фрагментарного шейдера:

#version 460 core

layout (location = 0) out vec4 color;
in vec3 Color;
in vec3 Normal;
in vec3 FragPos;
uniform vec3 lightPos;

void main()
{
	float ambientRatio = 0.1;
	vec3 ambient = ambientRatio * vec3(1.0, 1.0, 1.0);
	vec3 normal = Normal;
	vec3 lightDir = normalize(lightPos - FragPos);
	float diff = max(dot(normal, lightDir), 0.0);
	vec3 diffuse = diff * vec3(1.0, 1.0, 1.0);
	color = vec4((ambient + diffuse) * Color, 1.0);
}

Отлично! Теперь вы увидите такой результат (я сдвинул камеру):

изменил цвет на зелёный

Хотя стоило бы немного поправить одну строчку:

Normal = normalize(mat3(transpose(inverse(model))) * normal);

Это сделано для того, чтобы можно было без вреда масштабировать поверхность.

Зеркальные блики

Наконец, последнее, самое сложное. Для начала нам нужна uniform-переменная vec3 типа, показывающая позицию камеры в мире.

uniform vec3 eyePos;

Добавим это в фрагментарный шейдер для удобства.

Нам так же потребуется средняя интенсивность блика specularRatio, дадим ей значение 0.5. Теперь можем объявить два вектора: отражения и взгляда:

vec3 viewDir = normalize(eyePos - FragPos);
vec3 reflectDir = reflect(-lightDir, normal);

Мы написали минус к вектору lightDir, потому что функция reflect ожидает, что первый вектор будет указывать направление от источника света к положению поверхности.

Остаётся лишь вычислить компоненту зеркального блика, как это делали с прошлыми видами освещения:

float spec = pow(max(dot(viewDir, reflectDir), 0.0), 64); //здесь 64 это коэффициент, можете попробовать вручную настроить его из набора степеней двойки
vec3 specular = specularRatio * spec * vec3(1.0);

И добавить эту компоненту, наконец, в состав цвета:

color = vec4((ambient + diffuse + specular) * Color, 1.0);

И получится результат, как на превью.

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


Прошу прощения за то, что долго не выпускал, были дела и я изучал эту тему получше. Следующая статья будет по буферам, так как отрыв по голосам был недостаточно большой, там я и расскажу про буфер uniform-блока, очень удобная вещь.

Почитать:

http://leanopengl.com/Lighting/Basic-Lighting