Apprendre OpenGL moderne


précédentsommairesuivant

VI. Hello Triangle

Dans OpenGL, tout est en 3D, mais l’écran et la fenêtre sont en réalité un tableau 2D de pixels. Par conséquent, une grande partie du travail d’OpenGL consiste à transformer les coordonnées 3D en coordonnées 2D sur l’écran. Ce travail est réalisé par le pipeline graphique. Celui-ci est divisé en deux grandes parties : la première transforme les coordonnées 3D en coordonnées 2D, la seconde transforme les coordonnées 2D en vrais pixels colorés. Dans ce tutoriel, nous discuterons rapidement du pipeline graphique et nous verrons comment l’utiliser pour afficher de jolis pixels.

Une coordonnée 2D et un pixel sont deux choses différentes. Une coordonnée 2D est une représentation très précise d’un point de l’espace 2D, alors qu’un pixel est une approximation de ce point limitée par la résolution de l’écran ou de la fenêtre.

Le pipeline graphique utilise en entrée un ensemble de coordonnées 3D et les transforme en pixels colorés 2D sur l’écran. Le pipeline graphique se compose d’une suite d’étapes, où chaque étape utilise la sortie de l’étape précédente. Chacune de ces étapes est très spécialisée (chacune réalise une fonction très précise) et chacune peut facilement être exécutée de façon parallèle. La plupart des cartes graphiques récentes possèdent des milliers de petits noyaux de calcul pour traiter rapidement les données du pipeline graphique en exécutant de petits programmes sur le processeur graphique (GPU), à chaque étape du pipeline. Ces petits programmes sont appelés shaders.

Certains de ces shaders sont configurables par le développeur, ce qui permet d’écrire ses propres shaders et de remplacer les shaders par défaut. Cela donne un contrôle bien plus fin sur certaines parties du pipeline, et permet d’économiser du temps CPU car ils s’exécutent sur le GPU. Les shaders sont écrits dans le langage GLSL (OpenGL Shading Language), que nous explorerons plus dans le prochain tutoriel.

Une représentation de toutes les étapes du pipeline est figurée ci-dessous. Les parties bleues représentent les shaders qui peuvent être réécrits.

Le pipeline graphique d'OpenGL

On remarque que le pipeline graphique contient un grand nombre de sections, chacune étant chargée d’une opération spécifique pour convertir les sommets (vertex) en pixels. Nous allons expliquer brièvement chacune de ces étapes, pour donner un bon aperçu du fonctionnement du pipeline.

En entrée du pipeline, on trouve une liste de trois coordonnées 3D qui formeraient ici un triangle dans un tableau appelé « Vertex Data ». Ces données forment un ensemble de sommets. Un sommet est un ensemble de données, associé à chacun des points que nous définirons par ses coordonnées 3D. Ces données sont représentées en utilisant des attributs de sommets, qui peuvent contenir n’importe quelles valeurs souhaitées, mais pour simplifier nous supposerons que chaque sommet consiste juste en une position 3D et une couleur.

Pour qu’OpenGL puisse traiter ces collections de coordonnées et de couleurs, OpenGL doit être informé de quel type de rendu on souhaite obtenir avec ces données. Voulons-nous que ces données soient traitées comme de simples points, comme un ensemble de triangles, ou peut être juste comme une ligne ? Ces informations sont appelées primitives et sont transmises à OpenGL lors de l’appel des fonctions de dessin. Certains de ces attributs sont GL_POINTS, GL_TRIANGLES et GL_LINE_STRIP.

La première étape du pipeline est le vertex shader qui utilise en entrée un simple sommet. Son rôle principal est de transformer les coordonnées 3D en d’autres coordonnées 3D (plus de détails par la suite) et permet de réaliser des opérations de base sur les attributs des sommets.

L’étape de l'assemblage des primitives (primitive assembly) utilise en entrée tous les sommets (ou chaque sommet si GL_POINTS est choisi) issus du vertex shader qui forment une primitive, et assemble les points de cette forme primitive ; ici un triangle.

La sortie de cette étape est passée au geometry shader qui traite l’ensemble des sommets qui forment une primitive et qui a la capacité de générer d’autres formes en créant de nouveaux sommets pour créer de nouvelles primitives. Dans cet exemple, il générerait un second triangle à partir de la forme donnée.

La sortie du geometry shader est ensuite fournie à l’étape de rasterization qui fait correspondre aux primitives les pixels de l’écran final, pour donner des fragments qui sont traités par le fragment shader. Avant cela, on procède au clipping qui écarte les fragments situés en dehors de la vue, afin de ne pas traiter de données inutiles.

Dans OpenGL, un fragment contient toutes les données requises pour afficher un pixel.

Le but principal du fragment shader est de calculer la couleur d’un pixel et c’est en général l’étape au cours de laquelle tous les effets avancés d’OpenGL sont mis en œuvre. Le fragment shader contient des données sur la scène 3D, qu’il utilise pour calculer la couleur finale d’un pixel (comme les éclairages, les ombres, la couleur de la lumière, etc.).

Après que les couleurs ont été déterminées, l’objet final est ensuite passé à une ultime étape appelée le test alpha et la fusion (blending). Cette étape vérifie la profondeur (et le stencil) (détails plus loin) correspondante du fragment et l’utilise pour vérifier si le fragment est devant ou derrière d’autres objets pour éventuellement l’écarter. Cette étape vérifie aussi les valeurs alpha (valeur indiquant la transparence) et mélange les objets en conséquence. Ainsi, même si la couleur d’un pixel est calculée par le fragment shader, la couleur finale peut être très différente lorsque l’on affiche plusieurs triangles.

Comme on le voit, le pipeline graphique est un ensemble très complexe qui contient de nombreuses parties configurables. Cependant, dans la plupart des cas, on travaillera seulement avec le vertex shader et le fragment shader. Le geometry shader est optionnel et, en général, on utilise celui par défaut.

En OpenGL moderne, il faut définir au moins le vertex shader et le fragment shader soi-même (il n’en existe pas par défaut dans le GPU). Ainsi, il est souvent assez difficile de commencer l’apprentissage de l’OpenGL moderne, car des connaissances importantes sont nécessaires avant de pouvoir afficher un simple triangle. Lorsque vous aurez affiché un triangle à la fin de ce chapitre, vous en saurez déjà bien plus sur la programmation graphique.

VI-A. Sommets

Pour commencer à afficher quelque chose, nous devons donner à OpenGL des sommets en entrée. OpenGL est une bibliothèque graphique 3D, toutes les coordonnées sont donc en 3D (x, y, z). OpenGL ne fait pas que transformer les coordonnées 3D en pixels 2D sur l’écran ; OpenGL ne traite les coordonnées 3D que dans l’intervalle [-1.0, +1.0] sur chacun des trois axes (x, y, z). Toutes les coordonnées de cet intervalle (appelées les coordonnées normalisées pour le périphérique) seront visibles sur l’écran (à l’exclusion de celles qui ne le seraient pas pour d’autres raisons).

Puisque nous voulons afficher un seul triangle, nous allons définir trois sommets, chaque sommet ayant une position 3D. Nous les définissons en coordonnées normalisées dans un tableau de réels :

 
Sélectionnez
float vertices[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};

OpenGL opérant dans un espace 3D, un triangle 2D sera contenu dans le plan z=0. De cette façon, le triangle a la même apparence qu’un triangle en 2D.

Coordonnées normalisées
Une fois les coordonnées d’un sommet traitées par le vertex shader, elles doivent être en coordonnées normalisées, soit avec x, y et z dans l’intervalle [-1.0, +1.0]. Toute coordonnée en dehors de cet intervalle sera écartée et ne sera donc pas visible. On peut voir ci-dessous le triangle que nous avons défini (sans l’axe z) :

Représentation des coordonnées normalisées dans un graphe

Contrairement aux coordonnées habituelles de l’écran, l’axe y pointe vers le haut, et le point (0, 0) est au centre du dessin, au lieu d’être en haut à gauche. Vous veillerez à ce que les coordonnées finales se trouvent dans cet espace, autrement les sommets ne seront pas visibles.
Les coordonnées normalisées seront transformées en coordonnées écran en utilisant la transformation de la zone d'affichage (viewport), que l’on aura spécifiée avec glViewport(). Les coordonnées écran obtenues sont ensuite transformées en fragments pour être passées au fragment shader.

Nous allons maintenant fournir ces données de sommets à la première étape du pipeline graphique : le vextex shader. Pour cela, on crée une zone mémoire dans le GPU pour y placer ces données, puis on configure la façon dont OpenGL interprétera ces données et comment ces données seront envoyées à la carte graphique. Le vertex shader traite ensuite les données qui lui ont été fournies.

Cette zone mémoire est représentée par un Vertex Buffer Object (VBO), un tampon qui peut contenir un grand nombre de sommets dans la mémoire du GPU. L’intérêt d’utiliser ces objets tampons est de pouvoir envoyer simultanément un grand ensemble de données à la carte graphique sans avoir à envoyer les sommets un par un. Envoyer des données du CPU vers la carte graphique est assez lent, ainsi on essaiera d’envoyer autant de données que possible en une seule fois. Une fois ces données stockées dans la mémoire de la carte graphique, le vertex shader a un accès direct aux données des sommets, ce qui est très rapide.

Le VBO est le premier objet OpenGL que nous rencontrons. Comme tout objet OpenGL, ce tampon a un identifiant unique que l'on peut générer avec la fonction glGenBuffers() :

 
Sélectionnez
unsigned int VBO;
glGenBuffers(1, &VBO);

OpenGL dispose de beaucoup d’objets tampon et le type pour un VBO est GL_ARRAY_BUFFER. OpenGL permet d’utiliser plusieurs tampons à la fois, tant qu’ils ont des types différents. On peut attacher ce tampon nouvellement créé à la cible GL_ARRAY_BUFFER en utilisant la fonction glBindBuffer() :

 
Sélectionnez
glBindBuffer(GL_ARRAY_BUFFER, VBO);

Dès lors, chaque appel travaillant sur un tampon (dont la cible est GL_ARRAY_BUFFER), modifiera le tampon actuellement lié, c'est-à-dire le VBO. On peut ainsi utiliser la fonction glBufferdata() qui copie les sommets définis dans la mémoire du tampon :

 
Sélectionnez
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

glBufferdata() est une fonction dédiée pour copier les données utilisateur dans le tampon du type défini. Le premier argument spécifie le type de tampon dans lequel les données doivent être copiées : ici le VBO lié à la cible GL_ARRAY_BUFFER.

Le second argument précise la taille des données (en octets) à copier. Un simple sizeof des données des sommets suffit. Le troisième paramètre pointe vers les données à transférer.

Le dernier paramètre spécifie comment nous souhaitons que ces données soient traitées. Cela peut prendre trois formes :

  • GL_STATIC_DRAW : les données ne seront pas modifiées (ou rarement) ;
  • GL_DYNAMIC_DRAW : les données seront souvent modifiées ;
  • GL_STREAM_DRAW : les données seront modifiées à chaque affichage.

Les sommets de notre triangle ne changent pas et restent identiques à chaque rendu, ainsi nous utiliserons l’option GL_STATIC_DRAW. Si nos sommets changeaient souvent, les autres options permettraient à la carte graphique de placer les données dans une zone mémoire ayant un accès plus rapide.

Nous avons jusqu’ici transféré nos données dans une zone mémoire de la carte graphique, gérées par un objet appelé le VBO. Nous allons maintenant créer un vertex shader et un fragment shader pour traiter ces données.

VI-B. Le vertex shader

Le vertex shader est l’un des shaders programmables par l’utilisateur. En OpenGL moderne, il faut au minimum configurer un vertex shader et un fragment shader pour effectuer un rendu ; nous allons ainsi présenter brièvement les shaders et configurer deux shaders très simples pour afficher notre premier triangle. Dans le prochain tutoriel, nous examinerons les shaders plus en détail.

La première chose à faire est d’écrire le vertex shader en langage GLSL (OpenGL Shading Language), puis le compiler pour l’utiliser dans notre application. Ci-dessous est présenté le code source d’un vertex shader très basique en langage GLSL :

 
Sélectionnez
#version 330 core
layout (location = 0) in vec3 aPos;

void main()
{
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

Comme on peut le voir, le GLSL ressemble au C. Chaque shader commence par la déclaration de la version utilisée. Depuis OpenGL 3.3 et pour les versions suivantes, les numéros de version de GLSL correspondent aux numéros de version d’OpenGL (la version 420 de GLSL correspond à la version 4.2 d’OpenGL par exemple). Nous mentionnerons aussi explicitement que nous utilisons les fonctionnalités core-profile.

Ensuite, nous déclarons tous les attributs des sommets comme entrées du vertex shader, avec le mot clé in. Pour l’instant, on se soucie seulement de la position des sommets, nous n’avons donc besoin que d’un seul attribut de sommet. GLSL a un type vecteur qui peut contenir de un à quatre nombres à virgule flottante, ce nombre étant spécifié par le dernier caractère du type. Puisque chaque sommet possède trois coordonnées, nous créons une variable de type vec3, que nous appelons aPos. Nous spécifions aussi l’emplacement de la variable d’entrée avec layout (location = 0) et nous verrons ensuite pourquoi nous utilisons cet emplacement.

Les Vecteurs

La programmation graphique utilise largement le concept mathématique de vecteur (voir chapitre 8), car il permet de représenter les positions et directions dans tout espace ; de plus les vecteurs ont des propriétés très utiles. Un vecteur GLSL a au plus quatre membres qui peuvent être accédés grâce à vec.x, vec.y, vec.z et vec.w respectivement, chacun d’eux pouvant représenter une coordonnée dans l’espace. vec.w n’est pas utilisé comme une coordonnée (ici nous travaillons en 3D, pas en 4D), mais trouve son utilité dans ce qu’on appelle la division de perspective. Nous parlerons des vecteurs plus en détail dans un tutoriel ultérieur.

Pour définir la sortie du vertex shader, nous devons assigner la position des données à la variable prédéfinie gl_Position qui est un vec4 prédéfini. À la fin de la fonction main, ce que nous aurons défini avec cette variable gl_Position sera utilisé en sortie du vertex shader. Puisque notre entrée est un vec3, nous devons le convertir en vec4. Cela est fait en insérant les valeurs de vec3 dans un constructeur vec4 et en assignant la valeur 1,0f à vec.w (nous verrons pourquoi plus tard).

Le vertex shader que nous avons construit est des plus simples, car nous ne faisons aucun traitement sur les données entrées, nous ne faisons que les copier en sortie. Dans une application réelle, les données entrées ne sont pas déjà des coordonnées normalisées et nous aurons à les modifier pour qu’elles soient dans la région visible d’OpenGL.

VI-C. Compiler un shader

Nous avons écrit le code source du vertex shader (archivé dans des chaînes de caractères C), mais pour qu’OpenGL utilise le shader, il doit être compilé dynamiquement lors de l’exécution.

La première opération consiste à créer un objet shader, à nouveau référencé par son identifiant. Nous mémorisons le shader avec un unsigned int et créons le shader avec glCreateShader() :

 
Sélectionnez
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);

Nous précisons le type de shader que nous voulons créer en passant comme argument GL_VERTEX_SHADER pour un vertex shader.

Ensuite, nous attachons le code source du shader à l’objet shader et compilons le shader :

 
Sélectionnez
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

Cette fonction prend l’objet shader à compiler en premier argument ; le second argument spécifie combien de chaînes nous utilisons pour le code source, ici seulement une. Le troisième paramètre est le code source lui-même du vertex shader et nous laissons le dernier paramètre à NULL.

On voudra sans doute tester si la compilation a réussi et sinon traiter les erreurs. Tester le résultat de la compilation s’effectue ainsi :

 
Sélectionnez
int  success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);

Nous définissons un entier pour indiquer le résultat de l’opération, ainsi qu’un tampon pour les messages d’erreurs éventuels. Ensuite, nous testons le résultat. En cas d’erreur, nous affichons le message d’erreur :

 
Sélectionnez
if(!success)
{
    glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}

S’il n’y a pas d’erreur, le vertex shader est désormais compilé.

VI-D. Le fragment shader

Le fragment shader est le second et dernier shader que nous aurons à créer pour afficher le triangle. Ce shader calculera la couleur des pixels. Pour simplifier, ce shader donnera la couleur orange pour tous les pixels.

Les couleurs en programmation graphique sont définies par un tableau de quatre valeurs : rouge, vert, bleu et alpha (transparence), abrégées en RGBA. Pour définir une couleur dans OpenGL ou GLSL, on choisira pour chaque composante une valeur entre 0.0 et 1.0. Si par exemple on choisit 1.0 pour le rouge et le vert et 0.0 pour le bleu, on aura un mélange de rouge et de vert, soit du jaune. On peut ainsi générer 16 millions de couleurs différentes !

 
Sélectionnez
#version 330 core
out vec4 FragColor;

void main()
{
    FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}

Le fragment shader ne requiert qu’une seule variable de sortie, un vecteur de dimension 4 qui définira la couleur de sortie que nous devons calculer nous-mêmes. On peut déclarer les valeurs de sortie avec le mot clef out, que nous appelons FragColor. On assigne ensuite la couleur de sortie (orange) avec une transparence de 1.0, c'est-à-dire opaque.

Pour compiler le fragment shader, on procède de la même façon que pour le vertex shader, mais on utilise l’option GL_FRAGMENT_SHADER :

 
Sélectionnez
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

Les deux shaders sont compilés, il reste à lier ces deux objets shader dans un program shader que nous utiliserons pour effectuer le rendu.

VI-E. Le program shader

L’objet program shader est la version finale liée, combinaison des différents shaders. Pour utiliser les shaders compilés, nous devons les lier à un objet program shader, et activer celui-ci pour le rendu. Les shaders de ce programme seront ainsi utilisés lors des appels effectués pour le rendu.

Lors de l’édition de liens du program shader, chaque sortie de shader est utilisée comme entrée du shader suivant. Il peut y avoir des erreurs lors de l’édition des liens, si les sorties ne correspondent pas aux entrées.

La création du program shader est simple :

 
Sélectionnez
unsigned int shaderProgram;
shaderProgram = glCreateProgram();

La fonction glCreateProgram() crée un program shader et retourne l’identifiant de ce nouvel objet. On doit ensuite attacher les shaders compilés à ce program shader, et les lier avec glLinkProgram() :

 
Sélectionnez
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

Comme pour la compilation des shaders, on peut vérifier si l’édition de liens du programme a réussi, et retrouver les erreurs éventuelles. Cependant, à la place de glGetShaderiv(), nous utiliserons glGetProgramiv() :

 
Sélectionnez
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
    glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
    ...
}

Le résultat est un objet programme que l’on peut activer en appelant glUseProgram() avec comme argument le nouveau programme :

 
Sélectionnez
glUseProgram(shaderProgram);

Chaque shader et chaque appel pour le rendu utilisera cet objet.

Ah oui, n’oublions pas de détruire les objets shader une fois intégrés au program shader, ils ne sont plus utiles :

 
Sélectionnez
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

Maintenant, nous allons transmettre les sommets au GPU et lui spécifier comment il doit traiter ces sommets dans le vertex shader et le fragment shader. Nous y sommes, mais pas encore tout à fait. OpenGL ne sait pas comment interpréter les données sommets en mémoire et comment il doit connecter les données des sommets aux attributs du vertex shader. Nous allons lui dire comment faire.

VI-F. Lier les attributs de sommets

Le vertex shader nous permet de spécifier chaque entrée sous la forme d’attributs de sommets et bien que cela autorise une grande flexibilité, cela implique de spécifier quelles parties de nos données correspondent à quels attributs de sommets du shader. Il faut donc spécifier comment OpenGL interprétera les données avant le rendu.

Les données de nos sommets sont formatées ainsi :

Configuration des attributs de sommets en OpenGL
  • chaque coordonnée des positions est mémorisée dans un float codé sur 32 bits (4 octets) ;
  • chaque position est composée de 3 valeurs ;
  • il n’y a pas d’espace libre entre les données, qui sont consécutives dans un tableau ;
  • la première valeur est au début du tampon.

Sachant cela, on peut dire à OpenGL comment il doit interpréter les données (par attribut de sommet) en utilisant glVertexAttribPointer() :

 
Sélectionnez
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

Cette fonction utilise plusieurs arguments qu’il faut examiner :

  • Le premier paramètre spécifie quel attribut nous voulons configurer. Rappelons-nous que avons spécifié la position de l’attribut dans le vertex shader avec layout (location = 0). Cela définit l’endroit de l’attribut à 0, et puisque nous souhaitons passer les données à cet attribut, nous passons 0 en argument.
  • L’argument suivant spécifie la taille de l’attribut. Il s’agit d’un vec3, il est donc composé de trois valeurs.
  • Le troisième argument spécifie le type de données qui est ici GL_FLOAT (une donnée de type vec* est constituée de nombres à virgule flottante dans GLSL).
  • L’argument suivant spécifie si nous souhaitons que les données soient normalisées. Si nous le positionnons à GL_TRUE, toutes les données qui ne seront pas dans l’intervalle [0.0, 1.0] ([-1.0, 1.0] pour les données signées) seront projetées sur cet intervalle. Ici nous laissons cet argument à GL_FALSE.
  • Le cinquième argument se nomme stride. Il détermine l’espace entre les différents ensembles consécutifs d’attributs. Puisque la position qui suit se trouve exactement à une distance de trois fois la taille d’un float, nous spécifions cette valeur pour le stride. Puisque les données dans le tableau sont consécutives les unes aux autres, nous aurions pu aussi spécifier 0 pour le stride et laisser OpenGL calculer lui-même la valeur du stride. À chaque fois que nous aurons plusieurs attributs, nous devrons définir précisément l’espace séparant ces attributs, nous en verrons des exemples plus loin.
  • Le dernier paramètre est de type void* et requiert cet étrange transtypage. Il indique le décalage (offset) où les données de position commencent dans le tampon. Puisque nos données position sont placées au début du tampon, la valeur à mettre est 0. Nous verrons ce paramètre en détail dans la suite.

Chaque attribut tire ses données de la mémoire gérée par un VBO, en l’occurrence celui lié à GL_ARRAY_BUFFER (il peut y avoir plusieurs VBO). Puisque le VBO défini a été lié avant l’appel, l’attribut vertex 0 est donc associé avec les données des sommets.

Maintenant que nous avons spécifié comment OpenGL doit interpréter les données des sommets, nous devons aussi activer l’attribut en question avec gllEnableVertexAttribArray() en passant en argument l’endroit où se trouve l’attribut (les attributs sont désactivés par défaut). Dès lors tout est en place : nous avons initialisé les données dans un tampon en utilisant un VBO, défini un vertex shader et un fragment shader et dit à OpenGL comment lier les données des sommets aux attributs du vertex shader. Afficher un objet dans OpenGL va donc ressembler à cela :

 
Sélectionnez
// 0. Copier nos sommets dans un tampon
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. Initialiser un pointeur vers les attributs des sommets
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  
// 2. Utiliser notre program shader pour l'affichage d'un objet
glUseProgram(shaderProgram);
// 3. Afficher un objet 
someOpenGLFunctionThatDrawsOurTriangle();

Nous aurons à répéter ces opérations à chaque fois que nous voudrons afficher un objet. Mais supposons que nous ayons cinq attributs et peut-être 100 objets différents, lier les tampons et configurer tous les attributs deviendrait rapidement très fastidieux. N’y aurait-il pas un moyen d’archiver ces configurations dans un objet et de lier simplement cet objet pour retrouver l’état voulu ?

VI-G. Le Vertex Array Object

Un Vertex Array Object (VAO) peut être lié exactement comme un VBO et tous les appels d’attributs à partir de ce point seront archivés dans le VAO. L’avantage est qu’après avoir configuré les attributs, on n’a qu’à effectuer l’appel une fois, et à chaque fois que l’on voudra afficher l’objet, on n’aura qu’à lier le VAO correspondant. Cela permet de passer de certaines données de sommets et attributs de sommets à d’autres en se liant seulement à différents VAO. L’état qui est défini est archivé dans le VAO.

Le core-profile OpenGL requiert l’utilisation d’un VAO pour savoir que faire de nos sommets en entrée. Si l’on ne se lie pas à un VAO, OpenGL refuse d’afficher quoi que ce soit.

Un VAO archive les choses suivantes :

  • les appels à glEnable ou à glDisableVertexAttribArray() ;
  • les configurations des attributs via glVertexAttribPointer() ;
  • les Vertex Buffer Objects (VBO) associés aux attributs par les appels à glVertexAttribPointer().
Fonctionnement d'un Vertex Array Object (VAO) et ce qu'il contient

Le processus pour générer un VAO est similaire à celui pour générer un VBO :

 
Sélectionnez
unsigned int VAO;
glGenVertexArrays(1, &VAO);

Pour utiliser un VAO, il suffit de le lier avec glBindVertexArray(). Dès lors, on peut lier/configurer le VBO correspondant et les pointeurs d’attribut et détacher ensuite le VAO pour un usage ultérieur. Dès que l’on veut afficher un objet, on lie simplement le VAO avec les options voulues avant d’afficher l’objet et c’est tout. Le code ressemble à cela :

 
Sélectionnez
// ..:: Initialisation (à faire une seule fois, sauf si l’objet change souvent) :: ..
// 1. Lier le Vertex Array Object (VAO)
glBindVertexArray(VAO);
// 2. Copier les sommets dans un tampon pour qu’OpenGL les utilise
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. Initialiser les pointeurs d’attributs de sommets
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

[...]

// ..:: Code pour l’affichage (dans la boucle de rendu) :: ..
// 4. Afficher l’objet
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
une_fonction_OpenGL_qui_dessine_notre_triangle();

Et c’est tout ! Toutes ces pages pour arriver à cela : un VAO qui mémorise notre configuration d’attributs et quel VBO utiliser. Lorsque l’on veut afficher de nombreux objets, on génère et configure tous les VAO (et aussi les VBO requis et les pointeurs d’attributs) puis on les archive pour un usage ultérieur. Au moment d’afficher un des objets, on prend le VAO correspondant, on le lie, on affiche l’objet et on détache le VAO.

VI-H. Le triangle que nous attendions

Pour afficher les objets souhaités, OpenGL fournit la fonction glDrawArrays() qui trace des primitives en utilisant le shader actuellement actif, la configuration d’attributs définie précédemment avec les données de sommets du VBO (liées indirectement par le VAO).

 
Sélectionnez
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);

La fonction glDrawArrays() utilise en premier argument le type de primitive que nous voulons tracer, ici GL_TRIANGLES. Le second argument spécifie l’index de début du tableau de sommets à afficher, ici 0. Le dernier argument précise combien de sommets doivent être tracés, trois dans notre cas (nous n’affichons qu’un seul triangle grâce à nos données qui contiennent exactement trois sommets).

Compilez le code et vérifiez-le si des erreurs se produisent. Puis vous devriez voir s’afficher le résultat suivant :

Rendu d'un triangle simple avec OpenGL

Le code source se trouve ici.

En cas de problème, vous avez probablement commis une erreur, vérifiez le code entièrement, et si le problème persiste, posez une question sur le forum.

VI-I. Les Element Buffer Objects

Une dernière chose à examiner pour le rendu des sommets est l’Element Buffer Objects (EBO). Pour expliquer le fonctionnement d’un EBO, le mieux est de donner un exemple. Supposons que nous voulions tracer un rectangle plutôt qu’un triangle. On peut afficher un rectangle en affichant deux triangles (OpenGL travaille essentiellement avec des triangles). Nous définirions cet ensemble de sommets :

 
Sélectionnez
float vertices[] = {
    // first triangle
     0.5f,  0.5f, 0.0f,  // top right
     0.5f, -0.5f, 0.0f,  // bottom right
    -0.5f,  0.5f, 0.0f,  // top left 
    // second triangle
     0.5f, -0.5f, 0.0f,  // bottom right
    -0.5f, -0.5f, 0.0f,  // bottom left
    -0.5f,  0.5f, 0.0f   // top left
};

Comme on peut le voir, les données se recouvrent. Nous avons spécifié le sommet bas droit et le sommet haut gauche deux fois ! Le rectangle peut être défini par quatre sommets, et non six. Cela deviendrait très complexe dès que des objets complexes sont à afficher. Une meilleure solution consiste à ne définir les sommets nécessaires qu’une seule fois, et de spécifier ensuite l’ordre dans lequel nous souhaitons afficher ces sommets. Nous aurions donc seulement quatre sommets pour le rectangle, et ensuite à définir dans quel ordre les tracer.

Heureusement un EBO sert à cela. Un EBO est un tampon, juste comme un VBO, qui mémorise des indices qu’OpenGL utilisera pour décider quels sommets sont à afficher. Tout d’abord, spécifions les sommets du rectangle, puis les indices :

 
Sélectionnez
float vertices[] = {
     0.5f,  0.5f, 0.0f,  // top right
     0.5f, -0.5f, 0.0f,  // bottom right
    -0.5f, -0.5f, 0.0f,  // bottom left
    -0.5f,  0.5f, 0.0f   // top left 
};
unsigned int indices[] = {  // Notons que l’on commence à 0!
    0, 1, 3,   // premier triangle
    1, 2, 3    // second triangle
};

Nous devons ensuite créer l’EBO :

 
Sélectionnez
unsigned int EBO;
glGenBuffers(1, &EBO);

De la même façon que pour le VBO, nous lions l’EBO et y recopions les indices avec glBufferData(). Ces appels sont faits entre l’établissement du lien et sa suppression.

Cette fois nous utilisons en paramètre GL_ELEMENT_ARRAY_BUFFER comme type de tampon.

 
Sélectionnez
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

Notons que nous donnerons GL_ELEMENT_ARRAY_BUFFER comme cible de tampon. La dernière chose à faire est de remplacer l’appel pour indiquer que nous affichons les triangles à partir d’un EBO. L’affichage sera fait à partir des indices de l’EBO qui est lié à ce moment-là, grâce à glDrawElements() :

 
Sélectionnez
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

Le premier argument spécifie le mode dans lequel nous voulons afficher, comme avec glDrawArrays(). Le second argument indique le nombre d’éléments à tracer (6 dans cet exemple). Le troisième argument est le type d’indice utilisé, ici GL_UNSIGNED_INT. Le dernier argument nous permet de préciser un décalage dans l’EBO (ou bien de passer un indice de tableau quand on n’utilise pas un EBO), ici nous indiquons 0.

La fonction glDrawElements() prend les indices dans l’EBO actuellement lié à la cible GL_ELEMENT_ARRAY_BUFFER. Cela implique d’attacher l’EBO correspondant à chaque rendu d’un objet avec les indices, ce qui semble fastidieux. Heureusement, un VAO garde la trace des liens avec les EBO. L’EBO en cours lorsqu’un VAO est attaché est archivé en qualité d’EBO de ce VAO. L’attachement du VAO attache donc automatiquement son EBO.

Structure d'un VAO, comprennant l'EBO

Un VAO archive les appels glBindBuffer() lorsque la cible est GL_ELEMENT_ARRAY_BUFFER. Cela signifie qu’il archive les appels de détachement pour être sûr que l’on ne détache pas l’EBO avant de détacher le VAO, sinon, l’EBO ne serait plus configuré.

L’initialisation et l'affichage ressemblent donc à ceci :

 
Sélectionnez
// ..:: Initialisation :: ..
// 1. attacher le Vertex Array Object
glBindVertexArray(VAO);

// 2. copier les sommets dans un VBO
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

// 3. copier le tableau d’indices dans un tampon d’éléments
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

// 4. Établir les pointeurs d’attributs de sommets
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  

[...]
  
// ..:: Affichage (dans la boucle de rendu) :: ..
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)
glBindVertexArray(0);

L’exécution du programme doit donner l’image ci-dessous. Le rectangle vide est constitué de deux triangles.

Un rectangle dessiné grâce aux rendu indexés d'OpenGL

Le mode fil de fer (wireframe)

Pour tracer un triangle en mode fil de fer, on peut configurer la façon dont OpenGL trace ses primitives, via glPolygonMode(GL_FRONT_AND_BACK, GL_LINE). Le premier argument indique que nous voulons appliquer cela à la face avant et à la face arrière, le second argument que nous souhaitons tracer que les bords. Les appels suivants seront tracés en mode fil de fer, à moins que l’on spécifie l’autre mode, celui par défaut, avec

glPolygonMode(GL_FRONT_AND_BACK,GL_FILL).

Si vous avez réussi à tracer un triangle ou un rectangle comme indiqué, félicitations, vous avez passé l’une des phases les plus difficiles de l'apprentissage de l'OpenGL moderne. C’est difficile, car des connaissances poussées sont nécessaires avant de pouvoir afficher un premier triangle. Les tutoriels suivants seront plus faciles à aborder.

VI-J. Ressources supplémentaires

VI-K. Exercices

Afin de bien appréhender les concepts précédents, nous proposons quelques exercices. On vous conseille de les traiter avant d’aborder la suite, pour vous assurer d’avoir bien assimilé cette partie.

  1. Essayer de tracer deux triangles l’un à côté de l’autre en utilisant glDrawArrays() et en ajoutant d’autres sommets à vos données. solution.
  2. Créer maintenant ces mêmes deux triangles en utilisant deux VAO et deux VBO différents. solution.
  3. Créer deux program shader, le second program shader utilisant un fragment shader différent qui affiche un triangle jaune. Afficher ces deux triangles dont l’un est en jaune. solution.

VI-L. Remerciements

Ce tutoriel est une traduction réalisée par Jean-Michel Fray dont l’original a été écrit par Joey de Vries et qui est disponible sur le site Learn OpenGL.


précédentsommairesuivant

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2018 Joey de Vries. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.