Shaders, kesako ?

Vous avez appris à envoyer des données à la carte graphique afin de dessiner des formes simples. L'objectif est maintenant d'apprendre à coder des shaders et les brancher à votre pipeline graphique afin de personnaliser l'algorithme de rendu implémenté par OpenGL.

Qu'est ce qu'un shader ?

Un shader est un petit programme qui s'execute sur la carte graphique. Votre GPU implémente un pipeline de rendu dont certains stages sont laissés à votre responsabilité. Si vous ne les implémentez pas, un comportement par défaut les remplace (pas de transformations, couleur blanche). Cette possibilité de coder certains stages du pipeline offre une puissance considérable. Basiquement c'est ce qui fait la différence entre des graphismes Playstation 2 et des graphismes Playstation 3: le GPU programmable.

Quel language de programmation ?

Puisqu'un shader est un programme, il faut le coder avec un language de shading. Il existe plusieurs languages de shading, les plus connus étant HLSL (qui va avec Direct3D, donc Microsoft), Cg (développé par NVidia, utilisé sur PS3 par exemple) et GLSL (qui va avec OpenGL). Nous allons bien évidemment utiliser le dernier. Nous pourrions également utiliser Cg mais cela nous forcerait à utiliser une API différente de OpenGL pour compiler nos shaders.

Les Vertex Shaders

Un Vertex Shader (VS) prend en entrée les attributs d'un sommet et a pour rôle principal d'appliquer une transformation géométrique pour projeter ce sommet à l'écran. OpenGL utilise ensuite les positions projetées de tous les sommets pour rasteriser les triangles projetés. La rasterisation consiste simplement à transformer les triangles en fragments (un fragment est un pixel).

En plus de cela le VS peut fournir en sortie des valeurs qui seront utilisées par le fragment shader. Ces valeurs sont interpolées sur les fragments des triangles rasterisés afin de créer un dégradé des valeurs. L'exemple le plus parlant est celui des couleurs: on observe bien dans l'exemple du triangle en couleur que les couleurs sont interpolées sur le triangle. Il est possible de faire de même avec d'autre types de valeurs qui nous seront utiles par la suite (normales ou coordonnées de texture par exemple).

Les Fragment Shaders

Un Fragment Shader (FS) prend en entrée un fragment calculé par la rasterisation (un triangle est transformée en plein de fragments). En terme de programmation, un fragment est représenté par l'ensemble des valeurs de sortie du VS. Le FS a pour rôle de calculer la couleur finale du pixel correspondant au fragment en utilisant les valeurs interpolées par la rasterisation.

Le schéma suivant illustre le processus de rasterisation appliqué sur un triangle et l'ensemble des fragments qui en résulte:

La couleur de chaque fragment est obtenue par interpolation des couleurs des 3 sommets. C'est la couleur interpolée qui arrive en entrée du fragment shader.

Lorsqu'on fait du rendu 3D le FS doit calculer la couleur finale du fragment à partir des données géométriques interpolées (position, normale, coordonnées de texture) et des lumières de la scène.

Les autres types de shader

Nous n'utiliserons que les VS et les FS mais il existe d'autres types de shader. Voici la liste pour votre culture:

  • Geometry Shader: Permet de créer à la volée de nouvelles primitives géométrique (on peut par exemple générer des cubes à partir de simples points).
  • Tesselation Shader: Permet de tesseller dynamiquement les primitives d'entrée, c'est à dire créer de nouveaux points sur les triangles à des endroits bien précis. Très utile pour ajouter du niveau de détail lorsque la caméra se rapproche des objets.
  • Compute Shader: Permet de faire du calcul parallèle sur GPU (comme en Cuda pour ceux qui connaissent). Ce type de shader n'est même pas lié au pipeline et peut être lancé à n'importe quel moment.

Contrairement aux VS et FS, il n'est pas obligatoire d'utiliser ces trois autres types de shader pour faire de la 3D simple.

A retenir

Deux points importants à retenir:

  • Un vertex shader traite un vertex provenant du CPU (envoyé par votre code C++) et produit une position projetée à l'écran.
  • Un fragment shader traite un fragment généré par le rasterizer et produit une couleur à afficher à l'écran dans le pixel correspondant au fragment.

Afin de conserver le code du TP1 tel quel, dupliquez le répertoire TPtemplate et renommez le TP2. Travaillez dedans pour ce TP.

Les shaders par l'exemple

Rien de vaut un exemple. Nous allons étudier ligne par ligne l'exemple des shaders que vous avez utilisé au TP précédent pour afficher un triangle en couleur (avec quelques variantes).

Le Vertex Shader

Le Vertex Shader est destiné à traiter des sommets. Il fera donc son traitement à partir des attributs de chaque vertex. Il fournit en sortie des valeurs à interpoler pour chaque fragment produit par le rasterizer sur le triangle. Il renseigne également une variable particulière, gl_Position, qui est la position projetée à l'écran. Celle ci est utilisée par le rasterizer: il combine les 3 gl_Position correspondant à un triangle afin de produire une multitude de fragments. OpenGL se charge d'executer le Vertex Shader branché au moment du draw call sur chaque sommet à dessiner.

#version 330 core

layout(location = 0) in vec2 aVertexPosition;
layout(location = 1) in vec3 aVertexColor;

out vec3 vFragColor;

void main() {
  vFragColor = aVertexColor;
  gl_Position = vec4(aVertexPosition, 0, 1);
};

Comme vous pouvez le constater, cela ressemble à du C++. Il y a même une fonction main, qui sera executée par le GPU pour traiter chaque sommet à dessiner.

La première ligne indique à OpenGL la version de GLSL à utiliser. Ici on demande explicitement la version 3.3 avec un profile Core (qui bannit les fonctionnalités dépreciées).

Les deux lignes suivantes:

layout(location = 0) in vec2 aVertexPosition;
layout(location = 1) in vec3 aVertexColor;

indiquent les attributs d'entrée constituant un sommet ainsi que leur type et leur location La location permet de faire le lien avec l'application C++: lorsque vous configurez le VAO décrivant vos sommet, il faut utiliser pour chaque attribut activé la location correspondant à l'attribut dans le shader (Il existe une alternative pour spécifier la location, en utilisant la fonction glBindAttribLocation depuis l'application C++, mais nous ne l'utiliserons pas). Le mot clef in indique que c'est une variable d'entrée qui est déclarée.

La location correspond au paramètre index des fonctions glEnableVertexAttribArray et glVertexAttribPointer. Voici un rappel du code C++ pour faire le lien entre les deux concepts:

const GLuint VERTEX_ATTR_POSITION = 0;
const GLuint VERTEX_ATTR_COLOR = 1;
glEnableVertexAttribArray(VERTEX_ATTR_POSITION);
glEnableVertexAttribArray(VERTEX_ATTR_COLOR);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glVertexAttribPointer(VERTEX_ATTR_POSITION, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex2DColor), 
	(const GLvoid*) offsetof(Vertex2DColor, position));
glVertexAttribPointer(VERTEX_ATTR_COLOR, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex2DColor), 
	(const GLvoid*) offsetof(Vertex2DColor, color));
glBindBuffer(GL_ARRAY_BUFFER, 0);

OpenGL "comprend" comment appliquer le vertex shader à chaque sommet en utilisant les locations que vous lui fournissez. C'est à vous de choisir les location/index associé à chaque attribut. Ici j'ai choisi 0 et 1 mais dans le TP précédent j'avais choisi 3 et 8 (pour vous embrouiller mwahaha).

La ligne:

out vec3 vFragColor;

déclare une variable de sortie du shader. Je l'ai appelé vFragColor car elle correspond à la couleur qui sera affectée à chaque fragment (j'aurais pu l'appeler "toto" mais ça aurait été moins clair). Nous verrons plus loin que cette variable arrivera en entrée du fragment shader.

On pourrait également déclarer d'autres variables de sortie, ce que nous ferons plus tard en fonction de l'algorithme de rendu que l'on souhaite implanter.

Viens ensuite la fonction main. Contrairement au C++, celle ci ne renvoit pas de valeur en GLSL.

La ligne :

vFragColor = aVertexColor;

affecte la couleur d'entrée à la variable de sortie. On pourrait effectuer un traitement plus complexe, comme faire varier la couleur en fonction de la position. Ici on fait simplement un transfert de la valeur d'entrée vers la valeur de sortie (cela arrivera souvent dans les Vertex Shaders).

Enfin la ligne:

gl_Position = vec4(aVertexPosition, 0, 1);

est sans doute la plus importante du shader. On renseigne la variable built-in (built-in = définie par OpenGL) gl_Position qui est utilisée par OpenGL dans le rasterizer pour transformer les triangles en fragments. Cette variable représente les coordonnées projetées à l'écran du sommet. Dans le cas de la 2D il n'y a pas de projection, aucune transformation n'est donc appliquée ici. On ajoute néammoins deux coordonnées. Une profondeur z = 0 car on considère qu'on regarde le plan 2D z = 0 (on pourrait mettre n'importe quelle valeur entre -1 et 1, cela ne changerait rien). On ajoute également une coordonnée homogène (OpenGL travaille avec des coordonnées homogènes) w = 1. Quand nous ferons de la 3D, cette coordonnée sera calculée par la transformation projective. Pour l'instant, en 2D, gardez à l'esprit qu'on ajoutera toujours 0 et 1 à la fin de nos positions.

Le Fragment Shader

Le Fragment Shader est destiné à traiter des fragments (ie. des pixels). Il fera son traitement à partir des valeurs aux sommets interpolées par la rasterizer lors de la production des fragments. Il fournit en sortie une couleur pour le pixel associé au fragment (OpenGL gère l'association fragment-pixel, il n'y a donc pas à spécifier les coordonnées du pixel).

#version 330 core

in vec3 vFragColor;

out vec3 fFragColor;

void main() {
  fFragColor = vFragColor;
};

La ligne:

in vec3 vFragColor;

indique à OpenGL une valeur d'entrée du fragment shader. Deux choses à noter:

  • elle à le même nom que la valeur de sortie du VS. C'est normal, et même obligatoire. OpenGL utilise les noms des variables pour savoir comment faire correspondre les valeurs de sortie du VS avec les valeurs d'entrée du FS. C'est un peu comme brancher une prise, l'entrée et la sortie doivent correspondre. A noter que le type est le même également.
  • Contrairement aux variables d'entrées du VS, il n'y a pas de déclaration de la forme layout(location = ...). C'est inutile en effet car la location est spécifiée pour faire le lien entre l'application C++ et le VS. Or les données d'entrée du FS ne viennent pas de l'application ! elles viennent du rasterizer.

Les valeurs que récuperera notre fragment shaders ne seront pas exactement les mêmes que celles sortant du VS: il ne faut pas oublier que ces dernières sont interpolées sur toute la surface du triangle afin d'être affecté à chaque fragment.

La ligne:

out vec3 fFragColor;

déclare la variable de sortie du FS destinée à contenir la couleur finale du pixel correspondant au fragment. Vous pouvez nommer cette variable comme vous voulez: OpenGL sait que s'il n'y a qu'une seule variable de sortie alors il doit l'interpreter comme une couleur et l'afficher à l'écran si le fragment est visible.

A noter qu'il est possible d'avoir plusieurs variables de sortie (pour écrire dans plusieurs images à la fois) mais nous n'utiliserons pas cette fonctionnalité durant ces TPs.

Et enfin le main ne fait qu'une chose: transférer la couleur d'entrée vers la couleur de sortie. A nouveau on pourrait appliquer des traitements sur cette couleur pour faire quelque chose de plus interessant.

Des vrais shaders

Ces shaders sont vraiment simples et ne font pas grand chose (c'est toujours mieux que du blanc mais bon...). Le site Shadertoy permet de coder en ligne (WebGL powa !) des shaders afin de les mettre à disposition d'autres utilisateurs. Vous avez donc accès à des centaines d'exemples de shaders auquels vous ne comprendrez pas grand chose (pour l'instant !). Il vous suffit de passer la souris sur une des vignette pour que l'animation se déclenche en temps réel. Les PC de l'université ont un peu du mal (c'est vraiment pas des shaders de tapette :p) mais vous devriez pouvoir constater la puissance à laquelle vous avez accès avec les shaders.

Il est interessant de constater que la plupart des shaders de ce site sont entièrement procéduraux, c'est à dire qu'il n'y a aucune modélisation 3D derrière: tout est calculé mathématiquement à la volée (vous voyez que les maths c'est de l'art :p).

Charger, compiler et utiliser

Pour utiliser un couple de shaders il faut fournir leur code source à OpenGL puis lui demander de compiler les shaders pour produire un programme GPU. Ce programme peut ensuite être utilisé avant de dessiner. Il est possible de changer de programme à la volée pour dessiner des objets différent avec des shaders différent. Cette section vous présente rapidement les fonctions permettant de créer et compiler des shaders en OpenGL sans rentrer dans les détails. Les fonctions de la bibliothèque glimac fournies dans le template de code vous permettent de faire tout ça très rapidement.

Création et compilation d'un shader

OpenGL fournit les fonctions suivantes:

  • glCreateShader qui permet de créer un nouveau shader et renvoit son identifiant OpenGL (doc).
  • glShaderSource qui permet de spécifier le code source du shader sous la forme de chaines de caractères (doc). Lorsque le shader est stocké dans un fichier il est necessaire de le charger préalablement (ce que vous pourrez faire avec le code fournit).
  • glCompileShader qui permet de compiler un shader (doc). Les shaders sont donc compilés au moment de l'execution de votre programme, lors de l'initialisation le plus souvent.
  • glGetShaderInfoLog qui nous permet d'obtenir des messages d'erreur / warning relatifs à la compilation d'un shader (doc).

La pipeline de compilation d'un shader peut donc être représentée par ce schéma:

Création et link d'un programme

Une fois qu'un VS et un FS ont été compilés, il faut les lier en un programme. Pour cela OpenGL fournit les fonctions suivantes:

  • glCreateProgram qui permet de créer un nouveau programme GLSL et renvoit son identifiant OpenGL (doc).
  • glAttachShader qui permet d'attacher un shader à un programme (doc). Il faut attacher le VS et le FS au programme.
  • glLinkProgram qui permet de linker les deux shaders et ainsi avoir un programme utilisable (doc).
  • glGetProgramInfoLog qui nous permet d'obtenir des messages d'erreur / warning relatifs au link du programme. (doc).

Utiliser le code fourni

Le code que je vous ai fournit vous permet de charger, compiler et utiliser des shaders GLSL. Vous l'avez déjà utilisé au TP1 pour afficher le triangle en couleur.

Pour que le tout fonctionne bien, les shaders doivent être placés dans le repertoire shader correspondant au TP courant (donc TP2 actuellement). Le fichier CMake se charge de copier ce repertoire dans le build afin d'avoir un chemin de fichier relatif à l'executable. Ainsi, à chaque fois que vous modifiez vos shaders, il faut relancer cmake (et pas juste make), sinon les modifications ne seront pas prise en compte. C'est un peu fastidieux mais plus simple à gérer ensuite dans le code.

La classe glimac::Program représente un programme GPU. La fonction glimac::loadProgram charge et compile un vertex shader et un fragment shader pour produire un programme. Si une erreur de compilation est detectée, une exception est lancée par la fonction et arrête votre executable. L'erreur est affichée et vous pouvez la corriger.

Le code type pour charger des shaders est donc:

FilePath applicationPath(argv[0]);
Program program = loadProgram(applicationPath.dirPath() + "shaders/[VERTEX_SHADER]",
	applicationPath.dirPath() + "shaders/[FRAGMENT_SHADER]");
Il faut bien évidemment remplacer [VERTEX_SHADER] par le nom du fichier contenant le code source du VS et [FRAGMENT_SHADER] par le nom du fichier contenant le code source du FS.

Concernant la variable applicationPath, elle contient le chemin de votre executable. applicationPath.dirPath() permet de récuperer le chemin du repertoire contenant l'executable. Par conséquent, applicationPath.dirPath() + "shaders/[VERTEX_SHADER]" sera le chemin vers le vertex shader copié par CMake. De même pour le fragment shader.

Dans le TP2, copiez le code C++ permettant de dessiner un triangle en couleur (celui du TP1).

Dans le dossier shader du TP2, créez deux fichiers color2D.vs.glsl et color2D.fs.glsl. Dans ces deux fichiers, copiez-coller le code des shaders expliqués précédement:

Vertex Shader:
#version 330 core

layout(location = 0) in vec2 aVertexPosition;
layout(location = 1) in vec3 aVertexColor;

out vec3 vFragColor;

void main() {
  vFragColor = aVertexColor;
  gl_Position = vec4(aVertexPosition, 0, 1);
};
Fragment Shader:
#version 330 core

in vec3 vFragColor;

out vec3 fFragColor;

void main() {
  fFragColor = vFragColor;
};

Modifiez le code C++ copié afin qu'il fonctionne avec ces shaders. Il y a deux choses à changer:

  • Le chargement des shaders: il faut changer le nom des fichiers
  • L'index des attributs: ce n'est plus 3 et 8 mais 0 et 1.

Dans le build, lancez cmake et make puis executez le programme. Le triangle en couleur devrait s'afficher correctement. Afin de voir comment se comporte le programme lorsqu'un shader contient une erreur, introduisez en une dans le Vertex Shader, relancez CMake, puis votre programme. Constatez l'erreur puis corrigez la. (Une erreur simple à introduire: rajoutez "bhbfhzebfhbzefjze" juste avant la fonction main du shader).

Jouer avec les shaders

Après toutes ces explications un peu barbante (mais necessaires :p) sur les shaders, on va pouvoir commencer à jouer un peu. Vous allez pour l'instant travailler sur les deux shaders d'exemple qui ont l'avantage d'être simple.

Le triangle générique

Une fois n'est pas coutume, faites une copie du code du triangle en couleur. Nous allons faire en sorte qu'il puisse afficher un triangle en utilisant n'importe quel shaders passés en paramètre du programme. De cette manière vous n'aurez pas à dupliquer à nouveau du code pour les quelques exercices suivants.

Le paramètre argv de la fonction main (du code C++) est sensé contenir les arguments passés à l'executable. argv[0] contient le chemin vers l'executable. argv[i] pour i supérieur ou égal à 1 contient le i-ème argument passé au programme. Nous allons passer en premier argument le nom du vertex shader et en deuxieme argument le nom du fragment shader.

Modifiez le code C++ afin d'utiliser argv[1] et argv[2] pour charger les shaders. Compilez et essayez en passant à votre executable les deux shaders de l'exercice précédent:

TP2/TP2_exo2_triangleGenerique color2D.vs.glsl color2D.fs.glsl

Si vous avez bien codé, le triangle en couleur devrait à nouveau s'afficher.

Les types de GLSL

Pour réaliser les exercices suivants vous aurez besoin de manipuler des variables GLSL, en particulier des vecteurs et des matrices. Gardez la page suivante dans un onglet afin d'y piocher les informations dont vous aurez besoin: Data Type (GLSL)

Quelques conventions de code pour les shaders

Afin d'avoir un code GLSL plus clair, appliquez les conventions suivantes:

  • Variables d'entrée du Vertex Shader: On les préfixe par un "a" (exemples: aVertexPosition, aVertexColor). Ce "a" signifie "attribute", pour signifier que la variable représente un attribut de sommet.
  • Variables de sortie du Vertex Shader (et d'entrée du Fragment Shader): On les préfixe par un "v" (exemple: vFragColor). Ce "v" signifie "vertex", pour signifier que la variable sort du Vertex Shader.
  • Variables de sortie du Fragment Shader: On les préfixe par un "f" (exemple fFragColor). Ce "f" signifie "fragment", pour signifier que la variable sort du Fragment Shader.

Le triangle des années 30

L'objectif de cet exercice est d'afficher le triangle en noir et blanc simplement en modifiant le fragment shader. Supposons que la couleur d'entrée est $(R, G, B)$, nous allons simplement fournir comme couleur de sortie le triplet $(M, M, M)$ avec $M = \frac{R + G + B}{3}$.

Commencez par faire une recopie du fragment shader; renommez le grey2d.fs.glsl.

Modifiez le pour que le triangle soit affiché en noir et blanc comme indiqué dans l'énoncé (il suffit juste de changer une ligne). Testez le shader en utilisant votre executable du triangle générique (utilisez le même vertex shader qu'avant, ce dernier restant le même).

Transformation !

Il est possible de transformer votre triangle simplement en modifiant le vertex shader. Il suffit pour cela de modifier la ligne:

gl_Position = vec4(aVertexPosition, 0, 1);

En appliquant une opération sur aVertexPosition

Translatez le triangle selon le vecteur $(0.5, 0.5)$ (utilisez pour cela simplement une addition de vecteurs)

Doublez la taille du triangle (utilisez pour cela une multiplication par un scalaire)

Doublez la taille du triangle sur l'axe x et réduisez la de moitié sur l'axe y (utilisez une multiplication de vecteurs)

Une solution plus souvent utilisée pour transformer les vertex est d'utiliser des matrices. Afin de pouvoir appliquer les transformations 2D les plus utilisées (translation, rotation, scale) il est necessaire d'utiliser des matrices 3x3 ( la translation n'étant pas réprésentable par une matrice 2x2).

Soit $P = (x, y)$ un point et $M$ une matrice de transformation 3x3. Pour appliquer la transformation $M$ a $P$ on fait simplement une multiplication matricielle: $P' = M \times (x, y, 1)$. On rajoute 1 à la fin du vecteur si on transforme un point. Dans le cas ou on transforme un vecteur on ajoute 0 (ce qui a pour effet de ne pas appliquer la partie translation, un vecteur n'ayant pas de position). En GLSL le vecteur que l'on récupère est de dimension 3; il faut le repasser en dimension 2 en gardant uniquement x et y. On pourra par exemple écrire:


vec2 transformed = (M * vec3(aVertexPosition, 1)).xy;
gl_Position = vec4(transformed, 0, 1);

On peut même se passer de la variable temporaire et directement ecrire:


gl_Position = vec4((M * vec3(aVertexPosition, 1)).xy, 0, 1);

Les matrices en GLSL sont dites column-major: on les stocke colonne par colonne. Par exemple si on ecrit :


mat3 M = mat3(vec3(1, 2, 3), vec3(4, 5, 6), vec(7, 8, 9));

Cela représente la matrice $M = \begin{pmatrix} 1&4&7 \\ 2&5&8 \\ 3&6&9 \end{pmatrix}$

De même l'accès aux valeurs d'une matrices par indice de tableau se fait en spécifiant la colonne en premier: la valeur 6 dans la matrice est accessible en écrivant en GLSL: M[1][2] (colonne d'indice 1, ligne d'indice 2). Il faut faire attention car c'est la convention inverse en mathémtiques

Voici la forme générale d'une matrice de translation: $T_{tx,ty} = \begin{pmatrix} 1&0&tx \\ 0&1&ty \\ 0&0&1 \end{pmatrix}$

Voici la forme générale d'une matrice de scale (non uniforme): $S_{sx,sy} = \begin{pmatrix} sx&0&0 \\ 0&sy&0 \\ 0&0&1 \end{pmatrix}$

Ecrivez deux fonctions dans le vertex shader: mat3 translate(float tx, float ty) et mat3 scale(float sx, float sy) qui renvoient les matrices correspondantes.

Refaites les transformations précédentes sur votre triangle (la translation et les scales) en utilisant cette fois des matrices.

Voici la forme générale d'une matrice de rotation d'angle $\alpha$ autour de l'origine: $R_{\alpha} = \begin{pmatrix} cos(\alpha)&-sin(\alpha)&0 \\ sin(\alpha)&cos(\alpha)&0 \\ 0&0&1 \end{pmatrix}$

Ecrivez la fonction mat3 rotate(float a) qui renvoit une matrice de rotation d'angle a (exprimé en degrés, faites la conversion avec la fonction radians de GLSL). GLSL vous permet d'utiliser les fonctions cos et sin (voir la doc pour une liste complète des fonctions GLSL).

Utilisez votre fonction pour appliquer une rotation de 45° à votre triangle. Le triangle doit apparaitre rotaté mais également déformé. Pouvez vous expliquer cette déformation ?

Combiner les transformations

L'avantage de représenter les transformations par des matrices est de pouvoir les combiner simplement en les multipliant.

L'ordre des transformations a une grande importance: la multiplication matricielle n'est pas commutative.

En pratique on combine toujours en multipliant à droite, c'est à dire en ajoutant les matrices à droite dans la liste de multiplication.

Il est important d'avoir une idée à peu près claire de ce qu'on va obtenir après avoir appliqué une suite de transformation. Lorsqu'on ajoute les transformations à droite, la façon la plus adaptée de penser est le modèle "local"

A chaque ajout de transformation la modification est faite sur le repère local de l'objet

Pour mieux comprendre observez l'image suivante:

Lorsqu'on ajoute la rotation de 45°, le carré tourne sur lui même, et non pas autour de l'origine. La transformation est donc appliquée sur son repère local et non sur le repère global.

De même quand on applique le scale, le carré se réduit sur lui même: c'est seulement le carré qui est scalé et non pas toute la scène.

En utilisant la multiplication matricielle, appliquez la suite de transformation du schéma sur votre triangle.

Modifiez l'ordre des transformations afin que la rotation s'applique autour de l'origine de la scène, tout en le gardant droit (à distance 0.5 de l'origine par exemple).

Pour les binômes, prenez une feuille. L'un des deux doit dessiner une position finale pour le triangle et l'autre doit coder la suite de transformations menant au résultat. Inversez ensuite les rôles. Si vous êtes seul, faites le pour vous même en essayant de ne pas imaginer les transformations en faisant le dessin.

Transformer le triangle en particule

Il est possible de dessiner une particule (ronde avec un halo) à partir du triangle simplement en travaillant sur les couleurs.

L'idée est assez simple: chaque fragment se trouve à une certaine distance du centre du triangle. Si on atténue sa couleur en fonction de cette distance, on peut générer une forme circulaire et le halo.

La formule d'atténuation a appliquer est la suivante: $a = \alpha \times exp(-\beta \times distance^2)$. Il suffira ensuite de multiplier la couleur finale par cette valeur. Vous devez faire varier les paramètre $\alpha$ et $\beta$ pour obtenir un résultat qui vous plait. Essayez de comprendre le rôle de chacun :)

Malheuresement pour vous, il y a un problème: pour calculer la distance du fragment au centre du triangle il vous faut la position du fragment (dans l'espace local du triangle, c'est à dire une position non transformée).

Cette position peut être obtenue en exploitant les variables d'entrée - sortie des shaders, un peu comme pour la couleur.

A vous de trouver comment obtenir la position du fragment :) Ensuite pour obtenir la distance vous pouvez utiliser la fonction... distance ! Modifiez le vertex et le fragment shader pour obtenir l'affichage d'une particule.

Voici le type de résultat à obtenir (j'ai repris un vieux screenshot, on peut faire mieux en faisant varier les paramètres:

Combinez ensuite avec une transformation afin de voir si vous avez compris l'histoire d'espace local de coordonnées.

Textures procédurales

Le chargement et l'affichage de textures viendra plus tard. En attendant nous allons faire des textures procédurales pour notre triangle. Une texture procédurale est une texture calculée à la volée, avec des maths ! Vous pouvez voir le rendu sous forme de particule de l'exercice précédent comme une texture procédurale.

Il est possible de générer des patterns en combinant des fonctions simples comme fract, abs, smoothstep, mod, floor, ... Renseignez vous sur chacune de ces fonctions.

Voici quelques formules permettant de créer quelques patterns ($P$ est la position du fragment dans l'espace local au triangle):

  • length(fract(5.0 * P))
  • length(abs(fract(5.0 * P) * 2.0 - 1.0))
  • mod(floor(10.0 * P.x) + floor(10.0 * P.y), 2.0)
  • smoothstep(0.3, 0.32, length(fract(5.0 * P) - 0.5))
  • smoothstep(0.4, 0.5, max(abs(fract(8.0 * P.x - 0.5 * mod(floor(8.0 * P.y), 2.0)) - 0.5), abs(fract(8.0 * P.y) - 0.5)))

Chaque formule produit un coefficient que vous pouvez appliquer à la couleur du fragment (multiplication)

Essayez chacune des formules

Essayez ensuite de créer vos propre formules en combinant des appels aux fonctions de base de GLSL.

Petite vidéo pour ceux qui sont interessés par le rendu procédural (3 min d'interview de Ignigo Quilez qui nous parle du rendu dans le film Brave de Pixar).

Mandelbrot

Nous allons à nouveau faire le rendu d'une texture procédurale, sur toute la fenêtre cette fois. L'objectif est de coder le rendu de la fractale de Mandelbrot. Pour cet exercice, reprenez le fichier template de base (TPtemplate.cpp), copiez le et renommez le.

Dessiner toute la fenêtre

Première chose à faire: dessiner un quad qui occupe tout l'ecran (de -1 à 1 sur chacun des axes). Nous avons déjà dessiné un quad dans un exercice précédent donc vous ne devriez pas avoir de problème. N'envoyez que des positions dans le VBO (pas de couleur). Testez votre programme avant de passer à la suite (la fenêtre doit être toute blanche).

Mettre en place les shaders

Créez deux shaders mandelbrot.vs.glsl et mandelbrot.fs.glsl. Codez les pour le moment de manière à afficher du rouge à la place du blanc. Depuis l'application chargez les. Testez votre application pour vérifier que la fenêtre est bien rouge.

Comme pour certains des exercices précédents, nous allons avoir besoin de la position du fragment en entrée dans le fragment shader. Faites en sorte qu'elle soit passée du vertex au fragment shader. Pour vérifier que c'est correct affichez cette position comme si c'était une couleur (le x,y devient le r,g de la couleur de sortie).

La fractale de Mandelbrot

La fractale de Mandelbrot est un ensemble noté $M$ de nombres complexes respectant une certaine propriété. L'idée pour dessiner cet ensemble est d'identifier chaque fragment à un nombre complexe. En effet, les nombres complexes peuvent être placés sur un plan, et donc dessinés en 2D. Nous dessinerons en noir les fragments donc le nombre complexe associé appartient à l'ensemble de mandelbrot et en blanc les autres.

Commençons par la définition formelle de l'ensemble $M$. Pour chaque nombre complexe $c = a + ib$, on définit une suite récurrente d'autres nombre complexe:

  • $z_0(c) = c$
  • $z_{k+1}(c) = z_k(c)^2 + c$

Prenons par exemple le nombre $c = 2 + i$ et calculons les premiers termes de la suite (rappel: $i^2 = -1$):

  • $z_0(c) = 2 + i$
  • $z_1(c) = z_0(c)^2 + c = (2 + i)^2 + (2 + i) = (2^2 + 2 \times 2 \times i + i^2) + (2 + i) = 4 + 4i - 1 + 2 + i = 5 + 5i$
  • $z_2(c) = z_1(c)^2 + c = (5 + 5i)^2 + (2 + i) = (5^5 + 2 \times 5 \times i + 5^2i^2) + (2 + i) = 25 + 10i - 25 + 2 + i = 2 + 11i$

Pour chaque nombre complexe $c$, on s'interesse au comportement de la suite $(z_{n}(c))_{n\geq 0}$. Si tous les elements de la suite sont situés dans le disque de rayon 2 centré sur l'origine $(0, 0)$, alors $c$ appartient à $M$. Si des elements de la suite sont situés à l'exterieur de ce disque, alors $c$ n'appartient pas à $M$. Cela revient à vérifier si la condition $\forall n \in \mathbb{N}, |z_n(c)| \leq 2$ est respectée (rappel: $|z_n(c)|$ est le module du nombre complexe $z_n(c)$ et représente la distance entre l'origine et le point associé au nombre complexe).

Comment convertir tout ça en code ? c'est simple: dans le fragment shader on récupère la position $(x,y)$ du fragment (obtenue en entrée grace au vertex shader). A partir de cette position on calcule les termes de la suite $z_n(x + iy)$ pour $n$ variant de $0$ à un entier maximal $N_{max}$ fixé (dans une boucle). Si le module d'un des termes est supérieur à 2, on quitte la boucle et on sort la couleur blanche. Si ce n'est pas le cas, on sort la couleur noire.

Concretement, on n'affiche qu'une approximation de la fractale. Plus $N_{max}$ est elevé, meilleure est l'approximation (et plus long est le calcul).

Le calcul des termes de la suite requiert une multiplication complexe (le terme précédent élevé au carré). La multiplication des vec2 en GLSL ne correspond pas à la multiplication complexe (car il n'y a pas de prise en compte de $i$). Dans le fragment shader, ecrivez une fonction vec2 complexSqr(vec2 z)$ qui calcule le carré du nombre complexe $z.x + iz.y$ représenté par z. Comprenez bien que $i$ n'apparait pas dans la fonction, ce n'est qu'une entitée mathématique. Le calcul doit d'abord être posé sur papier, en appliquant la propriété $i^2 = -1$. A partir de ça on renvoit dans la fonction un vec2 ayant en x la partie réelle et en y la partie imaginaire du résultat.

Implantez dans le main du fragment shader l'algorithme décrit plus haut pour calculer la couleur du fragment en fonction de son appartenance à l'ensemble de mandelbrot. N'oubliez pas d'utiliser votre fonction complexSqr pour calculer chacun des termes de la suite ! Pour calculer le module des termes, vous pouvez utiliser la fonction GLSL length qui calcule la longueur d'un vecteur (c'est équivalent au module).

Une fois que vous avez la fractale en noir et blanc, il est possible d'ameliorer facilement le rendu en affichant une couleur qui dépend du nombre de tour de boucle qui se sont écoulés avant que la condition soit rompue. Imaginons que le test échoue au tour de boucle $j$, vous pouvez afficher comme couleur la valeur: vec3(float(j) / Nmax); Avec un peu d'imagination vous pouvez même faire plus joli :)

Préparer la suite

L'objectif des exercices suivants est d'apprendre à manipuler deux nouveaux concepts de GLSL: les variables uniformes et l'application de textures. Mais avant tout vous allez mettre en place la scène que nous utiliserons à présent, qui requiert d'avoir des coordonnées de texture pour chaque sommet.

La suite est à refaire "from scratch", c'est à dire sans copier-coller un des exercices précédents. Il faut simplement redessiner un triangle, mais en utilisant une structure de sommet differente:

Créez une nouvelle copie du cpp template SDLtemplate.cpp

Ecrivez une structure Vertex2DUV contenant un glm::vec2 pour la position (x, y) et un glm::vec2 pour les coordonnées de texture (u, v).

Dans la fonction main, partie initialisation, créez un VBO que vous remplirez avec les 3 sommets d'un triangle: $P_1 = (-1, -1)$, $P_2 = (1, -1)$, $P_3 = (0, 1)$. Vous devez utiliser votre structure Vertex2DUV. Pour le moment mettez u et v à 0 pour chacun des sommets.

Créez un VAO décrivant le VBO.

Créez deux nouveaux shaders tex2D.vs.glsl et tex2D.fs.glsl dans le répertoire des shaders. Codez les de manière à afficher en 2D des sommets de couleur rouge. Attention: notre structure de sommet à changé; par consequent le vertex shader ne doit plus prendre en entrée la couleur du sommet mais ses coordonnées de texture, de type vec2.

Dans le main, chargez vos shaders et activez le programme GLSL associé.

Dans la boucle de rendu, dessinez votre triangle (utilisez glDrawArrays puisque l'on utilise pas de IBO).

Enfin testez l'application, un triangle rouge devrait s'afficher.

Un triangle qui tourne

Jusqu'ici nos scènes ont été statique. Nous allons utiliser la notion de variable uniforme pour envoyer au shader le temps courant et faire tourner le triangle en fonction de ce temps.

Les variables uniformes

Une variable uniforme est une variable d'entrée d'un shader qui reste constante pour tous les sommets d'un appel de dessin (draw call).

Un draw call est simplement un appel à une fonction de la forme glDraw*, comme par exemple glDrawArrays que vous utilisez dans votre boucle de rendu.

Lorsqu'on appelle une de ces fonctions, un ensemble de sommets est dessiné par la carte graphique sous la forme de primitives, en utilisant les shaders actifs.

Avant de faire un draw call, il est possible d'envoyer des valeurs à nos shaders à l'aide de variables uniformes. Ces valeurs resteront constantes pour tous les sommets dessinés par le draw call. Après le draw call, on peut modifier ces valeurs et faire un nouveau draw call pour dessiner un objet paramétré par les nouvelles valeurs.

L'exemple le plus courant est celui des transformations. Il est possible d'envoyer une matrice sous la forme de variable uniforme qui sera appliquée par nos shaders à tous les sommets. On peut ensuite dessiner plusieurs fois le même objet à des positions différentes simplement en changeant la matrice entre chaque appel de dessin.

Un autre exemple simple: les textures. On peut changer la ou les textures courantes avec des variables uniformes. Ainsi on peut dessiner le même objet plusieurs fois mais avec une apparence différente.

Définir une variable uniforme

Dans votre vertex shader (tex2D.vs.glsl), ajoutez avant le main la ligne:

uniform float uTime;

Cette ligne définie la variable uniforme uTime. Comme son nom l'indique, nous allons l'utiliser pour envoyer le temps écoulé depuis le début de l'application à notre shader.

Récupérez votre fonction mat3 rotate(float a) écrite pour les shaders du TP précédent. Dans le main du shader, utilisez cette fonction pour construire une matrice de rotation avec pour angle la valeur de la variable uTime. Utilisez ensuite cette matrice pour transformer le sommet en entrée du shader (référez vous au TP précédent si vous avez oubliez ces notions).

On préfixe les variables uniforme par un "u".

Modifier la variable uniforme depuis l'application

Placez vous à présent dans le main de l'application (code C++).

Chaque variable uniforme définie dans un programme GLSL (vertex shader ou fragment shader) possède une location, qui est simplement un entier allant de 0 au nombre total de variables uniformes - 1.

Cette location doit être récupérée pour pouvoir modifier la valeur de la variable présente dans le shader. Pour cela il faut utiliser la fonction glGetUniformLocation.

Juste après l'activation du programme GLSL (ligne program.use() normalement), utilisez la fonction GLint glGetUniformLocation(GLuint program, const GLchar *name) pour récuperer la location de votre variable uniforme. Le premier argument de cette fonction est l'identifiant OpenGL du programme, que vous pouvez obtenir en utilisant la méthode getGLId() de la classe Program.

glGetUniformLocation

Il est ensuite possible de modifier la valeur de la variable uniforme en utilisant les fonctions ayant la forme glUniform*. Il existe une fonction pour chaque type GLSL possible.

glUniform

Par exemple pour modifier une uniforme de type float, il faut utiliser la fonction glUniform1f. Pour le type vec3, il faut utiliser glUniform3f.

Avant d'appeler cette fonction, il faut avoir récupérée la location (car c'est le premier argument à passer à la fonction), et il faut que le programme définissant l'uniforme soit activé.

Dans un premier temps, avant le main, utilisez la fonction pour fixer l'uniforme uTime à la valeur 45. Essayez le programme, votre triangle devrait s'afficher tourné de 45°.

Si ça ne fonctionne pas, plusieurs choses à vérifier:

  • La fonction glGetUniformLocation doit vous avoir renvoyé une valeur non-négative. Si ce n'est pas le cas, alors soit vous n'avez pas définie la variable uTime dans le vertex shader, soit vous ne l'utilisez pas.
  • Vérifiez que vous appliquez bien la rotation à vos sommets dans le vertex shader.
  • Vérifiez que le programme est bien activé au moment de l'appel à glUniform1f (il faut le faire après program.use()).

Tu tournes !

L'intérêt des variables uniformes, c'est de les changer à chaque tour de boucle. Il faut la changer avant l'appel à glDrawArrays, afin que la modification soit prise en compte.

En incrémentant une variable (de type float) à chaque tour de boucle, modifiez la valeur de la variable uniforme uTime avant l'appel de dessin (glDrawArrays). Testez votre programme, le triangle devrait s'animer et tourner sur lui même.

La vitesse de rotation est determinée par la valeur d'incrémantation appliquée à chaque tour de boucle. Si on voulait faire les choses proprement, on récupererait le temps réel écoulé depuis le dernier tour et en fonction d'une valeur de vitesse spécifiée on modifierai l'angle.

Envoyer des matrices

Votre triangle doit tourner correctement mais le code n'est pas efficace: nous calculons la matrice de rotation dans le vertex shader. Ce calcul est fait pour chacun des 3 sommets, alors que c'est la même matrice pour chacun ! Lorsque l'on a que 3 sommet ça va, mais si on fait ce calcul sur 1 million de sommets, la perte de performance risque de se sentir. De manière générale, on calcule les matrices dans l'application et on les envoit au shader. C'est ce que nous allons faire.

Les matrices sur CPU

La bibliothèque glm permet d'utiliser des matrices grace aux types glm::mat2, glm::mat3 et glm::mat4 (ainsi que d'autres types pour les matrices rectangulaire). Elle propose également des fonctions pour construire les matrices de transformation standard. Malheuresement ces fonctions renvoient des matrices 4x4 (adaptés à la 3D). Nous les utiliserons dans les prochains TPs. Pour le moment nous avons besoin de matrices 3x3. Vous allez donc re-coder, dans votre code C++, les fonctions pour créer des matrices que vous avez déjà implanté dans le shader.

Ecrivez les fonctions translate, scale et rotate. Chacune doit renvoyer une matrice de type glm::mat3. Repenez le code des fonctions équivalentes codées en GLSL pour vous aider.

Modifier le shader

Modifiez le shader: la variable uniforme en entrée doit maintenant être de type mat3. Renommez la uModelMatrix.

Modifiez le main du shader pour appliquer directement la matrice uModelMatrix à la position du sommet d'entrée.

Modifier l'application

Dans votre boucle de rendu, utilisez votre fonction rotate pour construire une matrice de rotation à partir du temps écoulé. Utilisez ensuite la fonction glUniformMatrix3fv pour envoyer la matrice au shader. Attention aux paramètres de cette dernière fonction: le paramètre count est le nombre de matrices que l'on envoit (donc 1) et le paramètre transpose doit être fixé à GL_FALSE (voir la doc). Pour passer le pointeur vers la matrice, utilisez la fonction glm::value_ptr.

glUniform

Testez votre application, le triangle doit toujours tourner.

Plusieurs triangles

Un autre intérêt des variables uniformes est de pouvoir dessiner un objet plusieurs fois avec des paramètres différents. Ainsi on utilise qu'un seul couple VBO-VAO pour l'objet, mais on fait plusieurs appels de dessin en modifiant entre chaque appel les variables uniformes.

Des triangles

Dans la boucle de rendu, dessinez 4 fois le triangle. En modifiant correctement la variable uniforme uModelMatrix avant chaque appel à glDrawArrays, faites en sorte qu'une triangle soit placé au centre de chaque quart de l'écran (utilisez la translation).

Avec la multiplication matricielle, faites en sorte que les triangles soient dessiné avec un quart de leur taille initiale.

Faites en sorte que chaque triangle tourne sur lui même. Deux des triangles doivent tourner dans un sens, les deux autres dans le sens inverse.

Et des couleurs !

Ajoutez dans le fragment shader une nouvelle variable uniforme uniform vec3 uColor;. Dans le main du shader, fixez la couleur du fragment en utilisant cette variable.

Dans le fragment shader, récupérez la location de la nouvelle variable uniforme uColor. Faites ensuite en sorte d'afficher chaque triangle avec une couleur différente.

Et... des maths :D

Petit exercice pratique sur les matrices: faites en sorte que les triangles tournent tous autour du centre de l'écran (en plus de tourner sur eux même). Attention à l'ordre des multiplications de matrices.

Textures

Le template de code contient un bug chargeant des images noires. Remplacez le fichier glimac/src/Image.cpp par le suivant: Image.cpp.

L'objectif est à présent d'appliquer une texture aux triangles. Téléchargez le fichier triforce.png et placez le dans un répertoire nommé assets/textures à la racine de votre répertoire de TP.

Charger l'image

La fonction loadImage déclarée dans le fichier glimac/Image.hpp vous permet de charger une image depuis le disque. Cette fonction renvoit un std::unique_ptr<Image> (pointeur sur une image qui désalloue sa mémoire tout seul dans son destructeur).

En utilisant cette fonction, chargez la texture de triforce (utilisez le chemin absolu vers le fichier). Vérifiez que le chargement a bien réussi en testant si le pointeur renvoyé vaut NULL. Le chargement de la texture doit être fait avant la boucle de rendu (par exemple juste avant le chargement des shaders).

Dans un vrai moteur on n'utilise pas des chemins absolus vers les fichierq: on s'arrange pour que les assets soient situés relativement par rapport à l'executable et on les charge en utilisant le chemin de l'executable (c'est ce qui est fait pour les shaders dans votre code). J'ai décidé de ne pas faire copier au CMake les assets à coté de l'executable car ces derniers peuvent être gros (comparé à un shader) et votre place en mémoire est limitée sur les machine de la fac.

Créer un texture object OpenGL

Il faut ensuite envoyer la texture à la carte graphique. Pour cela OpenGL propose les texture objects.

En utilisant la fonction glGenTextures, créez un nouveau texture object.

A l'aide de la fonction glBindTexture, bindez la texture sur la cible GL_TEXTURE_2D.

Utilisez ensuite la fonction glTexImage2D pour envoyer l'image à la carte graphique afin qu'elle soit stockée dans votre texture object. Pour cela il faut utiliser les membres suivants de la classe Image: pImage->getWidth() pour obtenir la largeur, pImage->getHeight() pour obtenir la hauteur et pImage->getPixels() pour obtenir le tableau de pixels. La fonction glTexImage2D prend également en paramètre des formats (internalFormat et format), passez lui pour ces deux paramètres la constante GL_RGBA. Le paramètre type doit être GL_FLOAT car la classe Image stocke ses pixels en flottants. Enfin les paramètres level et border doivent être mis à 0.

Afin de pouvoir utiliser une texture, il faut spécifier à OpenGL des filtres que ce dernier appliquera lorsque 1) plusieurs pixels à l'écran sont couvert par un pixel de texture et 2) un pixel à l'écran couvre plusieurs pixels de texture. Rajoutez les lignes suivantes:


glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

Ces deux lignes permettent d'appliquer un filtre linéaire dans les deux cas cités plus haut. Référez vous à la documentation si vous êtes interessé par d'autres filtres.

Débindez la texture en utilisant glBindTexture avec 0 passé en deuxième paramètre.

A la fin du programme, ajoutez un appel à glDeleteTextures afin de détruire le texture object.

Spécifier les coordonnées de texture

Jusqu'a présent nous avions mis les coordonnées de texture des vertex à $(0, 0)$. Il faut les changer afin que chaque vertex soit associé au bon pixel dans la texture. Voici un schéma indiquant les coordonnées de texture à associer à chaque vertex:

Le coin haut-gauche a pour coordonnées $(0, 0)$ et le coin bas-droit $(1, 1)$ (quelque soit les dimensions de l'image d'entrée).

Dans le tableau de sommets, modifiez les coordonnées de texture de chacun des sommets à partir du schéma ci dessus.

Utiliser la texture dans le shader

Une texture s'utilise dans un fragment shader en utilisant une variable uniforme de type sampler2D.

Ajoutez une variable uniforme nommée uTexture dans votre fragment shader.

Il est ensuite possible de lire dans la texture en utilisant la fonction GLSL texture(sampler, texCoords). Le premier paramètre est le sampler2D (uTexture dans notre shader) et le deuxième paramètre les coordonnées de texture du fragment. Ces dernières doivent être récupérée en entrée du fragment shader depuis le vertex shader (comme nous faisions au TP précédent pour la couleur et la position).

Faite en sorte que la couleur affichée par le fragment shader soit celle lue depuis la texture à la bonne position. Attention: la fonction texture renvoit un vec4, il faut donc le transformer en vec3 si votre variable de sortie du fragment shader est de type vec3.

Spécifier la valeur de la variable uniforme

Dans le code de l'application, récupérez la location de la variable uniforme en utilisant glGetUniformLocation

Les sampler GLSL sont en réalité des entiers. Il faut les remplir à l'indice de l'unité de texture sur laquelle est branchée la texture voulue. Nous verrons plus tard les unités de texture, pour l'instant nous en utilisons une sans nous en rendre compte: l'unité de texture 0. Il faut donc remplir la variable uniforme avec la valeur 0 en utilisant la fonction glUniform1i.

Dans la boucle de rendu, avant l'appel à glDrawArrays: bindez la texture sur la cible GL_TEXTURE_2D, puis fixez la valeur de la variable uniforme uTexture à 0. Après l'appel à glDrawArrays, débindez la texture. Testez votre programme: les triangles doivent à présent être texturés.