Contexte
Cet article détaille l'implémentation du SMAA dans les examples Vireo RHI Samples du projet Vireo Rendering Hardware Interface
Qu'est ce que le SMAA ?
L'anti-aliasing est l'une des problématiques fondamentales du rendu temps réel. SMAA (Subpixel Morphological Anti-Aliasing) propose une approche post-process qui travaille sur le résultat final du rendu : l'image est traitée après rendu, sans utiliser la géométrique (comme le fait MSAA).
Le principe est morphologique : on détecte la forme des contours pour en déduire des poids de mélange adaptatifs, ce qui offre une qualité proche du MSAA 4× pour un coût bien moindre.
Dans cet example, SMAA est intégré au module PostProcessing aux
côtés de FXAA et TAA, activable à la volée via la touche M lors de
l'exécution.
Son implémentation se découpe en trois passes de fragment shader, implémentées en C++23 et Slang.
Le pipeline SMAA
SMAA Nécessite trois passes pour arriver aux résultat final :
Chaque passe est un full-screen quad.
La géométrie de ce triangle est générée entièrement dans le vertex
shader (quad.vert), sans vertex buffer,
en exploitant SV_VertexID.
Les shaders sont écrits en Slang,
un langage de shading moderne compilé vers SPIR-V (Vulkan) ou
DXIL (DirectX 12).
Passe 1 — Détection des contours
Cette passe reçoit l'image couleur sortant du pipeline de rendu (inputImage)
et produit un buffer encodant l'intensité des arêtes horizontales et verticales pour chaque pixel.
#include "postprocess.inc.slang"
ConstantBuffer<Params> params : register(b0);
Texture2D inputImage : register(t1);
SamplerState sampler : register(SAMPLER_LINEAR_EDGE, space1);
float4 fragmentMain(VertexOutput input) : SV_TARGET {
// Coefficients de la formule de luminance perceptuelle ITU-R BT.601
float3 lumaWeight = float3(0.299, 0.587, 0.114);
// Échantillonnage du pixel central et de ses 4 voisins directs
float lumaC = dot(inputImage.Sample(sampler, input.uv).rgb, lumaWeight);
float lumaN = dot(inputImage.Sample(sampler, input.uv + float2( 0, -1) / params.imageSize).rgb, lumaWeight);
float lumaS = dot(inputImage.Sample(sampler, input.uv + float2( 0, 1) / params.imageSize).rgb, lumaWeight);
float lumaW = dot(inputImage.Sample(sampler, input.uv + float2(-1, 0) / params.imageSize).rgb, lumaWeight);
float lumaE = dot(inputImage.Sample(sampler, input.uv + float2( 1, 0) / params.imageSize).rgb, lumaWeight);
// Gradient horizontal : différence gauche-centre + centre-droite
float edgeH = abs(lumaW - lumaC) + abs(lumaC - lumaE);
// Gradient vertical : différence haut-centre + centre-bas
float edgeV = abs(lumaN - lumaC) + abs(lumaC - lumaS);
// Sortie : R = arête horizontale, G = arête verticale
return float4(edgeH, edgeV, 0, 0);
}
Travailler en luminance plutôt qu'en RGB présente deux avantages :
réduction du nombre de samples (un scalaire au lieu d'un vecteur de 3 float)
et meilleure corrélation avec la perception humaine.
Les coefficients (0.299, 0.587, 0.114) sont ceux de la
norme BT.601, standard en infographie.
Le sampler utilisé ici est SAMPLER_LINEAR_EDGE
(mode d'adressage clamp-to-edge, filtrage bilinéaire).
Cela garantit que les pixels aux bords de l'image ne lisent pas de texels hors-limites.
Le buffer smaaEdgeBuffer est alloué en
R16G16_SFLOAT soit deux canaux en 16 bits.
Le canal Red est utilisé pour le gradient horizontal et
le canal Green pou le gradient vertical.
Passe 2 — Calcul des poids de mélange
Cette passe consomme le buffer produit en passe 1 et calcule un poids scalaire de mélange pour chaque pixel.
#include "postprocess.inc.slang"
ConstantBuffer<Params> params : register(b0);
Texture2D edgeBuffer : register(t1);
SamplerState sampler : register(SAMPLER_NEAREST_BORDER, space1);
float4 fragmentMain(VertexOutput input) : SV_TARGET {
// Lecture des arêtes H et V depuis la passe 1
float2 edge = edgeBuffer.Sample(sampler, input.uv).rg;
// Le poids est le maximum des deux directions, clampé dans [0, 1]
float weight = saturate(max(edge.r, edge.g));
return float4(weight, weight, weight, 0);
}
max(edgeH, edgeV) sélectionne la direction dominante
de l'arête.
Un pixel qui se trouve à l'intersection d'une arête forte
dans les deux directions obtiendra un poids maximal,
ce qui est le comportement souhaité puisque ce pixel
bénéficiera davantage du lissage.
La fonction saturate(x) est l'équivalent GPU de
la fonction C++ clamp(x, 0, 1).
Contrairement à la passe 1, le sampler SAMPLER_NEAREST_BORDER
utilisé en passe 2
n'applique pas de filtrage bilinéaire :
on lit les données d'arêtes telles quelles,
pixel à pixel. Le mode border retourne zéro pour
tout accès hors de la texture.
Passe 3 — Mélange de voisinage
La dernière passe combine l'image originale avec ses voisins immédiats, proportionnellement aux poids calculés en passe 2.
#include "postprocess.inc.slang"
ConstantBuffer<Params> params : register(b0);
Texture2D inputImage : register(t1);
Texture2D blendBuffer : register(t2); // Sortie de la passe 2
SamplerState sampler : register(SAMPLER_NEAREST_BORDER, space1);
float4 fragmentMain(VertexOutput input) : SV_TARGET {
float4 color = inputImage.Sample(sampler, input.uv); // Pixel central
float2 blend = blendBuffer.Sample(sampler, input.uv).rg; // Poids H et V
float4 n = inputImage.Sample(sampler, input.uv + float2( 0, -1) / params.imageSize);
float4 e = inputImage.Sample(sampler, input.uv + float2( 1, 0) / params.imageSize);
// Interpolation verticale : mélange avec le voisin Nord
float4 blended = lerp(color, n, blend.r);
// Interpolation horizontale : mélange avec le voisin Est
blended = lerp(blended, e, blend.g);
return blended;
}
Cette passe est la seule à utiliser le layout
smaaDescriptorLayout avec trois bindings :
le buffer de paramètres (b0),
l'image couleur originale (t1)
et le buffer de poids (t2).
La première interpolation (lerp)
mélange le pixel courant avec son voisin Nord
proportionnellement à blend.r
(arête horizontale détectée = transition haut/bas).
La seconde mélange le résultat avec le voisin Est selon
blend.g.
Là où il n'y a aucune arête (blend ≈ 0),
le pixel est retourné inchangé.
Là où une arête forte est détectée (blend ≈ 1),
le pixel est fortement interpolé avec son voisin dans la
direction perpendiculaire à l'arête.
Intégration dans le pipeline PostProcessing
Le module samples.common.postprocessing (fichiers PostProcessing.ixx et PostProcessing.cpp) gère l'ensemble du cycle de vie des passes post-traitement. L'architecture utilise les modules C++20 (export module), qui remplacent les headers traditionnels.
Initialisation des pipelines (onInit)
// Création des trois pipelines graphiques SMAA
pipelineConfig.colorRenderFormats.push_back(vireo::ImageFormat::R16G16_SFLOAT);
pipelineConfig.resources = resources; // layout standard (2 bindings)
pipelineConfig.fragmentShader = vireo->createShaderModule("shaders/smaa_edge_detect.frag");
smaaEdgePipeline = vireo->createGraphicPipeline(pipelineConfig);
pipelineConfig.fragmentShader = vireo->createShaderModule("shaders/smaa_blend_weigth.frag");
smaaBlendWeightPipeline = vireo->createGraphicPipeline(pipelineConfig);
// La passe 3 a un format et un layout différents
pipelineConfig.colorRenderFormats[0] = renderFormat; // format final de l'image
pipelineConfig.resources = smaaResources; // layout avec 3 bindings
pipelineConfig.fragmentShader = vireo->createShaderModule("shaders/smaa_neighborhood_blend.frag");
smaaBlendPipeline = vireo->createGraphicPipeline(pipelineConfig);
Deux layouts de descripteurs différents sont créés : descriptorLayout (2 bindings : params + input) pour les passes 1 et 2, et smaaDescriptorLayout (3 bindings : params + input + blendBuffer) pour la passe 3.
Boucle de rendu (onRender)
La passe SMAA dans onRender est intégrée aux autres
passe de post processing :
// === Passe 1 : détection des contours ===
cmdList->barrier(colorInput, // image source → lecture shader
vireo::ResourceState::RENDER_TARGET_COLOR,
vireo::ResourceState::SHADER_READ);
cmdList->barrier(frame.smaaEdgeBuffer, // edge buffer → cible de rendu
vireo::ResourceState::UNDEFINED,
vireo::ResourceState::RENDER_TARGET_COLOR);
frame.smaaEdgeDescriptorSet->update(BINDING_INPUT, colorInput->getImage());
// ... beginRendering → bindPipeline → draw(3) → endRendering
// === Passe 2 : poids de mélange ===
cmdList->barrier(frame.smaaEdgeBuffer, // edge buffer → lecture shader
vireo::ResourceState::RENDER_TARGET_COLOR,
vireo::ResourceState::SHADER_READ);
cmdList->barrier(frame.smaaBlendBuffer, // blend buffer → cible de rendu
vireo::ResourceState::UNDEFINED,
vireo::ResourceState::RENDER_TARGET_COLOR);
frame.smaaBlendWeightDescriptorSet->update(BINDING_INPUT, frame.smaaEdgeBuffer->getImage());
// ... beginRendering → bindPipeline → draw(3) → endRendering
// === Passe 3 : mélange de voisinage ===
cmdList->barrier(frame.smaaBlendBuffer, // blend buffer → lecture shader
vireo::ResourceState::RENDER_TARGET_COLOR,
vireo::ResourceState::SHADER_READ);
frame.smaaBlendDescriptorSet->update(BINDING_INPUT, colorInput->getImage());
frame.smaaBlendDescriptorSet->update(BINDING_SMAA_INPUT, frame.smaaBlendBuffer->getImage());
// ... beginRendering → bindPipeline → draw(3) → endRendering
Le pattern est identique pour chaque passe :
barrier sur la source (transition vers SHADER_READ),
barrier sur la destination (transition vers RENDER_TARGET_COLOR),
mise à jour du descriptor set, puis exécution du draw.
Les barrières garantissent la synchronisation correcte entre
les passes sur GPU (être sûr que les données soit bien
écritent dans les buffers avant d'êtres lues).
SMAA vs FXAA vs MSAA — comparaison
Les exemples Vireo RHI de ce projet intègrent trois techniques d'anti-aliasing :
FXAA (Fast Approximate Anti-Aliasing)
FXAA détecte les contours par contraste local et applique un flou directionnel en une seule passe. Extrêmement rapide, mais peut rendre l'image légèrement floue, notamment sur les textures.
SMAA (Subpixel Morphological AA)
SMAA analyse la morphologie des arêtes pour des poids de mélange plus précis. La qualité est supérieure à FXAA, avec moins de perte de netteté. Le coût est plus élevé (3 passes) mais reste largement acceptable sur du matériel moderne.
TAA (Temporal Anti-Aliasing)
TAA exploite l'historique des frames précédentes pour accumuler davantage d'information. Excellente qualité sur les géométries statiques, mais nécessite un buffer de vélocité et une gestion du ghosting sur les objets en mouvement rapide.
La combinaison TAA + SMAA (supportée dans cet exemple) est particulièrement intéressante : TAA stabilise temporellement l'image tandis que SMAA affine les contours spatiaux, chacune compensant les faiblesses de l'autre.
Conclusion
Plusieurs axes d'amélioration sont possibles pour se rapprocher du SMAA "complet" :
- Utiliser une LUT de recherche d'arêtes en passe 2 pour calculer des poids directionnels précis basés sur la longueur des arêtes
- Implémenter le stencil masking pour ne traiter que les pixels à proximité d'une arête, réduisant la charge GPU
- Ajouter la variante SMAA T2× (avec jitter temporel) pour un anti-aliasing subpixel encore plus fin