Exemple d'implémentation du SMAA avec Vireo RHI

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

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 :

Passe 1 Détection des contours smaaEdgeBuffer · R16G16_SFLOAT
Passe 2 Poids de mélange smaaBlendBuffer · R16G16_SFLOAT
Passe 3 Mélange de voisinage smaaColorBuffer · format 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.

Note sur l'exemple
Dans une implémentation SMAA complète, la passe 2 est plus complexe. Dans cet exemple, l'implémentation utilise une approche simplifiée.

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