steps3D - Tutorials - Temporal Anti-Aliasing

Temporal Anti-Aliasing

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

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

Рис 1. Алиасинг и способ уменьшить его

Классическим вариантом антиалиасинга для GPU является мультисэмплинг или суперсэмплинг. К сожалению оказалось, что для случая отложенного освещения (deferred shading), очень часто применяемого сейчас, он оказывается крайне дорогостоящим. Одна из первых игр, успешно применявших отложенное освещение - STALKER: Shadow Of Chernobyl - для реализации антиалиасинга использовала специальную screen-space технику.

Основная ее идея заключалась в том, что обычно алиасинг возникает там, где имеют место границы граней или объектов. Поэтому сперва по буферу глубины и буферу нормалей из G-буфера запускался фильтр для обнаружения разрывов (edge detect). Только там где были обнаружены границы выполнялось сглаживание (т.е. размытие изображения).

Для своего времени (а игра вышла в 2007 году) этот прием был довольно хорош но сейчас этого уже не достаточно. И одной из часто применяемых техник является темпоральный антиалиасинг (Temporal Anti-Aliasing, TAA). Его основная идея заключается в том, что мы используемый полученные ранее кадры для сглаживания текущего кадра.

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

Рис 2.Суперсэмплинг - несколько сэмплов на пиксел

Классический суперсэмплинг заключается в том, что мы для каждого пиксела строим распределение сэмплов в пространстве (внутри пиксела). Основная идея ТАА заключается в том, что мы распределяем эти сэмплы не только в пространстве, но и во времени - для каждого кадра для пиксела выбирается свой единственный сэмпл, но при этом его положение внутри пиксела меняется от кадра к кадру.

Хотя сейчас все чаще применяются приемы антиалиасинга, основанные при применении нейросетей (DLSS), но во первых они также используют предыдущие кадры и, во-вторых, сама техника TAA содержит много полезных элементов и приемов. Кроме того, используемые сейчас нейросетевые алгоритмы антиалиасинга по сути привязаны к конкретной платформе - NVidia или AMD.

Первое, с чего начинается ТАА - это добавление к координатам субпикселольного случайного шума (jitter). Обычно заранее считается небольшое число смещений и дальше просто по номеру кадра выбирается одно из них. Таким образом у нас на каждый кадр камера будет слегка смещена на случайную величину \( (j_x, j_y ) \), определяемую номером кадра.

Есть несколько способов получения этого смещения. Самый прост - мы просто используем генератор псевдослучайных чисел, создавая новое смещение для каждого кадра. Но обычно идут несколько другим путем - через последовательность Холтона строится массив квазислучайных пар чисел - точек в \( [-1,1]^2 \).

Далее по номеру кадра (по модулю размера массива) мы просто выбираем очередную точку и покомпонентно умножаем на \( (\frac{1}{width}, \frac{1}{height}) \). Здесь через \(width\) и \(height\) обозначены размеры фреймбуфера, куда мы производим рендеринг.

Ниже приводится пример фрагментного шейдера, использующего переданное через uniform-переменную значение смещения.

#version 330 core

uniform mat4 mv;
uniform mat4 proj;
uniform vec2 jitter;

layout ( location = 0 ) in vec3 pos;
layout ( location = 1 ) in vec2 texCoord;

out vec2  tex;

void main(void)
{
    vec4    p = proj * mv * vec4 ( pos, 1.0 );

    tex   = texCoord;
    p.xy += jitter * p.w;

    gl_Position = p;
}

Для работы алгоритма нам нужно знать историю, т.е. предыдущие кадры. Обычно вместо набора предыдущих кадров мы храним так называемый history buffer, в котором содержится результирующее изображение для предыдущего кадра.

При этом после рендеринга текущего кадра мы накладываем на него history buffer, строя тем самым history buffer для следующего кадра. На практике нам нужны два таких буфера - один будет содержать буфер для предыдущего кадра и в другой мы будем строить сглаженное изображение. И на каждый кадр мы меняем их местами.

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

\[ c_{out} = c_{hist} \cdot (1 - \alpha) + c_{curr} \cdot \alpha \]

Ниже приводится фрагментный шейдер, реализующий данное поведение.

#version 330 core

uniform sampler2D curMap;
uniform sampler2D historyMap;
uniform vec2 jitter;

in  vec2 tex;
in  vec4 viewRay;
out vec4 color;

const float alpha = 1.0 - 0.2;

void main(void)
{
    color = mix ( texture ( curMap, tex - jitter ), texture ( historyMap, tex ), alpha );
}

Если у нас все неподвижно (и камера и объекты сцены), то на этом можно остановиться - мы получим хорошую картинку с подавленным алиасингом. Но обычно у нас присутствует движение - как самой камеры, так и различных объектов, составляющих сцену. Поэтому если мы просто будем накладывать текущий буфер цвета на буфер истории как показано выше, то мы получим размытые образы объектов, так называемый ghosting.

Рис. 3. Пример ghosting

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

Рис 4. Восстановление трехмерных координат.

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

#version 330 core

layout(location = 0) in vec4 pos;

out vec2 tex;

void main(void)
{
    tex         = 0.5 * ( pos.xy + vec2 ( 1.0 ) );
    gl_Position = vec4 ( pos.xy, 0.0, 1.0 );
}

#version 330 core

uniform mat4 proj;          // we will need it in pos reconstruction
uniform mat4 viewInv;       // inverted view matrix from current frame
uniform mat4 projOld;       // projection matrix from previous frame
uniform mat4 viewOld;       // view matrix from previous frame

uniform sampler2D historyMap;

in  vec2  tex;
out vec4  color;

    // Note: it assumes proj is classical projection matrix
    // (not multiplied by smth else)
    // uv - current texture coordinates (glFragCoord.xy / screenSize)
vec3    getViewPos ( in vec2 uv, in float zEye )
{
    vec2    a  = vec2 ( -2.0 / proj [0][0], -2.0 / proj [1][1] );
    vec2    b  = vec2 (  1.0 / proj [0][0],  1.0 / proj [1][1] );
    
    return zEye * vec3 ( a*uv + b, 1.0 );
}

    // takes depth from depth buffer
    // return z in [zNear, zFar]
float linearDepth ( float d )
{
    return -zFar * zNear / (d * (zFar - zNear) - zFar );
}

void    main ()
{
        // get depth value from depth buffer
    float   depth = texture ( depthMap, tex ).r;

        // get camera-space coordinates of current proj
    vec4    pos = vec4 ( getViewPos ( tex ), 1.0 );

        // now transform into world space by inverse 
        // current view matrix
    vec4    posWorld = viewInv * pos;   

        // now use view and projection from 
        // previous frame to get old projection
    vec4    posOld = projOld * viewOld * posWorld;

        // now convert to NDC and texture coordinates
    vec2    uv = 0.5 * ( posOld.xy / posOld.w ) + vec2 ( 0.5 );
    

    color = texture ( historyMap, uv );
}

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

Но есть альтернативный вариант, хорошо работающий и в данном случае. Мы в вершинном шейдере берем текущие матрицы преобразования и матрицы с предыдущего кадра, находим xy-смещение вершины за кадр (но без учета jitter) и выдаем его как атрибут вершины для последующей интерполяции. В фрагментном шейдере мы записываем проинтерполированное значение в специальную текстуру скорости (velocity texture). Для нее хорошо подходит 16-битовый формат GL_RG16F.

#version 330 core

uniform mat4 mv;
uniform mat4 proj;
uniform mat4 mvPrev;
uniform mat4 projPrev;
uniform vec2 jitter;

layout ( location = 0 ) in vec3 pos;
layout ( location = 1 ) in vec2 texCoord;

out vec2  tex;

out vec4 pCurr; // vertex position for current frame
out vec4 pPrev; // vertex position for previous frame

void main(void)
{
    vec4    p = proj * mv * vec4 ( pos, 1.0 );
    
    pPrev = projPrev * mvPrev * vec4 ( pos, 1.0 );
    pCurr = p;
    tex   = texCoord;
    p.xy += jitter * p.w;
    
    gl_Position = p;
}

#version 330 core

uniform sampler2D   image;

in  vec2  tex;

in vec4 pCurr;
in vec4 pPrev;

out vec4 color [2];

void main(void)
{
        // we have to do this per-fragment due to
        // parts of triangles being cut
        // doing this in vertex shader leads to artifacts
    vec2    uvCurr   = (pCurr.xy / pCurr.w + vec2 ( 1.0 ) ) * 0.5;
    vec2    uvPrev   = (pPrev.xy / pPrev.w + vec2 ( 1.0 ) ) * 0.5;
    vec2    velocity = uvCurr - uvPrev;
    
    color [0] = texture ( image, tex );
    color [1] = vec4    ( velocity, 0, 1 );
}

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

В этом случае для получения текстурных координат для обращения в буфер истории мы просто смещаем текущие текстурные координаты на значение из текстуры скорости.

#version 330 core

uniform sampler2D curMap;
uniform sampler2D velMap;
uniform sampler2D historyMap;
uniform vec2 jitter;

in  vec2 tex;
in  vec4 viewRay;
out vec4 color;

const float alpha = 1.0 - 0.2;

void main(void)
{
    vec2    vel = texture ( velMap, tex ).xy;

    color = mix ( texture ( curMap, tex - jitter ), texture ( historyMap, tex - vel ), alpha );
}

Если мы просто слегка поворачиваем камеру (без ее переноса, т.е. относительные видимости объектов при этом не меняются), то этот прием отлично работает. Но в общем случае у нас происходят (пусть и небольшие) изменения видимости за кадр - ранее невидимые объекты становятся видимыми, а часть ранее видимых становятся невидимыми. И в этом случае описанное выше решение приводит к ряду артефактов, это называется disocclusion.

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

Рис.5. Точка, видимая в кадре N-1, становится невидимой в кадре N.

Color clipping

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

В идеале мы просто строим по цветам из окрестности их выпуклую оболочку (convex hull) в трехмерном RGB-пространстве и проверяем цвет из буфера истории на попадание внутрь. Если не попал - просто отбрасываем. Более удачным оказывается вариант - не отбрасывать, а проектировать на выпуклую оболочку.

К сожалению считать выпуклую оболочку и проектировать цвет на нее для каждого фрагмента оказывается слишком дорогостоящим, поэтому обычно используются более простые варианты. Самым простым вариантом является построение по цветам из окрестности 3х3 AABB - т.е. мы находим для нее покомпонентные минимум и максимум. После чего обрезаем цвет из буфера истории по ним (clamp).

uniform sampler2D curMap;
uniform sampler2D velMap;
uniform sampler2D historyMap;
uniform vec2 jitter;

in  vec2 tex;
in  vec4 viewRay;
out vec4 color;

const float alpha = 1.0 - 0.2;
const float EPS   = 0.001;

const mat3 RGBToYCoCgMatrix = mat3 ( 0.25, 0.5, -0.25, 0.5, 0.0,  0.5,  0.25, -0.5, -0.25 );
const mat3 YCoCgToRGBMatrix = mat3 ( 1.0,  1.0,  1.0,  1.0, 0.0, -1.0, -1.0,   1.0, -1.0  );

    // note: clips towards aabb center + p.w
    // p = colorCurr, q = colorHist
vec4 clipAABB ( vec3 pMin, vec3 pMax, vec4 p, vec4 q ) 
{
    vec3 p_clip = 0.5 * (pMax + pMin);
    vec3 e_clip = 0.5 * (pMax - pMin) + EPS;
    
    vec4  v_clip  = q - vec4 ( p_clip, p.w );
    vec3  v_unit  = v_clip.xyz / e_clip;
    vec3  a_unit  = abs ( v_unit );
    float ma_unit = max ( a_unit.x, max ( a_unit.y, a_unit.z ) );   
    
    if ( ma_unit > 1.0 )
        return vec4 ( p_clip, p.w ) + v_clip / ma_unit;
    else
        return q;   // point inside aabb
}

void    computeStats ( out vec3 mean, out vec3 sigma )
{
    vec3    m2 = vec3 ( 0.0 );

    mean    = vec3 ( 0.0 );
    
    for ( int i = -1; i <= 1; i++ )
        for ( int j = -1; j <= 1; j++ )
        {
            vec3    c = textureOffset ( curMap, tex, ivec2 ( i, j ) ).rgb;
            
            mean += c;
            m2   += c*c;
        }
        
    mean /= 9;
    sigma = sqrt ( m2 / 9 - mean * mean );
}

void    computeMinMax ( out vec3 minColor, out vec3 maxColor )
{
    minColor = vec3 ( 100.0 );      // too big for a minColor
    maxColor = vec3 ( -100.0 );     // too small for a maxColor
    
    for ( int i = -1; i <= 1; i++ )
        for ( int j = -1; j <= 1; j++ )
        {
            vec3    c = textureOffset ( curMap, tex, ivec2 ( i, j ) ).rgb;
            
            minColor = min ( minColor, c );
            maxColor = max ( maxColor, c );
        }

}

void main(void)
{
    vec2    vel = texture ( velMap, tex ).xy;
    vec3    minColor, maxColor;
    
    computeMinMax ( minColor, maxColor );
    
    vec3    historyColor = texture ( historyMap, tex - vel ).rgb;
    
    historyColor = clamp ( historyColor, minColor, maxColor );

    color = vec4 ( mix ( texture ( curMap, tex - jitter ).rgb, historyColor, alpha ), 1.0 );
}

Есть слегка отличающийся вариант, когда мы по окрестности считаем среднее \( \overline{c} \) и среднеквадратичное отклонение \( \sigma \). После это мы строим границы AABB через них по следующим формулам (здесь \( \gamma \) это задаваемый параметр, обычно лежит от 0.75 до 1.25)

\[ \begin{matrix} \begin{aligned} c_{min} = \overline{c} - \gamma \cdot \sigma \\ c_{max} = \overline{c} + \gamma \cdot \sigma \\ \end{aligned} \end{matrix} \]

vec3    mean, sigma;

computeStarts ( mean, sigma );

vec3    cMin = mean - sigma * gamma;
vec3    cMin = mean + sigma * gamma;

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

\[ \begin{pmatrix} Y \\ Co \\ Cg \end{pmatrix} = \begin{pmatrix} \frac{1}{4} & \frac{1}{2} & \frac{1}{4} \\ \frac{1}{2} & 0 & -\frac{1}{2} \\ -\frac{1}{4} & \frac{1}{2} & -\frac{1}{4} \\ \end{pmatrix} \begin{pmatrix} R \\ G \\ B \end{pmatrix}, Y \in [0,1], Co, Cg \in [-0.5, 0.5] \]

\[ \begin{pmatrix} R \\ G \\ B \end{pmatrix} = \begin{pmatrix} 1 & 1 & -1 \\ 1 & 0 & 1 \\ 1 & -1 & -1 \\ \end{pmatrix} \begin{pmatrix} Y \\ Co \\ Cg \end{pmatrix} \]

Ниже приводится код на GLSL, осуществляющий перевод цвета между цветовыми пространствами RGB и YCoCg.

vec3 rgbToYCbCg ( float r, float g, float b )
{
    float   Co  = r - b;
    float   tmp = b + Co * 0.5;
    float   Cg  = g - tmp;
    float   Y   = tmp + Cg * 0.5;

    return vec3 ( Y, Co, Cg );
}

vec3    yCoCgToRgb ( float y, float co, float cg )
{
    float   tmp = y - cg * 0.5;
    float   g   = cg + tmp;
    float   b   = tmp - co * 0.5;
    float   r   = B + co;
 
    return vec3 ( r, g, b );
}

В презентации по Unreal Engine предлагается еще один вариант - берутся сразу две окрестности, по ним считается min/max и усредняются между ними. В качестве используемых окрестностей предлагается классическая 3х3 окрестность и ее "скругленная" версия.

Рис 6. Используемые окрестности.

Вместо простого отсечения цвета по границам AABB (как в пространстве RGB, так и в пространстве YCoCg) можно пойти слегка другим путем. Мы строим отрезок от цвета из буфера истории до цвета из текущего кадра и находим его пересечение с границей нашего AABB. И именно эту точку мы и берем в качестве цвета из истории. Если предыдущий подход называется clamping, то этот подход называется clipping.

Рис 7. clip vs clamp

Еще одним (правда более сложным) вариантом является использование вместо AABB другого ограничивающего тела - OBB. Тогда по цветам из окрестности считается OBB и цвет из буфера истории проектируется уже на него. Но в этом случае мы получаем заметно более сложный код.

Depth rejection

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

Color Velocity rejection

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

TAA и tonemapping

На самом деле есть еще один довольно важный момент - когда мы применяем ТАА - до tonemapping или после. У каждого варианта есть свои плюсы и минусы. В результате пришли к комбинированному варианту - мы сперва применяем простой tonemapping (обычно Reinhard), после него применяем ТАА и уже после него мы инвертируем использованный tonemapping и передаем получившееся изображение на настоящий tonemapping и постобработку.

Для Reinhard tonemapping есть очень простая формула

\[ c' = \frac{c}{1 + l} \] \[ l = (c, L), L = (0.2126, 0.7152, 0.0722) \]

Теперь давайте рассмотрим как можно обратить это преобразование. Давайте посмотрим чему равна яркость \(l'\) для преобразованного цвета \(c'\):

\[ l' = (c', L ) = \frac{(c, L)}{1+(c,L)} = \frac{l}{1 + l} = 1 - \frac{1}{1 + l} \]

Отсюда

\[ 1 + l = \frac{1}{1 - l'} \] \[ c'' = c' \cdot ( 1 + l ) = \frac{c'}{1 - (c',L)} \]

float   luminance ( in vec3 color )
{
    return dot ( color, vec3 ( 0.2126, 0.7152, 0.0722 ) );
}

vec3    reinhard ( in vec3 color )
{
    return color / ( 1.0 + luminance ( color ) );
}

vec3    reinhardInv ( in vec3 color )
{
    return color / ( 1.0 - luminance ( color ) );
}

Размытие

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

Для борьбы с этим является использование более сложным схем восстановления значения (интерполяции) при чтении из текстуры. Часто для этого применяется фильтр Катмулла-Рома, содержащий отрицательные веса (см рис), что делает получающееся изображение более четким.

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

Рис. 8. Схема Катмулла-Рома

Сам одномерный сплайн Катмулла-Рома задается следующей формулой

\[ q(t) = \frac{1}{2} \left( 2p_1 + (-p_0 + p_2) \cdot t + (2p_0 - 5p_1 + 4p_2 - p_3) \cdot t + (-p_0 + 3p_1 -3p_2 + p_3) \cdot t \right) \]

В двухмерном случае нам уже нужно не 4, а 16 точек, т.е. чтений из текстуры. Для нас 16 чтений из текстуры это слишком много (хотя можно использовать вычислительный шейдер и там использовать shared-память). Есть оптимизированные варианты, дающие вполне приемлемую точность при заметно меньшем числе точек.

Классическая схема Катмулла-Рома требует 16 чтений из текстуры, что довольно много. Можно оптимизировать по приводимой ниже схема - чтение по точкам, указанным на рисунке позволяет получить значение при всего 9 обращениях к текстуре.

Рис. 9. Паттерн с 9 точками

Ниже приводится функция на GLSL, осуществляющая интерполяцию Катмулла-Рома по 9 точкам.

// 9-tap bicubic filtering
vec3 bicubicFilter9 ( vec2 texcoord )
{
    vec2 size                   = vec2 ( textureSize ( historyMap, 0 ) );
    vec2 invSize                 = 1.0 / size;
    float REPROJECTION_SHARPNESS = 50;

    // from [0,1] to texels
    vec2 position       = size * texcoord;      
    vec2 centerPosition = floor ( position - 0.5 ) + 0.5;
    vec2 f              = position - centerPosition;
    vec2 f2             = f * f;
    vec2 f3             = f * f2;
 
    float c  = REPROJECTION_SHARPNESS / 100.0;  
    vec2  w0 =        -c  * f3 +  2.0 * c         * f2 - c * f;
    vec2  w1 =  (2.0 - c) * f3 - (3.0 - c)        * f2         + 1.0;
    vec2  w2 = -(2.0 - c) * f3 + (3.0 -  2.0 * c) * f2 + c * f;
    vec2  w3 =         c  * f3 -                c * f2;
 
    vec2 w12         = w1 + w2;
    vec2 tc12        = invSize * ( centerPosition + w2 / w12 );
    vec3 centerColor = texture ( historyMap, vec2 ( tc12.x, tc12.y ) ).rgb;
 
    // from texels to [0,1]
    vec2 tc0 = invSize * (centerPosition - 1.0);
    vec2 tc3 = invSize * (centerPosition + 2.0);
    
    vec4 color = vec4 ( texture ( historyMap, vec2 ( tc12.x, tc0.y  ) ).rgb, 1.0) * (w12.x * w0.y ) +
                 vec4 ( texture ( historyMap, vec2 ( tc0.x,  tc12.y ) ).rgb, 1.0) * (w0.x  * w12.y) +
                 vec4 ( centerColor,                                         1.0) * (w12.x * w12.y) +
                 vec4 ( texture ( historyMap, vec2 ( tc3.x,  tc12.y ) ).rgb, 1.0) * (w3.x  * w12.y) +
                 vec4 ( texture ( historyMap, vec2 ( tc12.x, tc3.y  ) ).rgb, 1.0) * (w12.x * w3.y );

    return color.rgb / color.a;     // assume A=1, then color.a is normalization
}

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

Рис. 10. Паттерн с 5 точками

Еще одним местом, где может иметь место размытие, является само текстурирование. При наложении текстуры на геометрии обычно уже применяется пирамидальное фильтрование дающее требуемое размытие. Но за счет использование jitter'а мы размываем текстуру еще больше. Самым простым способом борьбы с этим является добавление отрицательного смещения (bias) к значению LOD используемому при текстурировании.

Если мы рассмотрим производные текстурных координат (по которым собственно и считается LOD) как радиус размытия, то в самом простом случае (всего два различных значения для jitter) нам достаточно задать смещение LOD на \( -\frac{\sqrt{2}}{2} \).

Для задания смещение LOD, которое будет добавляться при любом обращении к текстуре, можно использовать следующий фрагмент кода.

glTextureParameteri ( texture, GL_TEXTURE_LOD_BIAS, bias );

Другим важным моментом является то, что в отличии от буфера истории текстура скоростей (как и буфер глубины) уже содержат в себе алиасинг. И с ним нужно как-то бороться. Самым простым способом борьбы будет вместо чтения одного значения по заданным координатам читать сразу некоторую окрестность (3х3 или 5-точечную окрестность). И в этой окрестности мы выбираем значение скорости с наибольшей длиной (для скорости) или ближайшее значения (для буфера глубины).

vec2    dilateVelocity ( in vec2 uv )
{
    float   vLenMax = 0.0;
    uv  uvMax   = uv;
    vec2    scale   = 1.0 / vec2 ( textureSize ( velocityMap, 0 ) );

    for ( int i = -1; i <= 1; i++ )
        for ( int j = -1; j <= 1; j++ )
        {
            vec4    v  = textureOffset ( velocityMap, uv, ivec2 ( i, j ) );
            float   vl = dot ( v, v );

            if ( vl > vLenMax )
            {
                vLenMax = vl;
                uvMax   = uv + vec2 ( i, j ) * scale;
            }
        }

    return uvMax;
}

TAA в игре Horizon: Zero Dawn

Очень интересная модификация TAA была использована в игре Horizon: Zero Dawn. Там были использованы сразу два алгоритма антиалиасинга - FXAA и TAA. Вместо буфера истории мы просто используем предыдущий кадр в том виде, в котором он подан на вход TAA (после применения FXAA). Смешение текущего кадра с предыдущим происходит с весом 0.5 (если фрагмент с предыдущего кадра не был отброшен).

При этом используется всего два значения для субпиксельного смещения (jitter) - одно для четных кадров и одно для нечетных. В одном случае мы сдвигаемся на полпиксела по горизонтали, в другом - на полпиксела по вертикали. За счет этого вместо сложного фильтра Катмулла-Рома достаточно использовать простую 4-точечную схему для фильтрации.

Рис 11. Фильтрация для 4-х точечной схемы по горизонтали

Исходный код к этой статье можно скачать по следующей ссылке.

Полезные ссылки

Temporal AA and the quest for the Holy Trail

DECIMA: Advances in Lighting and AA

A Survey of Temporal Antialiasing Techniques

Temporal Anti-Aliasing(TAA) Tutorial

Temporal Anti Aliasing – Step by Step

High Quality Temporal Supersampling

Temporal Reprojection Anti-Aliasing in INSIDE

Temporal Reprojection Anti-Aliasing

Intel® Graphics Optimized TAA

Filmic SMAA: Sharp Morphological and Temporal Antialiasing

Temporal Reprojection Anti-Aliasing in INSIDE

Filmic SMAA. Sharp Morphological and Temporal Antialiasing

Temporal Antialiasing Starter Pack

HIGH-QUALITY TEMPORAL SUPERSAMPLING

Temporal supersampling and antialiasing

Temporal Antialiasing in Uncharted 4