В этой статье я расскажу, как я с нуля делал SSAO (Screen Space Ambient Occlusion — расчёт фонового освещения в экранном пространстве) с учетом нормалей. Сразу следует отметить, что это наиболее простая и прямолинейная реализация «в лоб», не претендующая на оптимальность или новизну. Статья будет полезна в первую очередь тем, кто имеет желание разобраться, как это работает.
Как-то захотелось мне поупражняться с графикой, и я решил сделать SSAO с нуля, опираясь на мои опыты с трассировкой лучей и на полученные ранее знания о том, как в целом это должно работать. В общем, поставил задачу написать к своему движку демку, с использованием всяких разных технологий. Решено было также поизучать deferred shading и screen-space local reflections, но об этом как-нибудь в другой раз. В этой статье сконцентрируюсь на SSAO.
Что нам говорит Википедия, по поводу ambient occlusion:
Модель затенения, используемая в трёхмерной графике и позволяющая добавить реалистичности изображению за счёт вычисления интенсивности света, доходящего до точки поверхности. В отличие от локальных методов, как например затенение по Фонгу, ambient occlusion является глобальным методом, то есть значение яркости каждой точки объекта зависит от других объектов сцены. В принципе, это достаточно отдалённо напоминает глобальное освещение.
Получается, что нам нужно рассчитать, сколько света доходит до конкретной точки из полусферы, ориентированной по нормали в этой точке. Я даже как смог нарисовал в фотошопе картинку:
Что мы тут видим:
Сверху расположена камера, которая смотрит на нашу сцену.
Разными цветами показаны точки на объекте, их нормали и полусферы, по которым мы будем собирать затенение.
Точка, обозначенная фиолеовым ничем не затенена.
Точка, обозначенная желтым — затенена совсем чуть-чуть.
Точка, обозначенная голубым — затенена практически наполовину.
А вот точка на заднем объекте, обозначенная оранжевым, по идее, с точки зрения камеры частично перекрыта передним объектом, но так как она находится относительно далеко от объекта, то по факту передний объект эту точку не затеняет. С этим нам придется бороться отдельно, чтобы избежать неприятных артефактов в виде темных силуэтов объектов.
Таким образом, нам нужно рассчитать затенение для каждой точки, учитывая расстояние до объекта, который её перекрывает. Это и будет наш ambient occlusion.
Я решил в отличие от «традиционного» SSAO (например того, который, если я правильно помню, использоваться в первом Crysis) рассчитывать это не в screen space, а во view space. Минус этого подхода в большей сложности вычислений (хотя тоже, надо смотреть, проверять и сравнивать), плюс — в более точном AO.
2. Подготовка
Итак, для расчета ambient occlusion нам понадобятся две текстуры: глубины и нормалей.
Как я уже сказал, нормали будут во view space. Как сохранять и восстанавливать нормали — ваше дело, я, например, использую хитрую функцию, которая записывает нормаль в две компонеты. Подробнее об этом в последнем разделе «Ништяки». Пока, пускай у нас будут две функции в шейдере:
Вернее, первая функция тут даже и не нужна, потому что она используется при записи нормали.
Выглядеть текстура с нормалями будет примерно вот так:
+ Показать
− Скрыть
Текстура с глубиной у нас будет хранить «стандартную» глубину OpenGL.
Значения глубины, приведенные к интервалу [-1..1] и возведенные в 64-ю степень, у меня выглядят как-то так:
+ Показать
− Скрыть
Но, так как мы будем использовать view space нам нужно будет восстанавливать положение точки во view space по глубине. О том, как это сделать смотрите в последнем разделе «Ништяки». Пока, пускай у нас будут четыре функции в шейдере:
Также, для того, чтобы придать разнообразия нашему расчету AO, нам понадобится текстура с шумом. Самая обычная текстура с шумом, я даже показывать её здесь не буду. В дополнение к этой текстуре нам специально для неё нужны будут текстурные координаты. Такие, чтобы текстура рисовалась на экране тексель в пиксель. По большому счету, это не обязательно, но очень желательно, чтобы выборки были «более случайными».
Итого, на входе во фрагментный шейдер у нас есть три текстуры и два набора текстурных координат.
Надо заметить, что в моем движке для того, чтобы поддерживались разные версии шейдеров, сделаны следующие штуки:
Входящая переменная во фрагментный шейдер — etFragmentIn. В старых шейдерах заменяется на varying, в новых на in.
Результат фрагментного шейдера записывается в переменную etFragmentOut (gl_FragColor в старых шейдерах и "out vec4 ..." + glBindFragDataLocation в новых версиях).
Теперь можно приступить непосредственно к расчету нашего затенения.
3. Расчет SSAO
Общая идея такова: в данной точке получить положение и нормаль, затем сгенерировать несколько случайных направлений на полусфере, заданой нормалью, и проверить затенения в них. Результат собрать и поделить на количество выборок. Таким образом мы хотим контролировать как минимум три параметрa:
1) количество выборок;
2) минимальное расстояние, на котором мы проверяем затенение (оно нужно нам, чтобы избавиться от некоторых неприятных артефактов);
3) максимальное расстояние, на котором мы проверяем затенение;
Вот пару картинок для сравнения параметров: количество выборок — чем больше, тем более плавное и красивое затенение у нас получается:
Максимальное расстояние — чем оно больше, тем «шире» и мягче у нас затенение:
Для тестовой сцены (Crytek Sponza) я использовал такие параметры:
К сожалению, мне пока не пришло в голову, как можно избавиться от этих параметров и вычислять их, исходя из того, что у нас есть на экране. Буду рад, если кто-то подскажет, куда двигаться в этом направлении.
Итак, у нас все есть для того, чтобы рассчитать затенение каждой точки на экране. Для начала нам нужно найти нормаль в этой точке и её положение (не забывайте, что мы работает во view space). Делается это просто чтением нормали из текстуры и восстановлением положения по глубине:
Функция etTexture2D — это тоже магия моего движка. Для старых версий GLSL она превращается в texture2D, для новых в texture.
Теперь, чтобы не городить все в теле функции main(), заведем специальную функцию, которая рассчитывает затенение в данной точке. Я пафосно назвал её performRaytracingInViewSpace:
float performRaytracingInViewSpace(in vec3 vp, in vec3 vn, in vec4 noise)
Ну, и собственно, чтобы не томить, остаток шейдера:
То есть здесь мы просто подсовываем в эту функцию начальные параметры для текущей точки и некую псевдослучайную величину, которую мы потом обновляем (читаем из текстуры шума по новым координатам).
Таким образом, весь секрет у нас в функции расчета затенения. Давайте рассмотрим её поближе.
Здесь нам нужно сгенерировать случайное направление на полусфере, заданой нормалью в точке. Я это делаю очень просто: нормализую значение из текстуры шума, и если оно лежит в другой полуплоскости от нужной нам нормали, то умножаю на –1. Выглядит это вот так:
vec3 randomVectorOnHemisphere(in vec3 normal, in vec3 noise)
{
vec3 n = normalize(noise - 0.5);
return n * sign(dot(n, normal));
}
Так как нам нужна вся полусфера, и у нас нет какого-то предпочитаемого направления, то эта функция очень даже подходит. Если нужно делать выборки в некоем конусе — в движке есть функция для этого (могу показать, если сами не найдете).
Теперь у нас есть случайное направление, по которому мы будем делать выборку. Мы сдвигаем точку в этом направлении на случайную величину между MIN_SAMPLE_SIZE и SAMPLE_SIZE и проецируем её в screen space. После чего получаем некие новые текстурные координаты и глубину в интервале [0..1].
Далее, мы смотрим, какая глубина у нас находится по новым текстурным координатам, делаем новую выборку из текстуры глубины. Затем проверяем: если новая глубина оказалась больше, чем та, которую мы получили после проецирования, значит новая точка лежит дальше нашей и перекрывать её не может, перекрытия нет — возвращаем ноль из функции:
А дальше, когда мы определили, что новая точка лежит ближе к камере, чем наша спроецированная, начинается магия затенения. Что у нас есть на входе:
— глубина нашей спроецированной точки (которая гарантировано больше, чем новая глубина);
— глубина, которую мы получили после выборки (которая гарантирована меньше, чем глубина спроецированной точки).
Что нам нужно вычислить:
— насколько сильно объект (на точку которого мы наткнулись) перекрывает нашу исходную точку.
Что нам нужно учесть:
— чем ближе новая точка к спроецированной, тем сильнее перекрытие;
— если новая точка сильно «далеко» от спроецированной, тем меньше перекрытие.
Итого: нам надо сравнить две нелинейных глубины, которые, скорее всего, близки к единице. Можно еще раз восстановить линейную глубину, а можно сделать небольшой хак и получить значение, которое характеризует глубину. После некоторых экспериментов, я пришел к выводу, что функция вида
оценка = 1.0 / sqrt(1.0 - глубина);
лучше всего подходит для получения такой оценки.
Итого, у нас есть два значения оценки глубины, возьмем между ними разницу, которая будет характеризовать расстояние одной точки от другой:
Для тестовой модели я использовал значение DEPTH_DIFFERENCE_SCALE равным 3.33333. Все зависит от масштабов того, что мы рисуем и на чём хотим вычислять затенение.
Теперь у нас есть расстояние между точками, давайте вычислим степень затенения. Опять таки, после многочисленных экспериментов, я пришел к выводу, что лучше всего описывает затенение функция вида:
затенение = 1.0 / (1.0 + расстояние2)
Чтобы сделать его более мягким и приятным, еще можно учитывать расстояние, на которое мы делали выборку (приведенное к промежутку [0..1]). Итоговая формула выглядит вот так:
Вот как влияет масштаб расстояний (тот, который DEPTH_DIFFERENCE_SCALE)
При уменьшении расстояние (при DEPTH_DIFFERENCE_SCALE < 1.0) за объектами появляются темные ореолы. При увеличении масштаба затенение становится практически незаметным.
Ну вот, собственно и все, для каждой точки мы посчитали затенение.
4. Расчет отражения света
Теперь, когда у нас есть такая крутая функция расчета затенения подумаем, можно ли как-то расширить её? Да, мы практически не напрягаясь можем рассчитать первое отражение цвета (я так понимаю, получится алгоритм вроде screen space direction occlusion — ищите его в гугле). Все, что нам нужно — это сделать выборку из текстуры с цветом по новым координатам и учесть влияние этого цвета на текущую точку. Будем считать, что влияние цвета прямо пропорционально тому, насколько новая точка перекрывает нашу исходную. Следовательно, теперь наша модная функция performRaytracingInViewSpace возвращает не float, а цвет (vec4), а в альфа компоненте будем хранить затенение. Также нам понадобится текстура с цветом. Так как у меня в демке используется deferred shading, то она у меня уже есть, вам, возможно, придется прикручивать multiple render targets или делать несколько проходов. В общем, у нас на входе появляется еще одна текстура:
uniform sampler2D texture_diffuse;
А функция теперь выглядит вот так:
vec4 performRaytracingInViewSpace(in vec3 vp, in vec3 vn, in vec4 noise)
{
...
return etTexture2D(texture_diffuse, projected.xy) * occlusion;
}
Ну и в функции main теперь у нас не float, а vec4:
В результате мы получим примерно вот такую картинку (альфа-канал не показан, а яркость увеличена в 10 раз для наглядности):
+ Показать
− Скрыть
В этой текстуре, как видно, хранится цвет, которые отражается от окружающих объектов. Воспользуемся:
На первый взгляд различий немного, но приглядитесь к правой картинке: освещение намного мягче, и на стенках есть небольшие отсветы от «штор». В общем, при минимальных затратах получилось использовать интересную технику.
5. Выводы
Итого, что у нас получилось:
1) рассчитать некое подобие глобального освещения, не передирая исходные коды шейдеров;
2) рассчитать первое отражение света с минимальными затратами и изменениями в алгоритме;
3) ну и наконец, разобраться в том, как это работает.
6. Ништяки
Как и обещал, в последней части я выкладываю куски кода:
Восстановление положения во view space из глубины и проецирование обратно:
uniform vec2 clipPlanes;
uniform vec2 texCoordScales;
#define NEAR clipPlanes.x
#define FAR clipPlanes.y
float restoreViewSpaceDistance(in float depth)
{
return(2.0 * NEAR * FAR) / (depth * (FAR - NEAR) - FAR - NEAR);
}
vec3 restoreViewSpacePosition(in vec2 texCoords, in float depth)
{
return vec3(texCoords * texCoordScales, 1.0) * restoreViewSpaceDistance(depth);
}
float projectViewSpaceDistance(in float z)
{
return(2.0 * NEAR * FAR / z + FAR + NEAR) / (FAR - NEAR);
}
vec3 projectViewSpacePosition(in vec3 viewSpace)
{
return0.5 + 0.5 * vec3((viewSpace.xy / texCoordScales) / viewSpace.z, projectViewSpaceDistance(viewSpace.z));
}
clipPlanes — это расстояние до ближней и дальней плоскости отсечения (то, что мы скармливаем в функцию установки перспективной проекции).
texCoordScales — компоненты обратной матриции проекции взятые со знаком минус.
Как я уже говорил в начале, данная реализация является самой простой и прямолинейной. Вот ссылки на некоторые другие техники, которые используются в играх: