Langage C++ et OpenGL
Contrairement à la 1ère partie, les codes (et résultats graphiques) seront plus simples et n'utiliserons pas la bibliothèque CGP. Cela vous permettra de comprendre la logique et les fonctions sur laquelle se base la bibliothèque (ou tout autre moteur de rendu utilisant la carte graphique).
Notez que le contenu de cette partie est long et dense. Il intéressera principalement ceux souhaitant comprendre plus en détail le fonctionnement précis de l'affichage. Les apartés techniques sont à destination des étudiants aillant déjà des connaissances préalables en C++/OpenGL.
Si vous débutez, n'hésitez pas à passer rapidement sur les points informatifs et visez la réalisation des exercices qui sont assez simples. Il sera toujours possible de revenir sur les notions détaillées plus tard lorsque vous y serez confrontés. Le plus important est que vous compreniez la logique générale.
Tour d'horizon du langage C++
\(\Rightarrow\) Lien annexe de généralités sur le langage C++Principes fondamentaux
Programme minimaliste
L'un des codes les plus simples en C++ est le suivant#include <iostream> int main() { std::cout << "Hello world" << std::endl; return 0; }
- - Vous pouvez trouver ce code et les scripts de compilations dans le répertoire
-
- scenes/inf443/02_opengl_cpp/01_basic_cpp/
- - Le fichier source du code C++ est src/main.cpp
- #include <iostream>
-
- - #include permet d'inclure le code d'un autre fichier. Il permet le mécanisme d'inclusion de bibliothèque externe en C++.
- - #include <FileName> permet l'inclusion du fichier nommé `FileName`. Les chevrons indiquent que ce fichier doit être préférentiellement cherché dans les fichiers système (ce qui est le cas pour iostream). Le mécanisme d'inclusion est très minimaliste car le contenu du fichier est littéralement copié/collé lors de la compilation.
- - iostream correspond à la bibliothèque standard C++ d'entrée sortie (iostream = Input Output Stream). Elle permet en particulier l'affichage sur la ligne de commande.
- int main()
-
- Correspond au point d'entrée, ou "de départ", de l'exécutable, c'est-à-dire la fonction qui va être appelée automatiquement lorsque vous lancez l'exécutable.
- Tout programme C++ doit nécessairement contenir une (et une seule) fonction appelée main.
- std::cout << "Hello World" << std::endl;
-
- std :: cout << syntaxe standard pour afficher du texte sur la ligne de commande (ici "Hello World")
-
- - cout signifie Common Output. (réfère par défaut à la ligne de commande sauf si l'on a redirigé la sortie standard)
- - std::cout est le nom complet de l'objet cout qui est présent dans l'espace de nommage (namespace) std (standard library).
- - :: est appelé l'opérateur de résolution de portée (scope resolution operator). Ici il permet de trouver l'objet cout dans l'espace de nom std
- - << est un opérateur C++ (c-a-d un symbole utilisé en tant que fonction). Ici l'opérateur << est utilisé pour envoyer la chaine de caractère (string) à l'objet std::cout afin de l'afficher en ligne de commande.
- <<std::endl Ajout un saut de ligne à la fin de l'affichage. endl réfère à end of line.
- return 0; Valeur de retour de l'exécutable (renvoi au processus appelant - par exemple la ligne de commande). Par convention une valeur de retour de 0 indique que le programme s'est terminé sans erreurs.
Remarques générales:
- - Chaque instruction de code C++ doit nécessairement terminer par un point virgule ;
- - Contrairement à Python, mais similairement à Java, l'idendation et les sauts de lignes n'ont pas d'importance sur l'exécution du code.
Outils de compilation
La compilation du code C++ nécessite d'appeler un compilateur (qui dépend de votre système d'exploitation) sur chaque fichier source de votre programme C++ (les fichiers d'extensions .cpp). Pour éviter d'avoir à lister manuellement l'ensemble des fichiers, nous utilisons la chaine de compilation suivante:- - CMake est utilisé (avec le fichier CMakelists.txt) pour générer un projet listant l'ensemble des fichiers sources et de leur dépendance.
-
- - Makefile sous Linux/MacOS
- - Projet Visual Studio (.sln) sous Windows
- - La compilation est ensuite réalisé de manière transparente par votre IDE (Visual Studio/QtCreator), ou en appelant make en ligne de commande.
Types fondamentaux C++
Vous utiliserez principalement deux types fondamentaux dans vos codes:- int: qui correspond à un nombre entier (integer). Sur nos machines, un int est encodé sur 4 octets.
-
- ex. int = 325;
- float: qui correspond à un nombre à virgule flottante dit à "simple précision". Sur nos machines, un float est encodé également sur 4 octets.
-
- ex. float = 3.2f;
- bool: Valeur booléenne qui peut prendre la valeur true/false.
-
- Ce type est introduit en C++ (n'existe pas en C), et permet un code plus expressif que l'utilisation d'un entier.
- double: Un nombre à virgule flottante dit à "double précision". Sur nos machines, un double est encodé sur 8 octets.
-
- ex. double = 3.2;
- Ce type est celui utilisé par défaut (nombre à virgule sans extension), mais nous utiliserons dans notre cas davantage les "float" qui sont compatible avec la carte graphique.
- char: Un caractère dont la correspondance entre valeur/caractère est donnée par la table ASCII.
- ex. "int a;" ne vaudra donc pas nécessairement 0 si on ne le définit pas explicitement avec "int a=0;".
- \(\Rightarrow\) Pour éviter les "comportements indéterminés", il est donc préférable d'initialiser ces valeurs au moment de leur déclaration.
Notions basiques du langage
Déclaration de variables
Les variables doivent se déclarer avec leur typetype nomVariable; // ex. // int maVariable; // float y;
type nomVariable = valeur; // ou similairement: // type nomVariable(valeur); // type nomVariable = type(valeur);
auto a = 5; // a est un int
type const nomVariable = valeur; // ou similairement const type nomVariable = valeur;
int const a = 5; a = 6; // erreur de compilation
Fonctions
Les fonctions suivent la syntaxe suivantetypeRetour nomFonction(type nomArgument1, type nomArgument2, etc.) { ... code de la fonction return value; }
int addition(int a, int b) { return a+b; }
- - Une fonction qui ne renvoie pas de valeur aura pour typeRetour void.
- - Une fonction qui ne prend pas d'argument aura simplement des parenthèses vides.
- - La première ligne décrivant le nom et les types de la fonction est appelée la signature ou l'en-tête de la fonction.
- - Le reste est appelé le corps ou l'implémentation de la fonction.
int addition(int a, int b) { return a+b; } int main() { // OK // La signature (et le corps) // est déclarée avant. int c = addition(5,3); }
int addition(int a, int b); int main() { // OK // La signature et déclarée avant. int c = addition(5,3); } int addition(int a, int b) { return a+b; }
int main() { // KO - ne compile pas, // la fonction "addition" // n'est pas déclarée. int c = addition(5,3); } int addition(int a, int b) { return a+b; }
Instructions conditionnelles
if(condition) { ... } else if(autreCondition) { ... } else { // si aucune condition précédente n'est vérifiée ... }
Boucles
for(initialisation; conditionArret; increment){ ... } // Exemple: for(int k=0; k<5; k++) { std::cout<< k << std::endl; }
Accolades
Les accolades sont nécessaires (à la suite d'un if, for, etc) dès lors qu'il y a plus d'une instruction. Exemple:for(int k=0; k<5; k++){ std::cout<< k << std::endl; } // est identique à for(int k=0; k<5; k++) std::cout<< k << std::endl;
for(int k=0; k<5; k++){ std::cout<< k << std::endl; std::cout<<"Je suis également dans la boucle"<<std::endl; } // n'est pas identique à for(int k=0; k<5; k++) std::cout<< k << std::endl; std::cout<<"Attention, je suis hors de la boucle même si on m'indente"<<std::endl;
Struct et class
Les objets peuvent se déclarer avec les mots clés struct ou class.struct/class nomObject { public: // parametres et méthodes accessible publiquement private: // parametres et méthodes privées de l'objet. };
struct vec3 { float x, y, z; float norm() { return sqrt(x*x+y*y+z*z); } };
class vec3 { public: float x, y, z; float norm() { return sqrt(x*x+y*y+z*z); } };
int main() { // Creation d'une instance de l'objet vec3 p = {5.3f, 1.1f, 3.2f}; // Accès à un membre public p.y = -4.5f; // Appel à une méthode publique float n = p.norm(); }
- - Les paramètres et méthodes d'une struct sont publics par défaut. Alors qu'ils sont privés pour une class. Il faut donc explicitement indiquer "public" pour une class, et pas nécessairement pour une struct. En dehors de cette différence, struct et class sont similaires.
- - Contrairement à Java(/Python), il n'est pas obligatoire de précéder le nom des paramètres par this(/self). pour y accéder dans les méthodes.
Variables
En C++ le contenu des variables est placé par défaut sur la pile (stack memory). Cette mémoire est gérée automatiquement par le système d'exploitation, et est très rapide d'utilisation (les variables sont placées de manière contigues et la mémoire est pré-allouée par le système au lancement du programme).Contrairement à Java, il n'y a donc pas besoin du mot clé "new" lors de la création d'une instance d'une classe.
- Le mot clé "new" existe également en C++ pour allouer manuellement de la mémoire dans une autre zone appelée le tas (heap memory), mais que nous n'utiliserons pas explicitement dans nos programmes
- - Modifier une variable, revient à modifier le contenu d'une zone mémoire (la taille de la zone mémoire est fixe et dépend du type de la variable).
- - Copier une variable avec l'opérateur "=" consiste à copier l'ensemble de la zone mémoire associée à cette variable.
int main() { vec3 a = {1,2,3}; vec3 b = a; // b est une nouvelle variable et donc une nouvelle zone en mémoire // le contenu de b est une copie du contenu de a b.x = 5; // modifier b.x ne modifie pas les valeurs de a }
\(\Rightarrow\) En C++, la copie ou l'affectation (avec l'utilisation de l'opérateur "=", ou lors d'un passage de paramètre) réalise par défaut un "clone" du contenu des objets qui est dans la mémoire de la pile.
\(\Rightarrow\) Toute variable C++ est par défaut "mutable": on peut modifier le contenu de la mémoire. La durée de vie des variables standard ("scope") est celle du bloc d'instruction dans laquelle elle est créée ("bloc scope") - c-a-d depuis sa déclaration jusqu'à l'accolade fermante "}" du bloc où elle a été créée.
int main() { if(someCondition) { int x = 5; // x est défini dans le bloc "if" ... // x existe jusqu'à la fin du bloc } // x n'existe plus ici }
int main() { int x = 5; // x est défini dans le bloc // de la fonction main() if(someCondition) { ... // x peut être utilisé ici (sous-bloc) } // x existe toujours jusqu'à la fin de main() }
- - Ce comportement est différent de celui de Python qui définit la durée de vie jusqu'à la fin de la fonction.
- - On ne peut pas définir plusieurs variables ayant le même nom dans un même bloc (il est possible de le faire dans des sous-blocs, mais c'est à éviter).
- - Bonne pratique: dans le cas général, préférez déclarer vos variables dans le bloc de plus courte durée de vie - cela améliore la lisibilité de votre code.
Adresses et pointeurs
Comme expliqué précédemment, une variable en C++ définie un emplacement en mémoire, et le type indique la taille en octets réservé à la suite de cet emplacement (ainsi que la manière de traiter les opérations qui s'y appliquent).Cet "emplacement" est désigné par le terme "d'adresse" mémoire. Chaque variable possède donc une adresse qu'il est possible d'obtenir à l'aide de la syntaxe "&variable". Il est possible d'utiliser cette adresse comme un moyen indirect de désigner et de modifier une valeur en mémoire à l'aide de ce que l'on appelle un pointeur.
- Un pointeur est une variable qui va stocker une adresse et indiquer le type associé à cette adresse.
-
- - Le type d'un pointeur s'écrit "typePointé* nomVariable".
- Le contenu de la mémoire désigné par l'adresse peut être accédé/modifié en "déréférençant" le pointeur.
-
- - Le "déréférencement" s'écrit "*nomVariable"
Références
Principe
Comme vu précédemment les pointeurs (qui existent en C) permettent de manipuler des variables par l'indirection de leur adresse. L'utilisation des pointeurs peut cependant devenir rapidement complexe: il faut les initialiser correctement à une adresse valide, et il faut les utiliser par déréférencement avec la syntaxe *nomPointeur. Pour simplifier le cas d'utilisation classique des pointeurs pour le passage d'argument, sans la surcharge syntaxique des adresses/déréférencement, le langage C++ introduit la notion de référence. Une référence est une variable qui, lors de sa déclaration, est un "alias" vers une autre variable.Une référence s'initialise avec la syntaxe
type& nomReference = variableExistante;
int a = 5; int& ref_a = a; // désormais ref_a et a désignent strictement la même zone mémoire ref_a = 8; // a et ref_a valent désormais 8 a = 12; // a et ref_a valent désormais 12
void addOne(int& x){ // x est une référence/un alias vers la variable passée en paramètre x = x + 1; // syntaxe légère, il n'y a pas besoin de la syntaxe *x du pointeur } int main() { int a = 5; // on passe l'argument sans syntaxe particulière addOne(a); // le contenu de la variable a est modifié // a vaut désormais 6 std::cout<< a <<std::endl; }
- - Ne pas confondre la syntaxe d'une référence et d'une adresse - les deux utilisent le symbole &, mais dans des contextes différents.
// Lors d'une déclaration "type& nomVariable" - il s'agit d'une référence int& a = b; // a est une référence sur b // Lors d'une affectation "nomPointeur = &nomVariable" - il s'agit d'une adresse p = &b; // p est un pointeur qui recoit l'adresse de la zone mémoire associé à b
- - Une référence doit forcément être initialisée sur une variable lors de sa déclaration.
int& a; // ce code ne compile pas, il faut initialiser la référence a
- - Il n'est pas possible de changer la référence sur une autre variable après la déclaration.
int a = 5; int b = 6; int& c = a; //c est une référence sur a (déclaration) c = -1; // désormais a et c valent -1 c = b; // désormais a et c valent 6 // mais attention: c reste une référence sur a (défini à la déclaration) // ici c (et donc a) recoivent la copie du contenu de b c = -1; // a et c valent -1, et b vaut toujours 6
- En interne, le compilateur implémente la notion de référence (très généralement) par l'utilisation d'un pointeur. La référence permet surtout de simplifier l'écriture et la lecture du code pour le programmeur (syntactic sugar) et ajoute des contraintes qui évitent certains bugs.
Passage d'arguments de grande taille
Nous avons vu l'utilisation des références lors de la modification d'une variable dans une fonction. Une autre utilisation classique des références concerne le passage d'argument de grande taille à une fonction, sans nécessairement nécessiter sa modification. Considérons un type fictif "vec1000" qui est un vecteur contenant 1000 valeurs flottantes. Et une fonction "norm" prenant en paramètre un vec1000 pour calculer sa norme. Il est possible de définir cette fonction par le code suivant.float norm(vec1000 v) { for(int k=0; k<1000; ++k) n += v[k]*v[k]; return sqrt(n); }
- - Le passage d'argument est alors rapide
- - Le compilateur vérifie automatiquement que le vecteur de 1000 valeurs n'est pas modifié dans la fonction.
float norm(vec1000 const& v) // référence constante: rapide et sécurisé { // le corps de la fonction est strictement identique for(int k=0; k<1000; ++k) n += v[k]*v[k]; return sqrt(n); }
Bonnes pratiques du passage d'argument
D'une manière générale, il est conseillé de suivre la démarche suivante:- - Les variables de faibles tailles non modifiées dans une fonction sont passées par copies
type nomVariable
- - Les variables de grande taille non modifiées dans une fonction sont passées par référence constantes
type const& nomVariable
- - Les variables modifiées dans une fonction sont passées par références
type& nomVariable
-
- (ou pointeur si on préfère expliciter le fait que la variable est modifiée.)
Premier programme OpenGL
Création d'une fenêtre
Le code présent dans le répertoire scenes/inf443/02_opengl_cpp/02_opengl_init/ présente le cas minimaliste de la création d'une fenêtre et de l'initialisation d'OpenGL.- Une fois compilé et exécuté, le programme doit afficher une fenêtre (vide) de couleur orangée.
- - GLFW - gestion d'une fenêtre (et gestion des évènements clavier/souris).
- - GLAD - initialisation des fonctions d'OpenGL.
L'étape de création d'une fenêtre est, dans une très large part, indépendante de l'approche mis en place pour réaliser le rendu graphique. Cependant, la fenêtre doit être capable d'interagir et de synchroniser avec l'API graphique utilisé (dans notre cas, il s'agira d'un contexte OpenGL).
La création d'une fenêtre sans bibliothèque externe est une opération complexe qui est spécifique à chaque système d'exploitation. Dans la plupart des cas, une bibliothèque dédiée à la gestion d'une fenêtre est utilisée pour gérer cette étape.
Dans notre cas, nous utilisons la bibliothèque GLFW.
GLFW est une bibliothèque C légère capable de créer des fenêtres compatibles avec un contexte OpenGL. GLFW permet également de gérer des évènements utilisateur tels que les clics/déplacement souris, ou l'appui sur les touches du clavier.
- - Sous Linux/MacOS, GLFW est installé de manière globale à partir d'un gestionnaire de packages.
- - Sous Windows, les fichiers de la bibliothèque sont fournis localement avec les codes. Il sont compatibles avec la dernière version de visual studio.
GLFWwindow* create_window_using_glfw() { ... glfwInit(); GLFWwindow* window = glfwCreateWindow(width, height, "GLFW Window", nullptr, nullptr); glfwMakeContextCurrent(window); ... }
-
glfwInit(); est l'appel à une fonction de la bibliothèque GLFW permettant d'initialiser celle-ci.
- glfwCreateWindow(...) est une fonction qui permet d'initialiser une fenêtre graphique. Cette fonction renvoie une variable de type GLFWwindow*
- GLFWwindow* est un pointeur sur une classe définie dans la bibliothèque GLFW. Son contenu peut être complexe, mais il n'y a pas besoin d'en avoir connaissance pour utiliser la fenêtre graphique. Il s'agit d'une variable qui est passée aux fonctions de GLFW.
while (!glfwWindowShouldClose(window)) // loop as long as the window is not closed { // Set here the color used to clear the window at each frame glClearColor(0.8f, 0.6f, 0.5f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); glfwSwapBuffers(window); glfwPollEvents(); }
- glClearColor(R, G, B, A);
-
- Il s'agit d'une fonction OpenGL qui indique la couleur à utiliser lors des futurs appels à un "Clear"
- glClear(GL_COLOR_BUFFER_BIT);
-
- Fonction OpenGL qui réécrit sur le buffer de couleur (l'image à l'écran) avec la couleur paramétrée dans glClearColor.
- rem. glClearColor ne fait que paramétrer une couleur, c'est glClear qui "réécrit" une couleur sur la fenêtre.
- glfwSwapBuffers(window);
-
- Fonction de GLFW qui réalise l'échange de buffer pour permettre un double buffering
-
- Le double buffering consiste à stocker 2 buffers (/images de la scène). Pendant que l'une est modifiée, l'autre est affichée à l'écran. Cela permet d'éviter des clignotements de l'image lors des affichages.
- glfwPollEvents;
-
- Fonction de GLFW qui traite la pile d'évènements qui ont pu être reçus (touche clavier, clic/déplacement souris). (pour l'instant ce code n'utilise pas les évènements).
int counter = 0; while( !glfwWindowShouldClose(window) ) { counter = (counter+1)%100; float u = counter/99.0f; // Set the (R,G,B,A) color to clear the screen glClearColor(0.5+std::cos(2*3.14f*u)/2.0f, 1.0f, 0.5f, 1.0f); // Clear the screen (designated by the color buffer) glClear(GL_COLOR_BUFFER_BIT); glfwSwapBuffers(window); glfwPollEvents(); }
- > Essayez de comprendre le code
-
- - Quelles valeurs prend la variable counter ?
- - Quelles valeurs prend la variable u ?
- - Quelles valeurs prend le premier paramètre (Red) de la fonction glClearColor ?
Notez que vous pouvez tester avec d'autres fonctions pour obtenir d'autres effets de changement de couleur.
Notez la syntaxe explicite counter/99.0f et non pas counter/99
- - Cette syntaxe permet d'expliciter le fait que le calcul doit être réalisé sur des nombres flottants (l'un des paramètres est un nombre flottant)
- - Dans le cas contraire: counter/99 réaliserait un calcul sur des nombres entiers. Le résultat correspondant serait donc égal à 0 (la plupart du temps) ou 1.
OpenGL et conventions
OpenGL est un ensemble de fonctions standardisées (appelé une API) qui permet de mettre en oeuvre de manière très efficace et générique le "pipe-line de rendu graphique". Plus précisément, OpenGL consiste en un ensemble de fonctionnalités permettant de communiquer directement avec la carte graphique (GPU - Graphics Processing Unit), offrant ainsi une approche optimale pour afficher des données 3D en temps-réel. Rem. OpenGL n'est pas une "bibliothèque de code", il s'agit uniquement d'un standard d'appels de fonctions et de types qui sont décrits à l'aide du langage C. L'implémentation effective de ces fonctions dépend de votre système, de votre carte graphique, et du driver que vous avez installés. Les appels OpenGL sont dits de "bas niveau": Ils permettent une communication directe avec votre carte graphique très efficace, mais nécessitent un travail conséquent de mise en place. Rem. OpenGL utilise les conventions suivantes de nommage:- - Les fonctions OpenGL sont dénommés glFunctionName()
- - Les valeurs constantes prédéfinies par OpenGL sont des mots en majuscules, séparées par underscore (ex. GL_COLOR_BUFFER_BIT).
-
- Ces "constantes" sont stockées en interne par des nombres entiers, et leurs noms sont remplacés au moment de la compilation par leur valeur numérique.
- - Les types OpenGL GLtype: GLint, GLfloat, GLenum, etc.
-
- Ces types sont utilisés pour avoir la garantie d'un nombre d'octets spécifique en mémoire lors de leur utilisation. Techniquement, C++ ne spécifie pas la taille mémoire des types standards int/float/etc. Cependant dans la grande majorité des cas de nos systèmes, GLint/int et GLfloat/float seront identiques.
- - Certaines fonctions ont un comportement similaire mais prennent différents types d'arguments. Dans ce cas, la fin du nom de la fonction désigne le nombre et/ou type attendu.
-
- Exemple des arguments attendus dans le cas de la fonction glUniform
-
-
- glUniform1f: 1 flottant
- glUniform2f: 2 flottants
- glUniform2i: 2 entiers
- glUniform1fv: 1 flottant passé par adresse
- glUniform3fv: 3 flottants passés par adresse
- glUniformMatrix3fv: 9 flottants -représentant une matrice 3x3- passés par adresse
-
Affichage d'un triangle
La seule forme 3D qu'une carte graphique est en mesure d'afficher par le "pipe-line graphique natif" est le triangle. Toutes les formes 3D visibles dans les scènes affichées par les cartes graphiques sont donc généralement formées d'assemblages de triangles.
Nous allons voir dans cette partie comment afficher un premier triangle à l'aide d'OpenGL. Les parties suivantes consisterons à en afficher plusieurs, et associer ceux-ci à différentes propriétés (couleurs, textures, transformation, etc.) pour visualiser des objets d'apparence plus complexe.
Le code correspondant à l'affichage d'objets en OpenGL peut généralement être catégorisé en trois étapes
- Étape d'initialisation, formée par
-
- Mise en place des shaders (1)
- Envoie des données sur la carte graphique (2)
- Demande d'affichage des données (3)
Les deux premières étapes d'initialisations ne sont exécutées qu'une seule fois. (1) et (2) peuvent être réalisés dans un ordre arbitraire. L'étape (3) quant à elle correspond à la mise en oeuvre du pipe-line graphique (voir cours) et nécessite que les deux étapes précédentes aient été traitées préalablement.
On considère désormais le code scenes/inf443/02_opengl_cpp/03_triangle/- > Un triangle rouge sur fond noir devrait apparaitre une fois exécuté.
(1) Mise en place des shaders
\(\Rightarrow\) Détails sur ce qu'est un shader L'exemple du triangle utilise les vertex et fragment shaders suivants (fichiers shaders/triangle.vert.glsl et shaders/triangle.frag.glsl) Vertex Shader:#version 330 core // OpenGL 3.3 // Only one argument here: the vertex position layout (location = 0) in vec3 position; void main() { // Output position (in normalized device coordinates) is directly the position gl_Position = vec4(position, 1.0); }
-
- #version 330 core indique l'utilisation de GLSL 3.30 (en accord avec la version d'OpenGL)
-
- layout (location = 0) in vec4 position indique que le premier paramètre associé à un vertex (position indicée par 0) est reçu dans une variable nommée position. Cette variable est un vecteur \((x,y,z)\). Dans ce cas, il n'y a qu'un seul paramètre par sommet.
-
- Une fois que les variables globales ont été déclarées (ici position) le code GLSL définit une fonction main avec le code à exécuter.
- - L'un des objectifs du vertex shader est de compléter la variable globale de sortie gl_Position. Cette variable contient les coordonnées dans l'espace des clip coordinates utilisés par OpenGL pour afficher la primitive dans la fenêtre. Le type attendu est un vecteur à 4 composantes \((x,y,z,w)\) de l'espace homogène.
#version 330 core // OpenGL 3.3 // The output variable of the fragment is the color (R,G,B,A) out vec4 FragColor; void main() { FragColor = vec4(1.0, 0.0, 0.0, 1.0); }
- Dans ce cas, la variable globale de sortie est FragColor (vecteur 4D) correspond à la couleur (r,g,b,a) du fragment courant. Ici, le vecteur (1,0,0,0) indique une couleur rouge uniforme.
-
- La dernière composante a n'ayant pas d'influence dans cet exemple.
La fonction opengl_load_shader dont le code est décrit dans le fichier helper_opengl.cpp vous pré-code la lecture des fichiers et la compilation de leur contenu en tant que shader program. Cette étape est une suite d'appels de fonctions OpenGL, et de vérifications d'erreurs. Sauf cas particuliers, vous n'aurez pas à modifier/coder vous même ces étapes. Les fonctions associées (et leurs documentations) sont disponibles ici:
(2) Envoie de données sur la carte graphique
Notion de VBO et VAO
Les données d'entrées des shaders, typiquement liées à la définition des maillages tels que les coordonnées des sommets et leurs attributs, doivent être envoyées sur la mémoire de la carte graphique afin de pouvoir être utilisées par les shaders lors de leur exécution pendant l'étape d'affichage.
Ce transfert, de la mémoire RAM vers la mémoire du GPU, est une opération pouvant se révéler couteuse. Ce transfert est donc réalisé, dans la mesure du possible, une unique fois lors de l'étape d'initialisation (et non pas à chaque appel d'affichage dans la boucle d'animation).
Les données sur la carte graphique sont gérées en tant que buffers, c'est à dire des blocs de données contigus (similaire à des tableaux). Ces buffers sont appelés VBO - Vertex Buffer Object. Les VBO sont similaires à des pointeurs C: la valeur du VBO est un identifiant d'un buffer sur le GPU. Les données désignées par un VBO peuvent contenir les coordonnées des sommets, mais aussi leurs attributs tels que les couleurs, coordonnées de textures, etc. Plus généralement, les buffers contiennent des données interprétées comme étant par sommets, et peuvent être reçus comme variable d'entrée dans le vertex shader.
Les fonctions OpenGL permettent d'initialiser ces buffers à partir des pointeurs (compatible C) depuis le code.
GLSL réalise ces calculs en flottants simples précisions. Pour éviter des conversions supplémentaires, il convient de manipuler les données du code C++ également en simple précision (corresponds aux float et non pas double). OpenGL définit le type GLfloat pour assurer la cohérence d'encodage pour toutes les architectures. Au final, manipuler des buffers compatibles OpenGL en C++ reviendra à stocker des données en tant que std::vector<float> .
Les données stockées dans un VBO sont des valeurs génériques, elles peuvent donc désigner différents types d'attributs. Il est donc nécessaire de définir explicitement quelles données du buffer correspondent à quelles variables des shaders. Cette relation est réalisée par un VAO - Vertex Array Object. Le VAO stocke les relations entre l'organisation mémoire dans le buffer et les variables utilisés dans le shader (dont l'index est indiqué par le layout).
- VBO = Le buffer de données brutes
- VAO = L'organisation de ces données
- 1. Mise en place des données dans le code C++ en tant que tableau contigu de valeurs.
- 2. Envoie des données sur le GPU et enregistrement de l'identifiant associé: VBO.
- 3. Paramétrage de la relation entre l'organisation mémoire des données dans le VBO et les variables d'entrées du vertex shader en utilisant un VAO.
Code
Ces trois étapes sont illustrées dans le cas du triangle simple par le code ci-après
Création de données contigues en mémoire RAM
// 2.1 Setup contiguous array of floating point value // ******************************************* // // Here the coordinates of the vertices position const std::vector<GLfloat> position = { -0.5f, -0.5f, 0.0f, 0.5f, -0.5f, 0.0f, 0.0f, 0.5f, 0.0f };
std::vector est un conteneur de la bibliothèque standard C++.
- Il s'agit d'un buffer/tableau de données dont le stockage est contigue en mémoire.
- Les données du std::vector sont allouées sur le tas. Ce nombre de données contenu peut être grand (uniquement limité par la RAM).
- Le paramètre entre chevrons < > décrit le paramètre contenu dans le vector (ici des flottants).
- La capacité du std::vector peut être redimensionnée dynamiquement (.resize(), et .push_back()), mais cela doit être réalisé explicitement par le programmeur.
Envoie des données sur le GPU
// 2.2 Create VBO - Send data to GPU // ******************************************* // GLuint vbo = 0; // Create an empty VBO identifiant glGenBuffers(1, &vbo); // Activate the VBO designated by the variable "vbo" glBindBuffer(GL_ARRAY_BUFFER, vbo); // Send data to GPU: Fill the currently designated VBO with the buffer of data passed as parameter glBufferData(GL_ARRAY_BUFFER, position.size() * sizeof(GLfloat), &position[0], GL_STATIC_DRAW); // Good practice to set the current VBO to 0 (=disable VBO) after its use glBindBuffer(GL_ARRAY_BUFFER, 0);
glBufferData(GL_ARRAY_BUFFER, position.size()*sizeof(GLfloat), &position[0], GL_STATIC_DRAW );
- glBufferData(GLenum target, GLsizeiptr size, const GLvoid* data, GLenum usage) est la fonction réalisant le transfert de données
-
- - target: Placé à la constante GL_ARRAY_BUFFER pour envoyer des attributs de sommets
- - size: Taille en octets du buffer. Dans ce cas, il est calculé comme étant le nombre d'éléments de position multipliée part la taille d'un élément (ici GLfloat)
- - data: Adresse du premier élément du buffer. La syntaxe `&vector[0]` permets d'accéder à l'adresse du premier élément du std::vector.
- - usage: Parametre permettant d'optimiser l'organisation mémoire sur le GPU en fonction de la modification ultérieure ou non des données. _GL_STATIC_DRAW_ indique que les données ne seront pas modifiées par la suite.
- Cette opération est potentiellement couteuse pour un grand nombre de sommets. On préfèrera l'utiliser lors de l'initialisation des données, et éviter de l'utiliser dans la boucle d'affichage.
- - glGenBuffers: Indique à la carte graphique/driver la demande de création d'un buffer. L'appel à cette fonction complète la variable (ici vbo passé en adresse) par un indice entier différent de 0.
- - glBindBuffer: Indique à la carte graphique/driver l'indice du buffer à prendre en compte lors des prochaines instructions (ici vbo).
Paramétrage du VAO
Dans le cas présent, l'organisation mémoire des données sur le VBO en mémoire du GPU est simple, il n'y a qu'un seul paramètre (positions \((x,y,z)\)) donné de manière contigue.// 2.3 Create VAO - Relation between VBO organization and input variables of shaders // ******************************************* // // In this case, we have only one VBO containing triplets of floats, and used as the "layout 0" in the shader GLuint vao = 0; // Create an empty VAO identifiant glGenVertexArrays(1, &vao); // Activate the VAO designated by the variable "vao" glBindVertexArray(vao); // Indicate the VBO to parameterize glBindBuffer(GL_ARRAY_BUFFER, vbo); // Activate the use of the variable at index layout=0 in the shader glEnableVertexAttribArray(0); // Define the memory model of the current VBO: here contiguous triplet of floating values (x y z) at index layout=0 in the shader glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, nullptr); // As a good practice, disable VBO and VAO after their use glBindBuffer(GL_ARRAY_BUFFER, 0); glBindVertexArray(0);
- - Comme précédemment, les fonctions glGenVertexArrays, glBindVertexArray, et glBindBuffer permettent de respectivement définir un nouvel indice (glGen...), et d'indiquer les indices à utiliser lors des prochains appels (glBind...).
glVertexAttribPointer( 0, 3, GL_FLOAT, GL_FALSE, 0, nullptr );
- glVertexAttribPointer(GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride, const GLvoid* pointer)
-
- - index: Indique l'indice de la variable correspondance dans le shader. Ici il s'agit de la variable position au layout=0.
- - size: Nombre de composantes à lire depuis le buffer pour chaque sommet. Ici 3 composantes (x, y, z).
- - type: Enum indiquant le type de variable du buffer, ici le type GLfloat est indiqué par la constante GL_FLOAT (voir OpenGL types conventions).
- - normalized: Indique si la valeur vectorielle doit être normalisée à une norme unitaire avant d'être utilisée dans le shader.
- - stride: Indique l'espace entre deux valeurs consécutives dans le buffer. Stride est utilisé lorsque des valeurs sont intercalées entre plusieurs attributs (attributs entrelacés). Dans le cas où les valeurs sont consécutives dans le buffer, stride peut-être conventionnellement placé à 0.
- - pointer: Offset indiquant l'indice du premier élément à lire dans le buffer. Dans notre cas, l'offset est de 0. Par compatibilité avec les anciennes versions d'OpenGL, cet offset est indiqué sous forme d'une adresse, la valeur 0 correspond donc à nullptr.
Remarques générales sur l'utilisation des fonctions OpenGL
On peut noter la démarche générale d'OpenGL travaillant à l'aide d'identifiants (/handler) que l'on rencontrera dans l'ensemble des programmes.
-
Premièrement, un identifiant est créé à l'aide d'une fonction nommée glGenXXX. Cette fonction ne fait que générer un identifiant (pas de transfert de données, etc).
-
Avant de réaliser des opérations associées à cet identifiant, celui-ci doit être activé (/"bindé") - c'est-à-dire que l'on indique que les appels suivants feront référence à cet identifiant par la suite. Pour cela, on retrouve l'appel à des fonctions nommées glBindXXX. Les fonctions glBind ne font que placer l'identifiant à un état actif, encore une fois il n'y a pas d'opérations spécifiques. Un identifiant reste actif tant qu'un autre n'est pas activé à sa place.
-
Une fois activées, les opérations peuvent être réalisées par rapport à ce buffer.
- Une fois réalisées, on considère comme bonne pratique de désactiver les identifiants en réalisant un bind sur l'identifiant 0 (glBindXXX(0)). Cela permet de détecter plus facilement certains bugs (oubli d'activation d'identifiant qui aurait alors utilisé l'ancien).
(3) Affichage des données
Dans la boucle d'animation, afficher des données revient principalement à indiquer le shader à utiliser, activer le VAO, et demander l'affichage. Il n'y a alors plus d'opérations complexes à réaliser sur le CPU, l'ensemble des traitements étant portés par le GPU.
Le code correspondant à l'affichage du triangle est le suivant
// ************************************************* // // 3 - Displaying Data // // ************************************************* // glUseProgram(shader); // Activate shader program glBindVertexArray(vao); // Activate attributes for the drawing glDrawArrays(GL_TRIANGLES, 0, 3); // Draw 3 vertices glBindVertexArray(0); glUseProgram(0);
- - mode: Indique le type de primitive à afficher: ici des triangles par GL_TRIANGLES.
- - first: Le premier indice de sommet devant être affiché (ici l'indice 0).
- - count: Nombre de sommets devant être affichés (ici 3).
Notez que la demande d'affichage est très courte et simple. Le paramétrage du contenu et de la méthode d'affichage ayant été fait au préalable, et stocké respectivement dans le VBO, VAO et shaders.
Exercices
- > Modifiez dans le code les valeurs des composantes \((x,y)\) du triangle. Essayez de retrouver le système de coordonnées utilisé par OpenGL. Plus précisément, où les points suivants sont-ils placés sur l'écran (gardez une fenêtre carrée) ?
-
- \((-1,-1)\)
- \((-1,1)\)
- \((1,1)\)
- \((1,-1)\)
- \((0,0)\)
Note: Aucune projection n'est appliquée dans le vertex shader, ainsi les coordonnées 3D du triangle correspondent directement aux Normalized Device Coordinates/Clip Coordinates utilisées par OpenGL, et dont les coordonnées \((x,y)\) (une fois normalisées) correspondent aux coordonnées de l'espace image.
- > Modifiez la composante \(z\) des coordonnées du triangle. Différenciez deux cas
-
- \(z\in[-1,1]\)
- \(|z|>1\)
- Vous retrouvez la notion de clip coordinates: seules les coordonnées (en sortie de vertex shader) situées dans le cube normalisé \([-1,1]\) sont affichées, celles situées en dehors ne sont pas traitées.
Modification du shader
> Modifiez le fragment shader pour afficher un triangle uniformément jaune.
> Modifiez le vertex shader pour appliquer une translation et un homothétie ("scaling") sur le triangle.
> En notant que les coordonnées des sommets sont des coordonnées homogènes à 4 dimensions, expliquez la différence visuelle obtenue entre les deux codes suivants dans le vertex shaders
gl_Position = vec4(position, 1.0); gl_Position = gl_Position * 2.0;
gl_Position = vec4(position, 1.0); gl_Position.xyz = gl_Position.xyz * 2.0;
Second triangle
Considérez que la variable position dans le code C++ est désormais initialisée de la manière suivante. (On considèrera que les 3 premières coordonnées correspondent à un triangle, et les 3 suivantes, à un autre triangle.)const std::vector<GLfloat> position = { -0.5f, -0.5f, 0.0f, 0.5f, -0.5f, 0.0f, 0.0f, 0.5f, 0.0f, 0.9f, 0.9f, 0.0f, 0.6f, 0.9f, 0.0f, 0.6f, 0.6f, 0.0f };
Variables Uniforms
Dans cette partie, nous allons voir comment envoyer des paramètres à la carte graphique par le biais de variables appelées uniforms dans les shaders. Uniform: Un paramètre "uniform" est une variable d'un shader qui va avoir la même valeur pour l'ensemble des sommets et fragments d'un même appel d'affichage. La valeur du paramètre uniform est passée du programme vers le shader avant l'affichage de l'objet. Cette valeur peut être modifiée d'un affichage à l'autre.Exemple d'utilisation
Principe
Considérons le cas d'un objet que l'on souhaite animer au cours du temps. Pour cela, il est nécessaire d'utiliser des coordonnées différentes à chaque frame de l'animation. Considérons une animation simple consistant en un déplacement d'une forme. Une approche naïve pour appliquer cette rotation consisterait à calculer de nouvelles coordonnées dans le programme C++ à chaque boucle d'itération, puis de renvoyer celles-ci sur le GPU. Cette approche est fonctionnelle, mais peut être couteuse en temps de calcul sur des objets de taille importante dû:- 1- Au calcul de la transformation réalisée sur le CPU sur chaque sommet de l'objet à chaque frame
- 2- A l'envoie des nouvelles coordonnées de la mémoire RAM vers la mémoire du GPU (utilisation de la bande passante pour du transfert de données).
Code
Considérez désormais le code scenes/inf443/02_opengl_cpp/04_uniform/Les étapes à réaliser sont les suivantes:
Dans le shader
Le vertex shader contient désormais une variable uniform vec3 translation; Cette variable est utilisée dans le shader pour translater les coordonnées du triangle.#version 330 core layout (location = 0) in vec3 position; // This variable must be set in the C++ code before the draw call uniform vec3 translation; void main() { vec3 p = position + translation; gl_Position = vec4(p, 1.0); }
Dans le code C++
Un vecteur contenant les composantes de la translation est créé dans le code C++. Les valeurs de ce vecteur sont modifiées d'une frame à l'autre dans la boucle d'affichage.float tr[3] = {0.3f*std::sin(t), 0.3f*std::cos(t), 0.0f};
- - Contrairement au GLSL (dans le shader), le C++ ne définit pas par défaut de structure vec3. On utilise dans le cas de ce programme le type "float variable[N]" qui génère un vecteur de N valeurs (souvent appelée "tableau") placées sur la pile. Le nombre N doit être connu au moment de la compilation.
-
- Un type vec3 est défini dans la librairie CGP, mais elle n'est pas utilisée dans ce code d'exemple minimaliste.
-
- Le type "float T[N]" est similaire à l'appel "std::array
T" - à l'exception de la copie (std::array permet de copier les données, alors que float T[N] donne accès au pointeur sur le 1er élément du tableau).
Cette opération se réalise en 2 étapes:
- 1- Récupération d'un indice de position de la variable uniform que l'on vise dans le shader (translation dans notre cas) à l'aide de la fonction glGetUniformLocation.
- 2- Envoie de la valeur de tr sur la variable uniform désignée à l'aide de glUniform(3f).
// Locate the targeted uniform variable in the shader GLint uniform_translation_location = glGetUniformLocation(shader, "translation"); ... // Send the current value to the uniform glUniform3f(uniform_translation_location, tr[0], tr[1], tr[2]);
- - La commande glGetUniformLocation(shaderID, uniformName) renvoie un identifiant entier correspondant à la localisation de la variable uniformName dans le shader.
-
- - Il est nécessaire de connaitre cette "localisation" avant l'utilisation de glUniform.
- - L'indice dépend du shader, mais reste constant pour un shader donné. Il serait également possible d'appeler cette fonction hors de la boucle d'affichage et de ne stocker que l'indice correspondant par la suite.
- - glUniform est une fonction dont le nom dépend des arguments passés en paramètre.
-
- - glUniform3f permet l'envoi de 3 valeurs flottantes passées en argument (ici tr[0], tr[1], tr[2]).
- - Il serait également possible de passer les arguments par adresse avec la commande
-
- glUniform3fv(uniform_translation_location, 1, tr);
- - Le nom de la variable utilisé dans le code C++ et sa correspondance en variable uniform dans le shader sont indépendants. Ils peuvent être le même ou différent (nous aurions pu appeler la variable "tr" translation).
- - glGetUniformLocation peut être appelé n'importe quand avant l'utilisation de glUniform (potentiellement une unique fois à l'initialisation).
- - glUniform doit être appelé:
-
- - Après avoir activé le shader courant: glUseProgram(...)
-
- En effet glUniform n'agit que sur le shader qui est activé.
- - Avant l'appel à la fonction de draw (glDrawArrays(...))
-
- Cette valeur sera celle utilisée dans le shader à chaque appel de draw.
- La valeur uniform n'est pas ré-initialisée automatiquement après l'affichage, ni au début d'une nouvelle frame. Elle gardera sa valeur pour les appels d'affichage qui suivent tant qu'elle n'est pas mise à jour.
Exercice
Remarque préliminaire: Lors d'une animation il est important d'effacer l'image après chaque frame.- > Commentez la ligne glClear(GL_COLOR_BUFFER_BIT); et observez le résultat obtenu.
- > Modifiez le shader et introduisez les variables nécessaires afin de pouvoir paramétrer la couleur du triangle depuis la code C++.
- Aide:
-
- - Vous pouvez ajouter une variable de type vec3 pour désigner une couleur (r,g,b) dans le fragment shader.
-
- Les variables uniforms sont accessibles similairement depuis le vertex et fragment shader.
- - Il faudra rechercher la localisation de votre nouvelle variable uniform de couleur à l'aide de glGetUniformLocation
- - Vous pouvez créer dans votre code C++ une variable stockant 3 flottants décrivant la couleur souhaitée.
- - Puis vous enverrez cette couleur à la carte graphique à l'aide de l'appel glUniform3f().
- > Modifiez le shader et introduisez les variables nécessaires afin d'appliquer une rotation du triangle depuis la code C++.
- Aide:
-
- - Vous pouvez définir dans le code C++ une rotation comme matrice \(3 \times 3\) - se stockant comme un vecteur contigu de 9 composantes.
float rotation[9] = { std::cos(t),-std::sin(t), 0, std::sin(t), std::cos(t), 0, 0, 0, 1, };
-
- - L'envoi d'une variable uniform de type matrice peut se réaliser avec l'appel
glUniformMatrix3fv(uniform_rotation_location, 1, GL_TRUE, &rotation[0]);
-
- - Cette variable correspond alors à un type mat3 en GLSL.
- - Le second argument "1" correspond au nombre de matrices envoyées, ici une seule.
- - Le troisième argument "GL_TRUE" indique que la matrice doit être transposée avant d'être utilisée dans le shader.
-
- GLSL utilise la convention où les matrices sont définies par colonne, alors que celle-ci est définie par ligne.
- - Le dernier argument pourrait être simplement "rotation" à la place de "&rotation[0]" en utilisant le type float [9]. Cependant, la syntaxe &rotation[0] (adresse du 1er élément) sera plus générique lors de l'utilisation d'autres types.
std::vector<GLfloat> const position = { -0.5f, -0.5f, 0.0f, 0.5f, -0.5f, 0.0f, 0.0f, 0.5f, 0.0f, 0.2f, 0.0f, 0.0f, 0.8f, 0.0f, 0.0f, 0.8f, 0.6f, 0.0f };
- - Le shader que vous avez réalisé (paramètre d'une translation, rotation, et couleur) n'a pas besoin d'être modifié.
- - Vous pouvez réaliser deux appels d'affichages d'affilés, chacun affichant un triangle.
- - Chaque appel d'affichage doit être précédé de l'envoi des paramètres uniforms appropriés.
-
- N'oubliez pas que la valeur d'un paramètre uniform est utilisée dans tous les affichages qui en suivent. Si vous souhaitez le modifier, il est nécessaire de le mettre à jour avant chaque affichage.
Attributs des sommets
Cette partie correspond au code du répertoire: scenes/inf443/02_opengl_cpp/05_vertex_attributes/ Rem. Il s'agit principalement d'une description informative optionnelle. Il vous sera possible d'y revenir plus tard si vous souhaitez mieux comprendre le fonctionnement d'openGL. Lors de l'affichage d'un maillage, la seule connaissance des coordonnées des sommets n'est pas suffisante. Il est également nécessaire d'associer à chaque sommet d'autres informations telles que:- - La normale au sommet pour le calcul d'illumination
- - Des coordonnées paramétriques (u,v) pour l'utilisation d'une texture
- - Une couleur pour permettre d'avoir une couleur définie par sommet
- - ou toute autre information associée à chaque sommet du maillage utile pour l'affichage.
Vertex shader
#version 330 core // OpenGL 3.3 // 2 per-vertex attributes: // - position at location 0 // - color at location 1 layout (location = 0) in vec3 position; layout (location = 1) in vec3 color; out vec3 color_fragment; void main() { color_fragment = color; // this variable will be used in the fragment shader // Output position (in normalized device coordinates) is directly the position gl_Position = vec4(position, 1.0); }
- - Une coordonnée (à l'indice/location 0)
- - Une couleur (à l'indice/location 1)
-
- Rem. la "location" est choisie arbitrairement. L'important est d'être cohérent dans les indices entre le shader et le code C++ car elle réalise la correspondance entre les données envoyées et leur utilisation.
La donnée de couleur n'est pas utilisée directement dans le vertex shader, mais doit être "passée" au fragment shader.
Le principe est le suivant:
- - Dans le vertex shader, la variable à "passer" au fragment shader est déclarée avec le mot clé out
- - Puis il sera "reçu" dans le fragment shader sous le même nom en tant que paramètre in.
Fragment shader
Le fragment shader reçoit en tant que in la couleur attribuée au sommet.
-
- Rem. La variable reçue dans le fragment shader par ce biais (in/out) correspond à l'interpolation linéaire des valeurs de ce paramètre issu du vertex shader vis-à-vis des 3 sommets du triangle auquel il appartient.
#version 330 core // OpenGL 3.3 // Input interpolated from the vertex shader in vec3 color_fragment; // The output variable of the fragment is the color (R,G,B,A) out vec4 FragColor; void main() { FragColor = vec4(color_fragment, 1.0); }
Code C++
Il est nécessaire de déclarer les données des coordonnées et des couleurs, et d'envoyer ces informations à la carte graphique sous forme de VBO. Dans cet exemple, nous déclarons 2 buffers qui seront associés à 2 VBO distincts: l'un pour les coordonnées et l'autre pour la couleur. Déclaration des données:
// Here the coordinates of the vertices position std::vector<GLfloat> position = { -0.5f, -0.5f, 0.0f, 0.5f, -0.5f, 0.0f, 0.0f, 0.5f, 0.0f }; // Here the color of the vertices std::vector<GLfloat> color = { 1.0f, 0.0f, 0.0, // vertex 0 - red 0.0f, 1.0f, 0.0, // vertex 1 - green 0.0f, 0.0f, 1.0 // vertex 2 - blue };
// Fill VBO for position GLuint vbo_position = 0; glGenBuffers(1, &vbo_position); glBindBuffer(GL_ARRAY_BUFFER, vbo_position); glBufferData(GL_ARRAY_BUFFER, position.size() * sizeof(GLfloat), &position[0], GL_STATIC_DRAW); glBindBuffer(GL_ARRAY_BUFFER, 0); // Fill VBO for color GLuint vbo_color = 0; glGenBuffers(1, &vbo_color); glBindBuffer(GL_ARRAY_BUFFER, vbo_color); glBufferData(GL_ARRAY_BUFFER, color.size() * sizeof(GLfloat), &color[0], GL_STATIC_DRAW); glBindBuffer(GL_ARRAY_BUFFER, 0);
GLuint vao = 0; glGenVertexArrays(1, &vao); glBindVertexArray(vao); // position at layout 0 glBindBuffer(GL_ARRAY_BUFFER, vbo_position); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, nullptr); // position at layout 1 glBindBuffer(GL_ARRAY_BUFFER, vbo_color); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, nullptr); // As a good practice, disable VBO and VAO after their use glBindBuffer(GL_ARRAY_BUFFER, 0); glBindVertexArray(0);
> Modifiez les couleurs des sommets à partir du code C++ et vérifiez le résultat obtenu.
Structure indexée
Cette partie correspond au code du répertoire: scenes/inf443/02_opengl_cpp/06_indexing/ Considérons le cas où l'on souhaite afficher un quadrangle composé de 4 sommets aux positions respectives \(p_0=(0,0,0)\), \(p_1=(1,0,0)\), \(p_2=(1,1,0)\), \(p_3=(0,1,0)\).Ce quadrangle est composé lui-même de deux triangles \((p_0,p_1,p_2)\) et \((p_0,p_2,p_3)\).
Un maillage est alors représenté par deux structures
- - La géométrie, correspondant à un vecteur de coordonnées contigües en mémoire (std::vector de float). Chaque coordonnée de sommet n'y est mentionnée qu'une seule fois. Les sommets ne suivent pas d'ordre particulier.
- - La connectivité, correspondant à un vecteur d'indices (std::vector de unsigned int). Chaque triplet consécutif indique les trois sommets d'un triangle. Chaque indice indique la position des coordonnées dans le vecteur de géométrie.
Application au quadrangle
Déclaration de la structure géométrie/connectivité// Geometry (vertex position) const std::vector<GLfloat> position = { -0.5f, -0.5f, 0.0f, 0.5f, -0.5f, 0.0f, 0.5f, 0.5f, 0.0f, -0.5f, 0.5f, 0.0f }; // Connectivity (triangle index) const std::vector<GLuint> index = { 0, 1, 2, 0, 2, 3 };
Envoie des données au GPU
Les indices définissant la connectivité doivent être stockés sur la mémoire du GPU en suivant la même procédure que les VBO de valeurs floats. Attention au fait que le type associé au VBO stockant les indices est GL_ELEMENT_ARRAY_BUFFER (et non pas GL_ARRAY_BUFFER).
// Fill VBO for position GLuint vbo_position = 0; glGenBuffers(1, &vbo_position); glBindBuffer(GL_ARRAY_BUFFER, vbo_position); glBufferData(GL_ARRAY_BUFFER, position.size()*sizeof(GLfloat), &position[0], GL_STATIC_DRAW ); glBindBuffer(GL_ARRAY_BUFFER, 0); // Fill VBO for index glGenBuffers(1, &vbo_index); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vbo_index); glBufferData(GL_ELEMENT_ARRAY_BUFFER, index.size()*sizeof(GLuint), &index[0], GL_STATIC_DRAW ); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
Demande d'affichage réalisée à l'aide de la fonction glDrawElements
// Draw call glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vbo_index); glDrawElements(GL_TRIANGLES, 3*2, GL_UNSIGNED_INT, nullptr);
- glDrawElements(GLenum mode, GLsizei count, GLenum type, const GLvoid* indices)
-
- - mode: Indique le type de primitive à afficher. OpenGL est capable d'afficher différents types de primitives. Dans notre cas, GL_TRIANGLES interprète chaque triplet d'indice comme un triangle distinct (manière la plus générique de définir un ensemble de triangles). D'autres interprétations sont des triangles sous la forme STRIP ou FAN pouvant être utilisés afin de limiter l'utilisation mémoire et d'accélérer le rendu sur GPU. Finalement, des segments (appelés line en OpenGL) peuvent également être rendus.
- - count: Nombre total de sommets à être rendu. Dans le cas présent: 6 sommets (3 sommets par triangles \(\times\) 2 triangles)
- - type: Le type de données utilisé pour stocker les indices (unsigned integer ici).
- - indices: L'offset (sous forme de pointeur) indiquant le premier élément à être rendu (ici offset à 0).
- > Ajoutez un triangle supplémentaire dans le code d'exemple afin d'obtenir la figure suivante