Formes
Enfin ! Tous ce que nous avons appris nous a mené jusqu'à ce moment ! Nous avons vu la plupart des bases, des types et des fonctions, nous avons répété nos fonctions de formes à une dimension, il est temps de faire marcher tout ça ensemble et vous êtes paré ! Dans ce chapitre, vous apprendrez à dessiner des formes, de façon procédurale et en parallèle sur un GPU.
Rectangle
Imaginons que nous avons un papier millimétré, comme à l'école, et nos devoirs consistent à dessiner un carré. La taille de la feuille est 10x10 et le carré doit mesurer 8x8, comment faire ?
A priori nous allons colorier tout sauf : la première et la dernière rangée et la première et la dernière colonne, c'est bien cela ?
En quoi est-ce lié aux shaders ? Chaque petit carré du papier millimétré est un thread (un pixel, ou fragment). chaque petit carré connaît sa position, comme des coordonnées sur un échiquier. Dans les chapitres précédents, nous avons appris à nous servir des valeurs normalisées, par exemple, nous avons mappé ces positions x et y vers les canaux rouge et vert. Comme nos valeurs étaient normalisées entre 0.0 and 1.0, on pouvait les utiliser comme des couleurs, ou dans les fonctions de formes, ou dans les interpolations. Mais à présent, comment utiliser ces valeurs x et y normalisées pour dessiner un carré au centre du canvas ?
Commençons par utiliser un pseudocode se servant d'une condition if/else
sur toute la taille du canvas.
Le procédé est très proche de la démarche que nous avons eu avec le papier millimétré.
if ( (X SUPERIEUR A 1) AND (Y SUPERIEUR A 1) )
dessine en blanc
else
dessine en noir
Nous avons une meilleure idée du code qu'il va falloir produire, nous allons remplacer les if
par des step()
et au lieu d'utiliser 10x10, nous utiliserons les valeurs x et y normalisées entre 0.0 et 1.0 :
uniform vec2 u_resolution;
void main(){
vec2 st = gl_FragCoord.xy/u_resolution.xy;
vec3 color = vec3(0.0);
// chaque appel à step() renverra soit: 1.0 (blanc), soit 0.0 (noir).
float gauche = step(0.1,st.x); // équivalent à: si( X supérieur à 0.1 )
float bas = step(0.1,st.y); // équivalent à: si( Y supérieur à 0.1 )
// multiplier gauche par bas revient à faire un AND logique.
color = vec3( gauche * bas );
gl_FragColor = vec4(color,1.0);
}
La fonction step()
, va dessiner tous les pixels dont la valeur des x est inférieure à 0.1 en noir (vec3(0.0)
) et tous les autres en blanc (vec3(1.0)
).
Le fait de multiplier gauche
par bas
est équivalent à l'opérateur logique AND
; si les deux opérantions (x<.1
et y<.1
) renvoient 1.0, le résultat sera 1.0, sinon, ce sera 0.0.
Cela nous permet de dessiner 2 lignes noires, une en bas et une à gauche du canvas.
En règle générale et bien que ce ne soit pas interdit, il est déconseillé d'utiliser les if
dans un shader.
ça peut paraître contre-intuitif mais la raison est simple, si on fait un if
(ce qu'on appelle un conditional branching),
le programme va devoir évaluer les 2 branches de toutes façons et cette évaluation va ralentir (voire anéantir) le bénifice d'utiliser le GPU.
Une stratégie pour parer à ce problème est de structurer le code de manière à éliminer les conditions, donc les branches.
En l'occurrence, se servir du résultat (0 ou 1) de step()
et le multiplier par une autre variable (la couleur par exemple).
Si le step()
renvoie 1, la couleur se multipliera par 1 et restera la même, si le step()
renvoie 0, la couleur se multipliera par 0 donc elle passe au noir.
C'est une technique que vous retrouverez souvent dans les shaders.
Dans l'exemple ci-dessus, nous répétons la même opération sur chaque axe gauche et bas.
Nous pouvons économiser quelques lignes de code en passant les deux valeurs x et y au step()
simultanément au lieu de faire deux appels à step()
séparés, ce qui resemble à ça :
vec2 limites = step(vec2(0.1),st);
float pct = limites.x * limites.y;
Nous avons à présent deux limites de notre rectangle : gauche et bas. Occupons nous des deux autres : haut et droite, regardez le code suivant :
Décommentez les lignes 21-22 et notez comment nous inversons les coordonnées de st
(1.0 - st) et réutilisons le même appel à step()
.
Le vec2 tr
contient à présent les réponses du test pour le coin haut droit. C'est l'équivalent numérique de : "retourner la page et appliquer la même procédure".
Notez qu'aux lignes 18 et 22, tous les côtés sont multipliés entre eux, ce qui revient à écrire :
vec2 bl = step(vec2(0.1),st); // bas-gauche
vec2 tr = step(vec2(0.1),1.0-st); // haut-droit
color = vec3(bl.x * bl.y * tr.x * tr.y);
Intéressant n'est ce pas ?
Le principe de cette technique est de se servir de step()
et des multiplications comme d'opérateurs logiques et de retourner les coordonnées pour traiter les deux côtés de l'image.
Avant d'aller plus loin, essayez les choses suivantes :
-
Changer la taille et les proportions du rectangle.
-
Utilisez
smoothstep()
au lieu destep()
. Notez comme les arêtes passent d'un rendu net à flou. -
Réimplémentez les conditions en utilisant
floor()
. -
Conservez votre version préférée et faites-en une version réutilisable, si possible une fonction flexible et efficace (pas de if par exemple).
-
Créez une fonction qui dessine uniquement l'extérieur du rectangle.
- Comment placer et déplacer plusieurs rectangles sur le canvas ? Si vous trouvez la réponse, tentez de créer une composition à la Piet Mondrian.
Cercles
Dessiner des carrés sur du papier millimétré est assez simple mais les cercles demandent un changement d'approche, notamment lorsqu'on doit traiter une masse de pixels.
Une solution est de re-mapper le système de coordonnées de manière à pouvoir utiliser la fonction step()
pour dessiner un cercle.
Comment ? Retournons à notre cours de math et à notre papier millimétré, on utilise un compas, on lui donne le bon rayon, on pose la pointe au centre du cercle et on trace le contour du cercle.
Traduire cette démarche dans un shader où chaque carré du papier est un pixel revient à demander à chaque pixel (ou thread ou fragment), s'il est à l'intérieur ou à l'extérieur du cercle. Pour ce faire, nous allons donc calculer la distance de chaque fragment au centre de notre cercle.
Il existe plusieurs façons de calculer une distance. La plus simple est d'utiliser la fonction distance()
, qui - en interne - calcule la longueur de la différence entre les deux points passés en arguments (dans notre cas, les coordonnées du pixel et la position du centre).
La fonction length()
est simplement un raccourci pour l'équation de l'hypoténuse qui utilise la racine carrée sqrt()
de la somme des différences en x et y, au carré.
On peut donc utiliser indifféremment distance()
, length()
ou sqrt()
pour calculer la distance du pixel au centre du canvas.
Le code suivant contient ces trois fonctions et démontre sans surprise que les trois renvoient le même résultat.
- Commentez et décommentez les blocs pour utiliser les différentes manières de calculer la distance.
Dans l'exemple ci-dessus, nous mappons la distance au centre du canvas sur la valeur (la luminosité) du pixel de sortie.
Plus on est proche du centre, moins la distance est grande donc plus le pixel est sombre.
Notez que les valeurs ne montent pas énorménent (jusqu'au blanc par exemple) parce que, la plus grande distance entre un pixel et le centre (vec2(0.5, 0.5)
) dépasse péniblement 0.5.
Prenez un moment pour observer et demandez vous :
-
Que peut-on déduire de ça?
-
Comment peut-on s'en servir pour dessiner un cercle?
- Modifiez le code suivant pour faire tenir le dégradé complet du cercle à l'intérieur du canvas.
Note : Pour être exact, la plus grande distance entre un pixel et le centre est : sqrt( 2.0 ) * .5
soit environ 0.7071 !
Champ de distance (Distance field)
Nous pouvons penser l'exemple ci-dessus comme étant une carte d'élévations ou les parties sombres seraient plus élevées. Le dégradé du cercle ressemble à ce que serait un cone. Imaginez vous au sommet de ce cone, la distance horizontale vers les bords du cone serait 0.5, elle est constante dans toutes les directions. En choisissant à quelle hauteur on peut couper le cone, on obtiendra un disque plus ou moins grand.
En somme, on utilise une ré-interprétation de l'espace, en se basant sur la distance au centre plutôt que sur les coordonnées des pixels, pour créer des formes. Cette technique s'appelle un "champ de distances" (Distance Field) et s'applique dans de nombreux contextes allant du dessin de contours à la 3D.
Essayez les choses suivantes:
-
Utilisez
step()
pour passer toutes les valeurs supérieures à 0.5 en blanc et toutes les autres en noir. -
Inverser les couleurs d'avant plan et d'arrière plan.
-
Utilisez
smoothstep()
et changez les paramètres pour ajouter un contour flou au cercle. -
Quand vous obtenez un résultat satisfaisant, créez une fonction que vous pourrez réutiliser.
-
Ajoutez de la couleur.
-
Pouvez-vous animer le cercle pour qu'il grossisse et rapetisse ? Pour qu'il simule un battement de coeur ? Les animations du chapitre précédent vous aideront !
-
Comment déplacer ce cercle ? Pouvez vous le déplacer et placer plusieurs cercles sur le même canvas ?
- Que se passe-t'il quand on combine plusieurs champs de distances en utilisant différentes fonctions et opérations ?
pct = distance(st,vec2(0.4)) + distance(st,vec2(0.6));
pct = distance(st,vec2(0.4)) * distance(st,vec2(0.6));
pct = min(distance(st,vec2(0.4)),distance(st,vec2(0.6)));
pct = max(distance(st,vec2(0.4)),distance(st,vec2(0.6)));
pct = pow(distance(st,vec2(0.4)),distance(st,vec2(0.6)));
- Faites trois compositions avec ces techniques, si elles bougent, c'est encore mieux !
Pour la boîte à outils
La fonction sqrt()
- comme toutes les fonctions qui en dépendent - est assez gourmande en ressources.
Voici autre une technique permettant de créer un champ de distances basée sur la fonction dot()
.
dot()
renvoie le produit scalaire de deux vecteurs: dot(a,b) = a.x * b.x + a.y * b.y;
Propriétés utiles des champs de Distances
Les champs de distance permettent de dessiner à peu près n'importe quoi. Evidemment, plus la forme est complexe, plus l'équation sera complexe mais une fois qu'on a la formule d'une forme en particulier, il devient assez simple de la combiner avec d'autres et/ou de lui appliquer des effets comme des bords lissés, ou plusieurs contours. C'est pourquoi les champs de distances sont utilisés pour le rendu des polices de caractères: Mapbox GL Labels, Matt DesLauriers, Material Design Fonts et comme expliqué au chapitre 7 de "iPhone 3D Programming", O’Reilly.
Prenez le code suivant:
On commence par déplacer le système de coordonnées au centre et à le diviser par deux pour obtenir des valeurs comprises entre -1 et 1.
À la ligne 24, nous visualisons le champ de distances grâce à la fonction fract()
ce qui nous permet de mieux voir le motif qu'il crée.
Le motif du champ de distances se répète en cercles concentriques, à la manière d'un jardin zen.
Regardons la formule du champ de distances ligne 19. Nous calculons la distance entre chaque position et la coordonnée ( .3,.3 )
( entre st
et le vecteur vec2(.3)
) pour chaque quadrant, c'est à ça que sert l'appel à abs()
.
Si vous décommentez la ligne 20, vous noterez que nous combinons les distances à ces 4 points en utilisant la fonction min()
contre 0, ce qui produit un nouveau motif.
L'idée est qu'il n'y a qu'un seul point, et non '4', celui en haut à droite mais il est reflété dans les 3 autres quadrants du fait qu'on a changé la taille de l'espace ligne 16 !
Essayez à présent de décommenter la ligne 21, nous utilisons cette fois la fonction max()
.
Le résultat est un rectangle aux bords arrondis.
Remarquez comme les anneaux du champ de distance deviennent de plus en plus lissse à mesure qu'ils s'éloignent du centre.
Enfin, décommentez les ligne 27 à 29 une par une pour comprendre les différentes utilisations du champ de distances.
Formes polaires
Au chapitre des couleurs, nous avons projeté (mappé) des coordonnées cartésiennes sur des coordonnées polaires en calculant un rayon et un angle entre chaque pixel et le centre grâce à la formule suivante:
vec2 pos = vec2(0.5)-st;
float r = length(pos)*2.0;
float a = atan(pos.y,pos.x);
Nous avons ré-utilisé une partie de cette formule au début du chapitre pour dessiner un cercle.
Nous calculions la distance en nous servant de length()
.
Maintenant que nous en savons plus sur les champs de distances, nous pouvons dessiner de nouvelles formes grâce aux coordonnées polaires.
La technique est un peu restrictive mais très simple : elle consiste à changer la valeur du rayon en fonction de l'angle pour obtenir une variété de formes. Comment moduler cette longueur ? Avec des fonctions de formes bien sûr !
Ci-dessous, vous trouverez les mêmes fonctions dans un espace cartésien et dans un espace polaire (entre les lignes 21 et 25 du shader). Décommentez les fonctions une par une dans les deux démos pour comprendre les relations qui existent entre les deux systèmes de coordonnées.
Essayez de :
- Animer les formes.
- Combiner différentes fonctions de formes pour creuser des trous dans les formes pour faire des fleurs, des flocons et des engrenages.
- Utilisez la fonction
plot()
du chapitre sur les fonctions de forme pour ne garder que le contour.
Combinatoires
Nous venons de voir comment moduler le rayon d'un cercle en fonction d'un angle en utilisant la fonction atan()
pour dessiner différentes formes.
Voyons maintenant comment utiliser atan()
avec un champ de distances de façon à pouvoir utiliser les effets qu'ils permettent.
L'astuce c'est d'utiliser le nombre de côtés d'un polygone régulier pour construire un champ de distances dans un espace polaire. Pour plus d'informations, vous pouvez vous référer au code suivant par Andrew Baldwin.
-
En reprenant cet exemple, créez une fonction qui reçoit une position et un nombre de côtés et retourne la valeur du champ de distance correspondant.
-
Mélangez les champs de distances en utilisant
min()
etmax()
. - Choisissez un logo géométrique et reproduisez le avec des champs de distance.
Félicitations ! Vous avez fait le plus dur ! Faites une pause et laissez décanter ces nouveaux concepts. Dessiner des formes dans une API de dessin, c'est facile mais ici c'est une autre histoire. Au pays des shaders, dessiner des formes géométriques est un peu tordu et s'imposer la disciple nécessaire à la compréhension de ce paradigme est épuisant.
À la fin de ce chapitre, vous trouverez un lien vers [PixelSpirit Deck] (https://patriciogonzalezvivo.github.io/PixelSpiritDeck/). Ce jeu de cartes vous aidera à apprendre les nouvelles fonctions SDF, à les composer dans vos conceptions et à les utiliser. sur vos shaders. La plate-forme a une courbe d’apprentissage prégressive, aussi prendre une carte par jour et travailler dessus vous poussera et mettra au défi vos compétences pendant des mois.
Maintenant que vous savez comment dessiner des formes, je suis sûr que ça va vous donner des idées. Au prochain chapitre, nous apprendrons à déplacer, à appliquer des rotations et à changer d'échelle pour créer des compositions !