7. Les shaders▲
Les shaders sont de petits programmes qui sont chargés dans le GPU. Ces programmes traitent chacun une étape spécifique du pipeline. Les shaders sont des programmes indépendants qui ne communiquent entre eux qu’au moyen de leurs entrées/sorties.
Dans le tutoriel précédent, nous avons approché ces shaders et leur utilisation. Nous allons ici les détailler et en particulier le langage OpenGL Shading Language (GLSL).
7-1. GLSL▲
Les shaders sont écrits en langage GLSL, qui ressemble au C. Le langage est conçu pour le traitement graphique, et en particulier la manipulation des vecteurs et matrices.
Les shaders commencent avec une déclaration de version, suivie d'une liste de variables d'entrées et de variables de sortie, des déclarations de variables uniformes (avec le mot-clef uniform), puis la fonction main(). main() est le point d'entrée du shader et traite les entrées pour obtenir les sorties. Passons pour le moment sur les déclarations des variables uniformes.
Un shader a la structure suivante :
#
version
version_number
in
type in_variable_name;
in
type in_variable_name;
out
type out_variable_name;
uniform
type uniform_name;
void
main
(
)
{
// Traitement des entrées et calculs graphiques divers
...
// Préparation de sorties
out_variable_name =
résultat des traitements pour les sorties;
}
Les variables d'entrées du vertex shader sont appelées attributs de sommets. Le nombre maximum d'attributs de sommets est limité par le matériel. OpenGL garantit au minimum la place pour 16 attributs de sommets 4D, mais certains matériels peuvent en offrir plus, ce que l'on peut vérifier avec GL_MAX_VERTEX_ATTRIBS:
int
nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &
nrAttributes);
std::
cout <<
"Maximum nr of vertex attributes supported: "
<<
nrAttributes <<
std::
endl;
Cet appel retourne en général la valeur 16, ce qui est suffisant la plupart du temps.
7-1-1. Les types▲
Le GLSL offre des types de données pour spécifier quel genre de variable nous voulons représenter. On dispose ainsi des types C les plus courants : int, float, double, uint et bool. Le GLSL offre aussi deux types de structures couramment utilisés : les vecteurs et les matrices.
7-1-2. Les vecteurs▲
Un vecteur GLSL est une structure possédant 1, 2, 3 ou 4 composantes pour chacun des types simples possibles. Cela prend la forme suivante (n représente le nombre de composantes) :
- vecn : vecteur de n réels (float).
- bvecn : vecteur de n booléens.
- ivecn : vecteur de n entiers.
- uvecn : vecteur de n entiers non signés.
- dvecn : vecteur de n réels (double).
La plupart du temps, on utilisera le type basique vecn, car les réels (float) sont suffisants pour la plupart des applications.
Chaque composante d'un vecteur peut être accédée par vec.x, vec.y, vec.z ou vec.w. Le GLSL permet d'utiliser aussi les couleurs définies en rgba et les coordonnées des textures en stpq, accédant de la même façons aux quatre composantes.
Le type vecteur permet une sélection intéressante et flexible des composantes appelée swizzling. Le swizzling permet la syntaxe suivante :
vec2
someVec;
vec4
differentVec =
someVec.xyxx;
vec3
anotherVec =
differentVec.zyw;
vec4
otherVec =
someVec.xxxx +
anotherVec.yxzy;
On peut utiliser toute combinaison des quatre lettres pour créer un nouveau vecteur (du même type) tant que le vecteur d'origine possède ces composantes. On ne peut pas accéder à la composante z d'un vec2 par exemple. On peut aussi passer des vecteurs en argument à d'autres constructeurs de vecteurs, en réduisant le nombre d'arguments requis :
vec2
vect =
vec2
(
0
.5
, 0
.7
);
vec4
result =
vec4
(
vect, 0
.0
, 0
.0
);
vec4
otherResult =
vec4
(
result.xyz, 1
.0
);
Le type vecteur est donc un type très flexible que l'on peut utiliser pour tous les types d'entrées/sorties. Nous verrons dans les exemples comment utiliser ce type de façon créative.
7-1-3. Entrées et sorties▲
Les shaders sont des petits programmes indépendants mais font partie d'un tout, il faut donc gérer les entrées et les sorties pour être compatibles avec leur environnement. Le GLSL utilise les mots-clés in et out à cet effet, pour définir les entrées et les sorties respectivement. Chaque fois qu'une variable de sortie correspond à une entrée du shader suivant, les données sont transmises d'un shader au suivant. Le vertex shader et le fragment shader sont cependant légèrement différents.
Le vertex shader doit recevoir des entrées sinon il ne servirait à rien, mais le vertex shader est quelque peu particulier, car ses entrées proviennent du tampon de données. Pour préciser comment les données du vertex shader sont organisées, nous spécifions les variables d'entrée avec la métadonnée location qui permet de configurer les attributs de sommets sur le CPU. Nous avons rencontré cela dans le tutoriel précédent avec layout (location = 0). Le vertex shader requiert ainsi une spécification supplémentaire pour ses entrées de façon à pouvoir les relier aux données des sommets.
Il est aussi possible d'omettre la spécification layout (location = 0) et de rechercher l'emplacement des attributs avec glGetAttribLocation(), mais on préférera les préciser directement dans le vertex shader, c'est plus facile à comprendre.
L'autre exception concerne le fragment shader qui requiert une variable de sortie vec4 pour la couleur, car le fragment shader doit générer une couleur finale. Si l'on ne spécifie pas de couleur, OpenGL fera un rendu noir (ou blanc) de l'objet.
Pour transmettre les données d'un shader au suivant, nous devons déclarer les sorties de l'un de façon similaire aux entrées du suivant. Lorsque ces entrées et sorties correspondent (noms et types), OpenGL relie ces entrées et sorties, ce qui permet aux données de passer d'un shader au suivant (cela est réalisé lors de l'édition des liens du shader). Pour montrer cela en pratique, nous allons modifier les shaders du tutoriel précédent pour laisser le vertex shader décider de la couleur du triangle.
Vertex shader
#
version
330
core
layout
(
location =
0
) in
vec3
aPos; // La variable position a l'attribut de position 0
out
vec4
vertexColor; // Nous définirons la couleur dans cette variable
void
main
(
)
{
gl_Position
=
vec4
(
aPos, 1
.0
); // un vec3 est utilisé pour construire un vec4
vertexColor =
vec4
(
0
.5
, 0
.0
, 0
.0
, 1
.0
); // Couleur rouge foncé
}
Fragment shader
#
version
330
core
out
vec4
FragColor;
in
vec4
vertexColor; // Variable d'entrée identique à la sortie du vertex shader
void
main
(
)
{
FragColor =
vertexColor;
}
On peut remarquer que la variable vertexColor est déclarée comme vec4 dans le vertex shader et de la même façon dans le fragment shader (nom et type). Ainsi ces deux variables sont liées et la couleur définie dans le vertex shader peut être utilisée dans le fragment shader, pour dessiner l'image suivante :
Compliquons un peu et voyons comment définir la couleur dans l'application pour ensuite la passer au fragment shader !
7-1-4. Variables uniformes▲
Les variables uniformes offrent un autre moyen de passer les données de notre application sur le CPU vers les shaders sur le GPU, mais les variables uniformes sont un peu différents des attributs de sommets. Tout d'abord, les variables uniformes sont globales, ce qui signifie qu'elles sont accessibles dans tout le shader et qu'elles ne peuvent être déclarées qu'une seule fois. De plus, les variables uniformes conservent leur valeur jusqu'à modification ou réinitialisation.
Pour déclarer une variable uniforme en GLSL, on la précise simplement avec le mot-clé uniform. Dès lors on peut utiliser la variable dans le shader. Utilisons cela pour déclarer la couleur :
#
version
330
core
out
vec4
FragColor;
uniform
vec4
ourColor; // nous affecterons cette variable dans le code OpenGL.
void
main
(
)
{
FragColor =
ourColor;
}
Nous déclarons une variable uniforme vec4 ourColor dans le fragment shader et assignons la sortie du fragment shader avec cette variable. Puisque cette variable est globale, on peut la déclarer dans n'importe quel shader, inutile d'y faire référence dans le fragment shader puisqu'on ne l'utilise pas dans celui-ci.
Si l'on déclare une variable uniforme sans l'utiliser, le compilateur l'enlèvera sans prévenir, ce qui peut être la cause d'erreurs difficiles à détecter.
La variable uniforme est pour l'instant non affectée. Pour ce faire, nous avons besoin de trouver son emplacement dans le shader. Après cela, nous pourrons lui affecter une valeur. Au lieu de passer simplement une couleur, modifions la couleur en fonction du temps :
float
timeValue =
glfwGetTime();
float
greenValue =
(sin(timeValue) /
2.0
f) +
0.5
f;
int
vertexColorLocation =
glGetUniformLocation(shaderProgram, "ourColor"
);
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0
f, greenValue, 0.0
f, 1.0
f);
Tout d'abord, nous obtenons la date courante en secondes avec glfwGetTime(). Ensuite, nous faisons varier l'intensité de la couleur verte avec la fonction sinus.
Puis, nous cherchons l'emplacement de la variable globale ourColor avec glGetUniformLocation(), en donnant le nom de cette variable et le shader considéré. Si glGetUniformLocation() retourne -1, cette variable n'a pas été trouvée. Enfin, nous initialisons cette variable avec la fonction glUniform4f. Notons que retrouver l'emplacement de la variable ne requiert pas d'utiliser le shader, mais par contre l'affectation de cette variable ne peut se faire que si l'on utilise effectivement le shader (avec glUseProgram()).
Le noyau d'OpenGL étant écrit en C, il ne supporte pas la surcharge de type, ainsi lorsqu'une fonction peut être utilisée avec des types différents, OpenGL définit une fonction par type de paramètre. glUniform() en est un bon exemple. Cette fonction requiert de préciser le type de la variable à affecter, grâce au suffixe :
- f : la fonction attend un float
- i : la fonction attend un int
- ui : la fonction attend un unsigned int
- 3f : la fonction attend 3 float
- fv : la fonction attend un tableau de float
Chaque fois que vous souhaitez configurer une option dans OpenGL, choisissez la fonction qui correspond à votre type de variable. Dans notre cas, nous affectons quatre float indépendamment, et utilisons donc glUniform4f() (on aurait aussi pu utiliser la version fv).
Sachant comment affecter la valeur de la variable globale, on peut s'en servir dans la boucle de rendu, pour modifier la couleur de rendu à chaque itération. Nous calculons la couleur en fonction de la date, et ceci à chaque passage dans la boucle de rendu :
while
(!
glfwWindowShouldClose(window))
{
// Entrées
processInput(window);
// rendu
// effacement du tampon des couleurs
glClearColor(0.2
f, 0.3
f, 0.3
f, 1.0
f);
glClear(GL_COLOR_BUFFER_BIT);
// activation du program shader
glUseProgram(shaderProgram);
// Mise à jour de la couleur
float
timeValue =
glfwGetTime();
float
greenValue =
sin(timeValue) /
2.0
f +
0.5
f;
int
vertexColorLocation =
glGetUniformLocation(shaderProgram, "ourColor"
);
glUniform4f(vertexColorLocation, 0.0
f, greenValue, 0.0
f, 1.0
f);
// Rendu du triangle
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0
, 3
);
// échange des tampons et vérification des entrées utilisateurs
glfwSwapBuffers(window);
glfwPollEvents();
}
Ce code découle simplement de la version précédente. Vous devriez voir la couleur du triangle changer graduellement du vert au noir.
En cas de problème, le code est ici : ici.
Comme on le voit, les variables uniformes sont pratiques pour modifier des attributs dans la boucle de rendu, permettant le transfert de données de l'application vers les shaders. Mais que faire si l'on souhaite avoir une couleur pour chaque sommet ? Nous pourrions avoir une variable uniforme pour chaque sommet, mais une meilleure solution est d'inclure des données supplémentaires dans les attributs de sommets ce que nous allons montrer dans la suite.
7-2. Plus d’attributs▲
Nous avons vu dans le tutoriel précédent comment initialiser un VBO, configurer les pointeurs d'attributs de sommets et les mémoriser dans le VAO. Maintenant, nous voulons aussi ajouter une couleur aux données des sommets. Nous allons ajouter la couleur avec trois réels dans le tableau des sommets. Nous ajoutons une couleur différente pour chacun des trois sommets :
float
vertices[] =
{
// positions // colors
0.5
f, -
0.5
f, 0.0
f, 1.0
f, 0.0
f, 0.0
f, // bottom right
-
0.5
f, -
0.5
f, 0.0
f, 0.0
f, 1.0
f, 0.0
f, // bottom left
0.0
f, 0.5
f, 0.0
f, 0.0
f, 0.0
f, 1.0
f // top
}
;
Puisque nous devons fournir plus de données au vertex shader, nous devons ajuster le vertex shader pour y mémoriser la couleur comme attribut de sommets. Nous spécifions l'emplacement de l'attribut aColor avec un spécificateur layout :
#
version
330
core
layout
(
location =
0
) in
vec3
aPos; // la variable aPos a l'attribut de position 0
layout
(
location =
1
) in
vec3
aColor; // la variable aColor a l'attribut de position 1
out
vec3
ourColor; // transmettre une couleur au fragment shader
void
main
(
)
{
gl_Position
=
vec4
(
aPos, 1
.0
);
ourColor =
aColor; // affecter ourColor avec l'entrée color issue des données vertex
}
Nous n'utilisons plus de variable uniforme pour la couleur mais plutôt la variable de sortie ourColor, et nous devons aussi modifier le fragment shader.
#
version
330
core
out
vec4
FragColor;
in
vec3
ourColor;
void
main
(
)
{
FragColor =
vec4
(
ourColor, 1
.0
);
}
Puisque nous avons ajouté un autre attribut de sommet et mis à jour la mémoire du VBO, il faut reconfigurer les pointeurs d'attributs de sommets. Les données du VBO en mémoire suivront ce schéma :
Connaissant l'emplacement en cours, on peut mettre à jour le format des vertex avec glVertexAttribPointer() :
// position attribute
glVertexAttribPointer(0
, 3
, GL_FLOAT, GL_FALSE, 6
*
sizeof
(float
), (void
*
)0
);
glEnableVertexAttribArray(0
);
// color attribute
glVertexAttribPointer(1
, 3
, GL_FLOAT, GL_FALSE, 6
*
sizeof
(float
), (void
*
)(3
*
sizeof
(float
)));
glEnableVertexAttribArray(1
);
Les premiers arguments de glVertexAttribPointer() sont assez évidents. Cette fois, nous configurons les attributs de sommets de l'emplacement 1. Les couleurs ont une taille de 3 floats et nous ne normalisons pas les valeurs.
Puisqu'il y a deux attributs de sommets, nous devons recalculer le stride. Chacun des attributs de couleur est séparé du suivant de la taille de 6 floats (3 pour la position et 3 pour la couleur).
Cette fois nous devons également spécifier un décalage. La première position est à l'adresse 0, mais la première couleur est à l'adresse 3 * sizeof(float) en octets (= 12 octets).
Le résultat est le suivant :
Vérifiez le code ici en cas de pépin.
L'image n'est pas exactement ce que vous attendiez, puisque nous n'avons précisé que trois couleurs et non toute la palette que nous voyons. C'est le résultat de l'interpolation effectuée par le fragment shader. Lors du rendu, l'étape de rasterization produit bien plus de fragments que les sommets spécifiés au départ. Le rasterizer détermine la position de chacun de ces fragments en fonction de la forme du triangle. Ensuite, il interpole toutes les variables d'entrée du fragment shader. Supposons que nous ayons une ligne partant d'un sommet vert et finissant sur un sommet bleu, un fragment situé à 70 % de la distance des deux sommets aura une couleur avec 70 % de vert et 30 % de bleu.
C'est ce qui se passe pour le triangle entier qui contient environ 50 000 fragments, la couleur de chacun étant une interpolation de la couleur des trois sommets définis. Cette interpolation est appliquée pour tous les attributs des données entrées.
7-3. Notre propre classe shader▲
Écrire, compiler et gérer les shaders peut devenir fastidieux. Pour finir sur les shaders, nous allons construire une classe shader dont le code se trouve dans des fichiers texte, le compiler, le lier et vérifier les erreurs ; cela pour un usage plus simple des shaders.
Cela pourra vous donner aussi une idée de comment encapsuler les connaissances vues jusqu'ici dans des objets abstraits.
Nous créons la classe Shader entièrement dans un fichier d'en-tête, surtout pour des raisons pédagogiques et de portabilité. Commençons par ajouter les include et définissons la structure de la classe :
#ifndef SHADER_H
// Évite d'inclure plusieurs fois ce fichier
#define SHADER_H
#include
<glad/glad.h>
// inclure glad pour disposer de tout en-tête OpenGL
#include
<string>
#include
<fstream>
#include
<sstream>
#include
<iostream>
class
Shader
{
public
:
// the program ID
unsigned
int
ID;
// le constructeur lit et construit le shader
Shader(const
GLchar*
vertexPath, const
GLchar*
fragmentPath);
// Activation du shader
void
use();
// fonctions utiles pour l'uniform
void
setBool(const
std::
string &
name, bool
value) const
;
void
setInt(const
std::
string &
name, int
value) const
;
void
setFloat(const
std::
string &
name, float
value) const
;
}
;
#endif
Nous avons utilisé plusieurs directives pour le préprocesseur au début de notre fichier. Ces petites lignes de code imposent au compilateur de n’inclure et compiler ce fichier d'en-tête que si cela n’a pas déjà été fait, évitant ainsi les inclusions multiples de ce fichier shader.h, et donc des erreurs de compilation.
La classe Shader contient l'identifiant du program shader. Le constructeur requiert le chemin d'accès au fichier contenant le code source du vertex shader et du fragment shader que nous aurons placés dans un fichier texte. Nous avons également prévu quelques fonctions pour nous simplifier la vie : use() active le program shader et les fonctions set… récupèrent l'emplacement des variables uniformes et les initialisent.
7-3-1. Lire dans les fichiers▲
Nous utilisons les flux de fichiers C++ (filestreams) pour lire le contenu du fichier dans des chaînes de caractères (string) :
Shader(const
char
*
vertexPath, const
char
*
fragmentPath)
{
// 1. récupère le code du vertex/fragment shader depuis filePath
std::
string vertexCode;
std::
string fragmentCode;
std::
ifstream vShaderFile;
std::
ifstream fShaderFile;
// s'assure que les objets ifstream peuvent envoyer des exceptions:
vShaderFile.exceptions (std::ifstream::
failbit |
std::ifstream::
badbit);
fShaderFile.exceptions (std::ifstream::
failbit |
std::ifstream::
badbit);
try
{
// ouverture des fichiers
vShaderFile.open(vertexPath);
fShaderFile.open(fragmentPath);
std::
stringstream vShaderStream, fShaderStream;
// lecture des fichiers et place le contenu dans des flux
vShaderStream <<
vShaderFile.rdbuf();
fShaderStream <<
fShaderFile.rdbuf();
// fermeture des fichiers
vShaderFile.close();
fShaderFile.close();
// convertions des flux en string
vertexCode =
vShaderStream.str();
fragmentCode =
fShaderStream.str();
}
catch
(std::ifstream::
failure e)
{
std::
cout <<
"ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ"
<<
std::
endl;
}
const
char
*
vShaderCode =
vertexCode.c_str();
const
char
*
fShaderCode =
fragmentCode.c_str();
[...]
7-3-2. Compilation▲
Ensuite nous devons compiler et lier les shaders. Notons que nous vérifions aussi les erreurs de compilation et d'édition des liens en affichant le cas échéant les erreurs, ce qui est très important pour déboguer.
// 2. compiler les shaders
unsigned
int
vertex, fragment;
int
success;
char
infoLog[512
];
// vertex shader
vertex =
glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1
, &
vShaderCode, NULL
);
glCompileShader(vertex);
// affiche les erreurs de compilation si besoin
glGetShaderiv(vertex, GL_COMPILE_STATUS, &
success);
if
(!
success)
{
glGetShaderInfoLog(vertex, 512
, NULL
, infoLog);
std::
cout <<
"ERROR::SHADER::VERTEX::COMPILATION_FAILED
\n
"
<<
infoLog <<
std::
endl;
}
;
// de même pour le fragment shader
[...]
// program shader
ID =
glCreateProgram();
glAttachShader(ID, vertex);
glAttachShader(ID, fragment);
glLinkProgram(ID);
// affiche les erreurs d'édition de liens si besoin
glGetProgramiv(ID, GL_LINK_STATUS, &
success);
if
(!
success)
{
glGetProgramInfoLog(ID, 512
, NULL
, infoLog);
std::
cout <<
"ERROR::SHADER::PROGRAM::LINKING_FAILED
\n
"
<<
infoLog <<
std::
endl;
}
// supprime les shaders qui sont maintenant liés dans le programme et qui ne sont plus nécessaires
glDeleteShader(vertex);
glDeleteShader(fragment);
7-3-3. Fonctions▲
La fonction use() est très simple :
void
use()
{
glUseProgram(ID);
}
De la même façon les fonctions set… :
void
setBool(const
std::
string &
name, bool
value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), (int
)value);
}
void
setInt(const
std::
string &
name, int
value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), value);
}
void
setFloat(const
std::
string &
name, float
value) const
{
glUniform1f(glGetUniformLocation(ID, name.c_str()), value);
}
7-3-4. Utilisation▲
Dès lors, nous avons une classe complète. Utiliser cette classe est très facile, nous créons un objet Shader et nous l'utilisons :
Shader ourShader("path/to/shaders/shader.vs"
, "path/to/shaders/shader.fs"
);
...
while
(...)
{
ourShader.use();
ourShader.setFloat("someUniform"
, 1.0
f);
DrawStuff();
}
Ici, nous plaçons le code des shaders dans deux fichiers appelés shader.vs et shader.fs. Vous pouvez les appeler comme vous vous voudrez. J'utilise les extensions .vs et .fs pour plus de clarté.
Vous trouverez code source ici, utilisé par la classe Shader.
7-4. Exercices▲
- Modifier le vertex shader pour inverser le triangle (bas en haut) : solution.
- Spécifier un décalage horizontal avec une variable uniforme et déplacer le triangle vers la droite de la fenêtre en utilisant ce décalage dans le vertex shader : solution.
- Faire passer la position des sommets au fragment shader par le mot-clé out et définir la couleur du fragment égal à la position du sommet (voir comment les valeurs position sont interpolées dans le triangle). Ceci réalisé, une question : pourquoi le côté en bas à gauche du triangle est-il noir ? Solution.
7-5. Remerciements▲
Ce tutoriel est une traduction réalisée par Jean-Michel Fray dont l’original a été écrit par Joey de Vries et qui est disponible sur le site Learn OpenGL.