M2 images
TP5 - TAA / Temporal Anti Aliasing
Le TAA est utilisé dans quasi-tous les codes de rendu temps réel...
Le principe est simple : au lieu de tout calculer à chaque image,
TAA va permettre de ne calculer qu'une partie des résultats et
d'accumuler ces résultats partiels avec les résultats précédents.
Par exemple, au lieu de calculer l'influence de 256 sources de
lumières sur chaque fragment, il devient possible de n'évaluer que
quelques sources à chaque image. Par exemple 32 sources par image...
Par contre, il faudra 8 images pour accumuler la totalité des 256
sources. On vient d'introduire de la latence dans le calcul de
l'éclairage, mais on vient aussi de gagner beaucoup de temps sur le
calcul de l'image complète. Pourquoi vouloir faire ça ? Peut-être
parce que cette modification permet de rendre 60 ou 120 images par
secondes et de rester parfaitement fluide, sans aucune saccade dans
l'affichage. A 60 images par seconde, 8 images ne représentent que
128ms ou 0.1s...
On peut bien sur utiliser TAA pour anti-aliasser l'image complète !
en remplacant les n points testés par la fragmentation /
rasterization des triangles par un seul, ce qui est encore plus
appréciable lorsque le code de rendu utilise le rendu différé. on
peut aussi l'utiliser pour filtrer les textures opaques et même les
textures semi-transparentes !
alors c'est magique ? sur une image fixe ? oui, sur une image
en mouvement, beaucoup moins...
Partie 1 : comment ça marche ?
Sur une image fixe, le principe est très simple. on suppose que l'on
a décomposé nos calculs en N étapes :
pour l'image i sur N,
réaliser la ième étape de calculs
intermédiaires pour chaque pixel de l'image,
accumuler chaque pixel avec les résultats des
étapes précédentes, de 0 à i-1,
afficher l'accumulation.
Il est assez direct de n'utiliser qu'une seule image "historique"
qui permet d'accumuler les images précédentes, au lieu de stocker
séparément chaque image intermédiaire et d'accumuler répétitivement
les étapes précédentes.
historique= 0
pour l'image i sur N,
réaliser la ième étape de calculs
intermédiaires pour chaque pixel de l'image,
historique= historique + image,
afficher historique.
Parfait ! et si on veut calculer une moyenne sur N valeurs, qu'est
ce qui change ? on peut simplement diviser par N au moment de
l'affichage, on peut aussi calculer incrémentalement la moyenne, ie
l'historique contient la moyenne des étapes 0 à i-1 inclus, et on
pondère le nouveau résultat pour calculer la moyenne des étapes 0 à
i inclus.
comment ?
on note \( \bar{x}_n \) la moyenne des n premières étapes, ie de 0 à
i-1.
\[
\bar{x}_{n}= \frac{1}{n} \sum_{i=0}^{n-1} x_i
\]
on ajoute \( x_i \) le résultat de la ième étape pour obtenir \(
\bar{x}_{n+1} \) la moyenne des n+1 premières étapes.
\[
\bar{x}_{n+1}= \frac{1}{n+1} (n \cdot \bar{x}_{n}
+ x_i)
\]
ou sous une forme plus lisible :
\[
\bar{x}_{n+1}= \frac{n}{n+1} \bar{x}_{n} +
\frac{1}{n+1} x_i
\]
encore plus simple, en se rappelant que l'on peut ré-écrire \(
\frac{n}{n+1} \) comme \( \frac{n+1}{n+1} - \frac{1}{n+1} \)
\[
\bar{x}_{n+1}= \left(1 - \frac{1}{n+1} \right)
\bar{x}_{n} + \frac{1}{n+1} x_i
\]
et on peut ré-ecrire la boucle d'accumulation sous la forme utilisée
dans les articles qui décrivent TAA :
historique= 0
pour l'image i sur N,
réaliser la ième étape de calculs
intermédiaires pour chaque pixel de l'image,
// historique est la moyenne de i valeurs...
historique= (1 - 1 / (i+1)) historique + 1 /
(i+1) image,
afficher historique.
Et dans le cas normal, sur une image en mouvement ?
Il va falloir trouver sur quel pixel accumuler chaque étape. Dans le
cas "fixe", les résultats du pixel (x,y) sont accumulés sur le même
pixel de l'image qui contient l'historique, ie l'accumulation des
étapes précédentes. Si la camera ou les objets bougent, il va
falloir reprojeter le fragment visible pour le pixel (x,y) dans
l'image précédente. Il suffit de garder les transformations
utilisées pour dessiner les objets lors de l'image précédente et de
modifier le vertex shader pour calculer les 2 transformations :
celle de l'image i qui est en cours de dessin / calcul ainsi que la
transformation utilisée pour l'image précédente, i-1. Il reste un
tout petit problème à régler : on est pas du tout sur que le point
du triangle visible sur le pixel (x,y) à l'image i est le même que
le point visible sur le pixel reprojetté à l'image i-1 !
pourquoi ? les objets bougent, donc un objet peut cacher la
reprojection du point et le fragment visible dans l'image précédente
n'est pas le bon fragment. En gros, toutes les silhouettes des
objets en mouvement vont avoir ce problème, ie tous les pixels de
l'image ou la visibilité des surfaces change. Il va falloir détecter
ces situations et modifier l'accumulation des résultats de l'étape
courante. Il existe pleins de variantes de ces tests...
En résumé, le TAA peut se formuler assez simplement :
historique= 0
pour l'image i sur N,
réaliser la ième étape de calculs
intermédiaires pour chaque pixel de l'image,
reprojetter le point visible par chaque pixel
dans l'image précédente,
si le point reprojetté correspond à la même
surface :
accumuler les résultats des
étapes précédentes dans le pixel courant,
sinon
??
afficher l'image courante.
l'image courante devient l'historique / l'accumulation des
résultats précédents.
la qualité ou les défauts du TAA sont introduits par la partie notée
??, lorsque l'on ne connait pas l'historique du pixel...
le test de reprojection peut être approximatif lui aussi et
introduire des erreurs dans l'accumulation.
Il y a une autre cause de défauts d'accumulation même lorsque la
reprojection est correcte : le point reprojetté n'aura pas les
coordonnées exactes du centre d'un pixel dans l'image précédente.
Comment détecter que le pixel reprojetté est valide / correct ?
ie qu'il contient bien l'historique du pixel ?
1. tests classiques sur les différences de
normales et de profondeurs qui permettent de détecter une
discontinuité,
2. on pourrait aussi faire un test sur la
couleur, mais c'est inutilisable avec des reflets, on pourrait
stocker la couleur de base de l'objet, ou, plus simple, ses
coordonnées de texture.
Que peut-on faire lorsqu'on ne connait pas l'historique d'un pixel
?
1. rien ! le pixel n'a pas d'historique, il
n'y aura pas d'accumulation.
2. fabriquer une couleur plausible en utilisant
(la distribution des couleurs) des pixels voisins...
Comment accumuler ou oublier l'historique ?
toutes les implémentations utilisent une moyenne exponentielle pour
accumuler les résultats. Bizarrement, c'est assez intuitif : au lieu
de moyenner le ième résultat avec l'historique, on pondère le
nouveau résultat et l'historique :
\[ resultat = (1 - \alpha) \cdot historique + \alpha
\cdot resultat \]
par exemple, voici le poids de la première valeur accumulée (notée
\( x_0 \) au dessus) en fonction de \( \alpha \) et du nombre
d'images accumulées / du temps. La courbe noire hachurée représente
la pondération utilisée pour calculer une moyenne comme expliqué
au-dessus \( \alpha= 1 / (n+1) \).
si \( \alpha \) est proche de 1, le résultat contient très peu
d'historique, la pondération de la première valeur \( x_0 \) est
déja à 0.1 pour l'image suivante lorsque \( \alpha= 0.9 \) et on
oublie l'historique l'image suivante si \( \alpha= 1 \).
à l'inverse, si \( \alpha \) est faible, 0.1 par exemple, on
accumule le résultat actuel avec les résultats précédents. et \( x_0
\) à encore une pondération de ~0.4 après 10 images accumulées...
exercice : quelle valeur de \( \alpha \) permet de calculer
une moyenne ?
ou : pourquoi appele-t-on cette accumulation une moyenne
exponentielle ?
la réponse se trouve, par exemple, dans "Amortized
Super-Sampling", L. Yang, Siggraph 2009
pour calculer la moyenne de N valeurs, \( \alpha \) n'est pas
constant dans le temps... mais toutes les implementaions de TAA
utilisent des valeurs constantes (sauf dans la partie notée ??) ce
qui veut dire qu'elles ne convergent jamais vers le résultat exact
d'une moyenne ou d'une intégration... les explications et comment
réduire l'erreur sont expliqués dans l'article précédent. (divulgachage
de la réponse : il suffit de compter !)
question : combien d'images peut-on espérer accumuler ?
indications : s'il y a beaucoup de mouvements, il y aura
beaucoup de discontinuités et les pixels correspondants n'auront pas
d'historique. pour les autres, il est aussi probable que leur
couleur change dans le temps, si une source de lumière bouge, s'ils
passent à l'ombre d'un objet, etc... en pratique, il est assez
difficile d'accumuler plus de 4 ou 8 images !
Partie 2 : comment ça se code ?
avant de vous lancer dans le code, passez un peu de temps à
comprendre les différentes options qui sont résumées dans cet
article (un peu trop) très complet :
"A
Survey of Temporal Antialiasing Techniques" L. Yang,
Eurographics 2020
oui, bien sur, TAA est surtout utilisé pour accumuler plusieurs
échantillons par pixel pour réduire l'aliasing géométrique, mais la
méthode est tellement générale que l'on peut l'utiliser pour presque
tout qui nécessite de calculer une moyenne ou d'intégrer une
fonction...
pour les curieux : anti aliasing géométrique avec TAA, "An
excursion in Temporal Supersampling", M. Salvi, GDC 2016
pour les très curieux : la version apprentissage par le même
auteur l'année suivante : "Deep
Learning: The Future of Real-Time Rendering ?", M. Salvi,
Siggraph course 2017
pour ceux qui veulent utiliser de l'apprentissage : "Minimal
Convolutional Neural Networks for Temporal Anti Aliasing", K.
Herveau, M. Piochowiak, C. Dachsbacher, HPG 2023
exercice 1 :
nettoyez votre code de rendu différé.
exercice 2 :
allouez une texture couleur supplémentaire 4 canaux GL_RGBA8
pour stocker l'historique. décomposez votre calcul d'éclairage
direct avec 256 sources ponctuelles sur plusieurs images et
additionnez les N valeurs successives dans l'historique.
vérifiez que sur une scène fixe, sans mouvement d'objet ou de
camera, l'image converge vers le même résultat qu'avant de
décomposer les calculs.
que se passe-t-il lorsque la camera bouge ? tout le problème
va être de détecter et de corriger ces défauts d'accumulation.
à gauche : scène fixe
convergée, à droite : scène en mouvement,
l'accumulation ne se fait pas sur le bon pixel...
rappel : vous pouvez copier une texture dans une autre avec
glBlitFramebuffer(), mais il faut préparer un framebuffer :
init( ):
// source
glGenFramebuffers(1, &src_framebuffer);
glBindFramebuffer(GL_READ_FRAMEBUFFER, src_framebuffer);
{
glFramebufferTexture(GL_READ_FRAMEBUFFER,
GL_COLOR_ATTACHMENT0, src_texture, /* mipmap */ 0);
glReadBuffer(GL_COLOR_ATTACHMENT0);
}
// copie
glGenFramebuffers(1, &dst_framebuffer);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, dst_framebuffer);
{
glFramebufferTexture(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
texture, /* mipmap */ 0);
GLenum buffers[]= { GL_COLOR_ATTACHMENT0 };
glDrawBuffers(1, buffers);
}
render( ):
glBindFramebuffer(GL_READ_FRAMEBUFFER, src_framebuffer);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, dst_framebuffer);
// copie
une région de 1024x1024 pixels...
glBlitFramebuffer(0,0, 1024,1024, 0,0, 1024,1024,
GL_COLOR_BUFFER_BIT,
GL_NEAREST);
exercice 3 :
modifiez l'application pour stocker la transformation de l'image
précédente. modifiez les shaders pour calculer les 2 transformations
et lire la couleur du pixel reprojetté dans l'image précédente. Dans
un rendu différé, il faut aussi créer une texture pour stocker la
reprojection du pixel dans l'image précédente. utilisez une texture
2 canaux GL_RG16F.
indications : n'oubliez pas que les coordonnées de texture()
sont entre 0 et 1, et que les coordonnées de texelFetch()
et imageLoad() sont en pixels (ie entre 0 et largeur
et 0 et hauteur). Pensez à utiliser la transformation Viewport(),
elle sert exactement à ça !
rappel : après une multiplication par une matrice de
projection, un point n'est plus réel mais homogène, pensez à
retrouver le point réel en normalisant le point homogène, ie vec4
ph= matrix * vec4(p, 1); vec3 pp= ph.xyz / ph.w;
à gauche : scène fixe
convergée, à droite : scène en mouvement,
l'accumulation se fait maintenant sur le bon pixel...
sauf sur le bord droit ou les points n’étaient pas visibles dans
l'image précédente (les pixels sont marqués en violet)
exercice 4 : c'est peu flou non ?
relisez "Amortized Super-Sampling" pour comprendre pourquoi
accumuler l'historique et le pixel filtre / floute le résultat.
indications : en plus des solutions proposées dans l'article,
il y a tout simplement la possibilité de désactiver le filtrage et
l'interpolation de la texture qui stocke l'historique ! il y a 2
solutions pour lire une texture sans interpolation :
1. soit paramétrer la texture pour désactiver
l'interpolation avec glTexParameter(GL_TEXTURE_2D,
GL_XXX_FILTER, GL_NEAREST), à placer après le code de
création de la texture dans init(), ie make_vec3_texture();
et utiliser texture() dans le shader,
2. soit utiliser texelFetch() dans le shader.
pour les curieux : vous pouvez tester avec un filtre de
meilleure qualité que le filtre bilinéaire du matériel. un filtre
bicubique par exemple. on peut soit lire 16 pixels dans l'image pour
filtrer, ce qui est assez (trop) lent... soit utiliser le filtre
bilinéaire du matériel pour accélérer le calcul du filtre
bicubique... en ne faisant que 4 ou 5 accès filtrés à la
texture... cf "Bicubic
Filtering in Fewer Taps"
ou pour les très curieux, on peut aussi estimer l'erreur
commise par le filtre bilinéaire et la corriger, c'est encore plus
rapide (en 3 accès filtrés à la texture) l'idée est présentée par M.
Drobot, dans "HRAA"
en 2014, slides 59-95. c'est la méthode d'anti aliasing utilisée par
FarCry 4. cf l'article original "An Unconditionally
Stable MacCormack Method" A. Selle, 2008 pour les détails
complets sur la méthode de correction d'erreur.
exercice 5 : c'est pas trop lisse non ?
Habituellement, la fragmentation ne teste qu'un seul point par pixel
(le centre du pixel) et n'évalue qu'un seul fragment shader. Pour
les pixels sur lesquels se projettent plusieurs triangles, un seul
triangle sera donc testé et évalué, et la couleur calculée pour ce
pixel ne sera pas vraiment représentative de sa "vraie" couleur (ie
la moyenne des couleurs des triangles). La fragmentation /
rasterization matérielle peut tester plusieurs points par pixel, ie
plusieurs échantillons, et calculer une meilleure estimation de la
couleur moyenne de ces échantillons. La plupart des cartes
graphiques supportent jusqu'à 8 ou 16 points nativement.
Exécuter les fragments shaders pour chaque point testé est vraiment
très long et ce mode d'anti-aliasing, le FSAA (Full Screen AA) n'est
pas utilisé en pratique (sauf peut être dans un mode photo non
interactif...). Les cartes proposent aussi une version approchée
plus rapide : le MSAA (Multi Sample AA) qui n'exécute les fragments
shaders qu'une fois par triangle touché par un point. Cette
optimisation du FSAA suppose que la couleur du triangle est
constante par pixel. Il n'y a en pratique que 2 ou 3 triangles qui
touchent quelques pixels le long des arêtes ou sur les sommets des
triangles. A l'intérieur des triangles, il n'y a bien sur qu'un seul
triangle. En résume, la quasi totalité des pixels de l'image
(>90%) n'exécute qu'une seule fois le fragment shader. Cette
solution, le MSAA est vraiment plus efficace, sans introduire trop
de défauts par rapport à la référence. Mais son utilisation est
limitée au rendu direct / forward. Lorsque l'on utilise un rendu
différé, il est très simple de reproduire le FSAA, ie d'évaluer la
couleur de tous les points. Par contre, reproduire MSAA est plus
délicat, il faudrait trier les triangles touchés par les points
testés par la fragmentation et n'exécuter que le bon nombre de
shaders, par pixel... ce qui produit une exécution très incohérente
qui est donc particulièrement lente. En bref, le MSAA n'est pas trop
adapté au rendu différé... (sans parler du volume des textures qui
stockent 4 ou 8 valeurs au lieu d'une seule. La mémoire disponible
pour stocker des choses plus intéressantes (comme des textures ou de
la géométrie...) est réduite elle aussi).
Pour gagner du temps et simplifier la réalisation, on va utiliser
TAA et un seul point aléatoire par pixel et par image. Il faut
configurer le pipeline graphique pour tester un point différent du
centre du pixel. cf les extensions propriétaires amd
et nvidia
par exemple. Ou, on peut tout simplement déplacer l'espace projectif
d'une fraction de pixel, ce qui revient au même (oui, tous les
pixels testent le même point au lieu de tester le centre). Il suffit
d'appliquer une translation sur le plan xy dans le repère projectif
: Translation(x / image_width, y / image_height, 0)
avec x et y, 2 nombres aléatoires compris entre -0.5 et 0.5.
Vérifiez que lorsque la scène est fixe vous obtenez une version
anti-aliassée de l'image, ie que vous calculez vraiment la moyenne
de 16 échantillons, par exemple. (ie quelles sont les valeurs
d'alpha dans ce cas ?) Que faire pour la 17 ieme image ?
Quel est le défaut de cette solution lorsque la camera ou la scène
bouge ? comment régler alpha dans ce cas ?
exercice 6 : et les textures semi-transparentes ?
cf "Stochastic
Transparency" E. Enderton, Siggraph I3D 2010
même principe que l'exercice précédent, on remplace une intégration
par TAA.
il suffit d'intégrer l'opacité dans la passe de construction du
GBuffer. et... c'est tout, on obtient magiquement la transparence
des objets.
dans le fragment shader qui construit le GBuffer, il y a
probablement un test sur la transparence de la texture :
vec4 color= texture( diffuse, texcoords );
if(color.a < 0.3)
discard;
il suffit de rendre ce test aléatoire pour détruire (ou pas) le
fragment le bon nombre de fois (ie color.a donne la
proportion) afin de rendre l'objet transparent ou opaque.
rappel : construire un générateur de nombre aléatoire est
aussi simple qu'une fonction de hachage, cf CRNG32 dans "Rendu
et générateurs de nombres aléatoires"

"Stochastic Transparency" intégrée "automatiquement" par TAA, la
camera est au milieu d'un arbre (!?)
et observe l'intérieur de la maison à travers plusieurs surfaces
semi-transparentes.
exercice 7 : peaufinage...
comment éliminer ou en tout cas estomper les défauts de TAA ?
relisez le résumé des techniques de TAA et expérimentez.
par exemple, ajuster alpha en fonction de la vitesse de déplacement
marche plutôt bien...
on peut également assez facilement détecter les discontinuités (en
analysant le voisinage 3x3 des pixels) sur les normales (à gauche)
et la profondeur (à droite) ou la vitesse de déplacement :
à gauche : discontinuités sur les normales dans
un voisinage 3x3, à droite : discontinuités sur
la profondeur dans un voisinage 3x3...
on remarque que les discontinuités détectées dans les 2 cas sont
complémentaires :
ie le test sur les normales ne détecte pas un changement de
profondeur comme autour des fenêtres sur la façade,
alors que le test sur la profondeur le détecte. et symétriquement,
le test sur la profondeur ne détecte pas les changements
d'orientation...
exercice 8 : et les textures opaques ?
il existe plusieurs paramètres de filtrage des textures : GL_LINEAR,
GL_NEAREST, GL_LINEAR_MIPMAP_LINEAR, GL_ANISOTROPY, etc pourquoi ?
ces filtres classiques sont-ils exacts ?
pourrait-on utiliser TAA pour remplacer les filtres classiques ?
comment ? quel serait l’intérêt ?
pour les curieux : monte carlo, importance sampling et
filtrage de texture, cf M. Salvi 2024 (encore lui !!) "Stochastic
Texture Filtering"
suivi de "Improved
Stochastic Texture Filtering Through Sample Reuse" B. Wronski,
M. Pharr, T. Akenine-Moller, 2025
quizz ! filtres GL_LINEAR_MIPMAP_LINEAR + ANISOTROPY 8x, ou
GL_NEAREST, ou GL_NEAREST + TAA ?