Введение
Данная заметка продолжает тему, начатую в прошлой заметке по загрузке моделей, и использует теорию из заметки об освещении в трехмерных приложениях.
Перед рассмотрением реализаций различных методов расчета освещения необходимо подготовить данные:
Далее будут рассмотрены модели освещения:
- плоское затенение (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.
Текущая версия доступна на теге 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.
Данный метод может быть более трудозатратным по сравнению со вторым из-за использования функций 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.
Данный подход более грубый в плане визуала, но более производительный.
Текущая версия доступна на теге 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.
По представленному рисунку видна угловатость результата освещения потому, что яркость рассчитывается для каждой вершины.
Текущая версия доступна на теге 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.
Текущая версия доступна на теге 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.
Разница с методом Фонга не видна на примере этой модели, но явно показана в заметке посвященной освещению.
Текущая версия доступна на теге v0.9 в репозитории 07.
Заключение
В данной заметке были подготовлены данные для работы с простым освещением: о материалах, источниках света и позиции камеры, а так же модели капли. Рассмотрены простые модели освещения (плоское, Гуро, Фонг, Блинн-Фонг).
Проект доступен в публичном репозитории: 07
Библиотеки: dependencies
Ресурсы: resources