TD3 - Pipeline programmable

1. Introduction

La bibliothèque OpenGL a subit une évolution majeure suite à l'apparition des shaders programmables, et de nombreuses autres évolutions suite à l'importante évolution des GPU ces 15 dernières années. Pour ces raisons, de nombreuses fonctionnalités des premières versions de l'API sont devenues inutiles ou peu adaptées aux architectures modernes. Par conséquent, lors du passage à la version 3 d'OpenGL, un certain nombre de ces fonctionnalités ont été dépréciées et ne devraient plus être utilisées. Elles ont d'ailleurs disparues des versions les plus récentes d'OpenGL et de ses dérivées (OpenGL-ES, WebGL).

Pour les TDs, nous utiliserons OpenGL 3.3 avec le profile core, ce qui signifie que les fonctionnalités dépréciées sont interdites et provoquent des erreurs à l'exécution en cas d'utilisation. Cela exclut toutes les fonctions bleues de la quick-reference-card. Il faut donc également se méfier de certains tutoriaux et exemples disponibles sur internet qui utilisent les fonctionnalités dépréciées et ne fonctionneront donc pas avec ces TDs. Ce n'est pas un bug.

Téléchargez et compilez le code fourni mds3d_td3.zip en utilisant cmake :

> mkdir build
> cd build
> cmake ../mds3d_td3
> make

La première étape pour utiliser OpenGL est de créer un context et généralement une fenêtre associée capable d'afficher un rendu OpenGL. Ces deux tâches seront effectuées ici par la bibliothèque GLFW qui est inclue avec le projet.

Documentation:

Le fichier main.cpp initialise le contexte OpenGL, crée la fenêtre principale (cf. class Viewer) et exécute l'application interactive. La fonction main() appelle:

  • la méthode Viewer::init après la création de la fenêtre et du contexte OpenGL,
  • la méthode Viewer::reshape lorsque la fenêtre est redimensionnée,
  • et la méthode Viewer::updateAndDrawScene lorsque l'image doit être redessinée, qui elle même appelle la méthode Viewer::drawScene.
Ce fichier fait également le lien entre les entrées utilisateurs (clavier/souris) et les méthodes respectives de la classe Viewer. En principe vous n'avez rien à modifier dans ce fichier.

2. Afficher un triangle

OpenGL peut être qualifié de machine à états dans le sens où le contexte lié à l’application possède tout au long de l’exécution un état courant qui peut être altéré au travers de nombreuses fonctions. Généralement, pour activer (resp. désactiver) un état du contexte, OpenGL fournie la fonction glEnable (resp. glDisable). D’autres fonctions sont disponibles pour spécifier des états plus complexes (la modification de l’état du viewport, par exemple).

Afin d'obtenir notre premier rendu, il nous faut au strict minimum :

  • Définir une zone de tracé au sein de la fenêtre à l'aide de la fonction glViewport. Pour l'instant, nous allons utiliser l'ensemble de la fenêtre avec: glViewport(0, 0, _winWidth, _winHeight);.
    TODO : ligne à insérer dans le fichier viewer.cpp au bon endroit.
  • Un vertex et un fragment shaders. Des versions minimalistes sont déjà fournies dans les fichiers data/shaders/simple.* qui sont chargés et compilés par la fonction Viewer::loadShaders() à l'aide de la classe Shader à l'initialisation. Les shaders peuvent être re-compilé à la volée avec la touche r.
  • Définir une géométrie, c'est à dire un maillage triangulaire et le charger en mémoire vidéo. C'est le rôle de la classe Mesh où le maillage est stocké sous la forme d'une tableau de sommets (sommet = positions x, y, z, et attributs) et d'un tableau de face (1 face = 3 indices). La fonction Mesh::load(filename) permet de charger un maillage à partir d'un fichier aux formats ASCII .off ou .obj. Après chargement des données, il faut les copier en mémoire vidéo via des Vertex Buffer Object (VBO), un pour les sommets, et un pour les indices, puis les lier dans un unique Vertex Buffer Array (VBA). Cette tache est réalisée par la fonction Mesh::initVBA(). Un maillage par défaut est chargé dans la fonction Viewer::init.
    Question : regarder le contenu de ce fichier et en déduire la forme qu'il représente.
Maintenant que tous les éléments sont initialisés, il nous reste à tracer la scène dans la fonction Viewer::drawScene.
TODO : pour cela vous devez effectuer les trois appels suivants à remettre dans le bon ordre :
  1. _mesh.draw(_shader);
  2. _shader.deactivate();
  3. _shader.activate();
Finalement, il nous reste à nous occuper du tracer du maillage qui est réalisé par la fonction Mesh::draw en quatre étapes:
  1. Activer le VBA et les VBO. (fait)
  2. Spécifier les données à fournir à l'attribut in vec3 vtx_position; du vertex shader. (fait)
  3. Tracer les triangles
  4. Désactiver l'attribut vtx_position. (fait)
TODO : pour tracer les triangles, nous allons utiliser la commande glDrawElements(GL_TRIANGLES, 3*mFaces.size(), GL_UNSIGNED_INT, 0); à insérer au bon endroit. Cette fonction émet 3*mFaces.size() sommets indicés par le tableau d'index courant de type unsigned int, en commençant par l'index 0 et en interprétant trois sommets consécutifs comme une face triangulaire.

Question : pourquoi cette fonction Mesh::draw à besoins de connaître le shader actif ?

Vous pouvez maintenant re-compiler et tester. Vous devez obtenir un triangle rouge comme spécifier par le maillage test.off. Essayez de re-dimensionner la fenêtre, pouvez vous expliquer ce comportement étrange ?

3. Du triangle au maillage complexe

house

1. Afin de résoudre le problème précédent, nous devons effacer l'image de destination avant de tracer notre scène. Pour cela, utilisez les commandes suivantes (au bon endroit et dans le bon ordre) de manière à avoir un fond gris :

  • glClear(GL_COLOR_BUFFER_BIT); : efface le tampon de couleur.
  • glClearColor(r, g, b, a); : spécifie la couleur RGBA d'effacement (chaque composante est un float entre 0 et 1).

2. Modifiez le fichier test.off de manière à tracer une petite maison comme la figure ci-contre.

3. Beaucoup d'efforts pour afficher 3 triangles... mais votre code permet déjà d'afficher des maillages beaucoup plus complexe :

  1. Ouvrez le logiciel Blender, cliquez sur la fenêtre et supprimer le cube par défaut avec la touche suppr.
  2. Ajoutez un maillage de singe avec space bar, entrez monk et valider.
  3. Lissage : onglet modifier, ajouter une Subdivision Surface, et mettre les niveaux à 3.
  4. Personnalisation : passez en mode édition avec la touche tab, sélectionner un sommet avec un clique droit, et déplacer le sommet en cliquant/déplaçant les flèches du repère
  5. Export : menu File->export->Wavefront (.obj), dans les options valider Triangulate faces, et enregistrez dans votre dossier data/models/monkey.obj.
  6. Dans la fonction Viewer::init(), chargez votre nouveau maillage à la place du fichier de test, compilez et testez.

4. Attributs aux sommets

1. La classe Mesh permet de définir des couleurs par sommet. Pour cela nous devons charger un maillage possédant cette information, tel que le fichier quad.off représentant un carré avec des couleurs différentes pour chacun des sommets. Testez. Pourquoi le carré est-il uniformément rouge ?

2. Afin de prendre en compte cette information de couleur dans notre rendu nous avons un peu de travail :

  1. Au niveau des shaders :
    Modifiez le vertex shader pour prendre en entrée (in) un attribut de couleur, par exemple un vec3 vtx_color. Nous devons ensuite passer cette information au fragment shader via le raster. Pour cela, ajoutez une variable en sortie du vertex shader, par ex. out vec3 var_color;. Réalisez la copie dans la fonction main, et dans le fragment shader ajoutez une variable en entrée (in) ayant le même type et le même nom que la variable en sortie du vertex shader. La valeur de cette variable sera calculée automatiquement par l'étage de raster en interpolant les valeurs aux sommets. Mettez à jour la fonction main pour recopier la valeur en sortie.
  2. lemming
  3. Au niveau de la classe Mesh :
    Nous devons maintenant mettre à jour le code de la fonction Mesh::draw afin de spécifier prendre les valeurs pour le nouvel attribut vtx_color. Pour cela, inspirez vous de ce qui est déjà fait pour les positions et les normales, et de la déclaration de la classe Mesh::Vertex.
Compilez et testez. Observez l'interpolation des valeurs.

3. Testez avec le modèle lemming.off. Pourquoi le rendu ne semble pas correct ?

4. Activez le z-buffer avec la commande glEnable(GL_DEPTH_TEST);. Observez bien ce qui se passe au démarrage de l'application. Qu'avons nous oublié ?

5. Shaders

1. Modifiez le ou les shaders pour que le personnage nous fasse face, c'est à dire, faire une symétrie par rapport au plan X-Y. Vous pouvez accéder aux composantes x, y, et z d'un vec3 avec v.x, v.y, v.z. Vous pouvez aussi faire du swizzling: v.xy, v.yxz, v.xxyy, ... Vous pouvez re-compiler à chaud les shaders avec la touche r.

2. Modifiez le ou les shaders pour que le personnage soit 2 fois plus grand.

3. Nous allons maintenant contrôler le niveau de zoom au clavier (au lieu d'éditer et recompiler à chaque fois le shader). Pour cela :

  1. Dans le shader, déclarez une variable globale uniform float zoom;, et mettez à jour la fonction main pour l'exploiter.
  2. La valeur de la variable zoom doit être définie par l'application C++ via l'instruction : glUniform1f(_shader.getUniformLocation("zoom"), value);. Testez avec une valeur arbitraire (par exemple 1.5), puis rendez cette valeur contrôlable au clavier (ajoutez un attribut à la classe Viewer, initialisez-la dans le constructeur, et modifiez sa valeur dans la fonction Viewer::keyPressed via les touches page up/down).
Voilà ! Vous avez votre premier effet de caméra !

4. Ajoutez une translation 2D contrôlée au clavier via les flèches. Côté Viewer, vous pouvez utiliser un Eigen::Vector2f pour stocker un vecteur 2D, et côté shaders un vec2, ainsi que la fonction glUniform2fv(int,int,float*) pour faire le lien.

6. Affichage en fils de fer

wireframe

1. Afin de visualiser le maillage, nous allons effectuer un rendu en fils-de-fer révélant ses arêtes. Pour cela, vous pouvez utiliser la commande glPolygonMode(GL_FRONT_AND_BACK,GL_LINE);. Testez.

2. Afin de rendre la visualisation plus lisible, nous allons superposer les arêtes visibles au rendu classique. Pour cela vous devez :

  1. Tracer deux fois le modèle ; une première fois de manière classique, une seconde fois en mode fils-de-fer.
  2. Modifier la fonction de test du z-buffer pour que le second tracé soit effectif (voir glDepthFunc).
  3. Tracer les lignes d'une couleur uniforme, par exemple blanche (vous pouvez utiliser un 2ème shader spécialisé, ou utiliser une variable uniforme pour passer du rendu classique à un rendu en blanc uniforme).
  4. Pensez à restaurer les états entre deux images !
  5. Pour un meilleur rendu, vous pouvez activer l'antialiasing des lignes avec glEnable(GL_LINE_SMOOTH);
  6. Ajoutez un raccourci clavier pour activer/désactiver ce mode de rendu.

viewport

7. Fenêtre d'affichage

Utilisez la commande glViewport pour scinder la fenêtre en deux, et afficher l'objet selon une vue de face et une vue de profil. Comme précédemment, vous pouvez au choix utiliser deux vertex shaders différents ou passer par une variable uniforme pour générer les 2 points de vue. Nous verrons au prochain TP comment généraliser ces transformations.