![]() |
Faculté Informatique & Communications Cours d'informatique |
NOTE : Ce tutoriel présente OpenGL et Qt. Mais ce
ne sont, bien entendu, pas les seules bibliothèques permettant
d'effectuer ces tâches. Vous pourriez
aussi par exemple utiliser
raylib,
GLFW,
OGRE,
wxWidgets,
SDL,
ou tout autre bibliothèque de votre choix à la place de Qt.
Mais Qt est la bibliothèque installée sur les VM.
Si vous utilisez une autre bibliothèque, assurez-vous que nous pouvons l'installer simplement sur les VMs.
Car je vous rappelle que votre code (ou en tout cas une version de celui-ci) doit compiler et fonctionner sur les VMs.
N'hésitez pas à me demander si vous avez des doutes !
Dans tous les programmes que vous avez écrits jusqu'à maintenant, vous disposiez du terminal pour afficher et saisir du texte. Ceci est insuffisant pour permettre d'afficher des graphismes évolués et offrir une interaction « vivante » avec l'utilisateur. Ce document a pour but de vous donner les bases nécessaires pour programmer en utilisant les bibliothèques de fonctions graphiques Qt et OpenGL. Nous abordons également le thème de la simulation dans un environnement graphique en temps réel.
NOTE : Le matériel présenté ici ne faisant pas partie des objectifs du cours (mais étant simplement un outil pour le projet de cette année), nous aurons une approche pratique par l'exemple, très superficielle, plutôt qu'une approche théorique et complète.
OpenGL propose une série de fonctions évoluées, compatibles avec toutes les plates-formes (Unix, Linux, MacOS, ...) et très performantes. Le dessin s'effectue grâce à des primitives géométriques (p.ex. polygones) en 2D ou 3D. Ces primitives peuvent êtres colorées, texturées et animées.
De son coté, la bibliothèque Qt s'occupe de l'interaction avec l'utilisateur. Elle permet en effet de gérer, entre autres :
ceci de façon indépendante de la plate-forme.
Le présent tutoriel ne constitue qu'une brève présentation de ces deux bibliothèques (Qt et OpenGL). La meilleure méthode d'apprentissage est de lire chaque exemple en entier, puis compiler l'exemple et regarder ce qui se passe en interagissant avec le programme. Dans un troisième temps, il est intéressant de modifier les paramètres des fonctions présentées (couleurs, positions, touches appuyées,...) afin de voir ce qui change. Finalement vous pouvez essayer de refaire le même programme.
Concernant le plan de ce document :
Nous commencerons par un exemple simple de dessin direct d'OpenGL via Qt.
Cet exemple présentera les concepts de
qmake
: utilitaire pour générer les Makefile
s et compiler.Nous travaillerons ensuite sur la conception, la structure du programme, de sorte à séparer clairement les objets d'un coté et leur(s) dessin(s) de l'autre.
Cet exemple montrera aussi comment dessiner un cube.
Puis nous verrons comment dessiner plusieurs objets, les placer les uns par rapport aux autres.
Dans le quatrième exemple, nous ajouterons un « point de vue » que l'on peut déplacer.
Les exemples suivants (sauf le neuvième) proposent alors plusieurs extensions différentes à partir de ce quatrième exemple.
Le cinquième exemple étend le quatrième en ajoutant une gestion du mouvement et du temps.
Le sixième exemple étend le quatrième en remplaçant le dessin des cubes par celui de sphère .
Le septième exemple étend le quatrième en ajoutant des déplacement à la souris.
Le huitième exemple étend le quatrième en ajoutant des textures sur les faces des cubes.
Le neuvième et dernier exemple enfin repart de du tout premier exemple, simple, pour montrer comment faire du dessin en 2D.
Ceux qui veulent aller vite de façon minimale peuvent essayer de ne regarder que l'exemple 5.
Ceux qui veulent aller un peu moins vite peuvent suivre les exemples de 1 à 5.
Et ceux qui sont intéressés peuvent continuer avec les exemples 6 à 9.
La première chose qu'il faut bien comprendre quand on utilise une bibliothèque graphique telle que Qt, c'est qu'il ne s'agit pas d'un programme de dessin séquentiel (« dessine ceci », puis « dessine cela », etc.), mais de « programmation événementielle » (c.-à-d. « à base d'évènements ») où tout se passe dans une boucle infinie qui ne fait qu'attendre des évènements (clic de souris, touche au clavier, redimensionnement de la fenêtre ou tout simplement l'évènement « dessine ») et appelle les fonctions correspondant à ces évènements.
Une première étape consiste donc à programmer toutes ces fonctions que l'on veut associer à des évènements.
Une seconde étape consiste à initialiser tout ce qu'il faut (p.ex. la fenêtre de dessin, les boutons, barres, etc.)
Puis enfin (et seulement en fin !) on lance la fameuse boucle infinie. Ce n'est qu'une fois cette boucle lancée que l'on pourra voir quelque chose. De manière générale, la boucle se déroule de la manière suivante :
Ces étapes seront détaillées plus loin dans ce tutoriel.
Dans la panoplie d'outils disponibles dans la bibliothèque Qt, il existe un module dédié à la gestion des représentations 2D et 3D. Ce module utilise une autre bibliothèque : OpenGL, et c'est sur elle que nous allons maintenant nous focaliser.
NOTE : OpenGL n'est pas à strictement parler un composant de la bibliothèque Qt, mais une couche plus basse, indépendante, à laquelle on peut « parler » et que l'on peut incorporer dans les objets de Qt.
Pour comprendre les commandes graphiques que vous allez utiliser, la seconde chose qu'il faut bien comprendre sont les « matrices de point de vue ».
Ce sont des matrices « affines » (c.-à-d. 4x4 pour un espace vectoriel 3D plus une translation) représentant la composition des transformations géométriques ayant été effectuées jusque là. Toutes transformations géométriques sont en fait représentées par des matrices 4x4 (et les objets par des vecteurs 4D, un par sommets, comme expliqué plus loin).
ATTENTION ! Cela suppose aussi que l'on utilise l'ordre de composition des fonctions : appliquer f, puis appliquer g, c'est en fait faire g(f(x)). Ceci implique que si vous multipliez à droite la matrice courrante par une matrice de translation puis par une matrice de rotation séquentiellement dans le code, et bien à l'écran, c'est comme s'il y avait d'abord eu une rotation, puis seulement ensuite une translation ! Faites donc attention à l'ordre de vos commandes graphiques.
Dans ce tutoriel, les matrices seront représentées à l'aide de la classe QMatrix4x4
. Nous passerons les matrices à OpenGL plus précisément à ce que l'on appelle des « shaders ».
Pour dessiner rapidement, OpenGL permet d'utiliser « l'accélération 3D » des cartes graphiques modernes (« GPU » pour « Graphics Processing Unit ») . Un dessin rendu avec OpenGL sera fait plus rapidement si l'on utilise directement le GPU que si l'on utilise uniquement le processeur (« CPU » pour « Central Processing Unit »).
Pour utiliser le GPU dans OpenGL, il faut écrire le programme correspondant dans un langage de programmation spécifique, le « GLSL » pour « OpenGL Shading Language ». C'est un langage assez proche du C (ancêtre du C++), mais particulièrement adapté à l'architecture des "GPU". Un programme en GLSL s'appelle techniquement un « shader ».
Dans ce tutoriel, nous ne vous expliquerons bien sûr pas le langage GLSL (cela nous emmènerait beaucoup trop loin). Nous vous fournissons plutôt des « shaders » déjà faits, très basiques (une douzaine de lignes de code en tout) mais qui permettent quand même de dessiner un tas de choses, suffisantes pour ce cours.
Techniquement parlant, les « shaders » que nous vous fournissons
permettent de dessiner des primitives colorées (par exemple : un triangle
rouge ou un parallélogramme bleu) auxquelles on peut appliquer des
transformations géométriques comme par exemple une translation, une
rotation ou une homothétie en utilisant une QMatrix4x4
.
Tout ceci sera détaillé plus bas.
NOTE : Le chargement de shaders OpenGL peut être compliqué et n'est pas au programme. Nous utiliserons donc
Qt pour le faire à notre place via la classe QOpenGLShaderProgram
.
Qt et OpenGL sont installées sur les ordinateurs du CO. Si vous voulez utiliser ces bibliothèques chez vous, il faudra les installer.
Sous Linux : OpenGL est en général installé par
défaut et la bibliothèque Qt devrait se trouver dans les dépôts sous le nom qtbase5-dev
, ou alors qt-sdk
si vous voulez tout l'environnement de développement.
autres OS : OpenGL est normalement fourni avec votre compilateur. Qt est téléchargeable ici : Téléchargement Qt.
Pour compiler un programme avec les bibliothèques
Qt et OpenGL, il est nécessaire de donner
au compilateur des directives dans ce sens.
Qt utilise un mécanisme (et un langage) qui lui est propre pour générer ses Makefile
: qmake
.
Pour générer le(s) Makefile
, qmake
utilise un fichier « .pro
» que nous vous expliquerons plus loin. La procédure de compilation consiste donc à faire :
qmake fichier.pro
ou simplement
qmake
en général une seule fois (à moins de modifier le fichier « .pro
» ), puis
make
comme d'habitude à chaque fois que vous voulez recompiler.
Sous réserve de faire le fichier .pro
comme indiqué ci-dessus, vous pouvez bien sûr travailler dans n'importe quel environnement de développement, comme d'habiture (p.ex. Geany). Cependant, il existe un environnement de développment (« IDE ») particulièment adapté à Qt : Qt-Creator.
Si vous souhaitez passer à Qt Creator comme EDI pour le projet (et que vous ne l'avez pas utilisé au premier semestre), voyez:
Commençons par un premier exemple simple consistant à dessiner un triangle coloré, vu de face (on est en 3D)
NOTE : Vous pouvez télécharger ici le code de ce premier
exemple. Décompressez l'archive (par exemple avec unzip
). allez dans le répertoire ex_01
, tapez qmake
puis make
. Enfin, lancez l'exécutable en tapant ./ex_01
. Pour résumer :
unzip ex_01.zip cd ex_01 qmake make ./ex_01
Et si nécessaire, pour repartir depuis zéro, effacer tous les fichiers intermédiaires, faire : make distclean
Pour commencer notre programme, la première chose à faire est de construire une fenêtre permettant de dessiner de l'OpenGl.
NOTE : Qt est en fait une bibliothèque très riche qui permet de définir toutes sortes d'objets graphiques (boutons, règles, ascenseurs, ...) et peut tout à fait fonctionner sans OpenGL. On pourrait donc bien sûr ajouter plusieurs autres objets graphiques, mais ce n'est pas le propos du présent tutoriel.
Pour simplifier et éviter d'entrer dans les détails ici, nous vous avons écrit un programme main()
(voir le fichier main_qt_gl.cc
fourni) et une classe (GLWidget
) qui font cela pour vous. Il n'est pas nécessaire de comprendre le main()
, qui ne changera pas pour tout ce tutoriel, ni tous les détails de la classe GLWidget
, mais simplement quelques unes de ses parties, que nous expliquerons au fur et à mesure.
Cette classe (fichier glwidget.h
) :
#pragma once #include <QOpenGLWidget> // Classe pour faire une fenêtre OpenGL #include <QOpenGLShaderProgram> // Classe qui wrap les fonctions OpenGL liées aux shaders class GLWidget : public QOpenGLWidget /* La fenêtre hérite de QOpenGLWidget ; * les événements (clavier, souris, temps) sont des méthodes virtuelles à redéfinir. */ { public: GLWidget(QWidget* parent = nullptr) : QOpenGLWidget(parent) {} virtual ~GLWidget() {} private: // Les 3 méthodes clés de la classe QOpenGLWidget à réimplémenter virtual void initializeGL() override; virtual void resizeGL(int width, int height) override; virtual void paintGL() override; // Un shader OpenGL encapsulé dans une classe Qt QOpenGLShaderProgram prog; };
contient essentiellement 4 éléments :
un « shader » (expliqué plus haut) ;
et 3 méthodes pour gérer différents évènements :
initializeGL
pour ce qui doit être fait à l'initialisation de notre scène OpenGL ;
resizeGL
qui sera appelée si la fenêtre est redimensionnée ;
paintGL
qui sera appelée à chaque fois qu'il est nécessaire de (re)dessiner la scène. C'est donc bien là que nous allons écrire le code de notre dessin.
Commençons donc par la méthode de dessin : cela se fait en
ajoutant les lignes suivantes à la classe
GLWidget
(fichier glwidget.cc
, donc) :
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); /* Puis on crée une matrice identitée (constructeur par défaut) * que l'on multiplie à droite par une matrice de translation * avec comme vecteur de translation (0,0,-2). * Finalement, on envoie cette matrice au shader. * * Sur notre shader (voir les sources .glsl), il y a deux matrices * projection et vue_modele. * Les sommets dessinés seront multipliés par la matrice vue_modele * puis par projection avant d'être dessiné sur l'écran. */ QMatrix4x4 matrice; matrice.translate(0.0, 0.0, -2.0); prog.setUniformValue("vue_modele", matrice); // On dessine un joli triangle coloré glBegin(GL_TRIANGLES); prog.setAttributeValue(CouleurId, 1.0, 0.0, 0.0); // rouge prog.setAttributeValue(SommetId, 0.0, 0.0, 0.0); prog.setAttributeValue(CouleurId, 0.0, 1.0, 0.0); // vert prog.setAttributeValue(SommetId, 1.0, 0.0, 0.0); prog.setAttributeValue(CouleurId, 0.0, 0.0, 1.0); // bleu prog.setAttributeValue(SommetId, 1.0, 1.0, 0.0); glEnd(); }
La première ligne (glClear(...);
) permet simplement de repartir sur un nouveau dessin.
Les trois lignes suivantes « placent le point de vue » au point (0,0,2) en passant la matrice de la scène au shader (détaillé plus bas). En fait, on place la scène à dessiner en (0,0,-2). Il faut savoir que l'on regarde toujours depuis (0,0,0) dans la direction (0,0,-1). Ce sont les (sommets des) objets de la scène qui sont déplacés par les opérations géométriques représentées par les matrices 4x4.
Viennent ensuite les lignes qui font effectivement le dessin du triangle :
on commence pour cela par dire que l'on veut dessiner une « facette » triangle (c.-à-d. que les 3 points suivants seront reliés par des traits, c'est cette instruction « glBegin(GL_TRIANGLES);
» qui dit à OpenGL de relier entre-eux les 3 prochains points ; voir plus bas pour d'autres directives de dessin) ;
on dessine ensuite consécutivement trois points en indiquant d'abord la couleurs associée au point, puis seulement ensuite où se place le point ; il est important de terminer le dessin d'un point par l'instruction qui affecte (setAttributeValue
) le SommetId
;
CouleurId
) est spécifiée en indiquant trois nombres compris entre 0 et 1 correspondant respectivement au degré de rouge, de vert et de bleu ; ainsi
(1.0, 0.0, 0.0)
correspond à la couleur rouge,(0.0, 1.0, 0.0)
correspond à la couleur verte,(0.0, 0.0, 1.0)
correspond à la couleur bleue,(0.0, 0.0, 0.0)
correspond au noir,(1.0, 1.0, 1.0)
au blanc,(0.5, 0.5, 0.5)
à un gris à 50%,(0.75, 0.0, 0.75)
à un violet à 75% ;les cordonnées d'un point (identifié par SommetId
) sont dans l'ordre usuel (x,y,z); on a donc dans cet exemple mis le point de vue en (0,0,2) [voir plus haut] puis dessiné dans l'ordre un point rouge en (0,0,0), un point vert en (1,0,0) et un point bleu en (1,1,0) ;
chaque sommet a une position codée dans un vecteur à 4 composantes (la quatrième composantes est toujours fixée à 1 c'est pourquoi elle n'apparait pas dans le code) puis on multiplie la matrice de dimention 4x4 avec le vecteur pour obtenir la position finale du sommet ;
pourquoi en dimention 4 ?
la quatrième coordonnée sert à représenter les translations ; pour plus de détail voir les coordonnées homogènes
on termine enfin en indiquant à OpenGL que c'est ici que se termine le dessin du triangle (glEnd();
)
Voilà pour l'essentiel de cette méthode de dessin. Nous donnons encore ici quelques compléments techniques qui peuvent être sautés dans une première approche. Si cela ne vous intéresse pas, vous pouvez continuer ici.
Nous avons ci-dessus utilisé la directive OpenGL GL_TRIANGLES
. On peut aussi utiliser GL_QUADS
pour des quadrilatères (utile pour des cubes, par exemple) ou simplement GL_LINES
pour des lignes.
Ensuite, le code expliqué ci-dessus est assez simple. Il fonctionne car nous avons programmé pour vous un « shader » qui fonctionne avec simplement 2 paramètres : une couleur et un point. Ce shader est décomposé en 2 parties écrites dans les fichiers vertex_shader.glsl
et fragment_shader.glsl
fournis.
Le liens entre ces codes GLSL et notre programme C++ est fait, d'une part au travers du fichier vertex_shader.h
pour les définitions des identifiants de couleur (CouleurId
) et de point (SommetId
), et d'autre part au travers de l'attribut prog
(voir glwidget.h
) qui sera lié aux fichiers GLSL précédents dans la méthode d'initialisation initializeGL
.
La méthode resizeGL
ensuite :
* fenêtre OpenGL doit dessiner. * Ici on lui demande de dessiner sur toute la fenêtre. */ glViewport(0, 0, width, height); /* Puis on modifie la matrice de projection du shader. * Pour ce faire on crée une matrice identité (constructeur * par défaut), on la multiplie par la droite par une matrice * de perspective. * Plus de détail sur cette matrice * http://www.songho.ca/opengl/gl_projectionmatrix.html * Puis on upload la matrice sur le shader avec la méthode * setUniformValue */ QMatrix4x4 matrice; matrice.perspective(70.0, qreal(width) / qreal(height ? height : 1.0), 1e-3, 1e5); prog.setUniformValue("projection", matrice); } // ====================================================================== void GLWidget::paintGL()
Cette méthode est appelée lorsque la fenêtre est redimensionnée. Nous choisissons simplement ici de changer la perspective utilisée pour garder les proportions de la fenêtre (c.-à-d. si la fenêtre est agrandie dans une direction, nous agrandissons de même le dessin). Nous faisons ceci en recalculant la matrice de projection utilisée par le shader.
Vous n'aurez pas à changer cette méthode et pouvez donc la reprendre telle quelle sans en comprendre les détails.
La méthode initializeGL
, enfin, est très technique et ne devra pas non plus être modifiée. Vous pouvez donc
aussi ignorer ses détails.
Pour en expliquer l'essentiel :
void GLWidget::initializeGL() { /* Initialise notre vue OpenGL. * Dans cet exemple, nous créons et activons notre shader. * * En raison du contenu des fichiers *.glsl, le shader de cet exemple * NE permet QUE de dessiner des primitives colorées * (pas de textures, brouillard, reflets de la lumière ou autres). * * Il est séparé en deux parties VERTEX et FRAGMENT. * Le VERTEX : * - récupère pour chaque sommet des primitives de couleur (dans * l'attribut couleur) et de position (dans l'attribut sommet) * - multiplie l'attribut sommet par les matrices 'vue_modele' et * 'projection' et donne le résultat à OpenGL * - passe la couleur au shader FRAGMENT. * * Le FRAGMENT : * - applique la couleur qu'on lui donne */ prog.addShaderFromSourceFile(QOpenGLShader::Vertex, ":/vertex_shader.glsl"); prog.addShaderFromSourceFile(QOpenGLShader::Fragment, ":/fragment_shader.glsl"); /* Identifie les deux attributs du shader de cet exemple * (voir vertex_shader.glsl). * * L'attribut identifié par 0 est particulier, il permet d'envoyer un * nouveau "point" à OpenGL * * C'est pourquoi il devra obligatoirement être spécifié et en dernier * (après la couleur dans cet exemple, voir plus bas). */ prog.bindAttributeLocation("sommet", SommetId); prog.bindAttributeLocation("couleur", CouleurId); // Activation du shader prog.bind(); /* Activation du "Test de profondeur" et du "Back-face culling" * Le Test de profondeur permet de dessiner un objet à l'arrière-plan * partielement caché par d'autres objets. * * Le Back-face culling consiste à ne dessiner que les face avec ordre * de déclaration dans le sens trigonométrique. */ glEnable(GL_DEPTH_TEST); glEnable(GL_CULL_FACE); } // ====================================================================== void GLWidget::resizeGL(int width, int height)
nous ajoutons notre shader (en 2 parties) dans l'attribut prog
de notre instance de la classe GLWidget
;
nous associons les identifiants C++ choisis (CouleurId
et SommetId
) aux parties correspondantes du shader (lien entre le C++, dans le CPU, et le GLSL, dans le GPU) ;
puis nous compilons le code GLSL et l'envoyons sur le GPU ;
enfin, nous initialisons quelques comportements voulus pour le rendu OpenGL.
Voilà donc pour tout ce code, dont, encore une fois, il n'est pas nécessaire de comprendre les détails, beaucoup de choses vous étant fournies afin de faciliter la prise en main et la mise en œuvre du graphisme.
Pour compiler tout ceci, il faut d'abord générer le Makefile
, ce qui se fait en lançant la commande qmake
qui lit le fichier « .pro
» suivant :
# # Ce fichier est lu par le programme qmake pour générer le Makefile # QT += core gui opengl widgets contains(QT_VERSION, ^6\\..*) { QT += openglwidgets } QMAKE_CXXFLAGS += -std=c++17 win32:LIBS += -lopengl32 TARGET = ex_01 TEMPLATE = app SOURCES += \ main_qt_gl.cc \ glwidget.cc HEADERS += \ glwidget.h \ vertex_shader.h RESOURCES += \ resource.qrc
Là non plus, pas besoin de tout comprendre. Ce qu'il faut savoir c'est que :
La variable TARGET
doit contenir le nom de l'exécutable final (au choix) ;
SOURCES
contient la liste des fichiers C++ (« .cc
» ou « .cpp
») nécessaires ;
HEADERS
contient la liste des fichiers « .h
» utilisés ;
et RESOURCES
doit rester comme ceci. Il indique où se trouvent les shaders utilisés.
Le fichier resource.qrc
est fourni et n'aura pas à être modifié.
Dans les listes SOURCES
, HEADERS
et RESOURCES
, vous pouvez mettre
plusieurs fichiers sur une seule ligne en les séparant par un espace. Chaque ligne doit cependant être séparé
de la precedente par un "\", la dernière ne prend pas de backslash.
Si tout se passe bien (qmake && make && ./ex_01
) vous devriez voir un joli triangle coloré !
Notre exemple simple précédent nécessite déjà 8 fichiers :
le fichier main_qt_gl.cc
, qui contient le main()
[que vous pourrez reprendre à l'identique] ;
glwidget.h
, qui contient la description de la classe principale pour dessiner (fenêtre principale) ;
glwidget.cc
, qui contient les définitions des méthodes de cette classe (méthodes de dessin) ;
vertex_shader.h
, qui fait le lien entre les conventions du shader utilisées et celles de nos programmes C++ ;
vertex_shader.glsl
, première partie du shader , executée en parallèle une fois par sommet;
fragment_shader.glsl
, seconde partie du shader , executée aussi en parallèle mais cette fois une fois par pixel;
resource.qrc
, qui décrit à Qt les ressources utilisées, c'est à dire les fichiers à insérer dans l'executable (shader, dans notre cas) , c'est typiquement dans ce fichier qu'on rajouterait l'image de l'icone de l'application;
et enfin le fichier ex_01.pro
qui permet facilement de compiler tout ça grâce à qmake
.
C'est déjà pas mal ! Mais c'est un peu trop simple, pas assez modularisé, pour un projet comme le nôtre.
L'idée de cette partie du tutoriel est d'avoir une conception générale et modulaire pour tout projet graphique comportant d'un coté un système à simuler (et visualiser) et de l'autre une interface de visualisation.
Nous allons organiser notre application (notre programme final) suivant deux grands principes (techniquement on appelle cela des « design patterns ») :
clairement séparer trois choses : la gestion de notre application (le main()
si vous voulez, ou notre GLWidget
), le contenu à afficher et la façon de l'afficher ; cette dernière pouvant être sous différentes formes : soit format texte, soit écriture dans un fichier (non présenté dans ce tutoriel), soit affichage à l'écran, là aussi de diverses façons possibles ;
Voilà pour le programme de cette partie ! Comment faire ?
NOTE : Vous pouvez télécharger ici le code de ce deuxième exemple.
[ Nous vous expliquons ici la démarche suivie, mais vous pouvez simplement réutiliser l'essentiel du code fourni sans en comprendre tous les détails. ]
Commençons par séparer notre programme en plusieurs parties, 3 dans notre cas, mais cela pourrait être plus :
une partie générale qui contient tout le code indépendant d'un mode de visualisation particulier ; tout le « contenu » donc ;
et ensuite autant de parties que de façons de visualiser le contenu. Dans le cadre de ce tutoriel, nous n'en considérerons que deux : le format texte (sur cout
, comme d'habitude) et le format graphique avec Qt et OpenGL.
Nous créons donc 3 répertoires, par exemple : general
, Qt_GL
et text
. Commençons par mettre tout notre code précédent (les 8 fichiers) dans Qt_GL
et essayons d'ajouter une visualisation en mode texte de notre exemple.
Il faut pour cela conceptualiser, concevoir, un peu notre application : nous dirons que nous avons un Contenu
qui est Dessinable
.
Pour bien séparer les différents moyens de visualisation, tout Dessinable
a une méthode dessine_sur()
qui reçoit un SupportADessin
(par référence, forcément !), qui représente la façon choisie pour dessiner : mode texte sur écran, mode graphique avec telle bibliothèque, ou telle autre (Qt ici), dans un fichier, etc.
Nous créons donc les 3 classes suivantes (dans general
)
Dessinable
(dessinable.h
) :
#pragma once class SupportADessin; class Dessinable { public: virtual void dessine_sur(SupportADessin&) = 0; virtual ~Dessinable() = default; Dessinable(Dessinable const&) = default; Dessinable& operator=(Dessinable const&) = default; Dessinable(Dessinable&&) = default; Dessinable& operator=(Dessinable&&) = default; Dessinable() = default; };
SupportADessin
(support_a_dessin.h
) :
#pragma once class Contenu; class SupportADessin { public: virtual ~SupportADessin() = default; // on ne copie pas les Supports SupportADessin(SupportADessin const&) = delete; SupportADessin& operator=(SupportADessin const&) = delete; // mais on peut les déplacer SupportADessin(SupportADessin&&) = default; SupportADessin& operator=(SupportADessin&&) = default; SupportADessin() = default; virtual void dessine(Contenu const& a_dessiner) = 0; /* Mettre ici toutes les méthodes nécessaires pour dessiner tous les * objets que l'on veut dessiner. Par exemple : * virtual void dessine(Nounours const& a_dessiner) = 0; * virtual void dessine(Voiture const& a_dessiner) = 0; */ };
et Contenu
(contenu.h
) :
#pragma once #include "dessinable.h" #include "support_a_dessin.h" class Contenu : public Dessinable { public: virtual ~Contenu() = default; Contenu(Contenu const&) = default; Contenu& operator=(Contenu const&) = default; Contenu(Contenu&&) = default; Contenu& operator=(Contenu&&) = default; Contenu() = default; virtual void dessine_sur(SupportADessin& support) override { support.dessine(*this); } };
L'aspect technique dans ce code, qui dépasse un peu le niveau de ce cours, est la méthode dessine_sur()
de la classe Contenu
. TOUTES les classes dessinables devront avoir cette même méthode, exactement la même, pour pouvoir être correctement dessinées par le « support à dessin » correspondant.
(Techniquement il s'agit ici de ce que l'on appelle en programmation le « double dispatch », une généralisation du polymorphisme : nous avons ici un comportement polymorphique à 2 paramètres : ce qui doit être dessiné (le contenu) et ce sur quoi (ou comment) il doit être dessiné (le « support à dessin »)).
Nous avons fini pour cet exemple avec la partie générale, abstraite.
Passons à la visualisation en mode texte.
Nous allons pour cela créer le SupportADessin
correspondant. Appelons-le TextViewer
. L'idée est d'avoir ensuite simplement le main()
suivant (fichier main_text.cc
) :
#include <iostream> #include "text_viewer.h" #include "contenu.h" using namespace std; int main() { /* Nous voulons un support à dessin : * * ici un TextViewer qui écrit sur cout */ TextViewer ecran(cout); // Nous voulons un contenu à dessiner Contenu c; // Nous dessinons notre contenu sur notre support à dessin précédent c.dessine_sur(ecran); return 0; }
Pour généraliser un peu, imaginons que cette classe TextViewer
puisse « choisir » le flot dans lequel elle veut écrire. Nous lui ajoutons donc simplement une référence sur un flot et avons donc le code suivant (fichier text_viewer.h
) :
#pragma once #include <iostream> #include "support_a_dessin.h" class TextViewer : public SupportADessin { public: TextViewer(std::ostream& flot) : flot(flot) {} virtual ~TextViewer() = default; // on ne copie pas les TextViewer TextViewer(TextViewer const&) = delete; TextViewer& operator=(TextViewer const&) = delete; // mais on peut les déplacer TextViewer(TextViewer&&) = default; TextViewer& operator=(TextViewer&&) = default; virtual void dessine(Contenu const& a_dessiner) override; private: std::ostream& flot; };
L'aspect technique à comprendre ici est que nous devrons lui ajouter autant de méthodes dessine()
que nous avons de sortes objets différents (c.-à-d. de classes) à dessiner. Ici nous ne voulons dessiner qu'une seule sorte d'objets, les Contenu
s ; nous n'avons donc qu'une seule méthode dessine()
dans cette classe.
Il ne reste donc plus qu'à écrire comment dessiner notre contenu. Décidons pour cela de simplement « dessiner » un cube en mode texte en affichant simplement le message : « un cube ». Ce qui conduit au code suivant (fichier text_viewer.cc
) :
#include <iostream> // pour endl #include "text_viewer.h" #include "contenu.h" void TextViewer::dessine(Contenu const&) { /* Dans ce premier exemple très simple, on n'utilise * * pas l'argument Contenu. Nous ne l'avons donc pas nommé. */ flot << "un cube" << std::endl; }
Si ce n'est pas déjà fait, vous pouvez déjà compiler cette partie à l'aide des fichiers « .pro
» fournis (que nous ne détaillerons pas ici) : dans le répertoire ex_02
, faire simplement
qmake make ./text/ex_02_text
Passons maintenant à la partie graphique. Il suffit de réorganiser notre exemple précédent suivant la nouvelle conception. Nous devons pour cela séparer dans notre ancienne classe GLWidget
les parties « application principale », « contenu » et « support à dessin ».
Pour le contenu, c'est simple, c'est déjà fait (contenu.h
). Il suffit alors juste de dire que la classe GLWidget
a/possède un contenu à dessiner (fichier glwidget.h
, ligne 28) :
// objets à dessiner Contenu c; };
Pour le support à dessin, c'est presque aussi simple : c'est notre shader qui va jouer ce rôle. Il nous faut « simplement » l'habiller (l'encapsuler) en un SupportADessin
. Techniquement, nous ajoutons juste quelques méthodes-outils supplémentaires pour faciliter son utilisation, ce qui conduit au code suivant (fichier vue_opengl.h
) :
#pragma once #include <QOpenGLShaderProgram> // Classe qui regroupe les fonctions OpenGL liées aux shaders #include <QMatrix4x4> #include "support_a_dessin.h" class VueOpenGL : public SupportADessin { public: // méthode(s) de dessin (héritée(s) de SupportADessin) virtual void dessine(Contenu const& a_dessiner) override; // méthode d'initialisation void init(); // méthode set void setProjection(QMatrix4x4 const& projection) { prog.setUniformValue("projection", projection); } // méthode utilitaire offerte pour simplifier void dessineCube(QMatrix4x4 const& point_de_vue = QMatrix4x4() ); private: // Un shader OpenGL encapsulé dans une classe Qt QOpenGLShaderProgram prog; };
Il nous faut aussi :
dire que la classe GLWidget
a/possède maintenant une VueOpenGL
au lieu du shader directement (fichier glwidget.h
, ligne 25) :
// Vue : ce qu'il faut donner au contenu pour qu'il puisse se dessiner sur la vue VueOpenGL vue;
et déplacer le code nécessaire de GLWidget
vers VueOpenGL
(fichiers glwidget.cc
et vue_opengl.cc
).
Terminons cette partie en changeant de dessin pour notre contenu : dessinons maintenant un cube avec des faces colorées, au lieu d'un triangle.
Comme pour le support à dessin « mode texte », cela se fait dans la méthode dessine()
correspondant au support à dessin et à l'objet à dessiner voulus ; plus clairement ici : dans la méthode dessine(Contenu)
du support à dessin VueOpenGL
.
Nous décomposons pour cela le dessin en 2 parties :
le positionnement du point de vue (fichier vue_opengl.cc
) :
void VueOpenGL::dessine(Contenu const& a_dessiner) { Q_UNUSED(a_dessiner); // Dans cet exemple simple, on n'utilise pas le paramètre. /* Création d'une matrice pour modifier le point de vue. * * Cette matrice sera appliquée aux 8 sommets du cube. * * Au départ, la matrice est la matrice identité (constructeur par défaut). */ QMatrix4x4 matrice; // Multiplie la matrice à droite par une matrice de translation (0, 0, -4). matrice.translate(0.0, 0.0, -4.0); // Multiplie la matrice à droite par une matrice de rotation de 60 degrés autour de l'axe Y. matrice.rotate(60.0, 0.0, 1.0, 0.0); // Multiplie la matrice à droite par une matrice de rotation de 45 degrés autour de l'axe Z. matrice.rotate(45.0, 0.0, 0.0, 1.0); // Dessine le cube dessineCube(matrice); }
et le dessin du cube proprement dit (fichier vue_opengl.cc
) :
prog.setUniformValue("vue_modele", point_de_vue); glBegin(GL_QUADS); // face coté X = +1 prog.setAttributeValue(CouleurId, 1.0, 0.0, 0.0); // rouge prog.setAttributeValue(SommetId, +1.0, -1.0, -1.0); prog.setAttributeValue(SommetId, +1.0, +1.0, -1.0); prog.setAttributeValue(SommetId, +1.0, +1.0, +1.0); prog.setAttributeValue(SommetId, +1.0, -1.0, +1.0); // face coté X = -1 prog.setAttributeValue(CouleurId, 0.0, 1.0, 0.0); // vert prog.setAttributeValue(SommetId, -1.0, -1.0, -1.0); prog.setAttributeValue(SommetId, -1.0, -1.0, +1.0); prog.setAttributeValue(SommetId, -1.0, +1.0, +1.0); prog.setAttributeValue(SommetId, -1.0, +1.0, -1.0); // face coté Y = +1 prog.setAttributeValue(CouleurId, 0.0, 0.0, 1.0); // bleu prog.setAttributeValue(SommetId, -1.0, +1.0, -1.0); prog.setAttributeValue(SommetId, -1.0, +1.0, +1.0); prog.setAttributeValue(SommetId, +1.0, +1.0, +1.0); prog.setAttributeValue(SommetId, +1.0, +1.0, -1.0); // face coté Y = -1 prog.setAttributeValue(CouleurId, 0.0, 1.0, 1.0); // cyan prog.setAttributeValue(SommetId, -1.0, -1.0, -1.0); prog.setAttributeValue(SommetId, +1.0, -1.0, -1.0); prog.setAttributeValue(SommetId, +1.0, -1.0, +1.0); prog.setAttributeValue(SommetId, -1.0, -1.0, +1.0); // face coté Z = +1 prog.setAttributeValue(CouleurId, 1.0, 1.0, 0.0); // jaune prog.setAttributeValue(SommetId, -1.0, -1.0, +1.0); prog.setAttributeValue(SommetId, +1.0, -1.0, +1.0); prog.setAttributeValue(SommetId, +1.0, +1.0, +1.0); prog.setAttributeValue(SommetId, -1.0, +1.0, +1.0); // face coté Z = -1 prog.setAttributeValue(CouleurId, 1.0, 0.0, 1.0); // magenta prog.setAttributeValue(SommetId, -1.0, -1.0, -1.0); prog.setAttributeValue(SommetId, -1.0, +1.0, -1.0); prog.setAttributeValue(SommetId, +1.0, +1.0, -1.0); prog.setAttributeValue(SommetId, +1.0, -1.0, -1.0); glEnd(); }
Pour dessiner le cube, il faut procéder en plusieurs étapes. La
première consiste à découper ce cube en faces. En effet OpenGL ne
peut dessiner que des lignes, triangles ou quadrilatères. On va
donc découper le cube en six faces carrées (quadrilatères, donc ;
on utilise la directive GL_QUADS
). Chaque face est
ensuite dessinée en indiquant les coordonnées de chacun de ses
coins. On peut également au préalable spécifier une couleur pour la
face.
NOTE 1 : On pourrait également spécifier une couleur par sommet, ce qui donne de jolis dégradés , comme dans le cas de notre premier triangle. Il faut simplement savoir que lorsqu'on fixe une couleur, tout sera dessiné de cette couleur jusqu'au prochain changement de couleur.
NOTE 2 : Si l'on veut dessiner plusieurs quadrilatères à la suite, on n'a pas besoin d'appeler chaque fois glBegin()
et glEnd()
. Une fois au début et à la fin suffit. Il faut juste qu'il y ait le bon nombre de sommets (un multiple de 4).
Voilà pour ce second exemple ! Ouf !
Cela peu paraître un peu fastidieux, surtout pour un dessin aussi simple, mais nous avons maintenant le cadre pour aborder dessiner de plusieurs façons des contenus aussi compliqués que l'on veut : nous n'aurons plus à changer ce cadre mais simplement à le remplir.
Nous abordons maintenant le dessin de plusieurs objets car, comme évoqué dans l'introduction générale, cela est peu intuitif la première fois et nécessite quelques précautions.
NOTE : Vous pouvez télécharger ici le code de ce troisième exemple.
Comme nous l'avons vu, pour dessiner un objet, il faut connaître les coordonnées de ses sommets. Pour dessiner plusieurs objets, on pourrait calculer la position de chacun des sommets de chacun des objets « à la main ». Mais cela devient très vite fastidieux si les objets se déplacent ou si les objets ont des formes complexes. De plus, cela ne permet pas de créer du code générique. Nous pouvons gérer les positions des objets au moyen de matrices ou de « déplacements » (ce n'est pas le terme officiel, mais c'est plus intuitif).
Dans l'exemple du cube, nous avions donné les coordonnées des
sommets par rapport à l'origine. On pourrait donc imaginer
« déplacer l'origine » pour pouvoir dessiner un nouveau cube à un
autre endroit. Pour se faire, il existe trois méthodes de la classe QMatrix4x4
:
translated(x, y, z);
qui déplace l'origine vers le
point (x,y,z) ;rotated(angle, x, y, z)
qui fait tourner le
repère d'un certain angle autour du vecteur
(x, y, z) ;scaled(facteur)
qui dilate l'espace du facteur donné
(homothétie). NOTE : En OpenGL, les angles
sont définis en degrés. Ce qui n'est pas le cas des fonctions C++ de
cmath
, qui sont en radians. Il vous faudra donc convertir les angles de radians à
degrés et vice-versa si vous voulez utiliser les fonctions
trigonométriques de cmath
.
Passons à un exemple : ajoutons 2 petits cubes à notre dessin, l'un au dessus de notre premier cube (axe des y avec le choix de repère que nous avons pris au début de VueOpenGL::dessine()
) et l'autre à droite (axe des z avec notre rotation de repère) et tourné de 45 degrés.
Pour le premier, il suffit donc d'ajouter (à la méthode dessine()
de la classe VueOpenGL
; fichier vue_opengl.cc
) :
// Dessine le 2e cube matrice.translate(0.0, 1.5, 0.0); // on se déplace de 1.5 au dessus (axe y) matrice.scale(0.25); // on réduit le cube d'un facteur 0.25 (homothétie) dessineCube(matrice); // et on dessine le cube
(le cube est réduit d'un facteur 0.25 puis translaté en (0, 1.5, 0). Je vous rappelle que les matrices se composent comme les fonctions : l'opération qui est en réalité appliquée en premier est celle écrite en dernier. Ici ce n'est pas important puisque les deux opérations commutent, mais voyez la suite)
Pour le 3e, on procède de façon similaire :
// Dessine le 3e cube matrice.translate(0.0, 0.0, 1.5); // (3) puis on se déplace à droite (axe z) matrice.scale(0.25); // (2) puis on réduit la taille matrice.rotate(45.0, 0.0, 1.0, 0.0); // (1) on tourne de 45 degrés dessineCube(matrice);
mais si l'on fait directement de la sorte (essayez !!) : le 3e cube sera dessiné par rapport au second cube et non pas par rapport au premier et sera encore plus petit (0.25 x 0.25).
Pour faire ce que l'on veut (le placer par rapport au premier cube), il faut « revenir en arrière » après le dessin du 2e cube. Il arrive souvent, que l'on veuille revenir en arrière après un déplacement, un dessin. Ce qu'il faut faire pour cela, c'est simplement de sauvegarder la matrice courante au préalable. On peut ensuite se positionner (translation , rotation, ...) au point où l'on souhaite dessiner, puis revenir au point de départ en restaurant la matrice sauvegardée. Dans notre cas cela donne donc le code complet suivant (surlignée, la matrice de sauvegarde intermédiaire) :
// Dessine le cube dessineCube(matrice); QMatrix4x4 reference(matrice); // sauvegarde le point de vue de référence // Dessine le 2e cube matrice.translate(0.0, 1.5, 0.0); // on se déplace de 1.5 au dessus (axe y) matrice.scale(0.25); // on réduit le cube d'un facteur 0.25 (homothétie) dessineCube(matrice); // et on dessine le cube // Revient au point de référence matrice = reference; // Dessine le 3e cube matrice.translate(0.0, 0.0, 1.5); // (3) puis on se déplace à droite (axe z) matrice.scale(0.25); // (2) puis on réduit la taille matrice.rotate(45.0, 0.0, 1.0, 0.0); // (1) on tourne de 45 degrés dessineCube(matrice); }
Concernant l'ordre des opérations : si l'on inverse rotation et translation du 3e cube (essayez !!) : le système d'axes aura d'abord été tourné puis translaté et donc le 3e cube ne sera pas déplacé suivant l'axe z de notre repère mais suivant un axe à 45 degrés (c.-à-d. l'axe z=y).
Si l'on veut, on peut aussi modifier l'affichage texte de cet exemple. Cela ne cause aucune difficulté.
Nous allons maintenant nous intéresser aux évènements simples venant du clavier, par exemple à faire tourner la scène à l'aide des flèches du clavier. Nous continuons pour cela sur le code de l'exemple précédent.
NOTE : Vous pouvez télécharger ici le code de ce quatrième exemple.
Comme toujours, pour faire une nouvelle tâche, il faut prévoir la fonction/méthode correspondante. Ici, les prototypes des méthodes permettant de gérer les évènements sont imposés par la bibliothèque.
Ajoutons donc la méthode prévue pour gérer les évènements clavier (fichier glwidget.h
) :
// Méthodes de gestion d'évènements virtual void keyPressEvent(QKeyEvent* event) override;
Il nous faut maintenant définir cette méthode (fichier glwidget.cc
).
Il faut donc, avant cela,
savoir CE que l'on veut faire...
Disons donc que nous voulons déplacer le point de vue comme dans les « jeux à la 1ère personne » :
les « flèches » tournent l'observateur sur lui-même
les touches 'W' et 'S' ou 'Page UP et 'Page Down' font avancer/reculer l'observateur
les touches 'A' et 'D' déplacent l'observateur à droite/gauche
les touches 'R' et 'F' déplacent l'observateur vers le haut/bas
les touches 'Q' et 'E' inclinent la tête de l'observateur vers la droite/gauche
enfin ajoutons que la touche 'Home' réinitialise la vue au point de départ.
Pour cela on commence par définir les deux constantes fixant la taille « d'un pas » et d'une rotation élémentaire :
void GLWidget::keyPressEvent(QKeyEvent* event) { constexpr double petit_angle(5.0); // en degrés constexpr double petit_pas(1.0);
Ensuite, il faut récupérer la touche
tapée au clavier via la méthode event->key()
. Il n'y a alors plus
qu'à traiter les différents cas. Utilisons pour cela l'approche modulaire : nous allons créer
de nouvelles méthodes dans le « support à dessin » qui feront tourner/déplacer/réinitaliser la caméra.
Si l'on suppose que de telles méthode existent (et s'appellent
rotate
, translate
et initializePosition
), alors le code de la méthode de gestion des évènements
clavier devient simplement :
void GLWidget::keyPressEvent(QKeyEvent* event) { constexpr double petit_angle(5.0); // en degrés constexpr double petit_pas(1.0); switch (event->key()) { case Qt::Key_Left: vue.rotate(petit_angle, 0.0, -1.0, 0.0); break; case Qt::Key_Right: vue.rotate(petit_angle, 0.0, +1.0, 0.0); break; case Qt::Key_Up: vue.rotate(petit_angle, -1.0, 0.0, 0.0); break; case Qt::Key_Down: vue.rotate(petit_angle, +1.0, 0.0, 0.0); break; case Qt::Key_PageUp: case Qt::Key_W: vue.translate(0.0, 0.0, petit_pas); break; case Qt::Key_PageDown: case Qt::Key_S: vue.translate(0.0, 0.0, -petit_pas); break; case Qt::Key_A: vue.translate( petit_pas, 0.0, 0.0); break; case Qt::Key_D: vue.translate(-petit_pas, 0.0, 0.0); break; case Qt::Key_R: vue.translate(0.0, -petit_pas, 0.0); break; case Qt::Key_F: vue.translate(0.0, petit_pas, 0.0); break; case Qt::Key_Q: vue.rotate(petit_angle, 0.0, 0.0, -1.0); break; case Qt::Key_E: vue.rotate(petit_angle, 0.0, 0.0, +1.0); break; case Qt::Key_Home: vue.initializePosition(); break; }; update(); // redessine }
Techniquement, il faut simplement ne pas oublier d'appeler la méthode qui redessine la scène : update()
(dernière ligne de la méthode)
Reste donc à écrire les méthodes VueOpenGL::rotate
, VueOpenGL::translate
et VueOpenGL::initializePosition
(fichier vue_opengl.cc
; pensez aussi à les
prototyper dans la classe, fichier vue_opengl.h
).
Pour cela, nous ajoutons d'abord une QMatrice4x4
comme attribut à la VueOpenGL
afin de mémoriser le point de vue courant, puisqu'il va changer (fichier vue_opengl.h
) :
// Caméra QMatrix4x4 matrice_vue;
ce qui nous permet donc de définir facilement les méthodes en question :
void VueOpenGL::initializePosition() { // position initiale matrice_vue.setToIdentity(); matrice_vue.translate(0.0, 0.0, -4.0); matrice_vue.rotate(60.0, 0.0, 1.0, 0.0); matrice_vue.rotate(45.0, 0.0, 0.0, 1.0); } // ====================================================================== void VueOpenGL::translate(double x, double y, double z) { /* Multiplie la matrice de vue par LA GAUCHE. * Cela fait en sorte que la dernière modification apportée * à la matrice soit appliquée en dernier (composition de fonctions). */ QMatrix4x4 translation_supplementaire; translation_supplementaire.translate(x, y, z); matrice_vue = translation_supplementaire * matrice_vue; } // ====================================================================== void VueOpenGL::rotate(double angle, double dir_x, double dir_y, double dir_z) { // Multiplie la matrice de vue par LA GAUCHE QMatrix4x4 rotation_supplementaire; rotation_supplementaire.rotate(angle, dir_x, dir_y, dir_z); matrice_vue = rotation_supplementaire * matrice_vue; }
[ la méthode initializePosition
correspond simplement au code que nous avions mis au début de la méthode dessine()
dans l'exemple précédent. ]
Reste encore à réaménager un peu le reste du code :
La méthode dessine()
devient plus simple puisque l'initialisation a été déplacée et qu'il n'y a plus besoin de mémoriser le point de vue puisque c'est fait dans l'attribut matrice_vue
:
void VueOpenGL::dessine(Contenu const& a_dessiner) { Q_UNUSED(a_dessiner); // dans cet exemple simple on n'utilise pas le paramètre // Dessine le 1er cube (à l'origine) dessineCube(); QMatrix4x4 matrice; // Dessine le 2e cube matrice.translate(0.0, 1.5, 0.0); matrice.scale(0.25); dessineCube(matrice); // Dessine le 3e cube matrice.setToIdentity(); matrice.translate(0.0, 0.0, 1.5); matrice.scale(0.25); matrice.rotate(45.0, 0.0, 1.0, 0.0); dessineCube(matrice); }
nous ajoutons un appel à la méthode initializePosition
en fin de VueOpenGL::init
et nous intégrons la matrice de point de vue (matrice_vue
) dans le dessin des cubes (méthode VueOpenGL::dessineCube
) :
void VueOpenGL::dessineCube (QMatrix4x4 const& point_de_vue) { prog.setUniformValue("vue_modele", matrice_vue * point_de_vue);
Et voilà !! Vous pouvez maintenant déplacer le point de vue (et enfin aller voir les faces cachées de nos cubes).
A partir de cet exemple nous allons présenter différents ajouts indépendants :
Le cinquième exemple ajouter une gestion du mouvement des objets et du temps.
Le sixième exemple présente comment dessiner des sphères.
Le septième exemple gère les déplacement à la souris.
Le huitième exemple montre comment mettre des textures sur les faces des cubes.
Le terme « temps réel » représente le fait que le temps (physique)
qui s'écoule a
une signification dans le programme. Jusqu'ici dans vos
programmes, l'utilisateur
pouvait attendre 1 ou 10 minutes à l'invite d'un
« cin
» sans que cela ne change en rien le
comportement du programme. Dans un processus « temps réel », le
programme continue par contre de s'exécuter, que
l'utilisateur agisse ou non. Ceci permet par exemple d'animer
de façon réaliste les éléments du monde que l'on représente.
Considérons le cas d'une balle qu'on lâche depuis une certaine hauteur. On pourrait, comme dans l'exercice que vous avez fait au premier semestre, calculer à l'avance le temps au bout duquel la balle touchera le sol. Mais dans une simulation physique en temps réel, on voudrait avoir la position de la balle à chaque instant, par exemple pour pouvoir l'afficher.
On doit donc pouvoir être capable de décrire à chaque instant
la nouvelle position de la balle en fonction de la position
précédente et du temps dt
écoulé entre deux calculs.
Ce temps est simplement le temps que l'ordinateur a mis pour
calculer et afficher la dernière position.
Dans une simulation numérique non temps réel, cet intervalle
dt
est fixé à une valeur arbitraire, aussi petite
que la précision de calcul voulue le nécessite (voir cours
d'analyse numérique).
Dans un programme « temps réel », c'est par contre la puissance
de la machine qui détermine la valeur de dt
: plus
la scène est complexe à animer et afficher, plus dt
sera grand, et plus la simulation sera approximative et
l'animation saccadée.
NOTE : La raison pour laquelle on ne fixe
pas à l'avance l'intervalle dt
est qu'on a a
priori aucune idée du temps que prendra le calcul (et
l'affichage !) d'une image et, surtout, qu'on n'a aucune
garantie que ce temps restera constant : plus il y a
d'éléments à prendre en compte, plus ce temps augmentera. On
s'en rend bien compte dans certains jeux vidéos :
lorsqu'il y a un phénomène complexe (p.ex. une explosion) ou
trop d'unités à gérer, c'est le nombre d'images par seconde
qui diminue et non le temps qui se dilate.
Concrètement, dt
est donné par un « timer »,
qui est tout simplement une « minuterie interne » de la
bibliothèque Qt. Nous utiliserons également un « chronomètre »
pour faire correspondre les temps du timer au temps physique réel.
La simulation est donc une boucle qui répète en permanence plusieurs étapes, parmi lesquelles :
calcul (ou mise à jour) : on détermine l'état suivant du
système, à partir de l'état courant et du pas de temps
dt
; c'est dans cette phase que dans votre projet interviendront les
équations de la simulation ;
affichage à l'écran (ou sur tout autre support à dessin) : on envoie les données vers la carte vidéo (ou sur cin
ou dans un
fichier, etc.) ;
En théorie, aucun calcul concernant la simulation n'est à effectuer dans ces deux dernières phases.
NOTE : dans Qt, comme dans beaucoup d'autres bibliothèques similaires, le programme est « averti » qu'il doit procéder à l'une ou l'autre étape : c'est ce que l'on appelle de la « programmation événementielle » (c.-à-d. « à base d'« évènements »).
Enfin, lorsqu'une certaine condition d'arrêt est atteinte (p.ex. un certain délai dépassé, une précision suffisante ou un évènement particulier [p.ex. clavier]), on arrête simplement le programme.
Passons maintenant à un exemple.
Le but est donc d'introduire une évolution « temps réel » dans notre exemple précédent. Nous proposons pour cela de faire tourner (simplement, de façon proportionnelle au temps (c.-à-d. mouvement circulaire uniforme)) un quatrième cube autour des trois autres précédemment dessinés.
NOTE : Vous pouvez télécharger ici le code de ce cinquième exemple.
La première chose à faire est donc de prévoir l'élément qui va
se déplacer, c'est-à-dire écrire l'équation d'évolution du
contenu. Nous allons donc devoir ajouter un nouveau fichier
contenu.cc
. Nous n'en avions en effet pas besoin
jusqu'ici car notre classe Contenu
n'était jusque là qu'une « coquille vide »,
sans vraiment de code ; ce qui n'est bien sûr pas très réaliste...
Prenons un modèle simple pour faire évoluer le contenu : un attribut angle
, son accesseur
(appelé infos()
ici) et une méthode evolue()
pour faire évoluer le contenu d'un pas de temps dt
(fichier contenu.h
) :
#pragma once #include "dessinable.h" #include "support_a_dessin.h" class Contenu : public Dessinable { public: Contenu() : angle(0.0) {} virtual ~Contenu() = default; Contenu(Contenu const&) = default; Contenu& operator=(Contenu const&) = default; Contenu(Contenu&&) = default; Contenu& operator=(Contenu&&) = default; virtual void dessine_sur(SupportADessin& support) override { support.dessine(*this); } void evolue(double dt); // accesseur double infos() const { return angle; } private: double angle; /* pour le mouvement ; dans cet exemple, juste une rotation au cours du temps */ };
Pour l'équation d'évolution, nous prenons un modèle très simple (rotation uniforme, fichier contenu.cc
) :
#include "contenu.h" // ====================================================================== void Contenu::evolue(double dt) { constexpr double omega(100.0); angle += omega * dt; }
Le fait d'introduire du code dans le répertoire general
nous oblige par contre à changer tous les fichiers .pro
et d'en ajouter un, general.pro
,dans le répertoire general
. Le but n'est pas de comprendre ici ce dont il s'agit, utilisez simplement les exemples fournis. Il suffira plus tard simplement d'ajouter vos SOURCES
et HEADERS
aux fichiers « .pro
» correspondants.
Nous avons donc maintenant tout ce qu'il faut pour la simulation. Commençons simplement par sa visualisation texte avec simplement 2 pas de calcul (fichier main_text.cc
) :
#include <iostream> #include "text_viewer.h" #include "contenu.h" using namespace std; int main() { TextViewer ecran(cout); Contenu c; cout << "Au départ :" << endl; c.dessine_sur(ecran); c.evolue(0.1); cout << "Après un pas de calcul :" << endl; c.dessine_sur(ecran); c.evolue(0.1); cout << "Après deux pas de calcul :" << endl; c.dessine_sur(ecran); return 0; }
Il nous faut aussi changer la méthode de visualisation du contenu, c'est-à-dire la méthode TextViewer::dessine(Contenu)
(fichier main_text.cc
) :
#include <iostream> // pour endl #include "text_viewer.h" #include "contenu.h" void TextViewer::dessine(Contenu const& a_dessiner) { flot << a_dessiner.infos() << std::endl; }
Si nécessaire, compilez et exécutez la version texte pour voir comment cela fonctionne.
Passons maintenant à la version graphique, qui est un peu plus compliquée.
Nous commençons par ajouter un « timer » et un chronomètre à la classe GLWidget
(fichier glwidget.h
) :
#pragma once #include <QOpenGLWidget> // Classe pour faire une fenêtre OpenGL #include <QElapsedTimer> // Classe pour gérer le temps #include "vue_opengl.h" #include "contenu.h" class GLWidget : public QOpenGLWidget /* La fenêtre hérite de QOpenGLWidget ; * les événements (clavier, souris, temps) sont des méthodes virtuelles à redéfinir. */ { public: GLWidget(QWidget* parent = nullptr) : QOpenGLWidget(parent) { chronometre.restart(); } virtual ~GLWidget() = default; private: // Les 3 méthodes clés de la classe QOpenGLWidget à réimplémenter virtual void initializeGL() override; virtual void resizeGL(int width, int height) override; virtual void paintGL() override; // Méthodes de gestion d'évènements virtual void keyPressEvent(QKeyEvent* event) override; virtual void timerEvent(QTimerEvent* event) override; // Méthodes de gestion interne void pause(); // Vue : ce qu'il faut donner au contenu pour qu'il puisse se dessiner sur la vue VueOpenGL vue; // Timer int timerId; // pour faire évoluer les objets avec le bon "dt" QElapsedTimer chronometre; // objets à dessiner, faire évoluer Contenu c; };
et
(fichier glwidget.cc
) :
void GLWidget::initializeGL() { vue.init(); timerId = startTimer(20); }
void GLWidget::timerEvent(QTimerEvent* event) { Q_UNUSED(event); const double dt = chronometre.elapsed() / 1000.0; chronometre.restart(); c.evolue(dt); update(); }
où l'on voit bien que dans la méthode associée au « timer » nous appelons la méthode d'évolution du contenu.
Reste maintenant à dessiner le cube qui bouge. Cela se fait bien sûr dans la méthode de dessin du contenu, c.-à-d. dans
la méthode VueOpenGL::dessine(Contenu)
(fichier vue_opengl.cc
) :
void VueOpenGL::dessine(Contenu const& a_dessiner) { // Dessine le 1er cube (à l'origine) dessineCube(); QMatrix4x4 matrice; // Dessine le 2e cube matrice.translate(0.0, 1.5, 0.0); matrice.scale(0.25); dessineCube(matrice); // Dessine le 3e cube matrice.setToIdentity(); matrice.translate(0.0, 0.0, 1.5); matrice.scale(0.25); matrice.rotate(45.0, 0.0, 1.0, 0.0); dessineCube(matrice); // Dessine le 4e cube matrice.setToIdentity(); matrice.rotate(a_dessiner.infos(), 1.0, 0.0, 0.0); matrice.translate(0.0, 2.3, 0.0); matrice.scale(0.2); dessineCube(matrice); }
Notez bien qu'ici la rotation est écrite avant (et donc se fait après) la translation : c'est bien les axes que nous voulons tourner ici, et non pas le cube sur lui-même (ce serait simplement un autre choix : essayez !).
Je vous propose pour finir sur ce sujet de rajouter une touche pour faire une pause dans la simulation. Comment faire ?
Il suffit pour cela de suspendre le « timer » à chaque fois qu'une
pause a été demandée (et de le reprendre lors de la prochaine
frappe au clavier). Il faut bien sûr associer, dans la fonction
de gestion des évènements clavier, une touche (par exemple la
barre espace ici) à cette action
(fichier glwidget.cc
) :
case Qt::Key_Home: vue.initializePosition(); break; case Qt::Key_Space: pause(); break; }; update(); // redessine
// ====================================================================== void GLWidget::pause() { if (timerId == 0) { // dans ce cas le timer ne tourne pas alors on le lance timerId = startTimer(20); chronometre.restart(); } else { // le timer tourne alors on l'arrête killTimer(timerId); timerId = 0; } }
Ceci conclu l'ajout d'une simulation « temps réel » dans notre environnement graphique, ainsi que la partie principale de ce tutoriel.
Le reste est constitué de diverses extensions telles que : dessiner des sphères, gérer les mouvements de la souris ou encore ajouter des textures.
Nous n'avons jusqu'ici dessiné qu'un triangle et des cubes. Dans cette section, nous allons voir comment dessiner des sphères et accessoirement un système d'axes (ce qui peut s'avérer très utile pour comprendre le
fonctionnement des transformations que l'on applique).
Nous repartons pour cela de l'exemple 4.
NOTE : Vous pouvez télécharger ici le code de ce sixième exemple.
Commençons par dessiner un système d'axes, avec, par exemple, au choix un dessin tout en blanc ou alors coloré : rouge pour x, vert pour y et bleu pour z.
Cela se fait simplement en ajoutant tout d'abord une méthode dans VueOpenGL
, dans le
fichier vue_opengl.h
:
// méthodes utilitaires offertes pour simplifier void dessineAxes(QMatrix4x4 const& point_de_vue, bool en_couleur = true); void dessineCube(QMatrix4x4 const& point_de_vue = QMatrix4x4() );
puis en la définissant dans vue_opengl.cc
:
glBegin(GL_LINES); // axe X if (en_couleur) { prog.setAttributeValue(CouleurId, 1.0, 0.0, 0.0); // rouge } else { prog.setAttributeValue(CouleurId, 1.0, 1.0, 1.0); // blanc } prog.setAttributeValue(SommetId, 0.0, 0.0, 0.0); prog.setAttributeValue(SommetId, 1.0, 0.0, 0.0); // axe Y if (en_couleur) prog.setAttributeValue(CouleurId, 0.0, 1.0, 0.0); // vert prog.setAttributeValue(SommetId, 0.0, 0.0, 0.0); prog.setAttributeValue(SommetId, 0.0, 1.0, 0.0); // axe Z if (en_couleur) prog.setAttributeValue(CouleurId, 0.0, 0.0, 1.0); // bleu prog.setAttributeValue(SommetId, 0.0, 0.0, 0.0); prog.setAttributeValue(SommetId, 0.0, 0.0, 1.0); glEnd(); }
Nous utilisons ici simplement la directive GL_LINES
pour ne dessiner que des segments de droites.
Dessiner une sphère est par contre plus compliqué (d'où cette section !). Il n'existe en effet pas de méthode OpenGL toute faite pour cela et il faut donc calculer le maillage de points nous-mêmes.
Nous allons pour cela créer une nouvelle classe GlSphere
qui contiendra donc le modèle de sphère que nous souhaitons dessiner. Nous calculons ce modèles (ensemble de points-supports) une fois pour toutes lors de l'initialisation, puis nous utiliserons ce modèle (auquel on appliquera translations, homothéties et rotations) à chaque fois que nous voudrons dessiner une nouvelle sphère.
Commençons donc par la classe GlSphere
(fichier glsphere.h
) :
#pragma once #include <QOpenGLBuffer> #include <QOpenGLShaderProgram> class GLSphere { public: GLSphere() : vbo(QOpenGLBuffer::VertexBuffer), ibo(QOpenGLBuffer::IndexBuffer) {} void initialize(GLuint slices = 25, GLuint stacks = 25); void draw(QOpenGLShaderProgram& program, int attributeLocation); void bind(); void release(); private: QOpenGLBuffer vbo, ibo; GLuint vbo_sz; GLuint ibo_sz[3]; };
Le but n'est pas du tout d'expliquer les détails ici, mais simplement de fournir du code utilisable. Dans les grandes lignes, cette classe contient (sa partie privée) le tableau des points-supports utilisés pour dessiner une sphère.
Nous décidons de plus d'avoir :
une méthode initialize
pour son initialisation ; cette méthode prend le nombre de «tranches» que l'on veut utiliser pour représenter la sphère, dans le sens des longitudes puis des latitudes ; les valeurs par défaut sont de 25 tranches ;
une méthode draw
pour dessiner la sphère (avec un « shader » donné) ;
et deux méthodes bind
et release
, pas nécessaires pour nos exemples simples.
Le
fichier glsphere.cc
implémente ensuite ces différentes méthodes.
Il faut également penser à rajouter ces deux fichiers au « .pro » :
QT += core gui opengl widgets contains(QT_VERSION, ^6\\..*) { QT += openglwidgets } QMAKE_CXXFLAGS += -std=c++17 win32:LIBS += -lopengl32 TARGET = ex_06_gl INCLUDEPATH = ../general SOURCES += \ main_qt_gl.cc \ glwidget.cc \ glsphere.cc \ vue_opengl.cc HEADERS += \ glwidget.h \ vertex_shader.h \ vue_opengl.h \ glsphere.h \ ../general/dessinable.h \ ../general/support_a_dessin.h \ ../general/contenu.h RESOURCES += \ resource.qrc
Voyons maintenant comment utiliser ce modèle de sphère. Tout d'abord nous ajoutons une GLSphere
comme attribut à la classe VueOpenGL
(fichier vue_opengl.h
) :
private: // Un shader OpenGL encapsulé dans une classe Qt QOpenGLShaderProgram prog; GLSphere sphere;
Il faut, bien sûr, penser à l'initialiser
(méthode init
, fichier vue_opengl.cc
) :
sphere.initialize(); // initialise la sphère initializePosition(); } // ====================================================================== void VueOpenGL::initializePosition()
Puis ajoutons, par exemple, une méthode dessineSphere()
(fichier vue_opengl.h
) :
// méthodes utilitaires offertes pour simplifier void dessineAxes(QMatrix4x4 const& point_de_vue, bool en_couleur = true); void dessineCube(QMatrix4x4 const& point_de_vue = QMatrix4x4() ); void dessineSphere(QMatrix4x4 const& point_de_vue, double rouge = 1.0, double vert = 1.0, double bleu = 1.0);
que nous définissons tout simplement ainsi
(fichier vue_opengl.cc
) :
prog.setUniformValue("vue_modele", matrice_vue * point_de_vue); prog.setAttributeValue(CouleurId, rouge, vert, bleu); // met la couleur sphere.draw(prog, SommetId); // dessine la sphère } // ====================================================================== void VueOpenGL::dessineAxes (QMatrix4x4 const& point_de_vue, bool en_couleur)
Reste à l'utiliser (méthode dessine()
de VueOpenGL
).
Décidons par exemple de dessiner :
les axes du repère principal ;
une sphère en mode « fil de fer », bleue de rayon 1.0, centrée en (-0.5, 0, -2), avec ses axes propres dessinés en blanc ;
une sphère jaune de rayon 0.5 centrée en (1, 0, -2) et tournée de -30 degrés par rapport à l'axe Oy puis de -30 degrés par rapport à 0x ;
une sphère rouge, remplie, de rayon 0.125 centrée en (0, 0, -2).
Ce qui nous donne
(fichier vue_opengl.cc
) :
void VueOpenGL::dessine(Contenu const& a_dessiner) { Q_UNUSED(a_dessiner); // dans cet exemple simple on n'utilise pas le paramètre glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // efface l'écran QMatrix4x4 matrice; dessineAxes(matrice); // dessine le repère principal matrice.translate(-0.5, 0.0, -2.0); glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); // passe en mode "fil de fer" dessineSphere(matrice, 0.0, 0.0); // bleu matrice.scale(1.5); // taille des axes (1.5 pour qu'ils dépassent un peu) dessineAxes(matrice, false); // dessine (en blanc) les axes de la sphere matrice.setToIdentity(); matrice.translate(1.0, 0.0, -2.0); matrice.scale(0.5); matrice.rotate(-30, 0.0, 1.0, 0.0); matrice.rotate(-30, 1.0, 0.0, 0.0); dessineSphere(matrice, 1.0, 1.0, 0.0); // jaune matrice.scale(1.5); dessineAxes(matrice); // dessine (en couleur) les axes de la sphere glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); // repasse en mode "plein" matrice.setToIdentity(); matrice.translate(0.0, 0.0, -2.0); matrice.scale(0.125); dessineSphere(matrice, 1.0, 0.0, 0.0); // rouge }
Nous avions ajouté la gestion d'« évènements clavier » pour déplacer le point de vue. On peut aussi gérer des « évènements souris ». Nous repartons pour cela de l'exemple 4.
NOTE : Vous pouvez télécharger ici le code de ce septième exemple.
Pour gérer des « évènements souris », il suffit d'ajouter deux méthodes
(fichier glwidget.h
) :
virtual void keyPressEvent(QKeyEvent* event) override; virtual void mousePressEvent(QMouseEvent* event) override; virtual void mouseMoveEvent(QMouseEvent* event) override;
Nous allons de plus ici ajouter un attribut pour se souvenir de la position de la souris
(fichier glwidget.h
) :
QPoint lastMousePosition; };
Pour la méthode mousePressEvent
, qui est appelée lorsqu'un bouton de souris est enfoncé, nous allons simplement mémoriser la position de la souris :
(fichier glwidget.cc
) :
void GLWidget::mousePressEvent(QMouseEvent* event) { lastMousePosition = event->pos(); }
Pour la méthode mouseMoveEvent
, appelée lorsque la souris est déplacée (avec un bouton enfoncé, voir le commentaire dans le code), nous allons effectuer une rotation de la scène que si le bouton gauche est maintenu enfoncé, puis il ne faut pas oublier de lancer la méthode update()
pour mettre à jour l'affichage :
(fichier glwidget.cc
) :
void GLWidget::mouseMoveEvent(QMouseEvent* event) { /* If mouse tracking is disabled (the default), the widget only receives * mouse move events when at least one mouse button is pressed while the * mouse is being moved. * * Pour activer le "mouse tracking" if faut lancer setMouseTracking(true) * par exemple dans le constructeur de cette classe. */ if (event->buttons() & Qt::LeftButton) { constexpr double petit_angle(.4); // en degrés // Récupère le mouvement relatif par rapport à la dernière position de la souris QPointF d = event->pos() - lastMousePosition; lastMousePosition = event->pos(); vue.rotate(petit_angle * d.manhattanLength(), d.y(), d.x(), 0); update(); } }
Et voilà ! Vous pouvez déplacer la scène en bougeant la souris lorsque le bouton gauche est enfoncé.
Dans cette section, nous allons voir comment plaquer des images (« textures ») sur les objets dessinés.
Nous repartons pour cela de l'exemple 4.
NOTE : Vous pouvez télécharger ici le code de ce huitième exemple.
TOUTE CETTE SECTION EST A REPRENDRE. Je vous laisse regarder directement le code source dans le zip fourni.La toute première chose pour mettre des textures est bien sûr d'avoir des images. Ici nous utiliserons une image de chat, cat.jpeg
, et un zoom sur une partie de l'ensemble de Mandelbrot mandelbrot.jpeg
.
Pour que ces images soient inclues dans l'exécutable, nous ajoutons le nom de ces fichiers à notre fichier ressource.qrc
.
Nous devons ensuite modifier notre « shader » (fichiers vertex_shader.glsl
, vertex_shader.h
et fragment_shader.glsl
), de sorte à remplacer la couleur par une texture. Remarquez que l'enum CouleurId
a été remplacé par CoordonneeTextureId
.
Il faut aussi le faire dans l'initialisation du shader
(fichier vue_opengl.cc
) :
textureDeChat = std::make_unique<QOpenGLTexture>(QImage(":/cat.jpeg"));
Dans cette initialisation, nous devons également allouer les ressources pour charger les textures dans OpenGL (sur la carte graphique s'il y en a une).
(fichier vue_opengl.cc
) :
// position initiale matrice_vue.setToIdentity(); matrice_vue.translate(0.0, 0.0, -4.0); matrice_vue.rotate(60.0, 0.0, 1.0, 0.0); matrice_vue.rotate(45.0, 0.0, 0.0, 1.0); } // ====================================================================== void VueOpenGL::translate(double x, double y, double z) { /* Multiplie la matrice de vue par LA GAUCHE. * Cela fait en sorte que la dernière modification apportée
Nous aurons donc pris soin d'ajouter comme attributs de quoi identifier nos textures (simples numéros qui identifient l'image)
(fichier vue_opengl.h
) :
// Textures utilisées std::unique_ptr<QOpenGLTexture> textureDeChat; std::unique_ptr<QOpenGLTexture> textureFractale;
Lors du dessin du cube, nous devons activer les textures puis les attacher à chaque coin de chaque face
(fichier vue_opengl.cc
) :
prog.setAttributeValue(CoordonneeTextureId, 0.0, 1.0); prog.setAttributeValue(SommetId, +1.0, -1.0, +1.0); // X- prog.setAttributeValue(CoordonneeTextureId, 0.0, 0.0); prog.setAttributeValue(SommetId, -1.0, -1.0, -1.0); prog.setAttributeValue(CoordonneeTextureId, 1.0, 0.0); prog.setAttributeValue(SommetId, -1.0, -1.0, +1.0); prog.setAttributeValue(CoordonneeTextureId, 1.0, 1.0); prog.setAttributeValue(SommetId, -1.0, +1.0, +1.0); prog.setAttributeValue(CoordonneeTextureId, 0.0, 1.0); prog.setAttributeValue(SommetId, -1.0, +1.0, -1.0); // Y+ prog.setAttributeValue(CoordonneeTextureId, 0.0, 0.0); prog.setAttributeValue(SommetId, -1.0, +1.0, -1.0); prog.setAttributeValue(CoordonneeTextureId, 1.0, 0.0); prog.setAttributeValue(SommetId, -1.0, +1.0, +1.0); prog.setAttributeValue(CoordonneeTextureId, 1.0, 1.0); prog.setAttributeValue(SommetId, +1.0, +1.0, +1.0); prog.setAttributeValue(CoordonneeTextureId, 0.0, 1.0); prog.setAttributeValue(SommetId, +1.0, +1.0, -1.0); glEnd(); /// On remplace la précédente texture de chat par la texture de fractale textureFractale->bind(); // Continue le dessin du cube. glBegin(GL_QUADS); // Y- prog.setAttributeValue(CoordonneeTextureId, 0.0, 0.0); prog.setAttributeValue(SommetId, -1.0, -1.0, -1.0); prog.setAttributeValue(CoordonneeTextureId, 1.0, 0.0); prog.setAttributeValue(SommetId, +1.0, -1.0, -1.0); prog.setAttributeValue(CoordonneeTextureId, 1.0, 1.0); prog.setAttributeValue(SommetId, +1.0, -1.0, +1.0); prog.setAttributeValue(CoordonneeTextureId, 0.0, 1.0); prog.setAttributeValue(SommetId, -1.0, -1.0, +1.0); // Z+ prog.setAttributeValue(CoordonneeTextureId, 0.0, 0.0); prog.setAttributeValue(SommetId, -1.0, -1.0, +1.0); prog.setAttributeValue(CoordonneeTextureId, 1.0, 0.0); prog.setAttributeValue(SommetId, +1.0, -1.0, +1.0); prog.setAttributeValue(CoordonneeTextureId, 1.0, 1.0); prog.setAttributeValue(SommetId, +1.0, +1.0, +1.0); prog.setAttributeValue(CoordonneeTextureId, 0.0, 1.0); prog.setAttributeValue(SommetId, -1.0, +1.0, +1.0); // Z- prog.setAttributeValue(CoordonneeTextureId, 0.0, 0.0); prog.setAttributeValue(SommetId, -1.0, -1.0, -1.0); prog.setAttributeValue(CoordonneeTextureId, 1.0, 0.0); prog.setAttributeValue(SommetId, -1.0, +1.0, -1.0); prog.setAttributeValue(CoordonneeTextureId, 1.0, 1.0); prog.setAttributeValue(SommetId, +1.0, +1.0, -1.0); prog.setAttributeValue(CoordonneeTextureId, 0.0, 1.0); prog.setAttributeValue(SommetId, +1.0, -1.0, -1.0); glEnd(); }
Il faut imaginer une texture comme une nappe carrée. Les quatre coins de la nappe sont repérés par 4 vecteurs en dimension 2 (0,0), (0,1), (1,1) et (1,0)
. Il faut imaginer que cette nappe va pouvoir être déformée autant que l'on veut pour être plaquée contre les faces que l'on va dessiner. Pour indiquer à OpenGL quelle partie de la texture (la nappe) il faut mettre sur chaque sommet on utilise l'attribut CoordonneeTextureId
.
Finalement, on ajoute aussi un destructeur, pour libérer les textures allouées :
fichiers vue_opengl.h
:
class VueOpenGL : public SupportADessin, protected QOpenGLFunctions {
et
vue_opengl.cc
:
void VueOpenGL::dessine(Contenu const& a_dessiner) { Q_UNUSED(a_dessiner); // dans cet exemple simple on n'utilise pas le paramètre // Dessine le 1er cube (à l'origine) dessineCube(); QMatrix4x4 matrice; // Dessine le 2e cube
Ceci conclut cet exemple de plaquage de deux textures.
Pour dessiner en 2D en OpenGl, il suffit en fait de dessiner dans un plan perpendiculaire à la direction de visée. Je n'ai donc pas grand chose de plus à dire ici que ce que nous avons présenté dans le tout premier exemple.
NOTE : Vous pouvez télécharger ici le code de ce neuvième exemple.
Par contre, pour dessiner de proche en proche (approximation
linéaire) des fonction dans le plan, il peut être utile d'utiliser la
directive GL_LINE_SPLIT
.
Voici un exemple qui dessine la fonction sinus (fichier
glwidget.cc
) :
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); QMatrix4x4 matrice; prog.setUniformValue("vue_modele", matrice); // On met la matrice identité dans vue_modele /* Dessine le cadre blanc */ matrice.setToIdentity(); matrice.ortho(-1.0, 1.0, -1.0, 1.0, -10.0, 10.0); // matrice simple pour faire le cadre prog.setUniformValue("projection", matrice); prog.setAttributeValue(CouleurId, 1.0, 1.0, 1.0); glBegin(GL_LINE_LOOP); // la primitive LINE_LOOP referme le tracé avec une ligne (n lignes) prog.setAttributeValue(SommetId, -1.0, -1.0, 2.0); // le 2.0 dans la composante z permet de mettre le cadre par dessus tout prog.setAttributeValue(SommetId, +1.0, -1.0, 2.0); // ceci fonctionne grace à l'option GL_DEPTH_TEST prog.setAttributeValue(SommetId, +1.0, +1.0, 2.0); prog.setAttributeValue(SommetId, -1.0, +1.0, 2.0); glEnd(); /* Change de matrice de projection adpatée aux zoom du graph */ matrice.setToIdentity(); double xmin(-2.0 * M_PI); double xmax(+2.0 * M_PI); double ymin(-1.2); double ymax(+1.2); matrice.ortho(xmin, xmax, ymin, ymax, -10.0, 10.0); prog.setUniformValue("projection", matrice); /* Dessine les axes */ prog.setAttributeValue(CouleurId, 0.0, 0.0, 1.0); glBegin(GL_LINES); // la primitive LINES dessine une ligne par paire de points (n/2 lignes) prog.setAttributeValue(SommetId, xmin, 0.0, -1.0); // le -1.0 dans la composante z met les axes en arrière plan prog.setAttributeValue(SommetId, xmax, 0.0, -1.0); prog.setAttributeValue(SommetId, 0.0, ymin, -1.0); prog.setAttributeValue(SommetId, 0.0, ymax, -1.0); glEnd(); /* Dessine la fonction sinus */ prog.setAttributeValue(CouleurId, 0.0, 1.0, 0.0); glBegin(GL_LINE_STRIP); // la primitive LINE_STRIP ne referme par le tracé (n-1 lignes) double xpas((xmax - xmin) / 128.0); for (double x(xmin); x <= xmax; x += xpas) { double y = sin(x); prog.setAttributeValue(SommetId, x, y, 0.0); } glEnd(); }
Qu'ai-je oublié ?...
Plein de choses bien sûr ; ceci n'est qu'un très modeste tutoriel. Pour aller plus loin, voir dans la bibliographie ci-dessous.
Ce que vous me demanderez peut être pour le projet :
Comment dessiner autre chose que des cubes ou des sphères ?
Pour dessiner des cubes et des sphères, ou toute transformation affine de ces objets, voir les exemples précédents.
Pour le reste, il faut décomposer l'objet à dessiner en facettes triangulaires ou rectangulaires (cf l'exemple du cube). Je vous conseille pour cela de faire des méthodes-outils, comme nous avons fait pour les cubes et les axes, voire des classes comme nous avons fait pour les sphères.
Pour dessiner des objets complexes, il vaut mieux utiliser logiciel de rendu 3D comme par exemple blender. On peut ensuite charger les modèles 3D dans notre programme par exemple avec la librairie assimp. Mais cela nous emmènerait vraiment beaucoup trop loin ;-)
Comment modifier la perspective ?
On peut modifier la perspective en modifiant les paramètres de la
méthode perspective()
de QMatrix4x4
appelée dans la
méthode resizeGL()
de GLWidget
(ou alors en changeant directement la matrice
« projection
» du « shader » !).
Le premier paramètre correspond à l'angle d'ouverture du cône de vision. Habituellement cette valeur se situe entre 65 et 100 degrés. Le deuxième paramètre est le format de la fenêtre, ratio largeur/hauteur. Les deux derniers paramètres définissent quels objets seront représentés à l'écran. Ici, tout se qui se situe à une distance entre 1e-3 et 1e5 de l'oeil sera dessiné à l'écran. Pour des raisons de performance, il est déconseillé de descendre trop prêt de 0 pour la limite inférieure.
Que faire si je ne sais plus où je me situe dans l'espace après avoir fait trop de déplacements ?
Une possibilité est de revenir à l'origine en donnant comme point de vue la matrice identité, par exemple
avec la méthode
setToIdentity()
, ou à un autre point fixe défini au départ, exactement comme nous l'avons
montré dans l'exemple 4 pour la touche 'Home'.
Une autre possibilité est de créer et d'appeler une
méthode dessineAxes()
, comme illustrée dans le septième exemple, qui dessine un
système orthonormé à l'endroit où l'on se
situe.
Que faire si je ne sais pas comment utiliser une fonction ou si je ne sais pas comment réaliser ce que j'aimerais ?
La première chose à faire est de chercher dans la documentation, via les liens fournis ci-dessous. Une grande partie du temps du programmeur est consacré à la recherche d'informations dans une documentation. Si vous n'avez pas trouvé ou que vous n'êtes pas satisfait de la réponse, posez votre question sur le forum du cours.