Рубрики
3D мир Программирование

OpenGL 7: освещение ч.1 — базовые модели затенения

Заметка посвящена подготовке данных и расчету простых моделей освещения (плоское, Гуро, Фонг, Блинн-Фонг)

Введение

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

Перед рассмотрением реализаций различных методов расчета освещения необходимо подготовить данные:

Далее будут рассмотрены модели освещения:

Характеристики материала

Перед началом реализации задач освещения необходимо скомпоновать данные о материала объекта. На данный момент модели в сцене разбиваются по используемым материалам.

В файле 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.

Рисунок 1 — Белая капля без текстур и материалов из файла blob.obj

Текущая версия доступна на теге 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.

Рисунок 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.

Рисунок 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.

Рисунок 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.

Рисунок 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.

Рисунок 6 — Результат работы метода Блинна-Фонга

Разница с методом Фонга не видна на примере этой модели, но явно показана в заметке посвященной освещению.

Текущая версия доступна на теге v0.9 в репозитории 07.

Заключение

В данной заметке были подготовлены данные для работы с простым освещением: о материалах, источниках света и позиции камеры, а так же модели капли. Рассмотрены простые модели освещения (плоское, Гуро, Фонг, Блинн-Фонг).

Проект доступен в публичном репозитории: 07
Библиотеки: dependencies
Ресурсы: resources

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.