Vous souhaitez réaliser un jeu-vidéo, mais vous ne savez pas par où commencer ? Eh bien bonne nouvelle, vous êtes au bon endroit. Nous allons apprendre à utiliser une librairie graphique puissante : OpenGL (version 3.3). Nous découvrirons ensemble les bases essentielles de la programmation 3D qui pourront vous servir plus tard dans un projet de jeu multi-plateforme.
Sachez qu'avant d'aller plus loin, il vous faut un PC soit sous Windows soit sous une distribution UNIX/Linux. Pour les utilisateurs de Mac, c'est un peu spécial, mais pour continuer il vous faudra au moins avoir le système d'exploitation OS X Lion (10.7) d'installé. Si vous possédez un de ces trois OS, il vous faudra aussi une carte graphique compatible avec l'API OpenGL 3.3. Les plus anciennes cartes compatibles sont les GeForce de la série 8000 chez NVidia et les Radeon HD chez ATI. Si vous possédez une carte inférieure à celles-ci, vous pouvez toujours suivre le tutoriel de Kayl sur OpenGL 2.1 ici :
Dans ce chapitre introductif, je vais vous parler rapidement d'OpenGL, de ses atouts et de l'installation des différents outils que nous utiliserons tout au long de ce tutoriel.
OpenGL est une librairie exploitant la carte graphique d'un ordinateur permettant ainsi aux développeurs de programmer des applications 2D et 3D. Cela permet en autres de développer des jeux-vidéo. L'avantage principal d'OpenGL est qu'il est multi-plateforme, c'est-à-dire qu'un programme codé avec OpenGL sera compatible avec Windows, Linux et Mac, sous réserve que la gestion de la fenêtre et des inputs soient également multi-plateforme (comme la SDL ;) ). Nous utiliserons une version récente d'OpenGL qui est la version 3.3 (sortie le 11 mars 2010). Cependant, nous n'utiliserons que la 3.1 pour la première partie du tuto, vous verrez pourquoi au fur et à mesure.
Actuellement, deux API existent pour exploiter notre matériel graphique : DirectX (uniquement utilisable sous Windows) et OpenGL (multi-plateforme). Vous l'aurez compris, OpenGL est dans un sens plus intéressant du fait de sa portabilité.
(Screenshot issu du jeu "Minecraft" proposant un affichage OpenGL)
Un des autres avantages d'OpenGL est que son utilisation est totalement gratuite, vous pouvez très bien coder des programmes gratuits voire commerciaux sans rendre de compte à personne. Vous pouvez aussi fournir votre code source pour en faire profiter la communauté ;) .
En plus d'être gratuite, cette librairie met à disposition son propre code source. Chacun d'entre nous peut aller voir le code source d'OpenGL librement; et c'est d'ailleurs ce que nous allons faire pour comprendre le fonctionnement de certaines fonctions ^^ .
Dans l'introduction du tutoriel, je vous ai parlé du langage C++. Ce langage de programmation est le langage le plus utilisé dans le monde du jeu vidéo et c'est pour cette raison que nous allons l'utiliser.
Le but de ce tutoriel est de vous apprendre les bases de la programmation d'un jeu vidéo. Et qui dit jeu vidéo, dit aussi ... mathématiques ! Alors oui je comprends que certains maudissent les maths au plus haut point à cause des mauvaises notes à l'école ou de son incompréhensible logique :colere2: . Mais si vous souhaitez vraiment développer un jeu vous ne passerez pas à coté.
N'ayez cependant pas peur des maths, il n'y a rien de compliqué dans ce que nous allons voir. Il suffit d'apprendre par cœur. Et si vous avez un trou de mémoire, vous pourrez toujours revenir voir les formules sur le site du zéro. Pas de contrôle non plus, donc pas de pression. La plupart des notions sont déjà expliquées dans les tutos du SdZ. Nous aborderons pas mal de domaines comme les vecteurs, les matrices, les quaternions, la trigonométrie, ... Que du bon en somme !
Bien, j'espère que la pilule est passée. :p
Que faut-il télécharger ?
Ah bonne question. Tout d'abord, comme dit dans l'introduction, il vous faut une carte graphique compatible avec OpenGL 3.x, soit au minimum les GeForce 8000 chez NVidia et les Radeon HD chez ATI. Ensuite, il faut mettre à jour vos drivers pour être sûr de ne pas avoir de problèmes plus tard.
Deuxièmement, il vous faut la librairie SDL installée. M@teo explique comment installer cette librairie dans son tuto, cependant la version donnée est incompatible avec la version d'OpenGL que nous allons utiliser. Il faudra donc passer par une autre version.
Bien, passons à l'installation :) et commençons par la SDL :
Pourquoi a-t-on besoin de la SDL me direz-vous ?
La raison est simple : OpenGL a besoin d'un contexte d'exécution dans lequel travailler, il faut d'abord créer ce contexte avant de pouvoir créer notre monde 3D. De plus, OpenGL ne sait pas gérer les inputs (clavier, souris, ...), il nous faut donc une librairie capable de savoir ce que fait l'utilisateur. Heureusement pour nous, la librairie SDL (que vous devez déjà connaitre ;) ) sait créer un contexte OpenGL et gérer les évènements. En combinant la SDL et OpenGL nous nous retrouvons avec un monde 3D interactif.
Il y a d'autres librairies capables de remplir ce rôle mais l'avantage de la SDL est qu'elle est, elle-aussi, multi-plateformes. Les programmes codés avec ces deux librairies fonctionneront aussi bien sous Linux que sous Windows (ainsi que toutes les plates-formes gérant les deux librairies).
Actuellement, la version la plus stable de la SDL est la version 1.2.x mais depuis quelques temps la compatibilité de la SDL tend à s'étendre sur toutes les plateformes : sur iPhone, Androïd, et même sur PS3 ! Cette nouvelle version de la SDL est pour le moment la version 2.0. Elle est encore en développement et n'est pas stable à 100% mais elle le sera dans l'avenir. D'ailleurs, elle n'est pas disponible officiellement, notre principal problème va être de devoir la compiler par nous-même afin de pouvoir l'utiliser. ;)
Hein ?! Compiler la SDL ! Ça va pas, je sais pas faire ça moi !
Ne vous inquiétez pas ça va être très facile, les développeurs de la SDL sont intelligents, il nous suffit d'exécuter quelques commandes dans le terminal et hop on a la librairie compilée. ;)
Mais avant cela pour les utilisateurs de Windows : vérifiez bien que les drivers de votre carte graphique sont à jour. Pour le reste, je vais vous fournir directement les fichiers compilés à inclure dans le répertoire de votre IDE, pourvu qu'il soit équipé du compilateur MinGW. Pour les utilisateurs de Visual C++, vous devrez compiler la librairie SDL et télécharger la librairie GLEW.
Pour les utilisateurs de Linux, vérifiez également que les drivers de votre carte graphique (Pilotes Propriétaires) sont à jour.
Pour Mac OS X, ça va être un peu spécial. Dans un premier temps, il vous faut être obligatoirement sous OS X 10.7 Lion ou plus. Cependant, pour utiliser OpenGL chez vous il faudra utiliser des notions assez complexes que l'on ne verra que dans la deuxième partie du tuto. Je vous conseille donc d'utiliser une version libre de Linux (comme Ubuntu) ou Windows si vous avez bootcamp pour suivre ce début de tutoriel. Un aparté est prévu pour vous quand nous aurons appris tout ce qui est nécessaire pour coder sous Mac.
Pour en revenir à Windows, si vous êtes utilisateur de Visual C++ vous devriez compiler la librairie vous-même, je vous donne le lien pour que le faisiez sans problème. Vous devrez aussi télécharger la librairie GLEW.
Pour MinGW sous Windows : dézippez l'archive et placez le dossier "SDL-2.0" dans le répertoire de MinGW. Si vous utilisez CodeBlocks, ce répertoire se trouve probablement dans C:\Program Files (x86)\CodeBlocks\MinGW. Attention cependant, placez bien le dossier "SDL-2.0" et pas ceux qui se trouvent à l'intérieur. Les dossiers "dll", "bin", "include" et "lib" doivent rester à l'intérieur.
Pour les linuxiens, téléchargez l'archive contenant le code source de la SDL et dézippez son contenu dans votre home (Exemple : /home/Boouh). Ensuite, ouvrez votre terminal et exécutez les commandes suivantes. Elle vont vous permettre de compiler puis d'installer la SDL :
sudo apt-get install libgl1-mesa-dev build-essential
cd
cd SDL-2.0/SDL-2.0.0-6713/
chmod +x configure
sudo ./configure
make
sudo make install
sudo ln -s /usr/local/bin/sdl2-config /usr/bin/sdl2-config
cd ..
sudo cp -r GL3/ /usr/local/include/
sudo cp -r glm/ /usr/local/include/
sudo chmod -R 715 /usr/local/include/GL3/
Grâce à ces commandes, vous avez maintenant la librairie SDL 2.0 installée sur votre ordinateur. ;)
Différences entre Windows et Linux
Dans l'archive pour Windows, j'ai inclus les fichiers compilés de la librairie GLEW. Pour ceux qui ne connaissent pas, GLEW est une librairie permettant de charger des extensions pour OpenGL (un peu comme les extensions des jeux Sims). Cette librairie est à l'origine utilisée sur Windows ET sur Linux. Mais avec la version 3.0 d'OpenGL, une grande partie des extensions de la version précédente sont devenues officielles et sont donc déjà inclues avec la version de "base".
Cependant, cette officialisation ne s'est pas faite sous Windows, il faudra donc toujours utiliser GLEW sous Windows. Nous verrons cela en détails un peu plus tard.
Pour les linuxiens, vous trouverez dans votre archive l'include "gl3.h" qui vient remplacer "gl.h". Vous utiliserez donc ce nouvel include (et non glew.h) pour les futurs chapitres.
Alors là, je conseille à ceux qui connaissent OpenGL 2.1 de bien s'assoir au risque de tomber dans les pommes :-° . Cette partie ne concerne pas uniquement ceux qui ont déjà codé avec OpenGL, vous ne comprendrez pas tout mais ça vous concerne aussi.
Tout d'abord, ce qu'il faut savoir c'est qu'avec la version 3, une grande partie des fonctions ont été marquées comme dépréciées. C'est-à-dire que le programmeur était fortement invité à ne plus les utiliser. Un peu plus tard, avec la version 3.1, le groupe Khronos a finalement décidé de supprimer ces fonctions dépréciées afin que les développeurs ne soient plus tentés de les utiliser.
Pourquoi a-t-on supprimé des fonctions me direz-vous ? Et bien tout simplement parce qu'elles ne sont plus adaptées aux jeux de nos jours. Soit elles ne sont plus utilisées, soit elles sont trop lentes. Imaginez une course de voitures avec des voitures qui vont à 30km/h ... Passionnant !
Ok des fonctions ont été supprimées, mais je ne vois pas ce qui peut me faire tomber dans les pommes. :p
Détrompez-vous, car certaines de ces fonctions étaient très utilisées avant.
Je vais prendre un exemple : glVertex(...)
Cette fonction permet avec OpenGL 2.1 de définir la position d'un point dans un espace 3D. Nous de notre coté, nous spécifions la position du point dans l'espace et la carte graphique s'occupait du reste. C'est-à-dire qu'elle multipliait les coordonnées du point par la matrice "modelView" puis par la matrice de projection, puis elle définissait sa couleur, etc ...
Maintenant c'est NOUS qui allons devoir faire TOUTES ces opérations.
QUOI ??!! Mais c'est nul ! On se retrouve avec quelque chose de plus compliqué maintenant. :(
Non c'est très bien au contraire puisqu'on se débarrasse d'une fonction lente, puis surtout, ça nous permet de faire ce que l'on veut. Pour reprendre l'exemple de la course, sans ces fonctions nous pourrons "tuner" notre voiture comme nous le voulons. Elle sera plus rapide, plus maniable et on se débarrassera de tout ce qui nous ralentit. Toutes les fonctions telles que glVertex, glColor, glLightv ... sont désormais inutilisables (et c'est tant mieux).
Ah oui, j'allais oublier, vous connaissez les matrices de projection et tout le reste ? Et bien comme vous le pensez (même si vous espérez vous tromper), ces matrices sont supprimées elles-aussi. Nous devrons donc créer notre propre matrice de projection, modelview, ...
La suppression des matrices entraine également la suppression des fonctions gluPerspective et gluLookAt. Heureusement pour nous, il existe une librairie parallèle à OpenGL qui s'appelle GLM (pour OpenGLMathematics). Cette librairie permet de faire pas mal de calculs mathématiques et permet surtout d'utiliser les matrices sans avoir à tout coder nous-même. Elle est incluse dans le téléchargement que vous avais fait juste avant. ;)
Vous vous dites peut-être que toutes ces suppressions sont injustes, tout est fait pour vous décourager. Eh bien non, ces suppressions ne peuvent être que positives car elles nous obligent à personnaliser complétement notre programme, nous pourrons donc mieux exploiter notre matériel et créer des jeux plus puissants.
Vous savez désormais ce qu'est OpenGL et avec quels outils nous allons travailler. J'expliquerai plus en détails le fonctionnement d'OpenGL dans une autre partie. Dans le chapitre suivant, nous allons écrire nos premières lignes de code, mais attention la 3D ce sera pour un peu plus tard. ;)
Dans ce chapitre nous allons créer une fenêtre avec la librairie SDL. Pour ceux qui connaissent la SDL avec le tuto de M@teo, vous pourrez comparer les deux versions. ;)
Pour coder à travers ce tutoriel, je vous recommande d'utiliser l'IDE Code::Blocks qui est un outil très utilisé sur le site du Zéro. :) Vous pourrez donc trouver plus facilement de l'aide en cas de problème. Pour ceux qui souhaitent l'utiliser, voici un lien pour le télécharger (Windows uniquement). Il existe deux versions de cet IDE, vous pouvez choisir celle que vous voulez, les projets donnés devraient fonctionner sur les deux sans problème.
D'ailleurs, si vous utilisez cet IDE je vais pouvoir vous fournir directement le template nécessaire pour créer un projet SDL 2.0. Ce template vous permettra de linker les librairies automatiquement sans que vous ayez à faire des manips compliquées. ;)
Pour Windows, dézippez l'archive et fusionnez le dossier "share" de l'archive avec le dossier "share" de Code::Blocks (chez moi : C:\Program Files (x86)\CodeBlocks\share).
Pour Linux, dézippez l'archive où vous voulez puis exécutez les commandes suivantes (pas dans le dossier "share" mais dans celui qui le contient) :
Nous pouvons maintenant créer notre premier programme en SDL 2.0. :D
Pour cela, il faut d'abord créer un projet SDL 2.0 et non pas OpenGL, ne vous trompez pas ! Sous Code::Blocks la procédure est la suivante :
. File -> New -> Project -> SDL 2.0 project
Vous vous souvenez du dossier "SDL-2.0" du chapitre précédent ? Si vous ne l'avez pas encore installé au bon endroit faites-le maintenant :) (reportez-vous au chapitre précédent).
Occupons-nous maintenant du linkage d'OpenGL, il faut dire à notre IDE que nous souhaitons utiliser cette librairie sinon il va nous mettre plein d'erreurs au moment de la compilation. Voici la procédure à effectuer sous Code::Blocks :
Un petit encadré vous demande quelle librairie vous souhaitez linker, cela dépend de votre système d'exploitation. Vous ne pouvez renseigner qu'une seule librairie par encadré. Ré-appuyez sur le bouton Add pour en ajouter une nouvelle.
IDE
Option
Code::Blocks Windows
opengl32 glew32
Code::Blocks Linux
GL
DevC++
-lopengl32 -lglew32
Visual Studio
opengl32.lib glew32.lib
Selon votre OS, il suffit d'ajouter le ou les mot-clef(s) dans le petit encadré.
Pour les utilisateurs de CodeBlocks sous Windows et uniquement sous Windows, Code::Blocks va certainement vous demander où se trouve la SDL avec cette popup :
Dans le champ base qui vous est proposé, renseignez le chemin vers le répertoire "SDL-2.0" que vous avez installé précédemment (chez moi : C:\Program Files (x86)\CodeBlocks\MinGW\SDL-2.0). Ne renseignez aucun autre champ et ça devrait être bon. Une popup vous indiquera surement un message du genre "Please select a valid location", mais ce sera une fausse alerte ne vous en faites pas, appuyez sur le bouton next. ;)
En parlant de ce dossier, si vous avez jeté un coup d’œil à l'intérieur vous vous apercevrez qu'il y a un sous-dossier nommé "dll". Ce dossier contient les fichiers SDL2.dll et glew32.dll, ils sont indispensables pour lancer vos futurs programmes.
Vous avez deux solutions pour les utiliser : soit vous les incluez dans le dossier de chaque projet, soit vous les copier dans le dossier "bin" du compilateur MinGW (chez moi : C:\Program Files (x86)\CodeBlocks\MinGW\bin). La première solution est la plus propre mais aussi la plus contraignante, faites comme bon vous semble mais il faut que vous utilisiez au moins une de ces techniques pour lancer vos programmes. ;)
Pour ceux qui utilisent d'autres IDE comme Visual C++ ou Eclipse vous allez devoir renseigner d'autres librairies car je n'ai pas de template tout fait à vous proposer. Il faudra que vous renseigneriez les dossiers où se trouvent les librairies et les includes. De plus, vous devrez linker manuellement deux librairies supplémentaires :
Nous avons tous les outils, ceux-ci sont tous configurés. Bien, commençons. :magicien:
Voici un code de base permettant de créer une fenêtre avec la SDL 1.2 :
#include <SDL/SDL.h>
#include <iostream>
int main(int argc, char **argv)
{
// Notre fenêtre et le clavier
SDL_Surface *fenetre(0);
SDL_Event evenements = {0};
bool terminer(false);
// Initialisation de la SDL
if(SDL_Init(SDL_INIT_VIDEO) < 0)
{
std::cout << "Erreur lors de l'initialisation de la SDL : " << SDL_GetError() << std::endl;
SDL_Quit();
return -1;
}
// Création de la fenêtre
fenetre = SDL_SetVideoMode(800, 600, 32, SDL_HWSURFACE);
// Boucle principale
while(!terminer)
{
SDL_WaitEvent(&evenements);
if(evenements.type == SDL_QUIT)
terminer = true;
}
// On quitte la SDL
SDL_Quit();
return 0;
}
Facile non ? Et bien maintenant, je vais vous demander d'oublier la moitié de ce code. :D
Quoi ? C'est si différent que ça ?
Oui en effet, car souvenez-vous que nous travaillons avec la version 2.0. Et vu que l'on passe d'une version 1.x à une version 2.0, le code change beaucoup.
Commençons à coder avec la SDL 2.0 pour se mettre l'eau à la bouche :
#include <SDL2/SDL.h>
#include <iostream>
int main(int argc, char **argv)
{
// Initialisation de la SDL
if(SDL_Init(SDL_INIT_VIDEO) < 0)
{
std::cout << "Erreur lors de l'initialisation de la SDL : " << SDL_GetError() << std::endl;
SDL_Quit();
return -1;
}
// On quitte la SDL
SDL_Quit();
return 0;
}
Bon jusqu'ici, pas de grand changement. Mis à part l'inclusion de la SDL qui passe de "SDL/SDL.h" à "SDL2/SDL.h". En même temps on ne peut pas changer grand chose. :p
Voici le nouveau code qui permet de créer une fenêtre :
#include <SDL2/SDL.h>
#include <iostream>
int main(int argc, char **argv)
{
// Notre fenêtre
SDL_Window* fenetre(0);
// Initialisation de la SDL
if(SDL_Init(SDL_INIT_VIDEO) < 0)
{
std::cout << "Erreur lors de l'initialisation de la SDL : " << SDL_GetError() << std::endl;
SDL_Quit();
return -1;
}
// Création de la fenêtre
fenetre = SDL_CreateWindow("Test SDL 2.0", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 800, 600, SDL_WINDOW_SHOWN);
// On quitte la SDL
SDL_DestroyWindow(fenetre);
SDL_Quit();
return 0;
}
Nous voyons ici deux choses importantes, premièrement :
SDL_Window* fenetre;
Le pointeur SDL_Window remplacera désormais notre fenêtre, il n'y a donc plus de SDL_Surface. Maintenant notre fenêtre aura sa structure à part entière bien différente des surfaces classiques.
Vous l'aurez compris, cette fonction va nous permettre de créer notre fenêtre, elle vient donc remplacer notre bonne vielle SDL_SetVideoMode. Voici le prototype de cette fonction :
SDL_Window* SDL_CreateWindow(const char *title, int x, int y, int w, int h, Uint32 flags);
title : le titre de notre fenêtre
x : l'abscisse de la position de la fenêtre, SDL_WINDOWPOS_CENTERED signifie que nous centrons la fenêtre par rapport à l'axe x
y : l'ordonnée de la position, SDL_WINDOWPOS_CENTERED signifie la même chose mais sur l'axe y
w : (widht) la largeur de la fenêtre
h : (height) la hauteur de la fenêtre
flags : ce paramètre est un peu spécial, il faudra toujours mettre SDL_WINDOW_SHOWN. Plus tard, ce sera ici que l'on indiquera à la SDL que nous souhaitons utiliser OpenGL
Cette fonction retourne notre fenêtre dans un pointeur de type SDL_Window.
Quant à la dernière fonction, son utilisation est simple : elle permet de détruire proprement notre fenêtre :
SDL_DestroyWindow(SDL_Window *window);
Nous lui donnerons la fenêtre créée au début du programme.
SDL_DestroyWindow(fenetre);
Puisque l'on parle de l'initialisation de la fenêtre, on va en profiter pour vérifier la valeur du pointeur SDL_Window. En effet dans de rares cas, celui-ci peut être nul à cause d'un éventuel problème logiciel ou matériel. On va donc tester sa valeur dans un if, s'il est nul alors on quitte la SDL en fournissant un message d'erreur, sinon c'est que tout va bien donc on continue :
// Création de la fenêtre
fenetre = SDL_CreateWindow("Test SDL 2.0", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 800, 600, SDL_WINDOW_SHOWN);
if(fenetre == 0)
{
std::cout << "Erreur lors de la creation de la fenetre : " << SDL_GetError() << std::endl;
SDL_Quit();
return -1;
}
Ajoutons maintenant le code gérant les évènements qui connait, lui-aussi, son lot de modifications.
Avant, pour savoir si une fenêtre devait se fermer, nous devions vérifier la variable type d'une structure SDL_Event comme ceci par exemple :
Avec la SDL 2.0, la gestion des évènements change quelques peu (nous verrons les différences un peu plus tard). Maintenant, pour savoir si on doit fermer une fenêtre, nous ferrons ceci :
Tout ce code c'est bien mais il n'y a rien qui permet d'exploiter OpenGL, mais qu'attendons-nous ? :diable:
Il y a pas mal d'attributs (des options de configuration) à paramétrer pour rendre notre programme compatible avec OpenGL, commençons par le plus important :
SDL_GLContext contexteOpenGL;
Cette structure va permettre à la SDL de créer un contexte OpenGL. Ce contexte est très important, pour utiliser l'API graphique et ses fonctions. Il faut tout d'abord le configurer.
Voici le prototype de la fonction qui permet de spécifier des attributs OpenGL à la SDL :
SDL_GL_SetAttribute(SDL_GLattr attr, int value);
attr : c'est notre attribut, nous verrons lesquels il faut spécifier
value : c'est la valeur de l'attribut
Avec cette simple fonction, nous allons pourvoir spécifier à la SDL quelle version d'OpenGL on souhaite utiliser :
Cette fonction demande un pointeur SDL_Window pour y attacher le contexte OpenGL. De plus, elle renvoie une structure SDL_GLContext, et justement c'est ce dont nous avons besoin :)
Dans notre cas, nous lui donnerons notre fenêtre puis nous récupéreront la structure retournée :
contexteOpenGL = SDL_GL_CreateContext(fenetre);
Comme pour la fenêtre SDL, la création du contexte OpenGL peut lui aussi échouer. Le plus souvent ce sera parce que la version OpenGL demandée ne sera pas supportée par la carte graphique. Il faut donc tester la valeur de la variable contexteOpenGL. Si elle est égale à 0 c'est qu'il y a eu un problème. Dans ce cas on affiche un message d'erreur, on détruit la fenêtre puis on quitte la SDL :
Si tout se passe bien lors de la création du contexte alors on continue.
Tout comme pour la fenêtre SDL encore une fois, il existe aussi une fonction qui permet de détruire le contexte lorsque nous n'en avons plus besoin. Son appel ressemble à ceci :
SDL_GL_DeleteContext(contexteOpenGL);
Bien, résumons tout cela :
#include <SDL2/SDL.h>
#include <iostream>
int main(int argc, char **argv)
{
// Notre fenêtre
SDL_Window* fenetre(0);
SDL_GLContext contexteOpenGL(0);
SDL_Event evenements;
bool terminer(false);
// Initialisation de la SDL
if(SDL_Init(SDL_INIT_VIDEO) < 0)
{
std::cout << "Erreur lors de l'initialisation de la SDL : " << SDL_GetError() << std::endl;
SDL_Quit();
return -1;
}
// Version d'OpenGL
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1);
// Double Buffer
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
// Création de la fenêtre
fenetre = SDL_CreateWindow("Test SDL 2.0", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 800, 600, SDL_WINDOW_SHOWN);
if(fenetre == 0)
{
std::cout << "Erreur lors de la creation de la fenetre : " << SDL_GetError() << std::endl;
SDL_Quit();
return -1;
}
// Création du contexte OpenGL
contexteOpenGL = SDL_GL_CreateContext(fenetre);
if(contexteOpenGL == 0)
{
std::cout << SDL_GetError() << std::endl;
SDL_DestroyWindow(fenetre);
SDL_Quit();
return -1;
}
// Boucle principale
while(!terminer)
{
SDL_WaitEvent(&evenements);
if(evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
}
// On quitte la SDL
SDL_GL_DeleteContext(contexteOpenGL);
SDL_DestroyWindow(fenetre);
SDL_Quit();
return 0;
}
On est presque au bout courage. :p Il nous faut juste rajouter un paramètre dans la fonction GL_CreateWindow. Avec la SDL 1.2, ce paramètre ressemblait à ça :
SDL_SetVideoMode(.., .., .., SDL_OPENGL);
Avec la SDL 2.0, le nom change légèrement :
SDL_WINDOW_OPENGL
Il se place également dans la fonction qui créé la fenêtre :
#include <SDL2/SDL.h>
#include <iostream>
int main(int argc, char **argv)
{
// Notre fenêtre
SDL_Window* fenetre(0);
SDL_GLContext contexteOpenGL(0);
SDL_Event evenements;
bool terminer(false);
// Initialisation de la SDL
if(SDL_Init(SDL_INIT_VIDEO) < 0)
{
std::cout << "Erreur lors de l'initialisation de la SDL : " << SDL_GetError() << std::endl;
SDL_Quit();
return -1;
}
// Version d'OpenGL
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1);
// Double Buffer
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
// Création de la fenêtre
fenetre = SDL_CreateWindow("Test SDL 2.0", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 800, 600, SDL_WINDOW_SHOWN | SDL_WINDOW_OPENGL);
if(fenetre == 0)
{
std::cout << "Erreur lors de la creation de la fenetre : " << SDL_GetError() << std::endl;
SDL_Quit();
return -1;
}
// Création du contexte OpenGL
contexteOpenGL = SDL_GL_CreateContext(fenetre);
if(contexteOpenGL == 0)
{
std::cout << SDL_GetError() << std::endl;
SDL_DestroyWindow(fenetre);
SDL_Quit();
return -1;
}
// Boucle principale
while(!terminer)
{
SDL_WaitEvent(&evenements);
if(evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
}
// On quitte la SDL
SDL_GL_DeleteContext(contexteOpenGL);
SDL_DestroyWindow(fenetre);
SDL_Quit();
return 0;
}
Vous devriez obtenir quelque chose comme ceci :
Quoi ! Mais on a une fenêtre blanche c'est nul !
Du calme, vous n'imaginez pas ce que l'on vient de faire (enfin ce que la SDL vient de faire), nous venons de créer un contexte dans lequel OpenGL va évoluer. En gros nous venons de créer l'univers. :D
Bref nous avons enfin une fenêtre SDL compatible avec OpenGL 3.1. Comparez le code avant et après, vous verrez la différence. Il y a plus de code certes, mais toutes ces instructions sont importantes. Ce nouvel environnement va nous permettre de créer des programmes plus paramétrables et donc plus puissants. ;)
Piouf, ça fait beaucoup de changements pour arriver grosso modo à la même chose. Mais au moins on passe à la nouvelle génération de programme. Bon, on a une fenêtre c'est bien, mais pour le moment on a linké OpenGL pour rien. Passons maintenant à la partie OpenGL :pirate: .
Nous allons apprendre ici à faire nos premiers affichages dans la fenêtre SDL. Les notions que nous allons aborder nous seront utiles tout au long de ce tutoriel et constituent réellement la base du fonctionnement d'OpenGL. Vous aurez l'occasion de vous exercer à travers quelques exercices. Je ré-utiliserai ce procédé régulièrement car il n'y a rien de mieux que la pratique pour apprendre quelque chose.
Ceci étant dit, commençons dès maintenant ce nouveau chapitre. :)
Nous allons commencer ce chapitre par un peu de théorie. :p
Comment fonctionne OpenGL ?
Basiquement, OpenGL fonctionne avec un système de "points" que nous plaçons dans un monde 3D. Nous appelons un point, un vertex (au pluriel : vertices), en français on peut dire un sommet. La première chose à faire avec ces vertices, c'est leur donner une position.
Une position dans un monde 3D est constituée de 3 coordonnées: x (la largeur), y (la hauteur) et z (la profondeur). Chaque vertex doit avoir ses propres coordonnées. Les vertices sont à la base de tout, ils forment tout ce que vous voyez dans un jeu-vidéo : le héros, une arme, une voiture, des chaussettes ...
Une fois les vertices définis, on les relie entre eux pour former des formes géométriques simples : des carrés, des lignes, des triangles, ...
Voici comment OpenGL "voit" une map : une succession de vertices reliés entre eux pour former un terrain en mode filaire.
Maintenant que l'on a nos formes géométriques, il faut les "colorier", sinon nous n'aurions que des fils difformes qui ne ressembleraient à rien. Imaginez si on laissait tout comme ça, le jeu serait un peu particulier.
On peut distinguer deux formes de "coloriage": les textures et les couleurs (que nous spécifions nous-même). Les textures sont utilisées dans 99% des cas. En effet, nous ne spécifions que très rarement la couleur de nos objets directement, en général on le fait pour des tests ou dans un shader (retenez bien ce mot, nous verrons cela plus tard). Dans les premiers chapitres nous n'utiliserons pas les textures, nous accorderons un chapitre entier pour cela.
Ce qu'il faut retenir c'est qu'au final nous définissons les coordonnées de nos vertices (sommets) dans l'espace, puis nous les relions entre eux pour former une forme géométrique, enfin nous colorions cette forme pour avoir quelque chose de "consistant".
Nomenclature des fonctions OpenGL
Comme vous l'aurez remarqué avec la SDL, chaque fonction de la librairie commence par "SDL_" . Bonne nouvelle, avec OpenGL c'est pareil, chaque fonction commence par la même chose : "gl".
Autre point, il se peut que vous soyez surpris en voyant le nom de certaines fonctions se répéter plusieurs fois, c'est normal.
Prenons un exemple : glUniform2f(...) et glUniform2i(...)
Nous verrons l'utilité de ces fonctions plus tard. Vous voyez la différence ? Dans la première, la dernière lettre est un "f" et dans la deuxième c'est un "i".
En général, la dernière lettre d'une fonction OpenGL sert à indiquer le type du paramètre à envoyer :
i : integer (entier)
f : float (réel)
d : double (réel plus puissant)
ub : unsigned byte (octet entre 0 et 255)
...
Selon le type de paramètre que vous enverrez il faudra utiliser la fonction correspondant au type de vos variables. :)
Vous remarquez aussi que l'avant-dernière lettre est un chiffre, ce chiffre peut lui aussi changer. En général, si le chiffre est 1 on envoie un seul paramètre, si c'est 2 on en envoie deux, ...
Autre point que vous remarquerez plus tard : les types de variables OpenGL. Vous tomberez souvent sur des fonctions demandant des paramètres de types GLfloat, GLbool, GLchar, ... Ce sont simplement des variables de type float, char, int, ... Le type GLbool ne pourra prendre que deux valeurs : soit GL_FALSE (faux) soit GL_TRUE (vrai).
Boucle principale
Comme avec la SDL, OpenGL fonctionne avec une boucle principale. Tous les calculs se feront dans cette boucle. De plus, à chaque affichage il va falloir effacer l'écran car la scène aura légèrement changée, puis ré-afficher chaque élément un à un. Voici la fonction permettant d'effacer l'écran :
glClear(GLbitfield mask)
Dans un premier temps on effacera uniquement ce qui se trouve à l'écran grâce au paramètre : GL_COLOR_BUFFER_BIT.
Avec la SDL, pour actualiser l'affichage on utilisait la fonction SDL_Flip(), mais avec OpenGL on utilisera la fonction :
SDL_GL_SwapWindow(SDL_Window* window);
Le paramètre window étant notre structure SDL_WindowID. ;)
Malheureusement il existe encore une différence entre Linux et Windows. Comme vous l'avez vu dans le chapitre précédent, pour utiliser OpenGL, Windows est obligé de passer par une autre librairie du nom de GLEW. Cette librairie va nous permettre d'utiliser les fonctions d'OpenGL 3. Mais comme toute librairie, il va falloir l'initialiser. Sous Windows vous devrez inclure l'en-tête suivant :
#ifdef WIN32
#include <GL/glew.h>
Pour initialiser la librairie GLEW, il faudra ajouter la fonction : glewInit(). Comme toute librairie, l'initialisation peut échouer, il faut donc tester son initialisation :
// On initialise GLEW
GLenum initialisationGLEW( glewInit() );
// Si l'initialisation a échouée :
if(initialisationGLEW != GLEW_OK)
{
// On affiche l'erreur grâce à la fonction : glewGetErrorString(GLenum code)
std::cout << "Erreur d'initialisation de GLEW : " << glewGetErrorString(initialisationGLEW) << std::endl;
// On quitte la SDL
SDL_GL_DeleteContext(contexteOpenGL);
SDL_DestroyWindow(fenetre);
SDL_Quit();
return -1;
}
Voila pour Windows. Pour Linux c'est beaucoup plus simple, il suffit de placer une "define" spéciale pour indiquer que nous utiliserons les fonctions d'OpenGL 3 puis d'inclure le fichier d'en-tête "gl3.h" :
#define GL3_PROTOTYPES 1
#include <GL3/gl3.h>
Bien, récapitulons tout ceci :
#ifdef WIN32
#include <GL/glew.h>
#else
#define GL3_PROTOTYPES 1
#include <GL3/gl3.h>
#endif
#include <SDL2/SDL.h>
#include <iostream>
int main(int argc, char **argv)
{
/* *** Création de la fenêtre SDL *** */
#ifdef WIN32
// On initialise GLEW
GLenum initialisationGLEW( glewInit() );
// Si l'initialisation a échouée :
if(initialisationGLEW != GLEW_OK)
{
// On affiche l'erreur grâce à la fonction : glewGetErrorString(GLenum code)
std::cout << "Erreur d'initialisation de GLEW : " << glewGetErrorString(initialisationGLEW) << std::endl;
// On quitte la SDL
SDL_GL_DeleteContext(contexteOpenGL);
SDL_DestroyWindow(fenetre);
SDL_Quit();
return -1;
}
#endif
// Boucle principale
while(!terminer)
{
// Gestion des évènements
SDL_WaitEvent(&evenements);
if(evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT);
// Actualisation de la fenêtre
SDL_GL_SwapWindow(fenetre);
}
// On quitte la SDL
SDL_GL_DeleteContext(contexteOpenGL);
SDL_DestroyWindow(fenetre);
SDL_Quit();
return 0;
}
Repère et Origine
J'espère que vous connaissez la définition de ces deux termes. Un repère est un ensemble d'axes représentant au moins les axes X et Y, l'origine est le point de départ de ces axes.
En théorie voici ce que donne le repère d'OpenGL :
Mais dans un premier temps, nous utiliserons le repère par défaut d'OpenGL que voici :
L'origine du repère se trouve au centre de l'écran, et les coordonnées maximales affichables sont comprises entre (-1, -1) et (1, 1).
Nous utiliserons l'autre repère dès que nous y aurons inclus les matrices :) . De plus, nous serons dans un premier temps dans un monde 2D, nous ne ferons pas de cube ou autre forme complexe pour commencer. Nous verrons cela un peu plus tard. :p
Maintenant que le blabla théorique est terminé, nous pouvons reprendre la programmation. :magicien:
Dans le chapitre précédent, nous avons appris que tous les modèles 3D présents dans un jeu étaient constitués de sommets (vertices), puis qu'il fallait les relier pour former une surface. Et si on assemble ces formes, ça donne nos modèles 3D.
Notre premier exercice est simple : afficher un triangle. Pour pouvoir afficher n'importe quel modèle 3D il faut déjà spécifier ses vertices que nous allons ensuite donner à OpenGL. En général, on définie nos vertices dans un seul tableau, on place chaque coordonnée de vertex à la chaine comme ceci :
N'oubliez pas que nous sommes en 2D pour l'instant, nous n'avons donc que les coordonnées x et y à définir. Ce début de code est bien mais si on utilise 3 tableaux, ça ne fonctionnera pas. Nous devons combiner les trois tableaux pour en former un seul :
Maintenant, il faut envoyer ces coordonnées à OpenGL. Pour envoyer des informations nous avons besoin d'un tableau appelé "Vertex Attribut". Pour ceux qui connaissent les "Vertex Arrays", c'est la même chose ;) . Sauf que maintenant ce n'est plus une optimisation mais belle et bien la méthode de base pour envoyer nos infos à OpenGL.
Ces "Vertex Attributs" sont déjà inclus dans l'API, il suffit de lui donner des valeurs (nos coordonnées) puis de l'activer. Voici le prototype de la fonction gérant les Vertex Attributs :
Malheureusement, en l'état, notre tableau est inutilisable, il faut d'abord l'activer avec la fonction :
void glEnableVertexAttribArray(GLuint index);
Le paramètre index étant notre identifiant de tableau, ici ce sera 0.
Bien, maintenant OpenGL sait quels vertices il doit afficher, il ne manque plus qu'à lui dire ... ce qu'il doit faire de ces points. :p
Pour ça, on utilise une fonction (il y en a en fait deux, mais pour le début de ce tutoriel nous n'utiliserons que la première). Cette fonction permet de dire à OpenGL quelle forme afficher avec les points donnés précédemment. Voici le prototype de la fonction :
mode : C'est la forme finale (nous verrons cela juste après).
first : C'est l'indice de notre premier vertex à afficher(nous lui donnerons un int).
count : C'est le nombre de vertices à afficher depuis first (nous lui donnerons également un int).
Alors Attention ! :
Le paramètre first est un indice (comme un indice de tableau), si vous voulez utiliser le premier vertex vous lui donnerez la valeur "0" et pas "1".
Pour count, c'est le contraire. Lui il veut le nombre de vertices à utiliser depuis first. Si vous utilisez 4 vertices vous lui donnerez la valeur "4" et pas "3".
Exemple : J'ai 4 vertices et je veux afficher un carré. Le premier vertex sera le vertex 0 (le premier vertex), et la valeur de "count" sera 4 car j'ai besoin du vertex 0 + les 3 vertices qui le suivent.
Passons au paramètre le plus intéressant : mode.
Ce paramètre peut prendre plusieurs formes donc voici les principales :
Valeur
Définition
GL_TRIANGLES
Avec ce mode, chaque groupe de 3 vertices formera un triangle. C'est le mode le plus utilisé.
GL_POLYGON
Tous les vertices s'assemblent pour former un polygone de type convexe (Hexagone, Heptagone, ...).
GL_LINES
Chaque duo de vertices formera une ligne.
GL_TRIANGLE_STRIP
Ici les triangles s'assembleront. Les deux derniers vertices d'un triangle s'assembleront avec un 4ième vertex pour former un nouveau triangle.
Toutes les valeurs possibles ne sont pas représentées mais je vous expose ici les plus utilisées. ;)
Revenons à notre code, nous avons désormais les différents modes d'affichage. Le but de ce chapitre est d'afficher un triangle, notre mode d'affichage sera donc : GL_TRIANGLES, ce qui donne :
glDrawArrays(GL_TRIANGLES, 0, 3);
La valeur "0" pour commencer l'affichage par le premier vertex, et le "3" pour utiliser les 3 vertices dont le premier sera le vertex "0".
Récapitulons tout ça :
// Vertices et coordonnées
float vertices[] = {-0.5, -0.5, 0.0, 0.5, 0.5, -0.5};
// Boucle principale
while(!terminer)
{
// Gestion des évènements
....
// On remplie puis on active le tableau Vertex Attrib 0
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
glEnableVertexAttribArray(0);
// On affiche le triangle
glDrawArrays(GL_TRIANGLES, 0, 3);
// On désactive le tableau Vertex Attrib puisque l'on en a plus besoin
glDisableVertexAttribArray(0);
// Actualisation de la fenêtre
....
}
Vous avez dû remarquer la présence d'une nouvelle fonction : glDisableVertexAttribArray. Elle permet de désactiver le tableau Vertex Attrib utilisé, le paramètre est le même que celui de la fonction glEnableVertexAttribArray. On désactive le tableau juste après l'affichage des vertices.
Récapitulons tout avec le code SDL :
#ifdef WIN32
#include <GL/glew.h>
#else
#define GL3_PROTOTYPES 1
#include <GL3/gl3.h>
#endif
#include <SDL2/SDL.h>
#include <iostream>
int main(int argc, char **argv)
{
// Notre fenêtre
SDL_Window* fenetre(0);
SDL_GLContext contexteOpenGL(0);
SDL_Event evenements;
bool terminer(false);
// Initialisation de la SDL
if(SDL_Init(SDL_INIT_VIDEO) < 0)
{
std::cout << "Erreur lors de l'initialisation de la SDL : " << SDL_GetError() << std::endl;
SDL_Quit();
return -1;
}
// Version d'OpenGL
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1);
// Double Buffer
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
// Création de la fenêtre
fenetre = SDL_CreateWindow("Test SDL 2.0", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 800, 600, SDL_WINDOW_SHOWN | SDL_WINDOW_OPENGL);
if(fenetre == 0)
{
std::cout << "Erreur lors de la creation de la fenetre : " << SDL_GetError() << std::endl;
SDL_Quit();
return -1;
}
// Création du contexte OpenGL
contexteOpenGL = SDL_GL_CreateContext(fenetre);
if(contexteOpenGL == 0)
{
std::cout << SDL_GetError() << std::endl;
SDL_DestroyWindow(fenetre);
SDL_Quit();
return -1;
}
#ifdef WIN32
// On initialise GLEW
GLenum initialisationGLEW( glewInit() );
// Si l'initialisation a échouée :
if(initialisationGLEW != GLEW_OK)
{
// On affiche l'erreur grâce à la fonction : glewGetErrorString(GLenum code)
std::cout << "Erreur d'initialisation de GLEW : " << glewGetErrorString(initialisationGLEW) << std::endl;
// On quitte la SDL
SDL_GL_DeleteContext(contexteOpenGL);
SDL_DestroyWindow(fenetre);
SDL_Quit();
return -1;
}
#endif
// Vertices et coordonnées
float vertices[] = {-0.5, -0.5, 0.0, 0.5, 0.5, -0.5};
// Boucle principale
while(!terminer)
{
// Gestion des évènements
SDL_WaitEvent(&evenements);
if(evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT);
// On remplie puis on active le tableau Vertex Attrib 0
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
glEnableVertexAttribArray(0);
// On affiche le triangle
glDrawArrays(GL_TRIANGLES, 0, 3);
// On désactive le tableau Vertex Attrib puisque l'on n'en a plus besoin
glDisableVertexAttribArray(0);
// Actualisation de la fenêtre
SDL_GL_SwapWindow(fenetre);
}
// On quitte la SDL
SDL_GL_DeleteContext(contexteOpenGL);
SDL_DestroyWindow(fenetre);
SDL_Quit();
return 0;
}
Si tout se passe bien, vous devriez avoir une belle fenêtre comme celle-ci :
Afficher plusieurs triangles
Les vertices
Comme nous l'avons vu dans le tableau précédemment, le paramètre GL_TRIANGLES dans la fonction glDrawArrays() permet d'afficher un triangle pour chaque triplet de vertices. Pour le moment, nous n'utilisons que 3 vertices, donc nous n'avons au final qu'un seul triangle. Or si nous en utilisons 6 nous aurons alors deux triangles. On peut même aller plus loin et prendre 60, 390, ou 3000 vertices pour en afficher plein ! C'est d'ailleurs ce que font les décors et les personnages dans les jeux-vidéo, ils ne savent utiliser que ça. :p
Enfin, prenons un exemple plus simple avant d'aller aussi loin. Si nous voulons afficher le rendu suivant ... :
... Nous devrons utiliser deux triplets de vertices.
Ce qu'il faut savoir, c'est qu'il ne faut surtout pas utiliser un tableau pour chaque triangle. On perdrait beaucoup trop de temps à tous les envoyer. A la place, nous devons inclure tous les vertices dans un seul et unique tableau :
Toutes nos données sont regroupées dans un seul tableau. Ça ne nous fait qu'un seul envoi à faire c'est plus facile à gérer, ça arrange même OpenGL. ;)
L'affichage
Au niveau de l'affichage, le code reste identique à celui du triangle unique. C'est-à-dire qu'il faut utiliser les fonctions :
glVertexAttribPointer() : pour donner les vertices à OpenGL
glEnableVertexAttribArray() : pour activer le tableau Vertex Attrib
glDrawArrays() : pour afficher le tout
La seule différence notable va être la valeur du paramètre count (nombre de vertices à prendre en compte) de la fonction glDrawArrays(). Celui-ci était égal à 3 pour un seul triangle, nous la passerons désormais à 6 pour en afficher deux. L'appel à la fonction ressemblerait donc à ceci :
// Affichage des triangles
glDrawArrays(GL_TRIANGLES, 0, 6);
Ce qui donne le code source de la boucle principale suivant :
// Vertices
float vertices[] = {0.0, 0.0, 0.5, 0.0, 0.0, 0.5, // Triangle 1
-0.8, -0.8, -0.3, -0.8, -0.8, -0.3}; // Triangle 2
// Boucle principale
while(!terminer)
{
// Gestion des évènements
SDL_WaitEvent(&evenements);
if(evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT);
// On remplie puis on active le tableau Vertex Attrib 0
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
glEnableVertexAttribArray(0);
// On affiche des triangles
glDrawArrays(GL_TRIANGLES, 0, 6);
// On désactive le tableau Vertex Attrib puisque l'on n'en a plus besoin
glDisableVertexAttribArray(0);
// Actualisation de la fenêtre
SDL_GL_SwapWindow(fenetre);
}
Je ne sais pas si vous l'avez remarqué, mais depuis le début du tutoriel nous n'avons pas codé une seule classe. À vrai dire c'est normal, nous n'en avions pas vraiment besoin jusqu'ici. Cependant nos programmes vont commencer à se complexifier donc autant prendre les bonnes habitudes dès maintenant.
La classe SceneOpenGL
Le header
Pour le moment, nous n'aurons besoin que d'une seule classe dans nos programmes de test. Elle devra être capable de remplir plusieurs rôles :
Créer la fenêtre SDL et le contexte OpenGL.
Initialiser tous les objets d'une scène (personnages, caisses, sol, ... Nous verrons cela un peu plus tard ;) ).
Gérer l'interaction entre les objets tout au long du programme.
Pour remplir ces objetifs, nous allons créer plusieurs méthodes, mais avant cela nous devons déclarer la classe C++ qui s'occupera de tout ça. Cette classe s'appellera : SceneOpenGL. Commençons donc par créer deux fichiers SceneOpenGL.h et SceneOpenGL.cpp.
Une classe en C++ est composée de méthodes et d'attributs. Pour les méthodes, on voit déjà à peu près ce que l'on va faire. En revanche, on ne sait pas encore de quels attributs nous aurons besoin.
Si on regarde le début du code pour afficher le triangle blanc, nous voyons trois variables importantes :
On voit dans cette liste 3 variables correspondant :
À la fenêtre
Au contexte OpenGL
Aux évènements SDL
Ces 3 variables deviendront les attributs de notre classe. On en rajoutera même trois supplémentaires qui correspondront :
Au titre de la fenêtre.
À sa largeur
À sa hauteur
Si on résume tout ça, nous avons une classe SceneOpenGL avec 6 attributs. Le header ressemblera donc à ceci :
#ifndef DEF_SCENEOPENGL
#define DEF_SCENEOPENGL
#include <string>
class SceneOpenGL
{
public:
SceneOpenGL();
~SceneOpenGL();
private:
std::string m_titreFenetre;
int m_largeurFenetre;
int m_hauteurFenetre;
SDL_Window* m_fenetre;
SDL_GLContext m_contexteOpenGL;
SDL_Event m_evenements;
};
#endif
Et voici donc le squelette de tous nos futurs programmes. :D
Bon pour le moment c'est un peu vide, on va habiller un peu tout ça. On va notamment ajouter deux éléments. Premièrement, il faut ajouter tous les includes concernant la SDL et OpenGL :
Deuxièmement, il faut modifier le constructeur de la classe pour prendre en compte les paramètres de création de la fenêtre (titre, largeur et hauteur) :
SceneOpenGL(std::string titreFenetre, int largeurFenetre, int hauteurFenetre);
Pour le moment, rien de bien compliqué. ^^
Les méthodes
Si on reprend la liste des objectifs de la classe, on retrouve trois points importants :
Créer la fenêtre SDL et le contexte OpenGL.
Initialiser tous les objets d'une scène (personnages, caisses, sol, ...).
Gérer l'interaction entre les objets tout au long du programme.
Nous allons créer une méthode pour chaque point de cette liste.
La première méthode consistera donc à initialiser la fenêtre et le contexte OpenGL dans lequel nous allons évoluer :
bool initialiserFenetre();
Elle renverra un booléen pour confirmer ou non la création de la fenêtre. Nous mettrons à l'intérieur tout le code permettant de générer la fenêtre.
Pour le deuxième point, nous devrons initialiser tout ce qui concerne OpenGL (mis à part le contexte vu qu'il est créé juste avant). Pour le moment, nous n'avons que la librairie GLEW à initialiser.
Généralement, la fonction qui s'occupe d'initialiser OpenGL s'appelle initGL(), nous appellerons donc notre méthode de la même façon :
bool initGL();
Comme la méthode précédente, elle renverra un booléen pour savoir si l'initialisation s'est bien passée.
Pour le troisième et dernier point, nous devons gérer la boucle principale du programme. Nous créerons donc une méthode bouclePrincipale() qui s'occupera de gérer tout ça :
void bouclePrincipale();
Elle ne renverra aucune valeur.
Résumé du Header
Si on met tout ce que l'on vient de voir dans le header, on a : une classe SceneOpenGL, des includes pour gérer la SDL et OpenGL, 6 attributs et 3 méthodes :
#ifndef DEF_SCENEOPENGL
#define DEF_SCENEOPENGL
// Includes
#ifdef WIN32
#include <GL/glew.h>
#else
#define GL3_PROTOTYPES 1
#include <GL3/gl3.h>
#endif
#include <SDL2/SDL.h>
#include <iostream>
#include <string>
// Classe
class SceneOpenGL
{
public:
SceneOpenGL(std::string titreFenetre, int largeurFenetre, int hauteurFenetre);
~SceneOpenGL();
bool initialiserFenetre();
bool initGL();
void bouclePrincipale();
private:
std::string m_titreFenetre;
int m_largeurFenetre;
int m_hauteurFenetre;
SDL_Window* m_fenetre;
SDL_GLContext m_contexteOpenGL;
SDL_Event m_evenements;
};
#endif
Implémentation de la classe
Dans cette dernière sous-partie, on va se reposer un peu. En effet, on a déjà tout codé avant, on n'a plus qu'à jouer au puzzle en coupant notre code et en mettant les bons morceaux au bon endroit. :p
Constructeur et Destructeur
Pour le constructeur rien de plus simple, on initialise nos attributs sans oublier de passer les 3 paramètres du constructeur concernant la fenêtre :
SceneOpenGL::SceneOpenGL(std::string titreFenetre, int largeurFenetre, int hauteurFenetre) : m_titreFenetre(titreFenetre), m_largeurFenetre(largeurFenetre),
m_hauteurFenetre(hauteurFenetre), m_fenetre(0), m_contexteOpenGL(0)
{
}
Pour le destructeur, on détruit simplement le contexte et la fenêtre, puis on quitte la librairie SDL :
Ici on sait déjà ce que l'on va mettre. On va implémenter le code permettant de créer la fenêtre et le contexte OpenGL, nous connaissons ce code depuis le chapitre précédent. Pour migrer celui-ci on va :
Prendre tout le code gérant l'initialisation de la SDL et du contexte.
Remplacer les deux occurrences de fenetre par "m_fenetre" et de contexteOpenGL par "m_contexteOpenGL".
Remplacer les valeurs retournées par "true" pour 0 et "false" pour -1.
Remplacer le paramètre title de la fonction SDL_CreateWindow() par la chaine de caractères de l'attribut m_titreFenetre soit m_titreFenetre.c_str().
Encore une fois rien de bien compliqué. ^^
En code, ça nous donne ceci :
bool SceneOpenGL::initialiserFenetre()
{
// Initialisation de la SDL
if(SDL_Init(SDL_INIT_VIDEO) < 0)
{
std::cout << "Erreur lors de l'initialisation de la SDL : " << SDL_GetError() << std::endl;
SDL_Quit();
return false;
}
// Version d'OpenGL
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1);
// Double Buffer
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
// Création de la fenêtre
m_fenetre = SDL_CreateWindow(m_titreFenetre.c_str(), SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, m_largeurFenetre, m_hauteurFenetre, SDL_WINDOW_SHOWN | SDL_WINDOW_OPENGL);
if(m_fenetre == 0)
{
std::cout << "Erreur lors de la creation de la fenetre : " << SDL_GetError() << std::endl;
SDL_Quit();
return false;
}
// Création du contexte OpenGL
m_contexteOpenGL = SDL_GL_CreateContext(m_fenetre);
if(m_contexteOpenGL == 0)
{
std::cout << SDL_GetError() << std::endl;
SDL_DestroyWindow(m_fenetre);
SDL_Quit();
return false;
}
return true;
}
On passe maintenant à la méthode initGL(). Pour le moment, nous n'avons pas grand chose à mettre à l’intérieur mise à part l'initialisation de la librairie GLEW pour Windows.
Et comme précédemment, il va falloir modifier le nom des attributs fenetre et contexteOpenGL en leur ajoutant le prefix "m_". Nous rajouterons également deux return dans la méthode :
Un si l'initialisation échoue (donc return false).
Et l'autre pour indiquer que l'initialisation s'est bien déroulée (return true).
bool SceneOpenGL::initGL()
{
#ifdef WIN32
// On initialise GLEW
GLenum initialisationGLEW( glewInit() );
// Si l'initialisation a échoué :
if(initialisationGLEW != GLEW_OK)
{
// On affiche l'erreur grâce à la fonction : glewGetErrorString(GLenum code)
std::cout << "Erreur d'initialisation de GLEW : " << glewGetErrorString(initialisationGLEW) << std::endl;
// On quitte la SDL
SDL_GL_DeleteContext(m_contexteOpenGL);
SDL_DestroyWindow(m_fenetre);
SDL_Quit();
return false;
}
#endif
// Tout s'est bien passé, on retourne true
return true;
}
La méthode bouclePrincipale()
Allez on passe à la dernière méthode, c'est dans celle-ci que va se passer la quasi totalité du programme.
Dans cette méthode, on commence par déclarer le booléen terminer que vous devez déjà connaitre. :p On ne l'a pas déclaré en temps qu'attribut car il ne sert que dans la boucle while.
Après ce booléen, on va en profiter pour déclarer notre fameux tableau de vertices. Dans le futur, nous ferons des objets spécialement dédiés pour afficher nos modèles. Mais pour le moment, nous n'avons que de simples vertices à afficher, on peut donc se passer de classe.
Voici donc le début de la méthode bouclePrincipale() :
Pour le reste de la méthode, il suffit simplement d'ajouter la boucle while que nous avons déjà codé. :)
Encore une fois, je me répète mais n'oubliez pas d'ajouter le préfixe "m_" aux attributs evenements et fenetre quand vous copiez le code :
void SceneOpenGL::bouclePrincipale()
{
// Variables
bool terminer(false);
float vertices[] = {-0.5, -0.5, 0.0, 0.5, 0.5, -0.5};
// Boucle principale
while(!terminer)
{
// Gestion des évènements
SDL_WaitEvent(&m_evenements);
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = 1;
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT);
// On remplie puis on active le tableau Vertex Attrib 0
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
glEnableVertexAttribArray(0);
// On affiche le triangle
glDrawArrays(GL_TRIANGLES, 0, 3);
// On désactive le tableau Vertex Attrib puisque l'on n'en a plus besoin
glDisableVertexAttribArray(0);
// Actualisation de la fenêtre
SDL_GL_SwapWindow(m_fenetre);
}
}
Le fichier main.cpp
Comme d'habitude en C++, la fonction main() sera la fonction la moins chargée du programme. Jusqu'à maintenant, nous codions tout à l'intérieur de cette fonction, ce qui la rendait un peu illisible.
Maintenant, nous allons juste déclarer un objet, l'initialiser et lancer sa boucle principale. ^^ Pour ça, il faut inclure le header de la classe SceneOpenGL dans le fichier main.cpp :
#include "SceneOpenGL.h"
Ensuite, on va déclarer un objet de type SceneOpenGL avec les bons paramètres :
int main(int argc, char **argv)
{
// Création de la sène
SceneOpenGL scene("Chapitre 3", 800, 600);
}
Maintenant, il faut appeler les deux méthodes qui permettent d'initialiser le programme correctement à savoir initialiserFenetre() et initGL(). De plus, il faut vérifier que ces méthodes retournent bien le booléen true et pas false. Si au moins une des deux initialisions échoue, alors on quitte le programme :
// Initialisation de la scène
if(scene.initialiserFenetre() == false)
return -1;
if(scene.initGL() == false)
return -1;
Enfin, on appelle la méthode bouclePrincipale() pour lancer la scène OpenGL :
// Boucle Principale
scene.bouclePrincipale();
N'oublions pas le return 0 lorsque l'on quittera le programme :
// Fin du programme
return 0;
Si on résume tout ça :
#include "SceneOpenGL.h"
int main(int argc, char **argv)
{
// Création de la sène
SceneOpenGL scene("Chapitre 3", 800, 600);
// Initialisation de la scène
if(scene.initialiserFenetre() == false)
return -1;
if(scene.initGL() == false)
return -1;
// Boucle Principale
scene.bouclePrincipale();
// Fin du programme
return 0;
}
Et voilà ! Il ne vous reste plus qu'à compiler tout ça. Vous devriez avoir le même résultat qu'au dessus mais vous avez codée proprement une classe en C++ avec du code OpenGL à l'intérieur. ^^
Nous ferons plein de classes au fur et à mesure de ce tuto, vous allez vite en prendre l'habitude. ;)
Tout au long de ce tutoriel, je vous ferai faire quelques petits exercices pour que vous appliquiez ce que nous aurons vu dans les chapitres. Ce seront des exercices assez simples, il n'y aura rien de farfelu je vous rassure. Évidemment, vous avez le droit de vous aider du cours, je ne vous demande pas de tout retenir d'un coup à chaque fois. D'ailleurs, les solutions sont fournies juste après les énoncés, ne les regardez pas avant sinon ça n'a aucun intérêt. ;)
Exercice 1 : Avec les notions vues dans ce chapitre, affichez un triangle ayant les coordonnées présentes ci-dessous. Vous n'avez besoin de modifier que les vertices par rapport aux exemples du cours, inutile de toucher au reste.
Exercice 2 : Reprenez le même triangle que l'exercice précédent mais modifiez ses vertices pour l'inverser :
Exercice 3 : On passe un cran au-dessus maintenant. Je vous demande d'afficher la forme suivante en utilisant deux triangles distincts (donc pas avec le paramètre GL_TRIANGLE_STRIP) :
Solutions
Exercice 1 :
La seule chose à modifier ici c'est le tableau de vertices. L’énoncé demandait un triangle avec des coordonnées spécifiques, le tableau de valeurs ressemble donc à ceci :
Vous remarquerez que j'ai délimité les coordonnées de façon à bien différencier les vertices. ;)
Je devrais en théorie m’arrêter là pour la correction, mais pour ceux qui le souhaitent je donne en détail le code source de l'affichage des vertices. Celui-ci ne change absolument pas par rapport aux exemples du cours, mais si ça peut vous aider à comprendre un peu mieux je vais le ré-expliquer rapidement.
Premièrement, on reprend le code de la boucle principale avec sa gestion d'évènements et son actualisation de fenêtre :
// Boucle principale
while(!terminer)
{
// Gestion des évènements
SDL_WaitEvent(&m_evenements);
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
// Actualisation de la fenêtre
SDL_GL_SwapWindow(m_fenetre);
}
Le code d'affichage consiste simplement à appeler les fonctions :
glClear() pour nettoyer ce qui était présent avant
glVertexAttribPointer() pour donner les vertices à OpenGL
glDrawArrays() pour les afficher
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT);
// Tableau Vertex Attrib 0 pour envoyer les vertices
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
// Affichage du triangle
glDrawArrays(GL_TRIANGLES, 0, 3);
Il ne faut bien sûr pas oublier d'activer le tableau Vertex Attrib au moment d'envoyer les vertices, puis de le désactiver quand on n'en a plus besoin :
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT);
// Tableau Vertex Attrib 0 pour envoyer les vertices
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
// Activation du tableau Vertex Attrib
glEnableVertexAttribArray(0);
// Affichage du triangle
glDrawArrays(GL_TRIANGLES, 0, 3);
// Désactivation du tableau Vertex Attrib
glDisableVertexAttribArray(0);
Ce qui donne le code source suivant pour la boucle principale :
// Vertices
float vertices[] = {-0.5, 0.0, 0.5, -0.5, 0.5, 0.5};
// Boucle principale
while(!terminer)
{
// Gestion des évènements
SDL_WaitEvent(&m_evenements);
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT);
// Tableau Vertex Attrib 0 pour envoyer les vertices
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
// Activation du tableau Vertex Attrib
glEnableVertexAttribArray(0);
// Affichage du triangle
glDrawArrays(GL_TRIANGLES, 0, 3);
// Désactivation du tableau Vertex Attrib
glDisableVertexAttribArray(0);
// Actualisation de la fenêtre
SDL_GL_SwapWindow(m_fenetre);
}
Exercice terminé. :)
Exercice 2 :
Pour cet exercice, il suffit juste de modifier notre tableau de vertices. On prend donc notre schéma pour en tirer les coordonnées suivantes :
Cet exercice est un poil plus compliqué que les deux autres mais il n'y a rien de dur si on regarde dans le fond. :)
La forme demandée est évidemment constituée de deux triangles, il faut donc commencer par déclarer deux tableaux de vertices qui contiendront les coordonnées de ces deux triangles :
Il faut évidemment modifier l'appel à la fonction glDrawArrays() pour qu'elle prenne en compte les 6 vertices et non uniquement 3 :
// Affichage du triangle
glDrawArrays(GL_TRIANGLES, 0, 6);
Le reste du code ne change pas.
Ah, nous avons enfin pu afficher quelque chose (même si ce n'est un simple triangle), nous avons enfin pu faire bosser notre carte graphique qui commençait légèrement à s'endormir. :p
Rappelez-vous bien de ce que nous avons vu ici, nous réutiliserons tout ça dans les futurs chapitres.
Ah c'est un vaste sujet que nous allons aborder aujourd'hui :) . Tellement vaste que nous allons le découper en 4 chapitres dont le premier sera celui-ci. Nous verrons les trois autres beaucoup plus tard, vous comprendrez pourquoi. Bref, il est temps de découvrir ce que sont ces mystérieux Shaders. :magicien:
Comme vous le savez, OpenGL est une librairie qui tire sa puissance de la carte graphique. Or lorsque nous programmons, nous n'avons pas accès à cette carte mais uniquement au processeur et à la RAM. Comment fait notre programme pour l'exploiter me direz-vous ?
En réalité, OpenGL va bidouiller pas mal de choses dans la carte lorsque nous appelons certaines fonctions comme glDrawArrays(). Voyons d’ailleurs ce qui se passe entre le moment où l’on appelle cette fonction et le moment où la scène s’affiche à l’écran :
Définition des coordonnées : Dans un premier temps, nous définissons les coordonnées de vertices. Ça on sait le faire grâce à la fonction glVertexAttribPointer().
Vertex Shader : Nous verrons cette étape dans quelques minutes. ;)
Pixelisation des triangles : C'est le moment où une forme géométrique est convertie en pixels.
Fragment Shader : Nous verrons également cela un peu plus loin.
Test de profondeur : Test permettant de savoir s'il faut afficher tel ou tel pixel. Cette notion sera développée un peu plus tard.
Affichage : C'est la sortie de la carte graphique, la plupart du temps ce sera notre écran.
Cette suite d’opérations constitue ce que l’on appelle: le pipeline 3D. Les parties qui nous intéressent dans ce chapitre sont : le Vertex Shader et le Fragment Shader.
Euh qu'est-ce que c'est que ça ? o_O
Un shader est simplement un programme exécuté non pas par le processeur mais par la carte graphique. Il en existe deux types (enfin 3 mais seuls 2 sont réellement importants) :
Vertex Shader : C’est l’étape qui va nous permettre soit de valider les coordonnées de nos sommets, soit de les modifier. Cette étape prend un vertex à part pour travailler dessus. S’il y a 3 vertices (pour un triangle) alors le vertex shader sera exécuté 3 fois.
Fragment Shader (parfois appelé Pixel Shader) : C’est l’étape qui va définir la couleur de chaque pixel de la forme délimitée par les vertices. Par exemple, si vous avez défini un rectangle de 100 pixels par 50 pixels, alors le fragment shader se chargera de définir la couleur des 5000 pixels composant le rectangle.
A quoi servent les shaders ?
Étant donné la complexité de cette notion, nous ne devrions étudier les shaders que beaucoup plus tard dans le tutoriel. Dans les anciennes versions d'OpenGL, ils étaient optionnels, c'était l'API elle-même qui se chargeait d'effectuer ces opérations. Mais avec la version 3.0, le groupe Khronos a voulu introduire une nouvelle philosophie : le tout shader. En clair, ils nous imposent leur utilisation.
Euh ... Pourquoi nous imposer un truc aussi compliqué alors que c'était géré automatiquement avant ?
Ce qu'il faut comprendre, c'est que les jeux-vidéo ont beaucoup évolué depuis qu'OpenGL existe. Il y a quelques années, il était plus simple de laisser l'API faire tout le boulot, les jeux ne prenaient pas énormément de ressources et surtout il y avait moins de choses à calculer. Mais de nos jours, les graphismes, le réalisme et la vitesse sont devenus les principales préoccupations des développeurs (et des joueurs ;) ). L'API n'est plus capable de gérer tout ça rapidement car elle le fait avec ses vielles méthodes (trop lentes pour les jeux d'aujourd'hui).
L'avantage d'une gestion personnalisée est que l'on peut faire ce que l'on veut et surtout on peut y mettre uniquement ce dont on a besoin, ce qui peut nous faire gagner parfois pas mal de vitesse.
Exemples de Shaders
Ah c'est certainement la partie qui va le plus vous intéresser. :p
Les shaders ne sont pas uniquement des étapes embêtantes qui sont là pour vous compliquer la vie :colere2: . Leur première utilité vient du fait qu'ils permettent d'afficher nos objets dans un monde 2D ou 3D. En gros, voila ce qui se passe lorsque nous voulons afficher un objet :
On définit ses coordonnées dans l'espace.
L'objet est ensuite passé au Vertex Shader (n'oubliez pas que c'est un programme exécuté par la carte graphique).
Puis au Fragment Shader (c'est un programme aussi).
Il est maintenant prêt à être affiché.
Ça c'est la première utilité, passons maintenant à la deuxième qui est certainement la plus importante (et la plus intéressante :p ) : ils permettent de faire tous les effets 3D magnifiques que vous voyez dans un jeu-vidéo tels que : les lumières, l'eau, les ombres, les explosions ... Ce sont les shaders qui font d'un jeu un jeu plus réaliste.
Alors ne vous emballez pas, ce n'est pas maintenant que nous allons apprendre à faire tous ces effets mais nous y viendrons. ;)
Le principal problème avec les shaders est que lorsque l'on débute dans la programmation 3D, il est très difficile d'apprendre à les utiliser du fait de leur complexité. C'est pour cela que dans un premier temps, je vais vous fournir le code source pour la création des shaders. Nous consacrerons trois chapitres entiers pour apprendre à les gérer une fois que vous serez plus habitués avec OpenGL.
En bref ce qu’il faut retenir c’est que :
Un shader est un programme exécuté par la carte graphique.
Il en existe de deux types : les vertex et les fragment.
Chaque chose que nous voulons afficher passera d'abord entre les mains de ces deux shaders.
Passons maintenant à la partie programmation. :magicien:
Pour commencer, je vais vous demander de télécharger l’archive ci-dessous (pour Linux et Windows), elle contient un fichier « Shader.h », « Shader.cpp » et un dossier « Shaders » contenant plein de petits fichiers. Vous placerez tout ça dans le répertoire de chaque projet que vous ferez (donc dans chaque chapitre que nous ferons).
Une fois les fichiers ajoutés à votre dossier, il vous suffit simplement d’ajouter le header « Shader.h » et le fichier source « Shader.cpp » à votre projet.
Comme vous le savez maintenant, un shader est un programme, différent d’un programme normal certes mais un programme quand même. Il doit donc respecter plusieurs règles :
Un code source
Une compilation
Nous ne verrons pas ces deux étapes maintenant mais sachez au moins que ce n’est pas si différent d'un programme normal. Les codes sources sont dans le dossier « Shaders » que vous devriez avoir placé dans le répertoire de votre projet.
Avant toute chose, pour pouvoir utiliser les shaders il va falloir utiliser la classe Shader dont voici le constructeur :
vertexSource : C’est le chemin du code source de notre Vertex Shader.
fragmentSource : C’est le chemin du code source de notre Fragment Shader.
Alors attention, appeler le constructeur ne suffit pas. Si vous n'appelez que lui, votre shader ne sera pas exploitable. Pour le rendre exploitable, il faut utiliser la méthode charger() qui permet en gros de lire les fichiers sources, de les compiler, ...
bool charger();
Cette méthode retourne un booléen pour savoir si la création du shader s'est bien passée.
En bref, voici un petit exemple de création de shader :
// Création du shader
Shader shaderBasique("Shaders/basique_2D.vert", "Shaders/basique.frag");
shaderBasique.charger();
// Début de la bouble principale
while(!terminer)
{
// Utilisation
}
Bien, passons à l'utilisation qui est ma foi assez simple puisque nous n'utilisons qu'une seule fonction :
glUseProgram(GLuint program) ;
Elle prend un paramètre : l'ID d'une certain "program", nous lui donnerons l'attribut : "m_programID" de la classe Shader grâce à la méthode :
GLuint getProgramID() const;
Ne vous posez pas de question sur ça pour le moment ;)
Cette fonction a deux utilités :
Lorsqu'on lui donne l'attribut "m_programID", OpenGL va comprendre "Je prends le shader que tu me donnes pour l'utiliser dans mon pipeline".
Une fois qu'on a affiché ce qu'on voulait afficher, on va faire comprendre à OpenGL de ne plus utiliser le shader. Dans ce cas, le paramètre ne sera pas "m_programID" mais nous lui donnerons la valeur 0.
Cette fonction se place juste avant glDrawArrays() lorsque nous voulons activer notre shader, puis après avec le paramètre 0 pour le désactiver :
// Activation du shader
glUseProgram(shaderBasique.getProgramID());
// Affichage du triangle
glDrawArrays(GL_TRIANGLES, 0, 3);
// Désactivation du shader
glUseProgram(0);
Simple non ? :-° D'ailleurs on peut même intégrer, entre ces deux appels de la fonction glUseProgram(), le code relatif à l'envoi des vertices au tableau Vertex Attrib. En général, on fait cela pour bien différencier le code d'affichage du reste du programme, ce qui donnerait pour nous :
// Activation du shader
glUseProgram(shaderBasique.getProgramID());
// On remplie puis on active le tableau Vertex Attrib 0
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
glEnableVertexAttribArray(0);
// Affichage du triangle
glDrawArrays(GL_TRIANGLES, 0, 3);
// On désactive le tableau Vertex Attrib puisque l'on n'en a plus besoin
glDisableVertexAttribArray(0);
// Désactivation du shader
glUseProgram(0);
Dans le chapitre précédent, nous nous sommes permis de ne pas utiliser ces fameux shaders. Mais comme je vous l'ai dit tout à l'heure, on ne peut plus continuer ainsi. Reprenons notre ancien code pour y ajouter les fonctions que nous venons de voir :
void SceneOpenGL::bouclePrincipale()
{
// Variables
bool terminer(false);
float vertices[] = {-0.5, -0.5, 0.0, 0.5, 0.5, -0.5};
Shader shaderBasique("Shaders/basique_2D.vert", "Shaders/basique.frag");
shaderBasique.charger();
// Boucle principale
while(!terminer)
{
// Gestion des évènements
SDL_WaitEvent(&m_evenements);
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT);
// Activation du shader
glUseProgram(shaderBasique.getProgramID());
// On remplie puis on active le tableau Vertex Attrib 0
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
glEnableVertexAttribArray(0);
// Affichage du triangle
glDrawArrays(GL_TRIANGLES, 0, 3);
// On désactive le tableau Vertex Attrib puisque l'on n'en a plus besoin
glDisableVertexAttribArray(0);
// Désactivation du shader
glUseProgram(0);
// Actualisation de la fenêtre
SDL_GL_SwapWindow(m_fenetre);
}
}
Vous devriez vous retrouver avec une fenêtre comme celle-la :
Quoi mais c’est nul ! C’est la même chose que dans le chapitre précédent mais en plus compliqué.
Oui c'est la même chose mais c'est comme cela que les choses doivent être faites ;). Pour le moment vous ne voyez pas trop l'intérêt d'utiliser les shaders mais vous allez vite voir que c'est indispensable.
D'ailleurs pourquoi attendons-nous ? Voyons dès maintenant ce qu'ils ont à nous offrir. :magicien:
Les trois paramètres étaient les composantes RGB (Rouge, Vert, Bleu). Il suffisait de combiner ces trois couleurs pour en former une seule au final. Bonne nouvelle, avec OpenGL c'est pareil. Pour fabriquer une couleur vous devez donner :
Une quantité de rouge
Une quantité de vert
Une quantité de bleu
Si vous voulez faire des tests pour obtenir différentes couleurs, essayez la palette de couleurs Windows (ou équivalent sur Linux) :
Il y a deux façons de représenter les couleurs :
Soit avec des valeurs comprises entre 0 et 255 (la plus compréhensible)
Soit avec des valeurs comprises entre 0 et 1 (un peu difficile d'imaginer une couleur entre 0 et 1)
Malheureusement pour nous, nous allons devoir utiliser la seconde méthode. Mais pas de panique, nous allons utiliser une petite combine pour utiliser la première.
Jauger une couleur entre 0 et 255 est plus facile à comprendre, pour pouvoir utiliser cette méthode nous allons diviser la valeur de la couleur (par exemple : 128) par 255. De cette façon on se retrouve avec une valeur comprise entre 0 et 1 :
La quantité de rouge sera de 128, le vert de 204 et le bleu de 36. De plus faites attention, nous travaillons avec des float donc n'oubliez pas de préciser les décimales même s'il n'y a en pas.
Au niveau du shader, on va changer le shader basique par le shader couleur2D car ce premier ne faisait qu'afficher ce que nous lui donnions en blanc. Maintenant que nous voulons de la couleur, il faut charger un autre shader gérer la couleur :
Vu que nous avons changé le nom du shader (shaderCouleur), il faut effectuer le même changement de nom lorsque l'on récupère le programID :
// Activation du shader
glUseProgram(shaderCouleur.getProgramID());
// Envoi des données et affichage
....
// Désactivation du shader
glUseProgram(0);
Au niveau du code d'affichage, vous connaissez déjà presque tout. Les fonctions utilisées sont les mêmes que celles des coordonnées de vertex. Les couleurs se gèrent également de la même façon : on place la valeur de chaque couleur (RGB) les unes à la suite des autres dans un tableau.
Le seul changement sera le numéro du tableau, au lieu de placer nos couleurs dans le tableau 0 nous les placerons dans le tableau 1. Le premier tableau (indice 0) servira à stocker tous nos vertices et le deuxième tableau (indice 1) servira à stocker nos couleurs.
// On définie les couleurs
float couleurs[] = {0.0, 204.0 / 255.0, 1.0, 0.0, 204.0 / 255.0, 1.0, 0.0, 204.0 / 255.0, 1.0};
while(...)
{
....
// Activation du shader
glUseProgram(shaderCouleur.getProgramID());
// Envoi des vertices
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
glEnableVertexAttribArray(0);
// On rentre les couleurs dans le tableau Vertex Attrib 1
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, couleurs);
glEnableVertexAttribArray(1);
....
// Désactivation du shader
glUseProgram(0);
....
}
Voyons ce que donne toutes ces petites modifications sur un exemple concret :
Vous commencez à voir l'intérêt des shaders ? ^^ Bon si vous n'êtes pas convaincus je vais vous montrer une autre façon de colorier notre triangle.
Si chaque sommet possède sa propre couleur alors OpenGL (avec l'aide des shaders) nous fera un joli petit dégradé entre les différentes couleurs. Prenons un exemple, nous allons définir une couleur différente pour chaque sommet :
// Remplaçons les couleurs par les suivantes ...
float couleurs[] = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0};
//... et voyons ce que ça donne;
Vous devriez avoir ceci :
Comme vous le constatez, les shaders ont calculé automatiquement la couleur de chaque pixel se trouvant entre les différents sommets.
Cette opération s’appelle l’interpolation. C’est-à-dire qu’OpenGL est capable de trouver la couleur de chaque pixel entre deux sommets ayant une couleur différente. Le gros avantage de l'interpolation c'est que nous ne nous en occupons pas :p . En effet, même si nous définissons nos shaders, nous ne nous occuperons pas de trouver la couleur de chaque pixel. Il nous suffira juste de donner notre code source pour seulement un et OpenGL se chargera de faire la même opération pour tous les autres. Magique n'est-ce pas ? :magicien:
Au même titre que le chapitre précédent, vous avez maintenant le droit à votre petite série d'exercice. Votre objectif va être de colorier différents triangles avec une couleur spécifique.
Exercice 1 : Coloriez le triangle du haut avec les valeurs données ci-dessous :
rouge : 240.0
vert: 210.0
bleu : 23.0
Exercice 2 : Même consigne que précédemment mais avec les valeurs :
rouge : 230.0
vert : 0.0
bleu : 230.0
Exercice 3 : Coloriez le triangle uniquement avec la couleur vert (valeur 255.0). Simplifiez le tableau si possible.
Exercice 4 : Reprenez les 3 couleurs précédentes, au lieu de les appliquer au triangle appliquez en une pour chaque vertex. Le résultat final doit ressembler à ceci (ce n'est pas grave si les couleurs ne sont pas dans le même ordre) :
Exercice 5 : Le dernier exercice va allier ce que nous avons vu dans le chapitre précédent et celui-ci. L'objectif est d'afficher un losange multicolore avec les couleurs de l'exercice 4, le dernier vertex doit utiliser le bleu (255.0) :
Solutions
Exercice 1 :
Le but des exercices est de colorier un triangle, il n'y a donc que le tableau couleurs à modifier. Le reste du code ne change pas, on n'envoie toujours ce tableau à OpenGL grâce au Vertex Attrib 1. Pour ce premier exercice, il fallait trouver :
Encore une fois, on conserve le même principe que les exercices 1 et 2. La couleur demandée n'était que le vert ici, ce qui allège un peu notre tableau :
Cependant il y avait un moyen de simplifier cette déclaration. En effet, je vous ai précisé dans le cours que l'on pouvait simplifier les divisions des couleurs brutes (rouge, vert et bleu). Donc au lieu de faire l'opération 255.0 / 255.0, on pouvait directement mettre la valeur 1.0. Ce qui allégeait encore plus le tableau final :
Le but de ce dernier exercice était bien évidemment d'appliquer chacune des couleurs mises en place précédemment sur un vertex en particulier. Nous avions déjà vu ce principe avec l'exemple du triangle multicolore :
L'ordre des couleurs n'est pas important ici, ce n'est pas quelque chose de demandé.
Exercice 5 :
La seule difficulté ici était qu'il fallait reprendre la couleur de deux vertices pour le second triangle. En effet, ceux-ci sont doubler pour pouvoir afficher le triangle du bas, il fallait donc doubler leur couleur :
Pensez à bien affecter la valeur 6 au paramètre count de la fonction glDrawArrays() pour afficher vos deux triangles, sinon elle n'en prendra en compte qu'un seul :
// Affichage des triangles
glDrawArrays(GL_TRIANGLES, 0, 6);
Ce chapitre (un peu compliqué je vous l'accorde) est enfin terminé. Vous savez maintenant ce que sont les shaders et à quoi ils servent. Je pense faire une partie entièrement consacrée aux effets assez sympas que l'on peut réaliser, mais bon ce n'est pas pour maintenant. ;)
Alors là, nous allons aborder un chapitre assez compliqué. Nous allons découvrir ce que sont les matrices avec OpenGL, leur utilité, etc... C'est un chapitre assez technique et surtout rempli de mathématiques :-° . Je vous conseille de bien le relire plusieurs fois jusqu'à que vous ayez tout compris, car les matrices sont indispensables avec OpenGL. ;)
Lançons nous tout de suite dans le monde des mathématiques. Je vous rappelle que si vous voulez développer des jeux-vidéo vous ne pouvez pas éviter les maths, elles sont indispensables au bon fonctionnement du jeu. Les premières notions que nous allons apprendre sont les transformations.
Qu'est-ce qu'une transformation vous allez me dire ? Au lieu d'une définition barbante que nous n'allons point retenir ( :p ) je vais vous donner des exemples de transformations et vous comprendrez tout de suite ce que c'est :
Une translation : consiste à faire "glisser" un ensemble de points sur un "rail" (un vecteur).
Une rotation : consiste à faire pivoter un ensemble de points d'un angle Thêta par rapport à un point.
Une homothétie : consiste simplement à agrandir ou à réduire une forme géométrique.
Une transformation est donc grosso-modo une modification apportée à un ensemble de points ou repère dans un espace donné (soit 2D, soit 3D, etc ...). Les 3 transformations que vous voyez là sont les principales transformations utilisées dans OpenGL, en particulier la translation.
Ok, on sait ce qu'est une transformation maintenant, mais à quoi ça sert ?
Elles vont nous servir à placer tous nos objets (personnages, arbres, maisons, ...) dans notre monde 3D. A chaque fois que nous voudrons placer un objet nous utiliserons les transformations. Elles seront essentiellement utilisées sur le repère.
Les matrices sous OpenGL 2.1
Avant de parler du fonctionnement des matrices dans la version 3, je vais vous parler de celui des anciennes versions, ce sera plus facile à comprendre. De plus le principe ne change absolument pas d'une version à l'autre. Tout d'abord, qu'est-ce qu'une matrice ? C'est un tableau de nombres ordonnés en lignes et en colonnes entourés par des parenthèses. Sa syntaxe est semblable à celle d'un vecteur mais avec plus de nombres :
Une matrice n'est pas forcément un tableau de 9 cases, elle peut en contenir jusqu'à l'infini. Cependant ce n'est pas qu'un simple tableau, c'est une sorte de super-vecteur qui permet de faire pas mal de choses intéressantes. Un vecteur est en général utilisé en 3D pour gérer les points, les directions, les normales... Les matrices permettent de faire bien plus que ça. Elles servent principalement à convertir des données géométriques en données numériques. Il est plus facile de travailler avec des nombres qu'avec des compas et des équerres placés sur notre écran. :p Voyons d'ailleurs pourquoi OpenGL a besoin des matrices :
Pour la projection : grâce à cela nous allons pourvoir "transformer" un monde 3D en un monde 2D (jusqu'à nouvel ordre, un écran ne dispose que de deux dimensions).
Pour les transformations : regroupant les transformations que je vous ai énumérées à l'instant.
Ce sont les besoins fondamentaux d'OpenGL pour utiliser la 3D. Dans les anciennes versions, les matrices étaient gérées automatiquement, on n'utilisait que quelques fonctions pour les créer et les utiliser. Depuis la version 3.1, ces fonctions sont supprimées, car l'approche des jeux d'aujourd'hui est différente. Heureusement pour nous, nous avons en notre possession une librairie mathématique du nom de GLM qui permet de gérer les matrices (et bien plus), mais avant de commencer à les manipuler nous allons faire un peu de théorie pour bien comprendre ce que nous faisons.
En définitif, nous aurons besoin de deux matrices pour coder avec OpenGL :
La matrice de projection : qui sert à transformer notre monde 3D en un monde 2D affichable sur l'écran.
La matrice modelview (ou visualisation de modèle): qui sera la matrice principale, c'est sur elle que nous allons appliquer nos transformations.
Chaque vertex sera multiplié par ces deux matrices pour pouvoir être affiché sur notre écran, le tout en nous donnant une impression de 3D (magique n'est-ce pas :magicien: ).
Dans cette partie nous allons apprendre ce que sont les matrices et comment les utiliser pour nos programmes. Pour simplifier cet apprentissage, je ne vais vous montrer que la multiplication de matrices car c'est une notion que nous retrouverons assez souvent dans ce tuto. De plus, cela vous donnera une bonne idée des calculs matriciels sans pour autant voir les choses les plus compliquées (inversion, déterminant, etc.). :p
Matrice carrée
On commence tout de suite cette partie par les matrices carrées.
Une matrice carrée est une matrice ayant le même nombres de colonnes et de lignes :
Il y a 3 rangées de colonnes et 3 rangées de lignes, c'est donc ce que l'on appelle une matrice carrée. Son nom complet est d'ailleurs : matrice carrée d'ordre 3. Le chiffre à la fin permet de spécifier la taille. Si on avait mis le chiffre 4, alors la matrice aurait eu 4 colonnes et 4 lignes. Nous n'utiliserons que les matrices carrées dans OpenGL. Et tant mieux, car ça simplifie grandement les choses :-° .
Matrice d'identité
Sous ce nom barbare se cache en réalité la matrice la plus simple qu'il soit. Elle a la particularité d'avoir toutes ses valeurs égales à 0 hormis les valeurs de sa diagonale qui, elles, sont égales à 1. Quelle que soit la taille de la matrice, on retrouvera toujours cette particularité :
Si nous multiplions un vecteur par une telle matrice, le vecteur ne sera absolument pas changé :p :
Nous allons étudier la multiplication matricielle dans un instant. Mais vous pouvez déjà retenir ce principe : si un vecteur est multiplié par une matrice d'identité, alors il ne sera pas modifié.
Multiplication d'un vecteur par une matrice
Partie 1 : la vérification
Dans ce premier exemple, je vais prendre une matrice carrée d'ordre 3 pour simplifier les choses. Sachez cependant que le principe est le même quelle que soit la taille de la matrice.
Voyons comment arriver à ce résultat. Premièrement, il faut vérifier que le nombre de colonnes de la matrice soit égal au nombre de coordonnées du vecteur. Si ce n'est pas le cas alors la multiplication est impossible.
La matrice possède 3 colonnes et le vecteur possède 3 coordonnées. La multiplication est donc possible.
Prenons un autre exemple :
La matrice possède 2 colonnes et le vecteur 3 coordonnées. La multiplication est impossible. Vous avez compris le principe ? Bien, passons à la suite.
Partie 2 : La multiplication
Attaquons la partie la plus intéressante :diable: . La multiplication d'une matrice et d'un vecteur s'effectue comme ceci : pour une seule coordonnée (x, y, ou z) du vecteur résultat, nous allons faire la somme des multiplications de chaque nombre d'une ligne de la matrice par chaque nombre correspondant du vecteur. Un peu barbant comme explication, rien ne vaut un bon exemple :
Commençons déjà par la première ligne, vous devriez comprendre le principe une fois cet exemple fini :
Il faut multiplier les nombres de même couleur sur le schéma, puis additionner les différents résultats. Une ligne de la matrice ne donnera qu'une seule coordonnée du vecteur résultat, ici la coordonnée x. Passons à la deuxième ligne :
Vous comprenez le principe ? La première ligne nous a donné la première coordonnée, la deuxième ligne nous donne la deuxième coordonnée. Nous multiplions chaque ligne de la matrice par toutes les valeurs du vecteur. Passons à la troisième ligne :
La troisième ligne de la matrice nous donne la troisième coordonnée. Si la matrice avait eu 4 lignes (et donc 4 colonnes) alors il y aurait eu une quatrième opération du même type pour la quatrième coordonnée. Si on récapitule tout ça, on a :
Vous avez compris ? Je vous donne un autre exemple pour comparer les différents résultats :
N'hésitez pas à bien relire et essayez de comprendre ces deux exemples. C'est la base de la multiplication matricielle.
Passons à un pseudo-exemple, vous n'avez pas oublié ce qu'est la matrice d'identité j'espère :p , on sait qu'un vecteur multiplié par cette matrice ne changera pas. Maintenant que l'on connait un peu la multiplication on va voir pourquoi le vecteur n'est pas modifié :
Oh magie, le résultat ne change pas. Vous comprenez pourquoi cette matrice est la plus simple à utiliser. ;) Je vais maintenant vous donner un exemple de multiplication avec une matrice carrée d'ordre 4. Le principe ne change absolument pas, nous allons voir cet exemple étape par étape pour bien comprendre. Commençons :
La matrice possède 4 colonnes et le vecteur 4 lignes, la multiplication est possible. Maintenant on passe à la première étape :
Vous ne voyez qu'une seule différence : on ajoute à la somme une multiplication supplémentaire. En effet, n'oubliez pas que l'on multiplie les nombres de la matrice avec les nombres correspondants du vecteur. Pour la deuxième ligne c'est pareil :
Pour la troisième ligne ça ne change pas :
Ah, une quatrième ligne, eh bien oui c'est plus long :p . Mais ça ne change toujours pas :
Ce qui nous donne au final :
Vous voyez, le principe de la multiplication ne change absolument pas.
Piouf, cette partie était un peu difficile à comprendre. Je vais vous donner quelques exercices pour vous entrainer à la multiplication. Faites-les sérieusement, vous avez besoin de comprendre ce que l'on vient de faire pour comprendre la suite. Sinon vous serez complétement largués :( .
Exercices de multiplications
Exercice 1 :
Exercice 2 :
Exercice 3 :
Solutions
Exercice 1 :
Exercice 2 :
La multiplication est impossible, la matrice possède 3 colonnes alors que le vecteur possède 4 coordonnées.
Exercice 3 :
Multiplication de deux matrices
La multiplication de deux matrices peut sembler plus compliquée à première vue mais si vous avez compris ce que l'on a fait avant, alors vous savez déjà multiplier deux matrices.
Partie 1
Avant tout, pour pouvoir multiplier deux matrices carrées entre-elles il faut absolument que les deux matrices soient de la même taille (donc du même ordre), c'est-à-dire qu'elles doivent avoir le même nombre de lignes et le même nombre de colonnes.
Les deux matrices ont la même taille, on peut donc les multiplier. Prenons un autre exemple :
Ici, les deux matrices n'ont pas la même taille nous ne pouvons pas les multiplier. Il ne devrait y avoir rien de compliqué pour le moment. :)
Partie 2
Pour pouvoir multiplier deux matrices carrées, nous allons utiliser une petite combine. Nous allons couper la deuxième matrice en 3 vecteurs (ou 4 selon la taille), puis nous appliquerons la multiplication que nous avons vue à l'instant pour chacun de ces vecteurs. Prenons deux matrices cobayes :
Bien, coupons la seconde matrice en 3 vecteurs :
Maintenant il nous suffit d'appliquer la multiplication d'une matrice et d'un vecteur pour chaque vecteur que nous venons de créer. Voici ce que ça donne pour le premier :
Au tour du deuxième :
Quand il y a un "0" dans une matrice ça nous facilite grandement le calcul :lol: . Bref, on fait la même chose avec le dernier vecteur :
Nous obtenons au final 3 vecteurs fraichement calculés. Il nous suffit ensuite de les assembler dans l'ordre de la division de la matrice !
Au final, nous obtenons :
Vous voyez, une fois que vous avez compris la première multiplication, vous savez déjà multiplier deux matrices. Évidemment, la taille de la matrice ne change toujours pas le principe, voyons ensemble un exemple de multiplication de deux matrices carrées d'ordre 4.
Coupons la seconde matrice en 4 vecteurs (et oui car c'est une matrice carrée d'ordre 4 et pas 3) :
Il nous suffit maintenant d'appliquer la multiplication "Matrice - Vecteur" sur les 4 vecteurs que nous venons de créer. Voici le premier résultat :
Puis le second :
Le troisième :
Et enfin le quatrième et dernier vecteur :
Maintenant on réunit les vecteurs résultats dans le bon ordre :
Voici donc le résultat final :
Vous remarquez que la multiplication se passe exactement de la même façon que l'on soit en présence de matrices à 3 colonnes ou à 4 colonnes ou même à 1000 colonnes :p . Passons maintenant à quelques exercices pour vous entrainer. C'est important je le répète, essayez de faire ces exercices sérieusement.
Exercices de multiplications
Exercice 1 :
Exercice 2 :
Exercice 3 :
Solutions
Exercice 1 :
La multiplication est impossible, la première matrice possède 4 lignes et 4 colonnes alors que la seconde matrice ne possède que 3 lignes et 3 colonnes.
Exercice 2 :
Exercice 3 :
Et voilà, la partie la plus compliquée de ce chapitre est enfin terminée ! La multiplication matricielle est une notion difficile à comprendre (même s'il en existe bien d'autres plus complexes) mais vous êtes maintenant capables de la maitriser. :D
Le but de la partie précédente était d'apprendre à multiplier deux matrices. J'ai volontairement inclus des exercices avec des matrices carrées d'ordre 4 car OpenGL aura besoin la plupart du temps de ce genre de matrices (et heureusement !). Dans cette partie, nous allons faire passer les transformations de la géométrie à l'algèbre (les nombres).
Comme vous le savez les transformations sont des outils géométriques, pour calculer une rotation par exemple nous avons besoin d'un rapporteur. Or c'est un peu compliqué de poser notre rapporteur sur l'écran, surtout si l'angle se trouve derrière le dos d'un personnage :lol: . Pour pouvoir utiliser les transformations numériquement nous devons utiliser... les matrices. ;)
La translation
La translation permet de déplacer un ensemble de points ou une forme dans un espace donné. En gros, on prend un vecteur qui servira de "rail" puis on fera glisser nos points sur ce rail. La forme finale ne sera pas modifiée, elle sera juste déplacée. :)
Une translation en 3 dimensions se traduit par la matrice suivante :
X : C'est la coordonnée x du vecteur de translation.
Y : C'est la coordonnée y du vecteur de translation.
Z : C'est la coordonnée z du vecteur de translation.
Vous remarquerez que cette matrice ressemble beaucoup à la matrice d'identité, il n'y a que les coordonnées du vecteur en plus.
L'homothétie
Une homothétie permet d'agrandir ou de réduire une forme géométrique, voici la matrice correspondante :
X : C'est le facteur multiplicatif de l'axe x.
Y : C'est le facteur multiplicatif de l'axe y.
Z : C'est le facteur multiplicatif de l'axe z.
La rotation
Attention ! La matrice que vous allez voir est certainement la matrice la plus compliquée de tout le tutoriel :p . Cependant, il est inutile de la retenir, elle est trop complexe nous la verrons jamais directement :
Je vous avais dit que cette matrice était... particulière (pour vous dire, je ne la connais pas par cœur moi-même). Grâce à elle, nous pouvons faire pivoter en ensemble de points d'un angle thêta autour d'un axe défini par les coordonnées (x, y, z). Nul besoin de retenir cette matrice, je le répète. ;)
Durant l'introduction, je vous ai brièvement parlé de deux matrices : la projection (qui permet de convertir un monde 3D en un monde 2D affichable sur notre écran) et la modelview (qui permet de placer nos objets dans ce même monde 3D).
C'est sur cette dernière matrice que l'on effectuera toutes les transformations que l'on a vues, à savoir : la translation, la rotation et l'homothétie. Voyons d'ailleurs comment placer un objet dans un monde 3D :
On crée la matrice modelview (une seule fois pour tout le programme, pas une seule fois par objet).
Puis on effectue une transformation sur cette matrice (par exemple une rotation de 90° sur l'axe Z).
Enfin on dessine ce que l'on veut dessiner et vu que l'on a effectué une rotation de 90°, alors notre objet sera penché de 90°.
Voyons ce que cela donne si je fais pivoter un triangle de 90°...
Voici un triangle avant la rotation :
Et le revoilà après :
Vous voyez ce qui c'est passé ? Le triangle a pivoté de 90° grâce à une transformation.
Les Transformations sous OpenGL
Aaahh... on commence à relier OpenGL et les mathématiques (enfin !). Il est temps de voir le comportement des transformations dans un programme.
La première chose à savoir est que chaque transformation (rotation, ...) sera effectuée non pas sur un objet mais sur le REPÈRE du monde, c'est-à-dire que si l'on veut faire pivoter un objet de 120° alors il faudra faire pivoter non pas l'objet en lui-même mais son REPÈRE.
Par exemple, si vous voulez afficher un objet à 300 mètres d'un autre, vous n'allez pas ajouter 300 unités de longueur à chaque vertex de l'objet, autant garder les vertices déjà définis.
Hein ??? J'ai rien compris. o_O
Bon, mettons que vous ayez un méchant soldat ennemi de 500 vertices. Vous n'allez pas modifier ses 500 vertices pour afficher un autre ennemi deux mètres plus loin. Ça serait trop couteux en ressources, surtout si vous affichez une armée de 200 soldats...
A la place, on modifiera la position du repère (qui est en fait la matrice modelview) pour chaque objet que l'on veut dessiner. Un personnage gardera ses 500 vertices intactes quelque soit sa position.
Vous voyez la différence entre modifier toute une scène de dizaines de milliers de vertices et modifier une matrice de 16 valeurs.
Illustrations
Je vais vous donner quelques exemples de transformations effectuées sur un repère, ce sera plus simple à comprendre si vous voyez ce que donne chaque transformation.
Le repère de base
Le repère de base est en fait un repère qui n'a pas encore été modifié, c'est sur celui-ci que l'on effectuera notre première transformation. Graphiquement, on représente un repère comme ceci :
Au niveau des matrices, ce repère correspond simplement à une matrice d'identité d'ordre 4 :
La translation
La translation est la transformation la plus simple :p . N'oubliez pas que c'est le REPÈRE qui est modifié. Ici, on translate le repère par rapport au vecteur V(2, 1), soit deux unités de longueur sur l'axe X et 1 unité sur l'axe Y :
Avec les matrices on multiplierait la matrice modelview par la matrice de translation suivante :
La rotation
Le principe ne change pas pour la rotation, on effectue la transformation sur le repère. Voici un exemple d'une rotation de 45° sur l'axe Z :
Pour la rotation, on multipliera la matrice modelview par la matrice de rotation (toujours aussi effrayante :diable: ) :
L'homothétie
En temps normal, avec une homothétie on modifie la taille d'une forme géométrique. Sauf qu'ici on modifie la taille du repère, donc on modifie la taille de chaque unité de longueur. Voici ce que donne une homothétie de coordonnées (2, 2) sur le repère :
Vous voyez que la taille du repère à changé, désormais si on fait une translation par un vecteur(2, 1) alors la translation sera plus grande et le repère se retrouvera plus loin.
Pour ce qui est de la matrice modelview, il suffira de la multiplier par la matrice suivante :
Les matrices et la boucle principale
Vous n'êtes pas sans savoir qu'un jeu se passe en grande partie dans ce que l'on appelle la boucle principale, c'est une boucle qui va se répéter indéfiniment jusqu'à ce que le joueur arrête de jouer (enfin pas vraiment, mais partons de ce principe).
Il faudra à chaque tour de boucle réinitialiser la matrice modelview pour ne pas qu'elle garde les traces de transformations du tour précédent. Si c'était le cas, le jeu ne se redessinerait jamais au même endroit et serait totalement difforme.
Voici ce qui se passera à chaque tour de boucle :
On replacera notre repère à sa position initiale, grâce à la fonction loadIdentity.
On effectuera une ou des transformation(s) pour afficher un objet grâce aux fonctions translate, ...
On répètera la deuxième étape jusqu'à que tous les objets du niveau soient affichés.
Toutes ces étapes se répèteront indéfiniment dans notre programme. En général, on affiche tous les objets d'un niveau 50 à 60 fois par seconde, imaginez le fourbi que l'ordinateur doit calculer. :p
Accumulation de transformations
Attention, de même que l'ordre des matrices dans la multiplication, l'ordre des transformations a une importance capitale ! Si vous effectuez une translation puis une rotation, vous n'obtiendrez pas la même chose que si vous faisiez une rotation et une translation.
Comme d'habitude prenons un petit exemple. Dans un premier temps, je vais faire une translation du repère par rapport au vecteur V(2, 1), puis une rotation de 90° sur l'axe Z (l'axe Z est en fait pointé vers nous, c'est pour ça que nous ne le voyons pas) :
Maintenant je vais l'inverse, une rotation de 90° puis une translation de vecteur (2, 1) :
Oh tiens ! Les repères ne sont pas les mêmes. Et oui, les repères sont différents car l'ordre des transformations est important. Faites donc bien attention à ce que vous voulez faire et à l'ordre dans lequel effectuer ces transformations. ;)
Enfin ce chapitre est terminé, il nous aura fallu du temps mais n’oubliez pas que tout ce que je vous enseigne est nécessaire pour comprendre et utiliser OpenGL. Récapitulons ce que nous savons faire :
On sait afficher des vertices pour former des formes géométriques.
On sait utiliser les shaders pour leur donner un peu de couleurs (même si l’on peut faire bien plus avec eux ;) ).
On sait utiliser des matrices pour effectuer des transformations.
Bonne nouvelle ! Nous connaissons tout ce qui est nécessaire pour ajouter une troisième dimension à nos programmes. Il est d'ailleurs temps mes amis, attaquons-nous à cette nouvelle dimension qui s'offre à nous ! :pirate:
Le chapitre précédent était un peu compliqué. Mais grâce à lui, nous sommes maintenant capables d'utiliser les transformations pour façonner un monde 3D. Dans ce nouveau chapitre, nous allons mettre en commun ce que l'on sait sur OpenGL avec ce que l'on a appris concernant les matrices.
J'ai préféré diviser ce chapitre en deux parties, étant donné que nous allons aborder pas mal de nouvelles notions. Dans cette première partie, nous allons nous concentrer sur l'implémentation des matrices dans notre programme, puis dans la seconde partie, nous implémenterons la troisième dimension et surtout nous apprendrons à l'utiliser ! :p
Dans le chapitre précédent, nous avons vu ce qu'étaient les matrices ainsi que leur fonctionnement. Nous nous sommes surtout concentrés sur la matrice modelview qui permet de placer nos objets dans un monde 3D. Les transformations que nous avons vues (translation, rotation et homothétie) sont appliquées sur elle.
Dans ce chapitre-la maintenant, nous allons nous concentrer sur une autre matrice : la matrice de projection.
Sa seule utilité est de permettre l'affichage d'un monde 3D sur notre écran qui lui ne possède que 2 dimensions. Cependant, vous vous imaginez bien que ce processus n'est pas aussi simple que cela, on a quand même besoin de quelques paramètres pour permettre une telle opération.
Pour expliquer son fonctionnement, je vais prendre une bonne vieille fonction connue des habitués d'OpenGL : la fonction gluPerspective().
Euh ouai, mais moi je viens ici pour apprendre OpenGL, je ne connais pas cette fonction. :colere2:
Ne vous inquiétez pas, je vais vous expliquer le fonctionnement de cette fonction en détails, et j'ai plutôt intérêt à le faire étant donné que nous allons la recoder entièrement. :p
Comme je vous l'ai dit dans les chapitres précédents, une bonne partie des anciennes fonctions d'OpenGL a disparu avec la nouvelle version. La fonction gluPerspective a elle aussi disparu (indirectement). Il nous faudra donc la coder de nos propres mains. Et pour vous faciliter la tâche, je vais vous expliquer en détails son fonctionnement.
Hum tout plein de paramètres intéressants ^^ . Je vais commencer par les deux derniers paramètres : near et far.
Near
Le paramètre Near correspond à la distance entre votre écran et ce qui sera affiché.
La petite zone entre l'écran et la plaque est une zone où rien ne sera affiché. Même s'il y a un objet dans cet intervalle il ne sera pas affiché.
Pourquoi me direz-vous ? Simplement parce que les mathématiques nous l'imposent, il nous faut obligatoirement une zone non-affichable pour que le calcul de la projection puisse s'effectuer. Il se passe la même chose avec la division par zéro, c'est impossible. Il faut obligatoirement que le dénominateur soit au moins un peu plus grand (ou plus petit mais on s'en moque) que zéro.
Le paramètre near représente donc ce petit intervalle nécessaire au calcul de la projection.
Far
Le paramètre far est un peu plus simple, on peut dire que c'est l'inverse de near.
C'est également une distance entre votre écran et ce qui sera affiché. La différence est que tout objet se trouvant au delà de cette distance ne sera pas affiché.
Au final, pour qu'un objet puisse s'afficher sur l'écran, il faut qu'il se situe entre les zones near et far, sinon il ne sera pas affiché.
Ratio
Je pense que vous avez tous entendu parler des télévisions 4/3 (quatre tiers) et 16/9 (seize neuvièmes). Le ratio est le rapport entre la longueur de la télé et sa hauteur. Dans la plupart des cas nous avons soit un ratio de 4/3 soit un ratio de 16/9, voire 16/10 pour les jeux-vidéo.
Pour nous, le ratio s'appliquera à la fenêtre SDL que l'on a codée. Dans le chapitre 3, nous avions créé une fenêtre de 800 pixels par 600 pixels. Le ratio sera donc de 800/600, et si on simplifie la fraction on a un ratio de 4/3.
Dans les jeux-vidéo, on propose généralement plusieurs modes d'affichage afin de s'adapter à l'écran du joueur. En effet, tous les joueurs n'ont pas forcément le même écran. Nous donnerons au final comme paramètre la division entre la longueur de la fenêtre SDL et sa hauteur.
Angle
Ce paramètre est le plus spécial de la fonction. C'est en fait l'angle de vue avec lequel nous allons voir la scène. Plus cet angle sera petit, plus on aura l'impression de faire un effet de zoom sur la scène. Et à l'inverse, plus il sera grand, plus on aura l'impression que la scène s'éloigne et se déforme.
Voici trois exemples de la même scène avec trois angles de vue différents :
Merci à Kayl pour sa scène de test. ^^ Dans la première image, on utilise un angle de 70°, dans la deuxième un angle de 30° et dans la troisième un angle de 100°.
L'avantage d'un angle plus petit est que l'on a l'impression de faire un zoom de jumelle ou de sniper ;) . A l'inverse, un angle plus grand créera une ambiance plus particulière, mais ce genre d'angle s'utilise rarement.
En général, l'angle de vision "normal" est de 70° (70 degrés) et c'est d'ailleurs ce que l'on mettra dans notre code.
Utilisation de la librairie GLM
Dans les précédentes versions d'OpenGL, il existait une multitude de fonctionnalités mathématiques qui étaient gérées par OpenGL même, les matrices en faisait évidemment partie. Cependant, ces fonctionnalités sont maintenant supprimées et il faut trouver un autre moyen de gérer nos matrices nous-même.
Heureusement pour nous, il existe une librairie qui s'appelle GLM (pour OpenGLMathematics). Vous l'avez déjà téléchargée normalement donc vous n'avez rien à faire. Cette librairie nous permet d'utiliser les matrices, et bien plus encore, sans avoir à nous soucier de tout programmer à la main. Elle inclut même certaines fonctions dépréciées comme gluPerspective(). :)
Nous allons donc utiliser GLM pour simuler toutes les fonctions dont nous aurons besoin.
Toutes ces belles matrices (que vous adorez j'en suis sûr :p ) ne nous servent pas à grand chose pour l'instant. Nous allons maintenant les intégrer dans notre code OpenGL pour enfin voir ce qu'elles ont véritablement dans le ventre.
Reprenons le code de la boucle principale que nous avons laissé au chapitre 4 :
Je n'ai pas mis tout le code, seule cette partie nous intéresse pour le moment.
Les en-têtes de GLM
Vous savez maintenant que l'on a besoin de deux matrices pour faire fonctionner un jeu 3D : la matrice de projection et la modelview. Nous allons donc les déclarer dans notre code grâce à librairie GLM.
Pour commencer, veuillez inclure les en-têtes suivants dans le fichier SceneOpenGL.h qui permettent à notre programme d'utiliser les matrices :
// Includes OpenGL
....
// Includes GLM
#include <glm/glm.hpp>
#include <glm/gtx/transform.hpp>
#include <glm/gtc/type_ptr.hpp>
// Autres includes
#include <SDL2/SDL.h>
#include <iostream>
#include <string>
#include "Shader.h"
La première inclusion permet d'utiliser les fonctionnalités principales de GLM, la deuxième les transformations et la dernière permet de récupérer les valeurs des matrices (nous verrons cela dans un instant).
Elle est bizarre ton extension c'est marqué .hpp c'est normal ?
Oui tout à fait. :)
La particularité de GLM c'est qu'elle est codée directement dans les headers, il n'y a pas de librairie pré-compilée comme la SDL par exemple. Grâce à ce système, GLM est compatible avec toutes les plateformes et il n'y a pas besoin de fournir de fichier spécifique pour Linux, Windows, etc. Ni même pour compilateur (.a, .dll, etc.)
Inclusion des matrices
Après toute cette théorie nous pouvons enfin coder nos premières matrices. L'objectif est de créer deux matrices du nom de projection et modelview.
Pour cela, nous allons déclarer deux objets de type mat4, pour matrice carrée d'ordre 4. Il faut utiliser le namespace glm en même temps que la déclaration :
// Matrices projection et modelview
glm::mat4 projection;
glm::mat4 modelview;
Tous les objets et méthodes de GLM doivent utiliser le namespace glm. Pour éviter d'avoir à le taper à chaque fois, ajoutez la ligne de code suivante dans chaque classe où vous utiliser les matrices :
// Permet d'éviter la ré-écriture du namespace glm::
using namespace glm;
Avec cette ligne de code, la déclaration des matrices devient :
// Matrices projection et modelview
mat4 projection;
mat4 modelview;
Maintenant que les matrices sont déclarées, nous allons pouvoir les initialiser.
La matrice projection
Occupons-nous d’abord de la projection. Dans la partie précédente, nous avons parlé d'une ancienne fonction du nom de gluPerspective(). Celle-ci a disparu avec OpenGL 3 mais GLM l'a gentiment recoder pour nous sous la forme d'une méthode, voici son prototype :
On remarque qu'elle contient exactement les mêmes paramètres, pratique n'est-ce pas ? La seule différence est qu'elle renvoie un objet de type mat4, cet objet doit être affecté à la matrice projection.
Nous appellerons cette méthode avec les paramètres suivants :
Un angle de 70° (qui correspond à un angle de vision normal).
Un ratio en fonction de la taille de la fenêtre, ici un ratio de m_largeurFenetre / m_hauteurFenetre.
Pour la matrice modelview c'est un peu plus simple. Pour l'initialiser, nous allons leur donner les valeurs d'une matrice d'identité (avec les 1 en diagonale). Elle ne doit pas avoir que des valeurs nulles car si nous multiplions des vecteurs par des 0, il risquerait d'y avoir un gros problème avec notre scène finale. :-°
Pour cela, nous allons utiliser un constructeur qui ne demande qu'une seule valeur : la valeur 1.0.
Le constructeur sait tout seul qu'il doit utiliser une matrice d’identité, cela éviter d'avoir à écrire toutes les valeurs à la main pour mettre des 1.0 en diagonal. :p
La boucle principale
Vous vous souvenez qu'au début du chapitre je vous ai parlé du comportement des matrices avec la boucle principale du programme ? Je vous avais dit qu'à chaque tour de boucle, il fallait réinitialiser la matrice modelview pour ne pas avoir les anciennes valeurs du tour précédent.
Cette réinitialisation se fait exactement de la même façon que l'initialisation. C'est-à-dire que l'on affecte le résultat du constructeur mat4(1.0) à la matrice modelview. Nous devons faire ceci juste après le nettoyage de l'écran :
while(!terminer)
{
// Gestion des évènements
SDL_WaitEvent(&m_evenements);
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT);
// Réinitialisation de la matrice modelview
modelview = mat4(1.0);
}
Interaction entre le programme et les matrices
Notre code OpenGL devient de plus en plus beau :D . Mais les matrices ne nous servent toujours pas à grand chose elles n'interagissent toujours pas avec notre programme.
Je ne vais pas m'étaler sur le sujet pour le moment, mais sachez que l'interaction entre les matrices et le programme se fait dans le shader. C'est lui qui va faire tous les calculs pour projeter le monde 3D sur notre écran.
Pour le moment, le shader que nous avons chargé (couleur2D) est incapable de gérer la projection, il faut en charger un autre. Si vous n'avez rien modifié dans le dossier "Shaders" du chapitre 4, vous devriez trouver des codes sources qui s'appellent couleur3D.vert et couleur3D.frag(à ne pas confondre avec couleur2D). Si vous ne trouvez pas ces fichiers, re-téléchargez le dossier complet depuis le chapitre 4.
À la différence du premier shader, couleur3D est, lui, capable de gérer la projection. Nous allons donc le charger à la place de son prédécesseur :
// Shader gérant la couleur et les matrices
Shader shaderCouleur("Shaders/couleur3D.vert", "Shaders/couleur3D.frag");
shaderCouleur.charger();
Ne vous inquiétez pas si le fragment shader ne change pas, c'est normal. ;)
Le shader n'est pas le seul à devoir changer, les vertices doivent elles aussi subir une modification. Il faut leur ajouter une troisième dimension pour les rendre compatibles avec la projection.
Ça veut dire que nos triangles seront en 3D ?
Oui et non ... En fait, ils sont en 3D mais pour le moment nous sommes mal placés pour les voir de cette façon. Nous verrons dans la deuxième partie de ce chapitre comment "bien se placer". Ne vous inquiétez pas ça arrivera très vite. ;)
Bref au final, il suffit d'ajouter la coordonnée Z à tous les vertices pour qu'ils soient en 3D. Nous mettrons la valeur -1 pour le moment :
Vu que nous ajoutons une coordonnée à nos vertices, il va falloir modifier un paramètre dans la fonction glVertexAttribPointer. Le paramètre size permet de spécifier la taille d'un vertex, donc son nombre de coordonnées. On le passe désormais à 3 car nous avons 3 coordonnées :
Revenons à notre shader, vu que c'est lui qui gère les calculs de projection, il va falloir lui envoyer les matrices que nous avons déclarées pour qu'il puisse travailler correctement. L'envoi de matrice au shader se fait avec cette fonction :
location : permet de savoir où envoyer les matrices à l'intérieur même du shader.
count : permet de savoir combien de matrice on envoie. On n'enverra qu'une seule matrice à la fois.
transpose : booléen qui permet d'inverser ou non la matrice qu'on envoie. Dans notre cas, on lui donnera toujours la valeur GL_FALSE.
value : est un pointeur sur le tableau de valeurs de la matrice. Nous utiliserons la méthode value_ptr() de la librairie GLM
Je vous expliquerai en détails le fonctionnement de cette fonction dans le chapitre sur les shaders. Sachez juste qu'elle nous permet d'envoyer nos matrices au shader.
Nous lui enverrons ces valeurs :
location : glGetUniformLocation(shaderCouleur.getProgramID(), "Le_nom_de_la_matrice"). Oui c'est bien une fonction (on la verra également dans le chapitre sur les shaders). Le paramètre en rouge est une chaine de caractères, nous lui donnerons la valeur "modelview" et "projection".
count : 1 (le chiffre 1) pour une matrice.
transpose : GL_FALSE.
value : Nous lui donnerons le résultat de la méthode value_ptr() de nos objets mat4.
Grâce à cette fonction, notre shader va pouvoir travailler avec les matrices que nous avons déclarées dans le main.
Nous appellerons cette fonction deux fois vu que nous avons deux matrices à envoyer. Ces appels se feront juste avant une fonction que vous connaissez bien : glDrawArrays().
Dans notre cas, nous ferons ces appels toujours de la même façon :
// On envoie les matrices au shader
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "modelview"), 1, GL_FALSE, value_ptr(modelview));
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "projection"), 1, GL_FALSE, value_ptr(projection));
Si on les intègre au code d'affichage ça donne ceci :
// On spécifie quel shader utiliser
glUseProgram(shaderCouleur.getProgramID());
// Envoi des vertices et des couleurs
....
// On envoie les matrices au shader
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "modelview"), 1, GL_FALSE, value_ptr(modelview));
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "projection"), 1, GL_FALSE, value_ptr(projection));
// On affiche le polygone
glDrawArrays(GL_TRIANGLES, 0, 3);
// Désactivation des Vertex Attrib
....
// On n'utilise plus le shader
glUseProgram(0);
Je pense vous avoir parlé de tout, compilons tout ça pour voir que ça donne :
void SceneOpenGL::bouclePrincipale()
{
// Variables
bool terminer(false);
float vertices[] = {-0.5, -0.5, -1.0, 0.0, 0.5, -1.0, 0.5, -0.5, -1.0};
float couleurs[] = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0};
// Shader
Shader shaderCouleur("Shaders/couleur3D.vert", "Shaders/couleur3D.frag");
shaderCouleur.charger();
// Matrices
mat4 projection;
mat4 modelview;
projection = perspective(70.0, (double) m_largeurFenetre / m_hauteurFenetre, 1.0, 100.0);
modelview = mat4(1.0);
// Boucle principale
while(!terminer)
{
// Gestion des évènements
SDL_WaitEvent(&m_evenements);
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT);
// Réinitialisation de la matrice modelview
modelview = mat4(1.0);
// On spécifie quel shader utiliser
glUseProgram(shaderCouleur.getProgramID());
// On remplie puis on active le tableau Vertex Attrib 0
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, vertices);
glEnableVertexAttribArray(0);
// Même chose avec le tableau Vertex Attrib 1
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, couleurs);
glEnableVertexAttribArray(1);
// On envoie les matrices au shader
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "modelview"), 1, GL_FALSE, value_ptr(modelview));
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "projection"), 1, GL_FALSE, value_ptr(projection));
// On affiche le polygone
glDrawArrays(GL_TRIANGLES, 0, 3);
// On désactive les tableaux Vertex Attrib puisque l'on n'en a plus besoin
glDisableVertexAttribArray(1);
glDisableVertexAttribArray(0);
// On n'utilise plus le shader
glUseProgram(0);
// Actualisation de la fenêtre
SDL_GL_SwapWindow(m_fenetre);
}
}
Vous devriez avoir cette fenêtre :
C'est tout ? C'est pratiquement la même chose qu'avant. :(
À première vue oui, il ne se passe rien de plus sauf peut-être une légère déformation de notre triangle. Mais sachez que dans votre carte graphique il se passe pas mal de choses. De plus, nous pouvons maintenant utiliser les transformations dans nos programmes et ce sont justement elles qui forment la base d'un jeu-vidéo. Sans transformations, nous serions encore en train de jouer au Pong. :lol:
On va se reposer un peu dans cette partie, il n'y a rien de nouveau, on utilisera juste ce que l'on a déjà codé.
Tout d'abord, on va changer un peu nos vertices pour avoir un triangle plus petit. Sinon nous ne pourrions pas vraiment voir ce que donnent les transformations :
Prenons un vecteur V de coordonnées (0.4, 0.0, 0.0) pour effectuer une translation du repère (matrice modelview) sur 0.4 unité sur l'axe X. Normalement, le triangle devrait se retrouver sur la gauche.
Pour faire une translation avec GLM, nous allons utiliser la méthode translate() dont voici le prototype :
mat4 translate(mat4 matrice, vec3 translation);
mat4 : matrice qui sera multipliée par la matrice de translation. Il s'agit ici de modelview
translation : objet vecteur à 3 coordonnées. Il correspond ici un point dans l'espace avec 3 coordonnées (x, y et z)
La méthode renvoie la matrice donnée en paramètre avec la translation ajoutée.
Pour contenter l'objet vec3, nous allons juste appeler le constructeur vec3 avec les coordonnées de la translation que l'on veut faire, ici (0.4, 0.0, 0.0).
On identifie bien ce que fait cette ligne de code. On appelle la méthode translate() qui va modifier la matrice modelview à l'aide du vecteur de coordonnées (0.4, 0.0, 0.0).
Nous devons inclure cette ligne juste avant l'envoi des matrices au shader :
// On spécifie quel shader utiliser
glUseProgram(shaderCouleur.getProgramID());
// Remplissage des tableaux Vertex Attrib 0 et 1
....
// Translation
modelview = translate(modelview, vec3(0.4, 0.0, 0.0));
// On envoie les matrices au shader
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "modelview"), 1, GL_FALSE, value_ptr(modelview));
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "projection"), 1, GL_FALSE, value_ptr(projection));
// Affichage
....
// On n'utilise plus le shader
glUseProgram(0);
Ce qui nous donne :
Le triangle ne connait aucun changement, nous n'avons pas modifié ses coordonnées. En revanche, le repère lui a changé. On l'a translaté selon le vecteur V, le triangle se trouve donc un peu plus loin.
Bon, sur une si petite forme on ne voit pas trop l'intérêt, mais sur un personnage de plus de 1000 vectices on voit la différence croyez-moi. :p
La rotation
Nous allons maintenant faire pivoter le triangle d'un angle de 60° selon l'axe Z. L'axe Z est pointé vers nous mais nous ne le voyons pas, nous verrons cela dans la deuxième partie du chapitre.
Nous utiliserons pour cela la méthode GLMrotate() :
On inclut cette ligne juste avant d'envoyer les matrices au shader :
// On spécifie quel shader utiliser
glUseProgram(shaderCouleur.getProgramID());
// Remplissage des tableaux Vertex Attrib 0 et 1
....
// Rotation
modelview = rotate(modelview, 60.0f, vec3(0.0, 0.0, 1.0));
// On envoie les matrices au shader
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "modelview"), 1, GL_FALSE, value_ptr(modelview));
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "projection"), 1, GL_FALSE, value_ptr(projection));
// Affichage
....
// On n'utilise plus le shader
glUseProgram(0);
Hop, le triangle a pivoté de 60° (dans le sens trigonométrique). Encore une fois, on modifie le repère, on ne touche pas aux coordonnées du triangle.
L'homothétie
Allez encore une petit exemple. Nous allons faire une homothétie en multipliant par 2 les unités de mesure du repère. 1 unité sera plus longue, donc le triangle paraitra plus gros.
On appellera pour cela la méthode scale() :
mat4 scale(mat4 matrice, vec3 factors);
matrice : matrice qui sera multipliée par la rotation. Il s'agit ici de modelview
factors : vecteur contenant les 3 facteurs de redimensionnement pour les X, Y et Z
On appellera cette méthode ainsi pour agrandir le repère de 2 unités sur les axes X et Y. L'axe Z n'est pas encore visible pour nous, on le laisse donc comme ça.
Et comme d'habitude, on l'appelle juste avant d'envoyer les matrices au shader :
// On spécifie quel shader utiliser
glUseProgram(shaderCouleur.getProgramID());
// Remplissage des tableaux Vertex Attrib 0 et 1
....
// Homothétie
modelview = scale(modelview, vec3(2, 2, 1));
// On envoie les matrices au shader
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "modelview"), 1, GL_FALSE, value_ptr(modelview));
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "projection"), 1, GL_FALSE, value_ptr(projection));
// Affichage
....
// On n'utilise plus le shader
glUseProgram(0);
Le triangle est tellement gros qu'il sort de la fenêtre :lol: . Bref vous avez compris le principe.
Je vais même vous montrer un truc sympa avec les homothéties, on va inverser le triangle. C'est une technique utilisée dans les jeux-vidéo pour les effets de reflets (comme un effet de miroir, d'eau, ...) :
// Inversion du repère
modelview = scale(modelview, vec3(1, -1, 1));
Les axes X et Z ne sont pas modifiés, ils sont multipliés par 1. Mais l'axe Y lui est inversé, voici ce que ça donne :
Ordre des transformations
Je vous ai dit au tout début que l'ordre des transformations était important. Vous pouvez désormais le constater par vous-même. Tout d'abord, on va translater notre triangle selon le vecteur V de coordonnées (0.4, 0.0, 0.0) puis on va le faire pivoter d'un angle de 60° sur l'axe Z :
On n'obtient pas la même chose dans les deux exemples. Faites attention à l'ordre des transformations c'est important. ;)
Multi-affichage
Courage on aborde le dernier point :p . Dans le deuxième chapitre nous avons vu comment afficher plusieurs triangles sans les transformations. Maintenant que nous les avons, on va voir comment se faciliter la vie.
Si vous affichez plusieurs fois la même chose, pas besoin de remplacer les valeurs du tableau car les valeurs sont toutes les mêmes :p . On ne fait qu'appliquer les transformations sur le repère puis on ré-affiche le triangle. Bien sûr, si on change les valeurs de la matrice modelview alors il faut la ré-envoyer au shader :
// On spécifie quel shader utiliser
glUseProgram(shaderCouleur.getProgramID());
// On translate le premier triangle
modelview = translate(modelview, vec3(0.4, 0, 0));
// On envoie les matrices au shader
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "modelview"), 1, GL_FALSE, value_ptr(modelview));
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "projection"), 1, GL_FALSE, value_ptr(projection));
/* ***** Affichage du premier triangle ***** */
glDrawArrays(GL_TRIANGLES, 0, 3);
// On fait pivoter le deuxième triangle puis on le translate
modelview = rotate(modelview, 60.0f, vec3(0, 0, 1));
modelview = translate(modelview, vec3(0.4, 0, 0));
// On envoie une deuxième fois les matrices au shader
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "modelview"), 1, GL_FALSE, value_ptr(modelview));
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "projection"), 1, GL_FALSE, value_ptr(projection));
/* ***** Affichage du deuxième triangle ***** */
glDrawArrays(GL_TRIANGLES, 0, 3);
// On n'utilise plus le shader
glUseProgram(0);
Je vous conseille de faire des petits tests avec les transformations, entrainez-vous avec avant de passer à la deuxième partie de ce chapitre. ;)
Nous arrivons à la fin de cette première partie. Nous avons vu pas mal de notions et il est important que vous les compreniez. Encore une fois, amusez-vous à faire des transformations pour vous familiariser avec OpenGL. Si vous vous sentez prêts, passez à la deuxième partie (qui est plus facile à comprendre que cette partie ;) ).
Dans cette deuxième partie, nous allons enfin faire ce que vous attendez tous : de la 3D. :D Cette partie sera plus facile que la première, il y aura de nouvelles notions à apprendre mais rien de bien compliqué. Encore une fois, si vous n'avez pas tout compris jusque là, je vous conseille de relire à tête reposée les chapitres précédents.
Dans le chapitre précédent, nous avons appris à faire interagir les matrices avec OpenGL et nous avons par la même occasion créé la matrice de projection. La bonne nouvelle, c'est que l'on connait déjà presque tout pour faire de la 3D. En effet, la troisième dimension est déjà implémentée dans nos programmes, nous l'avons vu en ajoutant la coordonnée Z à nos vertices. Seulement, nous sommes mal placés pour voir ce rendu en relief. Pour régler ce problème, il va falloir placer une chose indispensable à OpenGL : la caméra.
Eh oui, :p OpenGL fonctionne avec une caméra, exactement comme les films. Il faut donc placer une caméra qui va fixer un point pour que le spectateur (ou le joueur dans notre cas) puisse voir la scène.
Dans les versions précédentes d'OpenGL, la caméra était gérée par la même librairie que celle qui gérait la projection : la librairie GLU. Mais comme vous vous en douter, nous allons utiliser GLM pour la remplacer. :)
La méthode lookAt
L'ancienne fonction
Avant d'utiliser la librairie GLM, nous allons étudier l'ancienne fonction GLU qui gérait la caméra. Celle-ci permettait de placer la caméra au niveau d'un point dans l'espace. Ce point de vue nous permet de voir une scène en 3 dimensions au lieu des 2 dimensions dont nous avons l'habitude depuis le début du tuto.
La fonction en question s'appelle gluLookAt() et voici son prototype :
Si on les prend à part oui, mais vous remarquerez que les noms se ressemblent plus ou moins. En fait, il n'y a que 3 paramètres. Si on les prend par groupe de 3, on se retrouve avec 3 vecteurs bien distincts :
Le vecteur eye (œil) qui est un vecteur permetant de placer la caméra.
La vecteur center (centre) qui est le point que la caméra doit fixer. Il y a 3 coordonnées, le point se trouve donc dans un espace 3D.
Le vecteur axe qui est la verticale du repère.
Petite précision pour le dernier vecteur. En théorie, l'axe vertical est l'axe Y mais dans pas mal de jeux-vidéo on prend souvent l'axe Z. Personnellement, je fais de la résistance et je préfère utiliser l'axe Y comme axe vertical comme on nous l'a toujours appris depuis le collège. ;)
La nouvelle méthode
Pour remplacer cette ancienne fonction, nous allons utiliser une méthode de la librairie GLM qui s'appelle lookAt(). Son prototype est un peu plus compact que la fonction GLU :
mat4 lookAt(vec3 eye, vec3 center, vec3 up);
eye : vecteur permettant de placer la caméra
center : vecteur permettant d'indiquer le point fixé
up : vecteur représentant la verticale du repère
Vous remarquez que la méthode prend bien les 9 paramètres de gluLookAt(), elle les place juste dans 3 objets de type vec3. ;)
Par ailleurs, elle renvoie une matrice toute neuve, elle ne modifie donc pas le contenu d'une matrice existante comme le font les méthodes de transformations.
Utilisation
Grâce à cette méthode, nous pouvons tout de même utiliser notre caméra.
On va d'ailleurs faire un petit test dès maintenant. Vous vous souvenez des deux triangles du chapitre précédent ? On va voir ce que ça donne si on adopte un "point de vue" en 3 dimensions. Bon je vous préviens, avec des triangles 2D ça va être moche mais ce sera déjà notre premier pas dans la 3D. ;)
Nous allons placer notre caméra au point de coordonnées (1, 1, 1) et celle-ci fixera le centre du repère, donc le point de coordonnées (0, 0, 0). Enfin, nous utiliserons l'axe Y comme axe vertical (vous pouvez utiliser celui que vous voulez). L'appel à la méthode lookAt() sera donc :
// Placement de la caméra
modelview = lookAt(vec3(1, 1, 1), vec3(0, 0, 0), vec3(0, 1, 0));
Nous ajoutons cette ligne de code juste après avoir ré-initialisé la matrice modelview :
// Boucle principale
while(!terminer)
{
/* Gestion des évènements .... */
// Nettoyage de la fenêtre
glClear(GL_COLOR_BUFFER_BIT);
// Ré-initialisation de la matrice et placement de la caméra
modelview = mat4(1.0);
modelview = lookAt(vec3(1, 1, 1), vec3(0, 0, 0), vec3(0, 1, 0));
/* Rendu ... */
}
D'ailleurs, vous pouvez maintenant supprimer la ligne qui ré-initialise la matrice modelview avec les valeurs d'une matrice d'identité car la méthode lookAt() écrase complétement son ancien contenu et fait donc office de ré-initialisation. La ligne est donc inutile. :)
// Boucle principale
while(!terminer)
{
/* Gestion des évènements .... */
// Nettoyage de la fenêtre
glClear(GL_COLOR_BUFFER_BIT);
// Placement de la caméra
modelview = lookAt(vec3(1, 1, 1), vec3(0, 0, 0), vec3(0, 1, 0));
/* Rendu ... */
}
Voici ce que vous devriez obtenir :
Bon je vous avais prévenu c'est laid. :p Et pourtant on est bien en 3D :
Avec des triangles 2D on n'ira pas très loin mais ça va vite changer. Commençons enfin la partie la plus intéressante. :diable:
La première bonne nouvelle est que l'on va se débarrasser enfin du repère que l'on a utilisé jusqu'à maintenant :
Nous allons désormais utiliser celui-ci :
Nos polygones ne seront plus limités à des coordonnées comprises entre 0 et 1.
Concrètement, qu'est-ce qu'il faut pour passer à la 3D ?
Vous ne vous en êtes peut-être pas rendu compte, mais depuis le début du tutoriel, vous avez déjà appris pas mal de choses. Petit à petit, vous avez appris tout ce qui est nécessaire pour commencer la programmation 3D :
Un shader
Des matrices
La projection
Une caméra
En réunissant intelligemment tout ça, on peut intégrer une troisième dimension à nos programmes. ;)
Affichage d'un cube
La partie théorique
On va commencer par un exercice simple qui réunira tout ce que l'on connait déjà ainsi que ce que l'on va voir maintenant. A la fin, on sera en mesure d'afficher notre premier modèle 3D : un cube (en couleur s'il vous plaît ;) ).
Avant de s'attaquer à la programmation, il est essentiel de faire un peu de théorie en voyant de quoi est composé un cube. Il suffit d'ouvrir un manuel de géométrie pour y lire une des propriétés principales du cube : "un cube est composé de 8 sommets".
Les chiffres en parenthèses représentent les coordonnées de chaque sommet. Une arrête mesure donc 2 unités.
Hum intéressant, on retrouve le mot sommet ( = vertex). Il y a 8 sommets, nous aurons donc besoin de 8 vertices :
Bien évidemment, il faudra les dédoubler pour afficher toutes nos faces comme nous le faisions avec le losange par exemple. Nous allons les étudier ensemble une par une en expliquant bien les étapes nécessaires.
D'ailleurs en parlant de ça, dans les anciennes versions d'OpenGL, on utilisait une primitive spécifique pour afficher un carré (comme GL_TRIANGLES pour les triangles) que l'on utilisait avec la fonction glDrawArrays(). Cependant, cette primitive a également été supprimée au même titre que les fonctions lentes vu que les cartes graphiques ne savent gérer nativement que des triangles.
Il existe heureusement une petite combine pour afficher des carrés sans cette primitive : il suffit de coller deux triangles rectangles entre eux :
Ne vous inquiétez pas, ce n'est pas plus lent à l'affichage même si on affiche deux choses au lieu d'une. Pour vous dire, que ce soit des sphères, des cubes ou des personnages, absolument tous les modèles 3D ne sont composés uniquement que de triangles. ;)
La première face
Allez on attaque la partie programmation. :D
Pour le moment, on va tout coder dans la boucle principale, ce sera plus simple à comprendre. Ensuite, nous migrerons proprement le code dans une classe dédiée.
On va commencer notre cube en affichant sa première face (celle du fond) :
Pour cela, nous aurons besoin de 6 sommets vu que nous avons besoin de deux triangles pour faire un carré. Si on regarde le schéma ci-dessus, on remarque que l'on peut faire un triangle avec les vertices 0, 1 et 2, et un autre avec les vertices 2, 3 et 0. Le tableau dont nous avons besoin ressemblera donc à ceci :
Avant d'intégrer ce tableau, nous allons reprendre ensemble la boucle principale pour voir ce que donnerait le nouvel affichage. On commence par évidemment par déclarer les matrices projection et modelview :
Pour admirer le rendu final il ne manque plus qu'à colorier les deux triangles. Nous utiliserons le rouge vu qu'elle est présente dans le schéma un peu plus haut.
Le tableau à utiliser pour cela doit permettre d'affecter une couleur pour chaque vertex. Nous en avons 6 pour le moment donc nous aurons besoin de 6 couleurs :
Une fois toutes ces déclarations faites, nous devrons nous occuper de la caméra. Nous devons la déclarer juste avant la boucle principale et la placer à chaque tour de boucle au point de coordonnées (0, 0, 1). Elle sera en mode 'affichage 2D' temporairement pour nous permettre de voir le carré correctement :
// Boucle principale
while(!terminer)
{
// Gestion des évènements
SDL_WaitEvent(&m_evenements);
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT);
// Placement de la caméra
modelview = lookAt(vec3(0, 0, 1), vec3(0, 0, 0), vec3(0, 1, 0));
// Rendu
....
// Actualisation de la fenêtre
SDL_GL_SwapWindow(m_fenetre);
}
Pour le rendu en lui-même, il n'y a pas de grand changement à faire. On commence par activer le shader puis on envoie nos données aux tableaux Vertex Attrib, on sait le faire depuis un moment grâce à la fonction glVertexAttribPointer() :
// Activation du shader
glUseProgram(shaderCouleur.getProgramID());
// Envoi des vertices
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, vertices);
glEnableVertexAttribArray(0);
// Envoi de la couleur
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, couleurs);
glEnableVertexAttribArray(1);
....
// Désactivation du shader
glUseProgram(0);
Il ne nous reste plus qu'à envoyer les matrices projection et modelview au shader à l'aide des grosses fonctions. On pensera également à afficher le rendu grâce à la fonction glDrawArrays() et à désactiver les tableaux Vertex Attrib :
Si vous compilez ce code, vous devriez obtenir votre premier carré avec OpenGL :
Notre première face du cube est maintenant prête. :D
La deuxième face
Passons maintenant à la deuxième face du cube.
Pour commencer, on va placer la caméra un peu différemment de façon à voir la scène en 3 dimensions. Nous la positionnerons au point de coordonnées (3, 3, 3) et la ferons fixer l'origine du repère (0, 0, 0) :
// Placement de la caméra
modelview = lookAt(vec3(3, 3, 3), vec3(0, 0, 0), vec3(0, 1, 0));
La deuxième face que nous devons afficher doit ressembler à ceci :
On remarque qu'il faut prendre les vertices 5, 1 et 2 pour afficher le premier triangle, puis les vertices 2, 6 et 5 pour le second. Si on fait correspondre leurs coordonnées avec le schéma du début, on trouve les deux triangles suivants :
Évidemment, si nous utilisons de nouveaux vertices il faut leur associer une couleur. On ajoute donc 6 triplets au tableau couleurs spécialement pour eux. Nous utiliserons le vert pour différencier les deux faces :
Pour afficher la nouvelle face, il suffit de modifier le fameux paramètre count pour qu'il prenne en compte les 6 nouveaux sommets. On lui donne donc la valeur 6 + 6 = 12 :
// Affichage des triangles
glDrawArrays(GL_TRIANGLES, 0, 12);
Si vous compilez tout ça, vous devriez obtenir :
On a maintenant la deuxième face, et en 3D s'il vous plaît. ;)
La troisième face
Allez, on continue avec la troisième face. On commence par définir les sommets dont nous aurons besoin :
Comme d'habitude, on fait correspondre ces sommets avec leurs coordonnées pour trouver le tableau suivant :
Pour finir, on doit modifier une fois de plus le paramètre count pour prendre en compte les nouveaux vertices. Sa valeur passe de 12 à 18 :
// Affichage des triangles
glDrawArrays(GL_TRIANGLES, 0, 18);
En compilant le nouveau code, on obtient :
Pourquoi ça s'affiche comme ça ? Y'a un bug ? o_O
Non, ce n'est pas un bug, et vous allez vite comprendre pourquoi la face ne s'affiche pas correctement. ^^
Le Depth Buffer
C'est la première fois que nous avons un problème d'affichage, et c'est tout à fait normal puisqu'avant nous n'avions jamais eu de formes superposées l'une sur l'autre. Ici, la face bleue et la face verte se superposent, et pour OpenGL c'est un problème car il ne sait pas quelle forme doit être visible et quelle forme doit être cachée. N'oubliez pas qu'un ordinateur est très bête, il ne sait rien faire à part calculer.
Le Depth Buffer (ou Tampon de profondeur) est ce qui va permettre à OpenGL de comprendre ce qu'il doit afficher et ce qu'il doit masquer. Si un pixel de modèle se trouve derrière un autre alors le Depth Buffer indiquera à OpenGL : "N'affiche pas ce pixel, mais affiche celui-ci car il est devant".
Je vous avais déjà parlé brièvement de cette notion dans le chapitre sur les shaders, notamment avec ce schéma :
La dernière étape du pipeline 3D était le Test de profondeur. C'est justement là qu'intervient le Depth Buffer. Heureusement pour nous, il ne faudra pas gérer ce tampon par nous-même, cette fonctionnalité n'a pas été supprimée avec la nouvelle version d'OpenGL. :) Avant que l'API puisse se servir de ce tampon, il faut l'activer grâce à la fonction glEnable().
void glEnable(GLenum cap);
Nous reverrons plusieurs fois cette fonction qui permet d'activer certaines fonctionnalités d'OpenGL. Le paramètre cap est justement la fonctionnalité à activer. Pour le Depth Buffer, on lui donne le paramètre GL_DEPTH_TEST. On va donc appeler la fonction comme ceci dans la méthode initGL() juste après l'initialisation de la librairie GLEW :
bool SceneOpenGL::initGL()
{
#ifdef WIN32
/* ***** Initialisation de la librairie GLEW ***** */
#endif
// Activation du Depth Buffer
glEnable(GL_DEPTH_TEST);
// Tout s'est bien passé, on retourne true
return true;
A chaque tour de boucle, il faudra (comme avec les couleurs et la matrice modelview) ré-initialiser le Depth Buffer afin de vider toute traces de l'affichage précédent. Pour ça, il suffit d'ajouter un paramètre à une fonction que l'on utilise déjà : glClear(). Rappelez-vous que cette fonction permet de vider les buffers qu'on lui donne en paramètre.
Pour le moment, on ne lui donne que le paramètre GL_COLOR_BUFFER_BIT pour effacer ce qui se trouve à l'écran. Maintenant, on va ajouter le paramètre GL_DEPTH_BUFFER_BIT pour effacer le Depth Buffer :
// Nettoyage de la fenêtre et du Depth Buffer
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
Maintenant que l'on sait ce qu'est le Depth Buffer, on peut régler notre problème d'affichage. Après avoir placé la fonction glEnable(), vous devriez obtenir ceci :
Les trois dernières faces
Courage nous sommes presque au bout, il ne reste plus qu'à afficher les 3 dernières faces. Et je fais bien de vous dire courage car je vais vous demander de terminer les trois dernières faces tous seuls. :p
Je vais vous donner les schémas contenant les vertices et la couleur dont vous aurez besoin pour chaque face, pour le reste ce sera à vous de le faire. Il n'y a rien de compliqué en plus, nous avons déjà fait la moité du cube ensemble et vous n'aurez pas de surprise d'affichage car nous savons maintenant gérer le Depth Buffer.
Les tableaux finaux peuvent vous paraitre gros et moches, c'est tout à fait normal. Pour vous dire, les vertices de personnages ou de décors sont infiniment plus moches au vu de leur nombre de données. :lol: Mais l'avantage avec eux c'est que nous n'avons pas à les coder à la main, nous ne les voyons même pas d'ailleurs. Mais bon, ça ça sera pour plus tard. Pour le moment, je vous demande de finir notre fameux cube à l'aide des schémas suivants :
Allez hop à votre clavier !
.....
On passe à la correction. Le principe reste le même, il faut juste faire correspondre les vertices et les couleurs. Voici ce que donne les tableaux finaux :
Vos vertices peuvent parfaitement être déclarés dans un ordre différent de celui que je donne. Ce n'est pas grave du moment que vous affichez des carrés correctement :
Après tous les efforts que nous avons fournis dans la partie précédente, nous avons enfin pu afficher notre premier modèle 3D. :D Vous avez remarqué que, mises à part les matrices, le processus était le même que pour les modèles 2D. C'est-dire-à qu'il nous a suffi d'activer un shader, d'envoyer les données aux tableaux Vertex Attrib, puis d'afficher le tout avec glDrawArrays().
Ce que nous allons faire maintenant va nous permettre de nettoyer un peu la boucle principale. En effet, comme je vous l'ai précisé dans la correction du cube, les tableaux de vertices et de couleurs sont assez indigestes. Il serait donc judicieux de créer une classe dédiée au cube de façon à enfermer ces lignes de code à l'intérieur. Nous gagnerions en lisibilité et de plus, nous pourrions créer des cubes à l'infini en seulement quelques lignes de code !
Notre nouvel objectif va donc être la création d'une classe Cube dont on se servira pour la suite du tutoriel.
Et nous allons commencer tout de suite par le header. Celui-ci sera placé dans un fichier que nous appellerons Cube.h et devra contenir la déclaration de la classe ainsi que les inclusions nécessaires pour OpenGL, les shaders et les matrices :
#ifndef DEF_CUBE
#define DEF_CUBE
// Includes OpenGL
#ifdef WIN32
#include <GL/glew.h>
#else
#define GL3_PROTOTYPES 1
#include <GL3/gl3.h>
#endif
// Includes GLM
#include <glm/glm.hpp>
#include <glm/gtx/transform.hpp>
#include <glm/gtc/type_ptr.hpp>
// Includes
#include "Shader.h"
// Classe Cube
class Cube
{
public:
private:
};
#endif
Au niveau des attributs, cette classe devra contenir tous les éléments dont nous avons eu besoin pour afficher notre modèle, à savoir :
Un objet de type Shader
Un tableau flottant de vertices
Un tableau flottant de couleurs
Les matrices ne font pas partie de cette liste car nous n'en avons besoin qu'au moment de l'affichage, inutile donc de créer des attributs pour elles. Nous les enverrons en tant que paramètres dans une méthode.
La taille 108 des deux tableaux vient de la multiplication du nombre de vertices nécessaires pour un cube (36) par leur nombre de coordonnées (3), ce qui fait 36 vertices x 3 coordonnées = 108 cases.
Passons maintenant au constructeur, celui-ci aura besoin de trois paramètres : la taille du cube que l'on veut afficher ainsi que les deux codes sources du shader à utiliser.
Nous n'avons pas intégré la possibilité de choisir les dimensions avant afin d'éviter d’alourdir le code qui était déjà assez dense. Mais vu qu'à présent nous codons une classe, il serait quand même plus agréable de pouvoir créer des cubes de n'importe quelle taille en modifiant simplement une seule valeur. :)
Nous prendrons une variable de type float pour gérer cette taille. Quant aux autres paramètres, vous savez déjà que ce seront des string :
Passons maintenant à l'implémentation de la classe avec en premier lieu le constructeur.
Celui-ci débute avec l'initialisation des attributs. Nous en avons 3 mais seul le shader peut vraiment être initialisé ici car les vertices et les couleurs sont des tableaux, nous ne pouvons donc pas le faire directement après les deux points ":". Nous lui donnons les deux codes sources reçus en paramètres :
Ce qui se trouve entre les accolades commence également par le shader car nous devons appeler sa méthode charger() de façon à le charger complétement :
Le shader est maintenant initialisé et prêt à l'emploi.
On passe maintenant au plus délicat : l'initialisation des tableaux de vertices et de couleurs. Il y a deux manières de faire en C++ :
Soit on initialise leurs valeurs une par une (donc on initialise séparément les 24 valeurs d'un tableau de 24 cases par exemple).
Soit on déclare des tableaux temporaires contenant les valeurs désirées, puis on utilise des boucles pour les affecter aux vrais tableaux.
Si on utilise la première méthode, il nous faudrait 108 lignes de code juste pour initialiser les vertices, et le double si on s'occupe aussi des couleurs. Avouez tout de même que c'est méchamment fastidieux, surtout si on doit le faire deux fois. Je pense donc que vous serez d'accord pour utiliser la seconde méthode. :lol:
Nous devons donc utiliser un tableau temporaire qui va contenir tous les vertices du cube, nous l'appellerons verticesTmp[] :
Les vertices en l'état n'ont que bien peu d'intérêt car ils ne prennent pas en compte le paramètre taille du constructeur. Pour régler ce problème, nous allons simplement remplacer toutes les occurrences de la valeur 1.0 par le paramètre taille lui-même. C'est un peu long à faire mais la fonctionnalité "Find and Replace" de votre IDE devrait vous faciliter un peu la tâche. ;)
Le tableau remanié devrait ressembler à celui-ci :
Il reste encore une petite modification à faire. Si on regarde de plus près nos données, on remarque que les vertices vont de -taille à +taille. Cet intervalle fait que notre cube est multiplié par 2. :(
Pour éviter cela, il faut diviser le paramètre taille par 2 avant de remplir le tableau. Ainsi, notre cube qui devait être multiplié par 2 ne le sera plus :
Le tableau de couleurs quant à lui est plus simple à faire puisqu'il suffit juste de reprendre celui que nous utilisions avant. Nous modifierons juste son nom en l'appelant couleursTmp vu qu'il s'agit de données temporaires :
Maintenant que nos données sont déclarées, il ne manque plus qu'à les transférer dans nos attributs.
Pour cela, nous allons utiliser une boucle qui va s'exécuter 108 fois, ce qui permettra donc de copier non seulement les coordonnées des sommets mais aussi les composantes des couleurs. En effet, les tableaux font tous les deux la même taille, on peut alors n'utiliser qu'une seule boucle. :)
// Copie des valeurs dans les tableaux finaux
for(int i(0); i < 108; i++)
{
m_vertices[i] = verticesTmp[i];
m_couleurs[i] = couleursTmp[i];
}
Comme vous le savez déjà, un destructeur est une méthode appelée au moment de la destruction de l'objet. Il permet de libérer la mémoire prise par l'objet au cours de sa vie, en particulier la mémoire allouée dynamiquement.
Heureusement pour nous, dans notre cas nous ne faisons aucune allocation dynamique. :p Le destructeur va donc être vide :
Cube::~Cube()
{
}
La méthode afficher
Comme son nom l'indique, la méthode afficher() va nous permettre ... d'afficher notre cube. ^^ Son prototype est assez simple :
Elle prend en paramètre une référence sur les deux matrices que l'on connait si bien maintenant. Elles sont indispensables pour afficher un modèle 3D. Dans cette méthode, nous en avons besoin pour les envoyer au shader.
Son implémentation va être ultra simple pour nous : il suffit juste de copier le code contenu entre l'activation et la désactivation du shader. Ce qui comprend :
Le shader évidemment
L'envoi des matrices
L'utilisation des tableaux Vertex Attrib
L'appel à la fonction glUseProgram()
Le code à copier est le suivant :
// Activation du shader
glUseProgram(shaderCouleur.getProgramID());
// Envoi des vertices
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, vertices);
glEnableVertexAttribArray(0);
// Envoi de la couleur
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, couleurs);
glEnableVertexAttribArray(1);
// Envoi des matrices
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "projection"), 1, GL_FALSE, value_ptr(projection));
glUniformMatrix4fv(glGetUniformLocation(shaderCouleur.getProgramID(), "modelview"), 1, GL_FALSE, value_ptr(modelview));
// Rendu
glDrawArrays(GL_TRIANGLES, 0, 36);
// Désactivation des tableaux
glDisableVertexAttribArray(1);
glDisableVertexAttribArray(0);
// Désactivation du shader
glUseProgram(0);
Avant d'aller plus loin, il nous faut modifier le nom des variables anciennement utilisées. Ainsi :
L'objet shaderCouleur devient m_shader
Le tableau vertices devient m_vertices
Le tableau couleurs devient m_couleurs
Une fois le nom des variables modifié, on se retrouve avec la méthode afficher() suivante :
void Cube::afficher(glm::mat4 &projection, glm::mat4 &modelview)
{
// Activation du shader
glUseProgram(m_shader.getProgramID());
// Envoi des vertices
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, m_vertices);
glEnableVertexAttribArray(0);
// Envoi de la couleur
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, m_couleurs);
glEnableVertexAttribArray(1);
// Envoi des matrices
glUniformMatrix4fv(glGetUniformLocation(m_shader.getProgramID(), "projection"), 1, GL_FALSE, value_ptr(projection));
glUniformMatrix4fv(glGetUniformLocation(m_shader.getProgramID(), "modelview"), 1, GL_FALSE, value_ptr(modelview));
// Rendu
glDrawArrays(GL_TRIANGLES, 0, 36);
// Désactivation des tableaux
glDisableVertexAttribArray(1);
glDisableVertexAttribArray(0);
// Désactivation du shader
glUseProgram(0);
}
J'adore quand le copier-coller fonctionne aussi facilement. ^^ Nous avions déjà fait le plus gros avant, il ne nous restait plus qu'à adapter le nom des attributs.
La boucle principale
Il ne reste plus qu'une seule chose à faire : déclarer un objet de type Cube et utiliser sa méthode afficher() dans la boucle principale. On efface donc tout ce qu'on à fait avant (vertices, couleurs, affichage, ...). On ne doit garder que ceci :
void SceneOpenGL::bouclePrincipale()
{
// Variable
bool terminer(false);
// Matrices
mat4 projection;
mat4 modelview;
projection = perspective(70.0, (double) m_largeurFenetre / m_hauteurFenetre, 1.0, 100.0);
modelview = mat4(1.0);
// Boucle principale
while(!terminer)
{
// Gestion des évènements
SDL_WaitEvent(&m_evenements);
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// Placement de la caméra
modelview = lookAt(vec3(3, 3, 3), vec3(0, 0, 0), vec3(0, 1, 0));
// Rendu (Rien pour le moment)
....
// Actualisation de la fenêtre
SDL_GL_SwapWindow(m_fenetre);
}
}
Ensuite, on déclare notre objet de type Cube qui sera initialisé automatiquement avec le constructeur. Nous lui donnerons la valeur 2.0 pour le paramètre taille (ou une autre qui vous plaira :p ) ainsi que les string"Shaders/couleur3D.vert" et "Shaders/couleur3D.frag" pour le shader.
Enfin, on utilise la méthode afficher() dans la boucle principale en donnant les matrices en paramètres :
while(!terminer)
{
// Gestion des évènements
SDL_WaitEvent(&m_evenements);
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// Placement de la caméra
modelview = lookAt(vec3(3, 3, 3), vec3(0, 0, 0), vec3(0, 1, 0));
// Affichage du cube
cube.afficher(projection, modelview);
// Actualisation de la fenêtre
SDL_GL_SwapWindow(m_fenetre);
}
Si vous compilez tout ça, vous obtiendrez le même résultat que tout à l'heure mais cette fois-ci, vous avez un véritable objet C++ permettant d'afficher un cube. ^^
Pseudo-animation
Attention, cette sous-partie s'appelle bien "pseudo-animation" et pas animation tout court. On va apprendre à faire pivoter notre cube pour voir toutes ses faces, et ce grâce à la méthode rotate() de la librairie GLM.
Le principe est simple : on incrémente un angle à chaque tour de boucle puis on fait pivoter le cube avec cet angle qui change sans arrêt donnant ainsi une impression de mouvement. Pour le moment, il faudra bouger la souris dans la fenêtre SDL pour constater la rotation puisque nous utilisons la fonction SDL_WaitEvent() qui bloque le programme quand il n'y a pas d'évènements.
On commence par déclarer un angle de type float que l'on incrémentera à chaque tour boucle :
Ensuite, on incrémente l'angle de rotation à chaque tour de boucle. Petite précision, l'angle atteindra forcément les 360° vu qu'on l'incrémente sans arrêt. A chaque fois qu'il atteindra 360°, il faudra donc le remettre à zéro. Il est inutile d'avoir un angle incompréhensible de 1604°. :lol:
Je profite de ce chapitre pour vous introduire une nouvelle notion : celle du Frame Rate.
Le Frame Rate est le nombre de fois par seconde où la boucle principale est exécutée. En France, on prend généralement une valeur 50 fps (Frames Per Second ou Frames par seconde). Pour le moment, notre jeu fonctionne avec 0 fps étant donné que l'on utilise une fonction qui bloque le programme : SDL_WaitEvent(). La boucle ne s'exécute que si on fait quelque chose, sinon le jeu est bloqué.
Or, dans un jeu-vidéo, si on ne touche pas à la souris il se passe quand même quelque chose. Nous allons régler ce problème en introduisant la notion de frame rate. La première chose à faire est de changer la fonction SDL_WaitEvent() par la fonction SDL_PollEvent() qui, elle, ne bloque pas le programme :
SDL_PollEvent(&m_evenements);
L'utilisation de cette fonction va cependant nous poser un problème : le CPU va être totalement surchargé. Heureusement, le frame rate est là pour nous aider. Grâce à lui, nous n'exécuterons pas la boucle principale des centaines de fois par seconde mais uniquement quelques dizaines de fois, ce qui est pour le CPU largement calculable. :) Pour imposer cette limitation, il suffit de bloquer le programme quelques millisecondes à un certain moment. Ce petit intervalle dépend du nombre de FPS que l'on souhaite afficher.
Pour calculer ce temps de blocage, il suffit de diviser 1000 millisecondes (soit une seconde) par le nombre de FPS que l'on veut. Par exemple, si on veut 50 FPS il faut faire : 1000 / 50 = 20 millisecondes.
Pour 50 FPS, la boucle principale devra mettre 20 millisecondes à s'exécuter, quitte à mettre le programme en pause jusqu'à atteindre cet intervalle de 20 ms.
Programmation
La théorie c'est bien mais il faut maintenant adapter la notion de FPS dans notre code. Le principe est simple : on calcule le temps écoulé entre le début et la fin de la boucle. Si ce temps est inférieur à 20 ms alors on met en pause le programme jusqu'à atteindre les 20 ms.
L'implémentation se fait en plusieurs étapes :
À chaque tour de boucle, on enregistre le temps où on commence la boucle.
Puis on enregistre le temps une fois qu'elle est terminée.
On soustrait le temps enregistré au début par le temps de fin de boucle.
Si ce temps est inférieur à 20 ms, alors on met en pause le programme jusqu'à atteindre 20 ms.
Pour capturer le temps, on utilise une fonction de la SDL : SDL_GetTicks(). Elle retourne le temps actuel de l'ordinateur dans une structure de type Uint32 :
Uint32 SDL_GetTicks(void);
Une autre fonction qui va nous être utile est la fonction SDL_Delay(). Cette fonction va nous permettre de mettre en pause le programme lorsque nous en aurons besoin. Elle prend un seul paramètre : le temps en millisecondes durant lequel elle va bloquer le programme :
void SDL_Delay(Uint32 ms);
Bref, commençons déjà par déclarer nos variables juste après le booléen terminer de la méthode bouclePrincipale() :
void SceneOpenGL::bouclePrincipale()
{
// Variables relatives à la boucle
bool terminer(false);
unsigned int frameRate (1000 / 50);
Uint32 debutBoucle(0), finBoucle(0), tempsEcoule(0);
/* **** Reste du code **** */
}
N'oubliez pas que le temps de blocage est égal à : 1000 / Frame Rate.
Occupons-nous maintenant de la limitation. La première chose à faire est de déterminer le temps où la boucle commence :
// Boucle principale
while(!terminer)
{
// On définit le temps de début de boucle
debutBoucle = SDL_GetTicks();
/* ***** Boucle Principale ***** */
}
Ensuite, il faut déterminer le temps qu'a mis la boucle pour s'exécuter. Pour ça, on enregistre le temps de fin de boucle que l'on soustrait par le temps du début :
while(!terminer)
{
/* ***** Boucle Principale ***** */
// Calcul du temps écoulé
finBoucle = SDL_GetTicks();
tempsEcoule = finBoucle - debutBoucle;
}
Enfin, si le temps est inférieur à 20 ms, on met en pause le programme jusqu'à atteindre les 20 ms :
// Si nécessaire, on met en pause le programme
if(tempsEcoule < frameRate)
SDL_Delay(frameRate - tempsEcoule);
Si on résume tout ça :
void SceneOpenGL::bouclePrincipale()
{
// Variables relatives à la boucle
bool terminer(false);
unsigned int frameRate (1000 / 50);
Uint32 debutBoucle(0), finBoucle(0), tempsEcoule(0);
// Boucle principale
while(!terminer)
{
// On définit le temps de début de boucle
debutBoucle = SDL_GetTicks();
// Gestion des évènements
SDL_PollEvent(&m_evenements);
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
/* ***** Rendu OpenGL ***** */
// Calcul du temps écoulé
finBoucle = SDL_GetTicks();
tempsEcoule = finBoucle - debutBoucle;
// Si nécessaire, on met en pause le programme
if(tempsEcoule < frameRate)
SDL_Delay(frameRate - tempsEcoule);
}
}
Avec ce code, la boucle principale ne s'exécutera que 50 fois par seconde. Si vous observez l'activité de votre processeur, vous remarquerez que le programme ne le monopolise pas malgré l'utilisation de la fonction SDL_PollEvent(). Maintenant, nous ne sommes plus obligés de toucher la souris pour voir quelque chose bouger à l'écran. Essayez avec votre cube, il tourne même si vous ne faites rien. :p
Et voilà, nous avons fait nos premiers pas dans la 3D. Nous avons vu les principes de base et nous sommes maintenant capables d'afficher des polygones en 3 dimensions avec OpenGL. :p Dans le chapitre suivant, nous allons nous reposer un peu et étudier quelques points divers dont je n'ai pas encore parlés. Ce chapitre sera un peu plus court que les autres mais tout aussi important. ;)
Les chapitres les plus compliqués de la partie 1 sont enfin derrière nous, les suivants le seront moins. Aujourd'hui, on va s'intéresser aux piles de matrices.
Retenez bien ce que nous allons voir, et profitez de ce chapitre assez soft pour vous reposer. :magicien:
Je profite de cette partie vous faire un aparté sur la matrice modelview. En effet, pour le moment il y a un petit problème avec notre façon de l'utiliser, nous allons étudier un cas pour voir ce qui ne va pas. Jusqu'à maintenant, nous ne nous en sommes pas vraiment rendus compte car nos modèles 3D sont assez simples. Mais maintenant que nous avons fait nos premiers pas dans la 3D, il vaut mieux prendre les bonnes habitudes dès le début. :)
Un problème bien ennuyeux ...
Pour comprendre le problème, nous allons revenir un peu sur les transformations. Vous savez maintenant que chaque transformation que vous faites sera appliquée sur le repère de votre espace 3D et pas seulement sur l'objet que vous voulez afficher. Si vous faites une translation puis une rotation c'est le repère entier qui va être modifié.
Imaginez que vous vouliez afficher un toit pour une maison. Il faudra tout d'abord se placer en haut de la maison, puis faire une rotation pour que le toit soit légèrement penché et afficher le tout.
Maintenant si vous voulez afficher le jardin, comment faites-vous pour revenir en bas de la maison ? Comment faites-vous pour annuler la translation et la rotation qui permettaient d'arriver jusqu'au toit ?
Pour ça, nous avons trois solutions :
Refaire la rotation et la translation dans le sens inverse.
Réinitialiser totalement la matrice modelview, puis la caméra et enfin se replacer en bas de la maison.
Sauvegarder la matrice, afficher le toit en la modifiant, puis annuler les transformations en la restaurant à son état sauvegardé.
La première solution peut vous paraitre la plus simple à mettre en œuvre et sur un exemple comme celui-ci on peut le penser. Mais imaginez une maison de 500 vertices, l'afficher nécessitera plusieurs transformations. Vous pensez vraiment refaire toutes ces transformations dans le sens inverse pour retrouver votre position ?
La réponse est bien sûr non, d'une part parce que se rappeler de toutes les transformations est trop fastidieux et d'autre part parce que ça vous couterait trop de ressources pour refaire tout dans le sens inverse. Surtout que votre maison sera affichée 50 fois par seconde, imaginez le fourbi. :o
La deuxième solution nous pose les mêmes problèmes, réinitialiser la matrice et se replacer nous fait perdre du temps et des ressources.
Nous utiliserons donc la troisième solution qui nous permet de sauvegarder la matrice quand nous en avons besoin. De cette façon, on peut modifier le repère à volonté sans être obligé de retenir sur toutes les transformations. Une fois que nous voudrons revenir à la position sauvegardée, il suffira simplement de restaurer la sauvegarde du repère. :D
Les piles
Avec OpenGL 2
Dans les précédentes versions d'OpenGL, il existait deux fonctions qui permettaient de sauvegarder et de restaurer les matrices facilement. Celles-ci s’appelaient :
glPush() : pour la sauvegarde
glPop() : pour la restauration
Ces deux fonctions fonctionnaient sur un système de pile qui permettait de stocker les sauvegardes les unes sur les autres.
Le principe d'une pile en programmation est d'entasser des variables ou des objets de même type les uns sur les autres comme une pile d'assiettes. Le but de la fonction glPush() était justement d'empiler des matrices entre elles pour former une pile de matrices :
On utilisait généralement les piles sur la matrice modelview, étant donné que c'est elle la plus utilisée. Elles étaient basées sur le principe LIFOLIFO (Last In First Out), littéralement sur le principe du dernier arrivé premier sorti. C'est-à-dire que la dernière sauvegarde était la première restaurée.
Prenons l'exemple des assiettes. Si vous empilez 18 assiettes et que vous voulez laver celle la plus en dessous. Il faudra d'abord laver les 17 premières assiettes qui sont au dessus. En revanche, si vous voulez laver la dernière arrivée, elle se trouvera en haut de la pile (l'endroit le plus simple à accéder).
Ok on sait ce qu'est une pile maintenant. :) Mais à quoi ça pouvait bien servir ?
Bonne question, les piles permettaient de sauvegarder la matrice modelview à un état donné pour pouvoir la restaurer plus tard. L'intérêt principal était de pouvoir empiler des matrices pour avoir un système de restauration assez simple : la dernière sauvegarde était la première restaurée.
De cette façon, nous n'avions plus besoin de réinitialiser la matrice modelview ou de retenir toutes les transformations pour savoir où se trouvait le repère.
Avec OpenGL 3
Comme beaucoup d'autres fonctions, OpenGL a déprécié l'utilisation de glPush() et glPop() mais bon il n'y a rien de surprenant là-dedans. En revanche, ce qui est surprenant c'est que la librairie GLM n'inclut pas de méthodes de substitution à ces fonctions. Nous ne pouvons donc pas utiliser les piles de matrices.
Ceci est dû au fait que les matrices sont maintenant des objets au sens propre du terme. C'est-à-dire que nous pouvons les manipuler, utiliser l'allocation dynamique dessus, intégrer la POO, etc. L'usage des piles n'est donc plus utile. C'est assez perturbant pour ceux qui les ont toujours utilisées mais lorsque l'on connait la puissance du C++, on se rend compte que ce n'est pas une si mauvaise idée que ça. :)
Au final, nous allons utiliser une autre manière de faire pour sauvegarder nos matrices. Les piles ne sont plus indispensables mais les sauvegardes, elles, le sont toujours. On ne peut pas résoudre notre problème de toit sinon. :p
Comme nous l'avons vu à l'instant, les piles ne sont maintenant inutilisables, cependant les sauvegardes doivent quand même être faites.
Pour les faire, nous allons utiliser une des propriétés magiques du C++ : l'opérateur =. En effet, lorsque cet opérateur est surchargé, il permet de pouvoir copier un objet dans un autre objet. C'est ainsi que l'on peut, par exemple, copier deux voitures sans problème :
// Copie d'une voiture
Voiture maCopie = voitureOriginale;
Pour notre problème de matrices, nous allons faire exactement la même chose :
Pour la sauvegarde : nous allons copier une matrice dans un objet sauvegarde
Pour la restauration : nous allons faire l'inverse et copier la sauvegarde dans la matrice originale
En code, cela donnerait :
// Sauvegarde de la matrice
mat4 sauvegardeModelview = modelview;
// Restauration de la matrice
modelview = sauvegardeModelview;
Facile non ? Je dirais même que c'est plus facile à comprendre que les piles. :p
Quand sauvegarder ?
Le problème des transformations
Maintenant que nous avons une méthode de substitution aux piles, nous allons pouvoir sauvegarder nos matrices dans nos programmes. En théorie, nous devrions faire cela à chaque fois que l'on fait une transformation. Par exemple, le cube du chapitre du chapitre précédent utilise une transformation, et plus précisément une rotation, qui nous permet de faire une pseudo-animation. Nous devons donc utiliser la sauvegarde de matrice ici.
Pourquoi me direz-vous ? Simplement parce que le prochain modèle que nous voudrons afficher (comme un autre cube) sera automatiquement affecté par la rotation. Ce qui fait qu'au lieu de faire pivoter le cube initial, nous les ferons pivoter tous les deux. Essayez ce code pour voir :
// Rotation du repère
modelview = rotate(modelview, angle, vec3(0, 1, 0));
// Affichage du premier cube
cube.afficher(projection, modelview);
// Affichage du second cube un peu plus loin
modelview = translate(modelview, vec3(10, 0, 0));
cube.afficher(projection, modelview);
Reculez votre caméra avant en la plaçant au point de coordonnées suivantes :
// Placement de la caméra
modelview = lookAt(vec3(6, 6, 6), vec3(3, 0, 0), vec3(0, 1, 0));
Compilez pour voir.
Vous verrez que les deux cubes sont affectés par la rotation.
Dans un jeu-vidéo, ça serait assez problématique si une simple rotation de caméra faisait pivoter tout un bâtiment. :p
Pour éviter ça, il faut sauvegarder l'état de la matrice modelview avant la rotation, puis la restaurer une fois le modèle affiché :
// Sauvegarde de la matrice modelview
mat4 sauvegardeModelview = modelview;
// Rotation du repère
modelview = rotate(modelview, angle, vec3(0, 1, 0));
// Affichage du premier cube
cube.afficher(projection, modelview);
// Restauration de la matrice
modelview = sauvegardeModelview;
// Affichage du second cube plus loin
modelview = translate(modelview, vec3(10, 0, 0));
cube.afficher(projection, modelview);
Si vous essayez ce code, vous remarquerez que le second cube ne pivote plus. La rotation n'est valable que pour le premier car la matrice avait été sauvegardée avant.
Un autre exemple
Bien entendu, le système de sauvegarde/restauration ne doit pas être utilisé seulement pour les rotations mais bien pour toutes les transformations (translation et homothétie comprises).
Ce qui fait que le code précédent est encore incomplet car la translation du second cube affectera les objets affichés après. Il faut donc réutiliser la sauvegarde de matrice :
// Affichage du premier cube
....
// Sauvegarde de la matrice modelview
mat4 sauvegardeModelview = modelview;
// Affichage du second cube plus loin
modelview = translate(modelview, vec3(2, 0, 0));
cube.afficher(projection, modelview);
// Restauration de la matrice
modelview = sauvegardeModelview;
Le cas des objets proches
Ce système de sauvegarde nous permet de revenir à chaque fois au centre du repère. Nos modèles sont donc placés sans erreur vu que nous partons toujours du point de coordonnées (0, 0, 0). Aucune transformation ne les affecte.
Le seul cas où vous pourrez vous permettre de ne pas restaurer la matrice immédiatement est le cas où des objets se situeraient près les uns des autres. Par exemple, imaginez que votre cube devienne une caisse et que vous souhaitez en afficher plusieurs côte à côte. Vous n'allez pas sauvegarder votre matrice à chaque fois pour revenir quasiment au même point après.
Si elles sont assez proches, vous pourrez les afficher les unes à la suite des autres sans problème et ce même si vous utilisez d'autres transformations :
// Sauvegarde de la matrice modelview
mat4 sauvegardeModelview = modelview;
// Affichage du premier cube (au centre du repère)
cube.afficher(projection, modelview);
// Affichage du deuxième cube
modelview = translate(modelview, vec3(3, 0, 0));
cube.afficher(projection, modelview);
// Affichage du troisième cube
modelview = translate(modelview, vec3(3, 0, 0));
cube.afficher(projection, modelview);
// Restauration de la matrice
modelview = sauvegardeModelview;
Vous voyez ici que les cubes sont assez proches, il est donc inutile de revenir au centre du repère pour repartir afficher la caisse suivante.
Ce qu'il faut retenir
Pour résumer tout ce blabla un peu confus :
Vous devez sauvegarder et restaurer la matrice modelview à chaque fois que vous faites une transformation
Si vous affichez des objets assez proches, ne restaurez votre matrice qu'une fois toutes les transformations faites. Ceci afin d’économiser un peu de temps de calcul
Si vous ne faites pas de transformation, ne sauvegardez rien
C'est tout ce qu'il faut retenir dans ce chapitre. :)
Nous voici à la fin de ce chapitre. Nous avons appris à coder et à utiliser les sauvegardes de matrice. C'était un chapitre un peu technique mais il concernait une notion indispensable de la programmation avec OpenGL. N'oubliez pas qu'à partir de maintenant, nous utiliserons toujours les piles de matrices pour afficher nos modèles. ;)
Pour la suite du tuto je vous propose de vous reposer encore un peu, le prochain chapitre que nous allons aborder sera assez facile à comprendre. Il concernera les évènements avec la SDL 2.0. :)
Depuis le début du tutoriel, les évènements sont gérés automatiquement et sans contrôle sur le clavier et la souris. Nous pourrions continuer à coder ainsi, sans se préoccuper de rien mais on va profiter de ce petit chapitre pour créer une classe à part entière qui s'occupera de gérer tout ça pour nous. Avec elle, nous pourrons retrouver facilement les évènements qui nous intéressent comme les touches ou les boutons de souris qui sont utilisés, ...
Bien entendu, notre code devra respecter la règle de l'encapsulation. Il faudra donc faire attention à protéger les attributs pour ne pas accéder directement aux évènements.
Nous avons déjà eu l’occasion de constater les différences qu'ils existaient entre l'ancienne et la nouvelle version de la SDL, notamment lors de la création de fenêtre. Nous allons voir maintenant celles qui concernent les évènements car ils sont indispensables au développement d'un jeu vidéo. D'ailleurs, on les a déjà utilisés lorsque nous voulions savoir si une fenêtre devait se fermer ou pas.
En effet, à chaque tour de la boucle principale, on vérifie si l'évènement SDL_WINDOWEVENT_CLOSE est déclenché, ce qui nous permet de continuer ou d’arrêter la boucle :
// Boucle principale
bool terminer(false);
while(!terminer)
{
// Gestion des évènements
SDL_PollEvent(&m_evenements);
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
terminer = true;
}
Ce bout de code est pratique mais il ne nous permet pas de gérer la pression des touches. On pourrait par exemple appuyer sur la touche ECHAP pour terminer la boucle, ou utiliser un de souris.
Si vous vous rappelez du chapitre sur les évènements de la SDL 1.2 dans le cours de M@téo, vous vous souvenez surement de la façon de gérer les touches du clavier. Par exemple, pour gérer la pression des touches T ou ECHAP il fallait faire ceci :
// Structure
SDL_Event evenements;
// Récupération d'un évènement
SDL_PollEvent(&evenements);
// Switch sur le type d'évènement
switch(evenements.type)
{
case SDL_KEYDOWN:
// Gestion des touches
switch(evenements.key.keysym.sym)
{
case SDLK_T:
....
break;
case SDLK_ESCAPE:
....
break;
}
break;
}
Voilà comment on gérait les évènements avec la SDL 1.2, et la bonne nouvelle c'est qu'avec la version 2.0 ça se passe de la même façon. :p
Seulement deux choses vont être modifiées :
Premièrement, on ne vérifie plus le champ sym, mais le champ scancode. Ce qui donne au final : m_evenements.key.keysym.scancode.
Ensuite, le nom des touches ne commencent plus par SDLK_* mais par SDL_SCANCODE_*. Ce qui nous donne ici : SDL_SCANCODE_T et SDL_SCANCODE_ESCAPE.
Si on modifie le code précédent on trouve :
// Structure
SDL_Event evenements;
// Récupération d'un évènement
SDL_PollEvent(&evenements);
// Switch sur le type d'évènement
switch(evenements.type)
{
case SDL_KEYDOWN:
// Gestion des touches
switch(evenements.key.keysym.scancode)
{
case SDL_SCANCODE_T:
....
break;
case SDL_SCANCODE_ESCAPE:
....
break;
}
break;
}
Aucun gros changement. ;)
Je ne peux vous parler plus explicitement des scancodes mais sachez simplement que mis à part le nom des constantes, le code restera le même pour nous. Si on veut aller plus loin au niveau du fonctionnement de la SDL on remarque que les scancodes sont une modification majeur de la librairie car on change de norme pour identifier les touches en interne. Mais bon dans le fond on s'en moque un peu. :p
Concernant la souris, il n'y a rien à signaler. Il n'y a pas de grands changements par rapport à la SDL 1.2. La gestion se fera de la même façon pour nous.
Maintenant que nous avons vu les différences qu'il existait entre les deux versions de la SDL, nous allons passer à l'implémentation d'une classe qui gèrera tous les évènements toute seule. :)
Ça veut dire qu'on va remplacer la structure SDL_Event par une classe ?
Alors oui et non, car la structure SDL_Event existera toujours quelque part. Mais au lieu de l'utiliser dans la classe SceneOpenGL, on l’utilisera dans une classe à part. Grâce à ça, les évènements seront protégés par la règle de l'encapsulation et seront aussi plus simples à utiliser. Seul l'appel à une méthode nous permettra de savoir si telle ou telle touche est enfoncée. :)
On appellera cette classe : la classe Input.
En informatique, les inputs sont des systèmes qui permettent d'apporter à l'ordinateur des actions venant de l'utilisateur. Elles peuvent prendre la forme d'un mouvement de souris, d'une pression sur une touche du clavier, d'un geste de doigt sur un écran tactile, ... D'où le nom Input pour la classe qui gèrera tous ces évènements. :)
Voici sa déclaration :
#ifndef DEF_INPUT
#define DEF_INPUT
// Include
#include <SDL2/SDL.h>
// Classe
class Input
{
public:
Input();
~Input();
private:
};
#endif
Les attributs
La classe Input possèdera plusieurs attributs :
Une structure SDL_Event : pour récupérer les évènements SDL
Un tableau de booléensm_touches[] : regroupant toutes les touches du clavier
Un tableau de booléensm_boutonsSouris[] : regroupant tous les boutons de la souris
Deux entiers (int) : représentant la position (x, y) du pointeur de la souris
Deux entiers : représentant la position relative (x, y) du pointeur
Un booléenm_terminer : qui permettra se savoir quand la fenêtre devra être fermer
On va directement s’intéresser aux tableaux de booléens. Chaque case de ces tableaux pourra prendre deux valeurs : si une touche, ou un bouton, est enfoncé(e) elle prendra la valeur true, dans le cas contraire ce sera la valeur false. Au début du programme, toutes les cases sont initialisées à false, vu que l'on appuie sur aucune des touches.
Pour définir la taille de ces tableaux on pourrait croire qu'il faille utiliser l'allocation dynamique - vu qu'il existe plusieurs types de claviers - mais pas du tout. En effet, la SDL gère toute seule les différents claviers. Elle est capable de nous fournir une seule et unique constante (SDL_NUM_SCANCODES) représentant le nombre maximal de touches d'un clavier, et par conséquent la taille du tableau m_touches[]. Grâce à elle, on pourra gérer toutes les touches automatiquement. ;)
Pour la souris, ce sera un peu différent. Il n'existe pas de constante que l'on peut donner au tableau. En revanche, on peut savoir que la SDL ne peut gérer que 7 boutons pour la souris. On donnera donc une taille de 7 cases au tableau m_boutonsSouris[].
Si on regroupe les attributs on a :
#ifndef DEF_INPUT
#define DEF_INPUT
// Include
#include <SDL2/SDL.h>
// Classe
class Input
{
public:
Input();
~Input();
private:
SDL_Event m_evenements;
bool m_touches[SDL_NUM_SCANCODES];
bool m_boutonsSouris[8];
int m_x;
int m_y;
int m_xRel;
int m_yRel;
bool m_terminer;
};
#endif
Avec ces attributs, on pourra récupérer tous les évènements dont on aura besoin. On verra en dernière partie de ce chapitre quelques méthodes qui nous permettront d'accéder à ces attributs.
Le constructeur et le destructeur
Maintenant qu'on a défini la classe Input, on doit s'occuper d'initialiser ses attributs avec le constructeur. Dans cette classe, on aura besoin que d'un constructeur : la constructeur par défaut.
Input();
Il s'occupera de mettre tous les attributs soit à 0 soit à false.
Son implémentation commence par l'initialisation de tous les attributs (sauf les tableaux) :
Pour initialiser les tableaux de bool, il faudra juste faire une boucle pour affecter la valeur false aux différentes cases :
Input::Input() : m_x(0), m_y(0), m_xRel(0), m_yRel(0), m_terminer(false)
{
// Initialisation du tableau m_touches[]
for(int i(0); i < SDL_NUM_SCANCODES; i++)
m_touches[i] = false;
// Initialisation du tableau m_boutonsSouris[]
for(int i(0); i < 8; i++)
m_boutonsSouris[i] = false;
}
Voilà pour le constructeur. :)
On profite également de cette partie pour créer le destructeur. Même si on ne met rien dedans, on l'implémente quand même. C'est une bonne habitude à prendre. ;)
Input::~Input()
{
}
La boucle d'évènements
Pour la partie qui va suivre, je vais vous demander toute votre attention. Nous allons voir la notion la plus importante du chapitre : la boucle d'évènements.
Euh c'est quoi cette boucle ? Ça a un rapport avec les évènements SDL ?
Oui tout à fait. Il s'agit d'une boucle qui récupère tous les évènements à un moment donné, quelque soit leur nature (touche de clavier, mouvement de souris, ...).
Si je vous parle de cette boucle, c'est parce qu'à l'heure actuelle nous avons un gros problème : nous ne sommes pas capables de gérer plusieurs évènements à la fois. Je vais vous donner un exemple pour que vous compreniez bien le problème.
Dans votre vie, vous avez probablement déjà joué à un jeu-vidéo, comme Mario par exemple. Les jeux Mario sont en général des jeux de plateformes où notre petit plombier doit sauter et avancer sur des plateformes, tuyaux et autres éléments de l'environnement.
La plupart du temps, sans même vous en rendre compte, vous appuyez sur plusieurs touches (ou boutons) en même temps. Par exemple, un personnage saute et avance en même temps, il se passe deux actions : sauter et avancer. On peut même rajouter des actions à ça : tirer, sprinter, ... Dans ce cas, le programme doit gérer 4 évènements en même temps.
A l'heure actuelle, notre problème c'est qu'avec la SDL nous ne pouvons gérer qu'un seul et unique évènement à la fois. Si nous programmions un Mario avec la SDL, le personnage ne pourrait même pas avancer et sauter en même, ce qui est un peu contraignant ... :p
Le problème vient de la SDL alors ? Elle est un peu nulle cette librairie :(
Non pas du tout, car ça ne vient pas vraiment d'elle mais de notre code. Vous vous souvenez du switch au début du chapitre ? Celui-ci :
// Structure
SDL_Event evenements;
// Attente d'un évènement
SDL_PollEvent(&evenements);
// Switch sur le type d'évènement
switch(evenements.type)
{
case SDL_KEYDOWN:
// Gestion des touches
switch(evenements.key.keysym.scancode)
{
case SDL_SCANCODE_T:
....
break;
case SDL_SCANCODE_ESCAPE:
....
break;
}
break;
}
Avec ce code, on ne peut gérer qu'un seul évènement par tour de boucle OpenGL.
Pour régler ce problème, on va se servir d'une petite astuce de la fonction SDL_PollEvent(). En effet, si on s’intéresse à son prototype on peut remarquer une chose :
int SDL_PollEvent(SDL_Event *event);
La fonction SDL_PollEvent() renvoie une valeur (un int).
On ne s'est jamais servi de cette valeur (on ne savait même pas qu'elle existait d'ailleurs) et pourtant c'est elle qui va régler notre problème. Cet integer retourné peut prendre deux valeurs :
Soit 1 : ce qui veut dire qu'il reste encore des évènements à capturer dans la file d'attente
Soit 0 : ce qui veut dire qu'il n'y en a plus
Pour capturer tous les évènements, il suffit de piéger la fonction SDL_PollEvent() dans une boucle. Tant qu'il reste quelque chose à récupérer dans la file d'attente, la fonction retourne 1, donc on continue de récupérer les évènements jusqu'à qu'il n'y en ait plus. Grâce à cette astuce, on sera capable de gérer plusieurs touches/boutons en même temps. Le petit Mario de tout à l'heure pourra donc avancer, sauter, sprinter, tirer des boules de feu ... et tout ça en même temps. ;) D'où la boucle d'évènements dont je vous ai parlée tout à l'heure. :p
Au niveau du code, il suffit d'enfermer la fonction SDL_PollEvent() dans une boucle while. Tant que la fonction retourne 1 on continue la boucle :
// Structure
SDL_Event evenements;
// Boucle d'évènements
while(SDL_PollEvent(&evenements) == 1)
{
// Switch sur le type d'évènement
switch(evenements.type)
{
case SDL_KEYDOWN:
// Gestion des touches
switch(evenements.key.keysym.scancode)
{
case SDL_SCANCODE_T:
....
break;
case SDL_SCANCODE_ESCAPE:
....
break;
}
break;
}
}
On peut même simplifier le while en enlevant la condition ' == 1 ' :
Vous savez maintenant ce qu'est la boucle d'évènements. C'est une boucle qui permet de capturer toutes les actions qui concernent le clavier, la souris, ... en même temps.
Nous allons maintenant revenir à la classe Input pour intégrer cette boucle dans une méthode. :)
La méthode updateEvenements()
La méthode qui va implémenter cette boucle s'appellera updateEvenements(). Voici son prototype :
void updateEvenements();
Commençons cette méthode en codant la fameuse boucle while qui prendra en "condition" la valeur retournée par la fonction SDL_PollEvent().
D'ailleurs, on donnera à cette dernière l'adresse de l'attribut m_evenements - car oui la structure SDL_Event existe toujours quelque part, elle n'a pas disparue. :p
La suite du code ne changera pas beaucoup par rapport à la SDL 1.2. On met en place un gros switch qui va tester la valeur du champ m_evenements.type . On commencera par gérer les conditions relatives au clavier.
Deux cas doivent être gérés par le clavier :
Lorsqu'une touche est enfoncée, l'évènement SDL_KEYDOWN est déclenché (même chose qu'avec la SDL 1.2).
Lorsqu'une touche est relâchée, l'évènement SDL_KEYUP est déclenché (même chose aussi)
Dans les deux cas, on actualisera l'état de la touche correspondante dans le tableau m_touches[]. D'ailleurs, on utilisera le champ m_evenements.key.keysym.scancode qui nous servira d'indice pour retrouver la bonne case :
// Actualisation de l'état de la touche
m_touches[m_evenements.key.keysym.scancode] = true;
Le gros avantage du tableau de booléens c'est que, quelque soit la touche enfoncée, nous n'avons besoin que d'une seule ligne de code pour actualiser son état. Que ce soit la touche A, B, C, ESPACE, ... une seule ligne suffit. :D
En définitif, pour gérer les touches du clavier nous devons :
Tester le champ m_evenements.type pour savoir si une touche a été enfoncée ou relâchée
Actualiser la touche dans le tableau de booléens avec une valeur true ou un false
void Input::updateEvenements()
{
// Boucle d'évènements
while(SDL_PollEvent(&m_evenements))
{
// Switch sur le type d'évènement
switch(m_evenements.type)
{
// Cas d'une touche enfoncée
case SDL_KEYDOWN:
m_touches[m_evenements.key.keysym.scancode] = true;
break;
// Cas d'une touche relâchée
case SDL_KEYUP:
m_touches[m_evenements.key.keysym.scancode] = false;
break;
default:
break;
}
}
}
Grâce à ce code, toutes les touches du clavier peuvent être mises à jour en même temps (enfin, en une boucle :p ).
Gestion de la souris
Nous avons déjà fait la plus grosse part du travail, on sait désormais gérer toutes les touches du clavier. Nous allons maintenant passer à la gestion de la souris.
La bonne nouvelle, c'est que les boutons de la souris se gèrent de la même façon que les touches du clavier. Il suffit de rajouter deux cases au switch : un pour les boutons qui seront pressés (ce case utilisera la constante SDL_MOUSEBUTTONDOWN) et un autre pour les boutons qui seront relâchés (il utilisera la constante SDL_MOUSEBUTTONUP).
Pour récupérer l'indice du bouton dans le tableau m_boutonsSouris[], nous n'utiliserons pas le champ m_evenements.key.keysym.scancode, car ce champ ne concerne uniquement que le clavier. A la place, nous utiliserons le champ evenements.button.button qui lui est réservé à la souris.
On ajoute donc les deux cases suivants au switch :
// Cas de pression sur un bouton de la souris
case SDL_MOUSEBUTTONDOWN:
m_boutonsSouris[m_evenements.button.button] = true;
break;
// Cas du relâchement d'un bouton de la souris
case SDL_MOUSEBUTTONUP:
m_boutonsSouris[m_evenements.button.button] = false;
break;
Ça c'était la partie des boutons. Maintenant il faut s'occuper des mouvements de la souris.
Lorsque la souris est en mouvement, un évènement est déclenché dans la SDL. Cet évènement va permettre de mettre à jour les coordonnées (x, y) du pointeur ainsi que ses coordonnées relatives. Nous allons pouvoir détecter ces mouvements grâce à la constante SDL_MOUSEMOTION. Lorsque cet évènement sera déclenché, on mettra à jour les attributs qui concernent les coordonnées. On prendra les nouvelles valeurs dans le champ m_evenements.motion :
// Cas d'un mouvement de souris
case SDL_MOUSEMOTION:
m_x = m_evenements.motion.x;
m_y = m_evenements.motion.y;
m_xRel = m_evenements.motion.xrel;
m_yRel = m_evenements.motion.yrel;
break;
Fermeture de la fenêtre
Il ne reste plus qu'un seul évènement à gérer dans cette boucle : le cas de la fermeture de la fenêtre (la croix rouge en haut à droite sous Windows). Depuis le début du tutoriel, on utilise cet évènement dans la boucle principale d'OpenGL pour savoir si on doit quitter le programme :
Il faut maintenant enlever ce code pour le migrer dans la méthode updateEvenements(). N'oubliez pas que c'est elle et uniquement elle qui doit mettre à jour tous les évènements.
Nous allons donc ajouter un nouveau case dans le switch pour gérer cette fermeture. On utilisera la constante SDL_WINDOWSEVENT pour savoir si cet évènement a été déclenché. N'oubliez pas de modifier la variable terminer en m_terminer, car on met à jour non plus une variable mais un attribut :
// Cas de la fermeture de la fenêtre
case SDL_WINDOWEVENT:
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
m_terminer = true;
break;
Si réunie tous ces cases dans la méthode, on trouve :
void Input::updateEvenements()
{
// Boucle d'évènements
while(SDL_PollEvent(&m_evenements))
{
// Switch sur le type d'évènement
switch(m_evenements.type)
{
// Cas d'une touche enfoncée
case SDL_KEYDOWN:
m_touches[m_evenements.key.keysym.scancode] = true;
break;
// Cas d'une touche relâchée
case SDL_KEYUP:
m_touches[m_evenements.key.keysym.scancode] = false;
break;
// Cas de pression sur un bouton de la souris
case SDL_MOUSEBUTTONDOWN:
m_boutonsSouris[m_evenements.button.button] = true;
break;
// Cas du relâchement d'un bouton de la souris
case SDL_MOUSEBUTTONUP:
m_boutonsSouris[m_evenements.button.button] = false;
break;
// Cas d'un mouvement de souris
case SDL_MOUSEMOTION:
m_x = m_evenements.motion.x;
m_y = m_evenements.motion.y;
m_xRel = m_evenements.motion.xrel;
m_yRel = m_evenements.motion.yrel;
break;
// Cas de la fermeture de la fenêtre
case SDL_WINDOWEVENT:
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
m_terminer = true;
break;
default:
break;
}
}
}
Problème des coordonnées relatives
Cette méthode est presque complète, il reste juste un petit point à régler qui concerne les coordonnées relatives. Ces coordonnées représentent la différence entre la position actuelle et l'ancienne position, elles seront très utiles dans le chapitre sur la caméra.
Le problème avec ces coordonnées c'est que : s'il n'y a aucun évènement alors elles ne sont pas mises à jour, elles conservent donc leurs anciennes valeurs. Ce qui veut dire que le programme considère que la souris continue de bouger, même si elle est inactive.
Pour régler ce problème, on va ré-initialiser les coordonnées avec la valeur 0 au début de la méthode. Ne vous inquiétez pas, si les coordonnées doivent être mises à jour avec de vraies valeurs elles le seront dans le switch.
Grâce à cette astuce, nous n'aurons aucun problème de mouvement fictif. On rajoute donc ces deux lignes de code au début de la méthode :
// Pour éviter des mouvements fictifs de la souris, on réinitialise les coordonnées relatives
m_xRel = 0;
m_yRel = 0;
Notre méthode donne donc au final :
void Input::updateEvenements()
{
// Pour éviter des mouvements fictifs de la souris, on réinitialise les coordonnées relatives
m_xRel = 0;
m_yRel = 0;
// Boucle d'évènements
while(SDL_PollEvent(&m_evenements))
{
// Switch sur le type d'évènement
switch(m_evenements.type)
{
// Cas d'une touche enfoncée
case SDL_KEYDOWN:
m_touches[m_evenements.key.keysym.scancode] = true;
break;
// Cas d'une touche relâchée
case SDL_KEYUP:
m_touches[m_evenements.key.keysym.scancode] = false;
break;
// Cas de pression sur un bouton de la souris
case SDL_MOUSEBUTTONDOWN:
m_boutonsSouris[m_evenements.button.button] = true;
break;
// Cas du relâchement d'un bouton de la souris
case SDL_MOUSEBUTTONUP:
m_boutonsSouris[m_evenements.button.button] = false;
break;
// Cas d'un mouvement de souris
case SDL_MOUSEMOTION:
m_x = m_evenements.motion.x;
m_y = m_evenements.motion.y;
m_xRel = m_evenements.motion.xrel;
m_yRel = m_evenements.motion.yrel;
break;
// Cas de la fermeture de la fenêtre
case SDL_WINDOWEVENT:
if(m_evenements.window.event == SDL_WINDOWEVENT_CLOSE)
m_terminer = true;
break;
default:
break;
}
}
}
Voilà ! Maintenant nous sommes capables de mettre à jour tous nos évènements, peu importe leur nombre ils seront tous gérés simultanément par la classe Input. :D
La méthode terminer()
Il ne reste plus qu'une chose à ajouter dans la classe : une méthode qui permet de dire si oui ou non l'utilisateur veut quitter le programme. Jusqu'à maintenant, on utilisait un booléen terminer pour fermer la fenêtre, mais maintenant ce booléen se trouve dans la classe Input.
Or, avec la règle de l'encapsulation on ne peut pas directement vérifier cet attribut. Il nous faut donc coder un accesseur pour récupérer la valeur du booléen.
On passe maintenant à l'utilisation de la classe Input dans notre scène 3D. Pour cela, on va remplacer l'ancien code de gestion des évènements par nos nouvelles méthodes.
On commence donc par déclarer un objet de type Input dans la classe SceneOpenGL qui viendra remplacer l'ancien attribut m_evenements :
#include "Input.h"
class SceneOpenGL
{
public:
/* *** Méthodes *** */
private:
/* *** Attributs *** */
// Objet Input pour la gestion des évènements
Input m_input;
}
On n'oublie pas de l'initialiser dans le constructeur :
SceneOpenGL::SceneOpenGL(std::string titreFenetre, int largeurFenetre, int hauteurFenetre) : m_titreFenetre(titreFenetre), m_largeurFenetre(largeurFenetre),
m_hauteurFenetre(hauteurFenetre), m_fenetre(0), m_contexteOpenGL(0), m_input()
Dans la boucle principale, on ne vérifie donc plus le booléen terminer mais la valeur retournée par la méthode terminer() :
Enfin, on supprime notre ancien code de gestion d'évènements que l'on remplace par la méthode updateEvenements() de la classe Input :
// Boucle principale
while(!m_input.terminer())
{
// On définit le temps de début de boucle
debutBoucle = SDL_GetTicks();
// Gestion des évènements
m_input.updateEvenements();
/* *** Rendu *** */
}
Voilà pour la classe Input ! Grâce à elle, tous nos évènements seront mis à jour automatiquement. :)
Maintenant que l'on a fait cela, on va pouvoir passer à l'implémentation de plusieurs méthodes "indispensables" qui vont nous permettre de connaitre l'état du clavier, de la souris, ... Nous devons passer par ces méthodes pour respecter la règle de l'encapsulation, sans quoi nous serions obligés d’accéder directement aux attributs pour avoir nos valeurs.
Cette dernière partie sera consacrée à une série de méthodes qui seront utilisées tout au long du tuto. Je vais vous donner une petite liste des fonctionnalités que l'on va implémenter. Il nous faudra une méthode pour :
Savoir si une touche est enfoncée
Savoir si un bouton de la souris est enfoncé
Savoir si le pointeur de la souris a bougé
Récupérer les coordonnées (x, y) du pointeur
Récupérer les coordonnées relatives (x, y) du pointeur
Cacher le pointeur
Capturer le pointeur dans la fenêtre
Houla on va devoir coder tout ça ? o_O
Et bien oui. Mais sachez pour vous rassurer que la plus grosse méthode de cette liste ne fera que 4 lignes. :p
Ces méthodes ne se contenteront que de faire une simple action (renvoyer un booléen par exemple) avec parfois des bloc if else. Rien de compliqué ne vous inquiétez pas.
La méthode getTouche()
On commence par la méthode la plus importante : celle qui permet de savoir si une touche a été enfoncée (ou non). En gros, on va coder un getter sur le tableau m_touches[]. :p Elle prendra en paramètre une variable de type SDL_Scancode correspondant à la touche demandée :
bool getTouche(const SDL_Scancode touche) const;
Cette méthode renverra true si la touche est pressée ou false si elle ne l'est pas. N'oubliez pas de la déclarer en tant que méthode constante, vu que l'on ne modifie aucun attribut :
Bonus : On va utiliser cette nouvelle méthode dès maintenant. Désormais, je veux que chacune de vos fenêtres SDL de chaque projet que vous ferez puisse se fermer en appuyant sur la touche ECHAP.
Comment feriez-vous ça avec le getter que l'on vient de coder ? Je vous laisse réfléchir un peu. :p
Vous avez trouvé ?
// Boucle principale
while(!m_input.terminer())
{
// On définit le temps de début de boucle
debutBoucle = SDL_GetTicks();
// Gestion des évènements
m_input.updateEvenements();
if(m_input.getTouche(SDL_SCANCODE_ESCAPE))
break;
/* *** Rendu *** */
}
Il suffit simplement d'appeler le getter getTouche() avec le scancode SDL_SCANCODE_ESCAPE. Si le getter retourne true, on casse la boucle avec le mot-clef break.
Bonus 2 : Pour savoir si deux touches sont enfoncées simultanément, il suffira d'utiliser un bloc if avec les deux touches demandées :
// Est-ce que les touches Z et D sont pressées ?
if(m_input.getTouche(SDL_SCANCODE_Z) && m_input.getTouche(SDL_SCANCODE_D))
{
...
}
La méthode getBoutonSouris()
La méthode, ou plutôt le getter, getBoutonSouris() fera la même chose que getTouche(). C'est-à-dire qu'elle permettra de savoir si un bouton spécifié est enfoncé ou pas. La seule différence avec la méthode précédente c'est que cette fois-ci, on lui donnera en paramètre non pas un SDL_Scancode mais une variable de type Uint8 correspondant au bouton demandé (En réalité, ce sera une constante comme avec les scancodes).
bool getBoutonSouris(const Uint8 bouton) const;
Elle renverra l'état du bouton demandé dans le tableau m_boutonsSouris[] :
La méthode suivante nous permettra de savoir si le pointeur de la souris a bougé. Grâce à elle, nous pourrons déclencher une action dès que la souris bougera. On appellera cette méthode : mouvementSouris(), elle renverra un booléen.
bool mouvementSouris() const;
Pour détecter un mouvement de souris il suffit de comparer la position relative du pointeur grâce aux attributs m_xRel et m_yRel. Si ces deux attributs sont égals à 0, alors le pointeur n'a pas bougé. Si en revanche ils ont une valeur non nulle, alors c'est que le pointeur a bougé.
Les méthodes suivantes sont des getters, elles renvoient chacune un attribut qui concerne la position du pointeur. Vous savez déjà comment fonctionne un getter, je vous épargne donc les explications. :)
Voici leur constructeur :
// Getters
int getX() const;
int getY() const;
int getXRel() const;
int getYRel() const;
Implémentation :
// Getters concernant la position du curseur
int Input::getX() const
{
return m_x;
}
int Input::getY() const
{
return m_y;
}
int Input::getXRel() const
{
return m_xRel;
}
int Input::getYRel() const
{
return m_yRel;
}
La méthode afficherPointeur()
Les méthodes suivantes peuvent vous paraitre inutiles pour le moment, mais dès que nous utiliserons une caméra mobile pour comprendrez vite leur utilité.
La méthode afficherPointeur() va permettre d'afficher ou de cacher le pointeur à l'écran. Elle prendra en paramètre un booléen qui :
S'il est à true : le pointeur est caché
S'il est à false : le pointeur est affiché
void afficherPointeur(bool reponse) const;
Cette méthode va faire appel à une fonction SDL pour afficher ou cacher le pointeur :
int SDL_ShowCursor(int toggle);
Cette fonction prend en paramètre une constante qui peut être égale soit à SDL_ENABLE soit à SDL_DISABLE, ce paramètre se comporte un peu comme un booléen. On ne s'occupera pas de la valeur retournée par la fonction.
La dernière méthode va permettre d'utiliser le Mode Relatif de la Souris. Ce mode permet de piéger le pointeur dans la fenêtre, il ne pourra pas en sortir. C'est utile voir obligatoire dans un jeu-vidéo par exemple. On appellera cette méthode : capturerPointeur().
Faites attention à cette méthode, si vous l'appelez dans votre code vous devrez prévoir quelque chose pour fermer votre programme. Utilisez un getTouche() par exemple. Si vous ne faites pas ça, vous ne pourrez plus fermer votre fenêtre. Vous devrez alors utiliser les combinaisons CTRL + MAJ + SUPPR ou WINDOWS + TAB (si vous êtes sous Windows) pour pouvoir ré-utiliser la souris.
Le prototype de la méthode est le suivant :
void capturerPointeur(bool reponse) const;
Elle se comportera exactement de la même manière que la méthode précédente. Elle utilisera la fonction SDL_SetRelativeMousseMode() de la SDL pour activer le mode relatif de la souris :
int SDL_SetRelativeMouseMode(SDL_bool enabled);
Cette fonction prendra en paramètre un SDL_bool. Sa valeur peut être soit SDL_TRUE soit SDL_FALSE. Voici son implémentation :
Je vais vous donner des exercices assez simples pour vous habituez à utiliser votre nouvelle classe. Ceux-ci seront axés sur la pseudo-animation du cube (celle qui permettait de le faire tourner). Vous n'aurez besoin d'utiliser que les méthodes qui sont déjà codées, vous n'aurez donc pas besoin d'en rajouter.
Il est préférable que vous incluiez le code relatif aux évènements avant la fonction glClear(). En effet, vous verrez dans le futur que nous aurons parfois de faire plusieurs affichages de la même scène. Pour économiser du temps de calcul les inputs doivent se gérer avant ces affichages. La seule chose qui peut se trouver après la fonction c'est la méthode rotate() car elle ne fait pas partie des évènements mais des matrices (et donc de l'affichage).
Autant prendre les bonnes habitudes dès maintenant, surtout que ça ne mange pas de pain. :)
Exercice 1 : L'animation initiale du cube permettait de le faire pivoter selon un angle et par rapport à l'axe Y. Votre objectif est de dé-automatiser cette rotation pour qu'elle réponde aux touches du clavier Flèche Gauche et Droite (SDL_SCANCODE_LEFT et SDL_SCANCODE_RIGHT). Vous pouvez vous aider du code que l'on avait utilisé dans le chapitre 7 :
// Incrémentation de l'angle
angle += 4.0;
if(angle >= 360.0)
angle -= 360.0;
// Sauvegarde de la matrice
mat4 sauvegardeModelview = modelview;
// Rotation du repère
modelview = rotate(modelview, angle, vec3(0, 1, 0));
// Affichage du cube
cube.afficher(projection, modelview);
// Restauration de la matrice
modelview = sauvegardeModelview;
Exercice 2 : Même exercice que précédemment sauf que l'axe concerné n'est plus Y mais X et les touches sont Flèche Haut et Bas (SDL_SCANCODE_UP et SDL_SCANCODE_DOWN).
Exercice 3 : Rassemblez les deux animations précédentes pour le même cube. C'est-à-dire que vous devez pouvoir appuyer sur les 4 touches directionnelles pour le faire pivoter selon l'axe XetY (Petit indice : vous aurez besoin de deux angles).
Solution
Exercice 1 :
Le but de l'exercice était de "manualiser" la rotation en fonction des touches Haut et Bas. Pour cela, il fallait simplement appeler la méthode getTouche() de l'objet m_input deux fois avec les constantes SDL_SCANCODE_LEFT et SDL_SCANCODE_RIGHT :
// Gestion des évènements
....
// Rotation du cube vers la gauche
if(m_input.getTouche(SDL_SCANCODE_LEFT))
{
}
// Rotation du cube vers la droite
if(m_input.getTouche(SDL_SCANCODE_RIGHT))
{
}
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
....
Une fois la vérification des touches pressées faite, il ne manquait plus qu'à copier le code de rotation. Bien entendu, il fallait additionner l'angle dans un cas et le soustraire dans l'autre :
// Gestion des évènements
....
// Rotation du cube vers la gauche
if(m_input.getTouche(SDL_SCANCODE_LEFT))
{
// Modification de l'angle
angle -= 4.0;
// Limitation
if(angle >= 360.0)
angle -= 360.0;
}
// Rotation du cube vers la droite
if(m_input.getTouche(SDL_SCANCODE_RIGHT))
{
// Modification de l'angle
angle += 4.0;
// Limitation
if(angle >= 360.0)
angle -= 360.0;
}
// Nettoyage de l'écran
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
....
Enfin, il ne manquait plus qu'à appeler la méthode rotate() avec l'angle de rotation en paramètre et afficher le cube. Le tout encadré par la sauvegarde et la restauration de la matrice modelview. ;)
// Sauvegarde de la matrice modelview
mat4 sauvegardeModelview = modelview;
// Rotation du repère
modelview = rotate(modelview, angle, vec3(0, 1, 0));
// Affichage du premier cube
cube.afficher(projection, modelview);
// Restauration de la matrice
modelview = sauvegardeModelview;
Exercice 2 :
Cet exercice reprend le même principe que le précédent sauf qu'il fallait, d'une part, modifier les constantes utilisées :
// Rotation du cube vers le bas
if(m_input.getTouche(SDL_SCANCODE_DOWN))
{
// Modification de l'angle
angle -= 4.0;
// Limitation
if(angle >= 360.0)
angle -= 360.0;
}
// Rotation du cube vers le haut
if(m_input.getTouche(SDL_SCANCODE_UP))
{
// Modification de l'angle
angle += 4.0;
// Limitation
if(angle >= 360.0)
angle -= 360.0;
}
Et d'autre part, il fallait également modifier l'axe de rotation de la méthode rotate() :
Le dernier exercice était un poil plus dur que les deux autres, il fallait utiliser deux angles et gérer 4 touches du clavier.
Vous pouviez donner n'importe quel nom aux angles :
// Angles de la rotation
float angleX(0.0);
float angleY(0.0);
La gestion des 4 touches n'était pas compliquée du moment que vous utilisiez le bon angle avec le bon axe de rotation :
// Rotation du cube vers la gauche
if(m_input.getTouche(SDL_SCANCODE_LEFT))
{
angleY -= 5;
if(angleY > 360)
angleY -= 360;
}
// Rotation du cube vers la droite
if(m_input.getTouche(SDL_SCANCODE_RIGHT))
{
angleY += 5;
if(angleY < -360)
angleY += 360;
}
// Rotation du cube vers le haut
if(m_input.getTouche(SDL_SCANCODE_UP))
{
angleX -= 5;
if(angleX > 360)
angleX -= 360;
}
// Rotation du cube vers le bas
if(m_input.getTouche(SDL_SCANCODE_DOWN))
{
angleX += 5;
if(angleX < -360)
angleX += 360;
}
J'ai divisé le code en deux pour le rendre plus lisible. Il fallait évidemment tout coder au même endroit. ;)
Enfin, pour gérer les deux rotations, il fallait simplement appeler la méthode rotate() deux fois. Un appel prenait en compte l'angle angleX et l'autre l'angle angleY :
// Sauvegarde de la matrice modelview
mat4 sauvegardeModelview = modelview;
// Rotation du repère
modelview = rotate(modelview, angleY, vec3(0, 1, 0));
modelview = rotate(modelview, angleX, vec3(1, 0, 0));
// Affichage du premier cube
cube.afficher(projection, modelview);
// Restauration de la matrice
modelview = sauvegardeModelview;
Encore une fois, n'oubliez pas d'utiliser la sauvegarde/restauration lorsque vous faites une transformation. :)
Notre classe Input est maintenant complète et prête à l'emploi. Avec elle, nous pourrons gérer tous nos évènements simplement. Nous serons capable de gérer la pression de plusieurs touches, des mouvements de la souris, etc. Et tout ça de manière simultanée. Pour utiliser les évènements, il suffira juste de passer un objet de type Input aux modèles que l'on veut afficher. De plus, l'avantage d'avoir une classe à part entière c'est que l'on pourra ajouter de nouvelles méthodes au fur et à mesure du tutoriel. Nous n'aurons pas besoin de revenir en arrière pour modifier le code, il suffira d'ajouter ce qu'il faut dans la classe Input.
Après ce chapitre repos, je vous propose de passer à un chapitre hyper méga important qui concerne les Textures avec OpenGL ! :D
Après le chapitre quelque peu compliqué sur les piles de matrices, nous allons passer aujourd'hui à un chapitre beaucoup plus concret (et plus intéressant :p ) qui concerne le Texturing avec OpenGL. Avec ceci, nous pourrons rendre notre scène 3D plus réaliste. Nous verrons comment charger des textures stockées sur votre disque dur (ou SSD) et comment les appliquer à une surface 3D.
Avant de commencer ce chapitre très intéressant, on va définir ensemble un mot que vous avez déjà probablement entendu quelque part : le mot Texture.
Une texture est simplement une sorte de papier peint que l'on va "coller" sur les modèles (2D ou 3D). Dans un jeu vidéo, toutes les surfaces que vous voyez (sols, herbe, murs, personnages, ...) sont constituées de simples images collées sur des formes géométriques. Pour un mur par exemple, il suffit de créer une surface carrée puis de coller une image représentant des briques.
L'objectif de ce chapitre sera d'apprendre à créer une texture OpenGL de A à Z. Pour ceux qui auraient suivi le cours de M@téo sur la SDL, vous devriez déjà avoir une petite idée sur la façon de charger les images. Vous vous apercevrez cependant que le chargement de texture est assez différent avec OpenGL. Mais il permet de faire des choses beaucoup plus avancées.
L'une des premières difficultés va concerner la taille des textures à afficher. En effet, OpenGL ne sait gérer que les textures dont les dimensions sont des puissances de 2 (64 pixels, 128, 256, 512, ...).
Alors bon, ce n'est pas une obligation car OpenGL redimensionne de toute façon les images par lui-même mais si vous ne voulez pas vous retrouver avec une texture déformée, il vaut mieux prendre l'habitude des dimensions en puissance de 2. Le mieux est encore de modifier la taille de vos images avec des logiciels spécialisés. ;)
Avant d'aller plus loin, je vais vous demander de télécharger la librairie SDL_image (ou plutôt SDL2_image) qui nous permettra de réaliser la première étape dans la création de nos textures. Elle va nous faire gagner du temps en chargeant tous les bits d'une image en mémoire. Remarquez une fois de plus que toutes les librairies que nous utilisons sont portables. :p
Pour MinGW sous Windows : fusionnez les dossiers bin, dll, include et lib avec ceux du dossier SDL-2.0 que vous avez placé au tout début du tutoriel (chez moi : C:\Program Files (x86)\CodeBlocks\MinGW\SDL-2.0). Pensez à rajouter les nouvelles dll soit dans le dossier de chaque projet, soit dans le dossier bin de MinGW selon la méthode que vous avez choisie au début du tuto.
Pour Linux ça va être folklo. :lol: Vu que tout le monde n'a pas forcément la même distribution, il faudra que vous compiliez vous même SDL_image. Cependant, cette librairie fait appel à 5 autres librairies et il faudra également toutes les compiler ! Mais bon vous savez que je ne suis pas sadique (ah bon ?), je vais donc vous donner directement toutes les commandes à taper dans votre terminal.
Enfin, commencez par télécharger le code source de SDL_image et dézippez son contenu dans votre home. Ensuite, exécutez les commandes suivantes mais attention ! Si j'ai divisé les commandes en bloc ce n'est pas pour rien, je vous conseille d'exécuter les blocs un à un pour voir si tout compile normalement. Si vous copiez toute les commandes d'un coup vous risquez de zapper une erreur que vous regretterez plus tard. ;)
cd
cd SDL_image/tiff-4.0.3/
chmod +x configure
./configure
make
sudo make install
cd ../zlib-1.2.7/
chmod +x configure
./configure
make
sudo make install
cd ../libpng-1.5.13/
chmod +x configure
./configure
make
sudo make install
cd ../jpeg-8d/
chmod +x configure
./configure
make
sudo make install
cd ../libwebp-0.2.0/
chmod +x configure
./configure
make
sudo make install
cd ../SDL_image
chmod +x configure
./configure
make
sudo make install
On reprend au niveau des IDE pour tout le monde, il va falloir linker la librairie SDL_image avec vos projets. Voici un petit tableau avec le link à spécifier en fonction de votre IDE :
OS
Option
Code::Blocks Windows
SDL2_image
Code::Blocks Linux
SDL2_image
DevC++
-lSDL2_image
Visual Studio
SDL2_image.lib
Enfin pour terminer cette introduction, nous allons télécharger un pack de textures que l'on utilisera tout au long de ce tutoriel. Vous y verrez à l'intérieur plusieurs catégories d'images (sols, pierres, bois, ...). Je remercie au passage notre petit Kayl qui a fait découvrir ce pack dans son tuto. Je reprends le même vu qu'il est assez complet. :p
La librairie SDL_image va nous faciliter grandement la tâche dans le chargement de texture. Elle va nous faire gagner beaucoup de temps car elle sait charger une multitude de formats d'image, nous n'aurons donc pas à charger nos images manuellement. Cependant, elle ne peut pas tout faire pour nous. Les images chargées avec SDL_image seront, si on les laisse comme ça, inutilisables avec OpenGL.
En effet, la librairie permet de charger les textures uniquement pour la SDL et non pour les autres API. Il nous faut donc configurer OpenGL pour qu'il puisse reconnaitre ces nouvelles textures. Dans cette partie nous allons voir pas mal de fonctions spécifiques à la librairie OpenGL, et vous verrez que vous retrouvez certaines d'entre elles tout au long du tutoriel. Je vous dirai celles qui sont importantes à retenir. ;)
Les objets OpenGL
Avant d'aller plus loin, j'aimerais que l'on développe un point important de ce chapitre : les objets OpenGL. Les objets OpenGL sont semblables aux objets en C++ (même s'ils sont différents dans le fond), on peut les représenter par le laboratoire que l'on voit dans le chapitre sur les objets de M@téo. Ce sont donc des sortes de laboratoires dont on ne connait pas le fonctionnement, et d'ailleurs on s'en moque à partir du moment où ils fonctionnent. :p
Pourquoi je vous parle de ça ? Et bien simplement parce qu'une texture est un objet OpenGL. Vous verrez que l'on va apprendre à initialiser la texture mais vous n'aurez aucune idée de ce qui se passe à l'intérieur de la carte graphique, tout comme le laboratoire. Nous donnerons à la texture des pixels à afficher et OpenGL se chargera du reste. Bon je schématise un peu mais vous avez compris l'idée.
Comment on crée un objet OpenGL ? C'est dur à faire ? :(
Non pas du tout c'est en réalité très simple ! En effet, pour créer ces objets on utilise la plupart du temps la même fonction. Et cette fonction nous renverra toujours la même chose : un ID représentant l'objet créé. Cet ID est une variable de type unsigned int et va permettra à OpenGL de savoir sur quel objet il doit travailler.
En ce qui concerne la configuration de ces objets, nous procéderons ainsi :
Chargement d'une image avec la librairie SDL_image
Création (ou plutôt de génération) de l'ID
Verrouillage de l'ID (nous allons voir ce que c’est dans un instant)
Configuration de l'objet
Déverrouillage de l'ID
Toutes ces parties se gèrent avec les mêmes fonctions pour la plupart des objets OpenGL (que ce soit une texture ou autre). Il n'y a que l'étape de la configuration qui va varier.
La classe Texture
On commence la partie programmation par le plus simple : la création d'une classe Texture. Mis à part le constructeur et le destructeur, cette classe ne contiendra que la méthode charger() qui s'occupera de charger la texture demandée. Elle retournera un booléen pour confirmer ou non le chargement :
bool charger();
La classe contiendra également 2 attributs :
GLuint m_id : Un unsigned int qui représentera le fameux ID
string m_fichierImage : Le chemin vers le fichier contenant l'image
On rajoutera au passage un accesseur pour l'attribut m_id et un mutateur pour m_fichierImage au cas où devrions spécifier une image après déclaration de l'objet. L'accesseur sera important pour la suite :
Maintenant que l'on a un squelette de classe propre, nous pouvons nous lancer dans la création de texture. La première étape consiste à charger un fichier image en mémoire grâce à la librairie SDL_image. Pour cela rien de plus simple, il existe une et unique fonction pour charger plus d'une dizaine de formats d'image différents ! Que demande le peuple. ^^
La fonction est la suivante :
SDL_Surface *IMG_Load(const char *file)
file : Chemin du fichier image
La fonction renvoie une SDL_surface qui contiendra tous les pixels nécessaires.
Pour le chemin du fichier, nous donnerons l'attribut m_fichierImage, ou plutôt la chaine C de cet attribut car la fonction demande un tableau de caractère. ;)
bool Texture::charger()
{
// Chargement de l'image dans une surface SDL
SDL_Surface *imageSDL = IMG_Load(m_fichierImage.c_str());
}
Attention cependant, la fonction peut renvoyer un pointeur sur 0. Il faut donc gérer cette erreur au cas où l'image n'existerait pas ou si le chemin donné contient une erreur. En cas de problème, on affiche alors un message d'erreur grâce à la fonction SDL_GetError() :
char* SDL_GetError(void);
Cette fonction permet de renvoyer la dernière erreur qu'a rencontrée la SDL (dans une chaine de char). Donc en cas d'erreur de chargement, on inclut le résultat de cette fonction dans un flux cout :
On a vu tout à l'heure ce qu'étaient les objets OpenGL et on sait également que nous pouvons les gérer grâce à leur ID. Nous allons maintenant voir comment générer cet ID. Pour ce faire, il existe une fonction déjà toute prête dans OpenGL :
number : Le nombre d'ID à initialiser. Nous mettrons toujours la valeur 1
textures : Un tableau de type GLuint. On peut aussi mettre l'adresse d'une variable GLuint pour initialiser un seul ID de texture (et c'est ce qu'on fera)
Pour générer un ID de texture, il suffit d'utiliser cette fonction en donnant en paramètre l'attribut m_id de notre classe Texture :
// Génération de l'ID
glGenTextures(1, &m_id);
On appelle cette fonction juste après avoir charger l'image en mémoire :
bool Texture::charger()
{
// Chargement de l'image dans une surface SDL
SDL_Surface *imageSDL = IMG_Load(m_fichierImage.c_str());
if(imageSDL == 0)
{
std::cout << "Erreur : " << SDL_GetError() << std::endl;
return false;
}
// Génération de l'ID
glGenTextures(1, &m_id);
}
Le verrouillage
Je vous ai parlé rapidement du verrouillage d'objet tout à l'heure, ceci permettait à OpenGL de verrouiller un objet pour travailler dessus. Tous les objets OpenGL doivent être verrouillés pour être configurés (et même pour être utilisés !) sinon vous ne pourrez rien faire avec.
On utilisera une fonction simple pour verrouiller nos objets. Ce sera d'ailleurs la même pour les déverrouiller. :p Voici son prototype :
target : C'est un paramètre que vous retrouvez souvent avec tous les objets, nous le verrons même plusieurs fois dans ce chapitre. Il correspond au type de l'objet que l'on veut créer, nous lui affecterons la valeur GL_TEXTURE_2D en ce qui concerne les textures.
texture : C'est l'ID de l'objet, nous lui donnerons la valeur de l'attribut m_id. La valeur de m_id et non un pointeur cette fois ci ! :)
Voici donc comment utiliser la fonction dans notre cas :
Tiens au passage, vu que l'on a vu le verrouillage d'objets, nous allons voir maintenant le déverrouillage qui permet à OpenGL d’arrêter de se concentrer sur l'objet en cours, ce qui permet par extension d’empêcher les modifications.
Pour réaliser cette opération, on utilisera la même fonction mais avec le paramètre target non plus égal à la valeur de l'ID de la texture mais avec la valeur 0 (la valeur nulle quoi). En gros, on dit à OpenGL : "Verrouille l'objet possédant l'ID 0, soit rien du tout". :)
Notre texture a un ID généré, elle est également verrouillée, on peut maintenant passer à sa configuration. :D
Grossièrement parlant, pour avoir une texture dans OpenGL il suffit de copier les pixels d'une image dans la texture. C'est aussi simple que ça. Seulement voilà, il existe plusieurs formats d'image et certaines contiennent plus de données que d'autres, ...
Hein je croyais que la librairie SDL_image permettait justement de gérer tous ces formats ? :(
Et bien oui vous avez raison, c'est bien SDL_image qui gère les différents formats de l'image. Il existe cependant une chose qu'elle ne peut pas nous dire automatiquement.
Vous n'êtes pas sans savoir qu'un pixel est composé de 3 couleurs (rouge, vert et bleu) ... Les pixels d'une image n’échappent pas à cette règle, chacun d'entre eux est composé de ces 3 couleurs. Seulement voilà, il existe, pour certains formats, une quatrième composante qui s'appelle la composante Alpha. Cette composante permet de stocker le "niveau de transparence" d'une image.
Pour charger correctement une texture, il faut savoir si cette valeur alpha est présente ou non, et heureusement pour nous, la librairie SDL_image est capable de nous le dire. En effet, dans la structure SDL_Surface utilisée au début de la méthode charger(), il existe un champ BytesPerPixel qui permet de dire s'il y a 3 ou 4 couleurs. Nous devrons donc d'abord récupérer cette valeur avant de copier les pixels dans la texture.
Bien on arrête là pour la théorie, on passe au code.
On veut savoir si une image possède 3 ou 4 couleurs, on récupère donc le champ imageSDL->format->BytesPerPixel pour le vérifier puis on met le tout dans un bloc if. Si on a une valeur inconnue, on arrête le chargement de la texture pour éviter de se retrouver avec une grosse erreur puis on n'oublie pas de libérer la surface SDL avant de quitter la méthode :
// Détermination du nombre de composantes
if(imageSDL->format->BytesPerPixel == 3)
{
}
else if(imageSDL->format->BytesPerPixel == 4)
{
}
// Dans les autres cas, on arrête le chargement
else
{
std::cout << "Erreur, format de l'image inconnu" << std::endl;
SDL_FreeSurface(imageSDL);
return false;
}
On sait maintenant qu'il faut faire attention au bidule alpha, mais qu'est qu'on met à l'intérieur des if ? Ils sont tout vide. :(
C'est normal, il manque encore quelque chose. Comme on l'a vu plus tôt, OpenGL a besoin de savoir si la composante alpha existe ou pas. Seulement si on lui donne la valeur 3 ou 4 ça ne va pas lui suffire, il faudra envoyer une autre valeur qui sera un peu comme le paramètre GL_TEXTURE_2D que l'on a vu plus haut. Avec ce paramètre, il comprendra mieux ce qu'on lui enverra.
Il y aura deux cas à gérer :
Soit l'image ne contiendra pas la composante alpha et dans ce cas on retiendra la constante GL_RGB
Soit l'image contiendra la composante alpha et dans ce cas on retiendra la constante GL_RGBA
Au niveau du code, on utilisera une variable de type GLenum pour retenir cette valeur. On l'appellera formatInterne, vous verrez pourquoi juste après :
// Détermination du nombre de composantes
GLenum formatInterne(0);
if(imageSDL->format->BytesPerPixel == 3)
{
formatInterne = GL_RGB;
}
else if(imageSDL->format->BytesPerPixel == 4)
{
formatInterne = GL_RGBA;
}
// Dans les autres cas, on arrête le chargement
else
{
std::cout << "Erreur, format interne de l'image inconnu" << std::endl;
SDL_FreeSurface(imageSDL);
return false;
}
Il ne manque plus qu'une chose à faire. Selon le système d'exploitation ou même les images que vous utiliserez, les pixels ne seront pas stockés dans le même ordre. Par exemple sous Windows, la plupart des formats stockent leurs pixels selon l'ordre Rouge Vert Bleu (RGB) sauf les images au format BMP. Ceux-ci voient leurs pixels stockés selon l'ordre Bleu Vert Rouge (BGR). C'est un problème que nous devons gérer car certains auront la belle surprise de voir leurs images avec des couleurs complétement inversées (Imaginez un Dark Vador en blanc :p ).
Il faut donc dire à OpenGL dans quel ordre les pixels sont stockés, et pour ça on va utiliser une autre variable de type GLenum que l'on appelera format :
// Détermination du format et du format interne
GLenum formatInterne(0);
GLenum format(0);
Pour connaitre l'ordre des pixels, nous devons utiliser un autre champ de la structure imageSDL. Ce champ sera imageSDL->format->Rmask.
Il existe 4 champs similaires Rmask, Gmask, Bmask et Amask qui représente chacun la position de sa couleur à l'aide d'une valeur hexadécimal. Nous utiliserons le premier champ (Rmask), même si nous pouvions utiliser n'importe lequel. Sauf le dernier car il se trouve toujours à la fin quelque soit le format d'image.
Nous devons donc tester cette valeur pour connaitre la position de la couleur rouge. Si sa valeur est égale à 0xff alors elle est placée au début, sinon c'est qu'elle se trouve à la fin :
// Format de l'image
GLenum formatInterne(0);
GLenum format(0);
// Détermination du format et du format interne
if(imageSDL->format->BytesPerPixel == 3)
{
// Format interne
formatInterne = GL_RGB;
// Format
if(imageSDL->format->Rmask == 0xff)
{}
else
{}
}
else if(imageSDL->format->BytesPerPixel == 4)
{
// Format interne
formatInterne = GL_RGBA;
// Format
if(imageSDL->format->Rmask == 0xff)
{}
else
{}
}
// Dans les autres cas, on arrête le chargement
else
{
std::cout << "Erreur, format interne de l'image inconnu" << std::endl;
SDL_FreeSurface(imageSDL);
return false;
}
Il faut maintenant affecter une valeur à la variable format. Il y a 4 cas à gérer :
Rouge en premier pour une image à 3 composantes
Rouge en dernier pour une image à 3 composantes
Rouge en premier pour une image à 4 composantes
Rouge en dernier pour une image à 4 composantes
Ces quatre cas seront représentés par les constantes suivantes :
GL_RGB
GL_BGR
GL_RGBA
GL_BGRA
Il n'y a plus qu'à affecter la bonne constante à la variable format :
// Format pour 3 couleurs
if(imageSDL->format->Rmask == 0xff)
format = GL_RGB;
else
format = GL_BGR;
....
// Format pour 4 couleurs
if(imageSDL->format->Rmask == 0xff)
format = GL_RGBA;
else
format = GL_BGRA;
Ce qui donne au final :
// Format de l'image
GLenum formatInterne(0);
GLenum format(0);
// Détermination du format et du format interne pour les images à 3 composantes
if(imageSDL->format->BytesPerPixel == 3)
{
// Format interne
formatInterne = GL_RGB;
// Format
if(imageSDL->format->Rmask == 0xff)
format = GL_RGB;
else
format = GL_BGR;
}
// Détermination du format et du format interne pour les images à 4 composantes
else if(imageSDL->format->BytesPerPixel == 4)
{
// Format interne
formatInterne = GL_RGBA;
// Format
if(imageSDL->format->Rmask == 0xff)
format = GL_RGBA;
else
format = GL_BGRA;
}
// Dans les autres cas, on arrête le chargement
else
{
std::cout << "Erreur, format interne de l'image inconnu" << std::endl;
SDL_FreeSurface(imageSDL);
return false;
}
Pfiou tout ce gourbi pour déterminer deux valeurs ! :lol:
On a fait le plus dur, il ne nous reste plus qu'à copier les fameux pixels dans la texture. Pour ça, on va utiliser la fonction suivante (ne soyez pas surpris du nombre de paramètres :p ) :