Apprendre OpenGL moderne


précédentsommairesuivant

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 :

 
Sélectionnez
#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:

 
Sélectionnez
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 :

 
Sélectionnez
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 :

 
Sélectionnez
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.

Image non disponible

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

 
Sélectionnez
#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

 
Sélectionnez
#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 :

Image non disponible

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 :

 
Sélectionnez
#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.

Image non disponible

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 :

 
Sélectionnez
float timeValue = glfwGetTime();
float greenValue = (sin(timeValue) / 2.0f) + 0.5f;
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

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()).

Image non disponible

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 :

 
Sélectionnez
while(!glfwWindowShouldClose(window))
{
    // Entrées
    processInput(window);

    // rendu
    // effacement du tampon des couleurs
    glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    // activation du program shader
    glUseProgram(shaderProgram);
  
    // Mise à jour de la couleur
    float timeValue = glfwGetTime();
    float greenValue = sin(timeValue) / 2.0f + 0.5f;
    int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
    glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

    // 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 :

 
Sélectionnez
float vertices[] = {
    // positions         // colors
     0.5f, -0.5f, 0.0f,  1.0f, 0.0f, 0.0f,   // bottom right
    -0.5f, -0.5f, 0.0f,  0.0f, 1.0f, 0.0f,   // bottom left
     0.0f,  0.5f, 0.0f,  0.0f, 0.0f, 1.0f    // 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 :

 
Sélectionnez
#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.

 
Sélectionnez
#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 :

Données des positions et couleur entremêlées dans un VBO et configurée avec glVertexAttribPointer

Connaissant l'emplacement en cours, on peut mettre à jour le format des vertex avec glVertexAttribPointer() :

 
Sélectionnez
// 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 :

Image non disponible

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 :

 
Sélectionnez
#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

Image non disponible

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) :

 
Sélectionnez
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.

 
Sélectionnez
// 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 :

 
Sélectionnez
void use() 
{ 
    glUseProgram(ID);
}

De la même façon les fonctions set… :

 
Sélectionnez
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 :

 
Sélectionnez
Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.fs");
...
while(...)
{
    ourShader.use();
    ourShader.setFloat("someUniform", 1.0f);
    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

  1. Modifier le vertex shader pour inverser le triangle (bas en haut) : solution.
  2. 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.
  3. 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.


précédentsommairesuivant

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2018 Joey de Vries. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.