Développez vos applications 3D avec OpenGL 3.3

Développez vos applications 3D avec OpenGL 3.3

Mis à jour le vendredi 21 juin 2013

Introduction aux shaders

Ah c'est un vaste sujet que nous allons aborder aujourd'hui :) . Tellement vaste que nous allons le découper en 4 chapitres dont le premier sera celui-ci. Nous verrons les trois autres beaucoup plus tard, vous comprendrez pourquoi. Bref, il est temps de découvrir ce que sont ces mystérieux Shaders. :magicien:

Qu'est-ce qu'un Shader ?

Introduction

Commençons par un peu de théorie. ;)

Comme vous le savez, OpenGL est une librairie qui tire sa puissance de la carte graphique. Or lorsque nous programmons, nous n'avons pas accès à cette carte mais uniquement au processeur et à la RAM. Comment fait notre programme pour l'exploiter me direz-vous ?

En réalité, OpenGL va bidouiller pas mal de choses dans la carte lorsque nous appelons certaines fonctions comme glDrawArrays(). Voyons d’ailleurs ce qui se passe entre le moment où l’on appelle cette fonction et le moment où la scène s’affiche à l’écran :

Image utilisateur
  • Définition des coordonnées : Dans un premier temps, nous définissons les coordonnées de vertices. Ça on sait le faire grâce à la fonction glVertexAttribPointer().

  • Vertex Shader : Nous verrons cette étape dans quelques minutes. ;)

  • Pixelisation des triangles : C'est le moment où une forme géométrique est convertie en pixels.

  • Fragment Shader : Nous verrons également cela un peu plus loin.

  • Test de profondeur : Test permettant de savoir s'il faut afficher tel ou tel pixel. Cette notion sera développée un peu plus tard.

  • Affichage : C'est la sortie de la carte graphique, la plupart du temps ce sera notre écran.

Cette suite d’opérations constitue ce que l’on appelle: le pipeline 3D. Les parties qui nous intéressent dans ce chapitre sont : le Vertex Shader et le Fragment Shader.

Euh qu'est-ce que c'est que ça ? o_O

Un shader est simplement un programme exécuté non pas par le processeur mais par la carte graphique. Il en existe deux types (enfin 3 mais seuls 2 sont réellement importants) :

  • Vertex Shader : C’est l’étape qui va nous permettre soit de valider les coordonnées de nos sommets, soit de les modifier. Cette étape prend un vertex à part pour travailler dessus. S’il y a 3 vertices (pour un triangle) alors le vertex shader sera exécuté 3 fois.

  • Fragment Shader (parfois appelé Pixel Shader) : C’est l’étape qui va définir la couleur de chaque pixel de la forme délimitée par les vertices. Par exemple, si vous avez défini un rectangle de 100 pixels par 50 pixels, alors le fragment shader se chargera de définir la couleur des 5000 pixels composant le rectangle.

A quoi servent les shaders ?

Étant donné la complexité de cette notion, nous ne devrions étudier les shaders que beaucoup plus tard dans le tutoriel. Dans les anciennes versions d'OpenGL, ils étaient optionnels, c'était l'API elle-même qui se chargeait d'effectuer ces opérations. Mais avec la version 3.0, le groupe Khronos a voulu introduire une nouvelle philosophie : le tout shader. En clair, ils nous imposent leur utilisation.

Euh ... Pourquoi nous imposer un truc aussi compliqué alors que c'était géré automatiquement avant ?

Ce qu'il faut comprendre, c'est que les jeux-vidéo ont beaucoup évolué depuis qu'OpenGL existe. Il y a quelques années, il était plus simple de laisser l'API faire tout le boulot, les jeux ne prenaient pas énormément de ressources et surtout il y avait moins de choses à calculer. Mais de nos jours, les graphismes, le réalisme et la vitesse sont devenus les principales préoccupations des développeurs (et des joueurs ;) ). L'API n'est plus capable de gérer tout ça rapidement car elle le fait avec ses vielles méthodes (trop lentes pour les jeux d'aujourd'hui).

L'avantage d'une gestion personnalisée est que l'on peut faire ce que l'on veut et surtout on peut y mettre uniquement ce dont on a besoin, ce qui peut nous faire gagner parfois pas mal de vitesse.

Exemples de Shaders

Ah c'est certainement la partie qui va le plus vous intéresser. :p

Les shaders ne sont pas uniquement des étapes embêtantes qui sont là pour vous compliquer la vie :colere2: . Leur première utilité vient du fait qu'ils permettent d'afficher nos objets dans un monde 2D ou 3D. En gros, voila ce qui se passe lorsque nous voulons afficher un objet :

  • On définit ses coordonnées dans l'espace.

  • L'objet est ensuite passé au Vertex Shader (n'oubliez pas que c'est un programme exécuté par la carte graphique).

  • Puis au Fragment Shader (c'est un programme aussi).

  • Il est maintenant prêt à être affiché.

Ça c'est la première utilité, passons maintenant à la deuxième qui est certainement la plus importante (et la plus intéressante :p ) : ils permettent de faire tous les effets 3D magnifiques que vous voyez dans un jeu-vidéo tels que : les lumières, l'eau, les ombres, les explosions ... Ce sont les shaders qui font d'un jeu un jeu plus réaliste.

Image utilisateur
Image utilisateur

Alors ne vous emballez pas, ce n'est pas maintenant que nous allons apprendre à faire tous ces effets mais nous y viendrons. ;)

Le principal problème avec les shaders est que lorsque l'on débute dans la programmation 3D, il est très difficile d'apprendre à les utiliser du fait de leur complexité. C'est pour cela que dans un premier temps, je vais vous fournir le code source pour la création des shaders. Nous consacrerons trois chapitres entiers pour apprendre à les gérer une fois que vous serez plus habitués avec OpenGL.

En bref ce qu’il faut retenir c’est que :

  • Un shader est un programme exécuté par la carte graphique.

  • Il en existe de deux types : les vertex et les fragment.

  • Chaque chose que nous voulons afficher passera d'abord entre les mains de ces deux shaders.

Utilisation des shaders

Passons maintenant à la partie programmation. :magicien:

Pour commencer, je vais vous demander de télécharger l’archive ci-dessous (pour Linux et Windows), elle contient un fichier « Shader.h », « Shader.cpp » et un dossier « Shaders » contenant plein de petits fichiers. Vous placerez tout ça dans le répertoire de chaque projet que vous ferez (donc dans chaque chapitre que nous ferons).

Télécharger : Code Source C++ Shaders - Windows & Linux

Une fois les fichiers ajoutés à votre dossier, il vous suffit simplement d’ajouter le header « Shader.h » et le fichier source « Shader.cpp » à votre projet.

Comme vous le savez maintenant, un shader est un programme, différent d’un programme normal certes mais un programme quand même. Il doit donc respecter plusieurs règles :

  • Un code source

  • Une compilation

Nous ne verrons pas ces deux étapes maintenant mais sachez au moins que ce n’est pas si différent d'un programme normal. Les codes sources sont dans le dossier « Shaders » que vous devriez avoir placé dans le répertoire de votre projet.

Avant toute chose, pour pouvoir utiliser les shaders il va falloir utiliser la classe Shader dont voici le constructeur :

Shader(std::string vertexSource, std::string fragmentSource);
  • vertexSource : C’est le chemin du code source de notre Vertex Shader.

  • fragmentSource : C’est le chemin du code source de notre Fragment Shader.

Alors attention, appeler le constructeur ne suffit pas. Si vous n'appelez que lui, votre shader ne sera pas exploitable. Pour le rendre exploitable, il faut utiliser la méthode charger() qui permet en gros de lire les fichiers sources, de les compiler, ...

bool charger();

Cette méthode retourne un booléen pour savoir si la création du shader s'est bien passée.

En bref, voici un petit exemple de création de shader :

// Création du shader

Shader shaderBasique("Shaders/basique_2D.vert", "Shaders/basique.frag");
shaderBasique.charger();


// Début de la bouble principale

while(!terminer)
{
    // Utilisation
}

Bien, passons à l'utilisation qui est ma foi assez simple puisque nous n'utilisons qu'une seule fonction :

glUseProgram(GLuint program) ;

Elle prend un paramètre : l'ID d'une certain "program", nous lui donnerons l'attribut : "m_programID" de la classe Shader grâce à la méthode :

GLuint getProgramID() const;

Ne vous posez pas de question sur ça pour le moment ;)

Cette fonction a deux utilités :

  • Lorsqu'on lui donne l'attribut "m_programID", OpenGL va comprendre "Je prends le shader que tu me donnes pour l'utiliser dans mon pipeline".

  • Une fois qu'on a affiché ce qu'on voulait afficher, on va faire comprendre à OpenGL de ne plus utiliser le shader. Dans ce cas, le paramètre ne sera pas "m_programID" mais nous lui donnerons la valeur 0.

Cette fonction se place juste avant glDrawArrays() lorsque nous voulons activer notre shader, puis après avec le paramètre 0 pour le désactiver :

// Activation du shader

glUseProgram(shaderBasique.getProgramID());


    // Affichage du triangle

    glDrawArrays(GL_TRIANGLES, 0, 3);


// Désactivation du shader

glUseProgram(0);

Simple non ? :-° D'ailleurs on peut même intégrer, entre ces deux appels de la fonction glUseProgram(), le code relatif à l'envoi des vertices au tableau Vertex Attrib. En général, on fait cela pour bien différencier le code d'affichage du reste du programme, ce qui donnerait pour nous :

// Activation du shader

glUseProgram(shaderBasique.getProgramID());


    // On remplie puis on active le tableau Vertex Attrib 0

    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
    glEnableVertexAttribArray(0);


    // Affichage du triangle

    glDrawArrays(GL_TRIANGLES, 0, 3);


    // On désactive le tableau Vertex Attrib puisque l'on n'en a plus besoin

    glDisableVertexAttribArray(0);


// Désactivation du shader

glUseProgram(0);

Dans le chapitre précédent, nous nous sommes permis de ne pas utiliser ces fameux shaders. Mais comme je vous l'ai dit tout à l'heure, on ne peut plus continuer ainsi. Reprenons notre ancien code pour y ajouter les fonctions que nous venons de voir :

void SceneOpenGL::bouclePrincipale()
{
    // Variables

    bool terminer(false);
    float vertices[] = {-0.5, -0.5,   0.0, 0.5,   0.5, -0.5};

    Shader shaderBasique("Shaders/basique_2D.vert", "Shaders/basique.frag");
    shaderBasique.charger();


    // Boucle principale

    while(!terminer)
    {
        // Gestion des évènements

        SDL_WaitEvent(&m_evenements);

        if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
            terminer = true;


        // Nettoyage de l'écran

        glClear(GL_COLOR_BUFFER_BIT);


        // Activation du shader

        glUseProgram(shaderBasique.getProgramID());


            // On remplie puis on active le tableau Vertex Attrib 0

            glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
            glEnableVertexAttribArray(0);


            // Affichage du triangle

            glDrawArrays(GL_TRIANGLES, 0, 3);


            // On désactive le tableau Vertex Attrib puisque l'on n'en a plus besoin

            glDisableVertexAttribArray(0);


        // Désactivation du shader

        glUseProgram(0);


        // Actualisation de la fenêtre

        SDL_GL_SwapWindow(m_fenetre);
    }
}

Vous devriez vous retrouver avec une fenêtre comme celle-la :

Image utilisateur

Quoi mais c’est nul ! C’est la même chose que dans le chapitre précédent mais en plus compliqué.

Oui c'est la même chose mais c'est comme cela que les choses doivent être faites ;). Pour le moment vous ne voyez pas trop l'intérêt d'utiliser les shaders mais vous allez vite voir que c'est indispensable.

D'ailleurs pourquoi attendons-nous ? Voyons dès maintenant ce qu'ils ont à nous offrir. :magicien:

Un peu de couleurs

Un peu de couleurs

Il est maintenant temps de faire passer notre triangle à la couleur, je le trouve un peu trop pâlot. :p

Comment fonctionnent les couleurs avec OpenGL ?

La réponse est simple, vous savez qu'avec la SDL, pour créer un rectangle coloré il fallait utiliser cette fonction :

SDL_MapRGB(SDL_Surface *surface, Uint8 red, Uint8 green, Uint8 blue);

Les trois paramètres étaient les composantes RGB (Rouge, Vert, Bleu). Il suffisait de combiner ces trois couleurs pour en former une seule au final. Bonne nouvelle, avec OpenGL c'est pareil. Pour fabriquer une couleur vous devez donner :

  • Une quantité de rouge

  • Une quantité de vert

  • Une quantité de bleu

Si vous voulez faire des tests pour obtenir différentes couleurs, essayez la palette de couleurs Windows (ou équivalent sur Linux) :

Image utilisateur

Il y a deux façons de représenter les couleurs :

  • Soit avec des valeurs comprises entre 0 et 255 (la plus compréhensible)

  • Soit avec des valeurs comprises entre 0 et 1 (un peu difficile d'imaginer une couleur entre 0 et 1)

Malheureusement pour nous, nous allons devoir utiliser la seconde méthode. Mais pas de panique, nous allons utiliser une petite combine pour utiliser la première.

Jauger une couleur entre 0 et 255 est plus facile à comprendre, pour pouvoir utiliser cette méthode nous allons diviser la valeur de la couleur (par exemple : 128) par 255. De cette façon on se retrouve avec une valeur comprise entre 0 et 1 :

float rouge = 128.0/255.0; // = 0.50
float vert =  204.0/255.0; // = 0.80
float bleu =  36.0/255.0;  // = 0.141

La quantité de rouge sera de 128, le vert de 204 et le bleu de 36. De plus faites attention, nous travaillons avec des float donc n'oubliez pas de préciser les décimales même s'il n'y a en pas.

Au niveau du shader, on va changer le shader basique par le shader couleur2D car ce premier ne faisait qu'afficher ce que nous lui donnions en blanc. Maintenant que nous voulons de la couleur, il faut charger un autre shader gérer la couleur :

// Shader

Shader shaderCouleur("Shaders/couleur2D.vert", "Shaders/couleur2D.frag");
shaderCouleur.charger();

Vu que nous avons changé le nom du shader (shaderCouleur), il faut effectuer le même changement de nom lorsque l'on récupère le programID :

// Activation du shader

glUseProgram(shaderCouleur.getProgramID());


    // Envoi des données et affichage

    ....


// Désactivation du shader

glUseProgram(0);

Au niveau du code d'affichage, vous connaissez déjà presque tout. Les fonctions utilisées sont les mêmes que celles des coordonnées de vertex. Les couleurs se gèrent également de la même façon : on place la valeur de chaque couleur (RGB) les unes à la suite des autres dans un tableau.

Le seul changement sera le numéro du tableau, au lieu de placer nos couleurs dans le tableau 0 nous les placerons dans le tableau 1. Le premier tableau (indice 0) servira à stocker tous nos vertices et le deuxième tableau (indice 1) servira à stocker nos couleurs.

// On définie les couleurs 

float couleurs[] = {0.0, 204.0 / 255.0, 1.0,    0.0, 204.0 / 255.0, 1.0,    0.0, 204.0 / 255.0, 1.0};


while(...)
{
    ....


    // Activation du shader

    glUseProgram(shaderCouleur.getProgramID());


        // Envoi des vertices

        glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
        glEnableVertexAttribArray(0);


        // On rentre les couleurs dans le tableau Vertex Attrib 1

        glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, couleurs);
        glEnableVertexAttribArray(1);


        ....


    // Désactivation du shader

    glUseProgram(0);


    ....
}

Voyons ce que donne toutes ces petites modifications sur un exemple concret :

void SceneOpenGL::bouclePrincipale()
{
    // Variables
	
    bool terminer(false);
    float vertices[] = {-0.5, -0.5,   0.0, 0.5,   0.5, -0.5};
    float couleurs[] = {0.0, 204.0 / 255.0, 1.0,    0.0, 204.0 / 255.0, 1.0,    0.0, 204.0 / 255.0, 1.0};
	
	
    // Shader
	
    Shader shaderCouleur("Shaders/couleur2D.vert", "Shaders/couleur2D.frag");
    shaderCouleur.charger();

	
    // Boucle principale
	
    while(!terminer)
    {
        // Gestion des évènements
		
        SDL_WaitEvent(&m_evenements);
		
        if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
            terminer = true;
		
		
        // Nettoyage de l'écran
		
        glClear(GL_COLOR_BUFFER_BIT);
		
		
        // Activation du shader

        glUseProgram(shaderCouleur.getProgramID());


            // Envoi des vertices
		
            glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
            glEnableVertexAttribArray(0);


            // Envoi des couleurs

            glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, couleurs);
            glEnableVertexAttribArray(1);


            // Affichage du triangle

            glDrawArrays(GL_TRIANGLES, 0, 3);


            // Désactivation des tableaux Vertex Attrib

            glDisableVertexAttribArray(1);
            glDisableVertexAttribArray(0);


        // Désactivation du shader

        glUseProgram(0);
		
		
        // Actualisation de la fenêtre
		
        SDL_GL_SwapWindow(m_fenetre);
    }
}

Vous devriez obtenir un triangle coloré :

Image utilisateur

Vous commencez à voir l'intérêt des shaders ? ^^ Bon si vous n'êtes pas convaincus je vais vous montrer une autre façon de colorier notre triangle.

Si chaque sommet possède sa propre couleur alors OpenGL (avec l'aide des shaders) nous fera un joli petit dégradé entre les différentes couleurs. Prenons un exemple, nous allons définir une couleur différente pour chaque sommet :

// Remplaçons les couleurs par les suivantes ...

float couleurs[] = {1.0, 0.0, 0.0,  0.0, 1.0, 0.0,  0.0, 0.0, 1.0};


//... et voyons ce que ça donne;

Vous devriez avoir ceci :

Image utilisateur

Comme vous le constatez, les shaders ont calculé automatiquement la couleur de chaque pixel se trouvant entre les différents sommets.

Cette opération s’appelle l’interpolation. C’est-à-dire qu’OpenGL est capable de trouver la couleur de chaque pixel entre deux sommets ayant une couleur différente. Le gros avantage de l'interpolation c'est que nous ne nous en occupons pas :p . En effet, même si nous définissons nos shaders, nous ne nous occuperons pas de trouver la couleur de chaque pixel. Il nous suffira juste de donner notre code source pour seulement un et OpenGL se chargera de faire la même opération pour tous les autres. Magique n'est-ce pas ? :magicien:

Télécharger : Code Source C++ du Chapitre 4

Exercices

Énoncés

Au même titre que le chapitre précédent, vous avez maintenant le droit à votre petite série d'exercice. Votre objectif va être de colorier différents triangles avec une couleur spécifique.

Exercice 1 : Coloriez le triangle du haut avec les valeurs données ci-dessous :

  • rouge : 240.0

  • vert: 210.0

  • bleu : 23.0

Exercice 2 : Même consigne que précédemment mais avec les valeurs :

  • rouge : 230.0

  • vert : 0.0

  • bleu : 230.0

Exercice 3 : Coloriez le triangle uniquement avec la couleur vert (valeur 255.0). Simplifiez le tableau si possible.

Exercice 4 : Reprenez les 3 couleurs précédentes, au lieu de les appliquer au triangle appliquez en une pour chaque vertex. Le résultat final doit ressembler à ceci (ce n'est pas grave si les couleurs ne sont pas dans le même ordre) :

Image utilisateur

Exercice 5 : Le dernier exercice va allier ce que nous avons vu dans le chapitre précédent et celui-ci. L'objectif est d'afficher un losange multicolore avec les couleurs de l'exercice 4, le dernier vertex doit utiliser le bleu (255.0) :

Image utilisateur
Solutions

Exercice 1 :

Le but des exercices est de colorier un triangle, il n'y a donc que le tableau couleurs à modifier. Le reste du code ne change pas, on n'envoie toujours ce tableau à OpenGL grâce au Vertex Attrib 1. Pour ce premier exercice, il fallait trouver :

// Couleurs

float couleurs[] = {240.0 / 255.0, 210.0 / 255.0, 23.0 / 255.0,     // Vertex 1
                    240.0 / 255.0, 210.0 / 255.0, 23.0 / 255.0,     // Vertex 2
                    240.0 / 255.0, 210.0 / 255.0, 23.0 / 255.0};    // Vertex 3

Il ne fallait pas oublier de diviser les valeurs que je vous avais données par 255.0, sinon le code n'aurait pas fonctionné. ;)

Exercice 2 :

Le principe reste le même que le premier exercice, il suffit juste de modifier le tableau couleurs :

// Couleurs

float couleurs[] = {230.0 / 255.0, 0.0, 230.0 / 255.0,     // Vertex 1
                    230.0 / 255.0, 0.0, 230.0 / 255.0,     // Vertex 2
                    230.0 / 255.0, 0.0, 230.0 / 255.0};    // Vertex 3

Exercice 3 :

Encore une fois, on conserve le même principe que les exercices 1 et 2. La couleur demandée n'était que le vert ici, ce qui allège un peu notre tableau :

// Couleurs

float couleurs[] = {0.0, 255.0 / 255.0, 0.0,     // Vertex 1
                    0.0, 255.0 / 255.0, 0.0,     // Vertex 2
                    0.0, 255.0 / 255.0, 0.0};    // Vertex 3

Cependant il y avait un moyen de simplifier cette déclaration. En effet, je vous ai précisé dans le cours que l'on pouvait simplifier les divisions des couleurs brutes (rouge, vert et bleu). Donc au lieu de faire l'opération 255.0 / 255.0, on pouvait directement mettre la valeur 1.0. Ce qui allégeait encore plus le tableau final :

// Couleurs

float couleurs[] = {0.0, 1.0, 0.0,     // Vertex 1
                    0.0, 1.0, 0.0,     // Vertex 2
                    0.0, 1.0, 0.0};    // Vertex 3

Exercice 4 :

Le but de ce dernier exercice était bien évidemment d'appliquer chacune des couleurs mises en place précédemment sur un vertex en particulier. Nous avions déjà vu ce principe avec l'exemple du triangle multicolore :

float couleurs[] = {240.0 / 255.0, 210.0 / 255.0, 23.0 / 255.0,    // Vertex 1
                    230.0 / 255.0, 0.0, 230.0 / 255.0,             // Vertex 2
                    0.0, 1.0, 0.0};                                // Vertex 3

L'ordre des couleurs n'est pas important ici, ce n'est pas quelque chose de demandé.

Exercice 5 :

La seule difficulté ici était qu'il fallait reprendre la couleur de deux vertices pour le second triangle. En effet, ceux-ci sont doubler pour pouvoir afficher le triangle du bas, il fallait donc doubler leur couleur :

// Couleurs

float couleurs[] = {240.0 / 255.0, 210.0 / 255.0, 23.0 / 255.0,     // Vertex 1
                    230.0 / 255.0, 0.0, 230.0 / 255.0,              // Vertex 2
                    0.0, 1.0, 0.0,                                  // Vertex 3

                    240.0 / 255.0, 210.0 / 255.0, 23.0 / 255.0,     // Vertex 4 (Copie du 1)
                    0.0, 0.0, 1.0,                                  // Vertex 5
                    0.0, 1.0, 0.0};                                 // Vertex 6 (Copie du 3)

Il faut bien faire correspondre votre couleur avec votre vertex pour avoir un bon affichage. ;)

D'ailleurs, le tableau de vertices ne change absolument pas par rapport à avant.

// Vertices

float vertices[] = {-0.5, 0.0,   0.0, 1.0,   0.5, 0.0,          // Triangle 1
                    -0.5, 0.0,   0.0, -1.0,   0.5, 0.0};        // Triangle 2

Pensez à bien affecter la valeur 6 au paramètre count de la fonction glDrawArrays() pour afficher vos deux triangles, sinon elle n'en prendra en compte qu'un seul :

// Affichage des triangles

glDrawArrays(GL_TRIANGLES, 0, 6);

Ce chapitre (un peu compliqué je vous l'accorde) est enfin terminé. Vous savez maintenant ce que sont les shaders et à quoi ils servent. Je pense faire une partie entièrement consacrée aux effets assez sympas que l'on peut réaliser, mais bon ce n'est pas pour maintenant. ;)

L'auteur

Exemple de certificat de réussite
Exemple de certificat de réussite