La 3D avec le module OpenGL de Qt


précédentsommairesuivant

3. Envoyer des données au processeur graphique

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.

Image non disponible

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.

3-A. Envoi des vertex un par un

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.

 
Sélectionnez

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

 
Sélectionnez

            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.

 
Sélectionnez

            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.

3-B. Envoi de tableau de vertices (Vertex Array)

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.

 
Sélectionnez

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.

 
Sélectionnez

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.

 
Sélectionnez

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

3-C. Utilisation des tableaux d'indices

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.

 
Sélectionnez

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.

 
Sélectionnez

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.

 
Sélectionnez

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

3-D. Stockage des vertices dans la mémoire du processeur graphique (Vertex Buffer Objects)

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 :

 
Sélectionnez

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 :

 
Sélectionnez

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.

 
Sélectionnez

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.

 
Sélectionnez

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.

3-E. Comparaison des performances

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.

 
Sélectionnez

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 :

 
Sélectionnez

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.

 
Sélectionnez

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.

Image non disponible

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.

4. Utilisation du pipeline programmable

Ce chapitre présente l'utilisation du pipeline programmable et des shaders avec OpenGL et Qt. Le lecteur intéressé par les détails se reportera au tutoriel de LittleWhite. Les codes sources peuvent être téléchargés dans le fichier zip.

Jusqu'à maintenant nous nous sommes limités à l'utilisation d'OpenGL avec le pipeline de rendu fixe. Cette technique s'est avérée trop restrictive pour la création d'environnements 3D réalistes : l'industrie du rendu 3D a donc poussé les fabricants de processeurs graphiques à revoir leur architecture de rendu 3D afin de la rendre plus souple. Un effort particulier a été réalisé sur les méthodes de personnalisation du rendu 3D : c'est la naissance du pipeline programmable et des fameux shaders permettant aux développeurs de modifier certaines étapes du pipeline 3D selon leurs besoins. Cette transition s'est effectuée en 2001-2002, avec l'arrivée de DirectX8 et des OpenGL Shader extensions. L'utilisation du pipeline fixe est donc déconseillée, la plupart des fonctions utilisées dans la partie précédente sont dépréciées. Ces fonctions ne sont plus supportées sur OpenGL ES 2.0 (version d'OpenGL destinée aux systèmes embarqués).

Image non disponible
(source : OpenGL SDK site)

La principale nouveauté apportée par le pipeline programmable est l'utilisation de programmes remplaçant certaines étapes du rendu 3D qui étaient auparavant codées en "dur" dans le pipeline. Ces programmes sont fournis par le développeur 3D à la carte graphique et permettent de personnaliser des étapes du rendu OpenGL. On peut citer comme exemple d'utilisations répandues : la gestion de l'éclairage dynamique, le rendu d'eau réaliste ou encore la gestion des reflets. Ces programmes sont constitués de plusieurs shaders (qui provient de l'anglais to shade qui signifie nuancer). Les shaders sont écrits dans un langage proche du C et qui varie selon les API de rendu 3D utilisées. Dans le cas d'OpenGL, il s'agit du langage GLSL (OpenGL Shading Language). Direct3D utilise quant à lui le HLSL (High Level Shading Language). Heureusement, tous ces langages ont une syntaxe assez proche, ce qui permet aux développeurs de s'adapter à chaque langage rapidement.

Il existe plusieurs versions de GLSL et certaines pratiques sont dépréciées. Dans ce tutoriel, nous n'utiliserons pas les mots-clés et fonctions dépréciés. Le lecteur ayant déjà rencontré du code GLSL ne devra donc pas être surpris s'il ne retrouve pas exactement la même syntaxe dans les shaders qui seront présentés plus bas.

Il existe à l'heure actuelle quatre types de shaders, présentés dans l'ordre d'appel dans le pipeline :

  • Vertex Shader : applique un traitement à chaque vertex (translation, couleur, etc.) ;
  • Tessellation Shader : transforme une primitive (triangle, quadrilatère, etc.) en série de petits éléments (point, ligne, etc.) ;
  • Geometry Shader : crée ou supprime les vertices d'une primitive 3D dynamiquement ;
  • Fragment Shader (ou Pixel Shader dans la terminologie Direct3D) : applique un traitement à chaque pixel (modification de la couleur, transparence, etc.)

Les Vertex Shader et les Fragment Shader sont les plus anciens shaders pris en charge par les cartes graphiques et sont donc les plus utilisés. Les deux autres sont d'usage moins courant. Dans ce tutoriel, nous ne présenterons pas les Tessellation Shader, qui ne sont pas pris en charge par Qt. Les Vertex Shader et les Fragment Shader seront présentés dans ce chapitre et un exemple de Geometry Shader (uniquement à partir de la version 4.7 de Qt) sera présenté dans le chapitre suivant.

Chaque shader s'applique à un élément de base (par exemple, un vertex pour les Vertex Shader et un pixel pour les Fragment Shader). Une particularité est que le code des shaders est exécuté à l'identique pour chaque élément de base, indépendamment les uns des autres (ce qui implique que le code d'un shader qui s'exécute pour deux éléments différents ne peut pas échanger de données entre eux ; par contre, deux shaders de types différents peuvent échanger des données entre eux, dans l'ordre d'exécution du pipeline, via l'intermédiaire des mémoires graphiques partagées). Ce concept est appelé "programmation parallèle". Les cartes graphiques sont spécialement conçues pour profiter de cette particularité : les processeurs graphiques sont en fait constitués de plusieurs cores, chacun pouvant exécuter un shader sur un élément. Plus le processeur contient de cores, plus le temps d'exécution globale sera diminué (bien sûr, d'autres éléments des processeurs graphiques interviennent sur les performances).

Dans cette partie, nous allons réécrire le programme de rendu de terrain pour qu'il utilise le pipeline programmable et présenter les outils de manipulation de shaders mis à notre disposition par Qt. Nous ne donnerons pas la version OpenGL du code, qui peut être trouvée dans le tutoriel de LittleWhite.

4-A. La manipulation de shaders avec Qt : QGLShaderProgram et QGLShader

Dans Qt, les shaders sont gérés principalement avec deux classes dans Qt : QGLShaderProgram et QGLShader.

  • La classe QGLShader permet de manipuler les différents types de shaders, que ce soient les Vertex Shader, les Geometry Shader ou les Fragment Shader. Les shaders étant un code exécutable sur le processeur graphique, il faut fournir ce code sous forme de chaîne de caractères puis le compiler. Pour créer un shader, il suffit donc de créer un QGLShader en spécifiant le type de shader puis de compiler le code avec la fonction compileSourceCode (si l'on souhaite donner directement le code sous forme de chaîne de caractères) ou compileSourceFile (si le code est fourni dans un fichier séparé). On parle habituellement de compilation à la volée (Online compilation). Les dernières versions d'OpenGL permettent de sauvegarder et charger directement du code GLSL, sans avoir besoin de recompiler à chaque exécution (Offline compilation). Cette fonctionnalité n'est pas prise en charge nativement pour le moment dans Qt.
  • La classe QGLShaderProgram permet de manipuler un programme OpenGL, c'est-à-dire plusieurs shaders liés entre eux pour former un pipeline programmable spécifique. Il ne peut y avoir au maximum qu'un seul shader de chaque type par programme et qu'un seul programme actif en même temps. Cette classe est responsable de l'activation des shaders dans le contexte OpenGL et de leurs manipulations (notamment le passage de paramètres). Il est possible créer directement des shaders à l'aide des fonctions addShaderFromSourceCode et addShaderFromSourceFile puis de récupérer les shaders d'un programme à l'aide de la fonction shaders.

La création de shaders est relativement simple, nous allons donc utiliser directement la méthode de création de shaders à partir d'un programme. Le QGLShaderProgram étant utilisé pour passer des paramètres aux shaders, nous le créons comme variable membre :

 
Sélectionnez

class HeightmapWidget : public QGLWidget
{
private:
    QGLShaderProgram m_program;
}

Pour le moment, nous utiliserons un seul QGLShaderProgram qui ne sera pas modifié au cours de son utilisation. Nous l'initialisons dans la fonction initializeGL. De plus, pour alléger le code C++, nous plaçons le code de chaque shader dans un fichier .glsl séparé (remarquez l'utilisation du mécanisme de ressources Qt que nous avons déjà rencontré au chargement de l'image heightmap).

 
Sélectionnez

void HeightmapWidget::initializeGL()
{
    m_program.addShaderFromSourceFile(QGLShader::Vertex, ":/shaders/vertex_shader.gl");
    m_program.addShaderFromSourceFile(QGLShader::Fragment, ":/shaders/fragment_shader.gl");

Notre Shader Program contient simplement un Vertex Shader et un Fragment Shader. L'initialisation est presque terminée, la dernière étape consiste à lier les différents shaders composant le programme pour finaliser le pipeline de rendu. En cas d'erreur, le log peut être récupéré dans une QString grâce à la fonction log :

 
Sélectionnez

    if (!m_program.link())
	    QString error = m_program.log();
    ...
}

Si on avait voulu écrire directement le code dans le fichier .cpp sous forme de chaîne de caractères, il aurait fallu créer un objet de type QGLShader, l'initialiser en fournissant le type de shader et le code GLSL puis le compiler :

 
Sélectionnez

void HeightmapWidget::initializeGL()
{
    QGLShader vertex_shader(QGLShader::Vertex, this);
    const char* vertex_shader_source = "...";
    vertex_shader.compileSourceCode(vertex_shader_source);
    m_program.addShader(vertex_shader);
    m_program.link();
    ...
}

4-B. Le code source des shaders

Le contexte de rendu OpenGL est maintenant prêt pour utiliser le pipeline programmable, mais nous n'avons pas encore parlé du code des shaders. Pour débuter, un exemple de Vertex Shader et de Pixel Shader très simple va être présenté. Ces deux shaders se contentent de reproduire le comportement du pipeline fixe. Des exemples de shaders plus avancés seront présentés dans le chapitre suivant, sur la gestion des lumières, des ombres et des textures. Cependant, le code GLSL n'étant pas spécifique à Qt, le lecteur intéressé se reportera aux nombreux tutoriels présents sur la rubrique Jeux de Developpez et sur Internet.

Premier shader appelé dans le pipeline de rendu 3D, le Vertex Shader est exécuté pour chaque vertex composant le polygone à afficher. Il reçoit les paramètres d'un vertex en entrée (au minimum la position du vertex mais il peut également recevoir sa couleur, son vecteur normal, etc.) et renvoie des paramètres en sortie pour chaque vertex (également au minimum la position du vertex transformé).

Les variables d'entrées sont précédées des mots-clés in ou uniform (depuis la version 1.3 de GLSL, le mot-clé in remplace le mot-clé attribute). Les variables in sont les tableaux de données sur lesquels s'applique le shader. Comme un shader est exécuté en parallèle sur chaque élément, cela implique que l'ensemble du tableau n'est pas accessible mais uniquement les données correspondant au vertex courant. Au contraire, les variables uniform sont identiques pour tous les vertices. Elles seront donc utilisées pour passer par exemple la matrice de transformation Projection-Modèle-Vue ou la couleur d'ambiance.

Les variables de sortie sont spécifiées par le mot-clé out. En plus des variables définies par l'utilisateur, le langage GLSL fournit différentes variables "build-in", prêtes à l'emploi. Par exemple, la variable gl_position est utilisée pour envoyer la position du vertex, après transformation, aux étapes suivantes du pipeline.

En plus du mot-clé définissant si une variable est un paramètre d'entrée ou de sortie, il faut également indiquer le type de variable. En plus des types hérités du C (int, float, etc.), le langage GLSL fournit plusieurs autres types facilitant le calcul 3D. Par exemple, le type vec4 représente un vecteur composé de quatre composantes et le type mat4 définit une matrice de 4x4. Il est courant de manipuler les coordonnées 3D avec quatre composantes au lieu de trois. La raison provient du fait que l'ensemble des transformations possibles en 3D (c'est-à-dire trois rotations et une translation) ne peuvent être représentées que par une matrice 4x4. Puisqu'il n'est possible de multiplier une matrice 4x4 que par un vecteur de dimension 4 et que l'on préfère en général ne manipuler qu'un seul type de vecteur, on se limite habituellement aux vecteurs de dimension 4. Voir la FAQ Mathématiques pour les jeux pour plus de détails.

vertex_shader.gl
Sélectionnez

#version 130
in vec4 vertex;
uniform mat4 matrixpmv;
void main(void)
{
    gl_Position = matrixpmv * vertex;
}

Le Fragment Shader est appelé ensuite dans le pipeline pour chaque pixel apparaissant à l'écran. Il permet de modifier la couleur de chaque pixel dynamiquement, technique très utilisée entre autres pour l'éclairage. Le Fragment Shader présenté applique une couleur fixe (spécifiée dans la variable fixed_color) à chaque pixel. Nous verrons plus tard comment transmettre cette variable depuis notre application au shader. La couleur finale du pixel est transmise aux étapes suivantes du Pipeline 3D dans une variable de type vec4 (représentant une couleur en RGBA : rouge, vert, bleu et transparence).

fragment_shader.gl
Sélectionnez

#version 130
out vec4 color;
uniform vec4 fixed_color;
void main(void)
{
    color = fixed_color;
}

4-C. Manipulation des matrices à l'aide de Qt

Jusqu'à maintenant, nous avons utilisé les fonctions fournies par OpenGL pour définir les paramètres de projection (glMatrixMode, glLoadIdentity, glPerspective, glTranslate, gluLookAt, etc.) Cependant, ces fonctions sont maintenant dépréciées et la méthode recommandée est d'envoyer directement aux shaders les matrices de projection. C'est le but de la variable matrixpmv utilisée dans le Vertex Shader précédent.

Le développeur doit donc maintenant implémenter ses propres fonctions de manipulation des matrices ou utiliser une librairie tierce. Dans ce but, Qt fournit ces outils de manipulation des matrices. Dans notre cas, nous utiliserons exclusivement la classe QMatrix4x4 qui représente une matrice carrée 4x4. Cette classe est spécialement optimisée pour les calculs matriciels utilisés dans la programmation 3D. Voici un exemple de manipulation de matrices modèle-vue-projection utilisant la classe QMatrix4x4. Les fonctions rotate, translate, perspective, etc. sont similaires aux anciennes fonctions OpenGL utilisées dans le début du tutoriel.

Les paramètres utilisés sont les mêmes que ceux présentés dans le chapitre 2-C : x_rot, y_rot et z_rot représentent les rotations autour du point (0, 0, 0) et distance représente la distance entre ce point et l'observateur. Pour des raisons de lisibilité, les trois matrices (projection, modèle et vue) sont bien différenciées mais il est possible d'utiliser une seule matrice.

 
Sélectionnez

void HeightmapWidget::paintGL()
{
    QMatrix4x4 projection;
    projection.perspective(30.0, 1.0 * width() / height(), 0.1, 100.0);
 
    QMatrix4x4 model;
    model.rotate(x_rot / 16.0, 1.0, 0.0, 0.0);
    model.rotate(y_rot / 16.0, 0.0, 1.0, 0.0);
    model.rotate(z_rot / 16.0, 0.0, 0.0, 1.0);
 
    QMatrix4x4 view;
    view.translate(0.0, 0.0, distance);
    ...
}

Le langage GLSL permet la manipulation des matrices et il serait possible de n'envoyer aux shaders que les paramètres qui varient au cours du temps et faire les calculs dans les shaders. Cela impliquerait que le même calcul soit refait dans les shaders pour chaque vertex à calculer, ce qui pourrait nuire aux performances.

4-D. Passage de paramètres aux shaders

Les shaders sont maintenant initialisés, la matrice de projection est prête, il ne reste plus qu'à envoyer les données de la scène 3D au processeur graphique. Toujours situé dans la fonction paintGL, le code du rendu sera réalisé en utilisant la technique Vertex Buffer Object couplée à un tableau d'indices, comme présenté dans le chapitre précédent. Par défaut, OpenGL utilise le pipeline fixe (attention : OpenGL ES n'a pas de pipeline fixe et il est obligatoire de fournir un QGLShaderProgram), il faut donc dans un premier temps activer le QGLShaderProgram. Pour cela, on utilise la méthode bind. Il est conseillé de restaurer le contexte de rendu par défaut lorsque le rendu est fini avec la méthode release.

 
Sélectionnez

	m_program.bind();
	// utilisation du Program Shader
	m_program.release();

Comme indiqué précédemment, il existe deux types de paramètres que l'on peut passer aux shaders : les Uniform, qui sont constants pour tous les vertex (par exemple la matrice de projection ou la couleur ambiante) et les Attribute, dont la valeur est différente pour chaque vertex. Qt fournit des fonctions pour chaque type : setUniformValue et setAttributeValue et leurs dérivées : les versions Array (pour les Uniform et les Attribute), qui permettent d'envoyer des tableaux de données, et la version Buffer (pour les Attribute uniquement), qui permet de lier un QGLBuffer.

Image non disponible

Les paramètres sont identifiés dans le code GLSL par leur nom. Après compilation, le Shader program conserve un identifiant pour chaque variable du GLSL. Il existe trois méthodes pour passer des paramètres aux shaders : identifier la variable directement à partir de son nom, récupérer l'identifiant d'une variable puis utiliser cet identifiant ou imposer un identifiant constant au Shader Program (pour les Attribute uniquement).

 
Sélectionnez

// identifier la variable directement à partir de son nom
m_program.setUniformValue("param", value);
 
// récupération de l'identifiant
const int param_location = m_program.uniformLocation("param");
m_program.setUniformValue(param_location, value);
 
// imposer l'identifiant
const int param_location = 0;
m_program.bindAttributeLocation("param", param_location);
m_program.setAttributeValue(param_location, value);

On peut envoyer les données aux shaders à différents moments, à partir de l'instant où le programme est compilé : lors de l'initialisation (dans initializeGL ou à chaque mise à jour (dans paintGL). Pour des raisons de performances, on enverra un maximum de paramètres lors de l'initialisation.

Pour simplifier les idées, on pourra adopter la démarche suivante : lors de l'initialisation, on récupère les identifiants des paramètres utilisés dans paintGL, par exemple la matrice de projection, qui sera modifiée par l'utilisateur pour se déplacer dans la vue 3D, ou les Vertex Buffer Object :

 
Sélectionnez

void HeightmapWidget::initializeGL()
{
    ...
    m_matrix_location = m_program.uniformLocation("matrixpmv");
    m_vertices_attribute = m_program.attributeLocation("vertex");

Lors de l'initialisation, on envoie également les paramètres constants en utilisant directement le nom des variables, par exemple la couleur ambiante et les buffers :

 
Sélectionnez

    m_program.setUniformValue("fixed_color", QColor(Qt::red));
 
    m_vertexbuffer.bind();
    m_program.setAttributeBuffer(m_vertices_attribute, GL_FLOAT, 0, 3);
    m_vertexbuffer.release();
    ...
}

Dans la fonction paintGL(), on envoie les variables en utilisant leur identifiant :

 
Sélectionnez

void HeightmapWidget::paintGL()
{
    ...
    m_program.setUniformValue(m_matrix_location, projection * view * model);

L'utilisation des Vertex Buffer Object est similaire à la méthode présentée dans les chapitres précédents. La fonction glVertexPointer, est remplacée par la fonction Qt setAttributeBuffer. Cette fonction prend simplement l'identifiant du buffer à la place d'un pointeur constant vers les données. Il faut également préciser à OpenGL que l'on utilise un buffer comme variable avec la fonction enableAttributeArray.

 
Sélectionnez

    vertex_buffer.bind();
    m_program.enableAttributeArray(m_vertices_attribute);
    m_program.setAttributeBuffer(m_vertices_attribute, GL_FLOAT, 0, 3);
    vertex_buffer.release();

Le rendu du terrain est réalisé avec la fonction glDrawElements étant donné que nous utilisons un tableau d'indices :

 
Sélectionnez

    index_buffer.bind();
    glDrawElements(GL_TRIANGLES, m_indices.size(), GL_UNSIGNED_INT, NULL);
    index_buffer.release();

Pour terminer, une bonne pratique consiste à libérer les buffers précédemment utilisés.

 
Sélectionnez

    m_program.disableAttributeArray(PROGRAM_VERTEX_ATTRIBUTE);
}

Les performances obtenues sont similaires à celles de la version utilisant le pipeline fixe.

5. Ajouter des lumières et des textures

Les shaders sont un formidable outil mis à disposition des développeurs pour personnaliser le rendu 3D. Le but de ce tutoriel n'est pas d'entrer en détail dans les techniques les plus avancées de rendu 3D mais d'illustrer l'utilisation des shaders. Nous allons présenter deux exemples simples de shaders : un modèle d'illumination de Phong et l'utilisation de textures. Ces exemples, bien que basiques au regard de la puissance des shaders, sont un bon moyen de se familiariser avec certains concepts courants en programmation 3D et avec l'utilisation des shaders. Le lecteur désirant approfondir ce domaine se reportera aux références données.

5-A. Le modèle de Phong

Le modèle d'illumination de Phong est un modèle empirique décomposant la lumière en trois composantes :

  • la composante ambiante : correspond à l'éclairage ambiant de la scène, provenant de la réflexion multiple ; elle est constante pour chaque vertex ;
  • la composante diffuse : correspond à la lumière incidente sur l'objet 3D et partant dans toutes les directions ; elle ne dépend que de l'angle d'incidence entre le vecteur lumière et le vecteur normal ;
  • la composante spéculaire : correspond à la lumière réfléchie sur l'objet 3D ; elle dépend de l'angle d'incidence et de l'angle de réflexion entre le vecteur normal et le vecteur observateur.
Image non disponible
(source : Wikimedia Commons)

Le détail des calculs mathématiques ne sera pas donné ici. Nous allons par contre expliquer à quoi correspondent les différents vecteurs utilisés. Pour chaque vertex, trois points dans l'espace 3D sont pris en compte : la position de l'observateur et la position de la lumière, qui sont constantes pour tous les vertices, et la positon de chaque vertex. Le vecteur observateur correspond au vecteur allant du vertex à l'observateur. Le vecteur lumière correspond au vecteur allant de la lumière au vertex. Le vecteur normal correspond au vecteur partant du vertex et perpendiculaire à la surface. Le vecteur réfléchi correspond au vecteur symétrique du vecteur lumière par rapport au vecteur normal. Tous ces vecteurs doivent être normalisés avant utilisation. Le langage GLSL fournit la fonction normalize() dans ce but.

Il faut donc fournir de nombreuses informations aux shaders pour ce modèle d'illumination : les couleurs des lumières (ambiante, diffuse et spéculaire) et des matériaux, la position des lumières, la position de l'observateur et les vecteurs normaux à la surface pour chaque vertex. Pour les vecteurs normaux, nous utiliserons une Normal Map, c'est-à-dire une image pour laquelle chaque composante de la couleur (rouge, vert, bleu) correspond aux composantes du vecteur normal (x, y, z), chaque pixel correspondant à un vertex. Dans la troisième partie de ce tutoriel, sur le calcul GPGPU, nous présenterons en détail le calcul de vecteurs normaux et la génération de cette Normal Map.

5-B. Chargement de la Normal Map

Chaque coordonnée étant stockée dans une image au format RGB donc normalisé entre 0 et 255, il suffit de normaliser ces coordonnées entre -1 et 1 pour avoir les coordonnées en 3D. Le code ne présente pas de difficulté particulière et est très similaire au code de chargement des vertices.

vertex_shader.gl
Sélectionnez
img = QImage(":/normals.png");
m_normals.reserve(m_indices.size());
for(int z = 0; z < vertices_by_z; ++z)
{
    for(int x = 0; x < vertices_by_x; ++x)
    {
        QVector3D normal;
        QRgb rgb = img.pixel(x, z);
        normal.setX(1.0 * qRed(rgb)   / 125.0 - 1.0);
        normal.setY(1.0 * qGreen(rgb) / 125.0 - 1.0);
        normal.setZ(1.0 * qBlue(rgb)  / 125.0 - 1.0);
        normal.normalize();
        m_normals.push_back(normal);
    }
}

La génération de la Normal Map utilisée sera présentée dans la partie GPGPU de ce tutoriel. Voici à quoi elle ressemble :

Image non disponible

5-C. Afficher les lumières avec les shaders

Pour simplifier, nous n'allons présenter que le code des composantes ambiante et diffuse du modèle de Phong. La composante spéculaire permet d'ajouter un effet de reflet à la surface, ce qui est surtout intéressant pour des matériaux tels que l'eau ou le métal.

Pour calculer l'ombrage, il faut fournir au shader les informations suivantes : le vecteur normal pour chaque vertex et la direction de la lumière. Dans le Vertex Shader, on détermine le coefficient d'atténuation en calculant le produit scalaire entre le vecteur normal et le vecteur correspondant à la direction de la lumière avec la fonction dot. Les valeurs négatives du produit scalaire correspondent au cas où la lumière éclaire la face postérieure d'un triangle. On normalise donc entre 0 et 1 avec la fonction max. Le résultat est envoyé au Fragment Shader via la variable color_factor :

vertex_shader.gl
Sélectionnez
#version 130
 
in vec4 normal;
out float color_factor;
uniform vec4 light_direction;
 
void main(void)
{
    color_factor = max(dot(normal, light_direction), 0.0);
}

Dans le Fragment Shader, on récupère la variable color_factor. La couleur finale est le produit de la couleur ambiante et du coefficient d'atténuation. On affecte le résultat à la variable de sortie color.

fragment_shader.gl
Sélectionnez
#version 130
 
in float color_factor;
out vec4 color;
uniform vec4 ambiant_color;
 
void main(void)
{
    color = color_factor * ambiant_color;
}

Du côté des shaders, il ne reste plus qu'à calculer la position du vertex dans le repère de la caméra. Pour cela, il suffit de calculer le produit de la matrice de projection et la position du vertex et d'affecter le résultat dans la variable build-in gl_Position.

vertex_shader.gl
Sélectionnez

in vec4 vertex;
uniform mat4 matrixpmv;
 
void main(void)
{
    ...
    gl_Position = matrixpmv * vertex;
}

Du côté de l'application, il nous faut calculer les données et les envoyer au GPU. La méthode des Vertex Buffer Object est utilisée pour transmettre les positions des vertices et les vecteurs normaux. Les paramètres constants (direction de la lumière, la couleur ambiante, la matrice de transformation) sont envoyés comme Uniform :

 
Sélectionnez

m_program.setUniformValue("ambiant_color", QVector4D(0.4, 0.4, 0.4, 1.0));
m_program.setUniformValue("light_position", QVector4D(1.0, 1.0, 1.0, 1.0));
m_program.setUniformValue("matrixpmv", projection * view * model);

Voici le résultat de la heightmap obtenu avec les composantes ambiante et diffuse :

Image non disponible

5-D. Appliquer une texture

L'ajout d'une texture sur notre exemple de heightmap nécessite de charger et lier la texture au contexte OpenGL, de définir pour chaque vertex une coordonnée dans le repère de la texture puis de modifier les shaders pour récupérer cette coordonnée et lire la couleur correspondante dans la texture. Voici la texture utilisée :

Image non disponible

Pour lier une texture à un contexte OpenGL, on utilise habituellement les fonctions OpenGL glGenTextures, glBindTexture et glTexImage2D (voir le tutoriel d'introduction à OpenGL et Qt pour le détail de la méthode). Ici, nous allons utiliser une autre méthode. En effet, Qt fournit différentes fonctions pour manipuler les textures, en particulier une fonction pour lire directement un fichier image et le charger dans un contexte OpenGL : bindTexture(). Cette fonction retourne un identifiant de type GLuint pour cette texture, qui sera utilisé lors de l'affichage.

initializeGL()
Sélectionnez

m_texture_location = bindTexture(":/texture.png");

Dans la fonction de rendu, un appel à la fonction OpenGL glBindTexture permet d'activer la texture à utiliser :

paintGL()
Sélectionnez

glBindTexture(GL_TEXTURE_2D, m_texture_location);

Pour appliquer une texture, OpenGL a également besoin de pouvoir faire la correspondance entre un vertex et un point sur la texture. Pour cela, il faut fournir un tableau contenant les coordonnées (x, y) pour chaque vertex. Nous utiliserons un tableau de QVector2D pour les stocker :

 
Sélectionnez

QVector<Qvector2D> m_textures;

Les coordonnées de texture sont normalisées entre 0 et 1, c'est-à-dire que le coin en haut à gauche de la texture correspond au point (0, 0) et le point en bas à droite correspond au point (1, 1). Dans notre exemple de heightmap, le calcul des coordonnées de texture est relativement simple : chaque pixel de l'image de la texture correspond à un vertex, on modifie donc simplement la boucle de calcul des positions des vertices :

 
Sélectionnez

void HeightmapWidget::initializeGL()
{
	QVector2D coordonnees;
    for(int z = 0; y < vertices_by_z; ++z)
    {
        for(int x = 0; x < vertices_by_x; ++x)
        {
            // calcul de la position des vertices
 
            coordonnees.setX(1.0 * x / quads_by_x);
            coordonnees.setY(1.0 - 1.0 * z / quads_by_z);
            m_textures.push_back(coordonnees);
        }
    }

Pour passer ces coordonnées au processeur graphique, on utilise encore un Vertex Buffer Object chargé au moment de l'initialisation.

 
Sélectionnez

    m_texturebuffer.create();
    m_texturebuffer.bind();
    m_texturebuffer.allocate(m_textures.constData(), sizeof(QVector2D) * m_textures.size());
    m_texturebuffer.release();
 

Dans le Vertex Shader, on prend en entrée les coordonnées de texture puis on récupère la couleur correspondante dans la texture avec la fonction texture. La texture elle-même est passée comme paramètre de type sampler2D.

vertex_shader.gl
Sélectionnez

in vec2 texture_coordonnees;
out vec4 texture_color;
...
void main(void)
{
    texture_color = texture(texture2d, texture_coordonnees.st);
}

Le Fragment Shader reçoit en entrée la couleur interpolée à partir des couleurs de la texture envoyées par le Vertex Shader.

fragment_shader.gl
Sélectionnez

#version 130
in vec4 texture_color;
out vec4 color;
 
void main(void)
{
    color = color_ambiant * color_texture;
}

Voici le rendu généré avec la texture choisie :

Image non disponible

En appliquant la texture et les ombres, on obtient un effet 3D intéressant :

Image non disponible

5-E. Calcul dans le Vertex Shader vs dans le Fragment Shader

En fait, pour calculer la couleur de la texture pour un vertex, on a deux possibilités : soit on transmet les coordonnées de la texture entre le Vertex Shader et le Fragment Shader et on calcule dans ce dernier la couleur correspondante de la texture, soit on calcule la couleur de la texture dans le Vertex Shader et on transmet la couleur entre le Vertex Shader et le Fragment Shader. La première version donne le rendu suivant (détail après agrandissement) :

Image non disponible

La seconde version donne le rendu suivant (même détail affiché) :

Image non disponible

On observe que le résultat n'est pas identique. Il faut bien comprendre ce qui se passe lorsque l'on transmet des données entre le Vertex Shader et le Fragment Shader pour comprendre le rendu obtenu.

Pour chaque vertex que l'on affiche, le GPU crée une instance du Vertex Shader. Dans notre exemple, cela veut dire que l'on a 955206 vertices et donc 955206 threads pour le Vertex Shader. Le Fragment Shader est instancié pour chaque pixel de la fenêtre de rendu. Par exemple, pour une fenêtre de rendu de 800 par 600, on aura donc 480000 pixels et 480000 threads pour le Fragment Shader.

On comprend alors aisément que les données ne sont pas envoyées directement entre les shaders. Elles sont en fait interpolées : la valeur d'une variable in du Fragment Shader est en fait la combinaison de la variable out correspondante, provenant de plusieurs instances différentes du Vertex Shader. Le passage d'une primitive définie par des vertex à un ensemble de pixels visibles à l'écran est effectué par le moteur de rastérisation.

Image non disponible

En fonction du traitement que l'on souhaite faire, le rendu sera meilleur s'il est fait dans le Vertex Shader ou dans le Fragment Shader. De même, les performances obtenues seront différentes selon le shader dans lequel on fait les calculs. Il sera parfois nécessaire de faire un compromis entre qualité du rendu et performances.

6. Réaliser un rendu off-screen

OpenGL dispose d'un mécanisme permettant de rediriger le rendu d'une scène 3D dans un buffer au lieu de l'afficher directement à l'écran. L'intérêt principal est de pouvoir profiter de l'accélération matérielle pour générer une image (ou pour récupérer le résultat d'un calcul effectué sur un processeur graphique sous la forme d'une image).

Nous allons présenter les pBuffer et les Frame Buffer Object (FBO) à travers un exemple simple : nous allons dessiner dans le Fragment Shader des cercles concentriques de différentes couleurs.

6-A. Utilisation de QGLPixelBuffer

La classe QGLPixelBuffer permet de manipuler les pBuffer avec Qt.

 
Sélectionnez

QGLPixelBuffer m_pbuffer;

L'initialisation prend en paramètre la taille du buffer et le format utilisé. Le format correspond au format d'affichage de contexte OpenGL (il permet notamment l'activation du double buffering). La fonction format permet de récupérer le QGLFormat du contexte openGL courant.

 
Sélectionnez

    heightmapWidget::heightmapWidget(QWidget *parent) :
    m_pbuffer(QSize(512, 512), format(), this)
{

Cependant, dans notre cas, nous souhaitons créer un Pixel Buffer Object avec une taille identique au Frame Buffer Object, pour pouvoir comparer la différence de performance. Pour cela, on va créer Pixel Buffer Object sur le tas :

 
Sélectionnez

    // QGLPixelBuffer* m_pbuffer;
    m_pbuffer = new QGLPixelBuffer(QSize(512, 512), format(), this)
{

Nous allons maintenant générer une nouvelle texture dans laquelle sera stockée l'image produite. L'appel à la méthode generateDynamicTexture permet de générer une texture OpenGL de même taille que celle du pBuffer. L'identifiant de la texture est récupéré sous forme d'entier non signé dans la variable m_pbuffer_location.

La méthode bindToDynamicTexture permet de lier le contenu du pBuffer à une texture : dès que le pBuffer sera modifié, la texture liée sera mise à jour automatiquement. Malheureusement, cette fonctionnalité n'est pas disponible sur toutes les plateformes, notamment Linux (serveur X11). Cette fonction retourne un boolean indiquant si cette fonctionnalité est prise en charge par le système. Si ce n'est pas le cas, il faudra effectuer manuellement la mise à jour de la texture après modification.

 
Sélectionnez

    m_pbuffer_location = m_pbuffer->generateDynamicTexture();
    has_pbuffer =  m_pbuffer->bindToDynamicTexture(m_pbuffer_location);

La génération de la texture sera réalisée dans un Fragment Shader. Nous créons donc un Program Shader chargé d'appliquer ce Fragment Shader sur chaque pixel de notre texture de rendu. Nous passons les dimensions de la texture pour permettre de centrer notre rendu.

 
Sélectionnez

    m_program_pbuffer.addShaderFromSourceFile(QGLShader::Fragment, ":/pbuffer.glsl");
    m_program_pbuffer.link();
    m_program_pbuffer.bind();
    m_program_pbuffer.setUniformValue("pbuffer_size", QVector2D(m_pbuffer->width(), m_pbuffer->height()));
    m_program_pbuffer.release();

Le code du shader n'introduit pas de nouveau concept, il produit simplement des cercles concentriques à l'aide la fonction GLSL sin avec une couleur dépendant de la position du pixel par rapport au centre.

pbuffer.glsl
Sélectionnez

#version 130
 
uniform vec2 pbuffer_size;
out vec4 color;
 
void main(void)
{
    vec2 FragCoord = vec2(gl_FragCoord.x / pbuffer_size.x - 0.5, gl_FragCoord.y / pbuffer_size.y - 0.5);
    float radius = sqrt(FragCoord.x * FragCoord.x + FragCoord.y * FragCoord.y);
    float gray = sin(radius * 200.0);
 
    color = vec4(FragCoord.x * gray, FragCoord.y * gray, 0.0, 1.0);
}

Il ne reste plus qu'à modifier la fonction de rendu paintGL pour ajouter le rendu off-screen avant le code du rendu à l'écran. L'appel à la méthode makeCurrent permet d'utiliser le contexte OpenGL du pBuffer : l'image générée par notre Fragment Shader sera donc stockée dans le pBuffer et non à l'écran.

 
Sélectionnez

void HeightmapWidget::paintGL()
{
    m_pbuffer->makeCurrent();
    m_program_pbuffer.bind();

Nous traçons simplement un carré dans lequel sera dessinée la texture. Pour des raisons de simplicité, nous utilisons ici les primitives glVertex. Dans le cas où la liaison dynamique de texture n'est pas supportée, nous recopions manuellement le contenu du Vertex Buffer Object dans la texture avec la méthode updateDynamixTexture.

 
Sélectionnez

    glClear(GL_COLOR_BUFFER_BIT);
    glBegin(GL_QUADS);
        glVertex2f(-1.0, -1.0);
        glVertex2f(-1.0, 1.0);
        glVertex2f(1.0, 1.0);
        glVertex2f(1.0, -1.0);
    glEnd();
 
    if (!has_pbuffer)
        m_pbuffer->updateDynamicTexture(m_pbuffer_location);
 
    m_program_pbuffer.release();

On réactive le contexte OpenGL du widget avec makeCurrent. La texture est ensuite liée au contexte à l'aide la fonction OpenGL glBindTexture.

 
Sélectionnez

    makeCurrent();
    glBindTexture(GL_TEXTURE_2D, m_pbuffer_location);

Voici le rendu final de notre exemple :

Image non disponible

6-B. Utilisation de QGLFrameBufferObject

Les Frame Buffer Object permettent également de réaliser du rendu off-screen et de générer des images à l'aide d'OpenGL. De plus, ils présentent un certain nombre d'avantages par rapport aux Pixel Buffer Object, par exemple :

  • des performances légèrement plus élevées en général, dues à l'absence de changement de contexte OpenGL ;
  • les Frame Buffer Object sont une extension OpenGL pure, ne dépendant pas de bibliothèque spécifique au système utilisé, ce qui les rend plus portables que les Pixel Buffer Object.

Les Frame Buffer Object sont gérés dans Qt à l'aide de la classe QGLFrameBufferObject. À la différence des Pixel Buffer Object, il est nécessaire d'avoir un contexte OpenGL actif au moment de l'initialisation. Il n'est donc pas possible de les initialiser dans la liste d'initialisation dans le constructeur :

 
Sélectionnez

class HeightmapWidget : public QGLWidget
{
private:
    QGLFramebufferObject m_fbo;
public:
    HeightmapWidget(QWidget *parent) :
        QGLWidget(parent),
        m_fbo(512,512) // erreur : pas de contexte OpenGL valide
    {
    }
};

De plus, l'opérateur d'affectation étant privé, nous ne pouvons pas créer notre objet QGLFrameBufferObject dans la pile :

 
Sélectionnez

class HeightmapWidget : public QGLWidget
{
private:
    QGLFramebufferObject m_fbo;
public:
    HeightmapWidget(QWidget *parent) :
        QGLWidget(parent)
    {
        makeCurrent(); // OK : un contexte OpenGL est actif
        m_fbo = QGLFramebufferObject(512, 512); // erreur : l'affectation est impossible
    }
};

Il est donc indispensable de créer notre objet dans le tas sous forme de pointeur. On prendra les précautions nécessaires pour une gestion correcte de la mémoire, en n'oubliant pas d'appeler delete dans le destructeur.

 
Sélectionnez

class HeightmapWidget : public QGLWidget
{
private:
    QGLFramebufferObject* m_fbo;
public:
    HeightmapWidget(QWidget *parent) :
        QGLWidget(parent)
    {
        makeCurrent(); // OK : un contexte OpenGL est actif
        m_fbo = new QGLFramebufferObject(512, 512); // OK : on affecte un pointeur
    }
    ~HeightmapWidget()
    {
        if (m_fbo) delete m_fbo;
    }
};

Le Program Shader étant similaire à celui du chapitre précédent (les couleurs sont changées, pour voir la différence avec le Pixel Buffer Object), nous ne détaillerons que le code du rendu. L'utilisation des Frame Buffer Object ne requiert pas de changement de contexte OpenGL : nous appelons directement les méthodes bind et release pour activer le rendu off-screen.

 
Sélectionnez

void HeightmapWidget::paintGL()
{
    m_program_fbo.bind();
    m_fbo->bind();
    ...
    m_fbo->release();
    m_program_fbo.release();

La texture générée est ensuite liée au contexte OpenGL pour l'affichage du terrain :

 
Sélectionnez

    glBindTexture(GL_TEXTURE_2D, m_fbo>texture());

Le résultat final :

Image non disponible

6-C. Dessiner dans une texture dynamique avec QPainter

Les classes QGLPixelBuffer et QGLFramebufferObject héritent de la classe QPaintDevice, il est donc possible de dessiner dedans directement avec QPainter.

Pour cela, on crée un QPainter avec le QGLPixelBuffer comme paramètre et on peut l'utiliser dans la foulée. Lorsque l'on a fini d'utiliser QPainter, on réactive le contexte du QGLWidget et on active la texture dynamique avec la fonction glBindTexture :

 
Sélectionnez

    QPainter pbuffer_painter(m_pbuffer);
    // On dessine avec le QPainter
    pbuffer_painter.end();
    makeCurrent();
    glBindTexture(GL_TEXTURE_2D, m_pbuffer_location);
Image non disponible

Le code est similaire pour le Frame Buffer Object : l'identifiant GLuint de la texture gérée par le Frame Buffer Object est obtenu avec la fonction texture. De plus, comme QPainter modifie le contexte OpenGL courant, il est nécessaire de restaurer les paramètres du contexte après utilisation de QPainter. En revanche, il n'est pas nécessaire d'appeler la fonction makeCurrent puisque Frame Buffer Object travaille avec le même contexte OpenGL que le widget. Dans notre cas, puisque la plupart des paramètres du contexte sont envoyés directement dans le shader, il faut simplement rétablir les dimensions de la vue :

 
Sélectionnez

    glViewport(0, 0, width(), height());
Image non disponible

Cette technique pourra être utilisée, par exemple, pour afficher un texte défilant sur un panneau lumineux d'une scène représentant une rue ou afficher les images d'une télévision.

7. Overpainting : dessiner en 2D avec QPainter sur une scène 3D

Dans certaines applications, il peut être utile de dessiner des objets en premier plan d'une fenêtre de rendu OpenGL (par exemple un affichage tête haute pour les jeux vidéo). Dans cette partie, nous allons afficher en premier plan de la vue 3D les images de la heightmap et de la texture utilisées pour la génération du terrain.

Image non disponible

Qt dispose d'une classe dédiée au dessin 2D très complète : QPainter. Cette classe permet de dessiner toutes sortes de formes, de la simple ligne aux polygones complexes. Elle est aussi capable de dessiner des images à l'aide des fonctions drawImage et drawPixmap. Dans cet exemple, nous allons utiliser la fonction drawPixmap pour afficher les images et la fonction drawText pour afficher des informations. Pour rappel, la classe QPixmap est optimisée pour l'affichage, contrairement à QImage qui est optimisée pour la manipulation de pixels.

Habituellement, pour réaliser un rendu 3D, on n'a pas besoin d'implémenter la fonction paintEvent. Celle-ci est appelée automatiquement lors de la mise à jour du rendu et appelle la fonction paintGL. Pour réaliser l'overpainting, nous allons devoir modifier cette fonction paintEvent et appeler manuellement la fonction paintGL. Il faut donc dans un premier temps modifier le code de notre timer pour que celui-ci appelle la fonction update à la place de la fonction updateGL :

 
Sélectionnez

    connect(&timer, SIGNAL(timeout()), this, SLOT(update()));}

Ensuite, il faut spécifier à QWidget que l'on prend en charge la mise à jour du background et qu'il n'est pas nécessaire de le faire automatiquement. Pour cela, on ajoute les deux lignes suivantes dans le constructeur :

 
Sélectionnez

    setAttribute(Qt::WA_OpaquePaintEvent);
    setAutoFillBackground(false);}

Dans la fonction paintEvent, on commence par appeler la fonction de rendu 3D OpenGL paintGL. Ensuite, on crée un QPainter pour dessiner sur le widget courant. Lorsque le widget courant est un QGLWidget, QPainter utilise automatiquement OpenGL pour faire le rendu 2D. Lors de la création du contexte de QPainter, les paramètres 3D sont donc modifiés : il est donc nécessaire de les réinitialiser dans paintGL (par exemple d'activer le test de profondeur) :

 
Sélectionnez

    glEnable(GL_DEPTH_TEST);

Normalement, il faudrait aussi redonner les matrices de transformation 3D. Dans notre cas, les matrices sont envoyées directement aux shaders, sans passer par les fonctions glMatrixMode et autres. Il n'est donc pas nécessaire de les initialiser.

Pour le dessin 2D, on utilise les méthodes drawText et drawPixmap pour afficher une image et un texte. Pour terminer, il faut appeler la méthode end pour indiquer que nous en avons fini avec le dessin 2D, sous peine d'avoir des bogues graphiques.

 
Sélectionnez

    QPainter painter(this);
    painter.drawText(width() - 260, 20, "Heightmap :"); 
    painter.drawPixmap(width() - 260, 25, 100, 100, QPixmap(":/heightmaps/secondlife.jpg"));
    painter.drawText(width() - 130, 20, "Texture :");
    painter.drawPixmap(width() - 130, 25, 100, 100, QPixmap(":/textures/side1.png"));
    painter.end();
}

Il faut noter que cet exemple n'est pas du tout optimisé. En effet, le chargement des images dans les QPixmap avec la fonction drawPixmap est effectué à chaque mise à jour du rendu. Pour optimiser, il faudrait charger l'image une seule fois puis la stocker.

8. Gestion des extensions avec QGLContext::getProcAddress()

Les fonctionnalités offertes par les cartes graphiques sont très variables en fonction du constructeur et évoluent à un rythme différent d'OpenGL. Pour gérer cette grande diversité et pour faciliter l'intégration de nouvelles fonctionnalités, OpenGL utilise un système d'extensions : chaque nouvelle fonction est ajoutée dans une extension. Pour pouvoir l'utiliser, il faut donc : vérifier que le matériel supporte la fonctionnalité puis charger la fonction. Le lecteur se reportera au tutoriel Les extensions OpenGL pour avoir des explications détaillées sur la procédure générale à suivre, en particulier sur la syntaxe à utiliser pour déclarer les pointeurs de fonctions.

Il est possible d'utiliser une bibliothèque tierce qui permet de prendre en charge les extensions OpenGL, par exemple GLEW. Dans le cas où l'on souhaite utiliser peu d'extensions et que l'ajout d'une bibliothèque est trop lourd, Qt fournit la fonction getProcAddress pour récupérer une extension OpenGL :

 
Sélectionnez

    PFNGLGENBUFFERSARBPROC glGenBuffers = (PFNGLGENBUFFERSARBPROC) getProcAddress("glGenBuffersARB");
    if (glGenBuffers)
        // la fonction est disponible
    else
        // la fonction n'est pas disponible

précédentsommairesuivant