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

OpenGL 14: тени ч.3 — SSAO

Данная заметка посвящена реализации алгоритма SSAO (screen space ambient occlusion)

Введение

Метод ambient occlusion (окружающего преграждения) реализует расчет освещенности фрагмента с учетом влияния окружения, которое может преградить путь к лучам. В данной заметке рассматривается упрощенная модель: окружающее преграждение пространства экрана (screen space ambient occlusion). Подробнее о технике можно прочитать в соответствующем разделе заметки о теории освещения в 3D приложениях. Автор рекомендует ознакомиться с теорией по ссылке для вычисления полусферы выборок.

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

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

Модификация класса текстуры

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

Добавим конструктор класса Texture без привязки к буферу, загружающий массив пикселей заданного размера и формата, в файл include/Texture.h:

// Класс 2D текстуры
class Texture : public BaseTexture
{
    public:
        Texture(TexType type = TEX_AVAILABLE_COUNT, const std::string& filename = ""); // Загрузка текстуры с диска или использование "пустой"
        Texture(GLuint width, GLuint height, GLuint attachment, GLint internalformat = GL_RGBA, GLint format = GL_RGBA, GLenum dataType = GL_FLOAT, TexType texType = TEX_DIFFUSE); // Конструктор текстуры заданного размера для использования в буфере
        Texture(GLuint width, GLuint height, void* data, GLint internalformat = GL_RGBA, GLint format = GL_RGBA, GLenum dataType = GL_FLOAT); // Конструктор текстуры заданного размера без привязки к буферу с загрузкой пикселей по указателю
        Texture(const Texture& other); // Конструктор копирования

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

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

Реализация данного конструктора в файле src/Texture.cpp:

// Конструктор текстуры заданного размера без привязки к буферу с загрузкой пикселей по указателю
Texture::Texture(GLuint width, GLuint height, void* data, GLint internalformat, GLint format, GLenum dataType)
{
    // Генерация текстуры заданного размера
    glGenTextures(1, &handler);
    glBindTexture(GL_TEXTURE_2D, handler);
    glTexImage2D(GL_TEXTURE_2D, 0, internalformat, width, height, 0, format, dataType, data);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);

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

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

Реализация метода SSAO

Создадим буфер в файле src/main.cpp, который будет рендерить в текстуру результат расчета SSAO:

    // Создадим буфер для вычисления SSAO
    GLuint attachments_ssao[] = { GL_COLOR_ATTACHMENT0 };
    FBO ssaoBuffer(attachments_ssao, sizeof(attachments_ssao) / sizeof(GLuint));
    // Создадим текстуры для буфера кадра
    Texture ssaoTexture(WINDOW_WIDTH, WINDOW_HEIGHT, GL_COLOR_ATTACHMENT0, 6, GL_RED, GL_RED); 
    // Активируем базовый буфер кадра
    FBO::useDefault();

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

// Указатели на текстуры для изменения размеров окна
Texture* pssaoTexture = NULL;
...
// Функция-callback для изменения размеров буфера кадра в случае изменения размеров поверхности окна
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
...
    // SSAO
    if (pssaoTexture)
        pssaoTexture->reallocate(width, height, 6, GL_RED, GL_RED);
}

И после создания текстуры запомним её адрес:

    // Создадим текстуры для буфера кадра
    Texture ssaoTexture(WINDOW_WIDTH, WINDOW_HEIGHT, GL_COLOR_ATTACHMENT0, 6, GL_RED, GL_RED); 
    pssaoTexture = &ssaoTexture;
    // Активируем базовый буфер кадра

Подключим текстуру к шейдеру расчета освещения:

    // Шейдер для расчета освещенности
    ShaderProgram lightShader;
    ...
    const char* gtextures_shader_names[]  = {"gPosition", "gNormal", "gDiffuseP", "gAmbientSpecular", "sunShadowDepth", "pointShadowDepth", "ssao"};
    lightShader.bindTextures(gtextures_shader_names, sizeof(gtextures_shader_names)/sizeof(const char*));
    ...

Подключим текстуру из буфера SSAO для расчетов освещенности в рендер расчетов освещения:

...
        // Подключаем текстуры G-буфера
        gPosition.use();
        gNormal.use();
        gDiffuseP.use();
        gAmbientSpecular.use();
        // Подключаем текстуры теней
        sunShadowDepth.use();
        pointShadowDepth.use();
        // Подключаем текстуру SSAO
        ssaoTexture.use();
...

В процессе вычисления SSAO используются типовые параметры:

  • радиус полусферы (radius);
  • смещение (bias) — для нейтрализации акне;
  • количество выборок (size);
  • масштаб текстуры шума (scale) — отношение размера текстуры SSAO к текстуре шума по осям XY.
  • выборки (samples) — массив векторов.

Примечание: термин из теней «акне» тут не совсем применим при детализации, рассматриваемой при SSAO, но в целом подходит по смыслу.

Для задания максимального размера массива выборок объявим макро-константу в файле include/Lights.h:

// Максимальное число образцов для SSAO
#define MAX_SSAO 64

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

// Данные для SSAO
struct SSAO_data
{
    float radius = 0.5f;
    float bias = 0.025f;
    int size = MAX_SSAO;
    alignas(16) glm::vec2 scale;
    glm::vec3 samples[MAX_SSAO];
};

Важное замечание: для массива векторов samples не требуется выравнивание в отличии от вектора scale.

В файле src/main.cpp необходимо задать масштаб текстуры шума (SSAO_data::scale) и сгенерировать случайные векторы для выборок.

Для генерации случайных векторов используется std::uniform_real_distribution<GLfloat> в диапазоне [0.0, 1.0] и std::default_random_engine, для которых необходимо подключить заголовочный файл random:

#include <random>

В цикле (i) по количеству выборок требуется вычислить вектор sample.

Для осей X и Y значения генерируются по следующей формуле:
randomFloats(generator) * 2.0 — 1.0 — в диапазоне [-1.0; 1.0].

Для оси Z:
randomFloats(generator) — в диапазоне [0.0; 1.0].

Полученный вектор необходимо нормировать. Для большей точности вектор можно домножить на случайное число, которое дополнительно уменьшит его. Так как случайный генератор работает по нормальному распределению, у которого значения чаще выпадают ближе к центру диапазона, то требуется модифицировать их, домножив на следующее выражение:
sample *= 0.1 + 0.9 * (i / MAX_SSAO)2,
где i — счетчик цикла, MAX_SSAO — число выборок, 0.1 — минимальное значение, 0.9 — разность максимального и минимального значений. Данные преобразования позволят отмасштабировать вектор ближе к его началу.

Настройка параметров и генерация выборок SSAO в файле src/main.cpp:

    // Стандартные параметры SSAO
    SSAO_data ssao_data;
    // Расчет масштабирования текстуры шума
    ssao_data.scale = {WINDOW_WIDTH/4,WINDOW_HEIGHT/4};
    // Генерируем случайные векторы
    std::uniform_real_distribution<GLfloat> randomFloats(0.0, 1.0); // Генерирует случайные вещественные числа в заданном диапазоне
    std::default_random_engine generator;
    glm::vec3 sample; // Выборка
    for (int i = 0; i < ssao_data.size; i++)
    {
        sample = {  randomFloats(generator) * 2.0 - 1.0
                  , randomFloats(generator) * 2.0 - 1.0
                  , randomFloats(generator)
                 };
        sample = glm::normalize(sample);
        sample *= randomFloats(generator);
        
        // Отмасштабируем выборку
        sample *= 0.1 + 0.9 * (i / (float)ssao_data.size) * (i / (float)ssao_data.size);
        ssao_data.samples[i] = sample;
    }
    // Загрузка данных в uniform-буфер
    UBO ssaoUB(&ssao_data, sizeof(SSAO_data), 4);

Теперь требуется сгенерировать текстуру шума и присвоить ей второй слот:

    // Текстура шума
    glm::vec3 noise_vecs[16];
    for (int i = 0; i < 16; i++)
        noise_vecs[i] = {randomFloats(generator) * 2.0 - 1.0, randomFloats(generator) * 2.0 - 1.0, 0.0f};
    Texture noiseTexture(4, 4, noise_vecs, 2, GL_RGBA32F, GL_RGB);

Для вычислений на видеокарте потребуется создать только фрагментный шейдер, так как в качестве вершинного можно использовать шейдер полноэкранного прямоугольника shaders/quad.vert:

#version 420 core 

layout(location = 0) in vec3 pos; 

out vec2 texCoord;

void main() 
{ 
    gl_Position = vec4(pos, 1.0);
    texCoord = (pos.xy + vec2(1.0)) / 2; // Переход от [-1;1] к [0;1]
}

Фрагментный шейдер использует три текстуры: позиция в мировых координатах, нормаль к поверхности и шум (для случайных чисел). Получим доступ к параметрам камеры и алгоритма SSAO из соответствующих uniform-блоков.

Подготовим данные из текстур по соответствующим текстурным координатам:

  • fragPos — позиция в пространстве камеры;
  • normal — нормаль;
  • randomVec — случайный вектор из текстуры шума (текстурная координата домножается на масштаб — SSAO_data::scale).

Важное замечание: в g-буфер записывается позиция в мировом пространстве для правильного вычисления освещения, так что требуется дополнительное приведение в пространство камеры.

Далее требуется вычислить матрицу TBN для поворота полусферы.

Перед запуском цикла требуется проинициализировать переменную-счетчик значения преграждения (occlusion) и объявить дополнительные переменные:

  • sampleDepth — значение глубины образца выборки;
  • samplePos — вектор выборки, ориентированный в пространстве вида камеры;
  • sampleCoord — вектор выборки, преобразованный к текстурным координатам.

В цикле вычисляются значения:

  1. вектора выборки путем умножения его на матрицу TBN для приведения в TBN-пространство и последующим домножением на радиус и прибавлением к позиции в пространстве камеры, что дает в результате вектор в пространстве камеры;
  2. вектора выборки, преобразованного к текстурным координатам, путем домножения матрицы проекции на него, исправление искажений перспективы и приведению значений по осям XY к диапазону [0.0; 1.0];
  3. значения глубины на основании полученного вектора выборки в качестве текстурных координат для текстуры с позициями фрагментов;
  4. значения счетчика, если значение глубины по образцу выборки больше или равно значению глубины выборки в пространстве камеры с учетом отступа (SSAO_data::bias).

После цикла следует разделить значение счетчика преграждения на количество выборок и инвертировать.

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

#version 420 core

in vec2 texCoord;

out float occlusion;

uniform sampler2D gPosition;
uniform sampler2D gNormal;
uniform sampler2D noise;

layout(std140, binding = 0) uniform Camera
{
    mat4 projection;
    mat4 view;
    vec3 position;
} camera;

layout(std140, binding = 3) uniform SSAO
{
    float radius;
    float bias;
    int size;
    vec2 scale;
    vec3 samples[64];
} ssao;

void main()
{
    // Получим информацию из текстур для данного фрагмента по текстурным координатам
    vec3 fragPos = (camera.view * vec4(texture(gPosition, texCoord).xyz, 1)).xyz;
    vec3 normal = normalize(texture(gNormal, texCoord).rgb);
    vec3 randomVec = normalize(texture(noise, texCoord * ssao.scale).xyz);
    // Расчет TBN матрицы
    vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
    vec3 bitangent = cross(normal, tangent);
    mat3 TBN = mat3(tangent, bitangent, normal);
    
    float sampleDepth; // Значение глубины образца выборки
    vec3 samplePos; // Выборка, ориентированная в пространстве вида камеры
    vec4 sampleCoord; // Выборка, преобразованная к текстурным координатам
    
    // Проинициализируем значение счетчика и запустим цикл по выборкам
    occlusion = 0;
    for(int i = 0; i < ssao.size; i++)
    {
        samplePos = TBN * ssao.samples[i]; // в TBN-пространстве
        samplePos = fragPos + samplePos * ssao.radius; // в пространстве вида камеры
        
        sampleCoord = camera.projection * vec4(samplePos, 1.0); 
        sampleCoord.xyz /= sampleCoord.w; // Деление на значение перспективы
        sampleCoord.xyz = sampleCoord.xyz * 0.5 + 0.5; // Трансформация в диапазон [0.0; 1.0]
        
        // Получаем значение глубины по образцу выборки
        sampleDepth = (camera.view * vec4(texture(gPosition, sampleCoord.xy).rgb, 1)).z; 

        occlusion += (sampleDepth >= samplePos.z + ssao.bias ? 1.0 : 0.0);
    }

    occlusion = 1 - (occlusion / ssao.size);
}

Загрузим шейдеры, привязав текстуры из g-буфера и шума, после генерации текстуры шума в файле src/main.cpp:

    // Шейдер для расчета SSAO
    ShaderProgram ssaoShader;
    // Загрузим шейдер
    ssaoShader.load(GL_VERTEX_SHADER, "shaders/quad.vert");
    ssaoShader.load(GL_FRAGMENT_SHADER, "shaders/ssao.frag");
    ssaoShader.link();
    // Текстуры, используемые в шейдере
    const char* ssaoShader_names[]  = {"gPosition", "gNormal", "noise"};
    ssaoShader.bindTextures(ssaoShader_names, sizeof(ssaoShader_names)/sizeof(const char*));

В цикле рисования выполним рендер в буфер SSAO, подключив текстуры позиций и нормалей из g-буфера, а так же текстуру шума.

        // Активируем буфер SSAO
        ssaoBuffer.use();
        // Используем шейдер для расчета SSAO
        ssaoShader.use();
        // Очистка буфера цвета и глубины
        glClear(GL_COLOR_BUFFER_BIT);
        // Подключаем текстуры G-буфера
        gPosition.use();
        gNormal.use();
        // Подключаем текстуру шума для SSAO
        noiseTexture.use();
        // Рендерим прямоугольник
        quadModel.render();

Осталось дополнить фрагментный шейдер вычисления освещения (shaders/lighting.frag), применив текстуру SSAO.

Добавим текстуру:

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

Получим данные из текстуры по текстурным координатам:

    // Получим данные из текстур буфера
    vec3 fragPos = texture(gPosition, texCoord).rgb;
...
    float ssao_value = texture(ssao, texCoord).r;

Применим данную текстуру, помножив фоновую составляющую на полученное значение (ssao_value):

    // Фоновая освещенность
    color = vec4(ka, 1) * ssao_value;

Результат работы приложения без SSAO и с ним представлен на рисунке 1.

Рисунок 1 — Результат работы приложения без SSAO и с ним

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

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

Проверка диапазона

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

Добавим переменную rangeCheck перед циклом в файле shaders/ssao.frag:

    float sampleDepth; // Значение глубины образца выборки
    vec3 samplePos; // Выборка, ориентированная в пространстве вида камеры
    vec4 sampleCoord; // Выборка, преобразованная к текстурным координатам
    float rangeCheck; // Проверка диапазона

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

        rangeCheck = smoothstep(0.0, 1.0, ssao.radius / abs(fragPos.z - sampleDepth));
        occlusion += (sampleDepth >= samplePos.z + ssao.bias ? 1.0 : 0.0) * rangeCheck;

Сравнение значений из текстур SSAO представлено на рисунке 2, а сравнение результатов без и с проверкой диапазона на рисунке 3.

Рисунок 2 — Сравнение значений из текстур SSAO без и с проверкой глубины
Рисунок 3 — Сравнение результатов SSAO без и с проверкой глубины

По рисунку 3 можно сделать вывод, что картинка стала светлее, а так же пропали ненужные переходы на границах моделей плоскости и капли.

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

Размытие

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

Переименуем текстуру ssaoTexture в ssaoTexture_raw, созданную в файле src/main.cpp при создании буфера ssaoBuffer, а так же изменим точку её привязки на 0:

    // Создадим буфер для вычисления SSAO
    GLuint attachments_ssao[] = { GL_COLOR_ATTACHMENT0 };
    FBO ssaoBuffer(attachments_ssao, sizeof(attachments_ssao) / sizeof(GLuint));
    // Создадим текстуры для буфера кадра
    Texture ssaoTexture_raw(WINDOW_WIDTH, WINDOW_HEIGHT, GL_COLOR_ATTACHMENT0, 0, GL_RED, GL_RED); 
    pssaoTexture_raw = &ssaoTexture_raw;
    // Активируем базовый буфер кадра
    FBO::useDefault();

А так же создадим дополнительный буфер для размытия ssaoBlurBuffer:

    // Создадим буфер для размытия SSAO
    FBO ssaoBlurBuffer(attachments_ssao, sizeof(attachments_ssao) / sizeof(GLuint));
    // Создадим текстуры для буфера кадра
    Texture ssaoTexture(WINDOW_WIDTH, WINDOW_HEIGHT, GL_COLOR_ATTACHMENT0, 6, GL_RED, GL_RED); 
    pssaoTexture = &ssaoTexture;
    // Активируем базовый буфер кадра
    FBO::useDefault();

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

// Указатели на текстуры для изменения размеров окна
Texture* pssaoTexture = NULL;
Texture* pssaoTexture_raw = NULL;
...
// Функция-callback для изменения размеров буфера кадра в случае изменения размеров поверхности окна
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
...
    // SSAO
    if (pssaoTexture)
        pssaoTexture->reallocate(width, height, 6, GL_RED, GL_RED);
    if (pssaoTexture_raw)
        pssaoTexture_raw->reallocate(width, height, 0, GL_RED, GL_RED);
...

В качестве вершинного шейдера может использоваться shaders/quad.vert, а фрагментный шейдер shaders/ssaoBlur.frag имеет следующий вид:

#version 330 core

in vec2 texCoord;

out float occlusion;

uniform sampler2D ssao;

void main() 
{
    vec2 texelSize = 1.0 / vec2(textureSize(ssao, 0));
    vec2 offset;
    occlusion = 0.0;
    for (int x = -2; x < 2; x++) 
    {
        for (int y = -2; y < 2; y++) 
        {
            offset = vec2(x, y) * texelSize;
            occlusion += texture(ssao, texCoord+ offset).r;
        }
    }
    occlusion = occlusion / (4.0 * 4.0);
}  

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

    // Шейдер для размытия SSAO
    ShaderProgram ssaoBlurShader;
    // Загрузим шейдер
    ssaoBlurShader.load(GL_VERTEX_SHADER, "shaders/quad.vert");
    ssaoBlurShader.load(GL_FRAGMENT_SHADER, "shaders/ssaoBlur.frag");
    ssaoBlurShader.link();

Примечание: текстуру привязывать не обязательно, так как точка привязки 0 ставится по умолчанию.

Добавим вычисление размытия в цикл рисования

        // Активируем буфер SSAO
        ssaoBlurBuffer.use();
        // Используем шейдер для расчета SSAO
        ssaoBlurShader.use();
        // Очистка буфера цвета
        glClear(GL_COLOR_BUFFER_BIT);
        // Подключаем текстуру сырого SSAO
        ssaoTexture_raw.use();
        // Рендерим прямоугольник
        quadModel.render();

Сравнение текстур без и с размытием изображено на рисунке 4.

Рисунок 4 — Сравнение текстур SSAO без и с размытием

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

Рисунок 5 — Результат работы приложения с SSAO

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

Дополнение

На теге v0.4 используется значение радиуса полусферы равное 0.5, что является излишне большим радиусом для масштабов сцены, что можно заметить по рисунку 4 — капля и ровная поверхность полностью покрашены в серый с незначительными изменениями в местах где предполагается затенение.

Для решения данной проблемы необходимо уменьшить значение по умолчанию для радиуса полусферы в файле include/Lighting.h:

// Данные для SSAO
struct SSAO_data
{
    float radius = 0.05f;
    float bias = 0.025f;
    int size = MAX_SSAO;
    alignas(16) glm::vec2 scale;
    glm::vec3 samples[MAX_SSAO];
};

На рисунке 6 представлена текстура SSAO с меньшим радиусом полусферы.

Рисунок 6 — Текстура SSAO с радиусом полусферы 0.05f

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

Рисунок 7 — Использование корректной текстуры SSAO

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

Заключение

В данной заметке рассмотрен и реализован алгоритм расчета окружающего заграждения в пространстве экрана (screen space ambient occlusion), обнаружены и устранены распространенные артефакты.

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

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

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

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