Exemple d'implémentation du TAA avec Vireo RHI

Vireo RHI / C++ / Vulkan / DirectX 12 / Slang Shader Langage / Deferred Rendering Henri Michelon

Contexte

Cet article détaille l'implémentation du TAA dans les examples Vireo RHI Samples du projet Vireo Rendering Hardware Interface

Le code source correspondant se trouve dans l'exemple "Deferred" : https://github.com/HenriMichelon/vireo_samples/tree/main/src/samples/deferred

Introduction : qu'est-ce que le TAA ?

Le Temporal Anti-Aliasing (TAA) est une technique d'anti-aliasing qui utilise les informations accumulées sur plusieurs images successives pour lisser les artefacts visuels. Contrairement au MSAA qui opère pendant le rendu sur les arrêtes des géométries, ou au FXAA qui applique un flou post-traitement, le TAA tire parti du temps comme dimension supplémentaire d'échantillonnage. (les examples Vireo RHI Samples incluent aussi des examples de MSAA et FXAA).

L'idée centrale : chaque frame, la caméra est légèrement décalée d'un sous-pixel (jitter). Le résultat est ensuite fusionné avec l'historique des frames précédentes. C'est aujourd'hui la technique d'anti-aliasing dominante dans les moteurs 3D.

Place du TAA dans le pipeline

L'ordre d'exécution du pipeline deferred sera le suivant :

Depth
PrepassZ-buffer
G-Buffer
PassPosition/Normal/Albedo/Velocity
Lighting
PassDéferred shading
TAA
PassAccumulation
OIT
PassTransparence
Post-
processSMAA/FXAA/Effet

Le TAA opère sur les géométries opaques : le G-Buffer fournit les données de vélocité des artéfacts pour le shader TAA, bien avant que les géométries transparentes soient rendues lors de la passe OIT.

Jitter de caméra

Le jitter consiste à décaler la projection d'un sous-pixel différent à chaque frame, selon une séquence quasi-aléatoire (du moins suffisament aléatoire pour la TAA).

Les données TAA nécessitent d'enrichir le Global UBO (Uniform Buffer Object) partagé entre CPU et shaders :

struct Global {
    alignas(16) glm::mat4 projection;
    glm::mat4 view;
    glm::mat4 viewInverse;
    glm::mat4 previousProjection; // TAA
    glm::mat4 previousView;       // TAA
    glm::vec2 jitter;             // TAA
    alignas(16) glm::vec4 ambientLight;
};

POur calculer le jitter on utilise traditionnellement une séquence de Halton que l'on stocke dans l'UBO global.

Implémentation dans Scene.cpp

void Scene::jitterProjection(const vireo::Extent& extent) {
    static uint32_t frameIndex = 0;

    // Séquence de Halton : base b, index i
    const auto halton = [](const uint32_t index, const uint32_t base) {
        auto result = 0.0f;
        auto f = 1.0f / static_cast<float>(base);
        auto i = index;
        while (i > 0) {
            result += f * static_cast<float>(i % base);
            i /= base;
            f /= static_cast<float>(base);
        }
        return result;
    };

    global.jitter = {
        (halton(frameIndex % 16 + 1, 2) - 0.5f) / static_cast<float>(extent.width),
        (halton(frameIndex % 16 + 1, 3) - 0.5f) / static_cast<float>(extent.height)
    };

    global.previousProjection = global.projection;
    global.projection[2][0] = global.jitter.x; // Décalage X
    global.projection[2][1] = global.jitter.y; // Décalage Y

    frameIndex++;
}

Le jitter est appliqué directement sur la projection courante de la caméra, sur les cellules projection[2][0] et projection[2][1] qui correspondent au décalage de la ligne de visée dans la matrice de projection en perspective. La matrice courante est d'abord sauvegardée dans previousProjection avant modification.

Calcul de la vélocité par pixel

Le TAA nécessite de savoir où se trouvait chaque pixel dans la frame précédente. Cette information est stockée dans un velocity buffer (ausso appelé motion vector buffer) : un render target 2 canaux 16 bits contenant le déplacement en espace NDC de chaque fragment entre la frame n-1 et la frame n.

Ajout d'un 5ème attachement dans le G-Buffer Pass

// Format du velocity buffer
vireo::ImageFormat::R16G16_SFLOAT, // TAA Velocity

// Constante d'index pour le descriptor set
static constexpr int BUFFER_VELOCITY{4};

// Ajout du render target velocity
struct FrameData : FrameDataCommand {
    ...
    std::shared_ptr<vireo::RenderTarget>  velocityBuffer; // TAA
};

// Envoi du render target au shader dans onRender()
renderingConfig.colorRenderTargets[BUFFER_VELOCITY].renderTarget = frame.velocityBuffer;

// Création du render target dans onResize()
frame.velocityBuffer = vireo->createRenderTarget(
    pipelineConfig.colorRenderFormats[BUFFER_VELOCITY],
    extent.width,extent.height,
    vireo::RenderTargetType::COLOR,
    renderingConfig.colorRenderTargets[BUFFER_VELOCITY].clearValue,
    1, vireo::MSAA::NONE,
    "Velocity Buffer");

Modifications des shaders (Slang)

La structure VertexOutput est tour d'abords modifiée pour passer la position précédente entre le vertex shader et le fragment shader :

struct VertexOutput {
    float4 position   : SV_POSITION;
    float3 worldPos   : TEXCOORD0;
    float3 normal     : TEXCOORD1;
    float2 uv         : TEXCOORD2;
    float3 tangent    : TEXCOORD3;
    float3 bitangent  : TEXCOORD4;
    float4 previousPos: TEXCOORD5; // TAA
};

Le vertex shader calcule la position précédente du vertex à l'aide des données de la frame précédente sauvergardées côté CPU:

// Position précédente (frame n-1) pour TAA
float4 previousViewPos = mul(global.previousView, worldPos);
output.previousPos = mul(global.previousProjection, previousViewPos);

Le fragment shader en déduit le vecteur de vélocité en espace NDC, en retirant le jitter :

float2 curPos  = (input.position.xy  / input.position.w);
float2 prevPos = (input.previousPos.xy / input.previousPos.w);
output.velocity = (curPos - prevPos) - global.jitter; // TAA

La soustraction du jitter est indispensable : sans elle, la vélocité inclurait le décalage artificiel de la caméra, ce qui ferait croire à un mouvement factice et dégraderait la qualité du TAA.

L'application du TAA

Le cœur du TAA est le shader (taa.frag.slang). Il prend en entrée trois textures :

BindingTextureContenu
t1inputImageRendu courant (frame n)
t2historyRésultat TAA de la frame n-1
t3velocityVelocity buffer calculé par la G-Buffer pass
Texture2D inputImage : register(t1);
Texture2D history : register(t2);
Texture2D velocity : register(t3);

1. Reprojection par velocity

float2 vel = velocity.Sample(sampler, uv).xy;
float2 historyUV = uv - vel;

// Rejet si hors écran (occlusion, bord)
if (any(historyUV < 0.0) || any(historyUV > 1.0)) {
    return current;
}

2. Filtrage Catmull-Rom de l'historique

L'historique est échantillonné via un filtre Catmull-Rom bicubique plutôt que bilinéaire classique. Ce filtre donne un résultat plus net et réduit le flou de "ghosting".

float4 prev = SampleTextureCatmullRom(
    history, sampler, historyUV, params.imageSize);

3. Variance Clipping en espace YCoCg

Pour éviter le ghosting (résidus fantômes des frames précédentes), l'historique est contraint dans une AABB calculée à partir du voisinage 3×3 du pixel courant. Cette opération est réalisée en espace YCoCg, plus adapté à la perception visuelle que RGB.

velocity = clamp(prevYCoCg, mean − σ, mean + σ)
float3 m1 = 0.0, m2 = 0.0;
for (int x = -1; x <= 1; ++x) {
    for (int y = -1; y <= 1; ++y) {
        float3 neighborYCoCg = RGBToYCoCg(
            inputImage.Sample(sampler, uv + offset).rgb);
        m1 += neighborYCoCg;
        m2 += neighborYCoCg * neighborYCoCg;
    }
}
float3 mean   = m1 / 9.0;
float3 stddev = sqrt(max(0.0, (m2 / 9.0) - (mean * mean)));
float3 minColor = max(mean - 1.0 * stddev, neighborMin);
float3 maxColor = min(mean + 1.0 * stddev, neighborMax);
float3 clampedPrev = clamp(prevYCoCg, minColor, maxColor);

4. Mélange adaptatif

Le facteur de mélange entre la frame courante et l'historique n'est pas fixe — il s'adapte en fonction de deux signaux : la magnitude du vecteur de vélocité et l'intensité du clamping (indicateur de désocclusion).

Les constantes fixes de cette section permettent d'ajuster le résultat du TAA. Celles choisies dans cet exemple privilégient l'antialiasing aux artefacts (voir chapitre 07)

// Poids vélocité : plus on bouge, moins on fait confiance à l'historique
float velocityWeight = saturate(length(vel) * 30.0);
// Poids clamping : si l'historique a été beaucoup corrigé, réduire sa part
float clampWeight = saturate(length(clampedPrev - prevYCoCg) * 3.0);
// blend ∈ [0.75, 0.95] — 95% historique au repos, 75% lors de mouvement
float blendFactor = lerp(0.95, 0.75, max(velocityWeight, clampWeight));
float3 result = lerp(current.rgb, finalPrevRGB, blendFactor);

Gestion des buffers temporels (ping-pong)

Le TAA maintient deux buffers de couleur par frame-in-flight (taaColorBuffer[0] et taaColorBuffer[1]) selon un schéma ping-pong).

// Ajout des binding des buffers :
static constexpr vireo::DescriptorIndex BINDING_HISTORY{2}; // TAA Only
static constexpr vireo::DescriptorIndex BINDING_VELOCITY{3}; // TAA Only

// Ajout des buffers et descriptor set:
struct FrameData {
    ...
    std::shared_ptr<vireo::DescriptorSet> taaDescriptorSet[2];
    std::shared_ptr<vireo::RenderTarget>  taaColorBuffer[2];
};

// Ajout du descriptor layout et du pipeline TAA :
std::shared_ptr<vireo::Pipeline> taaPipeline;
std::shared_ptr<vireo::DescriptorLayout> taaDescriptorLayout;
          
// Dans onInit(), création du descriptor layout, descriptor set, pipeline et buffer:
taaDescriptorLayout = vireo->createDescriptorLayout();
taaDescriptorLayout->add(BINDING_PARAMS, vireo::DescriptorType::UNIFORM);
taaDescriptorLayout->add(BINDING_INPUT, vireo::DescriptorType::SAMPLED_IMAGE);
taaDescriptorLayout->add(BINDING_HISTORY, vireo::DescriptorType::SAMPLED_IMAGE);
taaDescriptorLayout->add(BINDING_VELOCITY, vireo::DescriptorType::SAMPLED_IMAGE);
taaDescriptorLayout->build();

const auto taaResources = vireo->createPipelineResources({
    taaDescriptorLayout,
    samplers.getDescriptorLayout() });

pipelineConfig.resources = taaResources;
pipelineConfig.fragmentShader = vireo->createShaderModule("shaders/taa.frag");
taaPipeline = vireo->createGraphicPipeline(pipelineConfig);

for (auto& frame : framesData) {
    ...
    frame.taaDescriptorSet[0] = vireo->createDescriptorSet(taaDescriptorLayout);
    frame.taaDescriptorSet[0]->update(BINDING_PARAMS, paramsBuffer);
    frame.taaDescriptorSet[1] = vireo->createDescriptorSet(taaDescriptorLayout);
    frame.taaDescriptorSet[1]->update(BINDING_PARAMS, paramsBuffer);
}
    

A chaque frame, le buffer courant devient le buffer historique de la frame suivante, et vice versa :

// Dans taaPass() :
const auto historyIndex  = (taaIndex + 1) % 2;
const auto currentHistory  = frame.taaColorBuffer[taaIndex];
const auto previousHistory = frame.taaColorBuffer[historyIndex];

// En fin de rendu — avance l'index si TAA actif
if (applyTAA) {
    taaIndex = (taaIndex + 1) % 2;
}

Le reste est une passe de rendu full-screen classique avec Vireo RHI :

 cmdList->barrier(
   colorBuffer,
   vireo::ResourceState::RENDER_TARGET_COLOR,
   vireo::ResourceState::SHADER_READ);
cmdList->barrier(
   previousHistory,
   vireo::ResourceState::UNDEFINED,
   vireo::ResourceState::SHADER_READ);
cmdList->barrier(
    currentHistory,
    vireo::ResourceState::UNDEFINED,
    vireo::ResourceState::RENDER_TARGET_COLOR);

frame.taaDescriptorSet[taaIndex]->update(BINDING_INPUT, colorBuffer->getImage());
frame.taaDescriptorSet[taaIndex]->update(BINDING_HISTORY, previousHistory->getImage());
frame.taaDescriptorSet[taaIndex]->update(BINDING_VELOCITY, velocityBuffer->getImage());

renderingConfig.colorRenderTargets[0].renderTarget = currentHistory;
cmdList->beginRendering(renderingConfig);
cmdList->setViewport(vireo::Viewport{
    static_cast<float>(extent.width),
    static_cast<float>(extent.height)});
cmdList->setScissors(vireo::Rect{
    extent.width,
    extent.height});
cmdList->bindPipeline(taaPipeline);
cmdList->bindDescriptors({frame.taaDescriptorSet[taaIndex], samplers.getDescriptorSet()});
cmdList->draw(3);
cmdList->endRendering();

cmdList->barrier(
    previousHistory->getImage(),
    vireo::ResourceState::SHADER_READ,
    vireo::ResourceState::UNDEFINED);
cmdList->barrier(
    colorBuffer->getImage(),
    vireo::ResourceState::SHADER_READ,
    vireo::ResourceState::UNDEFINED);

Intégration dans la chaîne de rendu

La sortie du TAA s'insère entre le rendu des géométries opaques (Deferred Lighting) et le rendu des géométries transparentes (OIT). Lorsque le TAA est activé, les passes suivantes de l'exemple (OIT, FXAA, SMAA, effet Voronoi, correction gamma) consomment le taaColorBuffer plutôt que le colorBuffer d'origine :

// Après le Deferred Lighting:
postProcessing.taaPass(
    frameIndex,
    swapChain->getExtent(),
    samplers,
    cmdList,
    frame.colorBuffer,
    gbufferPass.getVelocityBuffer(frameIndex));
auto colorBuffer = postProcessing.applyTAA
    ? postProcessing.getTAAColorBuffer(frameIndex)
    : frame.colorBuffer;

// OIT et post-processing reçoivent ce colorBuffer
transparencyPass.onRender(..., colorBuffer);
postProcessing.onRender(..., colorBuffer);

Autres considérations pratiques

Dans l'exemple Deferred le TAA est activable à la volée avec la touche T, comme pour les autres effets post-traitement (S pour SMAA, F pour FXAA, etc.).

Paramètres de qualité ajustables

ParamètreValeurRôle
Cycle Halton16 framesNombre de positions de jitter distinctes
σ variance clipping1.0×Tolérance avant rejet de l'historique
Facteur blend (repos)0.95Poids de l'historique quand aucun mouvement
Facteur blend (mouvement)0.75Poids de l'historique lors de camera/objet en mouvement
Velocity scale30.0×Sensibilité du blend au vecteur de vélocité
Limitation

Le velocity buffer étant calculé uniquement sur les géométries opaques dans le G-Buffer, les objets transparents (OIT) ne contribuent pas à la vélocité, ce qui peut produire du ghosting sur les surfaces transparentes en mouvement.