Уроки по OpenGL с сайта OGLDev


Урок 45 - Screen Space Ambient Occlusion

Помните как развивалась наша модель освещения? В уроке 17 мы увидели нашу первую модель освещения, которая начиналась с фонового освещения. Фоновое освещение имитирует ощущение "все освещено", которое можно почуствовать в светлый полдень. Оно было реализовано с использованием одного значения с плавающей точкой, которое прилогалось к каждому источнику света; мы умножали на это значение на цвет текстуры поверхности. Таким образом, вы можете иметь один источник света, названный "солнце", и вы можете поиграться с фоновым освещением для управления общей освещенностью сцены - значение близкое к нулю создает темные сцены, а близкие к единице - яркие.

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

Как вы видите, ребра самые яркие, а углы, куда как мы ожидали попадет меньше всего света, ощутимо темнее.

Существует немало исследовательских работ по теме ambient occlusion и много алгоритмов, которые приблизительно его находят. Мы собираемся изучить одно из ветвлений этих алгоритмов, известное как Screen Space Ambient Occlusion (SSAO), разработка Crytek, ставшее популярным в 2007 после выхода http://en.wikipedia.org/wiki/Crysis. Много игр реализую свои вариации SSAO на его основе. Мы рассмотрим самую простую версию алгоритма, основываясь на статью SSAO tutorial by John Chapman.

Ambient occlusion может потребовать немало вычислительных ресурсов. Crytek пришли к компромису, где окклюзия вычисляется один раз для пикселя. Отсюда и префикс "Пространства Экрана" в имени алгоритма. Идея была в том, что бы пробежаться по экрану пиксель за пикселем, извлечь позицию в пространстве экрана, выбрать несколько случайных точек в окрестности этой позиции и проверить, лежат ли эти точки внутри или снаружи геометрического объекта. Если большинство точек лежат внутри, то исходный пиксель находится в углу, образованного несколькими полигонами, и получает меньше света. Если большинство точек лежат снаружи, то исходный пикслеь хорошо освещен, и следовательно, получает больше света. Для примера рассмотрим следующее изображение:

Мы имеем поверхность с 2 точками на ней - P0 и P1. Предположим, что мы смотрим на неё откуда-то с верхнего левого угла изображения. Мы выбираем несколько точек в окрестности каждой точки и проверяем, лежат ли они внутри или снаружи геометрического объекта. В случае P0 шанс на то, что случаяйная точка будет внутри объекта, выше. Для P1 наоборот. Поэтому мы ожидем большее освещение для P1, т.е. в итоге рендера точка будет ярче.

Давайте перейдем к более глубокому уровню. Мы собираемся добавить проход ambient occlusion где-то до нашего стандартного этапа освещения (нам понадобится фоновые значения для освещения). Этап ambient occlusion будет стандартным проходом с полноэкранным четырехугольником, где вычисления происходят один раз для пикселя. Для каждого пикселя нам нужны его позиция в пространстве экрана и мы хотим генерировать несколько случайных точек в близкой окрестности к этой позиции. Проще всего будет использовать текстуру, включающую в себя всю геометрию (очевидно, что только для ближайших пикселей), заполненную позицией из пространства камеры. Для этого нам потребуется геометрический этап перед фоновым проходом, где что-то похожее на G буфер из урока про Deferred Shading будет заполнено данными из пространства позиции камеры (на этом все, нам не нужны нормали, цвета и т.д.). Таким образом получение позиции текущего пикслея из пространства камеры всего лишь одна операция выборки.

Итак, теперь мы находимся во фрагментном шейдере, при этом мы имеем позицию в пространстве камеры для текущего пикселя. Генерировать случайные точки вокруг неё очень просто. Мы будем передавать в шейдер (как uniform-переменные) массив случайных векторов, и по одному прибавлять их к позиции пикселя. Для каждой полученной точки мы хотим проверить, лежит ли пиксели внутри или снаружи объекта. Вспомните, что эти точки виртуальные, т.е. не стоит ожидать проверки с настоящей поверхностью. Мы собираемся использовать что-то подобное тому, что мы использовали для карт теней. Будем сравнивать значение Z для случайной точки со значением Z ближайшей точки исходной геометрии. Разумеется, что точка исходной геометрии должна лежать на одном луче из камеры до виртуальной точки. Посмотрим на диаграмму:

Точка P лежит на красной поверхности, а красная и зеленая точки были случайно созданы. Зеленая точка лежит вне (до) объекта, а красная внутри (это используется для ambient occlusion). Окружность обозначает радиус, в котором могут генерироваться случайные точки (мы не хотим, что бы они были слишком далеко от точки P). R1 и R2 являются лучами из камеры (в 0,0,0) до красной и зеленой точек. Оба пересекаются с геометрическим объектом. Для того, что бы вычислить ambient occlusion мы должны сравнить значения Z красной и зеленой точек со значением Z соответствующих точек, полученных при пересечении объекта лучами R1/R2. У нас уже есть значения Z для красной и зеленой точек (в пространстве камеры, в конце концов так мы их и создавали). Но какое значение Z для точек пересечения?

Что ж, существует более одного решения у этой проблемы, но поскольку у нас уже есть текстура со сначениями в пространстве камеры для всей сцены, проще всего будет искать каким-то образом в этой текстуре. Для этого нам нужны две координаты для лучей R1 и R2. Вспомним, что исходные координаты текстуры, которые мы использовали нахождения позиции P не подходят. Эти координаты были сформированы при интерполяции полноэкранного прямоугольника, который мы развертываем в этом проходе. Но лучи R1 и R2 не проходят через P. Они где-то пересекают поверхность.

Теперь нам надо быстро обновлять текстуру с позицией пространства камеры способом аналогичным тому, который мы использовали для её создания. После переноса объекта из локального пространства в пространство камеры полученые векторы были умножены на матрицу проекции (по факту, все эти преобразования были выполнены одной матрицей). Все это происходило в вершинном шейдере, и на пути к фрагментному шейдеру GPU автоматически выполнило деление перспективы для завершения проецирования. Такое проецирование размещает позицию из пространства камеры на ближайшей плоскости клиппера, а координаты XYZ точек внутри усеченного конус лежат на отрезке (-1,1). В то время как позиция в пространстве камеры пишется в текстуру в фрагментном шейдере (вычисления выше будут выполняться только над gl_Position; данные для записи в текстуру будут переданы ещё одной переменной), XY переносятся на отрезок (0,1), т.е. будут использоваться для указания позиции на текстуре, куда будет записана позиция в пространстве камеры.

Итак, можем ли мы использовать такую же процедуру для вычисления координат текстуры для красной и зеленой точек? Что-ж, почему бы и нет? Математика остается той же самой. Все что нам требуется, это передать в шейдер матрицу проекции и использовать её для проецирования красной и зеленой точек на ближнюю плоскость клиппера. Нам потребуется вручную произвести деление перспективы, но в этом ничего заумного. Затем нам потребуется перенести результат на (0,1), а вот и наши координаты текстуры! Нас отделяет только выборка значения из текстуры от получения координаты Z и проверки, будет ли виртуальная точка лежать внутри или снаружи геометрии. Теперь перейдем к коду.

Прямиком к коду!

tutorial45.cpp:156

virtual void RenderSceneCB()
{
    m_pGameCamera->OnRender();
    m_pipeline.SetCamera(*m_pGameCamera);
    GeometryPass();
    SSAOPass();
    BlurPass();
    LightingPass();
    RenderFPS();
    CalcFPS();
    OgldevBackendSwapBuffers();
}

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

tutorial45.cpp:177

void GeometryPass()
{
    m_geomPassTech.Enable();

    m_gBuffer.BindForWriting();

    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    m_pipeline.Orient(m_mesh.GetOrientation());
    m_geomPassTech.SetWVP(m_pipeline.GetWVPTrans());
    m_geomPassTech.SetWVMatrix(m_pipeline.GetWVTrans());
    m_mesh.Render();
}

В геометрическом проходе вся сцена рендерится в текстуру. В этом примере только один меш. На практике мешей скорее всего будет несколько.

geometry_pass.vs

#version 330

layout (location = 0) in vec3 Position;

uniform mat4 gWVP;
uniform mat4 gWV;

out vec3 ViewPos;

void main()
{
    gl_Position = gWVP * vec4(Position, 1.0);
    ViewPos     = (gWV * vec4(Position, 1.0)).xyz;
}

geometry_pass.fs

#version 330

in vec3 ViewPos;

layout (location = 0) out vec3 PosOut;

void main()
{
    PosOut = ViewPos;
}

Это вершинный и фрагментный шейдеры для геометрического прохода. В вершинном шейдере мы как обычно вычисляем gl_position и передаем позицию в пространстве камеры в фрагментный шейдер в виде ещё одной переменной. Вспомним, что деление перспективы для этой переменной не выполнялось, но это работа для обычной интерполяции, выполняемой в процессе растеризации.

В фрагментном шейдере мы записываем интерполированную позицию в пространстве камеры в текстуру. На этом всё.

tutorial45.cpp:192

void SSAOPass()
{
    m_SSAOTech.Enable();
    m_SSAOTech.BindPositionBuffer(m_gBuffer);
    m_aoBuffer.BindForWriting();
    glClear(GL_COLOR_BUFFER_BIT);
    m_quad.Render();
}

Это код для прохода SSAO, и он очень прост. На вход подаётся позиция в пространстве камеры, полученная на предыдущем шаге, а результат мы пишем в AO буфер. Для рендера мы используем прямоугольник во весь экран. При таком подходе значение AO будет сгенерировано для каждого пикселя. Настоящее мясо начинается в шейдерах.

ssao.vs

#version 330

layout (location = 0) in vec3 Position;

out vec2 TexCoord;

void main()
{
    gl_Position = vec4(Position, 1.0);
    TexCoord = (Position.xy + vec2(1.0)) / 2.0;
}

Как и во многих алгоритмах с позицией в пространстве камеры вершинный шейдер просто передает координаты полноэкранного прямоугольника. gl_Position будет использована GPU для растеризации, но мы используем её координаты XY в качестве координат текстуры. Вспомним, что для полноэкранного прямоугольника координаты лежат в квадрате от (-1,-1) до (1,1), таким образом во фрагментном шейдере всё будет интерполироваться в этой области. Мы же хотим, что бы координаты текстуры лежали на отрезке (0,1), поэтому мы преобразуем их здесь, до отправки в фрагментный шейдер.

ssao.fs

#version 330

in vec2 TexCoord;

out vec4 FragColor;

uniform sampler2D gPositionMap;
uniform float gSampleRad;
uniform mat4 gProj;

const int MAX_KERNEL_SIZE = 128;
uniform vec3 gKernel[MAX_KERNEL_SIZE];

void main()
{
    vec3 Pos = texture(gPositionMap, TexCoord).xyz;

    float AO = 0.0;

    for (int i = 0 ; i < MAX_KERNEL_SIZE ; i++) {
        vec3 samplePos = Pos + gKernel[i];   // генерируем случайные точки
        vec4 offset = vec4(samplePos, 1.0);  // преобразуем в 4-вектор
        offset = gProj * offset;        // проецируем на ближнюю плоскость клиппера
        offset.xy /= offset.w;      // производим деление перспективы
        offset.xy = offset.xy * 0.5 + vec2(0.5);    // переносим на отрезок (0,1)

        float sampleDepth = texture(gPositionMap, offset.xy).b;

        if (abs(Pos.z - sampleDepth) < gSampleRad) {
            AO += step(sampleDepth,samplePos.z);
        }
    }

    AO = 1.0 - AO/128.0;

    FragColor = vec4(pow(AO, 2.0));
}

В вот и сердце алгоритма SSAO. Мы используем координаты текстуры, полученные из вершинного шейдера, для взятия из карты с позицией в пространстве камеры координат текущего пикселя. Затем мы входим в цикл и создаем случайные точки. Для этого мы используем массив случайных векторов (gKernel). Массив заполнен случайными векторами, принадлежащими кубу, координаты вершин которого лежат на отрезке (-1,1). Заполнение происходит в файле ssao_technique.cpp, код которого я не привожу, поскольку он довольно стандартный. Теперь нам нужны координаты текстуры для получения значения Z для точки геометрии, которая бы соответствовала виртуальной точке. Мы проецируем случайную точку на ближнюю плоскость клиппера используя матрицу проекции, производим деление перспективы и переносим на отрезок (0,1). Теперь мы можем использовать её для получения позиции в пространстве экрана точки геометрии, а затем сравнить её Z координату со случайной точкой. Но перед этим мы должны убедиться, что расстояние между исходной точкой и той, чьё значение Z мы только что получили, не сликом большое. Это поможет избежать некоторых неприятных артефактов. Попробуйте изменить значения gSampleRad, что бы добиться лучшего результата.

Далее мы сравниваем значение глубины виртуальной точки и соответствующей ей точки исходной геометрии. Функция step(x,y) возвращает 0, если y < x, иначе 1. Это значит, что локальная переменная AO увеличивается для каждой точки позади геометрии. Мы планируем умножать результат на цвет подсвеченного пикселя, поэтому мы делаем AO = 1.0 - AO/128.0, что бы обратить значение. Результат записывается в выходной буфер. Заметим, что мы берём квадрат значения AO перед записью. На мой взгляд так результат получается немного лучше. Это ещё одна переменная, с которой вы можете поиграться.

tutorial45.cpp:205

void BlurPass()
{
    m_blurTech.Enable();

    m_blurTech.BindInputBuffer(m_aoBuffer);

    m_blurBuffer.BindForWriting();

    glClear(GL_COLOR_BUFFER_BIT);

    m_quad.Render();
}

Для этапа размытия код в цикле рендера аналогичен проходу SSAO. На вход подается текстура ambient occlusionn, а результатом будет буфер, содержащий смягченные значения.

blur.vs

#version 330

layout (location = 0) in vec3 Position;

out vec2 TexCoord;

void main()
{
    gl_Position = vec4(Position, 1.0);
    TexCoord = (Position.xy + vec2(1.0)) / 2.0;
}

blur.fs

#version 330

in vec2 TexCoord;

out vec4 FragColor;

uniform sampler2D gColorMap;

float Offsets[4] = float[]( -1.5, -0.5, 0.5, 1.5 );

void main()
{
    vec3 Color = vec3(0.0, 0.0, 0.0);

    for (int i = 0 ; i &lt; 4 ; i++) {
        for (int j = 0 ; j &lt; 4 ; j++) {
            vec2 tc = TexCoord;
            tc.x = TexCoord.x + Offsets[j] / textureSize(gColorMap, 0).x;
            tc.y = TexCoord.y + Offsets[i] / textureSize(gColorMap, 0).y;
            Color += texture(gColorMap, tc).xyz;
        }
    }

    Color /= 16.0;

    FragColor = vec4(Color, 1.0);
}

Это пример очень простого алгоритма размытия. VS такой же, как и для SSAO. В фрагментном шейдере мы выбираем 16 точек вокруг исходной и находим среднее значение.

tutorial45.cpp:219

void LightingPass()
{
    m_lightingTech.Enable();
    m_lightingTech.SetShaderType(m_shaderType);
    m_lightingTech.BindAOBuffer(m_blurBuffer);

    glBindFramebuffer(GL_FRAMEBUFFER, 0);

    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    m_pipeline.Orient(m_mesh.GetOrientation());
    m_lightingTech.SetWVP(m_pipeline.GetWVPTrans());
    m_lightingTech.SetWorldMatrix(m_pipeline.GetWorldTrans());
    m_mesh.Render();
}

Для прохода света у нас уже давно привычный код. Единственное отличие - это добавление буфера AO.

lighting.fs

vec2 CalcScreenTexCoord()
{
    return gl_FragCoord.xy / gScreenSize;
}


vec4 CalcLightInternal(BaseLight Light, vec3 LightDirection, vec3 Normal)
{
    vec4 AmbientColor = vec4(Light.Color, 1.0f) * Light.AmbientIntensity;

    if (gShaderType == SHADER_TYPE_SSAO) {
       AmbientColor *= texture(gAOMap, CalcScreenTexCoord()).r;
    }

    ...

Я не стал включать код шейдера полностью, поскольку изменения не значительны. Значение фонового освещения домножается на значение из карты AO для текущего пикселя. Поскольку мы рендерим саму сцену, а не полноэкранный прямоугольник, для вычисления координат текстуры используется gl_FragCoord. gShaderType - это пользовательская переменная, которая помогает переключаться на SSAO и обратно на обычный фоновый свет. Для переключения используйте кнопку 'a'.

Использованая литература: SSAO tutorial by John Chapman

powered byDisqus