Programmation Graphique : CM séance 02
5. OpenGL moderne
5.1. Introduction
OpenGL peut être décrit sous la forme d'un client-serveur :
- le client est l'application, qui tourne sur le CPU ;
- le serveur est la carte graphique, animée par le GPU, qui gère sa propre mémoire.
Le client et le serveur dialoguent via le bus de la carte graphique. Même s'il peut avoir une bande passante énorme (>100 Go/s sur les bus PCIE récents), il peut constituer un goulot d'étranglement, c'est pourquoi il convient de limiter le volume des dialogues, par exemple en stockant le plus possible les données dans le serveur.
L'idée générale est que le client envoie les données et traitements à effectuer au serveur, qui les stocke, puis le client "pilote" les opérations et les dessins à effectuer par le serveur.
Pour augmenter les possibilités de traitement, OpenGL 2.0 a adopté l'usage des shaders :
- petits programmes, compilés par le driver, puis exécutés par le GPU ;
- écrits dans le langage GLSL (GL Shader Langage, inspiré du C).
OpenGL "moderne", versions 3.2+ :
- abandon du mode immédiat de rendu avec
glBegin()
...glEnd()
; - utilisation systématique des shaders ;
-
dépréciation des fonctions :
glBegin
,glEnd
,glVertex
,glColor
(mode immédiat),- les primitives
GL_QUADS
etGL_POLYGON
, glMatrixMode
,glPushMatrix
,glPopMatrix
,glFrustum
,glPerspective
,glOrtho
,glOrtho2D
,glLoadIdentity
,glRotatef
,glTranslatef
,glScalef
,gluLookAt
.
Ces fonctions sont remplacées par des manipulations explicites de structures de données et de matrices, ce qui est plus verbeux mais offre plus de possibilités.
On peut toutefois choisir un niveau de compatibilité :
- le core profile = sans les fonctions dépréciées ;
- le compatibility profile = core + fonctions dépréciées.
5.2. Pipeline de rendu
OpenGL implémente un pipeline de rendu, c'est-à-dire une succession séquentielle d'étapes depuis les données initiales (sommets, couleurs, etc) jusqu'à l'image finale.
Ce pipeline de rendu a considérablement évolué depuis les premières versions ; depuis la version 4.0 il est constitué des étapes suivantes :
block-beta
columns 5
A space B space C
space:5
F space E space D
space:5
G space H space I
space:5
space:2 K space I
A["Vertex Data"] --> B
B("Vertex\nShader") --> C
C("Tessellation\nControl Shader") --> D
D("Tessellation\nEvaluation Shader") --> E
E("Geometry\nShader") --> F
F("Primitive\nSetup") --> G
G("Culling and\nClipping") --> H
H("Rasterization") --> I
I("Fragment\nShader") --> J
J("Per-Fragment\nOperations") --> K("Final Image")
style A fill:palegreen
style B fill:lightpink
style C fill:lavender
style D fill:lavender
style E fill:lavender
style I fill:lightpink
style K fill:lightcyan
Chaque étape est effectuée en parallèle, et distribuée sur les nombreux cœurs du GPU.
Il y a 5 types de shaders, dont 2 sont obligatoires : le Vertex Shader et le Fragment Shader.
Examinons les étapes :
-
-
Les données sont envoyées au serveur :
- le profile compatibility permet d'envoyer les données au moment du dessin ;
- le profile core impose que toutes les données soient stockées dans des buffers objects, qui sont des zones de la mémoire du serveur.
-
Le client demande de dessiner des primitives géométriques, au moyen des commandes de dessin d'OpenGL, par exemple
glDrawArrays
. Il faut au préalable dire quelles données on va utiliser, et comment elles sont organisées en mémoire.
-
-
Vertex Shader (OpenGL 2.0)
- Pour chaque sommet utilisé par une commande de dessin, un Vertex Shader va être appelé, pour traiter les données liées à ce sommet (coordonnées, couleur, normale, texture...).
- Seules les données concernant ce sommet lui sont présentées (le shader n'a pas accès aux autres sommets ou données).
- un shader simple peut transmettre les données telles quelles (pass-through shader), un shader plus évolué peut effectuer des transformations géométriques, des calculs de couleur, etc.
- une application peut définir de multiples Vertex Shaders, mais un seul peut être actif à la fois.
- Si un sommet est utilisé dans plusieurs dessins, une implémentation peut supprimer les appels multiples en mémorisant le résultat du shader dans un cache.
-
Tessellation Shaders (OpenGL 4.0)
- S'ils sont activés, le Tessellation Control Shader reçoit les données en sortie du Vertex Shader puis les envoie au Tessellation Evaluation Shader.
- La tessellation consiste à subdiviser une maille (mesh), par exemple une triangulation, en une maille plus fine, pour améliorer le rendu. Ce calcul peut prendre en compte par exemple la distance à la caméra pour ajuster le niveau de détail.
- Les sommets sont organisés en patch ; les shaders sont appelés pour chaque sommet du patch. Chaque shader a accès aux données de tous les sommets du patch en entrée, mais aussi en sortie (données partagées).
-
Geometry Shader (OpenGL 3.2)
- S'il est activé, traite les primitives géométriques. Par exemple un triangle strip est vu comme une suite de triangles.
- Il reçoit une primitive géométrique à la fois, puis en renvoie zéro, une ou plusieurs. Il peut subdiviser une primitive, interpoler des sommets, modifier la nature d'une primitive (transformer un segment en point, un point en segment), etc.
- Il peut accéder aux primitives ou aux sommets adjacents.
-
- Cette étape réorganise les primitives géométriques, dont les sommets sont issus des étapes précédentes.
-
- Le culling consiste à supprimer certains triangles selon leur face apparente.
- Le clipping consiste à couper les primitives dont une partie sort du viewport, de manière à ce que tous les sommets soient à l'intérieur du viewport.
-
- Le rasterizer découpe chaque primitive en fragments.
- Un fragment est un "pixel candidat", c'est-à-dire qu'il pourra encore être supprimé avant de devenir un pixel dans le framebuffer.
- Chaque fragment reçoit comme paramètres le résultat de l'interpolation des paramètres des sommets (position, couleur, normale, etc).
-
Fragment Shader (OpenGL 2.0)
- Ce shader est obligatoire. C'est la dernière étape où on a un contrôle direct sur la couleur d'un pixel.
- Le Fragment Shader est appelé pour chaque fragment, et reçoit les paramètres interpolés (position, couleur, normale, etc).
- Il peut transmettre la couleur telle quelle (pass-through), ou faire des calculs plus ou moins élaborés, par exemple d'éclairage, de transparence, de texture, etc.
- Il n'a pas accès aux valeurs des autres fragments.
-
- Cette étape est un post-traitement, appliqué aux résultats du Fragment Shader, pour déterminer la couleur finale des pixels dans le framebuffer.
- La visibilité des fragments est déterminée par rapport à divers élements :
- le pixel appartient à la fenêtre (il n'est pas recouvert par une autre fenêtre) ;
- le Z-buffer, si activé ;
- le tampon pochoir (stencil buffer), si activé.
- Des opérations sont réalisées, telles que : la transparence, le mélange (blending), des opérations logiques, etc.
Remarque : il existe aussi un Compute Shader (OpenGL 4.3) qui permet de tirer partie de la puissance du GPU pour effectuer des calculs non graphiques.
5.3. Chargeur OpenGL
Les implémentations d'OpenGL peuvent varier fortement (fabriquant, version, modèle de carte, driver...) ; OpenGL n'est en fait qu'une spécification.
Pour utiliser le core profile, il faut d'abord interroger OpenGL pour obtenir la liste des fonctions disponibles et un pointeur vers chaque fonction.
Ce travail est lourd (il y a des centaines de fonctions) et il est donc délégué à un chargeur, qui ne fait pas partie d'OpenGL à proprement parler.
Il existe plusieurs chargeurs OpenGL, et qui sont tenus régulièrement à jour :
- GLAD, un générateur de chargeur d'extension pour OpenGL, Vulkan, etc ;
- GL3W, spécialisé pour le core profile d'OpenGL 3 et 4 ;
- glbinding, un nouveau chargeur en C++ ;
- etc.
Dans ce cours nous utiliserons GLAD. Il génère un chargeur via un
formulaire en ligne, qui permet de configurer selon ses besoins,
puis générer des fichiers glad.c
, glad.h
et khrplatform.h
(environ 25000 lignes !)
à placer directement dans le répertoire du projet.
Il suffit ensuite d'importer glad.h
(avant glfw3.h
) puis d'appeler
la fonction gladLoadGL
après la création d'un contexte GL :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
5.4. Création des objets
OpenGL manipule de nombreux objets (buffers, shaders, etc) qui sont désignés par des identifiants entiers. Dans la terminologie il sont appelés des noms (OpenGL names), ou encore des handles.
Les fonctions glCreate*
, qui créent un objet, renvoient un identifiant de
type GLuint
; certaines renvoie un GLint
pour signaler une erreur avec
la valeur -1
.
Lorsqu'on a créé un objet, pour l'utiliser il faut ensuite le
lier ou le rendre actif en appelant la fonction glBind*
ou glEnable*
correspondante.
Dans nos exemples, on regroupe la création des objets GL dans la méthode
initGL
, qui est appelée par le constructeur de MyApp
à la fin :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
Lorsqu'il y en aura besoin, on regroupera la destruction des objets GL
dans la méthode tearGL
(tear signifie détruire), qui sera appelée
dans le destructeur de MyApp
.
5.5. Shaders de base
Pour OpenGL, un shader est une chaîne de caractères terminée par 0. Un shader peut être chargé depuis un fichier, ou mémorisé dans une chaîne de caractères constante, ou encore généré au vol.
Les shaders d'OpenGL (et Vulkan) sont écrits dans le langage GLSL :
- compilé par le driver,
- avant exécution ou au runtime,
- dans un format de bytecode appelé SPIR-V : bytecode décompilable, utilisé par la plupart des langages de shaders actuels (GLSL, HLSL, ...). ;
- syntaxe du C, sans les pointeurs ;
- récursion interdite.
Vertex Shader : voici un shader pass-through, qui se contente de transmettre les coordonnées du sommet et sa couleur :
1 2 3 4 5 6 7 8 9 10 |
|
-
Ligne 1 : le numéro de version d'OpenGL minimal demandé est 3.3 (on aurait mis
450
pour la version 4.5, etc ; depuis la version 330, la version de GLSL correspond à la version d'OpenGL). Il est obligatoire de mettre une version car sinon, OpenGL suppose que c'est la version110
, ce qui empêche d'utiliser le core profile. -
Lignes 2 à 4 : déclaration des variables globales
in
etout
reçues et transmises par le shader. Les typesvec3
etvec4
sont prédéfinis, il s'agit de tableaux defloat
de taille 3 et 4. -
Ligne 6 : chaque shader doit avoir une fonction
main
. Celle-ci sera appelée pour chaque sommet. -
Ligne 8 : la variable prédéfinie
gl_Position
reçoit la position calculée.
Fragment Shader : voici un shader pass-through, qui recopie la couleur du fragment :
1 2 3 4 5 6 7 8 |
|
-
Ligne 7, la variable en sortie
fragColor
reçoit la couleur calculée. -
L'opération
vec4(color, 1.0)
permet de fabriquer unvec4
à partir d'unvec3
en rajoutant une coordonnée (ici l'opacité à1.0
).
Voir l'exemple fw21-shader.cpp
.
5.6. Shader program
Pour envoyer un shader à OpenGL, on commence par déclarer le source dans une chaîne de caractère :
const char* m_vertex_shader_text =
"#version 330\n"
...
"void main()\n"
"{\n"
...
"}\n";
La suite est effectuée dans la méthode initGL
:
1 2 3 |
|
- Ligne 1 : on crée un objet shader et on obtient un identifiant
vertex_shader
. - Ligne 2 : on transmet le source
m_vertex_shader_text
pour l'identifiantvertex_shader
. - Ligne 3 : on compile le shader avec la méthode
compile_shader
.
La méthode compile_shader
fait appel à glCompileShader
ci-dessous ligne 4.
La suite du code demande si la compilation a réussi ligne 7,
puis affiche les messages d'erreurs éventuels lignes 10 à 18 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
Exemple de message obtenu si on oublie un ;
:
Compile vertex shader...
### Compilation errors:
0(6) : error C0000: syntax error, unexpected reserved word "void", expecting ',' or ';' at token "void"
On fait de même pour le Fragment Shader :
1 2 3 |
|
Exemple de message obtenu si au lieu de out vec4 fragColor
on utilise la
variable dépréciée gl_FragColoc
:
Compilation messages:
0(6) : warning C7533: global variable gl_FragColor is deprecated after version 120
La liste des shaders utilisés lors d'un rendu est appelé un shader program. On déclare le shader program de cette façon :
1 2 3 4 |
|
La méthode link_program
appelle la fonction glLinkProgram
pour fabriquer l'exécutable qui sera
envoyé au GPU, ci-dessous ligne 4.
Cette fonction peut échouer pour différentes raisons (fonction main
absente, variables in/out
absentes ou trop nombreuses, etc), voir ligne 7.
Les messages obtenus ligne 12 sont affichés lignes 14 à 18 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
Exemple de message obtenu si on ne met pas de fonction main
dans le Fragment
Shader :
### Linking errors:
Fragment info
-------------
(0) : error C3001: no program defined
Après cette étape, on peut récupérer l'identifiant des variables d'entrée vPos
et vCol
du Vertex Shader. Cet identifiant est appelé location ; il sera
utilisé lors des dessins pour relier les données (positions et couleurs)
aux variables des shaders.
m_vPos_loc = glGetAttribLocation (m_program, "vPos");
m_vCol_loc = glGetAttribLocation (m_program, "vCol");
5.7. Dessins avec VAA
On ne peut plus utiliser le mode de rendu immédiat glBegin
/glVertex
/glEnd
.
OpenGL fournit deux moyens pour transmettre des sommets et des couleurs :
-
Un Vertex Attribute Array (VAA) : il s'agit d'une structure côté client, qui sert à préciser l'organisation des données et la location de la variable correspondante dans le shader ; les données sont envoyées lors de chaque rendu avec
glDrawArrays
. -
Un Vertex Buffer Object (VBO) : c'est un buffer stocké côté serveur ; le gros avantage est qu'il peut être mémorisé au démarrage du programme, il n'y a plus besoin de renvoyer les données lors des rendus, ce qui économise la bande passante du bus de la carte graphique.
Envoyer des sommets via un VAA est assez simple, c'est ce qui se faisait au "début" des shaders. Mais avec le profile core c'est devenu interdit (la fenêtre reste vide) : il faut obligatoirement stocker les données dans un ou plusieurs VBO. Comme c'est un peu plus compliqué à mettre en œuvre nous le verrons au prochain cours.
Voici le début typique du processus de rendu dans la méthode displayGL
:
1 2 3 4 5 6 |
|
On vide le framebuffer et le Z-buffer ligne 4 (la ligne 3 permet de changer
la couleur de fond). Ligne 5 on active le shader program en invoquant
glUseProgram
.
On déclare ensuite les coordonnées des sommets et leurs couleurs dans des tableaux :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
On crée ensuite deux VAA avec la fonction glVertexAttribPointer
,
qui prend les paramètres suivants :
GLuint index
: la location de la variable dans le shader, qui recevra les coordonnées ou la couleur d'un sommet ;GLint size
: le nombre de valeurs par sommet ou couleur (entre 1 et 4) dans le tableau ;GLenum type
: le type d'une valeur (en généralGL_FLOAT
) dans le tableau ;GLboolean normalized
:GL_TRUE
s'il faut normaliser les données entre 0.0 et 1.0 ;GLsizei stride
: le nombre d'octets séparant 2 sommets ou couleurs dans le tableau ; un0
indique qu'elles sont consécutives en mémoire ;const void* pointer
: l'adresse de base du tableau.
Cela donne :
1 2 |
|
Enfin, on active les VAA pour faire les dessins :
1 2 3 4 5 6 7 |
|
La fonction glEnableVertexAttribArray
active le VAA pour
alimenter la variable du shader correspondante avec les données ;
la fonction glDisableVertexAttribArray
désactive le VAA.
La fonction glDrawArrays
réalise les dessins à partir des sommets
données en indice, avec les paramètres :
GLenum mode
: la nature du dessin ; les valeurs possibles sont à peu près les mêmes que pourglBegin
(sans les polygones et quads) ;GLint first
: l'indice du premier sommet ou couleur dans le tableau ;GLsizei count
: le nombre de sommets ou couleurs.
Le code complet de notre exemple est dans le fichier
fw21-shader.cpp
.
5.8. Matrices de transformation
OpenGL "moderne" a également supprimé les 3 piles de matrices et toutes les opérations associées :
glMatrixMode
,glPushMatrix
,glPopMatrix
glFrustum
,glPerspective
,glOrtho
,glOrtho2D
glLoadIdentity
,glRotatef
,glTranslatef
,glScalef
gluLookAt
Il faut maintenant définir ces matrices et les transmettre aux shader, pour placer la caméra, paramétrer la projection et opérer des transformations géométriques sur les primitives.
Heureusement, il existe de nombreuses librairies ou modules pour définir et manipuler ces matrices, par exemple la librairie GLM.
Dans ce cours nous allons utiliser le module
vmath.h
, fourni avec le code source du livre de référence
OpenGL Red Book. Son avantage est que toute la librairie est contenue
dans un seul fichier .h
.
L'idée générale est de déclarer une matrice identité 4x4 au début de
displayGL
:
vmath::mat4 matrix = vmath::mat4::identity();
puis de lui appliquer les différents calculs, y compris la correction du facteur d'aspect :
GLfloat hr = m_radius, wr = hr * m_aspect_ratio;
matrix = matrix * vmath::frustum (-wr, wr, -hr, hr, 1.0, 5.0);
vmath::vec3 eye {0, 0, 3.0}, center {0, 0, 0}, up {0, 1., 0};
matrix = matrix * vmath::lookat (eye, center, up);
// Rotation de la scène pour l'animation, tout en float pour le template
matrix = matrix * vmath::rotate (m_anim_angle, 0.f, 1.f, 0.15f);
Il faut ensuite transmettre cette matrice au Fragment Shader.
Il existe une catégorie spéciale de variables, qui permet de transmettre une
même valeur à tous les shaders de façon identique : ce sont les variables
uniform
.
Nous allons "envoyer" la matrice matrix
dans le Fragment Shader
sous la forme d'une variable uniform
nommée matMVP
dans le shader
(MVP pour Modèle Vue Projection).
On récupère l'identifiant de matVP
dans initGL
en appelant
glGetUniformLocation
:
m_matMVP_loc = glGetUniformLocation (m_program, "matMVP");
puis dans paintGL
, après le calcul de matrix
, on rend accessible la
variable uniform
en appelant glUniformMatrix4fv
:
glUniformMatrix4fv (m_matMVP_loc, 1, GL_FALSE, matrix);
Le Vertex Shader devient :
1 2 3 4 5 6 7 8 9 10 11 |
|
La matrice est reçue ligne 5 ; la transformation géométrique est appliquée au sommet ligne 9.
Le code complet de l'exemple est dans le fichier
fw22-matrix.cpp
.
Deux exemples supplémentaires sont fournis :
-
l'exemple
fw23-wirecube.cpp
déplace le dessin des deux triangles dans une classeTriangles
, et rajoute une classeWireCube
pour dessiner un cube repère ; -
l'exemple
fw24-proj.cpp
permet de choisir au clavier le type de projection et de la paramétrer.
Dans ce dernier exemple, le calcul de la projection est regroupé dans la méthode :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
Les étapes dans paintGL
deviennent :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
Si on veut réaliser des transformations géométriques supplémentaires sur
les objets affichés, il suffit de copier matrix
dans une variable temporaire
matrix2
(équivalent de glPushMatrix
/glPopMatrix
), multiplier matrix2
par la
matrice de transformation, puis appeler glUniformMatrix4fv
pour
transmettre matrix2
au Vertex Shader, et faire le dessin :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|