Initiation aux shaders (Partie 1)

Lecture 9 min. ‱


Table des matiĂšres

la guerre

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 mainImage( out vec4 fragColor, in vec2 fragCoord )
{

}

Cela ressemble vaguement Ă  du C mais il y des diffĂ©rences. Le main qui s’applle mainImage prend deux paramĂštres:

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

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(1.0, 2.0);

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(1.0, 2.0, 3.0);
vec2 pos2 = pos.xz; // (2.0, 3.0)

Ou mĂȘme dupliquer des composantes

vec3 pos = vec3(1.0, 2.0, 3.0);
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, 0.0)
vec3 pos2 = vec3(4.0); // (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 mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    // Output to screen
    fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

Mais aussi du vert

fragColor = vec4(0.0, 1.0, 0.0, 1.0);

Mais aussi du bleu

fragColor = vec4(0.0, 0.0, 1.0, 1.0);

Mais aussi du jaune

fragColor = vec4(1.0, 1.0, 0.0, 1.0);

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 mainImage( out vec4 fragColor, in vec2 fragCoord )
{

    // Output to screen
    fragColor = vec4(fragCoord.x, 0.0, 0.0 ,1.0);
}

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 mainImage( out vec4 fragColor, in vec2 fragCoord )
{

    vec2 uv = fragCoord / iResolution.xy;

    // Output to screen
    fragColor = vec4(uv.x, 0.0, 0.0 ,1.0);
}

De mĂȘme en Y

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{

    vec2 uv = fragCoord / iResolution.xy;

    // Output to screen
    fragColor = vec4(uv.y, 0.0, 0.0 ,1.0);
}

On peut mĂȘme cartographier les deux composantes

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{

    vec2 uv = fragCoord / iResolution.xy;

    // Output to screen
    fragColor = vec4(uv.x, uv.y, 0.0 ,1.0);
}

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 mainImage( out vec4 fragColor, in vec2 fragCoord )
{

    vec2 uv = fragCoord - 0.5 * iResolution.xy;

    // Output to screen
    fragColor = vec4(uv.x, uv.y, 0.0 ,1.0);
}

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 mainImage( out vec4 fragColor, in vec2 fragCoord )
{

    vec2 uv = (fragCoord - 0.5 * iResolution.xy) / (iResolution.xy / 2.0);

    // Output to screen
    fragColor = vec4(uv.x, uv.y, 0.0 ,1.0);
}

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 mainImage( out vec4 fragColor, in vec2 fragCoord )
{

    vec2 uv = (fragCoord - 0.5 * iResolution.xy) / (iResolution.xy / 2.0);

    // Output to screen
    fragColor = vec4(uv.x, uv.y, 0.0 ,1.0);
    
    if (length(uv) < 1.0) {
        fragColor = vec4(1.0, 1.0, 1.0 ,1.0);
    }
}

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 mainImage( out vec4 fragColor, in vec2 fragCoord )
{

    vec2 uv = (fragCoord - 0.5 * iResolution.xy) / (iResolution.y / 2.0);

    // Output to screen
    fragColor = vec4(uv.x, uv.y, 0.0 ,1.0);
    
    if (length(uv) < 1.0) {
        fragColor = vec4(1.0, 1.0, 1.0 ,1.0);
    }
}

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 mainImage( out vec4 fragColor, in vec2 fragCoord )
{

    vec2 uv = (fragCoord - 0.5 * iResolution.xy) / (iResolution.y / 2.0);

    // Output to screen
    fragColor = vec4(uv.x, uv.y, 0.0 ,1.0);
    
    // Coordonnées des sommets du triangles équilatéral
    float k = 1.75;
    vec3 A = vec3(0.0, 0.57, 0.0) * k;
    vec3 B = vec3(0.5, -0.28, 0.0) * k;
    vec3 C = vec3(-0.5, -0.28, 0.0) * k;
    
    // Calcul des coordonnées des vecteurs
    vec3 AB = B-A;
    vec3 AC = C-A;
    
    vec3 BA = A-B;
    vec3 BC = C-B;
    
    vec3 CA = A-C;
    vec3 CB = B-C;
    
    // M est le point que l'on cherche à déterminer s'il est en dehors ou non du triangle
    vec3 AM = vec3(uv - A.xy, 0);
    vec3 BM = vec3(uv - B.xy, 0);
    vec3 CM = vec3(uv - C.xy, 0);
    
    // Calcul des déterminants
    float d1 = dot(cross(AB, AM), cross(AM, AC));
    float d2 = dot(cross(BA, BM), cross(BM, BC));
    float d3 = dot(cross(CA, CM), cross(CM, CB));
    
    // Dessin du cercle de rayon 1
    if (length(uv) < 1.0) {
    
        fragColor = vec4(0.0, 0.0, 1.0 ,1.0);
    }
    
    // Si tous les déterminants sont positifs, le point est dans le triangle
    if (d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0) {
    
        fragColor = vec4(1.0, 1.0, 1.0 ,1.0);
    }
}

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. đŸ€—