19 Hanoï en 3D Au chapitre précédent, nous n’avons manipulé que des formes élémentaires : un triangle et un cube, lui-même composé de triangles. Dans un programme plus complexe comme celui des Tours de Hanoï, naturellement, il nous faudra des formes plus élaborées – encore que nous allons nous limiter à des choses relativement simples. Si OpenGL nous permet de représenter à peu près n’importe quoi, encore faut-il lui dire quoi afficher. L’un des problèmes majeurs en programmation 3D est donc de “nourrir” OpenGL, c’est-à-dire de passer d’une forme quelconque – comme un disque – à un ensemble d’objets fondamentaux – comme des triangles – qu’OpenGL sera capable de traiter. Pour cela, il existe fondamentalement deux approches : ●● La forme peut être décrite par une ou plusieurs équations mathématiques à partir desquelles votre programme va calculer un certain nombre de points puis relier ces points pour obtenir des triangles. ●● La forme voulue est réalisée dans un logiciel de modélisation dédié à cette tâche, sauvegardée sur disque dans un fichier, lequel fichier sera interprété par votre programme pour fournir les triangles nécessaires à OpenGL. © 2011 Pearson France – Initiation à la programmation avec Python et C++ – Yves Bailly Book_Prog_initia.indb 261 03/02/11 15:52 262 Pour aller plus loin La première solution présente l’avantage de rendre le programme relativement autonome, c’est-àdire qu’il ne dépend pas de la présence de fichiers "annexes" pour son fonctionnement. Par contre, elle donne en général naissance à des algorithmes assez compliqués, faisant intervenir des notions mathématiques relativement sophistiquées. Aussi, par souci de simplicité, nous allons mettre en œuvre la seconde approche. 19.1. Du triangle à la lumière Au-delà de la modélisation des objets à afficher, il est une caractéristique que nous avons ignorée jusqu’à maintenant, mais qui est présente tout autour de nous et essentielle pour notre perception des volumes : la lumière. Regardez la Figure 19.1 : les deux sphères ont en réalité la même couleur. Seulement, celle de gauche est affichée sans tenir compte d’aucun éclairage, comme nous l’avons fait au chapitre précédent, tandis que celle de droite est affichée en exploitant une source de lumière. Figure 19.1 Différence entre ignorer (à gauche) ou prendre en compte (à droite) la lumière. À strictement parler, cette image est loin d’être parfaite. Notamment, on s’attendrait à voir l’ombre de la sphère de droite sur le plan. Nous allons ignorer le problème spécifique de l’ombre générée par les objets, pour nous contenter de modéliser l’effet d’une source de lumière. La modélisation d’une source lumineuse et de la façon dont elle affecte la perception visuelle d’un objet peut se révéler d’une particulière complexité si on recherche absolument à obtenir un résultat réaliste. Nous allons nous contenter d’une technique extrêmement simple, suffisante pour nos besoins. Mais, aussi simple soit-elle, cette technique a besoin d’une information géométrique fondamentale : la normale. © 2011 Pearson France – Initiation à la programmation avec Python et C++ – Yves Bailly Book_Prog_initia.indb 262 03/02/11 15:52 Chapitre 19 Hanoï en 3D 263 19.1.1. Notion de normales Si on considère la surface, la “peau” d’un objet, on appelle normale en un point de cette surface une direction (les mathématiciens diront un vecteur) perpendiculaire à la surface en ce point, et pointant en général vers l’extérieur de l’objet. La Figure 19.2 donne quelques exemples de normales. Figure 19.2 Des normales sur une sphère et au coin d’un cube. Sur une sphère, chaque point possède une normale différente. Sur un cube, chaque côté n’a qu’une normale (ou, pour être strict, tous les points d’un même côté ont la même normale) mais, par contre, les coins possèdent trois normales : un coin ou plus généralement les points le long d’une arête vive possèdent autant de normales qu’il y a de faces partageant ce point (ou cette arête). Dans le cadre d’OpenGL, la normale fait partie des attributs d’un vertex au même titre que ses coordonnées ou sa couleur. Si on dessine un objet d’une seule couleur, comme c’était le cas des deux sphères de la Figure 19.1 et comme ce le sera pour nos disques des Tours de Hanoï, la connaissance de la normale permet d’ajuster la couleur affichée pour donner l’impression de volume, en tenant compte d’une direction d’éclairage. Le problème est maintenant de savoir comment obtenir ces fameuses normales, en chacun des vertices qui constituent un objet. Rassurez-vous, les logiciels de modélisation et d’image de synthèse savent très bien calculer ces directions : il nous suffira donc de les lire depuis un fichier. 19.1.2. Blender et le format de fichier Wavefront OBJ Voyons justement comment obtenir les fichiers à partir desquels nous allons construire les différents modèles à afficher. Nous allons utiliser pour cela le logiciel Blender (http://www.blender.org), un logiciel d’image de synthèse et d’animation libre et gratuit, disponible sur pratiquement tous les systèmes. © 2011 Pearson France – Initiation à la programmation avec Python et C++ – Yves Bailly Book_Prog_initia.indb 263 03/02/11 15:52 264 Pour aller plus loin Figure 19.3 Le logiciel Blender, en préparation de la Figure 19.2. Blender enregistre les fichiers dans son propre format, bien trop complexe pour nos besoins. Il nous faut quelque chose de simple. Le choix est alors fait de modéliser chacun des éléments qu’il nous faut dans un fichier séparé, en l’enregistrant au format OBJ développé par Wavefront (http:// fr.wikipedia.org/wiki/Objet_3D_%28format_de_fichier%29). Il s’agit d’un fichier texte, donc aisément lisible (voir la section 15.2). Un exemple très abrégé pourrait ressembler à ceci : v 1.769225 -0.174253 0.415734 v 1.683198 -0.165781 0.461939 # ...et quelques centaines ainsi... v 1.777785 -0.000000 0.415734 vn 0.058687 -0.004791 0.998260 vn 0.058870 0.000946 0.998260 # ...et encore quelques centaines ainsi... vn 0.827540 -0.080599 0.555559 f 140//1 1086//2 8//3 f 685//4 7//5 1087//6 # ...et toujours quelques centaines ainsi... f 4//1076 20//1077 1077//134 © 2011 Pearson France – Initiation à la programmation avec Python et C++ – Yves Bailly Book_Prog_initia.indb 264 03/02/11 15:52 Chapitre 19 Hanoï en 3D 265 Chaque ligne commençant par un v représente les coordonnées d’un point. Chaque ligne commençant par vn représente une normale, avec ses coordonnées. Enfin, chaque ligne commençant par f est la description d’une face, en donnant la liste des points qui la constituent, à chaque point étant associée une normale. C’est cette association qui donne naissance au vertex. Dans l’exemple précédent, la première face est un triangle (car il y a trois vertices), son premier point est le point d’indice 140 dans la liste des v, auquel est associé la normale d’indice 1 dans la liste des vn. Pour être juste, le format OBJ est capable de bien plus que cela, mais c’est tout ce dont nous avons besoin. Si vous utilisez Blender, obtenir un tel fichier se fait en passant par le menu File > Export > Wavefront, puis en n’activant que les options Selection only (pour n’exporter que l’objet sélectionné), Normals (pour avoir les normales) et Triangulate, et aucune autre. La dernière est particulièrement importante pour que les faces ne soient bien que des triangles, pas des quadrilatères ou autre polygone. D’autres logiciels devraient proposer des options similaires lors d’un enregistrement au format OBJ. 19.2. Préparation du programme Après tous ces prolégomènes, revenons à la programmation. Nous allons repartir de l’état dans lequel nous avons laissé le programme au Chapitre 17, pour tout simplement lui ajouter une possibilité de représentation, en plus du mode texte (section 11.3.1), de l’image (Chapitre 13) et de la scène “plate” (le canevas, Chapitre 14). Cette nouvelle capacité sera stockée dans une bibliothèque nommée 3d et donnera un résultat illustré par la Figure 19.4. Figure 19.4 Hanoï en 3D ! Comme il faut bien se donner une référence quelque part, nous posons les termes suivants : ●● L’origine, le point de coordonnées (0, 0, 0), correspond exactement à la base de l’aiguille du milieu. ●● Les aiguilles s’étendent le long de l’axe Z ; donc, sur l’image cet axe va vers le haut. ●● La longueur du plateau s’étend le long de l’axe X ; donc, sur l’image il va vers la droite en montant. ●● Et, par conséquent, la largeur du plateau s’étend le long de l’axe Y, qui semble donc s’éloigner de nous vers le haut à gauche. © 2011 Pearson France – Initiation à la programmation avec Python et C++ – Yves Bailly Book_Prog_initia.indb 265 03/02/11 15:52 266 Pour aller plus loin Dans le répertoire jeu_hanoi, qui contient normalement les sous-répertoires dessin, gui, scene, etc., créez un nouveau sous-répertoire nommé 3d. Placez dedans un fichier projet 3d.pro avec le contenu suivant : 1. TEMPLATE = lib 2. QT += core gui opengl 3. CONFIG += release 4. MOC_DIR = .moc 5. OBJECTS_DIR = .objs 6. DESTDIR = ../bin 7. LIBS += -L../bin -ljeu -ldessin 8. INCLUDEPATH += .. . 9. DEFINES += GLEW_STATIC 10. SOURCES += vue_gl.cpp glew.c mesh.cpp dessin_3d.cpp 11. HEADERS += vue_gl.h mesh.h dessin_3d.h Comme au chapitre précédent, nous allons nous appuyer sur les bibliothèques GLM et Glew. À partir des dossiers issus de l’extraction des archives correspondantes, ou bien à partir du dossier du programme du chapitre précédent, dupliquez dans 3d les répertoires glm, GL et le fichier glew.c, comme nous l’avions fait pour le cube. 19.2.1. Les objets 3D Pour nous simplifier la vie, le programme (ou plus précisément la bibliothèque 3d que nous allons créer) va aller chercher les fichiers contenant les descriptions des objets 3D dans le même répertoire où il va se trouver lui-même, le sous-répertoire bin dans jeu_hanoi. Nous allons créer : ●● cylindre.obj, décrivant un simple cylindre qui sera utilisé pour l’aiguille de chaque tour ; ●● hemisphere.obj, ●● support.obj, décrivant une demi-sphère pour faire l’arrondi au sommet de chaque aiguille ; pour afficher un plateau sur lequel disques et aiguilles reposeront ; ●● huit fichiers disque_01.obj à disque_08.obj, chacun représentant un disque d’une taille donnée ; ●● enfin, un fichier suzanne.obj, dont l’obscure fonction sera explicitée plus loin. Vous pouvez créer vous-même ces fichiers, ou bien les récupérer à partir de l’archive contenant les codes sources de ce livre, dont l’adresse a été donnée à la fin du premier chapitre. Les objets sont modélisés de telle sorte que : ●● Le haut du plateau est à la position 0 sur l’axe Z ; autrement dit, il est “dans” le plan XY, le milieu étant exactement à l’origine des coordonnées. ●● Le centre de l’hémisphère et le milieu du bas du cylindre sont à l’origine ; tous deux ont un diamètre de 1, la hauteur du cylindre valant également 1. ●● Enfin, les disques sont centrés sur l’origine. © 2011 Pearson France – Initiation à la programmation avec Python et C++ – Yves Bailly Book_Prog_initia.indb 266 03/02/11 15:52 Chapitre 19 Hanoï en 3D 267 La Figure 19.5 illustre ces positionnements tels que définis dans les fichiers des modèles. Figure 19.5 Positionnements initiaux des modèles. Ces choix auront une certaine importance pour pouvoir placer et adapter les objets selon nos besoins, ce que nous allons voir bientôt. 19.2.2. Le mesh Les informations que nous allons lire depuis les fichiers d’objets seront stockées dans des tampons (buffers) OpenGL. À chaque objet va correspondre un tampon pour les points, un tampon pour les normales et un tampon pour les triangles, représentés par des indices de vertices comme au chapitre précédent. Pour nous simplifier un peu les choses, tous ces tampons seront stockés dans une classe Mesh. Le mot anglais mesh se traduit par filet, grillage ou maillage. Dans le cadre de la programmation 3D, ce terme désigne une structure de donnée contenant la modélisation d’un objet, le plus souvent à base de triangles, comme un filet dont les mailles viendraient épouser les formes de l’objet. Implicitement, la notion de “maille” suggère que l’on dispose d’un ensemble de points, ces points étant reliés entre eux pour constituer, justement, les mailles. Pour utiliser un terme mathématique, on dira qu’un mesh contient une information topologique plus ou moins détaillée, permettant à partir d’un sommet ou d’une face de trouver les sommets ou les faces voisines, ainsi que les arêtes. © 2011 Pearson France – Initiation à la programmation avec Python et C++ – Yves Bailly Book_Prog_initia.indb 267 03/02/11 15:52 268 Pour aller plus loin La classe Mesh est donc déclarée ainsi : 1. #ifndef MESH_H 2. #define MESH_H 3. #include <GL/gl.h> 4. #include <QtCore/QObject> 5. #include <QtOpenGL/QGLBuffer> 6. #include <glm/glm.hpp> 7. class Mesh: public QObject 8. { 9. public: 10. Mesh(QObject* parent = 0); 11. GLuint Points_Buffer() const; 12. GLuint Normales_Buffer() const; 13. GLuint Triangles_Buffer() const; 14. void Lire_Obj( 15. 16. QString const& fichier); void Reserver( 17. int const nb_vertices, 18. int const nb_triangles); 19. int Nb_Vertices() const; 20. int Nb_Triangles() const; 21. void Map(); 22. glm::vec4* Points() const; 23. glm::vec3* Normales() const; 24. glm::uvec3* Triangles() const; 25. void Unmap(); 26. 27. void Dessiner(); private: 28. QGLBuffer points_buffer; 29. QGLBuffer normales_buffer; 30. QGLBuffer triangles_buffer; 31. glm::vec4* points; 32. glm::vec3* normales; 33. glm::uvec3* triangles; 34. int nb_vertices; 35. int nb_triangles; Pour les tampons, on utilise la classe QGLBuffer proposée par Qt (lignes 5 et 28-30). Par ailleurs, la classe Mesh fournit diverses facilités, comme les méthodes Map() et Unmap(), qui vont correspondre aux fonctions OpenGL glMapBuffer() et glUnmapBuffer(). De cette façon, nous pourrons avoir accès aux contenus des tampons, et éventuellement les adapter à notre situation particulière (ce que nous ferons plus loin). La méthode Dessiner() (ligne 26) va se charger d’invoquer les différents glBindBuffer(), glEnableClientState() et autre glDrawElements() pour effectivement faire dessiner l’objet par OpenGL. Le code de la méthode paintGL() de la classe Vue_Gl, que nous verrons bientôt, s’en trouvera allégé d’autant et donc plus facile à relire. Vous l’aurez deviné, la méthode Lire_Obj() (ligne 14) a pour tâche de lire les informations d’un fichier au format OBJ et de les stocker dans les différents tampons. Le soin vous est laissé de consulter son code (non sans avoir essayé de l’écrire vous-même !), mais notez deux petites subtilités. Pour OpenGL, l’association entre un point et une normale se fait par les indices : le point no 1 est associé à la normale no 1, et ainsi de suite. Or, dans un fichier OBJ, points et normales ne sont pas toujours donnés dans le même ordre : il faut donc utiliser un tableau temporaire pour l’un des deux, afin de tout remettre en ordre quand on lit les triangles. Les indices donnés pour les triangles, justement, commencent à 1 et non pas à 0 comme c’est l’usage pour les tableaux en C++. Il faut donc ajuster (diminuer de 1) les indices lus dans le fichier. 36. }; // class Mesh 37. #endif // MESH_H 38. Nantis de cette classe utilitaire (nos amis anglo-saxons parlent de helper class, classe qui aide), nous pouvons nous pencher sur l’essentiel du programme. © 2011 Pearson France – Initiation à la programmation avec Python et C++ – Yves Bailly Book_Prog_initia.indb 268 03/02/11 15:52 Chapitre 19 Hanoï en 3D 269 19.3. Affichage général L’affichage sera assuré par une classe Vue_Gl assez semblable à celle décrite au chapitre précédent, à laquelle on va ajouter les informations nécessaires au jeu – comme nous l’avons fait dans la classe Scene pour l’affichage basé sur un canevas d’items. 19.3.1. Initialisation L’initialisation du contexte OpenGL se déroule, comme toujours, dans la méthode initializeGL(). Entre autres, c’est là que vont effectivement être lus les fichiers d’objet. Cette méthode commence ainsi : 144. void Vue_Gl::initializeGL() 145. { 146. glewInit(); 147. QDir chemin(qApp->applicationDirPath()); 148. // création du support 149. float const larg_support = 2.0f*(this->jeu->Hauteur()+1)+2.0f; 150. float const long_support = 3*2.0f*(this->jeu->Hauteur()+1)+4.0f; 151. this->base_tours = new Mesh(this); 152. this->base_tours->Lire_Obj( 153. QFileInfo(chemin, “support.obj”).absoluteFilePath()); applicationDirPath(), héritée de QApplication, donne une chaîne de caractères la classe QCoreApplication dont dérive la classe contenant le répertoire dans lequel se trouve le fichier exécutable. On crée à partir de là une instance de la classe QDir (ligne 144), laquelle représente justement un répertoire. On l’utilise comme premier paramètre au constructeur de la classe QFileInfo, le second paramètre étant le nom d’un fichier auquel on souhaite accéder. Cette classe propose nombre de méthodes fort utiles, dont la méthode absoluteFilePath(), laquelle retourne une chaîne de caractères contenant le chemin (path) absolu (absolute) vers le fichier (file) demandé. C’est cette chaîne que l’on va donner à la méthode Lire_Obj() de notre classe Mesh, ici pour lire le modèle du support (lignes 152-153). Notez que, dans un “vrai” programme, il faudrait déjà vérifier la présence et la lisibilité de ce fichier, par exemple à l’aide des méthodes exists() et isReadable() de QFileInfo. La méthode Seulement, cela ne va pas suffire : la taille du support dépend du nombre de disques. Or nous n’avons qu’un seul modèle : il va donc falloir l’adapter un peu, ce qui est en fait fort simple, étant donné la façon dont le plateau a été modélisé. Le milieu du plateau étant initialement à l’origine, les points correspondant aux coins ont des coordonnées X et Y soit positives soit négatives selon le côté où ils se trouvent. Il suffit alors de diminuer ou d’augmenter ces coordonnées, d’une certaine quantité adaptée à la taille voulue (voyez les lignes 149 et 150), pour “agrandir” le plateau. © 2011 Pearson France – Initiation à la programmation avec Python et C++ – Yves Bailly Book_Prog_initia.indb 269 03/02/11 15:52 270 Pour aller plus loin Nous allons donc devoir modifier le contenu du tampon contenu dans l’instance de Mesh : 155. this->base_tours->Map(); 156. glm::vec4* base_tours_pts = this->base_tours->Points(); 157. for(int vertex = 0; vertex < this->base_tours->Nb_Vertices(); ++vertex) 158. { 159. glm::vec4& pt = base_tours_pts[vertex]; 160. if ( pt.x < 0.0f ) 161. pt.x -= long_support / 2.0f; 162. else 163. pt.x += long_support / 2.0f; 164. if ( pt.y < 0.0f ) 165. pt.y -= larg_support / 2.0f; 166. else 167. pt.y += larg_support / 2.0f; 168. } 169. this->base_tours->Unmap(); À la suite de l’appel à la méthode Map(), on récupère un pointeur sur les points du modèle (ligne 156). Rappelez-vous le chapitre précédent : un pointeur peut s’utiliser un peu comme un tableau de type std::vector<>, ce dont nous n’allons pas nous priver. La boucle initiée en ligne 157 va parcourir les points et adapter leurs coordonnées X et Y selon qu’ils se trouvent, d’une part, à gauche ou à droite (lignes 160 à 163), d’autre part, en haut ou en bas (lignes 164 à 167), par rapport à l’origine. Notez l’utilisation d’une référence ligne 159, afin d’éviter de ressaisir six fois base_tours_pts[vertex]. Et ne surtout pas oublier l’appel à Unmap() quand on a terminé ! La hauteur du cylindre sera ajustée de la même façon, sauf qu’on ne va modifier que la coordonnée en Z des seuls points qui sont “en haut” du cylindre. L’hémisphère et les disques n’ont besoin d’aucune modification : nous verrons un peu plus loin par quel moyen nous allons les positionner. La seule petite difficulté peut concerner la construction du nom du fichier pour les disques. Nous pourrions créer un par un les meshs en citant les noms de fichier disque_01.obj, disque_02.obj, etc. Mais, au-delà du fait que c’est affreusement laborieux et dépourvu du moindre ludisme, cela pourrait devenir insupportable si on décidait un jour de représenter le jeu tel que décrit dans la légende, avec ses 64 disques. Nous allons donc écrire une boucle : 191. for(int disque = 0; disque < this->jeu->Hauteur(); ++disque) 192. { 193. Mesh* dsq = new Mesh(this); 194. QString const nom_disque = 195. QString(“disque_%1.obj”).arg(disque+1, 2, 10, QChar(’0’)); 196. dsq->Lire_Obj( 197. QFileInfo(chemin, nom_disque).absoluteFilePath()); 198. 199. this->disques[disque] = dsq; } © 2011 Pearson France – Initiation à la programmation avec Python et C++ – Yves Bailly Book_Prog_initia.indb 270 03/02/11 15:52