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.
Son implémentation se découpe en trois passes de compute shader, implémentées en C++23 et Slang.
Par rapport à une approche full-screen quad en pipeline graphique, l'implémentation compute
évite les passes beginRendering/endRendering et les
render targets intermédiaires : les sorties de chaque passe sont des images
en lecture/écriture (createReadWriteImage) accédées directement
via des descripteurs READWRITE_IMAGE.
Le pipeline SMAA
SMAA Nécessite trois passes pour arriver aux résultat final :
Chaque passe est un compute shader dispatchant une grille de groupes de travail couvrant l'image entière (groupes de 8×8 threads). Les shaders sont écrits en Slang, un langage de shading moderne compilé vers SPIR-V (Vulkan) ou DXIL (DirectX 12).
Contrairement à l'approche full-screen quad en pipeline graphique, aucun
vertex shader ni vertex buffer n'est nécessaire.
Les buffers intermédiaires sont des images en lecture/écriture
(createReadWriteImage) et non des render targets, ce qui
supprime les transitions RENDER_TARGET_COLOR et les appels
beginRendering/endRendering.
Passe 1 — Détection des contours
Cette passe reçoit l'image couleur sortant du pipeline de rendu (inputImage)
et produit une image encodant l'intensité des arêtes horizontales et verticales pour chaque pixel.
Le compute shader est dispatché avec des groupes de 8×8 threads, chaque thread traitant un pixel.
#include "postprocess.inc.slang"
ConstantBuffer<Params> params : register(b0);
Texture2D inputImage : register(t1);
RWTexture2D<float4> edgeOutput : register(u2);
SamplerState sampler : register(SAMPLER_LINEAR_EDGE, space1);
[numthreads(8, 8, 1)]
void computeMain(uint3 dispatchId : SV_DispatchThreadID) {
float2 uv = (dispatchId.xy + 0.5) / params.imageSize;
// 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, uv).rgb, lumaWeight);
float lumaN = dot(inputImage.Sample(sampler, uv + float2( 0, -1) / params.imageSize).rgb, lumaWeight);
float lumaS = dot(inputImage.Sample(sampler, uv + float2( 0, 1) / params.imageSize).rgb, lumaWeight);
float lumaW = dot(inputImage.Sample(sampler, uv + float2(-1, 0) / params.imageSize).rgb, lumaWeight);
float lumaE = dot(inputImage.Sample(sampler, 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);
// Écriture directe : R = arête horizontale, G = arête verticale
edgeOutput[dispatchId.xy] = float4(edgeH, edgeV, 0, 0);
}
Par rapport à un fragment shader, le compute shader reçoit sa position en pixels
via SV_DispatchThreadID et écrit directement dans
RWTexture2D via edgeOutput[dispatchId.xy],
sans passer par un render target ni un attachement de couleur.
Les coordonnées UV sont reconstruites en divisant par params.imageSize.
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.
L'image de sortie smaaEdgeImage est allouée en
R16G16_SFLOAT soit deux canaux en 16 bits.
Le canal Red est utilisé pour le gradient horizontal et
le canal Green pour le gradient vertical.
Exemple de détection de contour (Lysa Engine)
Passe 2 — Calcul des poids de mélange
Cette passe consomme l'image produite en passe 1 et
calcule un poids scalaire de mélange pour chaque pixel.
Elle lit edgeImage via un Texture2D
(accès en lecture seule) et écrit le résultat dans blendOutput,
une RWTexture2D.
#include "postprocess.inc.slang"
ConstantBuffer<Params> params : register(b0);
Texture2D edgeImage : register(t1);
RWTexture2D<float4> blendOutput : register(u2);
[numthreads(8, 8, 1)]
void computeMain(uint3 dispatchId : SV_DispatchThreadID) {
// Lecture des arêtes H et V depuis la passe 1 (coordonnées entières)
float2 edge = edgeImage[dispatchId.xy].rg;
// Le poids est le maximum des deux directions, clampé dans [0, 1]
float weight = saturate(max(edge.r, edge.g));
blendOutput[dispatchId.xy] = 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, aucun sampler n'est utilisé ici :
edgeImage[dispatchId.xy] effectue un accès direct par coordonnées
entières (équivalent à un nearest-neighbor sans filtrage),
ce qui est exact puisque l'on lit les données d'arêtes pixel à pixel.
Les accès hors limites retournent zéro grâce au comportement par défaut
de Texture2D en mode border.
Exemple de calcul de mélange (Lysa Engine)
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 (PostProcessing.ixx et PostProcessing.cpp)
gère l'ensemble du cycle de vie des passes post-traitement..
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