- En partant d’une simple animation CSS
border-radius sur une page d’accueil, l’article montre comment implémenter directement une animation de blob fluide avec quelques lignes de fragment shader
- Un shader est un petit programme qui transforme des coordonnées de pixels en couleurs sur le GPU ; grâce à son exécution parallèle, il est rapide mais fortement contraint en matière de partage d’état et d’abstractions de haut niveau
- L’exemple de base en GLSL mappe les coordonnées
vUv vers les valeurs RGBA de gl_FragColor, ce qui permet de se familiariser avec la syntaxe comme varying, uniform, les types vectoriels et le swizzling
- Les cercles et les blobs sont construits avec
distance(), step(), smoothstep() et les Signed Distance Functions (SDF), puis plusieurs SDF sont combinées avec min() ou un smooth minimum
- En utilisant
u_time pour animer les sphères et en ajoutant u_mouse au tableau des centres, on étend le tout vers un shader interactif où l’utilisateur peut agir avec la souris
Ce que font les shaders et leurs contraintes
- Un shader est un petit programme exécuté sur le GPU qui, au minimum, prend en entrée des coordonnées de pixels et produit une couleur en sortie
- Dans les jeux vidéo, il sert à créer des effets visuels comme l’éclairage, les effets spéciaux ou le rendu de style cartoon, et constitue une base essentielle des graphismes modernes
- Le cœur de sa rapidité vient de la parallélisation, avec une exécution simultanée sur de nombreux pixels
- Cette introduction se concentre sur l’OpenGL Shading Language compatible navigateur, c’est-à-dire GLSL
- En contrepartie des performances, les shaders doivent être écrits de façon compacte et bas niveau
- Il est difficile de s’appuyer sur des abstractions de haut niveau ou sur l’import de bibliothèques
- À cause de leur mode d’exécution parallèle, ils fonctionnent de manière sans état et sans mémoire, sans possibilité de stocker ni de partager des données entre pixels
Premier exemple GLSL : transformer des coordonnées en couleur
- Un shader convertit des coordonnées normalisées en couleur RGBA
- Les coordonnées sont généralement normalisées entre 0 et 1
(0, 0) correspond au coin inférieur gauche, (1, 1) au coin supérieur droit
- Par convention, ces coordonnées sont souvent appelées
st ou uv
- L’exemple le plus simple est un dégradé où plus la coordonnée x augmente, plus le rouge augmente, et plus la coordonnée y augmente, plus le vert augmente
varying vec2 vUv;
void main() {
vec2 st = vUv;
gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
}
- On peut créer un dégradé bleu avec
gl_FragColor = vec4(0.0, 0.0, st.x, 1.0);, en injectant la coordonnée x dans le canal bleu
- Les éléments importants de la syntaxe sont les suivants
varying : une entrée dont la valeur change pour chaque pixel
uniform : une entrée identique pour tous les pixels
vec2, vec3, vec4, mat2, mat3 : des types vectoriels et matriciels explicitement typés, comme en C
- swizzling : une notation qui permet d’extraire une partie d’un vecteur, comme
vec4(1, 2, 3, 4).xy
gl_FragColor : la sortie qui détermine la couleur de chaque pixel à la fin de main()
Créer un cercle avec distance(), step() et smoothstep()
- Même pour dessiner une forme nette comme un cercle, on n’utilise pas une fonction du type
drawCircle(), mais une distance mathématique
- La distance entre le pixel courant et le centre du cercle peut se calculer avec
distance(vec2 p1, vec2 p2)
- Si on mappe directement cette distance à une couleur, on obtient un dégradé circulaire ; pour un cercle plein, on utilise
step(float threshold, float value)
- si la distance dépasse le seuil, le résultat vaut 1
- sinon, il vaut 0
step() produit une transition abrupte, ce qui peut créer de l’aliasing sur le bord du cercle
- Pour obtenir un bord plus doux, on peut utiliser
smoothstep(float t_start, float t_end, float x)
Représenter des formes avec les Signed Distance Functions
- Une Signed Distance Function (SDF) représente, sous forme de distance signée, à quel point un point de l’espace est éloigné d’une forme
- à l’intérieur de la forme, la valeur est négative
- à l’extérieur, elle est positive
- sur la frontière, elle vaut 0
- La SDF d’un cercle peut s’obtenir en soustrayant le rayon à la distance mesurée depuis le centre
float circleSDF(vec2 p, float r) {
return length(p) - r;
}
- En décalant le point à partir de la position du pixel
uv, on peut calculer la distance à un cercle placé n’importe où dans l’espace UV
- Si
d < 0.0, alors le pixel est à l’intérieur du cercle et peut être coloré différemment
- On trouve davantage de formes 2D en SDF dans la liste complète d’Inigo Quilez
Combiner plusieurs SDF pour créer un blob
- Les SDF se prêtent facilement à la création de nouvelles formes via des opérations booléennes
- Pour l’union de deux SDF, il suffit de prendre le
min() des deux distances
- si l’une des deux formes contient le point, la distance minimale est négative
- si elles sont toutes deux à l’extérieur, la distance minimale est positive
- On utilise
1. - smoothstep() parce que step() et smoothstep() renvoient 1 pour les distances supérieures au seuil, c’est-à-dire à l’extérieur de la forme
- Un simple
min() crée une discontinuité nette à l’endroit où deux cercles se rencontrent
- Pour les fondre en douceur comme un blob, on utilise un smooth minimum
- l’argument supplémentaire
k contrôle l’intensité du lissage
- l’exemple utilise une fonction polynomial smooth min
float smin(float a, float b, float k)
{
float h = max( k-abs(a-b), 0.0 )/k;
return min( a, b ) - h*h*k*(1.0/4.0);
}
Créer une animation de metaballs avec un uniform temporel
- Un shader peut recevoir des valeurs arbitraires sous forme de uniforms, comme avec un slider
- L’animation consiste à passer au shader une entrée
u_time générée en JavaScript, puis à l’utiliser dans le calcul des coordonnées des centres des cercles
- Le shader est alors mis à jour en pratique 60 fois par seconde avec de nouvelles valeurs de
u_time, ce qui produit un mouvement fluide
- Les centres des cercles peuvent être animés avec des fonctions périodiques comme
sin et cos
- Lorsqu’on combine plusieurs cercles en metaballs, on stocke les coordonnées des centres dans un tableau et on accumule les SDF dans une boucle
vec2 centers[4] = vec2[4](c1,c2,c3,c4);
float d = 99.;
for (int i = 0; i < 4; i++) {
vec2 c = centers[i];
float sdf = circleSDF(uv, c, .1*u_slider);
d = smin(d, sdf, K);
}
- Le blob obtenu fonctionne sur le plan technique, mais reste monochrome ; l’article ajoute ensuite la couleur et l’interaction
Interaction finale pilotée à la souris
- La dernière étape consiste à récupérer les coordonnées de la souris dans le uniform
u_mouse afin de permettre à l’utilisateur de contrôler directement l’une des sphères du blob
uniform vec2 u_mouse;
- Il suffit d’ajouter les coordonnées de la souris au tableau des centres pour introduire l’interaction en une seule ligne
vec2 centers[5] = vec2[5](c1,c2,c3,c4,u_mouse);
- Le shader final inverse la coordonnée y de la souris pour l’aligner sur le repère du canvas, puis combine les 4 centres animés avec le centre piloté par la souris
- Les couleurs sont composées avec plusieurs appels à
mix(colorA, colorB, percent)
- si
percent est utilisé comme un booléen, le comportement se rapproche d’un if/else
- la valeur
metaball, la distance au point central, la brillance et la membrane influencent la couleur finale
- Une fois ce principe compris, il devient aussi plus facile de saisir le fonctionnement interne d’éditeurs de shaders nodaux comme les shader nodes de Blender ou le Shader Graph de Unity
Pour aller plus loin
1 commentaires
Avis sur Hacker News
J’ai enfin trouvé le courage d’écrire sur Internet et de m’exposer. Je voulais depuis longtemps apprendre les shaders, et je me suis dit que ce serait bien de documenter mon apprentissage pour le partager avec d’autres
Le blogging sincère est en train de mourir invisiblement dans l’ombre de l’algorithme tout-puissant, et Internet donne l’impression d’être arrivé en plein milieu de Star Wars épisode IV. Un seul retour : merci, et j’espère que tu écriras davantage
Si tu veux voir ce que les experts peuvent faire avec des shaders, je te recommande Inigo Quilez et son shader art : https://www.youtube.com/watch?v=BFld4EBO2RE
Ajout : je n’avais pas vu que tu étais l’auteur. L’article est très réussi, et je cherchais justement un tutoriel qui rende l’art du shader coding plus accessible et interactif
smoothstep(0.0f, 0.01f, dist);, il suffit d’utilisersmoothstep(fwidth(dist), -fwidth(dist), dist);L’article est plutôt bon, mais il passe trop rapidement sur le problème central des shaders.
Les shaders sont une source d’ennuis dont la plupart des programmes et applications ne veulent pas. La 3D aime les triangles, les GPU sont bien adaptés à cette abstraction, et les shaders sont utiles pour interpoler sur ces triangles.
Mais dans presque tout ce qui n’est pas de la 3D, les triangles ne conviennent pas vraiment. Le rendu 2D veut des chemins, le rendu de polices veut des chemins ou des pixmaps, les GUI se prêtent beaucoup mieux aux chemins et aux pixmaps, les compositeurs veulent des pixmaps, et les décodeurs vidéo veulent des pixmaps et du rendu parallèle.
Ce que le non-3D veut, ce sont des pixmaps rectangulaires et un accès direct en calcul à ces pixmaps, ce que les GPU n’apprécient pas particulièrement et dans quoi les shaders ne s’insèrent pas bien.
C’est vrai que ce n’est pas la forme souhaitée à haut niveau, mais c’est une base largement suffisante pour implémenter des abstractions de plus haut niveau. Le matériel pourrait aussi les prendre en charge directement, mais l’avantage n’est pas évident. Personne ne se plaint que le CPU ne prenne pas en charge les boucles
forau niveau architectural.En relisant, je ne vois pas bien quel problème il y aurait avec les GPU. On peut ignorer complètement le pipeline de traitement des sommets et simplement dessiner un rectangle plein écran, ou utiliser des compute shaders, et les GPU gèrent cela très bien. L’article lié parle aussi de ce genre de choses
Du moins, c’était la façon de penser dans les années 2000. À mesure que les logiciels deviennent plus complexes, le GPU peut devenir l’optimisation souhaitée, mais le pipeline GPU a toujours été strict et fermé, et le passage du monocœur à la programmation multicœur impose souvent un paradigme qui demande d’autres algorithmes.
La programmation GPU généraliste permet de ne plus être prisonnier des triangles, mais pour profiter de ce parallélisme, il faut tout de même une approche complètement différente
Génial ! Ces derniers temps, je suis un peu tombé dans le terrier des SDF. J’ai été content de voir un lien vers le site d’iq, ses ressources sont vraiment excellentes.
Je ne peux pas ne pas mettre le lien vers son shader “happy bouncing”. Personnellement, je le trouve prodigieux : https://www.shadertoy.com/view/3lsSzf
Il existe aussi une vidéo YouTube de 6 heures sur sa création. Environ 500 lignes de code bien dense
J’ai essayé plusieurs fois de m’intéresser à ce sujet, sans trouver de bonne porte d’entrée, et je l’ai trouvée dans cette introduction. C’est vraiment amusant et ludique, et j’ai hâte de lire la suite
Petit pinaillage, mais en mentionnant le cel shading, tu as écrit
cell. Le terme vient des cels utilisés dans l’animation dessinée à la main et des tons quantifiés de cet ombrageVraiment excellent. En tant qu’artiste devenu programmeur, j’ai parfois envie de me plonger dans la programmation graphique.
J’ai écrit quelques shaders très basiques, mais dès que les maths commencent à entrer en jeu — en fait assez tôt — je me heurte à un mur. J’ai étudié les beaux-arts, pas l’informatique, donc mon niveau en maths est pratiquement inexistant.
Quoi qu’il en soit, c’est bien écrit et j’aime l’article
Excellente introduction, et j’espère qu’elle continuera. Trop souvent, ce genre d’articles commence par une superbe entrée en matière puis s’interrompt
Je n’ai jamais manipulé de shaders, donc ma question est peut-être très basique. Dans une frame de jeu, est-ce qu’en substance ce sont les shaders qui dessinent tout ?
Ou bien existe-t-il des primitives comme des triangles, des rectangles, des cercles, sur lesquelles les shaders ajoutent des choses comme des ombres ou de l’anti-crénelage sur les bords ?
D’après les exemples, on dirait qu’on peut créer un shader qui dessine n’importe quel objet dans une scène, puis combiner d’autres shaders pour obtenir des ombres, de l’éclairage, etc. Dans ma très maigre expérience du dessin, je dessinais des formes, je ne dessinais pas avec des shaders. J’avais toujours pensé que les shaders ne dessinaient pas les objets eux-mêmes
Le GPU transforme une forme vectorielle abstraite, comme un triangle défini par trois sommets et des données associées à chaque sommet, par exemple des normales, en un flux de fragments, un par pixel couvert par cette forme dans le buffer de sortie, voire davantage en cas de multisampling. Toute cette partie est gérée par le matériel
Un fragment se compose de coordonnées de pixel et de données fournies par l’utilisateur. Ces données sont soit des constantes, des uniform, soit des varying, c’est-à-dire les données de sommet évoquées plus haut interpolées sur toute la surface du triangle. Cette interpolation est elle aussi gérée par le matériel et n’est pas programmable
Le fragment shader prend un fragment en entrée, calcule une couleur à partir de ces données puis, après quelques étapes supplémentaires, la produit comme couleur du pixel correspondant à l’écran ou dans un buffer hors écran. Cela peut être une couleur unie ou un calcul d’éclairage complexe
Dans le rendu GPU, tout cela se fait massivement en parallèle, avec un très grand nombre de fragments traités simultanément. Un shader est une fonction pure et sans état : il ne peut accéder qu’à ses entrées, et ses effets se limitent à renvoyer quelques valeurs comme une couleur et une profondeur
En résumé, le matériel GPU calcule quels pixels doivent être remplis pour dessiner chaque triangle, et le fragment shader détermine la valeur de couleur de chacun de ces pixels
Avant que le fragment shader ne dessine « par-dessus » la scène, d’autres étapes, comme les sommets ou la tessellation, dessinent les primitives
Même concernant les fragment shaders, il y a bien plus à dire que ce qui a été expliqué. Par exemple le rendu différé[2], qui est aussi un vaste sujet dès qu’on s’y plonge
1: https://vulkan-tutorial.com/Drawing_a_triangle/Graphics_pipe...
2: https://learnopengl.com/Advanced-Lighting/Deferred-Shading
Il existe un pipeline graphique qui mélange des étapes matérielles à fonctions fixes et des étapes programmables. À haut niveau : 1) le GPU reçoit du CPU un ensemble de triangles 3D, 2) le vertex shader transforme les sommets des triangles 3D en sommets de triangles 2D avec des coordonnées de pixels, les aplatissant ainsi, 3) le GPU rastérise les triangles 2D pour déterminer exactement quels pixels sont couverts par le triangle, 4) pour chaque pixel couvert, un pixel shader est exécuté afin de déterminer sa couleur, et 5) la couleur de pixel résultante est stockée dans le framebuffer. Elle peut alors être fusionnée avec la couleur existante
Ce pipeline est répété plusieurs fois, avec différents maillages de triangles et shaders, jusqu’à ce que toute la frame soit dessinée
Les couleurs, ombres, shading, effets d’image et traitements d’image généraux sont tous réalisés comme des calculs parallèles combinant plusieurs tableaux de données. Cela inclut les sommets et leurs attributs, les textures sources, les fonctions précalculées, les textures cibles, les buffers, etc.
Par exemple, pour obtenir lumière et ombres, un shader doit pouvoir accéder, probablement via des variables globales, à des informations comme la position et la direction d’un spot. L’éclairage composite est souvent obtenu en combinant plusieurs passes de shaders : une passe de base pour l’éclairage global, puis une passe pour chaque lumière, chacune ajoutant littéralement de la lumière
Ensuite, pour ne pas ajouter de lumière aux pixels dont la source lumineuse est masquée, c’est-à-dire aux ombres, la technique la plus courante consiste à utiliser ce qu’on appelle un Z-buffer. C’est en pratique une texture en virgule flottante. Comme on veut savoir jusqu’où chaque lumière de la scène atteint, avant d’appliquer tout l’éclairage on configure une unique passe de shader qui combine toute la géométrie solide de la scène, utilise la position et la direction de la lumière comme une transformation de caméra, et emploie un shader spécial qui ne fait qu’écrire la distance jusqu’aux objets dans le Z-buffer
Ensuite, chaque fois qu’on veut savoir si la lumière atteint un point de l’espace, après un peu de calcul géométrique on échantillonne ce Z-buffer et on compare la valeur enregistrée dans cette direction avec la distance du point. Cela peut être très sujet aux bugs, et les erreurs de précision sont fréquentes. Un moteur correct fait déjà ce travail, mais permet d’en modifier le processus
Le reste n’est que variations sur ce thème. Le rendu différé consiste à rendre des données, et non des couleurs, dans des textures intermédiaires, puis à les traiter plus tard pour obtenir la couleur. Les effets de flou sont traités comme une convolution 2D de la texture de rendu, par exemple avec un noyau gaussien. Les tessellation shaders concernent la génération de nouvelle géométrie à partir du vertex shader. Même le rendu de texte passe par un atlas de police et de petits rectangles
C’est pourquoi des artistes talentueux repoussent les limites dans les fragment shaders et se battent avec les compromis de performance
Plus couramment, les fragment shaders servent à des effets de filtre plein écran, par exemple la correction colorimétrique
Les shaders sont aussi utilisés pour créer les textures et matériaux des objets de base. Les artistes matériaux génèrent souvent des textures à l’aide des mathématiques de shaders
Beaucoup d’effets visuels sont créés par une utilisation créative des shaders
Les shaders s’exécutent sur le GPU de manière parallèle, par vagues. Un très grand nombre de threads s’exécutent dans une même wave sur les mêmes données
Dans certains cas, un shader est bien plus rapide que du code CPU avec des branchements. Les shaders ont aussi un accès plus facile à certaines données de rendu
C’est donc un bon terrain pour créer des effets spéciaux créatifs. Dans les jeux, les objets dont les surfaces présentent beaucoup de détails sont des candidats courants pour transférer ces détails vers des shaders. C’est le cas des surfaces océaniques, des maillages tessellés, etc., et comme le GPU est puissant et flexible, il existe aussi de nombreux autres usages.
Si l’image ressemble à du bruit qui clignote pour certaines personnes, j’ai contourné le problème en copiant l’image depuis le navigateur et en la collant ailleurs. On peut alors la voir correctement.
C’est un lien Imgur. La première image est une capture d’écran de ce qui s’affiche dans le navigateur, les autres sont les images réelles après les avoir collées dans Imgur.
https://imgur.com/a/F4203rz
Voici ma compréhension des shaders.
Quand on trace une ligne sur le CPU, c’est une fonction qui parcourt chaque pixel entre le point A et le point B et les dessine séquentiellement, un par un. Il y a exactement autant d’étapes que de pixels sur la ligne, et elle s’exécute une fois.
Quand on trace une ligne sur le GPU, c’est une fonction qui vérifie si le pixel concerné se trouve sur la ligne et le dessine si c’est le cas. Elle s’exécute en même temps sur tous les pixels de l’écran, y compris ceux très éloignés de la ligne.
C’est bien ça ?
Deuxièmement, le GPU n’a pas non plus besoin d’exécuter le pixel shader sur tout l’écran. On peut utiliser des triangles pour n’exécuter le shader que sur n’importe quelle forme souhaitée. La façon efficace de tracer une ligne consiste donc à envoyer au GPU deux triangles correspondant à la géométrie de ligne voulue, puis à exécuter le pixel shader uniquement sur les pixels que ces triangles recouvrent. C’est beaucoup plus efficace.