Введение
Данная заметка продолжает тему, начатую в прошлой заметке по загрузке моделей, и использует теорию из заметки об освещении в трехмерных приложениях.
Перед рассмотрением реализаций различных методов расчета освещения необходимо подготовить данные:
Далее будут рассмотрены модели освещения:
- плоское затенение (flat shading);
- затенение по Гуро (Gouraud/smooth shading);
- затенение по Фонгу (Phong shading);
- затенение Блинна-Фонга (Blinn-Phong shading).
Характеристики материала
Перед началом реализации задач освещения необходимо скомпоновать данные о материала объекта. На данный момент модели в сцене разбиваются по используемым материалам.
В файле include/Model.h определим структуру для хранения характеристик материала:
// Материал модели
struct Material
{
alignas(16) glm::vec3 ka; // коэф. фонового отражения (цвет фонового освещения)
alignas(16) glm::vec3 kd; // коэф. диффузного отражения (цвет объекта)
alignas(16) glm::vec3 ks; // коэф. зеркального блика
float p; // показатель глянцевости
// Значения по умолчанию
Material() : ka(0.2f), kd(0.2f), ks(0.2f), p(1) { };
};
Данная структура имеет конструктор, который инициализирует поля начальными значениями. Важное замечание: коэффициент p (глянцевость) не должен иметь значение равное нулю, которое может приводить к черным пятнам при вычислении зеркальной составляющей.
Добавим публичное поле к классу Model:
// Класс модели
class Model : public Node
{
public:
...
Material material; // Материал модели
};
В конструкторе копирования и операторе присваивания необходимо указать копирование материала:
// Конструктор копирования
Model::Model(const Model& copy) : Node(copy),
vao(copy.vao),
verteces_count(copy.verteces_count), first_index_byteOffset(copy.first_index_byteOffset), indices_count(copy.indices_count),
vertex_vbo(copy.vertex_vbo), index_vbo(copy.index_vbo), normals_vbo(copy.normals_vbo), texCoords_vbo(copy.texCoords_vbo),
texture_diffuse(copy.texture_diffuse),
material(copy.material)
{
...
// Оператор присваивания
Model& Model::operator=(const Model& other)
{
...
material = other.material;
return *this;
}
Дополним функцию loadOBJtoScene из файла src/Scene.cpp в последней части, где создаются копии объекта для сцены и идет загрузка текстур:
// Создаем копии модели, которые будут рендериться в заданном диапазоне
// И присваиваем текстуры копиям на основании материала
for (int i = 0; i < materials_range.size()-1; i++)
{
...
// Текстуры
Texture diffuse(TEX_DIFFUSE, texture_directory + materials[materials_ids[i]].diffuse_texname);
s->set_texture(diffuse);
// Материал
s->material.ka = glm::vec3(materials[materials_ids[i]].ambient[0], materials[materials_ids[i]].ambient[1], materials[materials_ids[i]].ambient[2]);
s->material.kd = glm::vec3(materials[materials_ids[i]].diffuse[0], materials[materials_ids[i]].diffuse[1], materials[materials_ids[i]].diffuse[2]);
s->material.ks = glm::vec3(materials[materials_ids[i]].specular[0], materials[materials_ids[i]].specular[1], materials[materials_ids[i]].specular[2]);
s->material.p = (materials[materials_ids[i]].shininess > 0.0f) ? 1000.0f / materials[materials_ids[i]].shininess : 1000.0f;
}
Важное замечание: показатель глянцевости хранится в диапазоне от 0 до 1000, где 1000 это максимальное значение блика, но формула расчета интенсивности блика использует этот показатель как степень, в которую возводится косинус угла, который в свою очередь принимает значение [0;1] (благодаря использованию функции max(cos(…), 0.0)) — необходимо инвертировать значения для использования в шейдере.
В файле src/Model.cpp добавим к аргументам метода Model::render ссылку на uniform-буфер и загрузку данных о материале:
void Model::render(const GLuint &model_uniform, UBO &material_buffer)
{
...
// Подключаем текстуры
texture_diffuse.use();
// Загружаем данные о материале
material_buffer.load(&material, sizeof(material));
...
}
Аналогично в файле src/Scene.cpp:
// Рендер сцены
void Scene::render(const GLuint &model_uniform, UBO &material_buffer)
{
for (auto & model : models)
model.render(model_uniform, material_buffer);
}
Примечание: важно не забыть изменить аргументы методов в файлах include/Model.h и include/Scene.h.
В файле src/main.cpp создадим uniform-буфер и передадим его при вызове метода отрисовки сцены:
...
// Uniform-буферы
UBO cameraUB(sizeof(glm::mat4)*2, 0);
UBO material_data(sizeof(Material), 1);
...
// Тут производится рендер
scene.render(model_uniform, material_data);
...
Помимо этого необходимо добавить uniform-блок к шейдеру shaders/shader.frag:
layout(std140, binding = 1) uniform Material
{
vec3 ka;
vec3 kd;
vec3 ks;
float p;
};
Добавим два новых вида текстур (карты фонового и зеркального отражений) в файле include/Texture.h:
enum TexType {
TEX_DIFFUSE,
TEX_AMBIENT,
TEX_SPECULAR,
TEX_AVAILABLE_COUNT
};
В файле src/main.cpp дополним массив с именами текстур на шейдерах:
// Установим значения текстур
const char* textures_base_shader_names[] = {"tex_diffuse", "tex_ambient", "tex_specular"};
А так же в шейдере shaders/shader.frag:
uniform sampler2D tex_diffuse;
uniform sampler2D tex_ambient;
uniform sampler2D tex_specular;
В функции загрузки loadOBJtoScene из файла src/Model.cpp в последней части, где создаются копии объекта для сцены:
// Текстуры
Texture diffuse(TEX_DIFFUSE, texture_directory + materials[materials_ids[i]].diffuse_texname);
s->set_texture(diffuse);
Texture ambient(TEX_AMBIENT, texture_directory + materials[materials_ids[i]].ambient_texname);
s->set_texture(ambient);
Texture specular(TEX_SPECULAR, texture_directory + materials[materials_ids[i]].specular_texname);
s->set_texture(specular);
В методе Model::render добавим подключение новых текстур:
// Подключаем текстуры
texture_diffuse.use();
texture_ambient.use();
texture_specular.use();
Дополним конструктор копирования и оператор присваивания:
// Конструктор копирования
Model::Model(const Model& copy) : Node(copy),
vao(copy.vao),
verteces_count(copy.verteces_count), first_index_byteOffset(copy.first_index_byteOffset), indices_count(copy.indices_count),
vertex_vbo(copy.vertex_vbo), index_vbo(copy.index_vbo), normals_vbo(copy.normals_vbo), texCoords_vbo(copy.texCoords_vbo),
texture_diffuse(copy.texture_diffuse), texture_ambient(copy.texture_ambient), texture_specular(copy.texture_specular),
material(copy.material)
...
// Оператор присваивания
Model& Model::operator=(const Model& other)
{
...
texture_diffuse = other.texture_diffuse;
texture_ambient = other.texture_ambient;
texture_specular = other.texture_specular;
material = other.material;
return *this;
}
Текущая версия доступна на теге v0.1 в репозитории 07.
Источник света
В данной заметке источники света будут точечными (всенаправленными).
Добавим структуру LightData в файле include/Lights.h:
// Точечный источник света
struct LightData
{
alignas(16) glm::vec3 position;
alignas(16) glm::vec3 color;
};
В файле src/main.cpp создадим экземпляр источника света и uniform-буфер для хранения информации о нем:
// Источник света
LightData light = { {0.0f, 0.0f, 1.0f} // позиция
, {0.0f, 1.0f, 1.0f} // цвет
};
...
// Uniform-буферы
UBO light_data(&light, sizeof(Material), 2);
Примечание: данные об источнике света загружаются единоразово при создании буфера.
А так же добавим uniform-блок к шейдерам. В файле shaders/shader.vert:
layout(std140, binding = 2) uniform Light
{
vec3 position;
vec3 color;
} light_v;
В файле shaders/shader.frag:
layout(std140, binding = 2) uniform Light
{
vec3 position;
vec3 color;
} light_f;
Текущая версия доступна на теге v0.2 в репозитории 07.
Модель капли
В заметке будет использоваться модель капли, которая доступна в репозитории resources под названием blob.obj. Используемая модель имеет 2200 фрагментов.
Поменяем загрузчик на загрузку данной модели в файле src/main.cpp:
// Загрузка сцены из obj файла
Scene scene = loadOBJtoScene("../resources/models/blob.obj", "../resources/models/", "../resources/textures/");
scene.root.e_scale() = glm::vec3(0.01);
scene.root.e_position().z = 1;
Помимо этого модель требуется уменьшить в сто раз и сдвинуть по оси Z на единицу.
Так как модель не имеет текстуры — на экране будет изображена полностью белая модель.
Результат представлен на рисунке 1.
![](https://rekovalev.site/wp-content/uploads/2022/11/blob_wo_shading.png)
Текущая версия доступна на теге v0.3 в репозитории 07.
Позиция камеры
Для расчетов освещения необходимо знать местоположение камеры.
Добавим к uniform-блоку камеры в вершинном шейдере поле с позицией камеры в мировых координатах:
layout(std140, binding = 0) uniform Camera
{
mat4 projection;
mat4 view;
glm::vec3 position;
};
Для упрощения загрузки данных о камеры создадим структуру в файле include/Camera.h, которая соответствует данным на шейдере:
struct CameraData
{
glm::mat4 projection;
glm::mat4 view;
glm::vec3 position;
};
Важное замечание: дополнительное выравнивание здесь не требуется.
Добавим публичный метод Camera::getData, который будет формировать описанную выше структуру:
// Данные о камере для шейдера
CameraData& Camera::getData()
{
static CameraData data;
data = {getProjection(), getView(), position};
return data;
}
Необходимо изменить замер буфера и изменить загрузку данных о камере в цикле рисования:
// Uniform-буферы
UBO cameraUB(sizeof(CameraData), 0);
...
// Данные о камере
cameraUB.load(&Camera::current().getData(), sizeof(CameraData));
Текущая версия доступна на теге v0.4 в репозитории 07.
Плоское затенение
Вычисление нормалей на фрагментном шейдере
Первый способ для получения плоского затенения — это расчет нормалей на фрагментном шейдере. В данном способе вершинный шейдер занимается вычислением следующих данных:
- позиция камеры относительно вершины (Cam_vertex);
- позиция источника света относительно вершины (L_vertex);
- вершина в пространстве камеры (Vertex_view).
Вычисления производятся следующим образом:
vec4 P = model * vec4(pos, 1.0); // трансформация вершины
Cam_vertex = normalize(camera.position - P.xyz);
L_vertex = normalize(light_v.position - P.xyz);
Vertex_view = P.xyz;
Примечание: переменную P (трансформация вершины) можно использовать для расчета gl_Position.
В фрагментном шейдере нормаль вычисляется с помощью векторного произведения результатов функций dFdx и dFdy, которые возвращают вектор частной производную относительно координат окна:
vec3 N = normalize(cross(dFdx(Vertex_view), dFdy(Vertex_view)));
Для диффузной составляющей отраженного света необходимо вычислить скалярное произведение векторов света относительно вершины и нормали:
float diffuse = max(dot(L_vertex, N), 0.0); // скалярное произведение с отсеканием значений < 0
Вектор направления отраженного луча относительно поверхности вычисляется с помощью функции reflect от обратного вектора света и прямого вектора нормали:
vec3 R = normalize(reflect(-L_vertex, N));
Для зеркальной составляющей необходимо вычислить скалярное произведение вектора камеры и отраженного луча, возведенный в степень p:
float specular = 0;
// Если есть диффузная составляющая, то считаем зеркальную
if (diffuse > 0)
specular = pow(max(dot(Cam_vertex, R), 0.0), p); // скалярное произведение с отсеканием значений < 0 в степени p
Важное замечание: тут необходимо учитывать наличие диффузного отражения для экономии производительности.
Результат получается как сумма произведений векторов на результаты скалярных произведений:
color = vec4(ka, 1)*texture(tex_ambient, texCoord)
+ vec4(light_f.color*kd*diffuse, 1)*texture(tex_diffuse, texCoord)
+ vec4(light_f.color*ks*specular, 1)*texture(tex_specular, texCoord);
У результата может отсутствовать диффузная составляющая, так как модель не имеет текстуры (можно закомментировать произведение на функцию texture).
Результат работы метода изображен на рисунке 2.
![](https://rekovalev.site/wp-content/uploads/2022/11/redflat.png)
Данный метод может быть более трудозатратным по сравнению со вторым из-за использования функций dFdx и dFdy.
Текущая версия доступна на теге v0.5 в репозитории 07.
Передача нормалей без интерполяции
Второй способ заключается в том, что в GLSL есть спецификатор flat — определяющий, что данные будут использоваться в фрагментном шейдере в не интерполированном виде.
Пример использования спецификатора flat в вершинном шейдере:
flat out vec3 N; // Нормаль трансформированная
В данном способе вершинный шейдер занимается вычислением следующих данных:
- позиция камеры относительно вершины (Cam_vertex);
- позиция источника света относительно вершины (L_vertex);
- нормаль трансформированная (N).
Вычисления производятся следующим образом:
vec4 P = model * vec4(pos, 1.0); // трансформация вершины
Cam_vertex = normalize(camera.position - P.xyz);
L_vertex = normalize(light_v.position - P.xyz);
N = normalize(mat3(model) * normals); // трансформация нормали
Примечание: переменную P (трансформация вершины) можно использовать для расчета gl_Position.
Пример использования спецификатора flat в фрагментном шейдере:
flat in vec3 N; // Нормаль трансформированная
Для диффузной составляющей отраженного света необходимо вычислить скалярное произведение векторов света относительно вершины и нормали:
float diffuse = max(dot(L_vertex, N), 0.0); // скалярное произведение с отсеканием значений < 0
Вектор направления отраженного луча относительно поверхности вычисляется с помощью функции reflect от обратного вектора света и прямого вектора нормали:
vec3 R = normalize(reflect(-L_vertex, N));
Для зеркальной составляющей необходимо вычислить скалярное произведение вектора камеры и отраженного луча, возведенный в степень p:
float specular = 0;
// Если есть диффузная составляющая, то считаем зеркальную
if (diffuse > 0)
specular = pow(max(dot(Cam_vertex, R), 0.0), p); // скалярное произведение с отсеканием значений < 0 в степени p
Важное замечание: тут необходимо учитывать наличие диффузного отражения для экономии производительности.
Результат получается как сумма произведений векторов на результаты скалярных произведений:
color = vec4(ka, 1)*texture(tex_ambient, texCoord)
+ vec4(light_f.color*kd*diffuse, 1)*texture(tex_diffuse, texCoord)
+ vec4(light_f.color*ks*specular, 1)*texture(tex_specular, texCoord);
У результата может отсутствовать диффузная составляющая, так как модель не имеет текстуры (можно закомментировать произведение на функцию texture).
Результат работы метода изображен на рисунке 3.
![](https://rekovalev.site/wp-content/uploads/2022/11/redflat2.png)
Данный подход более грубый в плане визуала, но более производительный.
Текущая версия доступна на теге v0.6 в репозитории 07.
Затенение по Гуро
Идея метода Гуро заключается в расчете освещенности для каждой вершины в рамках вершинного шейдера.
Из вершинного шейдера необходимо передать интенсивности:
- диффузная составляющая (diffuse);
- зеркальная составляющая (specular).
Для их расчета необходимо вычислить:
vec4 P = model * vec4(pos, 1.0); // трансформация вершины
// Позиция камеры относительно вершины
vec3 Cam_vertex = normalize(camera.position - P.xyz);
// Позиция источника света относительно вершины
vec3 L_vertex = normalize(light_v.position - P.xyz);
vec3 N = normalize(mat3(model) * normals); // трансформация нормали
Примечание: переменную P (трансформация вершины) можно использовать для расчета gl_Position.
Данные о материале потребуются как на вершинном (коэффициент глянцевости), так и на фрагментном (коэффициенты параметров материала).
Интенсивность диффузной составляющей вычисляется следующим образом:
diffuse = max(dot(L_vertex, N), 0.0); // скалярное произведение с отсеканием значений < 0
Для расчета интенсивности зеркальной составляющей используется отраженный вектор:
// Отраженный вектор
vec3 R = normalize(reflect(-L_vertex, N));
// Зеркальная составляющая
specular = 0;
// Если есть диффузная составляющая, то считаем зеркальную
if (diffuse > 0)
specular = pow(max(dot(Cam_vertex, R), 0.0), material_v.p); // скалярное произведение с отсеканием значений < 0 в степени p
В фрагментном шейдере происходит смешивание цветов с учетом коэффициентов материалов, интенсивностей отражения и текстур:
color = vec4(light_f.color*material_f.ka, 1)*texture(tex_ambient, texCoord)
+ vec4(light_f.color*material_f.kd*diffuse, 1)*texture(tex_diffuse, texCoord)
+ vec4(light_f.color*material_f.ks*specular, 1)*texture(tex_specular, texCoord);
Результат работы метода изображен на рисунке 4.
![](https://rekovalev.site/wp-content/uploads/2022/11/redgouraugh.png)
По представленному рисунку видна угловатость результата освещения потому, что яркость рассчитывается для каждой вершины.
Текущая версия доступна на теге v0.7 в репозитории 07.
Затенение по Фонгу
Метод затенения по Фонгу решает проблему угловатости, получаемой методом Гуро, путем интерполяции данных для фрагментного шейдера:
- позиция камеры относительно вершины (Cam_vertex);
- позиция источника света относительно вершины (L_vertex);
- нормаль (N).
Данные об источнике освещения используются как вершинным, так и фрагментным шейдерами.
Вершинный шейдер вычисляет необходимые данные следующим образом:
vec4 P = model * vec4(pos, 1.0); // трансформация вершины
Cam_vertex = normalize(camera.position - P.xyz);
L_vertex = normalize(light_v.position - P.xyz);
N = normalize(mat3(model) * normals);
Примечание: переменную P (трансформация вершины) можно использовать для расчета gl_Position.
Фрагментный шейдер вычисляет интенсивность диффузной составляющей следующим образом:
float diffuse = max(dot(L_vertex, N), 0.0); // скалярное произведение с отсеканием значений < 0
Для расчета интенсивности зеркальной составляющей используется отраженный вектор:
// Отраженный вектор
vec3 R = normalize(reflect(-L_vertex, N));
// Зеркальная составляющая
float specular = 0;
// Если есть диффузная составляющая, то считаем зеркальную
if (diffuse > 0)
specular = pow(max(dot(Cam_vertex, R), 0.0), p); // скалярное произведение с отсеканием значений < 0 в степени p
Цвет получаемый в результате вычисляется следующим выражением:
color = vec4(light_f.color*ka, 1)*texture(tex_ambient, texCoord)
+ vec4(light_f.color*kd*diffuse, 1)*texture(tex_diffuse, texCoord)
+ vec4(light_f.color*ks*specular, 1)*texture(tex_specular, texCoord);
Результат работы метода изображен на рисунке 5.
![](https://rekovalev.site/wp-content/uploads/2022/11/redphong.png)
Текущая версия доступна на теге v0.8 в репозитории 07.
Затенение Блинна-Фонга
Дополнение Блинна к методу Фонга заключается в использовании вектора половины пути в замен отраженного на фрагментном шейдере при вычислении интенсивности зеркального отражения.
Вершинный шейдер вычисляет все так же:
- позиция камеры относительно вершины (Cam_vertex);
- позиция источника света относительно вершины (L_vertex);
- нормаль (N).
Пример вычисления:
vec4 P = model * vec4(pos, 1.0); // трансформация вершины
Cam_vertex = normalize(camera.position - P.xyz);
L_vertex = normalize(light_v.position - P.xyz);
N = normalize(mat3(model) * normals);
Примечание: переменную P (трансформация вершины) можно использовать для расчета gl_Position.
Интенсивность диффузного отражения вычисляется:
float diffuse = max(dot(L_vertex, N), 0.0); // скалярное произведение с отсеканием значений < 0
Для вычисления интенсивности зеркального отражения вычисляется вектор половины пути:
// Вектор половины пути
vec3 H = normalize(L_vertex + Cam_vertex);
// Зеркальная составляющая
float specular = pow(max(dot(H, N), 0.0), p*4); // скалярное произведение с отсеканием значений < 0 в степени p
Важное замечание: при использовании вектора половины пути интенсивность зеркального отражения получается больше, для минимизации разницы необходимо увеличить коэффициент гляцевости в четыре раза.
Цвет получаемый в результате вычисляется следующим выражением:
color = vec4(light_f.color*ka, 1)*texture(tex_ambient, texCoord)
+ vec4(light_f.color*kd*diffuse, 1)*texture(tex_diffuse, texCoord)
+ vec4(light_f.color*ks*specular, 1)*texture(tex_specular, texCoord);
Результат работы метода изображен на рисунке 6.
![](https://rekovalev.site/wp-content/uploads/2022/11/redblinn-phong.png)
Разница с методом Фонга не видна на примере этой модели, но явно показана в заметке посвященной освещению.
Текущая версия доступна на теге v0.9 в репозитории 07.
Заключение
В данной заметке были подготовлены данные для работы с простым освещением: о материалах, источниках света и позиции камеры, а так же модели капли. Рассмотрены простые модели освещения (плоское, Гуро, Фонг, Блинн-Фонг).
Проект доступен в публичном репозитории: 07
Библиотеки: dependencies
Ресурсы: resources