INF443 Introduction maillages dans une scène 3D

Scène basique et controle

Compilez et exécutez le code situé dans le répertoire scenes/inf443/01_introduction
  • Vous devez suivre la même démarche expliquée dans le tutoriel de compilation (CMake -> compilation) dans le cas de ce répertoire.
Lors de l'exécution du code, vous devez visualiser une scène 3D contenant
Une caméra contrôlable à la souris est également fournie. Les déplacements suivants sont possibles:

Code

Le code correspondant à cette scène se décompose en deux parties:


Remarques: Dans un premier temps, le code C++ situé dans main.cpp (ainsi que de la bibliothèque) est déjà long et va vous sembler complexe, mais cela permet l'affichage d'une scène 3D complète dès à présent. Vous n'avez pas à comprendre l'intégralité en débutant le C++, mais petit à petit au cours des séances, vous serez en mesure de vous y habituer et de le comprendre.
Il est normal que des difficultés puissent provenir de différents points:
Dans l'ensemble des cas, n'hésitez pas à nous poser des questions lorsque vous ne comprenez pas certains points où que vous souhaitiez en savoir plus.

Ajout d'un élément dans la scène

Objectif: Nous allons dans un premier temps ajouter une sphère dans cette scène.

Fonction main et organisation générale

Observez rapidement la fonction "int main()" du fichier main.cpp.
  • - Tout programme C++ doit nécessairement avoir une fonction dénommée "main", qui correspond au point de départ du programme.
  • - A plusieurs reprises, vous croiserez la commande "std::cout<<" ... "<<std::endl;". Il s'agit de la commande d'écriture standard sur la ligne de commande (similaire à print() pour Python ou System.out.println() pour Java)
    • - std:: signifie l'appel à une fonction de la bibliothèque standard du C++
    • - cout signifie Common Output.
    • - endl signifie end of line.
    • - << est un opérateur en C++ qui est utilisé ici pour concaténer des chaine de charactères à afficher.
  • - En C++, les commentaires peuvent se déclarer suivant deux manières:
// Ceci est un commentaire qui s'arrête au bout de la ligne

/* Ceci est un commentaire qui perdure ... 
    ...
    jusqu'à rencontrer le symbole suivant */
La fonction main contient deux étapes principales:
Pour ajouter un nouvel objet 3D, nous allons désormais suivre le même processus que pour les variables "cube" et "ground".
> Dans la fonction initialize(), créez une variable sphere_mesh qui va contenir la structure du maillage de la sphère.
mesh sphere_mesh = mesh_primitive_sphere();
Remarques

Ajout de la sphère

Le process de passage des données du CPU vers la carte graphique (GPU) est géré par la structure mesh_drawable (structure prévue dans la bibliothèque CGP).
> Pour cela, suivez la démarche suivante:
mesh_drawable sphere;
// Ecrire à la suite de "mesh sphere_mesh = mesh_primitive_sphere();"
sphere.initialize(sphere_mesh, "Sphere");
draw(sphere, environment);
> Recompilez et relancez le code, et une sphère blanche devrait être affichée à l'origine.

Explication

  • - Il est généralement préférable d'éviter de déclarer trop de variables globales dans un programme complexe (en C++ comme dans tout autre langage). En effet, du fait de leur portée globale, il peut être difficile de suivre quelle variable est modifiée dans quelle fonction. Il est plus simple de suivre une logique de "variables d'entrée" en tant que paramètre, et de "variable de retour" pour la sortie. En utilisant des variables de portée globale, il y a également un risque d'entrer en conflit avec d'autres variables locales qui seraient déclarées avec le même nom.
  • - Dans les codes des prochaines séances, nous utiliserons un objet appelé scene dans laquelle les variables devant être partagées entre la fonction d'initialisation et d'affichage seront déclarées comme des variables de la classe (en suivant une programmation "orientée objet").
Remarque: La chaine de caractères (type string) "Sphere" est optionnelle, et peut être n'importe quel nom indépendant de celui de la variable. Cette string est uniquement utilisée pour simplifier le debug en cas de crash lors de l'affichage de l'objet (son nom est alors affiché en ligne de commande).

Modification de la sphère

Il est possible d'adapter des paramètres globaux de l'objet tels que sa couleur, position, dimension en modifiant certains paramètres de la classe mesh_drawable.
Par exemple écrivez dans la fonction initialize() après sphere.initialize(...):
// to add after "sphere.initialize(sphere_mesh, "Sphere");"
sphere.transform.scaling = 0.2f; // coordinates are multiplied by 0.2 in the shader
sphere.transform.translation = {1,2,0}; // coordinates are offseted by {1,2,0} in the shader
sphere.shading.color = { 1,0.5f,0.5f }; // sphere will appear red (r,g,b components in [0,1])
  • Notez "f" après les nombres à virgules (0.2f, 0.5f).
    • - En C++, les valeurs à virgules (ex. 0.2) sont par défaut des nombres flottants dits à double précision: type "double" - encodés sur 8 octets.
    • - Les cartes graphiques utilisent cependant des nombres flottants à simples précisions: type "float": encodés sur 4 octets. OpenGL et la bibliothèque CGP utilisent ainsi des types "float" par défaut et non pas des "double".
    • - Dans la grande majorité des cas, vous pouvez écrire dans le code "0.2" à la place de "0.2f" sans problèmes: le compilateur convertira de lui-même la valeur à double précision vers simple précision. Dans certains cas particuliers, le compilateur pourrait cependant indiquer un warning (perte de précision), voir une erreur (mélange de type entre float et double en paramètre templates), qui nécessiterait d'expliciter le type flottant simple précision.
    • - Les codes d'exemples expliciteront généralement l'utilisation des flottants à simple précision avec la lettre "f".
> Relancez le code pour observer le résultat.
assets/sphere.jpg

Chargement d'un maillage externe

La bibliothèque CGP fournie la création pré-codées de primitives basiques (sphères, cube, cylinder, cone, etc) par le biais de l'appel "mesh_primitive_xxx". Mais pour des objets plus complexes, il peut être avantageux de charger des maillages depuis un fichier que l'on peut télécharger ou éditer à l'aide de modeleur 3D.
Pour cela, une fonction de chargement simple d'un format classique: OBJ est fourni par défaut.
Suivez la démarche suivante pour charger l'exemple d'un modèle de dromadaire:
mesh_drawable camel;
// mesh_load_file_obj: lit un fichier .obj et renvoie une structure mesh lui correspondant
mesh camel_mesh = mesh_load_file_obj("assets/camel.obj");

// Initialisation classique de la structure mesh_drawable
camel.initialize(camel_mesh, "obj mesh");

// Ajustement de la taille et position de la forme
camel.transform.scaling = 0.5f;
camel.transform.translation = { -1,1,0.5f };
draw(camel, environment);
assets/camel.jpg

Affichage wireframe

Il est souvent utile de pouvoir visualiser les maillages en wireframe (mode "fil de fer") qui représente explicitement les arêtes des triangles afin de mieux comprendre la structure, et/ou pour du debug.
La bibliothèque CGP propose la fonction draw_wireframe(mesh_drawable, environment) précodée à cet effet.
Ajoutez les lignes suivantes dans la fonction display_scene() et observez le résultat.
draw_wireframe(ground, environment);
draw_wireframe(sphere, environment);
draw_wireframe(cube, environment);
draw_wireframe(camel, environment);
assets/camel_wireframe.jpg
Notez que par défaut, les arêtes sont affichées en bleu. Il est possible d'expliciter une couleur (r,g,b) quelconque en argument supplémentaire.
// affiche les arêtes en rouge
draw_wireframe(camel, environment, {1,0,0});

Buffer de profondeur

Par défaut, la scène est rendue en utilisant le buffer de profondeur (Depth/Z-Buffer).
Pour rappel, ce "buffer" est similaire à une image annexe (qui n'est pas affichée) stockant la profondeur la plus proche des pixel/fragment visibles. Ce buffer est utilisé pour savoir si le pixel d'un triangle en cours d'affichage est visible ou s'il est caché par un objet déjà affiché et plus proche de la caméra.
L'utilisation du buffer de profondeur est activée par défaut, ce qui permet un affichage cohérent indépendamment de l'ordre d'affichage des objets dans le code.
> Désactivez l'utilisation du buffer de profondeur (plus précisément l'écriture dans ce buffer) en ajoutant la ligne suivante au début de la fonction display_scene()
glDisable(GL_DEPTH_TEST);
Observez que les objets (ainsi que leur triangles) ne sont plus affichés dans un ordre correct vis à vis de leur position spatiale (désactivez l'affichage du mode wireframe pour mieux observer le phénomène).
En désactivant le buffer de profondeur, chaque objet (et chaque triangle individuel) est affiché dans l'ordre de leurs appels, même s'il se situe "derrière" un autre spatialement. Les objets sont donc affichés dans l'ordre de leurs appels - et les derniers objets à être affichés apparaitrons donc toujours "devant" les autres.
> Echangez l'ordre d'appel dans le code entre deux objets (exemple entre le sol et le cube) et observez le résultat.
Remarque: La désactivation du buffer de profondeur peut être utile dans certains cas particuliers. Par exemple pour l'affichage d'objets semi-transparent - un exemple sera proposé dans la séance consacrée au textures. Mais l'ordre d'affichage des objets doit alors être considéré avec soin.

GUI: Interface utilisateur

Le code prévoit également la possibilité d'intégrer une GUI (Graphical User Interface) qui permet d'ajouter des boutons/sliders associés à des variables de votre programme. Dans notre cas, le code utilise une bibliothèque externe appelée ImGui (simple et légère d'utilisation, et s'intègre à un contexte OpenGL).

Ajout d'un bouton

Considérons un exemple d'utilisation de cette bibliothèque pour ajouter un bouton permettant de sélectionner si il faut afficher ou non les maillages en mode wireframe.
Le principe est le suivant:
bool gui_display_wireframe = false;
ImGui::Checkbox("Wireframe", &gui_display_wireframe);
  • - La syntaxe "&gui_display_wireframe" -- ou plus généralement "&variable" -- consiste à considérer "l'adresse mémoire" de la variable plutôt que sa valeur. On parle de "pointeur" sur une variable.
  • - L'utilisation de l'adresse de gui_display_wireframe dans ce cas, permet à la fonction "ImGui::Checkbox" de modifier la valeur de gui_display_wireframe. Ce paramètre est donc un paramètre d'entrée et de sortie à cette fonction.
  • - La syntaxe "ImGui::" indique qu'il s'agit d'une fonction de la bibliothèque ImGui. On parle de "namespace".
  • - ImGui est une bibliothèque qui travaille en "mode immédiat". C'est-à-dire qu'à chaque frame, le bouton est créé et la variable qui lui est associée peut être modifiée. Les boutons peuvent être modifiés dynamiquement sans avoir à pré-concevoir une architecture fixe. D'autres bibliothèques (telles que Qt par exemple) utiliseront un principe différent où l'interface devra être spécifiée préalablement.
if (gui_display_wireframe==true) {
    draw_wireframe(ground, environment);
    draw_wireframe(sphere, environment);
    draw_wireframe(cube, environment);
    draw_wireframe(camel, environment);
}
  • - Vous pouvez écrire également plus simplement la condition
if (gui_display_wireframe) {
    ...
}
sans avoir à expliciter "==true". En C++ une condition est considérée comme valide tant que sa valeur est différente de 0 (ou false).
> Relancez votre programme et testez le fonctionnement du nouveau bouton de votre interface.

Ajout d'un slider

ImGui peut également gérer des sliders qui vous permet d'ajuster manuellement la valeur d'une variable dans un intervalle.
Considérons le cas où vous souhaitez translater suivant l'axe x le modèle de dromadaire dans l'intervalle \([-2,2]\). Pour cela, ajouter simplement la ligne suivante dans display_gui():
ImGui::SliderFloat("camel-x", &camel.transform.translation.x, -2.0f, 2.0f);
> Relancez votre programme et testez le fonctionnement du slider.
Remarques: Cette fois la procédure était encore plus simple que pour le bouton. Nous n'avons pas à créer de variable intermédiaire. En effet, la translation suivant x du dromadaire est déjà stockée et accessible dans la variable "camel.transform.translation.x" qui était déjà utilisée à chaque affichage (lors de l'appel à "draw(camel, environment)"). Dans ce cas, il suffit de lier cette variable au slider pour permettre l'interaction de l'utilisateur.

Projection

La bibliothèque CGP propose des modèles de projections de caméra pré-programmés dans le cas de représentation standard. Un modèle de projection en perspective, et un modèle de projection orthogonale.

Perspective

Modèle

Le modèle perspectif est celui proposé dans le code par défaut.
Ce modèle permet de convertir l'espace visible (un cône tronqué à base rectangulaire appelé "frustum") vers l'espace normalisé de représentation attendu par le GPU (appelé "normalized device coordinate") correspondant à un cube dans l'intervalle \([-1,1]\).
assets/perspective.png
Ce modèle, et donc l'espace visible du cône tronqué, est paramétré par:
La matrice \(4\times 4\) correspondante à ce modèle est la suivante:
\(\mathrm{P}= \left( \begin{array}{rrrr} f_x & 0 & 0 & 0 \\ 0 & f_y & 0 & 0 \\ 0 & 0 & C & D \\ 0 & 0 & -1 & 0 \\ \end{array} \right)\), avec \(\left\{ \begin{array}{l} f_y = 1/\tan(\theta/2) \\ f_x = f_y/a \\ L = z_{near}-z_{far} \\ C = (z_{far}+z_{near})/L \\ D = 2\,z_{far}\,z_{near}/L \end{array} \right.\)
On pourra noter que l'application de cette matrice (en coordonnées homogènes) à un point \((x,y,z,1)\) permet:

Perspective dans le code

La matrice \(P\) utilisée dans le code est accessible en appelant la fonction suivante: "environment.projection.matrix()".
Il est possible d'afficher cette matrice sur la ligne de commande en écrivant (par exemple dans la fonction initialize())
std::cout << str_pretty(environment.projection.matrix()) << std::endl;
(str_pretty est une fonction de CGP permettant d'exporter une chaine de caractères pour laquelle une matrice sera typiquement affichée ligne par ligne plutôt qu'une suite contigue de valeur)
Dans la bibliothèque, la matrice en tant que telle n'est qu'une variable temporaire utilisée pour l'affichage OpenGL. La structure "environment.projection" stocke en fait les paramètres fov, \(z_{near}\), \(z_{far}\), etc, qui permet de générer cette matrice.
Il est possible par exemple de forcer un angle d'ouverture de \(90^{\circ}\) en écrivant dans la fonction initialize():
environment.projection.perspective_data.field_of_view = Pi / 2.0f;
Remarques:
> Créez désormais un slider qui permet de modifier dynamiquement ce paramètre d'angle d'ouverture de caméra entre \([10^{\circ}, 150^{\circ}]\).

Projection orthogonale

La bibliothèque propose également un modèle pré-codé de projection orthogonale. Dans ce cas, le modèle est paramétré par les dimensions dans les 3 directions (left, right, bottom, up, front, back) par rapport au point central. Ajoutez ces lignes à la fin de la fonction initialize() pour obtenir une projection orthogonale.
// Change le type de projection à orthogonale
environment.projection.type = camera_perspective_type::orthographic;

// Autorise une vue en profondeur pour des objets 
//   situés à une distance comprise entre -10 et +10
environment.projection.orthographic_data.back = -10;
environment.projection.orthographic_data.front = 10;

Remarque: La projection orthogonale représente un modèle indépendamment du point de vue - ce qui explique son utilisation pour des représentations précises de longueurs, utile typiquement lors de la modélisation d'un objet suivant les directions x/y/z sans déformation.
Par contre, il n'y a pas d'effet de perspective (/éloignement) - les objets éloignés sont aussi grands que ceux proches de la caméra, et la caméra ne semble pas se déplacer vers l'avant/arrière: les objets apparaissent/disparaissent directement dans le champ de vision à leur dimension fixe. Cette représentation est généralement non adaptée pour naviguer dans une scène 3D, car non naturelle.

Shaders

Jusqu'à présent, nous avons manipulé des paramètres prévus dans la bibliothèque de code C++, et l'affichage est "pris en charge" par la fonction draw.
Cette fonction draw(mesh_drawable, environment) fait en fait appel à OpenGL, qui est lui-même une interface permettant d'utiliser la carte graphique pour de l'affichage de scène 3D. L'intérêt de faire appel à OpenGL et à la carte graphique directement (plutôt que d'utiliser par exemple une fonction "plot" dans un langage plus simple tel que Python) est l'extrême efficacité et flexibilité de ce qui est affiché.
L'affichage - et plus généralement l'ensemble des calculs et opérations - réalisé par la carte graphique est paramétré par des programmes que l'on appelle des shaders.
Les shaders en OpenGL sont écrits dans un langage appelé le GLSL (OpenGL Shading Language) qui est proche du C++, mais n'en est pas. Il s'agit d'un langage plus simple qui se concentre sur les opérations vecteurs/matrices de dimension 2, 3 et 4.
La librairie CGP est écrite pour suivre en grande partie la syntaxe du code glsl, ce qui signifie que votre code C++ sera très ressemblant au code GLSL des shaders.
Nous allons utiliser principalement deux type de shaders:
Les shaders sont à écrire par le développeur et vont varier en fonction des effets/paramètres souhaités. Cependant les exemples de programmes de ces sessions pratiques sont fournis avec des shaders basiques, qui permettent notamment l'affichage de maillages dans la plupart des cas.
Dans la suite de cette partie, nous allons manipuler un exemple de shader pré-écrit, et nous verrons plus en détail le principe et l'échange de données dans la prochaine séance.

Vertex shader

Le vertex shader utilisé dans le cas présent correspond au fichier shaders/mesh/vert.glsl.
Vous pouvez ouvrir ce fichier avec un éditeur de texte classique (ex. Visual Studio Code) et la plupart des éditeurs proposent des modules de coloration syntaxiques (vous pouvez l'installer dans Visual Studio et Visual Studio Code).
#version 330 core // OpenGL 3.3 shader

// Vertex shader - this code is executed for every vertex of the shape

// Inputs coming from VBOs
layout (location = 0) in vec3 position; 
layout (location = 1) in vec3 normal; 
layout (location = 2) in vec3 color; 
layout (location = 3) in vec2 uv;

...
Sans rentrer dans les détails de ce code, l'objectif de ce vertex shader est de réaliser l'étape de projection des sommets du maillage.
Formellement, la relation implémentée est la suivante:
\(p_{out} = \) projection \(\times\) view \(\times\) model \(\times p\), avec
Le reste des commandes permettent de gérer les dimensions entre vecteurs 3D et espace projectif 4D, ainsi que de gérer les normales, couleurs, et uv qui seront utilisées pour le calcul d'illumination dans le fragment shader. Les variables qualifiées de "uniform" sont des paramètres du shaders qui sont transmis depuis le code C++ juste avant l'affichage d'un objet.
L'un des points important (et potentiellement complexe à appréhender) est que ce programme est exécuté par la carte graphique à chaque nouvelle frame, et séparément pour chaque sommet. Notez par exemple qu'il n'y a pas de boucle sur les sommets dans ce code. Ainsi la variable d'entrée "vec3 position;" est différente pour chaque sommet, mais le code qui est exécuté est le même.
Plus précisément, ce code est exécuté en parallèle sur les sommets et cette parallélisation est gérée automatiquement par OpenGL. Notez que les cartes graphiques récentes possèdent plusieurs milliers de coeurs, et peuvent donc traiter des milliers de sommets en parallèle.
Grâce à cette exécution en parallèle sur la carte graphique, les calculs réalisés dans les shaders sont extrêmement efficaces. D'une manière générale toutes les opérations "couteuses" qui doivent être appliquées individuellement sur chaque sommet et à chaque frame aura avantage à être programmées dans le vertex shader plutôt que dans le code C++.

Transformation affines dans le shader

Il est possible de modifier directement le code des shaders, et ainsi de modifier l'apparence des objets.
Considérons le cas où l'on souhaite appliquer une transformation affine supplémentaire sur les formes.
> Modifiez la ligne suivante du shader "vec4 p = model * vec4(position, 1.0);" par
mat4 M = transpose(
         mat4(2.0, 0.0, 0.0, 0.0, 
              0.0, 1.0, 0.0, 0.0,
              0.0, 0.0, 1.0, 0.0,
              0.0, 0.0, 0.0, 1.0));

vec4 p = M * model * vec4(position, 1.0);
assets/shader_modif.jpg
Nous venons d'appliquer un scaling suivant l'axe x qui a allongé toutes les formes suivant cet axe.
Remarques:
> Appliquez des scaling suivant d'autres axes (ou des "shearing"/cisaillements). Il est également possible de définir des rotations.
> Retrouvez le principe des transformations affines vues en cours pour appliquer des translations.
> Que se passe-t-il si vous modifiez la toute dernière composante de la matrice en bas à droite de 1.0 à 2.0 ?. Expliquez pourquoi.
Remarques:

Fragment shader

Le fragment shader utilisé dans le cas présent correspond au fichier shaders/mesh/frag.glsl.
Ce code est exécuté après projection des sommets (et après "rasterization" des triangles) sur chaque pixel/fragment visible d'un triangle.
L'objectif général d'un fragment shader est de considérer en entrée les valeurs interpolées sur les triangles à l'endroit du fragment courant (coordonnées, normale, couleur, textures) et de définir en sortie (variable FragColor) la couleur résultante à afficher.
Dans le cas présent, ce code implémente une illumination de Phong avec trois composantes: ambiante, diffuse, et spéculaire qui vous sera détaillée plus tard.
> Modifiez la couleur de sortie des fragments en testant l'effet des lignes suivantes:
FragColor = vec4(1,0,0,0);
FragColor = abs(vec4(cos(fragment.position.x),0,0,0));
FragColor = abs(vec4(N.x,N.y,N.z,0));
// rem. N represente la normale de la surface (dont la norme est 1).
FragColor = 0.8*vec4(color_shading, alpha * color_image_texture.a) 
           + 0.2*abs(vec4(cos(10*fragment.position.z),0,0,0));
if(cos(25*fragment.position.z)<-0.5f) {
    discard;
    // discard signifie l'arrêt du fragment shader
    //  aucune couleur n'est affichée après discard
    //  le pixel correspondant sera donc transparent.
}
Remarque: Il s'agit d'exemples "de démonstrations" pour l'instant, mais notez d'une manière générale que la modification du fragment shader permet d'obtenir un contrôle très précis (calcul réalisé sur chaque pixel) qui serait complexe (ou très couteux) de réaliser hors d'un shader.
assets/shader_discard.jpg