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
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.
L'ordre d'exécution du pipeline deferred sera le suivant :
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.
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.
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.
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.
// 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");
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.
Le cœur du TAA est le shader (taa.frag.slang). Il prend en entrée trois textures :
| Binding | Texture | Contenu |
|---|---|---|
t1 | inputImage | Rendu courant (frame n) |
t2 | history | Résultat TAA de la frame n-1 |
t3 | velocity | Velocity buffer calculé par la G-Buffer pass |
Texture2D inputImage : register(t1);
Texture2D history : register(t2);
Texture2D velocity : register(t3);
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;
}
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);
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.
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);
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);
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);
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);
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ètre | Valeur | Rôle |
|---|---|---|
| Cycle Halton | 16 frames | Nombre de positions de jitter distinctes |
| σ variance clipping | 1.0× | Tolérance avant rejet de l'historique |
| Facteur blend (repos) | 0.95 | Poids de l'historique quand aucun mouvement |
| Facteur blend (mouvement) | 0.75 | Poids de l'historique lors de camera/objet en mouvement |
| Velocity scale | 30.0× | Sensibilité du blend au vecteur de vélocité |
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.