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

OpenGL 13: тени ч.2 — тени от точечного источника и прожектора

Заметка посвящена рисованию теней от множества точечных источников и прожекторов.

Введение

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

Данная заметка продолжает тему, поднятую в заметке по работе с тенями в OpenGL (направленный источник), и охватывает тени от точечных источников и прожекторов.

Задача расчета теней от точечных источников является весьма трудозатратной. Ранее в заметках использовались 300 точечных источников/прожекторов, но содержание такого количества источников на сцене без оптимизации алгоритма расчета теней является неподъемным. Оптимизации расчета теней будут рассмотрены в следующих заметках, так что для упрощения задачи сократим их количество до 64. Для этого изменим константу MAX_LIGHTS в файле include/Lights.h:

#define MAX_LIGHTS 64

А так же требуется изменить размер массива uniform-блока в файле shaders/lighting.frag:

layout(std140, binding = 2) uniform Light
{
    LightData data[64];
    int count;
} light_f;

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

Для хранения теневых карт будут использоваться текстурный атлас — большая текстура, которая должна содержать в себе все расчеты глубин. Один атлас состоит из 3D текстуры с параметром GL_TEXTURE_CUBE_MAP_ARRAY, где глубина равна произведению числа сторон кубической текстуры (6) на количество источников освещения (64). Детализацию текстуры (фрагмент атласа), которая отвечает за конкретный источник, по сравнению с тенями от солнца, можно снизить ради экономии памяти.

Содержание заметки:

Создание класса кубической текстуры

Тень от точечного источника обычно хранится в кубической текстуре, пример такой текстуры с индексами представлен на рисунке 1.

Рисунок 1 — Пример кубической текстуры

Так как источников больше одного, то необходимо использовать несколько кубических текстур, но такой подход усложняет работу. OpenGL, начиная с версии 4.0, позволяет создавать массив кубических текстур (3D текстура разделенная на кубические карты). Для дальнейшей работы поменяем версию спецификации OpenGL в файле src/main.cpp:

    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4); // Мажорная версия спецификаций OpenGL
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 6); // Минорная версия спецификаций OpenGL

Примечание: данная функциональность доступна на спецификациях 3 мажорной версии для некоторых GPU Nvidia и AMD, но для корректной работы лучше использовать подходящую версию.

В файле include/Texture.h добавим класс TextureCubeArray, дочерний от BaseTexture:

// Класс 3D кубической текстуры
class TextureCubeArray : public BaseTexture
{
    public:
        TextureCubeArray(GLuint levels, GLuint width, GLuint height, GLuint attachment, GLuint texType = TEX_DIFFUSE, GLint internalformat = GL_RGBA, GLint format = GL_RGBA, GLenum dataType = GL_FLOAT); // Конструктор текстуры заданного размера для использования в буфере
        TextureCubeArray(const TextureCubeArray& other); // Конструктор копирования

        TextureCubeArray& operator=(const TextureCubeArray& other); // Оператор присваивания

        void reallocate(GLuint levels, GLuint width, GLuint height, GLuint texType = TEX_DIFFUSE, GLint internalformat = GL_RGBA, GLint format = GL_RGBA, GLenum dataType = GL_FLOAT); // Пересоздает текстуру для имеющегося дескриптора

        virtual void use(); // Привязка текстуры       
};

Реализация конструктора и методов данного класса (жирным выделена разница с реализацией класса TextureArray):

// Конструктор текстуры заданного размера для использования в буфере
TextureCubeArray::TextureCubeArray(GLuint levels, GLuint width, GLuint height, GLuint attachment, GLuint texType, GLint internalformat, GLint format, GLenum dataType)
{
    type = texType;
    // Генерация текстуры заданного размера
    glGenTextures(1, &handler);
    glBindTexture(GL_TEXTURE_CUBE_MAP_ARRAY, handler);
    glTexImage3D(
        GL_TEXTURE_CUBE_MAP_ARRAY, 0, internalformat, width, height, 6*levels, 0, format, dataType, 0);

    glTexParameteri(GL_TEXTURE_CUBE_MAP_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
    glTexParameteri(GL_TEXTURE_CUBE_MAP_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);

    // Привязка к буферу кадра
    glFramebufferTexture(GL_FRAMEBUFFER, attachment, handler, 0);

    // Создаем счетчик использований дескриптора
    handler_count[handler] = 1;
}

// Конструктор копирования
TextureCubeArray::TextureCubeArray(const TextureCubeArray& other)
{
    handler = other.handler; 
    type = other.type;
    // Делаем копию и увеличиваем счетчик
    handler_count[handler]++;
}

// Оператор присваивания
TextureCubeArray& TextureCubeArray::operator=(const TextureCubeArray& other)
{
    // Если это разные текстуры
    if (handler != other.handler)
    {
        this->~TextureCubeArray(); // Уничтожаем имеющуюся
        // Заменяем новой
        handler = other.handler;
        handler_count[handler]++;
    }
    type = other.type;

    return *this;
}

// Пересоздает текстуру для имеющегося дескриптора
void TextureCubeArray::reallocate(GLuint levels, GLuint width, GLuint height, GLuint texType, GLint internalformat, GLint format, GLenum dataType)
{
    use();
    glTexImage3D(
        GL_TEXTURE_CUBE_MAP_ARRAY, 0, internalformat, width, height, 6*levels, 0, format, dataType, 0);
}

// Привязка текстуры
void TextureCubeArray::use()
{
    glActiveTexture(type + GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_CUBE_MAP_ARRAY, handler); // Привязка текстуры как активной
}

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

Расчет теней для источников

В файле src/main.cpp создадим буфер для рендера теней от источников:

    // Размер одной стороны кубической карты
    const GLuint pointShadow_resolution = 500;
    // Создадим буфер кадра для рендера теней от источников света
    FBO pointShadowBuffer;
    // Создадим текстуры для буфера кадра
    TextureCubeArray pointShadowDepth(MAX_LIGHTS, pointShadow_resolution, pointShadow_resolution, GL_DEPTH_ATTACHMENT, 5, GL_DEPTH_COMPONENT, GL_DEPTH_COMPONENT);
    // Отключим работу с цветом
    glDrawBuffer(GL_NONE);
    glReadBuffer(GL_NONE);
    // Активируем базовый буфер кадра
    FBO::useDefault();

Для вычисления буфера глубины источника требуется рассчитать шесть произведений матриц вида для каждого направления источника на матрицу проекции. В качестве матрицы проекции используется перспективная с углом обзора в 90°, соотношением сторон 1 к 1 (aspect), ближним отсечением со значением 0.1 и дальним, ограниченным радиусом действия источника.

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

Дополним класс Light методом recalcVP и структуру LightData полем-массивом из шести матриц vp в файле include/Lights.h:

// Точечный источник света
struct LightData 
{
    alignas(16) glm::vec3 position; // Позиция
    alignas(16) glm::vec3 color; // Цвет 
    alignas(16) glm::vec3 attenuation; // Радиус действия источника, линейный и квадратичный коэф. угасания   
    alignas(16) glm::vec4 direction_angle; // Направление и половинный угол освещенности
    alignas(16) glm::mat4 vp[6]; // Матрицы проекции и трансформации в пространство источника
};

// Источник света
class Light : public Node
{
...
    private:
...
        virtual void recalcMatrices(); // Метод пересчета матрицы трансформации по необходимости, должен сбрасывать флаг changed
        void recalcVP(); // Пересчитывает по необходимости матрицу вида-проекции
...
};

Реализация метода Light::recalcVP в файле src/Lights.cpp, который вызывается в методе Light::toData:

// Преобразует информацию об источнике в структуру LightData
void Light::toData()
{
    check_id(); // Проверка на работу с корректным индексом

    // Если позиция изменилась
    if (data[index].position.x != result_transform[3].x
    ||  data[index].position.y != result_transform[3].y
    ||  data[index].position.z != result_transform[3].z
    )
    {
        data[index].position = glm::vec3(result_transform[3]); // Позиция из матрицы трансформации
        recalcVP(); // Пересчет матрицы вида-проекции для расчета теней
    }
    data[index].color = color; // Цвет
...
}

// Пересчитывает по необходимости матрицу вида-проекции
void Light::recalcVP()
{    
    float near_plane = 0.1f;
    glm::mat4 shadowProj = glm::perspective(glm::radians(90.0f), 1.0f, near_plane, radius);
    data[index].vp[0] = shadowProj * glm::lookAt(position, position + glm::vec3( 1.0f,  0.0f,  0.0f), glm::vec3(0.0f, -1.0f,  0.0f));
    data[index].vp[1] = shadowProj * glm::lookAt(position, position + glm::vec3(-1.0f,  0.0f,  0.0f), glm::vec3(0.0f, -1.0f,  0.0f));
    data[index].vp[2] = shadowProj * glm::lookAt(position, position + glm::vec3( 0.0f,  1.0f,  0.0f), glm::vec3(0.0f,  0.0f,  1.0f));
    data[index].vp[3] = shadowProj * glm::lookAt(position, position + glm::vec3( 0.0f, -1.0f,  0.0f), glm::vec3(0.0f,  0.0f, -1.0f));
    data[index].vp[4] = shadowProj * glm::lookAt(position, position + glm::vec3( 0.0f,  0.0f,  1.0f), glm::vec3(0.0f, -1.0f,  0.0f));
    data[index].vp[5] = shadowProj * glm::lookAt(position, position + glm::vec3( 0.0f,  0.0f, -1.0f), glm::vec3(0.0f, -1.0f,  0.0f));
}

Важное замечание: компилятор может выдавать ошибку, если не подключен заголовочный файл <GLM/ext/matrix_transform.hpp>.

Благодаря такому подходу матрицы вида-проекции конкретного источника рассчитываются только в случае изменения его позиции, что позволяет избежать ненужных пересчетов и отправок массивов на видеокарту.

Необходимо добавить к структуре uniform-блока информацию о матрицах проекции и вида источника (6 штук) в файле shaders/lighting.frag:

struct LightData
{
    vec3 position;
    vec3 color;
    float angle;
    vec3 direction;
    float radius;
    vec2 K;
    mat4 vp[6];
};

Далее требуется добавить геометрический и фрагментный шейдеры для расчета теней от источников. В качестве вершинного шейдера можно использовать шейдер от расчета теней направленного источника (shaders/sun_shadow.vert).

Геометрический шейдер выполняет 6 вызовов (invocations) для каждой стороны кубической карты. В каждом вызове проходит вычисления для выбранного индекса источника света (light_i), который записан в uniform-переменную. Для каждой вершины запоминаются значения для фрагментного шейдера: позиции вершины в мировых координатах (FragPos), позиции источника в мировых координатах (lightPos) и радиуса его действия (radius); а так же вычисляется позиция вершины в пространстве источника (gl_Position) и слоя текстуры (gl_Layer). Содержимое файла shaders/point_shadow.geom:

#version 420 core
layout (triangles, invocations = 6) in; // здесь invocations соответствует числу сторон кубической карты теней
layout (triangle_strip, max_vertices=18) out; // здесь max_vertices = 3 вершины * 6 вызовов на стороны куба

struct LightData
{
    vec3 position;
    vec3 color;
    vec3 attenuation;
    vec4 direction_angle;
    mat4 vp[6];
};

layout(std140, binding = 2) uniform Light
{
    LightData data[64];
    int count;
} light_g;

uniform int light_i;

out vec4 FragPos; 
out vec3 lightPos;
out float radius;

void main()
{
    for(int i = 0; i < 3; ++i)
    {
        FragPos = gl_in[i].gl_Position;
        lightPos = light_g.data[light_i].position;
        radius = light_g.data[light_i].attenuation.r;
        gl_Position = light_g.data[light_i].vp[gl_InvocationID] * gl_in[i].gl_Position;
        gl_Layer = gl_InvocationID + light_i*6; 
        EmitVertex();
    }    
    EndPrimitive();
} 

Фрагментный шейдер вычисляет расстояние между источником и фрагментом (в диапазоне [0;1]) и записывает его в качестве значения буфера глубины. Содержимое файла shaders/point_shadow.frag:

#version 330 core

in vec4 FragPos;
in vec3 lightPos;
in float radius;

void main()
{
    // Расстояние между источником и фрагментом
    float lightDistance = length(FragPos.xyz - lightPos);
    
    // Приведение к диапазону [0;1]
    lightDistance = lightDistance / radius;
    
    // Замена значения глубины
    gl_FragDepth = lightDistance;
}

Теперь загрузим шейдеры в файле src/main.cpp:

    // Шейдер для расчета теней
    ShaderProgram pointShadowShader;
    // Загрузим шейдер
    pointShadowShader.load(GL_VERTEX_SHADER, "shaders/sun_shadow.vert");
    pointShadowShader.load(GL_GEOMETRY_SHADER, "shaders/point_shadow.geom");
    pointShadowShader.load(GL_FRAGMENT_SHADER, "shaders/point_shadow.frag");
    pointShadowShader.link();

Далее требуется подключить атлас теней к шейдеру расчета освещения:

     const char* gtextures_shader_names[]  = {"gPosition", "gNormal", "gDiffuseP", "gAmbientSpecular", "sunShadowDepth", "pointShadowDepth"};
     lightShader.bindTextures(gtextures_shader_names, sizeof(gtextures_shader_names)/sizeof(const char*));
     // Загрузка данных о границах каскадов
     glUniform1fv(lightShader.getUniformLoc("camera_cascade_distances"), CAMERA_CASCADE_COUNT, &camera_cascade_distances[1]);

А так же использовать текстуру при вычислении освещения в цикле:

...
         gAmbientSpecular.use();
         // Подключаем текстуры теней
         sunShadowDepth.use();
         pointShadowDepth.use();
         // Загружаем информацию о направленном источнике
         sun.upload(lightShader);
...

Так как поле количества источников является приватным (Light::count), требуется добавить метод доступа к значению Light::getCount в файле include/Lights.h:

// Источник света
class Light : public Node
{
    public:
...
        static int getCount(); // Возвращает количество источников
...
};

Реализация данного метода в файле src/Lights.cpp:

// Возвращает количество источников
int Light::getCount()
{
    return count;
}

Теперь можно выполнить рендер теневых карт в атлас после рендера теней от направленного источника, задав значения индекса источника и произведя рендер сцены для каждого источника (файл src/main.cpp):

        // Изменим размер вывода для стороны кубической карты точечного источника
                glViewport(0, 0, pointShadow_resolution, pointShadow_resolution);
        // Активируем буфер кадра для теней от солнца
        pointShadowBuffer.use();
        // Подключим шейдер для расчета теней
        pointShadowShader.use();
        // Очистка буфера глубины
        glClear(GL_DEPTH_BUFFER_BIT);
        // Для каждого источника вызывается рендер сцены
        for (int i = 0; i < Light::getCount(); i++)
        {
            glUniform1i(pointShadowShader.getUniformLoc("light_i"), i);
            // Рендерим геометрию в буфер глубины
            scene.render(pointShadowShader, material_data);  
            rectangle.render(pointShadowShader, material_data);
        }

Перед вычислением затененности в фрагментном шейдере shaders/lighting.frag необходимо добавить uniform-переменную с семплером pointShadowDepth:

uniform sampler2D gPosition;
uniform sampler2D gNormal;
uniform sampler2D gDiffuseP;
uniform sampler2D gAmbientSpecular;
uniform sampler2DArray sunShadowDepth;
uniform samplerCubeArray pointShadowDepth;

А так же три переменные:

    int x, y, z; // Счетчик для PCF
    float pcfDepth; // Глубина PCF
    int cascade_index; // Индекс текущего каскада для вычисления теней
    float cubemap_offset = 0.05f; // Отступ в текстурных координатах для PCF
    float cubemap_depth; // Дистанция между фрагментом и источником в диапазоне [0;1]

В цикле по индексам источников света (i) для каждого источника значение тени обнуляется, после чего вычисляется позиция фрагмента относительно источника (вычитание векторов), дистанция между фрагментом и источником (длина вектора, деленная на радиус источника). К дистанции применяется сдвиг для решения проблемы теневого акне. Далее три вложенных цикла по осям выполняют фильтрацию на процент ближе (PCF). Если значение текстуры меньше, чем дистанция между фрагментом и источником, то следует инкрементировать значение тени. После цикла значение тени делится на 27 (3 образца по каждой оси для PCF). Если значение меньше единицы, то следует приступать к вычислению освещенности. Итоговую сумму цветов диффузного и зеркального отражений следует умножить на (1.0 — shadowValue).

Для получения значения из массива кубических текстур (3D кубическая текстура) в функцию texture передается вектор от 4 компонентов, где первые три компоненты определяют координату кубической текстуры, а четвертый задает индекс в атласе (индекс источника).

Содержимое файла shaders/lighting.frag:

    // Цикл по источникам света
    int i;
    for (i = 0; i < light_f.count; i++)
    {
        // Обнулим значение тени
        shadowValue = 0;
        // Позиция фрагмента относительно источника
        fragPosLightSpace = fragPos - light_f.data[i].position;
        // Дистанция между фрагментом и источником в диапазоне [0;1]
        cubemap_depth = length(fragPosLightSpace) / light_f.data[i].attenuation.r;
        // Сдвиг для решения проблемы акне
        cubemap_depth -= max(0.05 * (1.0 - dot(N, light_f.data[i].direction_angle.xyz)), 0.005);
        for(x = -1; x <= 1; ++x)
        {
            for(y = -1; y <= 1; ++y)
            {
                for(z = -1; z <= 1; ++z)
                {
                    // Значение из кубической текстуры с учетом источника (i)
                    pcfDepth = texture(pointShadowDepth, vec4(fragPosLightSpace + vec3(x, y, z)*cubemap_offset, i)).r; 
                    if(cubemap_depth > pcfDepth)
                        shadowValue += 1.0;
                }
            }
        }
        shadowValue /= (27);
        if (shadowValue < 1.0)
        {
            // Данные об источнике относительно фрагмента
            L_vertex = light_f.data[i].position - fragPos;
            // Расстояние от поверхности до источника
            L_distance = length(L_vertex);
...
                    color += ( vec4(light_f.data[i].color*kd*diffuse  * attenuation, 1) 
                              + vec4(light_f.data[i].color*ks*specular * attenuation, 1) )  * (1.0 - shadowValue);
        }
   }

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

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

Изменение параметров сцены

Для наглядности изображения результатов полученных теней изменим параметры источников на сцене в файле src/main.cpp:

    // Источники света
    Light& first = Light::getNew();
    first.e_color() = {1.0f, 0.0f, 0.0f}; // цвет
    first.e_position() = {0.3f, 0.0f, 0.6f}; // Позиция
    first.e_angle() = 100.0f;
    Light& second = Light::getNew();
    second.e_color() = {0.0f, 0.0f, 1.0f}; // цвет
    second.e_position() = {-0.3f, 0.3f, 0.5f}; // Позиция

А так же характеристики материала плоскости, на которую проецируются тени — убавив фоновую освещенность и увеличив коэффициент диффузного отражения:

    // Параметры материала
    rectangle.material.ka = {0.2, 0.2, 0.2};
    rectangle.material.kd = {0.9, 0.9, 0.9};

Результат работы приложения представлен на рисунках 2-4.

Рисунок 2 — Пример теней от красного прожектора и направленного белого источника
Рисунок 3 — Пример теней от синего точечного и направленного белого источников
Рисунок 4 — Пример теней от красного прожектора, синего точечного и направленного белого источников

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

Заключение

В данной заметке рассмотрена работа с массивами кубических текстур (3D кубические текстуры), создан класс для упрощения работы с ними, реализован алгоритм расчета теней для точечного источника.

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

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

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

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