EPFL
Faculté Informatique & Communications
Cours d'informatique

Tutoriel OpenGL + Qt

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 !

Introduction

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 :

  1. Nous commencerons par un exemple simple de dessin direct d'OpenGL via Qt.

    Cet exemple présentera les concepts de

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

  3. Puis nous verrons comment dessiner plusieurs objets, les placer les uns par rapport aux autres.

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

  5. Le cinquième exemple étend le quatrième en ajoutant une gestion du mouvement et du temps.

  6. Le sixième exemple étend le quatrième en remplaçant le dessin des cubes par celui de sphère .

  7. Le septième exemple étend le quatrième en ajoutant des déplacement à la souris.

  8. Le huitième exemple étend le quatrième en ajoutant des textures sur les faces des cubes.

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

Dessin 3D

Quelques concepts de base

Boucle principale

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 :

  1. gestion des évènements ;
  2. évolution du système (évènements « Timer », en fait ; donc cas particulier du 1.) ;
  3. dessin de la nouvelle image.

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.

Matrice de dessin

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

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

Pour installer

Qt et OpenGL sont installées sur les ordinateurs du CO. Si vous voulez utiliser ces bibliothèques chez vous, il faudra les installer.

Pour compiler

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.

Pour travailler

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:

Premier exemple : dessiner un triangle coloré

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 :

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 :

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)

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 :

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é !

Deuxième exemple : réorganisation du code, meilleure conception, et dessin d'un cube

Notre exemple simple précédent nécessite déjà 8 fichiers :

  1. le fichier main_qt_gl.cc, qui contient le main() [que vous pourrez reprendre à l'identique] ;

  2. glwidget.h, qui contient la description de la classe principale pour dessiner (fenêtre principale) ;

  3. glwidget.cc, qui contient les définitions des méthodes de cette classe (méthodes de dessin) ;

  4. vertex_shader.h, qui fait le lien entre les conventions du shader utilisées et celles de nos programmes C++ ;

  5. vertex_shader.glsl, première partie du shader , executée en parallèle une fois par sommet;

  6. fragment_shader.glsl, seconde partie du shader  , executée aussi en parallèle mais cette fois une fois par pixel;

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

  8. 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 ») :

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 :

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)

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 Contenus ; 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 :

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 :

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.

Troisième exemple : dessin de plusieurs objets

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 :

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

Quatrième exemple : gestion du point de vue et évènements clavier

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 » :

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 :

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 :

Simulation en temps réel

Un peu de théorie

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 :

  1. 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 ;

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

  3. gestion des interactions (clavier, souris).

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.

Cinquième exemple : simulation temps réel

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.

Compléments : dessins de sphères

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 :

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 :

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
}

Compléments : gestion mouvement souris

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

Compléments : textures

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.

Compléments : dessins 2D

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();
}

Conclusion

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 :

Bibliographie


Auteurs : J.-C. Chappelier & M. Geiger
Dernière mise à jour le 18 avril 2024
Last modified: Thu Apr 18, 2024