[Из песочницы] Модифицированный алгоритм Geometry Buffer Anti-Aliasing

Алиасинг представляет одну из фундаментальных проблем компьютерной графики, и для борьбы с ним придумано множество разнообразных алгоритмов антиалиасинга. Появление MLAA привлекло интерес к алгоритмам, работающим на этапе постобработки. Одним из таких алгоритмов (с небольшой оговоркой) является Geometry Buffer Anti-Aliasing (GBAA). В этом материале описана попытка модификации оригинального алгоритма для улучшения качества антиалиасинга в некоторых случаях.

image

Geometric Post-process Anti-Aliasing (GPAA)


GBAA является усовершенствованной версией алгоритма Geometric Post-process Anti-Aliasing (GPAA). Лежащая в его основе идея заключается в том, что вместо поиска резких границ в исходном изображении для оценки расположения геометрических ребер (как это делает MLAA) можно использовать информацию о ребрах в «чистом виде», получив ее от рендерера. Алгоритм довольно прост:
  1. Отрендерить сцену в (основной проход);
  2. Сделать копию бэкбуфера;
  3. Отрендерить геометрические ребра в дополнительном проходе, смешивая цвета соседних пикселей для получения сглаженных ребер.

Смешивание цветов пикселей (блендинг) выполняется следующим образом:

  1. Для каждого пикселя определяется направление (вертикальное или горизонтальное) и расстояние до ближайшего ребра;
  2. С использованием направления и расстояния вычисляется покрытие пикселя соседним треугольником;
  3. Направление используется для выбора соседнего пикселя, а покрытие — для расчета коэффициентов блендинга.

Эта картинка иллюстрирует логику работы алгоритма:

GPAA illustration

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

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

Код шейдера (HLSL)
struct VsIn
{
    float3 Position0 : Position0;
    float3 Position1 : Position1;
};

struct PsIn
{
    float4 Position : SV_Position;
    // The parameters are constant across the line so use the nointerpolation attribute.
    // This is not necessarily required, but using this we can make the vertex shader slightly shorter.
    nointerpolation float4 KMF : KMF;
};

float4x4 ViewProj;
float4 ScaleBias;

PsIn main(VsIn In)
{
    PsIn Out;
    float4 pos0 = mul(ViewProj, float4(In.Position0, 1.0));
    float4 pos1 = mul(ViewProj, float4(In.Position1, 1.0));
    Out.Position = pos0;
    // Compute screen-space position and direction of line
    float2 pos = (pos0.xy / pos0.w) * ScaleBias.xy + ScaleBias.zw;
    float2 dir = (pos1.xy / pos1.w) * ScaleBias.xy + ScaleBias.zw - pos;
    // Select between mostly horizontal or vertical
    bool x_gt_y = abs(dir.x) > abs(dir.y);
    // Pass down the screen-space line equation
    if (x_gt_y)
    {
        float k = dir.y / dir.x;
        Out.KMF.xy = float2(k, -1);
    }
    else
    {
        float k = dir.x / dir.y;
        Out.KMF.xy = float2(-1, k);
    }
    Out.KMF.z = -dot(pos.xy, Out.KMF.xy);
    Out.KMF.w = asfloat(x_gt_y);
    return Out;
}

Texture2D BackBuffer;
SamplerState Filter;
float2 PixelSize;

float4 main(PsIn In) : SV_Target
{
    // Compute the difference between geometric line and sample position
    float diff = dot(In.KMF.xy, In.Position.xy) + In.KMF.z;
    // Compute the coverage of the neighboring surface
    float coverage = 0.5f - abs(diff);
    float2 offset = 0;
    if (coverage > 0)
    {
        // Select direction to sample a neighbor pixel
        float off = diff >= 0 ? 1 : -1;
        if (asuint(In.KMF.w))
            offset.y = off;
        else
            offset.x = off;
    }
    // Blend pixel with neighbor pixel using texture filtering and shifting the coordinate appropriately.
    return BackBuffer.Sample(Filter, (In.Position.xy + coverage * offset.xy) * PixelSize);
}


Главными преимуществами этого алгоритма являются качество и производительность. Качество антиалиасинга не зависит от угла наклона ребра, что представляет собой традиционную проблему для техник постобработки. На первой картинке продемонстрированы результаты работы FXAA с разными предустановками качества, на второй — результаты работы GPAA.

FXAA horizontal edges
FXAA 3, FXAA 5

GPAA horizontal edges
GPAA

Наиболее дорогой операцией является копирование экранного буфера: рендеринг одного кадра (в исходной реализации) на видеокарте HD 5870 в разрешении 1280×720 выполняется за 0.93 мс, из которых копирование экранного буфера занимает 0.08 мс, а собственно сглаживание ребер — 0.01 мс. Недостатком, очевидно, является необходимость предварительной обработки геометрии для извлечения ребер и дополнительной памяти для их хранения. Кроме того, GPU потребительского уровня выполняют растеризацию линий сравнительно медленно. Эти проблемы в совокупности негативно влияют на масштабируемость GPAA при растущей геометрической сложности сцены.

Geometry Buffer Anti-Aliasing (GBAA)


Итак, GBAA представляет собой усовершенствованную версию GPAA. Собственно усовершенствование заключается в том, что направления и расстояния до границ треугольников вычисляются в геометрическом шейдере, что устраняет необходимость предварительной обработки геометрии и растеризации линий, уменьшает объем используемой памяти и, что самое главное, устраняет зависимость производительности от геометрической сложности сцены.

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

GBAA distance calculation

Дополнительным преимуществом перед GPAA является возможность выполнения антиалиасинга не только геометрических ребер, но и других границ, расстояние до которых можно оценить: например, границ в альфа-прозрачных текстурах:

GBAA alpha-transparency

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

  • Выбираются смещения из 4 соседних пикселей;
  • В зависимости от смещения выбирается один из 4 соседей (тот, смещение которого соответствует направлению в сторону текущего пикселя):
     — левый: 0.5 <= offset.x <= 1.0
     — правый: -1.0 <= offset.x <= -0.5
     — верхний: 0.5 <= offset.y <= 1.0
     — нижний: -1.0 <= offset.y <= -0.5
  • Из выбранного смещения получается скорректированное смещение для текущего пикселя, после чего остается только посчитать коэффициенты и выполнить блендинг.
Код шейдера (HLSL)
struct PsIn
{
    float4 Position : SV_Position;
    float2 TexCoord : TexCoord;
};

[Vertex shader]

PsIn main(uint VertexID : SV_VertexID)
{
    // Produce a fullscreen triangle
    PsIn Out;
    Out.Position.x = (VertexID == 0)? 3.0f : -1.0f;
    Out.Position.y = (VertexID == 2)? 3.0f : -1.0f;
    Out.Position.zw = 1.0f;
    Out.TexCoord = Out.Position.xy * float2(0.5f, -0.5f) + 0.5f;
    return Out;
}

[Fragment shader]

Texture2D BackBuffer;
Texture2D  GeometryBuffer;
SamplerState Linear;
SamplerState Point;

float2 PixelSize;

float4 main(PsIn In) : SV_Target
{
    float2 offset = GeometryBuffer.Sample(Point, In.TexCoord);
    // Check geometry buffer for an edge cutting through the pixel.
    [flatten]
    if (min(abs(offset.x), abs(offset.y)) >= 0.5f)
    {
        // If no edge was found we look in neighboring pixels' geometry information. This is necessary because
        // relevant geometry information may only be available on one side of an edge, such as on silhouette edges,
        // where a background pixel adjacent to the edge will have the background's geometry information, and not
        // the foreground's geometric edge that we need to antialias against. Doing this step covers up gaps in the
        // geometry information.
        offset = 0.5f;
        // We only need to check the component on neighbor samples that point towards us
        float offset_x0 = GeometryBuffer.Sample(Point, In.TexCoord, int2(-1,  0)).x;
        float offset_x1 = GeometryBuffer.Sample(Point, In.TexCoord, int2( 1,  0)).x;
        float offset_y0 = GeometryBuffer.Sample(Point, In.TexCoord, int2( 0, -1)).y;
        float offset_y1 = GeometryBuffer.Sample(Point, In.TexCoord, int2( 0,  1)).y;
        // Check range of neighbor pixels' distance and use if edge cuts this pixel.
        if (abs(offset_x0 - 0.75f) < 0.25f) offset = float2(offset_x0 - 1.0f, 0.5f); // Left  x-offset [ 0.5 ..  1.0] cuts this pixel
        if (abs(offset_x1 + 0.75f) < 0.25f) offset = float2(offset_x1 + 1.0f, 0.5f); // Right x-offset [-1.0 .. -0.5] cuts this pixel
        if (abs(offset_y0 - 0.75f) < 0.25f) offset = float2(0.5f, offset_y0 - 1.0f); // Up    y-offset [ 0.5 ..  1.0] cuts this pixel
        if (abs(offset_y1 + 0.75f) < 0.25f) offset = float2(0.5f, offset_y1 + 1.0f); // Down  y-offset [-1.0 .. -0.5] cuts this pixel
    }
    // Convert distance to texture coordinate shift
    float2 off = (offset >= float2(0, 0))? float2(0.5f, 0.5f) : float2(-0.5f, -0.5f);
    offset = off - offset;
    // Blend pixel with neighbor pixel using texture filtering and shifting the coordinate appropriately.
    return BackBuffer.Sample(Linear, In.TexCoord + offset * PixelSize);
}

Модификация


У GBAA имеется неприятная особенность, выражающаяся в артефактах возле сходящихся ребер:

GBAA artifacts

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

GBAA artifact 1

Здесь точка выборки текущего пикселя в центре попадает внутрь тонкого треугольника, а точки выборки левого и правого пикселей — в большие треугольники, смежные с тонким. Если правое ребро тонкого треугольника оказывается ближе к центру среднего пикселя, как показано на картинке, то GBAA определит покрытие правого треугольника средним пикселем исходя из смещения правого ребра относительно его центра, а затем выдаст линейно интерполированный цвет между средним и правым пикселем. Однако средний пиксель покрывает фрагменты сразу трех треугольников, и если цвет хотя бы одного пикселя отличается от остальных, результирующий цвет будет определен неверно. Пусть a, b, c — исходные цвета трех пикселей, а α, β, γ — отношение площадей покрытых средним пикселем фрагментов треугольников к площади пикселя. Скорректированный цвет среднего пикселя в таком случае может быть определен по формуле

bout = αa + βb + γc,

в то время как оригинальный алгоритм вычислит его по формуле

bout = (α + β)b + γc

Если, например, левый пиксель окажется белым, а средний и правый — черными, то в описанной ситуации оригинальный алгоритм всегда будет выдавать черный цвет для среднего пикселя, оставляя фрагмент исходного изображения без изменений.

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

GBAA artifact 2 - example 1

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

GBAA artifact 2 - example 2

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

GBAA artifact 2 results

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

Для обработки этих двух случаев можно внести несколько изменений в оригинальный алгоритм.

  1. Корректное вычисление цвета пикселя в случае первом случае требует наличия информации о втором смещении, в то время как оригинальный алгоритм хранит лишь одно. Для этого потребуется дополнительное место в геометрическом буфере. Если существует второе смещение по той же оси, что первое, но противоположное ему, то это смещение необходимо также сохранить в геометрическом буфере. На этапе постобработки для определения случая тройного покрытия следует проверить, пересекается ли пиксель двумя ребрами с разных сторон, и если пересекается, вычислить скорректированный цвет.
  2. Обработка второго случая оказывает минимальное влияние на структуру алгоритма, требуя внесения дополнительной проверки в этап постобработки. Пиксель должен получить свой исходный цвет, если в направлении соответствующего ему смещения расположен соседний пиксель, которому соответствует противоположное направление смещения в той же оси.
Код шейдера (HLSL)
struct PsIn
{
    float4 Position : SV_Position;
    float2 TexCoord : TexCoord;
};

[Vertex shader]

PsIn main(uint VertexID : SV_VertexID)
{
    // Produce a fullscreen triangle
    PsIn Out;
    Out.Position.x = (VertexID == 0)? 3.0f : -1.0f;
    Out.Position.y = (VertexID == 2)? 3.0f : -1.0f;
    Out.Position.zw = 1.0f;
    Out.TexCoord = Out.Position.xy * float2(0.5f, -0.5f) + 0.5f;
    return Out;
}

[Fragment shader]

Texture2D BackBuffer;
Texture2D  GeometryBuffer;
Texture2D  InvGeometryBuffer;
SamplerState Linear;
SamplerState Point;

float2 PixelSize;
int Tweak;
int ShowEdges;

void check_opposite_neighbor(float2 tex_coord, inout float2 offset)
{
    // Select major offset
    float2 off;
    bool x_major = abs(offset.x) < abs(offset.y);
    if (x_major)
        off = float2(sign(offset.x), 0);
    else
        off = float2(0, sign(offset.y));
    // Select neighbor's offset
    float2 opp_offset = GeometryBuffer.Sample(Point, tex_coord + off*PixelSize);
    // Make sure it is valid
    bool apply_offset = true;
    if (min(abs(opp_offset.x), abs(opp_offset.y)) < 0.5f)
    {
        // Make sure it points towards current sample
        // if so - don't apply texture coordinate offset
        if (x_major)
        {
            if (sign(offset.x)!=sign(opp_offset.x) && abs(opp_offset.x) < 0.5f)
                offset = 0.5f;
        }
        else
        {
            if (sign(offset.y)!=sign(opp_offset.y) && abs(opp_offset.y) < 0.5f)
                offset = 0.5f;
        }
    }
}

float4 main(PsIn In) : SV_Target
{
    float2 offset = GeometryBuffer.Sample(Point, In.TexCoord);
    bool edge_found = false;
    bool triple_coverage = false;
    float4 result;
    // Check geometry buffer for an edge cutting through the pixel.
    [flatten]
    if (min(abs(offset.x), abs(offset.y)) >= 0.5f)
    {
        // If no edge was found we look in neighboring pixels' geometry information. This is necessary because
        // relevant geometry information may only be available on one side of an edge, such as on silhouette edges,
        // where a background pixel adjacent to the edge will have the background's geometry information, and not
        // the foreground's geometric edge that we need to antialias against. Doing this step covers up gaps in the
        // geometry information.
        offset = 0.5f;
        // We only need to check the component on neighbor samples that point towards us
        float offset_x0 = GeometryBuffer.Sample(Point, In.TexCoord, int2(-1,  0)).x;
        float offset_x1 = GeometryBuffer.Sample(Point, In.TexCoord, int2( 1,  0)).x;
        float offset_y0 = GeometryBuffer.Sample(Point, In.TexCoord, int2( 0, -1)).y;
        float offset_y1 = GeometryBuffer.Sample(Point, In.TexCoord, int2( 0,  1)).y;

        // Check range of neighbor pixels' distance and use if edge cuts this pixel.
        if (abs(offset_x0 - 0.75f) < 0.25f)
        {
            edge_found = true;
            offset = float2(offset_x0 - 1.0f, 0.5f); // Left  x-offset [ 0.5 ..  1.0] cuts this pixel
        }
        if (abs(offset_x1 + 0.75f) < 0.25f)
        {
            edge_found = true;
            offset = float2(offset_x1 + 1.0f, 0.5f); // Right x-offset [-1.0 .. -0.5] cuts this pixel
        }
        if (abs(offset_y0 - 0.75f) < 0.25f)
        {
            edge_found = true;
            offset = float2(0.5f, offset_y0 - 1.0f); // Up    y-offset [ 0.5 ..  1.0] cuts this pixel
        }
        if (abs(offset_y1 + 0.75f) < 0.25f)
        {
            edge_found = true;
            offset = float2(0.5f, offset_y1 + 1.0f); // Down  y-offset [-1.0 .. -0.5] cuts this pixel
        }
    }
    else
    {
        edge_found = true;
        if (Tweak)
        {
            float inv_offset = InvGeometryBuffer.Sample(Point, In.TexCoord);
            if (inv_offset != 0.0f)
            {
                triple_coverage = true;
                
                // Sample two neighbors
                float maj_offset;
                float2 off = 0;
                if (abs(offset.x) < abs(offset.y))
                {
                    off.x = -sign(inv_offset);
                    maj_offset = offset.x;
                }
                else
                {
                    off.y = -sign(inv_offset);
                    maj_offset = offset.y;
                }                
                float4 n1 = BackBuffer.Sample(Point, In.TexCoord + off*PixelSize);
                float4 n2 = BackBuffer.Sample(Point, In.TexCoord - off*PixelSize);
                
                // Calculate coverage for this sample (b) and two neighbors (a, c)
                float alpha = 0.5f-abs(maj_offset); // a (n1)
                float gamma = 0.5f-abs(inv_offset); // c (n2)
                float beta = 1-alpha-gamma; // b (this)

                // Blend final color
                result = alpha*n1 + beta*BackBuffer.Sample(Point, In.TexCoord) + gamma*n2;
            }
            else
                check_opposite_neighbor(In.TexCoord, offset);
        }
    }
    if (ShowEdges && edge_found)
        result = float4(1, 0, 0, 1);
    else if (!triple_coverage)
    {
        // Convert distance to texture coordinate shift
        float2 off = (offset >= float2(0, 0))? float2(0.5f, 0.5f) : float2(-0.5f, -0.5f);
        offset = off - offset;
        // Blend pixel with neighbor pixel using texture filtering and shifting the coordinate appropriately.
        result = BackBuffer.Sample(Linear, In.TexCoord + offset * PixelSize);
    }
    return result;
}


Тесты


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

Results - 1

Results - 2

Results - 3

Results - 4

Хотя качество фрагментов со сходящимися ребрами по-прежнему нельзя назвать идеальным, артефакты на них стали гораздо менее заметны. Сцены со сложными текстурами хорошо маскируют остаточные эффекты. Достигнутое улучшение качества получено ценой некоторого падения производительности. Если этап постобработки оригинального GBAA занимал 0.14 мс при разрешении 1920×1080, то модифицированный алгоритм требует 0.22 мс, что на 57% больше. Однако даже такой уровень производительности продолжает оставаться более чем удовлетворительным, оставляя позади MLAA и его модификации.

Заключение


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

Скомпилированные бинарники и исходники доступны на GitHub.

Комментарии (0)

© Habrahabr.ru