Introduction

Nous allons dans un premier temps dessiner des objets en 3D sans nous occuper de la caméra. Cela revient à utiliser comme ViewMatrix la matrice identité. L'objectif de ce TP sera donc de calculer des ModelMatrix de manière à dessiner nos objets en 3D à la bonne position.

Pour cela rien de mieux que de dessiner une planête et ses satellites ! En effet, les satellites tournant autour de la planête, il faut gérer la relation de hiérarchie en combinant correctement les transformations.

Les classes Sphere et Cone

Ces classes sont fournient avec le template, dans la bibliothèque glimac partagée entre les TPs. Elles vous permettent de créer des tableaux de sommets contenant des triangles pour dessiner des spheres et des cones.

Par exemple:

Sphere sphere(1, 32, 16);

Construit une sphere de rayon 1 et discrétisé selon 32 segments sur la lattitude et 16 sur la longitude. Un constructeur similaire existe pour Cone.

Ensuite la méthode getVertexCount() permet de récupérer le nombre de sommet et getDataPointer() renvoit un pointeur vers le tableau de sommets. Ces méthodes devront être utilisées pour envoyer les données à OpenGL (construction du VBO et du VAO).

Le type de sommet utilisé est définit dans common.hpp:

struct ShapeVertex {
    glm::vec3 position;
    glm::vec3 normal;
    glm::vec2 texCoords;
};

Ce type devra être utilisé conjointement avec sizeof et offsetof pour construire le VAO (comme nous faisions avec les classes Vertex2DColor et Vertex2DUV)

Dessiner une sphere

Le but de ce premier exercice est de dessiner une sphere. Commencez par repartir du template de base (SDLtemplate.cpp) afin d'avoir un code propre. Voici chacune des étapes à réaliser:

Créez une nouvelle variable de type Sphere à l'intialisation du programme (prenez comme rayon 1).

En utilisant la structure ShapeVertex et les méthode de l'objet sphere créé, construisez un VBO et un VAO contenant les sommets de la sphère.

Créez deux nouveau shaders 3D.vs.glsl et normals.fs.glsl. Le vertex shader doit prendre en entrée un sommet (3 attributs: position, normale, texCoords), trois matrices uniformes (4x4) uMVPMatrix, uMVMatrix et uNormalMatrix et calculer en sortie les position et normale en view coordinates, les coordonnées de texture sans les changer. Il doit également calculer la position projeté dans gl_Position. Essayez de coder ce shader from scratch (sans regarder l'exemple donné au cours du TP précédent). Le fragment shader doit prendre en entrée les variables de sortie du vertex shader et calculer en sortie la couleur du fragment. Comme couleur, utilisez la normale récupérée en entrée (normalisez la en utilisant la fonction normalize). Afficher la normale comme une couleur est une technique assez utilisée pour le debug.

Dans le code C++, chargez vos shaders afin d'obtenir un programme. Appelez la méthode use afin de l'utiliser.

Toujours à l'initialisation, récupérez les location des variables uniformes (glGetUniformLocation) de vos shaders.

Ajoutez la ligne glEnable(GL_DEPTH_TEST); qui permet d'activer le test de profondeur du GPU. Sans cet appel de fonction, certains triangles non visible viendraient recouvrir des triangles situés devant.

Créez 3 variables de type glm::mat4: ProjMatrix, MVMatrix et NormalMatrix.

Calculez la matrice ProjMatrix en utilisant la fonction glm::perspective. Le premier paramètre est l'angle vertical de vue (mettez glm::radians(70.f)), le second est le ratio de la largeur de la fenêtre par sa hauteur (largeur / hauteur), les 2 derniers sont le near et le far qui définissent une range de vision sur l'axe de la profondeur: mettez 0.1f et 100.f.

Utilisez la fonction glm::translate pour calculer la matrice MVMatrix. La convention OpenGL est de regarder du coté négatif de l'axe Z dans l'espace View. Faites donc en sorte que la sphère soit dessinée en $(0, 0, -5)$ via la translation qu'on lui applique.

Note: le fait de choisir near=0.1f et far=100.f pour la matrice de projection signifie que l'on ne verra que les objets situés entre -0.1f et -100.f sur l'axe Z (au risque de me répéter: car on voit du coté négatif des Z par défaut en OpenGL).

Calculez la matrice NormalMatrix en utilisant les fonction glm::inverse et glm::transpose sur la matrice MVMatrix.

Rappel: $NormalMatrix = (MV^{-1})^{T}$, c'est à dire: glm::mat4 NormalMatrix = glm::transpose(glm::inverse(MVMatrix))

Dans la boucle de rendu à présent: remplacez la ligne glClear(GL_COLOR_BUFFER_BIT); par glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);. Cela permet de nettoyer le depth buffer à chaque tour de boucle.

A la suite, envoyez les matrices au GPU en utilisant la fonction glUniformMatrix4fv. Pour la matrice MVP, il faut envoyer ProjMatrix * MVMatrix. (N'oubliez pas d'utiliser la fonction glm::value_ptr pour récupérer le pointeur sur les données de chacune des matrices).

Enfin, bindez le VAO et utilisez glDrawArrays pour dessiner la sphère (on utilise ici glDrawArrays car le code de la sphere ne créé par de buffer d'index). Il faut dessiner des triangles et utiliser la méthode getVertexCount sur la sphère pour obtenir le nombre de sommets à dessiner. N'oubliez pas de débinder le VAO ensuite.

Testez, et bon courage pour le déboguage de votre premier (ou pas ?) programme 3D :D Voici le résultat attendu:

Dessiner une lune

Afin de réaliser cet exercice, vous aurez besoin de récuperer le temps écoulé. Pour cela, j'ai ajouté la méthode getTime() à la classe SDLWindowManager. Téléchargez les fichiers SDLWindowManager.cpp et SDLWindowManager.hpp. Dans votre template de code (repertoire glimac), remplacez les anciennes versions par les nouvelles.

L'objectif à présent est de dessiner une lune tournant autour de notre sphère.

Dans notre cas une lune n'est rien de plus qu'une deuxième sphère, plus petite, placée relativement par rapport à la première.

Il va donc falloir dessiner deux sphère en changeant les transformations courantes avant chaque dessin. En particulier la matrice de transformation de la lune doit être en partie constituée de la transformation associée à la planète (puisque la lune tourne autour de la planète).

Pour dessiner une deuxieme sphere, il ne faut surtout pas créer un deuxieme couple VBO/VAO contenant les sommets d'une sphere plus petite. A la place on appelle juste deux fois la fonction glDrawArrays en modifiant les transformations avant chaque appel (on aura en particuler une transformation de type scale pour réduire la taille de la sphere).

Il est possible de combiner les transformations avec GLM en utilisant le premier paramètre de chaque fonction de transformation. Voici un exemple de code commenté:

glm::mat4 MVMatrix = glm::translate(glm::mat4(1), glm::vec3(0, 0, -5)); // Translation
MVMatrix = glm::rotate(MVMatrix, windowManager.getTime(), glm::vec3(0, 1, 0)); // Translation * Rotation
MVMatrix = glm::translate(MVMatrix, glm::vec3(-2, 0, 0)); // Translation * Rotation * Translation
MVMatrix = glm::scale(MVMatrix, glm::vec3(0.2, 0.2, 0.2)); // Translation * Rotation * Translation * Scale

La matrice passée en paramètre est multiplié à droite par la transformation demandée puis le résultat est renvoyé. Cela permet de recréer un concept de pile de transformation.

En utilisant des combinaisons de transformations, dessinez une lune. Dans un premier temps dessinez là immobile à gauche de la première sphère. Ensuite faites la tourner autour en utilisant comme angle de rotation le temps renvoyé par la méthode getTime() du windowManager.

De la même manière, en utilisant une boucle, dessinez 32 lunes tournant autour de la planète, placées pseudo-aléatoirement. Pour cela il suffit de tirer l'axe de rotation aléatoirement. Avant la boucle rendu, utilisez la fonction glm::sphericalRand (doc) pour tirer 32 axes de rotation que vous placerez dans un std::vector de glm::vec3. Utilisez ensuite ce vector dans la boucle de rendu pour dessiner toutes vos lunes. (il faut inclure le header glm/gtc/random.hpp pour utiliser glm::sphericalRand; rajoutez cet include dans le fichier glimac/include/glimac/glm.hpp). Testez le programme: il y a normalement un problème. Identifiez sa cause et corriger pour avec un résultat a peu près cohérent.

Dessiner la terre

Nous allons utiliser des textures pour donner à la planète l'apparence de la terre. La classe glimac::Sphere calcule automatiquement des coordonnées de textures qui sont reçuent par le shader. Il suffit donc de charger et binder correctement une texture de terre puis d'y accéder correctement depuis le fragment shader pour avoir une apparence correcte.

Téléchargez les texture EarthMap.jpg et MoonMap.jpg et placez les dans le répertoire textures de votre répertoire de TP.

Créez un nouveau shader tex3D.fs.glsl. Dans celui ci, créez une variable uniforme de type sampler2D et utilisez les coordonnées de texture en entrée pour obtenir la couleur de sortie en lisant dans la texture. Au besoin relisez le TP sur les textures pour vous aider.

Dans le code de l'application chargez la texture EartMap.jpg. Créez un texture object OpenGL à partir de l'image SDL et bindez la correctement pour qu'elle soit affichée lors du rendu. Testez le programme. Faites ensuite en sorte que la terre tourne sur elle même, mais pas les lunes !

Un problème apparait normalement: vos lunes ont également l'apparence de la terre. Afin de modifier cela, il suffit de modifier la texture bindée avant de dessiner les lunes. Chargez la texture MoonMap.jpg, créez un texture object OpenGL associé et utilisez ce dernier pour dessiner les lunes.

Multi-Texturing

Il est possible d'utiliser plusieurs textures à la fois dans un même shader. C'est ce qu'on appelle le Multi-texturing. L'utilisation la plus courante est l'application de materiaux: on a généralement une texture pour chaque type de reflexion (diffuse, speculaire, ...). Nous verrons cette application lors du TP sur les lumières. Pour le moment nous allons ajouter une couche de nuages à la terre, representée par une nouvelle texture.

Téléchargez le fichier CloudMap.jpg et placez le dans votre repertoire de textures.

Copiez le code source du TP précédent et renommez le.

Dans le code source, partie initialisation, chargez la nouvelle image et créez un texture object à partir de cette dernière.

Créez un nouveau shader multiTex3D.fs.glsl. Reprenez le code du shader tex3D.fs.glsl et ajoutez une nouvelle variable uniforme de type sampler2D pour la deuxième texture. Dans le main, lisez la couleur de chacune des textures. Additionnez les deux couleurs et placez le résultat dans la couleur de sortie.

Modifiez le code C++ pour charger votre nouveau shader.

Pour pouvoir utiliser plusieurs textures en même temps, il faut les binder sur des unités de texture différentes. Une unité de texture est représentée par une constant de la forme GL_TEXTUREi. Par exemple GL_TEXTURE0 représente l'unité de texture 0, GL_TEXTURE1 l'unité de texture 1, etc. A noter qu'on peut aussi utiliser GL_TEXTURE0 + 1 pour obtenir GL_TEXTURE1.

Pour binder sur une unité de texture particulière, il faut l'activer avec la fonction glActiveTexture.

Voici un exemple de code:

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE2D, earthTexture); // la texture earthTexture est bindée sur l'unité GL_TEXTURE0
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE2D, cloudTexture); // la texture cloudTexture est bindée sur l'unité GL_TEXTURE1

Les textures restent bindées sur les unités de texture tant qu'on ne les débind pas. Voici un exemple de débinding correspondant au code précédent:

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE2D, 0); // débind sur l'unité GL_TEXTURE0
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE2D, 0); // débind sur l'unité GL_TEXTURE1

Lorsqu'une texture est bindée sur une unité de texture, les shaders peuvent l'utiliser même si l'unité n'est plus "active" au sens d'OpenGL (le nom de la fonction glActiveTexture est assez mal choisi selon moi).

Il faut alors associer chaque variable uniforme de texture présente dans les shaders à une unité de texture. Pour cela on utilise la fonction glUniform1i en lui passant l'index de l'unité de texture sur laquelle on souhaite lire. Voici un exemple:

// Récupère la location de la première texture dans le shader
GLint uEarthTexture = glGetUniformLocation(program.getGLId(), "uEarthTexture");
// Récupère la location de deuxieme texture dans le shader
GLint uCloudTexture = glGetUniformLocation(program.getGLId(), "uCloudTexture");
// Indique à OpenGL qu'il doit aller chercher sur l'unité de texture 0 
// pour lire dans la texture uEarthTexture:
glUniform1i(uEarthTexture, 0);
// Indique à OpenGL qu'il doit aller chercher sur l'unité de texture 1
// pour lire dans la texture uEarthTexture:
glUniform1i(uEarthTexture, 1);

Vous noterez que pour les variables uniforme, on n'utilise pas les constantes GL_TEXTUREi mais directement l'index i.

Dans votre code C++, récupérez la location des variables uniformes correspondant à vos deux textures et utilisez glUniform1i comme indiqué dans l'exemple pour aller lire sur les unités de texture 0 et 1.

Dans votre boucle de rendu, bindez les textures sur les bonnes unités afin de pouvoir utiliser les deux textures. Dans le cas du dessin de la terre on utilise la texture de terre et la texture de nuage. Dans le cas du dessin de la lune on utilise la texture de lune et la texture de nuage. Voici un exemple de code pour dessiner la terre:

 // Les transformation viennent avant...
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, earthTexture);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, cloudTexture);

glDrawArrays(GL_TRIANGLES, 0, sphere.getVertexCount());

Voici une image du résultat attendu:

Retirer les nuages des lunes

Avoir des nuages sur les lunes, ce n'est pas très logique. On a deux solutions pour les retirer:

  • Solution simple mais peu générique: débinder la texture située sur l'unité de texture 1. En faisant ça, le shader essaiera de lire sur une unité de texture ne contenant aucune texture. Dans ce cas le comportement par défaut est de renvoyer la couleur noire (vec3(0)). On additionnera donc la couleur de la lune à vec3(0), ce qui affichera simplement la lune sans nuages.
  • Solution compliquée mais plus souple: on utilise deux shaders, un pour dessiner la terre et un pour dessiner les lunes.

Essayez la première solution.

Comme on aime les choses compliqués, et surtout qu'il est important de savoir utiliser plusieurs shaders pour dessiner différents objets de manière différente, nous allons également coder la deuxième solution dans l'exercice suivant.

Utiliser plusieurs shaders

Là ça devient compliqué car chaque programme GPU (un programme = un vertex shader + un fragment shader) possède son propre ensemble de variables uniformes. Par exemple, si deux programmes utilise la variable uniforme "uMVPMatrix", celle ci peu avoir une location différente pour chacun des deux programme. Il faudra donc récupérer deux fois sa location: une fois pour le premier programme et une fois pour le second programme.

Afin d'avoir un code à peu près propre, nous allons utiliser une structure pour représenter chaque programme. La structure EarthProgram contiendra le programme pour dessiner la terre, ainsi que les locations de chacun de ses variables uniforme. La structure MoonProgram contiendra la même chose mais pour les shaders correspondant à la lune.

Voici un exemple de ces deux structure dans mon code:

struct EarthProgram {
    Program m_Program;

    GLint uMVPMatrix;
    GLint uMVMatrix;
    GLint uNormalMatrix;
    GLint uEarthTexture;
    GLint uCloudTexture;

    EarthProgram(const FilePath& applicationPath):
        m_Program(loadProgram(applicationPath.dirPath() + "shaders/3D.vs.glsl",
                              applicationPath.dirPath() + "shaders/multiTex3D.fs.glsl")) {
        uMVPMatrix = glGetUniformLocation(m_Program.getGLId(), "uMVPMatrix");
        uMVMatrix = glGetUniformLocation(m_Program.getGLId(), "uMVMatrix");
        uNormalMatrix = glGetUniformLocation(m_Program.getGLId(), "uNormalMatrix");
        uEarthTexture = glGetUniformLocation(m_Program.getGLId(), "uEarthTexture");
        uCloudTexture = glGetUniformLocation(m_Program.getGLId(), "uCloudTexture");
    }
};

struct MoonProgram {
    Program m_Program;

    GLint uMVPMatrix;
    GLint uMVMatrix;
    GLint uNormalMatrix;
    GLint uTexture;

    MoonProgram(const FilePath& applicationPath):
        m_Program(loadProgram(applicationPath.dirPath() + "shaders/3D.vs.glsl",
                              applicationPath.dirPath() + "shaders/tex3D.fs.glsl")) {
        uMVPMatrix = glGetUniformLocation(m_Program.getGLId(), "uMVPMatrix");
        uMVMatrix = glGetUniformLocation(m_Program.getGLId(), "uMVMatrix");
        uNormalMatrix = glGetUniformLocation(m_Program.getGLId(), "uNormalMatrix");
        uTexture = glGetUniformLocation(m_Program.getGLId(), "uTexture");
    }
};

Puisque le shader tex3D.fs.glsl utilise une seule variable uniforme de texture, il n'y a qu'une seule variable membre pour stocker la location dans la structure MoonProgram ( contrairement à la structure EarthProgram qui en contient deux).

Il faut voir chacune de ces structure comme la représentation CPU du programme GPU associé. Elles nous permettent de faire facilement l'interface entre CPU et GPU.

Ajoutez ces deux structures à votre code. Si besoin modifiez le nom des variables uniformes dans le constructeur ainsi que le nom de la variable membre associée pour stocker la location.

Dans la fonction main du code C++, remplacez le chargement des shaders par une déclaration d'une variable pour chacune des structures:

FilePath applicationPath(argv[0]);
EarthProgram earthProgram(applicationPath);
MoonProgram moonProgram(applicationPath);

Puisque nous avons à présent deux programmes GPU, on ne peut plus faire un ".use()" global dans la partie initialisation comme on le faisait jusqu'a présent. Il va falloir, avant le dessin de chaque entitée (terre ou lune), changer le programme utilisé en utilisant la méthode use() sur la variable membre m_Program de la structure adaptée. De même pour modifier les variables uniformes. Voici par exemple mon code pour dessiner la terre, dans la boucle de rendu:

earthProgram.m_Program.use();

glUniform1i(earthProgram.uEarthTexture, 0);
glUniform1i(earthProgram.uCloudTexture, 1);

glm::mat4 globalMVMatrix = glm::translate(glm::mat4(1.f), glm::vec3(0, 0, -5));

glm::mat4 earthMVMatrix = glm::rotate(globalMVMatrix, windowManager.getTime(), glm::vec3(0, 1, 0));
glUniformMatrix4fv(earthProgram.uMVMatrix, 1, GL_FALSE, 
	glm::value_ptr(earthMVMatrix));
glUniformMatrix4fv(earthProgram.uNormalMatrix, 1, GL_FALSE, 
	glm::value_ptr(glm::transpose(glm::inverse(earthMVMatrix))));
glUniformMatrix4fv(earthProgram.uMVPMatrix, 1, GL_FALSE, 
	glm::value_ptr(projMatrix * earthMVMatrix));

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, earthTexture);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, cloudTexture);

glDrawArrays(GL_TRIANGLES, 0, sphere.getVertexCount());

On observe que j'utilise la variable earthProgram pour activer le bon programme et modifier les bonnes uniformes. Pour dessiner la lune il faut utiliser la variable moonProgram.

Modifiez votre code pour utiliser vos deux programmes séquentiellement.

Concernant l'appel à la méthode use(): celle ci est assez couteuse, on ne peut pas se permettre de changer de programme GPU à chaque dessin. C'est pourquoi avant cet exercice on le faisait en dehors de la boucle de rendu. Puisque vous dessinez plusieurs lunes, vous pourriez être tenté de faire l'appel moonProgram.m_Program.use() à chaque tour de la boucle qui dessine les lunes. La bonne solution est de faire cet appel avant cette boucle. Ainsi on ne fera pas:

for(uint32_t i = 0; i < nbMoon; ++i) {
	moonProgram.m_Program.use();

	// Modification des uniformes ...

	glDrawArrays(...);
}

Mais plutot:

moonProgram.m_Program.use();
for(uint32_t i = 0; i < nbMoon; ++i) {
	// Modification des uniformes ...

	glDrawArrays(...);
}

Dans un vrai moteur, on regroupe les appels de dessin de manière à minimiser le nombre de changement de shaders. Si on dessiner 42 terres et 69 lunes, on dessinerais d'abord toutes les terres avec le programme GPU pour dessiner la terre, puis ensuite toutes les lunes avec le programme GPU pour dessiner la lune. Cela permet de changer seulement deux fois de programme par tour de boucle de rendu.

Si au contraire on alternait dessin terre - dessin lune - dessin terre - dessin lune - etc. on devrait à chaque fois changer de programme, ce qui tuerait les performances (vous pouvez essayer si le coeur vous en dit !).

En construction