Initiation aux shaders (Partie 1)
Bon je vais pas vous mentir j'ai à peu près 3 jours d'expérience sur le sujet, voyez plutôt ça comme une sorte de cahier de route de ma découverte du domaine.
J'ai trouvé deux outils pour faciliter mon initiation:
Le premier est un peu la bible du débutant, le second permet d'expériencer facilement ce que l'on apprend.
En route pour l'aventure 🐱👓
Hello world sauce shader
Ce qui déroute en premier lorsque l'on lance shadertoy, c'est l'absence de ce que l'on connait d'habitude.
Le main
est déjà très étrange:
void
Cela ressemble vaguement à du C mais il y des différences. Le main qui s'applle mainImage
prend deux paramètres:
out vec4 fragColor
in vec2 fragCood
Beaucoup de choses déjà dans ces deux paramètres. D'abord les mots-clefs in/out
. Ils agissent comme des sas entre le code que l'on écrit et le code qui appelle le code que l'on écrit.
Opérateur de liaison
in
défini une variable qui peut-être modifié par notre code sans pouvoir impacter le code appelant, en forçant la recopie de la variableout
défini une variable qui n'est pas initialisée par le code appelant mais qui peut être utilisé par celui-ci à l'issue de l'éxécution de la fonction. C'est dans les faits une référence.
Les vecteurs
Ensuite vient les termes vec4
et vec2
, ce sont des structures de données semblables à des tableaux de float
(toujours des floats), mais boosté aux stéroïdes. Il existe en effet tout une collection de facilté d'écriture.
Il est par exemple possible de déclarer un vec2
ainsi:
vec2 pos = vec2;
Et accéder à ses composantes de cette manière:
float x = pos.x; // 1.0
float y = pos.y; // 2.0
On peut aussi opérer des inversions de composantes
vec2 inverted_pos = pos.yx; // (2.0, 1.0)
Il est aussi possible de faire de la récupération partielle de composantes
vec3 pos = vec3;
vec2 pos2 = pos.xz; // (2.0, 3.0)
Ou même dupliquer des composantes
vec3 pos = vec3;
vec3 pos2 = pos.xxx; // (1.0, 1.0, 1.0)
Dernier outil que je connais, il est possible de ne pas complètement définir un vecteur
vec3 pos = vec3; // (0.0, 0.0, 0.0)
vec3 pos2 = vec3; // (4.0, 4.0, 4.0)
Les nom de variables du main
Avant de parler de tout ça une petite remise en contexte est nécessaire.
Le mainImage
n'est pas vraiment le main
du programme. Un shader est une routine qui s'éxécute sur chaque pixels d'une image.
Pour rappel, un pixel est la plus petite unité qui compose une image, pixel ( picture element ).
Afin de rendre une image on ne peut décemment pas traiter un à un les pixels ça serait beaucoup trop long et gourmand en ressources, surtout avec les résolutions d'aujourd'hui qui sont gigantesques comme la 4k et la 8k.
L'idée qui a été choisi est de paralléliser les traitements de chacun des pixels.
Cela implique que le traitement entre deux pixels est totalement indépendant car il doit s'effectuer au même moment.
Un pixel est composé de 3 lampes, une rouge, une bleue et une verte. En fonction de la valeur de chaque composante on est capable de générer n'importe quelle couleur du spectre visible.
Couleur du pixel
Le but de notre routine est de définir l'intensité des lampes rouge, verte et bleue.
Le résultat de notre calcul doit être affecté dans la variable fragColor
. Celle-la est un vec4
, cela signifie qu'elle possède 4 composantes: (Rouge, Vert, Bleu, ?).
La dernière est le canal alpha qui gère la transparence. On ne s'en occupera pas dans cette partie.
Dernière chose à savoir est que chacune de ces composantes se répartissent entre 0 ( pas de lumière du tout ) et 1 ( intensité maximale ).
Le code ci-dessous explique de renvoyer pour chaque pixel de l'image un vecteur (1.0, 0.0, 0.0, 1.0)
autrement dit du rouge pur. La 4ème composante est à 1.0
pour signifier qu'il n'y a pas de transparence.
void
Mais aussi du vert
fragColor = vec4;
Mais aussi du bleu
fragColor = vec4;
Mais aussi du jaune
fragColor = vec4;
Bref vous voyez l'idée. Notre routine contrôle chaque pixel de l'écran qui mis tous ensemble forme l'image finale.
La position du pixel
Bon tout ça c'est très bien mais un peu limité tous les pixels ont la même couleur on est pas près de faire le nouveau Pixar à la mode. 😋
C'est là qu'intervient notre second paramètre fragCoord
il s'agit là aussi d'un vec2
, la première composante est l'abscisse du pixel sur l'écran et la deuxième son ordonnée dans la pratique (x, y).
Essayons de visualiser cela 🧐
void
Rouge, encore! Mais c'est normal quand on y réfléchi, les coordonnées des pixels sont données sous leur forme cartésiennes, c'est à dire des nombres entiers positifs.
Or les composantes de couleur ne peuvent pas dépasser la valeur 1.0
.
Il va donc falloir normaliser les valeurs. Pour cela on va utiliser un autre outils mis à disposition par le langage de shader qui nous fourni tout une série de variable appelée des uniforms
.
Il faut les voir comme le moyens de communiquer des informations entre les différentes unité de traitement de pixels. Celle qui va nous intéresser ici est iResolution
. Elle contient la taille en pixels de l'écran et donc le nombre en X et en Y de pixels.
Ce qui va nous permettre d'effectuer une règle de trois entre la position du pixel et la résolution.
iResolution.x -> rouge pur (1.0)
fragCoord.x -> intensité de rouge (X)
X = (fragCoord.x * 1.0) / iResolution.x
En utilisant les propriétés des vecteurs:
void
De même en Y
void
On peut même cartographier les deux composantes
void
Le noir est une absence de luminosité autrement dit (0.0, 0.0, 0.0, 1.0)
.
Le jaune une combinaison de rouge et de vert, le jaune pur représente (1.0, 1.0, 0.0, 1.0)
.
En prenant la formule à l'envers on peut déterminer l'origine du repère de notre écran ainsi que le sens du repère.
Pour une résolution de 800x450, il vient:
Changement de repère
Bon je sais pas vous mais le repère en bas à gauche n'a jamais été ma tasse de thé ou de café pour les amateurs.
Je préfère que l'origine de mon repère soit le centre de mon écran, cela va faciliter grandement les calculs qui vont suivre. (oui il va y avoir un max de maths 🤓).
Pour mettre le centre au centre on va retrancher la moitié de la résolution sur chacune des composantes.
Cette soustraction va automatiquement nous amener à obtenir des valeurs négatives. Une composante de couleur étant toujours positive, une valeur négative sera mise à 0.0
.
void
On se retrouve avec 4 quadrants:
Que l'on peut avec la même technique que tout à l'heure visualiser de manière plus précise sous la forme d'un continuum.
void
Dessiner des formes simples
Le disque
C'est la forme la plus simple à afficher.
Son équation dans le plan cartésien est :
x² + y² < R²
Comme on a centré le repère, il n'y a pas besoin de se soucier d'offset.
en prenant la racine de chaque côté
sqrt(x² + y²) < R
Le langage fourni une fonction appellée length
qui permet de récupérer la norme d'un vecteur.
void
Bon c'est presque ça, on a juste négligé le ratio de notre image. Pour rétablir le disque on peut diviser nos coordonnées par la résolution en Y sur 2.
void
Et tada 🎉 un disque!
Un triangle équilatéral
Je vais pas vous mentir, ça n'a pas été très facile de déterminer comment dessiner le triangle.
Le principe reste le même: déterminer si un point appartient ou non à une surface.
Le problème ici est que la forme est bien plus complexe qu'un disque.
Il faut donc ruser pour trouver une solution
Une petite simulation avec geogebra m'a permis de me simplifier la représentation du problème.
Si M
est en dehors de l'angle AB, AC. La valeur de d est négative.
De même dans l'autre sens
Par contre si l'on se trouve dans l'angle, la valeur d devient positive.
Le problème c'est que si le point est dans l'angle mais en dehors du triangle, la valeur reste positive.
L'idée est d'appliquer l'algorithme sur les 3 angles, pour que le point soit bel et bien à l'intérieur du triangle. Si chacun des produits scalaires sont positifs alors le point M appartient au triangle.
void
Et un triangle pour la table 12 ! Un ! 👨🍳
Champ de Distance Signé
Les deux algorithmes (pour le disque et pour le triangle) sont appelées des SDF : Signed Distance Field ou Champ de Distance Signé.
Un champ est une propriété qui recouvre tout un espace ( ici 2D ). Cela signifie que notre propiété va s'appliquer sur chaque pixel de l'écran.
La deuxième partie est cette idée de distance. On recheche pour tout point de l'écran si l'on est à l'intérieur ou l'extérieur d'une surface donnée et surtout à quelle distance on se trouve d'elle.
Reprenons le disque qui est un peu plus simple. Son champ de distance est donné en tout point de l'écran par la relation suivante:
Distance du point observé par rapport au centre du disque - rayon du disque
Tout ce que l'on fera par la suite sera de calculer la SDF de nos forme.
Et pourquoi "signé", parce que si l'on est à l'intérieur de la du disque, la distance devient négative.
Conclusion
C'est tout pour aujourd'hui!
La suite sera de se plonger dans le monde merveilleux de la 3D enfin de la projection de la 3D sur un écran 2D.
On y verra notamment le RayMarcher.
Bonne Fêtes à toutes et tous et merci de m'avoir lu. 🤗
Ce travail est sous licence CC BY-NC-SA 4.0.