Logo de SAASF

Utiliser OpenGL en C++

Pour l'instant, les seules interactions que nous pouvons avoir avec notre programme C++ sont les fichiers et la console. Nous allons ici apprendre à augmenter les possibilités d'interactions avec le programme, grâce à une API très connue : OpenGL. Pour rappel, nous avons déjà fait un cours sur OpenGL, juste ici. Pour des raisons de temps, nous allons considérer tout ce qui a été vu sur ce cours comme acquis.
Contenu

A. Créez votre fenêtre OpenGL

a. Mettre en place votre environnement

Disons le, OpenGL est une librairie assez complexe à utiliser. Pour y accéder, chaque systèmes d'exploitations offrent nativement différents outils pour y accéder. Cependant, pour gagner du temps, nous allons utiliser des librairies C++ qui permettent d'utiliser simplement tout ça. Nous allons utiliser 3 librairies : GLFW, Glad et GLM.

GLFW va être un moyen simple d'utiliser un contexte OpenGL. En effet, elle est pleine de fonctions très utiles pour travailler avec OpenGL. Elle a l'avantage d'être cross-platforme et en libre accès. Pour installer GLFW, rendez vous sur cette page. Vous pouvez télécharger le code source et le compiler vous même, ou utiliser du code pré-compilé. Pour rappel, nous avons déjà traité de l'utilisation de librairies de ce genre, dans ce cours là. Vous devrez théoriquement avoir accès a un DLL, deux fichiers librairies, et un ensemble de headers.

Glad va avoir pour tâche de rendre notre code OpenGL cross-plateforme. En effet, comme nous l'avons vu, chaque plateforme propose sa propre implémentation d'OpenGL. Glad réuni tous les types possibles, et les rend accessibles selon la plateforme actuelle grâce au système de template. Pour installer Glad, rendez vous sur cette page. Elle vous demandera pas mal de configuration pour le fichier environnement généré. Comme langage, sélectionnez "C/C++"". Comme spécification, sélectionnez "OpenGL". Comem API, sélectionnez "Version 3.3" dans "gl" (Comme sur notre cours d'OpenGL). Comme "Profile", choisissez "Core". Cependant, à l'inverse de GLFW, Glad ne propose pas de fichiers pré-compilés. Le site vous proposera un dossier de headers, ainsi qu'un fichier "glad.c", à ajouter aux fichiers compilés de votre code.

Finalement, GLM va nous apporter un ensemble d'outils mathématiques, très importants pour OpenGL. En effet, les mathématiques nécessaires seront très avancés ici. Heuresement, GLM en propose une partie. Pour installer GLM, rendez vous sur cette page. Vous y trouverez l'ensemble du projet GLM. Vous aurez besoin de tous les headers présents dans le dossier "glm". Dans cette librairie, il n'y a que des headers.

Dés que tout cela est installé, vous pouvez créer votre projet pour commencer à travailler avec OpenGL.

b. Créer votre première fenêtre

Pour créer notre première fenêtre avec OpenGL, il va falloir commencer à écrire le code permettant de configurer OpenGL. Nous vous conseillons grandement de bien le structurer, pour pouvoir l'utiliser de manière plus simple. Cependant, si vous avez la flemme (déconseillé), vous pouvez tout mettre directement dans la fonction "main" de votre code. Avant tout, initialisons GLFW, avec quelques simples lignes de code.

// Initialisation de GLFW
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
Ces fonctions sont assez claires : "glfwInit()" initialise GLFW, et "glfwWindowHint()" permet de configurer certaines valeurs de GLFW, grâce à deux paramètres : la configuration à modifier, et sa nouvelle valeur. Dans ce cas, nous configurons la version (majeure et mineure) d'OpenGL, en 3.3 ici, puis nous indiquons à GLFW que nous utiliser la version "core" d'OpenGL (téléchargée plus haut via Glad). En suite, créeons notre contexte OpenGL.
// Création du contexte OpenGL
GLFWwindow* window = glfwCreateWindow(500, 500, "Mon programme OpenGL", NULL, NULL);
if (window == NULL) {
    std::cout << "Failed to create GLFW window" << std::endl;
    glfwTerminate();
}
glfwMakeContextCurrent(window);
Ces fonctions restent aussi assez claires : "glfwCreateWindow()" crée la fenêtre pour OpenGL et la retourne sous forme de pointeur vers un objet "GLFWwindow", la condition vérifie si la création a bien eu lieu, et "glfwMakeContextCurrent()" indique à GLFW (et, indirectement, à l'OS du PC) que le contexte actuel du programme est le contexte passé en paramètre. Nous pouvons constater la fonction "glfwTerminate()" dans la condition, qui a pour tâche de décharger GLFW pour le programme (si il doit s'arrêter). Attardons nous sur "glfwCreateWindow()". Elle prend beaucoup de paramètres. Les deux premiers représentent la largeur et la hauteur de la fenêtre. Le troisième représente le titre affiché en haut à gauche de la fenêtre. Le quatrième représente un pointeur vers un objet GLFWmonitor. Si ce pointeur est utilisé, alors le programme va être en plein écran sur l'écran demandé. Sinon (comme maintenant), il ne sera pas en plein écran. Le cinquième permet de lier une autre fenêtre déjà crée avec celle-ci pour un partage de ressources entre fenêtre si besoin (nous n'en aurons pas besoin ici). Si la fenêtre ne peut pas être générée, la fonction renvoie un pointeur nul, et la condition d'erreur est activée, affichant un mesage d'erreur et déchargeant GLFW. En suite, chargeons Glad.
// Chargement de Glad
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
    std::cout << "Failed to initialize GLAD" << std::endl;
    glfwTerminate();
}
Glad est chargé dans la fonction "gladLoadGLLoader()", qui renvoie "true" si le chargement est bon, ou "false" si il n'est pas bon. Si il ne l'est pas, la condition d'erreur est activée, affichant un mesage d'erreur et déchargeant GLFW. Cette fonction est assez complexe : elle prend la fonction "glfwGetProcAddress()" de GLFW en paramètre, retournant un pointeur vers la bonne API OpenGL utilisée par le contexte actuel. N'hésitez pas à aller voir la documentation si cette fonction vous intéresse. Dés que tout cela est fait, la partie configuration est terminée.

Maintenant, nous allons implémenter la boucle qui va permettre à notre programme de tenir dans le temps. Tout aura lieu dans une boucle, qui s'arrêtera en même temps que le programme. La boucle aura cette allure :

// Boucle de temps
while(!glfwWindowShouldClose(window)) {

}
glfwTerminate();
Ici, "glfwWindowShouldClose()" vérifiera si la fenêtre doit se fermer. Après avoir fermer la fenêtre, on décharge GLFW avec "glfwTerminate()". Si nous lançons le programme comme ça, rien ne va se passer, et le programme ne pourra même pas se fermer tout seul. Pour utiliser la fenêtre, nous allons modifier l'intérieur de la boucle.
// Boucle de temps
while(!glfwWindowShouldClose(window)) {
    // Préparer l'affichage OpenGL
    glClearColor(0, 0.4, 0.9, 1.0);
    glClear(GL_COLOR_BUFFER_BIT);

    // Met à jour OpenGL
    glfwSwapBuffers(window);
    glfwPollEvents();
}
glfwTerminate();
Ce code rend la page tout à fait utilisable : décrivons le étape par étape. "glClear()" permet de remettre les zones mémoires générées auparavant d'OpenGL à 0 (ou ne fait rien si aucune zone mémoire n'a été générée). Comme ça, vous n'encombrez pas la mémoire de choses inutiles. Ici, on passe en paramètre "GL_COLOR_BUFFER_BIT", pour n'effacer que la partie "couleur" d'OpenGL (l'ancienne zone d'affichage). Il s'agit d'une fonction de base d'OpenGL (rendue utilisable par Glad). Cependant, OpenGL va remplir la nouvelle zone de mémoire d'affichage par une nouvelle couleur, spécifiée via la fonction glClearColor(). Elle prend 4 paramètres : les valeurs RGBA de la couleur nécessaire (qui peuvent être des valeurs sur 255, ramenée à entre 0 et 1). Ces valeurs doivent être entre 0 et 1. Ici, il s'agit d'un bleu ciel (le même que la couleur d'arrière plan de la fenêtre nouvellement crée). En suite, on demande à GLFW de confirmer l'affichage de la fenêtre, qui va être envoyée à la carte graphique avec glfwSwapBuffers(). Finalement, "glfwPollEvents()" met à jour les évènements ayant eu lieu depuis le dernier appel de la fonction (clique de souris, entré clavier, fermeture de la fenêtre...). Cet données seront par la suite accessible via des fonctions, que nous verrons plus tard. Maintenant que cette fenêtre est crée, commençons à la rendre utilisable.

Fenêtre OpenGL

c. Créer des objets OpenGL

Pour pouvour utiliser pleinement OpenGL, nous allons créer des objets dans OpenGL. Comme nous l'avons vu dans la présentation d'OpenGL, les objets OpenGL sont utilisés via des VAOs, des VBOs et des shaders.

Commençons par le plus simple : les VBOs. Pour des raisons de simplicité, nous mettrons l'entiereté des données d'un VBO dans une structure.

// Structure pour un VBO
struct VBO {

    // Id du VBO
    unsigned int id;

    // Points dans le VBO
    std::vector<float> points;
};
Nous l'étudierons plus précisément plus tard. Pour passer des VBOs à OpenGL, il vous faut une liste de points. Ici, notre liste sera dans le vecteur "points". Pour commencer simplement, faisons un simple triangle (pour rappel, OpenGL ne peut qu'afficher des triangles).
// Création du VBO nécessaire
VBO triangle;

// Création d'un triangle
triangle.points.push_back(-0.5);triangle.points.push_back(-0.5);triangle.points.push_back(0);
triangle.points.push_back(0.5);triangle.points.push_back(-0.5);triangle.points.push_back(0);
triangle.points.push_back(0);triangle.points.push_back(0.5);triangle.points.push_back(0);
Dans une fenêtre OpenGL, les extrémités (gauche / droite, bas / haut) représentent les coordonnées -1 et 1. Dés que cela est fait, nous allons les passer à OpenGL.
// Attribution d'un espace mémoire pour le VBO
glGenBuffers(1, &triangle.id);

// Passage du VBO à OpenGL
glBindBuffer(GL_ARRAY_BUFFER, triangle.id);
glBufferData(GL_ARRAY_BUFFER, sizeof(float) * triangle.points.size(), triangle.points.data(), GL_STATIC_DRAW);
Premièrement, obtenons de la part d'OpenGL une partie de mémoire, via la fonction glGenBuffers(). Son premier paramètre représente le nombre d'objets OpenGL que nous allons crée (ici, qu'un seul VBO), et le deuxième représente une référence vers un nombre (unsigned int), qui aura une valeur que nous pourrons utiliser pour accéder à cet objet OpenGL. En suite, nous indiquons à OpenGL que nous allons utiliser la partie de mémoire créée comme le VBO actuellement utilisé (GL_ARRAY_BUFFER) par OpenGL grâce à la fonction glGenBuffers(). Finalement, nous envoyons dans cette partie de mémoire les données du VBO. Pour cela, nous utilisons la fonction glBufferData(), permettant d'envoyer des données dans une zone de mémoire OpenGL. Le premier paramètre représente l'endroit où envoyer les données (ici, le VBO actuellement utilisé, défini plus haut). Le deuxième paramètre représente la taille en octet de données que nous allons passer à la mémoire. Nous n'allons passer que des "float" (4 octets de mémoire), autant qu'il y en a dans le vecteur des points du triangle. Le troisième paramètre nous permet d'indiquer les données à passer à la mémoire. Elle prend en paramètre un pointeur vers un tableau de données C++. Ici, nous lui passons un pointeur vers les données du vecteur sous forme de tableau, grâce à la méthode "data". Finalement, le dernier paramètre représente la façon dont OpenGL doit écrire les données. La méthode "GL_STATIC_DRAW" est la plus rapide à utiliser, mais sera plus longue à modifier dans le futur si besoin (nous ne le ferons pas ici). Pour en finir, il nous faut ajouter un moyen de supprimer les données allouées par OpenGL à la mémoire.
// Libère la mémoire allouée pour le VBO
glDeleteBuffers(1, &triangle.id);
La fonction glDeleteBuffers() fonctionne de la même manière que "glGenBuffers()", vue plus haut. Cette opération doit être effectuée après la boucle d'éxecution. Maintenant, votre VBO est bien chargé dans la mémoire d'OpenGL.

En suite, nous allons passer le shader vers OpenGL. Pour commencer, il vous faudra vos deux shaders nécessaires : un vertex shader et un fragment shader. Nous allons simplement recopier une version simplifiée de ceux dans le cours d'introduction à OpenGL.

// Vertex

// Version d'OpenGL
#version 330 core

// Données du VBO (vec3 représente un point 3D)
layout(location = 0) in vec3 pos; // Position du point P

// Fonction de base du shader
void main() {
    // Calcul du point retourné par le shader
    gl_Position = vec4(pos.xyz, 1.0);
}
// Fragment

// Version d'OpenGL
#version 330 core

// Couleur sortie du shader
out vec4 FragColor;

// Fonction de base du shader
void main() {
    // Calcul de la couleur nécessaire, avec ici le rouge, ou RGBA(255, 0, 0, 255)
    FragColor = vec4(1, 0, 0, 1);
}
Ce code utilise un langage très proche du C : GLSL. Comme en C, la fonction principale du programme est "main()", qui ne retourne rien. Le type vec3 correspond à un vecteur de 3 flottants et vec4 correspond à un vecteur de 4 flottants. Ici, le "#version 330 core" permet de spécifier la version d'OpenGL utilisé ici. Les variables définis avec "out" signifient que le shader retourne ces variables pour le prochain shader. "FragColor" représente la couleur pour le pixel traité par le shader. Cependant, certaines variables sont directement incluses à OpenGL, comme "gl_Position", qui représente la position précise d'un point du VBO (que nous pouvons modifier pour bouger l'objet). À l'inverse, les variables définis avec "in" signifient que le shader reçoit ces variables depuis le dernier shader (ou depuis le VBO dans le cas du vertex shader). Dans le vertex shader, "layout(location = x)" permet de dire à shader d'aller chercher une certains variable dans le VBO, acomme étant la "x ième" variable traitée pour un point du VBO. Nous verrons comment utiliser ça précisement un peu après. Plaçons ces codes dans des "std::string", que nous appellerons respectivement "vertex_shader" et "fragment_shader". Ces codes vont être directement compilés par OpenGL.
// Compilation du fragment shader
unsigned int fragment = glCreateShader(GL_FRAGMENT_SHADER);
char* current_shader = new char[fragment_shader.size()];
for(int i = 0;i<fragment_shader.size();i++){current_shader[i]=fragment_shader[i];}
int shader_size = fragment_shader.size();
glShaderSource(fragment, 1, &current_shader, &shader_size);
glCompileShader(fragment); delete current_shader;

// Compilation du vertex shader
unsigned int vertex = glCreateShader(GL_VERTEX_SHADER);
current_shader = new char[vertex_shader.size()];
for(int i = 0;i<vertex_shader.size();i++){current_shader[i]=vertex_shader[i];}
shader_size = vertex_shader.size();
glShaderSource(vertex, 1, &current_shader, &shader_size);
glCompileShader(vertex); delete current_shader;
Le code est quasiment le même pour les deux shaders. Nous commençons par créer un simple shader (de type GL_FRAGMENT_SHADER ou GL_VERTEX_SHADER) grâce à la fonction glCreateShader(). Elle retourne ensuite un ID, qui permettra d'accéder au shader nouvellement créé. En suite, nous spécifions au shader nouvellement créé le code qui le constitue, grâce à glShaderSource(). Cette fonction prend en premier paramètre le shader à traiter (via son ID). Le deuxième paramètre permet de savoir combien de shaders vont être compilés (qu'un seul ici). Le troisième paramètre est assez spécial. Il s'agit d'un pointeur VERS un pointeur VERS le contenu du shader, sous forme de tableau de caractère. C'est pour cela que nous le créeons dans un nouveau tableau de caractère, grâce à "current_shader" et à la boucle. Finalement, le dernier paramètre prend un pointeur vers une variable contenant la taille du shader a compilé. Dés que le shader est entièrement passé, il est compilé avec glCompileShader(). N'oubliez pas de supprimer le tableau de caracètre crée à la fin, pour ne pas perdre d'espace mémoire. Dés que cela est fait, nous devons nous assurer que rien n'est arrivé pendant la compilation.
// Vérifier le fragment shader
int success = 0;
char infoLog[512];
glGetShaderiv(fragment, GL_COMPILE_STATUS, &success);
if (!success) {
    glGetShaderInfoLog(fragment, 512, NULL, infoLog);
    std::cout << "Erreur de compilation sur le fragment" << std::endl << infoLog << std::endl;
}

// Vérifier le vertex shader
success = 0;
glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);
if (!success) {
    glGetShaderInfoLog(vertex, 512, NULL, infoLog);
    std::cout << "Erreur de compilation sur le vertex shader" << std::endl << infoLog << std::endl;
}
Ici, glGetShaderiv() permet de récupérer une information sur le shader. Le premier paramètre représente le shader à tester, le deuxième représente l'information à tester, et le troisième représente une référence vers un entier contenant la réponse de la fonction. Si le shader n'a pas pu être compilé, on cherche ce qui ne va pas grâce à la fonction glGetShaderInfoLog(). Son premier paramètre représente le shader à tester, le deuxième représente la taille maximale des informations sorties par la fonction, la troisième représente un pointeur vers une variable contenant la taille réelle renvoyée et la quatrième représente l'endroit où écrire l'information. Nous afficheons en suite cette erreur à l'écran. Si tout cela fonctionne, alors nous pouvons finir de créer notre shader.
// Génération du shader dans OpenGL
unsigned int shader_program = glCreateProgram();
glAttachShader(shader_program, vertex);
glAttachShader(shader_program, fragment);
glLinkProgram(shader_program);
Nous commençons par créer un programme entier de shader via glCreateProgram(), encore une fois sous la forme de "unsigned int". Après, nous attachons les programmes compilés à ce programme avec glAttachShader(). Finalement, vous liez le programme final avec glLinkProgram(), et le tour est joué. Il reste trois dernière étapes avant d'en finir.
// Vérifier le shader final
success = 0;
glGetProgramiv(shader_program, GL_LINK_STATUS, &success);
if (!success) {
    glGetProgramInfoLog(shader_program, 512, NULL, infoLog);
    std::cout << "Erreur de compilation" << infoLog << std::endl;
}

// Supression des ressources inutiles
glDeleteShader(vertex);
glDeleteShader(fragment);
Nous commençons par une dernière vérification du shader, puis nous supprimons les shaders compilé (le nouveau programme de shader en aura fait une copie). Dés que tout cela est fait, il ne nous reste plus qu'à préciser au shaders les paramètres d'entrées du vertex shader.
// Shader actuellement utilisé
glUseProgram(shader_program);

// Paramètre nécessaire
int taille_actuelle = 0;
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3, (void*)taille_actuelle);
glEnableVertexAttribArray(0);
Nous définissons "shader_program" comme le shader actuellement utilisé avec glUseProgram(). En suite, nous définissons une nouvelle entrée pour le vertex shader, via glVertexAttribPointer(). Le premier paramètre prend la position de l'entrée actuelle passée. Si vous passez plusieurs entrée (ce que nous ferons plus tard), il faut mettre le nombre nécessaire pour chaque entrées. Le deuxième paramètre représente le nombre de données prise par cette variable. En d'autre terme, il s'agit de la taille du vecteur représenté par cette variable (ou 1 si il ne s'agit pas d'un vecteur). Le troisième paramètre représente le type d'un composant du vecteur, ici un "float". Le quatrième paramètre indique si OpenGL doit normaliser le vecteur ou non. Le cinquième paramètre représente le nombre total d'octets entre deux occurences de cette entrée dans le VBO. Pour finir, le dernier paramètre représente le décalage nécessaire à partir du début de toutes les données dans un shader par rapport à la première donnée pour arriver à celle-ci. En quelque sorte, c'est la position de cette donnée par rapport aux autres. Plus précisement, il s'agit d'un pointeur vers une variable contenant cette donnée (ici taille_actuelle). Petite précision : cette fonction nécessite que le VBO est déjà été crée et qu'il soit défini comme VBO actuel d'OpenGL : nous verrons comment bien s'organiser avec ça après. En suite, cette entrée (ou du moins, l'entrée ayant comme position le paramètre de la fonction) est validée au shader par glEnableVertexAttribArray(). Finalement, comme toujours, nous devons libérer la mémoire à la fin du programme.
// Suppression de la mémoire
glDeleteProgram(shader_program);
Maintenant, nous pouvons utiliser ce shader.

Finalement, nous allons pouvoir commencer à créer le shader. Cependant, cette étape va devoir reprendre toutes les étapes que nous avons vu dans cette partie. Donc, nous allons créer pleins de fonctions, qui reprennent tout ce que l'on n'a fait sous un seul nom.

  • Le chargement d'un VBO (plaçage des points dans l'espace mémoire) sera assurée par une fonction "charger_vbo".
  • La création et chargement d'un shader sera assurée par une fonction "creer_shader".
  • La création d'un VBO (de ces points et de son espace mémoire) sera assurée par une fonction "creer_vbo".
  • La suppression d'un shader sera assurée par une fonction "supprimer_shader".
  • La suppression d'un VBO sera assurée par une fonction "supprimer_vbo".
Après tout ça, votre code avant la boucle devrait ressembler à ça.
// Créer le VBO
VBO* vbo = creer_vbo();
charger_vbo(vbo);

// Créer le shader
unsigned int shader = creer_shader();
Nous allons partir de cette base pour créer le VAO. Commençons par créer le VAO dans la mémoire.
// Créer le VAO
unsigned int vao;
glGenVertexArrays(1, &vao);
Ici, c'est glGenVertexArrays() qui s'en occupe, toujours avec le système de "unsigned int". Une fois le VAO crée, attachons-y le VBO, que nous créeons donc à cet instant là.
// Attache du VBO
glBindVertexArray(vao);
VBO* vbo = creer_vbo();
charger_vbo(vbo);
L'ordre d'appel est très important ici, et c'est pour ça qu'on a découpé le programme en fonctions plus précises. Nous commençons par fait de "vao" le VAO actuellement utilisé par OpenGL avec glBindVertexArray(), puis nous créeons le VBO juste après. En suite, viens la création du shader, qui doit avoir lieu après celle du VBO (comme nous avons vu plus haut).
// Créer le VAO
unsigned int vao;
glGenVertexArrays(1, &vao);

// Attache du VBO (vous pouvez créer le VBO avant / après "glBindVertexArray()", mais obligatoirement le charger après)
VBO* vbo = creer_vbo();
glBindVertexArray(vao);
charger_vbo(vbo);

// Créer le shader
unsigned int shader = creer_shader();
Maintenant que tout cela est fait, utilisons notre VAO pour effectuer un rendu fonctionnel. Tout ce qui va suivre doit, en toute logique, se trouver dans la boucle d'exécution.
// Dessin du triangle

// Shader actuellement utilisé
glUseProgram(shader);

// Tracer les points via le VAO
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, cube.points.size() / 3.0);
Nous devons réutiliser la fonction "glUseProgram()", pour dire à OpenGL qu'on utilise ce shader. En suite, nous définissons "vao" comme la VAO utilisé actuellement par OpenGL, grâce à "glBindVertexArray()". Pour finir, le traçage des formes sur le contexte actuel est ordonné par la fonction glDrawArrays(). Elle prend en premier paramètre le type de forme à tracer, en deuxième paramètre le décalage nécessaire de données dans le VBO pour commencer le traçage (avec ici aucun décalage), et finalement en troisième paramètre le nombre de points à tracer. Théoriquement, votre triangle devrait être bien tracé sur votre fenêtre OpenGL.

Fenêtre OpenGL avec un triangle

d. Communiquer avec les objets OpenGL

Imaginez que vous vouliez bouger votre objet. Comment faire ? On peut penser qu'il faut agir sur le VBO, mais modifier le VBO à chaque boucle prendrait beaucoup de temps. À la place, nous allons utiliser un système permettant de communiquer de manière rapide et simple avec les shaders : les variables "uniform". En effet, les variables notées "in" dans le shader ne peuvent pas être modifiée sans modifier le VBO. Les variables "uniform" peuvent être modifiée quand vous le souhaitez depuis le programme principal. Pour bouger le shader, c'est ici que les mathématiques et GLM entrent en jeu. En effet, notre vertex shader prend des points 3D, nous devons donc utiliser un moyen mathématique de bouger ces points 3D facilement. Ici, on va utiliser le système de calcul matriciel. Nous ne rentrerons pas dans les détails (GLM le fera pour nous), cependant les transformations que nous allons faire sont parfaitement applicables avec des matrices. Nous allons appliquer des translations, des homothéties (remise à l'échelle, aggrandissement / rétrecissement) et des rotations. Pour cela, nous aurons besoin de matrices de dimension 4X4. Ajoutons la au shader.

// Vertex

// Version d'OpenGL
#version 330 core

// Données du VBO (vec3 représente un point 3D)
layout(location = 0) in vec3 pos; // Position du point P

// Données du programme principal
uniform mat4 transformation;

// Fonction de base du shader
void main() {
    // Calcul du point retourné par le shader
    gl_Position = transformation * vec4(pos.xyz, 1.0);
}
En GLSL, une matrice utilise le type "mat4". Pour appliquer les transformations dans le shader, nous avons juste à multiplier la matrice aux coordonnées du points. Finalement, ajoutons du code au programme principal, pour appliquer des transformations. Nous devons commencer par inclure les librairies GLm que nous utiliserons ici.
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
Nous pouvons maintenant commencer les choses sérieuses.
// Calcul de la transformation
glm::mat4 transformation = glm::mat4(1);
// Translation de la matrice
transformation = glm::translate(transformation, glm::vec3(-0.5, 0, 0.2));
// Rotation de la matrice
transformation = glm::rotate(transformation, 0.5f, glm::vec3(0, 0, 1));
// Remise à l'échelle de la matrice
transformation = glm::scale(transformation, glm::vec3(0.8, 1.0, 0.4));

// Shader actuellement utilisé
glUseProgram(shader);
// Passage de la variable uniforme
int variable_uniform = glGetUniformLocation(shader, "transformation");
glUniformMatrix4fv(variable_uniform, 1, GL_FALSE, glm::value_ptr(transformation));

// Tracer les points via le VAO
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, cube.points.size() / 3.0);
Commençons par créer la transformation, avec une matrice 4X4, de type "glm::mat4". Pour être précis, nous créeons ici une matrice diagonale (avec des 1). En suite, nous obtenons une matrice transformé par translation de "transformation", qui sera la nouvelle valeur de "transformation". Pour cela, on utilise la fonction "translate()" de GLM, qui prend une matrice à transformer, ainsi que la translation à appliquer, et renvoie le résultat. Nous faisons la même avec la rotation, grâce à la fonction "rotate()" de GLM. Elle est un peu plus complexe : elle prend une matrice à transformer, ainsi qu'un angle en radians (OBLIGATOIREMENT UN "FLOAT", pas de "double" autorisé), et finalement l'axe sur lequel appliquer la rotation (ici l'axe Z). En effet, en 3D, les rotations se font autour d'un axe. Finalement, on utilise la fonction "scale()" de GLM, qui prend une matrice à transformer, ainsi que la remise à l'échelle à appliquer, et renvoie le résultat. Dés que cela est fait, nous passons cette matrice au shader. Cette étape doit impérativement avoir lieu après "glUseProgram(shader)". Pour cela, nous commençons par obtenir un "int" pour accéder à la variable uniform "transformation" du shader "shader", grâce à glGetUniformLocation(). Finalement, nous passons la variable au shader, avec glUniformMatrix4fv. Son premier paramètre représente "l'int" d'accés à la variable. Le deuxième renvoie le nombre de données qui vont être passées, et la troisième si la matrice doit être transposer (une opération matricielle, ici inutile) ou non. Le dernier paramètre fourni à OpenGL un pointeur vers les données de la matrices, se trouvant à l'adresse renvoyée par "glm::value_ptr(transformation)". Si tout est bien fait, votre triangle devrait subir ses première transformations. En modifiant les valeurs comme vous voulez, vous pouvez faire faire ce que vous voulez à votre triangle. D'ailleurs, vous pouvez utiliser des variables "uniform" dans le vertex shader, mais aussi dans le fragment shader.

Fenêtre OpenGL avec un triangle transformé

Pour utiliser pleinement ce système, il y a quelques petits détails à rajouter. Premièrement, vous pouvez dessiner avec un même VAO et un même shader autant que vous voulez en une fois, en modifiant seulement les variables "uniform" si vous le souhaitez. Par exemple, vous pouvez très bien créer un "std::vector" (une liste dynamique) de différentes transformations, et toutes les dessiner à chaque fois. Comme ça, vous pouvez faire des choses plus complètes. De plus, il est tout à fait possible de faire bouger (ou tourner, ou grossir / rétrécir) dans le temps des objets. Dans ce cas, à chaque frame, l'objet occupera une position différente. Bien que vous ayez beaucoup de façon de faire, je vais vous en montrer une assez générale. Première, vous aurez besoin d'un indicateur de temps, ici une fonction que nous appellerons "time_ns()", et qui renvoie le nombre de nano-secondes depuis 1970.

// Retourne le nombre de nano-secondes depuis 1970
long long time_ns() {
    timespec ts;
    clock_gettime(CLOCK_REALTIME, &ts);
    return static_cast<long long>(ts.tv_nsec) + static_cast<long long>(ts.tv_sec) * 1000000000;
};
À chaque début de frame, stocker le temps actuel dans une variable (de type "long long"), et comparez le avec l'ancienne valeur stockée (sauf si aucune valeur n'est déjà stockée). Vous aurez la durée en nano-secondes de la dernière frame (divisez là par 1 milliard pour obtenir le temps en secondes).
// Obtenir la durée en seconde de la dernière frame
long long temps_actuel = time_ns();
long long duree_frame_en_seconde = static_cast<double>(temps_actuel - dernier_temp) / 1000000000.0;
dernier_temp = temps_actuel;
Comme ça, vous saurez de quel distance l'objet à bouger PENDANT la dernière frame, ce qui vous permettra de bouger correctement l'objet. En suite, vous pouvez définir une variable de type "glm::vec3" / "double" / tout ce que vous voulez, qui correspond à la position (ou rotation...) de l'objet, que vous changez à chaque frame. Pour rendre vos animations plus complètes, il y a une dernière chose que vous pouvez faire. Pour savoir quand et comment un utilisateur a intéragi avec le système d'exploitation, il existe plusieurs fonctions différentes : glfwGetKey() pour les touches du clavier, ou même glfwGetMouseButton() pour les touches de la souris. Comme ça, vous pouvez faire à ce que la réaction de l'objet dépende des entrées de l'utilisateur.
// Obtenir l'état de la touche "A" du clavier
int touche_a = glfwGetKey(window, GLFW_KEY_A);
if (touche_a == GLFW_PRESS) {std::cout << "La touche A est pressée." << std::endl; }

// Obtenir l'état de la touche gauche de la souris
int etat_souris_gauche = glfwGetMouseButton(window, GLFW_MOUSE_BUTTON_LEFT);
if (etat_souris_gauche == GLFW_PRESS) {std::cout << "La touche gauche de la souris est pressée." << std::endl; }
Petit bonus : voici le VBO nécessaire pour réaliser un carré.
// Création du VBO nécessaire
VBO cube;

// Création d'un carré
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(0);
cube.points.push_back(-0.5);cube.points.push_back(-0.5);cube.points.push_back(0);
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(0);
cube.points.push_back(0.5);cube.points.push_back(0.5);cube.points.push_back(0);
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(0);
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(0);

Contenu

B. Utilisez des concepts récents

a. Ajouter des textures

Pour l'instant, nos objets ne peuvent avoir qu'une seule couleur (ou, du moins, autant que le shader en permet). Maintenant, ajoutons des textures pour nos objets. La première étape est probablement la plus complexe : charger la texture. Pour charger la texture, plusieurs formats sont disponibles, même si nous allons utiliser le plus simple (pour l'instant) : le RGBA 8 bits. En effet, quand vous chargerez votre texture, vous aurez besoin de charger la texture dans un bloc de données, de telle manière que chaque pixel soit composé de 4 composants (rouge, vert, bleu et alpha), que chacun de ces composants occupent 8 bits (ou 1 octet / 1 "char"), placés 1 par 1 l'un après l'autre. En toute logique, il y aura autant de composants dans l'image que de données dans le bloc de données, donc 4 * largeur * hauteur. Cependant, une grande partie des formats d'images modernes ne sont pas des pixels brutes. En effet, il s'agit souvent d'un bloc de données représentant des pixels bruts mélangés et comprimés pour gagner de la mémoire. Dans le cas du PNG, il s'agit de plusieurs blocs, compressés avec le système de compression deflate. À partir de là, deux choix s'offrent à vous : créer votre propre système de chargement d'image ou utiliser un système déjà existant. Pour la suite de ce cours, nous allons utiliser notre propre librairie d'image : SCLS Image "Michelangelo". N'oubliez pas de l'inclure et d'appeler SCLS_INIT. Grâce à elle, le chargement sera assez simple :

// Charger l'image
scls::Image texture_1 = scls::Image("textures/labyrinthe.png");
Pour l'instant, la librairie ne supporte ques des formats PNG. Comme pour les VBOs, nous allons mettre tout ça dans une structure, pour l'utiliser plus facilement.
// Structure pour une texture
struct Texture {
    Texture(std::string path):image(path){};
    scls::Image image;

    // ID d'accés à la texture
    unsigned int id;
};

Texture texture_1 = Texture("textures/labyrinthe.png");
Maintenant, nous pouvons nous concentrer sur OpenGL.

Avec OpenGL, le système de texture est directement implémenté dans la librairie. Donc, ce ne sera pas si compliqué que ça à faire. Pour commencer, nous allons spécifier à OpenGL comment la texture doit s'afficher sur l'objet, via le VBO. En fait, on va indiquer à OpenGL la position sur l'image de chaque point de l'objet, pour lui assigner un pixel de l'image. Pour cela, nous allons rajouter une variable d'entrée au shader, qui représentera la position de l'image pour ce point, soit un vecteur 2D, après la position 3D du point. Dans le cas de notre carré (car il est plus facile de réaliser cette opération sur un carré), chaque point représente un coin de l'image (en haut à droite / à gauche ou en bas à droite / à gauche). Voici le nouveau VBO nécessaire :

// Création du VBO nécessaire
VBO cube;

// Création d'un carré
// Point 1
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(0);
cube.points.push_back(0.0);cube.points.push_back(1.0);
// Point 2
cube.points.push_back(-0.5);cube.points.push_back(-0.5);cube.points.push_back(0);
cube.points.push_back(0.0);cube.points.push_back(0.0);
// Point 3
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(0);
cube.points.push_back(1.0);cube.points.push_back(0.0);
// Point 4
cube.points.push_back(0.5);cube.points.push_back(0.5);cube.points.push_back(0);
cube.points.push_back(1.0);cube.points.push_back(1.0);
// Point 1
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(0);
cube.points.push_back(0.0);cube.points.push_back(1.0);
// Point 3
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(0);
cube.points.push_back(1.0);cube.points.push_back(0.0);
Pour que OpenGL le comprenne, il faut aussi le spécifier à la création du shader. Pour cela, on va remplacer les lignes de codes pour le paramètres par de nouvelles :
// Paramètre nécessaire pour la position
int taille_actuelle = 0;
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)taille_actuelle);
glEnableVertexAttribArray(0);
// Paramètre nécessaire pour la position de la texture
taille_actuelle = 3 * sizeof(float);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)taille_actuelle);
glEnableVertexAttribArray(1);
Pour en finir avec le passage de la variable, nous allons devoir la rajouter au vertex shader. La modification est assez simple : on n'a juste qu'à rajouter la variable en variable "in" et en variable "out" pour le fragment shader.
// Vertex

// Version d'OpenGL
#version 330 core

// Données du VBO (vec3 représente un point 3D)
layout(location = 0) in vec3 pos; // Position du point P
layout(location = 1) in vec2 pos_texture; // Position sur la texture du point P

out vec2 position_texture; // Position sur la texture du point P

// Données du programme principal
uniform mat4 transformation;

// Fonction de base du shader
void main() {
    // Passage des variables "out"
    position_texture = pos_texture;

    // Calcul du point retourné par le shader
    gl_Position = transformation * vec4(pos.xyz, 1.0);
}
Une petite chose à noter : OpenGL s'occupera d'attribuer à chaque pixel précis le "position_texture" nécessaire lors de la rasterization. Donc, pouvons maintenant afficher l'image comme bon nous semble.

Finalement, passons à la manipulation de la texture en elle même. Pour commencer, occupons nous du fragment shader. Nous avons deux choses à rajouter : la variable d'entrée "position_texture" et un moyen d'accéder à la texture, que nous ferons via une variable "uniform".

// Fragment

// Version d'OpenGL
#version 330 core

// Position du pixel dans la texture
in vec2 position_texture;

// Couleur sortie du shader
out vec4 FragColor;

// Texture nécessaire
uniform sampler2D texture_0;

// Fonction de base du shader
void main() {
    // Calcul de la couleur nécessaire, avec ici le rouge, ou RGBA(255, 0, 0, 255)
    FragColor = texture(texture_0, position_texture);
}
Ici, la texture est de l'objet "sampler2D", représentant un tableau d'objets 2D (ici, un tableau de pixels). Le nom "texture_0" est nécessaire ici (il y a plusieurs nom réservés de texture, dont "texture_0"). Pour avoir la bonne valeur du pixel de l'image selon "position_texture", nous utilisons la fonction OpenGL texture(). Elle nous facilitera le travail. Maintenant, les modifications devront être apportées au code principal. Pour en finir, passons et utilisons cette texture dans le code principal. Commençons par créer la texture.
// Charger la texture
Texture texture_1 = Texture("textures/labyrinthe.png");
glGenTextures(1, &texture_1.id);
Pour cela, on utilise (encore) le système avec un "unsigned int", et la fonction glGenTextures() d'OpenGL, qui fonctionne exactement comme les fonctions de génération d'OpenGL. En suite, chargeons la texture dans la mémoire d'OpenGL.
// Passer la texture
glBindTexture(GL_TEXTURE_2D, texture_1.id);
GLint color_format = GL_RGBA; if(texture_1.image.color_type() != 6){color_format = GL_RGB;}
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, texture_1.image.width(), texture_1.image.height(), 0, color_format, GL_UNSIGNED_BYTE, texture_1.image.datas()->datas());
glGenerateMipmap(GL_TEXTURE_2D);
Premièrement, nous indiquons à OpenGL que la texture 2D actuellement utilisée est "texture_1.id" avec la fonction glBindTexture(). Elle fonctionne de la même façon que "glBindBuffer()" ou "glBindVertexArray()". De plus, le shader va utiliser l'image actuellement "bind" lors de l'affichage, il faut donc y penser si vous utilisez plusieurs textures. En suite, nous cherchons si notre image est sous le forma "RGB" ou "RGBA" avec les outils de notre libraririe d'image. Nous stockong le résultat dans la variable "color_format", de type GLint (un "int" dans OpenGL). Si le type de couleur n'est pas 6, alors l'image est sous le format "RGB", et si le type est de 6, alors elle est sous le format "RGBA". Après, nous passons la texture à OpenGL avec la fonction glTexImage2D(). Son premier paramètre représente l'endroit où l'image sera stockée (donc, ici, l'image 2D actuellement utilisée, soit "texture_1.id"). Le deuxième paramètre représente le niveau de détail de l'image (contre toute attente, plus il est haut, moins l'image est détaillée). Le troisème paramètre représente interne de l'image représente le type de l'image stocké dans dans la mémoire OpenGL (RGB, RGBA...). Le quatrième paramètre représente la largeur de l'image, le cinquième sa hauteur, le sixième une valeur toujours à 0 (allez savoir pourquoi) et le septième représente le type de l'image que l'on va passer à OpenGL (RGB ou RGBA, selon "color_format"). Finalement, le dernier paramètre représente un pointeur vers un "char*" contenant l'image. Dés que cela est fait, nous appelons la fonction glGenerateMipmap(). Cette fonction va générer des mipmaps pour une certaine texture, ici la texture 2D actuellement utilisée. En fait, les mipmaps représentent des versions moins détaillées de l'image (et donc moins demandantes en ressources), qu'on peut utiliser à la place de l'image de base dans un contexte où l'objet l'utilisant est éloigné de la caméra (et donc, que la texture apparaît plus petite que prévue). Nous l'ajoutons pour des raisons de performances. Théoriquement, si tout est bien fait, tout devrait marcher comme prévu.

Cependant, paramétrons un peu le système de texture. Pour commencer, définissons le comportement de l'image lorsque l'on agrandit ou qu'on rétrécit l'image. Pour cela, nous aurons besoin d'une seule fonction : glTexParameter().

// Paramétrer la texture
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST); // Agrandissement de la texture
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST_MIPMAP_NEAREST); // Rétrécissement of the texture
En réalité, il existe plusieurs formes de cette fonction, toutes différenciées par une (ou des) lettre à la fin, représentant le type du paramètre pris (int, float...). Le premier paramètre représente l'image à paramétrer (ici, l'image 2D actuellement "bind"). Le deuxième paramètre représente le paramètre à modifier. Dans le premier cas, il s'agit du comportement de l'image lors d'un agrandissement. Dans le deuxième cas, il s'agit du comportement de l'image lors d'un rétrécissement. Le troisième paramètre représente le paramètre actuel de cette texture. Dans les deux cas, nous utilisons la méthode "GL_NEAREST_MIPMAP_NEAREST" : retournant le pixel le plus proche de la position précise de chaque point du VBO, sur le mipmap le plus proche correspondant au zoom actuel. D'autres valeurs existent, comme "GL_LINEAR", qui retourne un mélange des pixels les plus proches de la position précise de chaque point du VBO. Beaucoup de paramètres sont disponibles, documentés sur cette page. D'ailleurs, il est important de rajouter que la présence d'un canal alpha peut mettre OpenGL en difficulté. Pour y remédier, on va devoir activer les canaux alphas et dire à OpenGL comment les traiter. L'activation est assez simple.
// Activer le canal alpha
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
On utilise la fonction "glEnable()", qui permet d'activer un concept OpenGL, et on active "GL_BLEND", permettant de traiter les canaux alphas. En suite, on dit à OpenGL comment traiter l'opération permettant d'utiliser les canaux alphas (nommée le "blending"), avec "glBlendFunc()". Le premier paramètre représente les canaux sources de l'opération, ici le canal "alpha" via "GL_SRC_ALPHA". Le deuxième paramètre représente la façon de traiter ce canal, ici en appliquant l'algorithme basique de transparence "GL_ONE_MINUS_SRC_ALPHA". On doit rajouter cela après l'initialisation de Glad.

Avant d'en finir, il reste un dernier truc à représenter avec ce système de texture. Bien que, pour l'instant, nous n'utilisons que des textures externes, nous pouvons aussi utiliser les shaders (et leur puissance) pour générer des textures bien précises dans ces derniers. Cependant, pour faire cela, il nous faut de bon modèle mathématiques, capables de générer de tels images. Le meilleur exemple est l'ensemble de Mandelbrot, et le fractale qu'il forme. Il s'agit d'un ensemble de suites mathématiques de nombres complexes à deux paramètres (un complexe et un entier pour la suite), caractérisées par leur limite en + l'infini. En cherchant si une suite d'un certain nombre complexe tend vers + l'infini en + l'infini, et en lui attribuant une couleur sur le plan complexe selon le résultat, nous pouvons créer une forme... totalement imprévue. Pour plus d'informations, rendez-vous ici. Si vous voulez voir quelqu'un en développer un de manière plus précise, je vous conseille cette vidéo de dimension code. Voici un petit exemple de shader modifié pour accueillir un fractale de Mandelbrot.

// Fragment "Mandelbrot"

// Version d'OpenGL
#version 330 core

// Position du pixel dans la texture
in vec2 position_texture;

// Couleur sortie du shader
out vec4 FragColor;

// Texture nécessaire
uniform sampler2D texture_0;

// Fonction de base du shader
void main() {
    // On cherche la valeur complexe du pixel actuel (dans un plan complexe entre x = -5 et x = 5, et y y -5 et y = 5)
    float x = (position_texture.x - 0.5) * 5.0;
    float y = (position_texture.y - 0.5) * 5.0;

    // On définit le nombre d'itération de la suite actuel, ainsi que la limite à partir de laquelle la suite sera considérée comme "tendant vers + l'infini"
    float current_x=0;float current_y=0;
    float iter=0;
    float limit=100.0;

    // Boucle d'exécution pour la suite
    for(;iter<100;iter++){

        // On calcul les valeurs actuelles complexes de la suite
        float temp=current_x;
        current_x=current_x*current_x-current_y*current_y;
        current_y*=2.0*temp;
        current_x+=x;current_y+=y;

        // Si la suite est trop grande, on sort de la boucle
        if(sqrt(current_x*current_x+current_y*current_y)>limit){break;}
    }

    // Si la suite ne tend pas, on affiche du noir
    if(iter==100){FragColor = vec4(0, 0, 0, 1.0);}
    // Sinon, on affiche une couleur représentant la vitesse à laquelle la suite tend
    else{FragColor = vec4(iter / 100.0, iter / 100.0, 1.0, 1.0);}
}
Pensez bien à ce genre de propriétés pour les shaders, elles pourraient nous être très utiles dans les temps à venir.

Fractale de Mandelbrot

b. Utiliser de la 3D

Aussi effrayant qu'elle puisse être, l'ajout de la 3D ne sera pas si complexe que ça. Pour commencer, nousa allons définir un objet très utilisé dans les jeux vidéos : une caméra. Pour notre caméra, nous aurons besoin de quelques données assez évidentes.

// Structure pour une caméra
struct Camera {

    // Position de la caméra
    double x = 0;
    double y = 0;
    double z = 0;

    // Vecteur directeur de la vue de la caméra
    double angle = 1.570796;
    double vue_x = 0;
    double vue_y = 0;
    double vue_z = 1;

    // FOV de la caméra (en degrés d'angle)
    double fov = 45;
    // Distance de rendu de la caméra
    double rendu_loin = 1000;
    double rendu_proche = 0.001;
};
Heuresement, le combo GLM et calcul matriciel va nous permettre de rendre tout ça très facile. Commençons par implémenter les choses nécessaires dans le shader. En réalité, nous n'allons que rajouter 2 variables "uniform" dans le vertex shader : 2 matrices pour utiliser de la 3D.
// Vertex

// Version d'OpenGL
#version 330 core

// Données du VBO (vec3 représente un point 3D)
layout(location = 0) in vec3 pos; // Position du point P
layout(location = 1) in vec2 pos_texture; // Position sur la texture du point P

out vec2 position_texture; // Position sur la texture du point P

// Données du programme principal
uniform mat4 transformation;

// Données permettant d'afficher de la 3D
uniform mat4 projection;
uniform mat4 view;

// Fonction de base du shader
void main() {
    // Passage des variables "out"
    position_texture = pos_texture;

    // Calcul du point retourné par le shader
    gl_Position = projection * view * transformation * vec4(pos.xyz, 1.0);
}
Ici, "view" représente la matrice nécessaire pour utiliser la caméra et "projection" représente la matrice permettant d'utiliser un rendu 3D réaliste. Maintenant, importons ces valeurs depuis le programme principal.
// On définit la taille de la fenêtre au début du programme (dans des variables)
int hauteur_fenetre = 1000;
int largeur_fenetre = 1000;

...

// Gestion de la caméra
glm::mat4 projection = glm::perspective(glm::radians(cam.fov), static_cast<double>(largeur_fenetre) / static_cast<double>(hauteur_fenetre), cam.rendu_proche, cam.rendu_loin);
glm::mat4 view = glm::lookAt(glm::vec3(cam.x, cam.y, cam.z), glm::vec3(cam.x + cam.vue_x, cam.y + cam.vue_y, cam.z + cam.vue_z), glm::vec3(0, 1, 0));

...

// Passage des variables uniformes nécessaires
glUseProgram(shader);
int variable_uniform = glGetUniformLocation(shader, "projection");
glUniformMatrix4fv(variable_uniform, 1, GL_FALSE, glm::value_ptr(projection));
variable_uniform = glGetUniformLocation(shader, "transformation");
glUniformMatrix4fv(variable_uniform, 1, GL_FALSE, glm::value_ptr(transformation));
variable_uniform = glGetUniformLocation(shader, "view");
glUniformMatrix4fv(variable_uniform, 1, GL_FALSE, glm::value_ptr(view));
Comme vous l'avez remarquer, nous allons devoir profondément changer notre code. Au début du programme, définissons des valeurs spécifiques pour la taille de la fenêtre, qui nous seront utiles plus tard. En suite, dans la boucle d'exécution, nous créeons les matrices nécessaires à la projection et à la vue caméra. Pour cela, nous utiliserons deux fonctions de GLM. La première est "perspective()", permettant d'avoir la matrice de projection. Cette fonction prend pas mal de paramètres pour les calculs. Le premier paramètre représente le FOV en gradians de la caméra. Le deuxième paramètre représente le ratio largeur/hauteur de l'écran (c'est pour cela que nous avons créer des variables avant). Le troisième et le quatrième représentetn les valeurs "proche" et "loin" de la caméra. Après cela, occupons nous de la vue caméra, avec la fonction "lookAt()". Son première paramètre représente la position de la caméra. Le deuxième représente le point regardé de la caméra, ici juste devant elle. Le troisième paramètre représente un vecteur directeur du sens, que la caméra considère comme le sens "en haut", donc ici un vecteur pointant vers le haut. Finalement, envoyez toutes ces données à OpenGL, via les variables uniformes. Un petit dernier truc possible : placer correctement l'objet observé dans le plan (dans l'idéal, à la coordonnée (0, 0, 1), pour directement le voir). Dés que cela est fait, votre objet apparaître sous la forme d'un plan carré dans un univers 3D. Si vous voulez vous en rendre compte, essayez d'implémenter un moyen de déplacement de la caméra, et regardez ce que cela donne. Voici un petit exemple de code permettant de naviguer dans votre programme, avec les touches "ZQSD" et rotation via les flèches.
// Tourner la caméra
int touche_ra = glfwGetKey(window, GLFW_KEY_RIGHT);
if (touche_ra == GLFW_PRESS) { cam.angle += 3.1415 * static_cast<float>(static_cast<double>(current - last_frame) / std::pow(10, 9)); }
int touche_la = glfwGetKey(window, GLFW_KEY_LEFT);
if (touche_la == GLFW_PRESS) { cam.angle -= 3.1415 * static_cast<float>(static_cast<double>(current - last_frame) / std::pow(10, 9)); }

// Appliquer la rotation
cam.vue_x = std::cos(cam.angle);cam.vue_z = std::sin(cam.angle);

// Bouger la caméra
int touche_d = glfwGetKey(window, GLFW_KEY_D);
if (touche_d == GLFW_PRESS) {cam.x -= cam.vue_z * static_cast<float>(static_cast<double>(current - last_frame) / std::pow(10, 9)); cam.z += cam.vue_x * static_cast<float>(static_cast<double>(current - last_frame) / std::pow(10, 9));}
int touche_q = glfwGetKey(window, GLFW_KEY_A);
if (touche_q == GLFW_PRESS) {cam.x += cam.vue_z * static_cast<float>(static_cast<double>(current - last_frame) / std::pow(10, 9));cam.z -= cam.vue_x * static_cast<float>(static_cast<double>(current - last_frame) / std::pow(10, 9)); }
int touche_z = glfwGetKey(window, GLFW_KEY_W);
if (touche_z == GLFW_PRESS) {cam.x += cam.vue_x * static_cast<float>(static_cast<double>(current - last_frame) / std::pow(10, 9)); cam.z += cam.vue_z * static_cast<float>(static_cast<double>(current - last_frame) / std::pow(10, 9));}
int touche_s = glfwGetKey(window, GLFW_KEY_S);
if (touche_s == GLFW_PRESS) {cam.x -= cam.vue_x * static_cast<float>(static_cast<double>(current - last_frame) / std::pow(10, 9)); cam.z -= cam.vue_z * static_cast<float>(static_cast<double>(current - last_frame) / std::pow(10, 9));}
Pour faire un tel code, de simples connaissances en géométrie (et, plus précisément, en trigonométrie) sont nécessaires.

Fractale en 3D

Pour l'instant, on utilise un plan 2D en 3D. Cependant, comment utiliser des objets 3D ? Pour utiliser des objets purement 3D, le plus complexe va être de réaliser leur VBO. Bien que vous pouviez penser à des logiciels de modélisations 3D comem Blender, convertir les types d'objets utilisés par Blender en type "VBO" n'est pas vraiment plus simple que de les faire soit même (on aura le même problème que pour les images). Par exemple, réaliser un simple cube demande de dupliquer 6 fois le VBO vu plus haut. En réalité, ce ne sera pas un problème pour OpenGL, mais pour l'humain qui devra rédiger le VBO chiffres par chiffres. Dans ce cas, il est aussi conseillé de découper votre texture en, partie (sur une seule image), que chaque face ira précisément sélectionner, pour économiser les textures. Comme je suis gentil, voici le VBO nécessaire pour un cube.

// Création du VBO nécessaire
VBO cube;

// Création de la face Y+
// Point 1
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(0.5);
cube.points.push_back(0.0);cube.points.push_back(0.49);
// Point 2
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.0);cube.points.push_back(0.0);
// Point 3
cube.points.push_back(0.5);cube.points.push_back(0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.32);cube.points.push_back(0.0);
// Point 4
cube.points.push_back(0.5);cube.points.push_back(0.5);cube.points.push_back(0.5);
cube.points.push_back(0.32);cube.points.push_back(0.49);
// Point 1
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(0.5);
cube.points.push_back(0.0);cube.points.push_back(0.49);
// Point 3
cube.points.push_back(0.5);cube.points.push_back(0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.32);cube.points.push_back(0.0);

// Création de la face Y-
// Point 1
cube.points.push_back(-0.5);cube.points.push_back(-0.5);cube.points.push_back(0.5);
cube.points.push_back(0.0);cube.points.push_back(1.0);
// Point 2
cube.points.push_back(-0.5);cube.points.push_back(-0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.0);cube.points.push_back(0.51);
// Point 3
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.32);cube.points.push_back(0.51);
// Point 4
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(0.5);
cube.points.push_back(0.32);cube.points.push_back(1.0);
// Point 1
cube.points.push_back(-0.5);cube.points.push_back(-0.5);cube.points.push_back(0.5);
cube.points.push_back(0.0);cube.points.push_back(1.0);
// Point 3
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.32);cube.points.push_back(0.51);

// Création de la face Z+
// Point 1
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(0.5);
cube.points.push_back(0.34);cube.points.push_back(0.0);
// Point 2
cube.points.push_back(-0.5);cube.points.push_back(-0.5);cube.points.push_back(0.5);
cube.points.push_back(0.34);cube.points.push_back(0.49);
// Point 3
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(0.5);
cube.points.push_back(0.65);cube.points.push_back(0.49);
// Point 4
cube.points.push_back(0.5);cube.points.push_back(0.5);cube.points.push_back(0.5);
cube.points.push_back(0.65);cube.points.push_back(0.0);
// Point 1
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(0.5);
cube.points.push_back(0.34);cube.points.push_back(0.0);
// Point 3
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(0.5);
cube.points.push_back(0.65);cube.points.push_back(0.49);

// Création de la face Z-
// Point 3
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.65);cube.points.push_back(1.0);
// Point 2
cube.points.push_back(-0.5);cube.points.push_back(-0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.34);cube.points.push_back(1.0);
// Point 1
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.34);cube.points.push_back(0.51);
// Point 3
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.65);cube.points.push_back(1.0);
// Point 1
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.34);cube.points.push_back(0.51);
// Point 4
cube.points.push_back(0.5);cube.points.push_back(0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.65);cube.points.push_back(0.51);

// Création de la face X+
// Point 1
cube.points.push_back(0.5);cube.points.push_back(0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.67);cube.points.push_back(0.0);
// Point 2
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.67);cube.points.push_back(0.49);
// Point 3
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(0.5);
cube.points.push_back(1.0);cube.points.push_back(0.49);
// Point 4
cube.points.push_back(0.5);cube.points.push_back(0.5);cube.points.push_back(0.5);
cube.points.push_back(1.0);cube.points.push_back(0.0);
// Point 1
cube.points.push_back(0.5);cube.points.push_back(0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.67);cube.points.push_back(0.0);
// Point 3
cube.points.push_back(0.5);cube.points.push_back(-0.5);cube.points.push_back(0.5);
cube.points.push_back(1.0);cube.points.push_back(0.49);

// Création de la face X-
// Point 1
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.67);cube.points.push_back(0.51);
// Point 2
cube.points.push_back(-0.5);cube.points.push_back(-0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.67);cube.points.push_back(1.0);
// Point 3
cube.points.push_back(-0.5);cube.points.push_back(-0.5);cube.points.push_back(0.5);
cube.points.push_back(1.0);cube.points.push_back(1.0);
// Point 4
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(0.5);
cube.points.push_back(1.0);cube.points.push_back(0.51);
// Point 1
cube.points.push_back(-0.5);cube.points.push_back(0.5);cube.points.push_back(-0.5);
cube.points.push_back(0.67);cube.points.push_back(0.51);
// Point 3
cube.points.push_back(-0.5);cube.points.push_back(-0.5);cube.points.push_back(0.5);
cube.points.push_back(1.0);cube.points.push_back(1.0);
Comme dit auparavant, chaque face à sa propre partie de la texture. Voici un exemple de texture fonctionnant avec ce VBO. Ne jugez pas mes qualités de dessins (s'il vous plaît).

Smiley

Ce VBO est fait pour être assez simple d'utilisation avec les textures. Le carré en haut à gauche représente la face haute (Y+), et celui en bas à gauche représente la face basse (Y-). Les deux carrés du milieu représentent la face avec z = 0.5 (Z+) et z = -0.5 (Z-). Dans les deux cas de cette texture, la partie droite représente x = 0.5, pour pouvoir facilement orienter la texture. Enfin, les deux carrés de droites représentent la face avec x = 0.5 (X+) et x = -0.5 (X-). Comme pour Z+ et Z-, la partie droite représente z = 0.5, pour pouvoir facilement orienter la texture. Si vous testez l'affichage comme ça, un phénomène étrange se passe : les faces arrières sont dessinées avant les faces avant. En effet, par défaut, OpenGL dessine les faces comme elles viennent, sans tester laquelle est derrière l'autre. En 3D, cela est très embêtant. Pour y remédier, nous allons ajouter deux lignes de codes à notre programme. Premièrement, nous allons indiquer à OpenGL qu'il va devoir prendre en compte la profondeur pendant l'affichage.

// Activer le test de profondeur
glEnable(GL_DEPTH_TEST);
Pour cela, on utilise la fonction glEnable() d'OpenGL, qui prend en paramètre un concpet a activer. Il faut absolument mettre cette fonction après l'initialisation de Glad. En suite, on va aller modifier notre ligne utilisant "glClear()".
// Nettoyer l'écran et le buffer du test de profondeur
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
Il est possible que la modification soit déjà faites, mais vérifiez quand même au cas ou. Comme ça, on réalise un test par frame, pour avoir une image propre tout le temps. Maintenant que vous avez ça, vous pouvez mieux comprendre les VBOs et leur fonctionnement, et créer tout ce que vous voulez.

Smiley 3D

c. Pleinement contrôler votre fenêtre OpenGL

Pour en finir, voyons quelques petits trucs assez ennuyants, mais très pratiques pour faire une fenêtre OpenGL. Commençons par traiter quelque chose d'assez important : certains évènements de l'application. En effet, comme nous l'avons vu beaucoup plus tôt, la fonction "glfwPollEvents()" permet de traiter les évènements dans le logiciel. Là où certains évènements peuvent être utiliser via des fonctions directes, comme "glfwGetKey()", d'autres ne fonctionnent pas comme ça. Pour être précis, OpenGL va utiliser des fonctions dites "callback" pour traiter ces évènements. Ce sont des fonctions qui seront appelées quand ces évènements arriveront. Pour être précis, nous allons passer des pointeurs vers ces fonctions à OpenGL, qui auront pour tâche d'avertir le logiciel que cet évènement a lieu. Ne vous inquiétez pas, toutes les informations de documentations sont présentes ici (GLFW s'en charge). Pour en citer quelques une importantes : glfwSetCursorEnterCallback(), glfwSetDropCallback() ou même glfwSetFramebufferSizeCallback().

Pour en finir, je vais vous présenter une dernière fonction. Quand vous redimensionner la fenêtre, rien ne change en elle. Premièrement, un redimensionnement de fenêtre est connue via une fonction callback, ici glfwSetFramebufferSizeCallback(). Deuxièmement, même avec cette fonction, aucun objet ne va changer dans la fenêtre. En effet, pour correctement redimensionner une fenêtre OpenGL, il faut appeler une autre fonction : glViewport().

// Fonction "callback" d'un redimensionnement de la fenêtre
void framebuffer_size_callback(GLFWwindow* window, int width, int height) {
    int screen_height = height; // Peut aussi être une variable globale
    int screen_width = width; // Peut aussi être une variable globale
    glViewport(0, 0, screen_width, screen_height);
}
Cette fonction prend 4 paramètres. Les deux premiers représentent une valeur "x" et "y" pour certains appareils utilisant des systèmes de coordonnées normalisés. Sur Windows et Linux, nous pouvons les mettre à 0. Les deux derniers paramètres représentent la largeur et la hauteur de la fenêtre. Maintenant, votre fenêtre OpenGL devrait être bien dimensionnée.