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