Langage C++ et OpenGL

Dans cette partie, nous allons voir plus en détail les éléments de langage C++ et les bases d'OpenGL. Cette partie a pour objectif de vous aider à comprendre le fonctionnement de l'affichage graphique a bas-niveau (= proche du matériel), et ce qui se déroule plus précisément lors de l'initialisation des données et de l'affichage.
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;
}
Ce programme affiche sur la ligne de commande "Hello world" puis termine.
Voici l'explication de chaque ligne de ce code:




Remarques générales:

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:
\(\Rightarrow\) Détails sur le processus de compilation
assets/compilation.jpg

Types fondamentaux C++

Vous utiliserez principalement deux types fondamentaux dans vos codes:
Vous rencontrerez également les types suivants:
Le type char possède la garantie d'être encodé sur 1 octet. Il peut être utilisé également à ce titre pour définir finement des valeurs en mémoires à l'octet prêt.
Remarque: En C++, les types fondamentaux ne sont pas initialisés à de valeurs spécifiques par défaut.

Notions basiques du langage

Déclaration de variables

Les variables doivent se déclarer avec leur type
type nomVariable;
// ex. 
// int maVariable;
// float y;
Il est possible d'affecter une valeur directement lors de la déclaration
type nomVariable = valeur;
// ou similairement:
//  type nomVariable(valeur);
//  type nomVariable = type(valeur);
Lorsqu'il n'y a pas d'ambiguité, le type peut être désigné par le mot clé "auto". Dans ce cas, le type est alors automatiquement déterminé par le compilateur.
auto a = 5; // a est un int
Pour des questions de lisibilité de code, on réservera cependant l'utilisation de auto à des types longs et complexes à écrire, et on explicitera par contre les types classiques.
Il est possible de spécifier qu'une variable va être constante par le mot clé "const"
type const nomVariable = valeur;
// ou similairement
const type nomVariable = valeur;
Une variable qualifiée de "const" ne peut plus voir sa valeur modifiée.
int const a = 5;
a = 6; // erreur de compilation
Remarque: L'utilisation de "const" permet d'aider le programmeur à éviter de modifier la valeur d'une variable qui ne le devrait pas par inadvertance. Il s'agit principalement d'un mot clé permettant de préserver une bonne qualité de code et d'éviter des bugs.

Fonctions

Les fonctions suivent la syntaxe suivante
typeRetour nomFonction(type nomArgument1, type nomArgument2, etc.)
{
    ... code de la fonction
    return value;
}
Exemple:
int addition(int a, int b)
{
    return a+b;
}
Il faut nécessairement que la signature d'une fonction soit déclarée avant son utilisation. Sinon il y aura une erreur de compilation.
Exemples:
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;
}
Remarque: k++ (ou ++k) est identique à écrire k=k+1

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;
Par contre:
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.
};
Exemple de déclaration:
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);
    }
};
Utilisation:
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();
}
Remarques:

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
En C++, une variable désigne directement une zone en mémoire.
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
}
Ce comportement par défaut est différent des langages tels que Python ou Java, qui lors de l'utilisation de classes, vont appliquer une indirection: La variable ne désignant pas une zone mémoire, mais l'adresse de cette zone (en Python ou Java cette gestion est implicite, et on parle de "référence").
\(\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()
}
Remarque:

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.
\(\Rightarrow\) Plus de détails sur les pointeurs

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;
Exemple:
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
Les références permettent de modifier les variables d'origine dans une fonction (arguments in/out) tout comme avec le passage par adresse. Par contre, elles sont syntaxiquement plus simples
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;
}
Remarques:
// 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
int& a; // ce code ne compile pas, il faut initialiser la référence a
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);
}
Ce code est fonctionnel, mais v est un argument passé "par copie". Ce qui signifie qu'à chaque appel à la fonction norm, 1000 valeurs sont copiées (soit 4000 octets), ce qui peut être couteux si cet appel est réalisé dans une boucle d'animation par exemple.
Si v est passé par adresse, ou par référence, alors il n'y a plus de copie. Le passage d'argument est beaucoup plus léger puisque seule l'adresse est copiée (une adresse ne prenant que 8 octets).
Le passage par référence n'est cependant pas exempt de défaut, car une fonction norm mal codée pourrait modifier la valeur de v par inadvertance. Ce type d'erreur serait alors difficile à détecter.
Une solution à ce problème est de passer v en référence constante (vec1000 const&).
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:
type nomVariable
type const& nomVariable
type& nomVariable

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.
assets/window.png
La création d'une fenêtre et l'initialisation d'OpenGL sont des étapes techniques qui dépendent du système d'exploitation. Pour les réaliser plus simplement, nous utilisons des bibliothèques externes:

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.

Remarque: L'installation de GLFW dépend de votre système et a du être réalisé lors de la mise en place du tutoriel d'installation pour votre système
  • - 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.
Les différentes étapes de l'initialisation de la fenêtre sont réalisées dans la fonction create_window_using_glfw. On pourra y noter les fonctions principales et types suivants:
GLFWwindow* create_window_using_glfw()
{
    ...
    glfwInit();
    GLFWwindow* window = glfwCreateWindow(width, height, "GLFW Window", nullptr, nullptr);
    glfwMakeContextCurrent(window);
    ...
}
Le reste de la fonction main() consiste en une boucle qui fait appel aux fonctions suivantes:
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();
}
Considérez désormais le code suivant pour la boucle d'affichage:
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();
}

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:
      • 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
Ce type de nommage d'une fonction dépendant de ces arguments est nécessaire car OpenGL est une spécification décrite en langage C. Au contraire du C++, le C ne permet pas de "surcharger" une fonction prenant différents types d'arguments - ce qui oblige à avoir un nom de fonction unique pour un type donné d'argument.

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

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/
assets/triangle.png
Les différentes parties du code permettant d'obtenir ce résultat sont décrites ci-après.

(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);
}
Fragment Shader:
#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);
}

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).

En synthèse:
  • VBO = Le buffer de données brutes
  • VAO = L'organisation de ces données
Trois étapes peuvent être identifiées lors de l'envoi de données sur le GPU

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);
La copie effective des données entre la RAM et le GPU est réalisée par la fonction
glBufferData(GL_ARRAY_BUFFER, position.size()*sizeof(GLfloat), &position[0], GL_STATIC_DRAW );
Les autres fonctions sont des opérations plus simple:
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.
assets/vbo.svg
Le code associé à cette paramétrisation est le suivant:
// 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);
La fonction indiquant le paramétrage du 1er (et unique) paramètre pour le VAO est
glVertexAttribPointer( 0, 3, GL_FLOAT, GL_FALSE, 0, 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.

Documentation des fonctions au besoin

(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);
La demande effective d'affichage est réalisée par la fonction glDrawArrays(GLenum mode, GLint first, GLsizei count)

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

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.

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;
et
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
};
> Adaptez le code de manière à pouvoir afficher ces deux triangles.
assets/triangle_exercise.png

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û:
Une approche plus efficace consiste à appliquer cette translation directement le shader (le vertex shader dans ce cas). La valeur de la translation pouvant alors être modifiée à chaque nouvelle frame. La valeur de cette translation étant la même pour tous les sommets d'un même objet, il est possible d'utiliser pour cela un paramètre "uniform".

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).
Les valeurs de la translation sont envoyées à la carte graphique en tant qu'uniform -- dans notre cas il s'agit de copier les valeurs de "tr" du code C++ vers la variable "translation" du vertex shader.
Cette opération se réalise en 2 étapes:
// 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]);
L'exécution de ce code permet d'afficher un triangle animé d'un mouvement de translation le long d'un cercle.
Remarques
Ordres des opérations

Exercice

Remarque préliminaire: Lors d'une animation il est important d'effacer l'image après chaque frame.
Couleur paramétrée
Rotation
float rotation[9] = {
    std::cos(t),-std::sin(t), 0,
    std::sin(t), std::cos(t), 0,
              0,           0, 1,
};
glUniformMatrix3fv(uniform_rotation_location, 1, GL_TRUE, &rotation[0]);
  • - 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.
Affichage de plusieurs triangles
Modifiez l'initialisation de la variable position pour être définie par
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
};
Ces coordonnées peuvent être utilisées pour afficher deux triangles différents.
> Adaptez votre code C++ pour représenter la scène suivante en utilisant un seul shader:
Aide:

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:
Considérez le cas du code d'exemple présentant un triangle où chacun des trois sommets est associé, respectivement, à la couleur rouge, verte et bleue.
assets/colored_triangle.png


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);
}
Le vertex shader reçoit désormais deux attributs par sommet:
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:

Fragment shader
Le fragment shader reçoit en tant que in la couleur attribuée au sommet.
#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
};
Gestion des deux VBO de position et de couleur:
// 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);
Création du VAO indiquant quel VBO à utiliser pour chaque attribut du shader
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);
Question:

> 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)\).
assets/quad_index.png
Il est possible d'envoyer à la carte graphique l'information de ces deux triangles sous la forme de 6 coordonnées consécutives des sommets. Cependant, cette approche revient à dupliquer l'information des coordonnées de \(p_0\) et \(p_2\) qui sont partagés par deux triangles. Au delà de la redondance de données, cette structure ne tient pas compte du fait que les sommets \(p_0\) et \(p_2\) correspondent, en principe, à un seul et même point de l'espace. Ainsi, si le programmeur/utilisateur souhaite modifier la coordonnée \(p_0\), il est nécessaire de réaliser la modification à deux endroits différents du buffer - ce qui est une source de lenteur et/ou d'erreur.
Une manière plus efficace d'encoder une structure de maillage est de séparer la notion de coordonnées géométrique de la connectivité. Cet encodage basé sur l'indexation (indexing) est géré de manière native par OpenGL.
Un maillage est alors représenté par deux structures

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);
Question
assets/extra_triangle.png