Sommaire
Les sources du chapitre sont disponibles dans le fichier zip. L'application permet de changer dynamiquement de mode de transfert de données, d'afficher ou non les textures, de modifier la limitation du FPS.
Il existe plusieurs méthodes pour transférer les données entre la mémoire centrale (RAM) et la mémoire du processeur graphique. Certaines des méthodes présentées ici sont obsolètes et sont donc déconseillées. Elles sont présentées à titre indicatif et de comparaison uniquement : beaucoup de tutoriels présents sur Internet utilisent des méthodes obsolètes ; il est donc intéressant d'avoir le code des différentes méthodes pour pouvoir adapter du code obsolète en code plus moderne.
Dans cette partie, nous allons utiliser le pipeline de rendu fixe de la carte graphique pour dessiner notre terrain. Le pipeline fixe fait partie des méthodes obsolètes et il n'est pas conseillé de l'utiliser. Il a été conservé sur les versions Desktop d'OpenGL uniquement, pour des raisons de compatibilité. Il est utilisé dans ce chapitre pour pouvoir se concentrer uniquement sur le transfert des données. L'utilisation du pipeline programmable est présentée dans le chapitre suivant.
La méthode la plus simple est d'envoyer chaque vertex au moment de l'affichage. Cette méthode a le désavantage de consommer de la bande passante inutilement, ce qui peut dégrader les performances. Dans la pratique, cette méthode se déroule en plusieurs étapes : dans un premier temps, il faut choisir le type de primitive à dessiner (ligne, triangle, carré, etc.) avec la fonction glBegin puis envoyer les caractéristiques de chaque vertex (position, couleur, vecteur normal, coordonnées de la texture, etc.) un par un. À la fin de l'envoi des vertices, il faut appeler la fonction glEnd. Il est possible de dessiner plusieurs primitives de même type dans un seul bloc glBegin-glEnd.
La difficulté dans cet exemple vient du fait que nous allons dessiner le terrain à l'aide de triangles, qui est la primitive de base pour le rendu 3D. Notre terrain est composé d'une grille carrée de N points, ce qui donne N-1 arêtes par dimension. La grille est donc composée de (N-1)2 carrés. Pour un carré, il nous faut deux triangles côte à côte, ce qui correspond à 6 vertices. Les vertices composant chaque triangle devant être envoyés dans l'ordre à la carte graphique, nous ne pourrons pas nous contenter de parcourir notre vecteur de vertices séquentiellement, deux boucles imbriquées seront nécessaires.
L'envoi des vertices composants chaque triangle du terrain est réalisé dans la fonction de rendu paintGL grâce à deux boucles for imbriquées parcourant chaque quadrilatère de la grille.
void HeightmapWidget::paintGL() { glBegin(GL_TRIANGLES); for (int z = 0; z < quads_by_z; ++z) { for (int x = 0; x < quads_by_x; ++x) {
Nous parcourons le vecteur vertices contenant les positions des vertices avec une boucle pour chaque dimension x et z. Chaque couple (x, z) correspond à un seul carré de la grille. Il est nécessaire de calculer l'indice dans le tableau du premier point du carré correspondant à (x, z) :
int i = z * vertices_by_x + x;
Les trois autres points définissant le carré se trouvent aux positions (x+1, z), (x, z+1) et (x+1, z+1). Les deux triangles composant chaque carré sont dessinés à l'aide de la fonction glVertex3f. La fonction qglColor permet de préciser la couleur qui sera appliquée à ces triangles.
qglColor(Qt::green); glVertex3f(m_vertices[i].x(), m_vertices[i].y(), m_vertices[i].z()); glVertex3f(m_vertices[i+vertices_by_x].x(), m_vertices[i+vertices_by_x].y(), m_vertices[i+vertices_by_x].z()); glVertex3f(m_vertices[i+1].x(), m_vertices[i+1].y(), m_vertices[i+1].z()); glVertex3f(m_vertices[i+1].x(), m_vertices[i+1].y(), m_vertices[i+1].z()); glVertex3f(m_vertices[i+vertices_by_x].x(), m_vertices[i+vertices_by_x].y(), m_vertices[i+vertices_by_x].z()); glVertex3f(m_vertices[i+1+vertices_by_x].x(), m_vertices[i+1+vertices_by_x].y(), m_vertices[i+1+vertices_by_x].z()); } } glEnd(); }
Une amélioration possible est de compiler les différentes instructions d'un bloc glBegin-glEnd dans une Display list avec les fonctions glNewList et glEndList.
Au lieu d'envoyer les vertices un par un, il est possible d'envoyer directement un tableau contenant une liste de vertex à la carte graphique. Cette technique est communément appelée Vertex Array (littéralement “tableau de vertices”). Pour l'utiliser, nous ne pourrons pas nous contenter d'envoyer directement le tableau de vertex créé au chargement des données à partir de l'image heightmap. En effet, comme on a pu le voir dans le chapitre précédent, chaque carré est dessiné à l'aide de deux triangles ayant deux vertices en commun. Il est donc nécessaire de recopier les vertices dans un nouveau tableau (également un vecteur), en les ordonnant et en dupliquant certains vertex.
class HeightmapWidget : public QGLWidget { ... private: QVector<QVector3D> m_vertexarray; };
La méthode de remplissage du vecteur est similaire à celle présentée dans la partie précédente, sauf que les positions des vertices sont stockées dans un tableau créé lors de l'initialisation.
void HeightmapWidget::initializeGL() { ... for (int z = 0; z < quads_by_z; ++z) { for (int x = 0; x < quads_by_x; ++x) { int i = z * vertices_by_x + x; m_vertexarray.push_back(m_vertices[i]); m_vertexarray.push_back(m_vertices[i+vertices_by_x]); m_vertexarray.push_back(m_vertices[i+1]); m_vertexarray.push_back(m_vertices[i+1]); m_vertexarray.push_back(m_vertices[i+vertices_by_x]); m_vertexarray.push_back(m_vertices[i+1+vertices_by_x]); } } }
L'affichage de ce tableau de données nécessite plusieurs étapes. Dans un premier temps, il faut indiquer à la carte graphique que l'on va travailler avec des Vertex Array à l'aide de la fonction glEnableClientState. Il faut ensuite envoyer le tableau de données contenant les vertices à la carte graphique à l'aide de la fonction glVertexPointer. Cette fonction prend trois paramètres : le nombre de composantes dans un vertex (trois dans notre cas), le type de données (des GL_FLOAT dans l'exemple) et un pointeur constant vers le tableau de données. La fonction glDrawArrays permet de dessiner les triangles et prend comme paramètres le type de primitive (GL_TRIANGLES dans l'exemple) et le nombre de vertices.
void HeightmapWidget::paintGL() { qglColor(Qt::white); glEnableClientState(GL_VERTEX_ARRAY); glVertexPointer(3, GL_FLOAT, 0, m_vertexarray.constData()); glDrawArrays(GL_TRIANGLES, 0, m_vertexarray.size()); glDisableClientState(GL_VERTEX_ARRAY); }
Dans le paragraphe précédent, nous avons mis en évidence un problème de duplication des vertices qui conduit à une augmentation de la taille du tableau envoyé à la carte graphique. Pour éviter cette surcharge inutile, il est possible d'utiliser notre tableau de vertices créé lors du chargement de la heightmap et de fournir un tableau d'indices indiquant l'ordre des vertices à utiliser pour dessiner les triangles. Lorsqu'un vertex sera commun à plusieurs triangles, il suffira donc de dupliquer uniquement l'indice. Cette technique permet de réduire de façon non négligeable le volume de données à envoyer au processeur graphique et donc d'améliorer les performances.
class HeightmapWidget : public QGLWidget { ... private: QVector<GLuint> m_indices; };
La méthode de création du tableau d'indices est équivalente aux méthodes présentées dans les chapitres précédents, avec pour seule différence que l'on remplit le tableau avec les indices.
void HeightmapWidget::initializeGL() { ... for (int z = 0; z < quads_by_z; ++z) { for (int x = 0; x < quads_by_x; ++x) { int i = z * vertices_by_x + x; m_indices.push_back(i); m_indices.push_back(i + vertices_by_x); m_indices.push_back(i + 1); m_indices.push_back(i + 1); m_indices.push_back(i + vertices_by_x); m_indices.push_back(i + 1 + vertices_by_x); } } }
Le rendu ne s'effectue plus avec la fonction glDrawArrays mais avec la fonction glDrawElements, qui prend comme paramètres le nombre d'indices dans le tableau, le type d'indice (GL_UNSIGNED_INT ici) et un pointeur constant vers le tableau d'indices.
void HeightmapWidget::paintGL() { qglColor(Qt::green); glEnableClientState(GL_VERTEX_ARRAY); glVertexPointer(3, GL_FLOAT, 0, m_vertices.constData()); glDrawElements(GL_TRIANGLES, m_indices.size(), GL_UNSIGNED_INT, m_indices.constData()); glDisableClientState(GL_VERTEX_ARRAY); ... }
Jusqu'à présent, les données sont envoyées à la carte graphique lors de chaque mise à jour de l'affichage. Lorsque le volume de données à envoyer est important, cela peut dégrader les performances. La solution consiste à envoyer une seule fois les données constantes et de mettre à jour uniquement les paramètres qui sont modifiés. Dans notre exemple de heightmap, les positions des vertices et les indices sont constants. On peut donc les envoyer lors de l'initialisation, ce qui diminue les transferts de données lors des mises à jour.
Les Vertex Buffer Objects sont des tampons de vertices stockés dans la carte graphique, à l'inverse des Vertex Array qui sont stockés dans la mémoire centrale et nécessitent donc un transfert vers la mémoire graphique à chaque rendu. L'économie en termes de transfert mémoire CPU/GPU sera important.
Le module QtOpenGL fournit la classe QGLBuffer pour faciliter la manipulation des buffers. Il est possible d'utiliser les buffers avec ou sans indices. Nous ne présentons ici que la version avec indices. Deux types QGLBuffer seront donc utilisés, un pour le tableau de vertices et l'autre pour le tableau d'indices :
class HeightmapWidget : public QGLWidget { ... private: QGLBuffer m_vertexbuffer; QGLBuffer m_indicebuffer; };
Il est nécessaire de préciser, lors de la construction de ces objets, le type de données qu'ils vont contenir :
HeightmapWidget::HeightmapWidget(QWidget *parent) : QGLWidget(parent), m_vertexbuffer(QGLBuffer::VertexBuffer), m_indicebuffer(QGLBuffer::IndexBuffer) { ... }
Pour créer les buffers, il faut les initialiser avec la fonction create puis préciser que l'on va travailler dessus avec la fonction bind. Il faut ensuite allouer un bloc mémoire dans la carte graphique de taille souhaitée avec la fonction allocate. Il est possible de remplir la mémoire graphique allouée à ce stade en fournissant un pointeur constant vers un tableau de données ou dans un second temps en fournissant un pointeur à la fonction write. On termine en précisant à la carte graphique que l'on a fini d'utiliser ce buffer avec la fonction release. L'allocation du buffer d'indices n'est pas détaillée mais ne présente aucune difficulté particulière.
Ces différentes fonctions sont équivalentes respectivement aux fonctions OpenGL suivantes : glGenBuffers, glBindBuffer, glBufferData et glDeleteBuffers.
void HeightmapWidget::initializeGL() { // Vertex buffer init m_vertexbuffer.create(); m_vertexbuffer.bind(); m_vertexbuffer.allocate(m_vertices.constData(), m_vertices.size() * sizeof(QVector3D)); m_vertexbuffer.release(); // Indices buffer init m_indicebuffer.create(); m_indicebuffer.bind(); m_indicebuffer.allocate(m_indices.constData(), m_indices.size() * sizeof(GLuint)); m_indicebuffer.release(); }
Le code du rendu est similaire au code du chapitre précédent. La principale différence vient du fait que l'on passe un pointeur nul aux fonctions, à la place du pointeur constant vers les données. Il faut dans un premier temps activer le buffer que l'on souhaite utiliser avec bind puis appeler les fonctions OpenGL en passant la valeur NULL à la place du pointeur de données. Il faut ensuite préciser à OpenGL que l'on a fini de travailler avec un buffer avec release avant de pouvoir utiliser un autre buffer. Si on souhaite ne pas utiliser de tableau d'indices, on utilisera glDrawArrays à la place de glDrawElements.
void HeightmapWidget::paintGL() { qglColor(Qt::green); glEnableClientState(GL_VERTEX_ARRAY); m_vertexbuffer.bind(); glVertexPointer(3, GL_FLOAT, 0, NULL); m_vertexbuffer.release(); m_indicebuffer.bind(); glDrawElements(GL_TRIANGLES, m_indices.size(), GL_UNSIGNED_INT, NULL); m_indicebuffer.release(); glDisableClientState(GL_VERTEX_ARRAY); ... }
Dans cet exemple, nous utilisons les VBO avec des tableaux d'indices, il est néanmoins tout à fait possible d'utiliser les VBO sans tableau d'indices et d'effectuer le rendu à l'aide de la fonction glDrawArrays.
Nous allons maintenant comparer les performances des différentes techniques présentées tout au long de cette partie. Pour cela, le code de chaque méthode est implémenté dans un même programme. Le choix du rendu peut être changé dynamiquement.
class HeightmapWidget : public QGLWidget { ... private: enum MODE_RENDU { MODE_GL_VERTEX, MODE_VERTEXARRAY, MODE_VERTEXARRAY_INDICES, MODE_VERTEBUFFEROBJECT_INDICES}; MODE_RENDU mode_rendu; }
La sélection du mode de rendu s'effectue dans la fonction paintGL :
void HeightmapWidget::paintGL() { ... switch(mode_rendu) { case MODE_GL_VERTEX: // implémentation de la première méthode break; case MODE_VERTEXARRAY: // implémentation de la deuxième méthode break; case MODE_VERTEXARRAY_INDICES: // implémentation de la troisième méthode break; case MODE_VERTEBUFFEROBJECT_INDICES: // implémentation de la quatrième méthode break; } }
La fonction keyPressEvent est surchargée pour permettre à l'utilisateur de changer de mode de rendu grâce à la barre d'espace du clavier. Le modulo sert à conserver une valeur comprise entre 0 et 3, car il n'y a que quatre éléments dans notre énumération.
void HeightmapWidget::keyPressEvent(QKeyEvent *event) { if (event->key() == Qt::Key_Space) mode_rendu = static_cast<MODE_RENDU>((mode_rendu + 1) % 4); }
Les différentes versions du rendu du terrain sont implémentées ainsi que le mécanisme de changement du mode de rendu pendant l'exécution. Il est maintenant temps d'analyser les performances obtenues par les différentes méthodes de rendu. Voici un tableau récapitulant les performances obtenues sur la plateforme de test (Intel i5, Ubuntu 9.10, GPU NVIDIA GTX460 driver 3.2) utilisée lors de la rédaction de ce tutoriel.
Sans surprise, les VBO fournissent les meilleures performances, le principal goulot d'étranglement dans notre exemple étant les transferts mémoire entre le client (CPU) et le serveur (GPU). En fonction du type de scène à dessiner et de la quantité de données constantes et non constantes utilisées, la différence de performances entre ces méthodes peut changer beaucoup. Il peut être intéressant d'effectuer des tests pour sélectionner la méthode la plus adaptée à ses besoins.
Il est possible que la synchronisation verticale bloque le nombre d'images par seconde (par exemple sous Windows). Pour débloquer le nombre d'images par seconde et donc mesurer les performances de l'application, il faut désactiver temporairement cette option dans le panneau de contrôle de la carte graphique.