Введение
Метод 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 — вектор выборки, преобразованный к текстурным координатам.
В цикле вычисляются значения:
- вектора выборки путем умножения его на матрицу TBN для приведения в TBN-пространство и последующим домножением на радиус и прибавлением к позиции в пространстве камеры, что дает в результате вектор в пространстве камеры;
- вектора выборки, преобразованного к текстурным координатам, путем домножения матрицы проекции на него, исправление искажений перспективы и приведению значений по осям XY к диапазону [0.0; 1.0];
- значения глубины на основании полученного вектора выборки в качестве текстурных координат для текстуры с позициями фрагментов;
- значения счетчика, если значение глубины по образцу выборки больше или равно значению глубины выборки в пространстве камеры с учетом отступа (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.
![](https://rekovalev.site/wp-content/uploads/2023/01/ogl_ssao.png)
Можно отметить, что картинка стала сильно темнее и появились артефакты в виде сетки. Для исправления сильного затемнения необходимо использовать проверку диапазона. Артефакты в виде сетки исправляются размытием.
Текущая версия доступна на теге 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.
![](https://rekovalev.site/wp-content/uploads/2023/01/ogl_ssao_range_check_textures.png)
![](https://rekovalev.site/wp-content/uploads/2023/01/ogl_ssao_range_check_results.png)
По рисунку 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.
![](https://rekovalev.site/wp-content/uploads/2023/01/ogl_ssao_blur_texture.png)
Результат работы приложения представлен на рисунке 5.
![](https://rekovalev.site/wp-content/uploads/2023/01/ogl_ssao_blur_result.png)
Текущая версия доступна на теге 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 с меньшим радиусом полусферы.
![](https://rekovalev.site/wp-content/uploads/2023/06/ssao_with_small_r-700x552.png)
На рисунке 6 можно заметить, что изображение стало сильно чище от затенений SSAO, которые должны присутствовать при больших перепадах глубины. Результат использования такой текстуры представлен на рисунке 7.
![](https://rekovalev.site/wp-content/uploads/2023/06/correct_ssao_result-700x552.png)
Текущая версия доступна на теге v0.5 в репозитории 14.
Заключение
В данной заметке рассмотрен и реализован алгоритм расчета окружающего заграждения в пространстве экрана (screen space ambient occlusion), обнаружены и устранены распространенные артефакты.
Проект доступен в публичном репозитории: 14
Библиотеки: dependencies
Ресурсы: resources