ПрограммированиеСтатьиГрафика

Screen space ambient occlusion с учетом нормалей и расчет одного отражения света.

Автор:

В этой статье я расскажу, как я с нуля делал SSAO (Screen Space Ambient Occlusion — расчёт фонового освещения в экранном пространстве) с учетом нормалей. Сразу следует отметить, что это наиболее простая и прямолинейная реализация «в лоб», не претендующая на оптимальность или новизну. Статья будет полезна в первую очередь тем, кто имеет желание разобраться, как это работает.

ao-thumbnail | Screen space ambient occlusion с учетом нормалей и расчет одного отражения света.

Как-то захотелось мне поупражняться с графикой, и я решил сделать SSAO с нуля, опираясь на мои опыты с трассировкой лучей и на полученные ранее знания о том, как в целом это должно работать. В общем, поставил задачу написать к своему движку демку, с использованием всяких разных технологий. Решено было также поизучать deferred shading и screen-space local reflections, но об этом как-нибудь в другой раз. В этой статье сконцентрируюсь на SSAO.

Для самых нетерпеливых, вот результат:

+ Показать

1. Немного теории
2. Подготовка
3. Расчет SSAO
4. Расчет отражения света
5. Выводы
6. Ништяки
Ссылки по теме

1. Немного теории

Что нам говорит Википедия, по поводу ambient occlusion:

Модель затенения, используемая в трёхмерной графике и позволяющая добавить реалистичности изображению за счёт вычисления интенсивности света, доходящего до точки поверхности. В отличие от локальных методов, как например затенение по Фонгу, ambient occlusion является глобальным методом, то есть значение яркости каждой точки объекта зависит от других объектов сцены. В принципе, это достаточно отдалённо напоминает глобальное освещение.

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

ao-sample | Screen space ambient occlusion с учетом нормалей и расчет одного отражения света.

Что мы тут видим:

Сверху расположена камера, которая смотрит на нашу сцену.

Разными цветами показаны точки на объекте, их нормали и полусферы, по которым мы будем собирать затенение.

Точка, обозначенная фиолеовым ничем не затенена.

Точка, обозначенная желтым — затенена совсем чуть-чуть.

Точка, обозначенная голубым — затенена практически наполовину.

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

Таким образом, нам нужно рассчитать затенение для каждой точки, учитывая расстояние до объекта, который её перекрывает. Это и будет наш ambient occlusion.

Я решил в отличие от «традиционного» SSAO (например того, который, если я правильно помню, использоваться в первом Crysis) рассчитывать это не в screen space, а во view space. Минус этого подхода в большей сложности вычислений (хотя тоже, надо смотреть, проверять и сравнивать), плюс — в более точном AO.

2. Подготовка

Итак, для расчета ambient occlusion нам понадобятся две текстуры: глубины и нормалей.

Как я уже сказал, нормали будут во view space. Как сохранять и восстанавливать нормали — ваше дело, я, например, использую хитрую функцию, которая записывает нормаль в две компонеты. Подробнее об этом в последнем разделе «Ништяки». Пока, пускай у нас будут две функции в шейдере:

vec2 encodeNormal(in vec3 n);
vec3 decodeNormal(in vec2 v);

Вернее, первая функция тут даже и не нужна, потому что она используется при записи нормали.

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

+ Показать

Текстура с глубиной у нас будет хранить «стандартную» глубину OpenGL.

Значения глубины, приведенные к интервалу [-1..1] и возведенные в 64-ю степень, у меня выглядят как-то так:

+ Показать

Но, так как мы будем использовать view space нам нужно будет восстанавливать положение точки во view space по глубине. О том, как это сделать смотрите в последнем разделе «Ништяки». Пока, пускай у нас будут четыре функции в шейдере:

float restoreViewSpaceDistance(in float depth)
vec3 restoreViewSpacePosition(in vec2 texCoords, in float depth)
float projectViewSpaceDistance(in float z)
vec3 projectViewSpacePosition(in vec3 viewSpace)

Также, для того, чтобы придать разнообразия нашему расчету AO, нам понадобится текстура с шумом. Самая обычная текстура с шумом, я даже показывать её здесь не буду. В дополнение к этой текстуре нам специально для неё нужны будут текстурные координаты. Такие, чтобы текстура рисовалась на экране тексель в пиксель. По большому счету, это не обязательно, но очень желательно, чтобы выборки были «более случайными».

Итого, на входе во фрагментный шейдер у нас есть три текстуры и два набора текстурных координат.

Надо заметить, что в моем движке для того, чтобы поддерживались разные версии шейдеров, сделаны следующие штуки:
Входящая переменная во фрагментный шейдер — etFragmentIn. В старых шейдерах заменяется на varying, в новых на in.
Результат фрагментного шейдера записывается в переменную etFragmentOut (gl_FragColor в старых шейдерах и "out vec4 ..." + glBindFragDataLocation в новых версиях).

Итого, кусочек шейдера у нас уже есть:

uniform sampler2D texture_diffuse;
uniform sampler2D texture_normal;
uniform sampler2D texture_depth;
uniform sampler2D texture_noise;

etFragmentIn vec2 TexCoord;
etFragmentIn vec2 NoiseTexCoord;

Теперь можно приступить непосредственно к расчету нашего затенения.

3. Расчет SSAO

Общая идея такова: в данной точке получить положение и нормаль, затем сгенерировать несколько случайных направлений на полусфере, заданой нормалью, и проверить затенения в них. Результат собрать и поделить на количество выборок. Таким образом мы хотим контролировать как минимум три параметрa:
1) количество выборок;
2) минимальное расстояние, на котором мы проверяем затенение (оно нужно нам, чтобы избавиться от некоторых неприятных артефактов);
3) максимальное расстояние, на котором мы проверяем затенение;

Вот пару картинок для сравнения параметров: количество выборок — чем больше, тем более плавное и красивое затенение у нас получается:

ao-samples | Screen space ambient occlusion с учетом нормалей и расчет одного отражения света.

Максимальное расстояние — чем оно больше, тем «шире» и мягче у нас затенение:

ao-distance | Screen space ambient occlusion с учетом нормалей и расчет одного отражения света.

Для тестовой сцены (Crytek Sponza) я использовал такие параметры:

#define NUM_SAMPLES          64
#define MIN_SAMPLE_SIZE      1.0
#define SAMPLE_SIZE          32.0

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

Итак, у нас все есть для того, чтобы рассчитать затенение каждой точки на экране. Для начала нам нужно найти нормаль в этой точке и её положение (не забывайте, что мы работает во view space). Делается это просто чтением нормали из текстуры и восстановлением положения по глубине:

void main()
{
  vec4 noiseSample = etTexture2D(texture_noise, NoiseTexCoord);
  vec3 normalSample = decodeNormal(etTexture2D(texture_normal, TexCoord).xy);
  float depthSample = 2.0 * etTexture2D(texture_depth, TexCoord).x - 1.0;

  vec3 viewSpacePosition = restoreViewSpacePosition(2.0 * TexCoord - 1.0, depthSample);
...

Функция etTexture2D — это тоже магия моего движка. Для старых версий GLSL она превращается в texture2D, для новых в texture.

Теперь, чтобы не городить все в теле функции main(), заведем специальную функцию, которая рассчитывает затенение в данной точке. Я пафосно назвал её performRaytracingInViewSpace:

float performRaytracingInViewSpace(in vec3 vp, in vec3 vn, in vec4 noise)

Ну, и собственно, чтобы не томить, остаток шейдера:

for (int i = 0; i < NUM_SAMPLES; ++i)
{
  environment += performRaytracingInViewSpace(viewSpacePosition, normalSample, noiseSample);
  noiseSample = etTexture2D(texture_noise, NoiseTexCoord + noiseSample.yz);
}
  
etFragmentOut = vec4(environment / float(NUM_SAMPLES));

В результате у нас будет затенение данной точки. Если нужно освещение, мы просто вычитаем затенение из единицы:

  
etFragmentOut = vec4(1.0 - environment / float(NUM_SAMPLES));

То есть здесь мы просто подсовываем в эту функцию начальные параметры для текущей точки и некую псевдослучайную величину, которую мы потом обновляем (читаем из текстуры шума по новым координатам).

Таким образом, весь секрет у нас в функции расчета затенения. Давайте рассмотрим её поближе.

Здесь нам нужно сгенерировать случайное направление на полусфере, заданой нормалью в точке. Я это делаю очень просто: нормализую значение из текстуры шума, и если оно лежит в другой полуплоскости от нужной нам нормали, то умножаю на –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].

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

float performRaytracingInViewSpace(in vec3 vp, in vec3 vn, in vec4 noise)
{
  vec3 randomNormal = randomVectorOnHemisphere(vn, noise.xyz);
  vec3 projected = projectViewSpacePosition(vp + randomNormal * (MIN_SAMPLE_SIZE + noise.w * SAMPLE_SIZE));
  float sampledDepth = etTexture2D(texture_depth, projected.xy).x;
  
  if (sampledDepth > projected.z)
    return 0.0;

А дальше, когда мы определили, что новая точка лежит ближе к камере, чем наша спроецированная, начинается магия затенения. Что у нас есть на входе:
— глубина нашей спроецированной точки (которая гарантировано больше, чем новая глубина);
— глубина, которую мы получили после выборки (которая гарантирована меньше, чем глубина спроецированной точки).

Что нам нужно вычислить:
— насколько сильно объект (на точку которого мы наткнулись) перекрывает нашу исходную точку.

Что нам нужно учесть:
— чем ближе новая точка к спроецированной, тем сильнее перекрытие;
— если новая точка сильно «далеко» от спроецированной, тем меньше перекрытие.

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

оценка = 1.0 / sqrt(1.0 - глубина);

лучше всего подходит для получения такой оценки.

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

float depthDifference =
  inversesqrt(1.0 - projected.z) - inversesqrt(1.0 - sampledDepth);

Так как мы хотим контролировать степень затенения, можем ввести некий коэффециент для этой разности. В финальном варианте получается так:

float depthDifference =
  DEPTH_DIFFERENCE_SCALE * (inversesqrt(1.0 - projected.z) - inversesqrt(1.0 - sampledDepth));

Для тестовой модели я использовал значение DEPTH_DIFFERENCE_SCALE равным 3.33333. Все зависит от масштабов того, что мы рисуем и на чём хотим вычислять затенение.

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

затенение = 1.0 / (1.0 + расстояние2)

Чтобы сделать его более мягким и приятным, еще можно учитывать расстояние, на которое мы делали выборку (приведенное к промежутку [0..1]). Итоговая формула выглядит вот так:

float occlusion = (1.0 - noise.w) / (1.0 + depthDifference * depthDifference);
return occlusion;

Вот как влияет масштаб расстояний (тот, который DEPTH_DIFFERENCE_SCALE)

ao-distance-scale | Screen space ambient occlusion с учетом нормалей и расчет одного отражения света.

При уменьшении расстояние (при 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:

vec4 environment = vec4(0.0);
...
etFragmentOut = environment / float(NUM_SAMPLES);

В результате мы получим примерно вот такую картинку (альфа-канал не показан, а яркость увеличена в 10 раз для наглядности):

+ Показать

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

ao-gi | Screen space ambient occlusion с учетом нормалей и расчет одного отражения света.

На первый взгляд различий немного, но приглядитесь к правой картинке: освещение намного мягче, и на стенках есть небольшие отсветы от «штор». В общем, при минимальных затратах получилось использовать интересную технику.

5. Выводы

Итого, что у нас получилось:
1) рассчитать некое подобие глобального освещения, не передирая исходные коды шейдеров;
2) рассчитать первое отражение света с минимальными затратами и изменениями в алгоритме;
3) ну и наконец, разобраться в том, как это работает.

6. Ништяки

Как и обещал, в последней части я выкладываю куски кода:

Запись и восстановление нормалей (взято отсюда):

vec2 encodeNormal(in vec3 n)
{
  return vec2(n.xy / sqrt(8.0 * n.z + 8.0) + 0.5);
}

vec3 decodeNormal(in vec2 v)
{
  vec2 fenc = 4.0 * v - 2.0;
  float f = dot(fenc, fenc);
  return vec3(fenc * sqrt(1.0 - 0.25 * f), 1.0 - 0.5 * f);
}

Восстановление положения во 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)
{
  return 0.5 + 0.5 * vec3((viewSpace.xy / texCoordScales) / viewSpace.z, projectViewSpaceDistance(viewSpace.z));
}

clipPlanes — это расстояние до ближней и дальней плоскости отсечения (то, что мы скармливаем в функцию установки перспективной проекции).
texCoordScales — компоненты обратной матриции проекции взятые со знаком минус.

texCoordScales = vec2(-inverseProjectionMatrix[0][0], -inverseProjectionMatrix[1][1]). 

Они необходимы для правильного восстановления позиции с учетом угла обзора и пропорций вьюпорта.


Вообще, в демке много чего есть, кроме затенения и отражения света:
+ Показать

Исходники демки доступны внутри движка:
https://github.com/sergeyreznik/et-engine
По идее, демка должна сходу собираться в Xcode под маком и MSVC 2013 под виндой.

Ссылки по теме

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

Horizon based ambient occlusion (HBAO)
http://developer.download.nvidia.com/presentations/2008/SIGGRAPH/… AO_SIG08b.pdf

Улучшенная техника HBAO+
http://www.geforce.com/hardware/technology/hbao-plus/technology

Оригинальная статья по SSDO (Approximating Dynamic Global Illumination in Image Space):
http://people.mpi-inf.mpg.de/~ritschel/Papers/SSDO.pdf

#Ambient Occlusion, #GLSL, #OpenGL, #SSAO

17 января 2015 (Обновление: 13 мар 2015)

Комментарии [69]