Outils d'utilisateurs

Outils du Site


qt_opengl_-_utilisation_du_pipeline_programmable

Qt OpenGL - Utilisation du pipeline programmable

Sommaire

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

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

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 :

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

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 :

    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 :

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

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 4×4. 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 4×4. Puisqu'il n'est possible de multiplier une matrice 4×4 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.

// Dans vertex_shader.gl
 
#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).

// Dans fragment_shader.gl
 
#version 130
out vec4 color;
uniform vec4 fixed_color;
void main(void)
{
    color = fixed_color;
}

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

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.

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.

	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.

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

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

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 :

    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 :

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.

    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 :

    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.

    m_program.disableAttributeArray(PROGRAM_VERTEX_ATTRIBUTE);
}

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

qt_opengl_-_utilisation_du_pipeline_programmable.txt · Dernière modification: 2014/12/11 17:18 par gbdivers