Programmation Graphique : CM séance 06
10. Compléments
10.1. Langage GLSL
10.1.1. Structs et tableaux uniform
Le passage des données vers une variable uniform
de type struct
se fait
membre à membre. Soit par exemple dans un shader :
struct Foo {
float bar;
vec3 fum;
};
uniform Foo foo;
alors dans le programme client en C++ il faudra écrire :
glUniformf (glGetUniformLocation (program, "foo.bar"), 10.0);
glUniform3fv (glGetUniformLocation (program, "foo.fum"), some_vec3);
Remarque : si la variable ou le champ ne sont pas trouvés, la valeur
renvoyée par glGetUniformLocation
est -1
, et
l'appel de glUniform*
est sans effet.
Le principe est le même pour un tableau, il faut passer la valeur pour chaque indice. S'il y par exemple dans un shader :
uniform float values[5];
alors dans le client on écrira :
glUniformf (glGetUniformLocation (program, "values[0]"), v[i]);
glUniformf (glGetUniformLocation (program, "values[1]"), v[i]);
...
ou avec une boucle, en C++17 :
1 2 3 4 5 6 7 |
|
ou en C++20 avec std::format
:
#include <format>
float v[5] = {...};
for (int i = 0; i < 5; i++) {
std::string s = std::format("values[{}]", i);
glUniformf (glGetUniformLocation (program, s.c_str()), v[i]);
}
Le principe est le même pour un struct
contenant un tableau :
glUniform... (glGetUniformLocation (program, "foo.bar[1]"), value);
ou pour un tableau de struct
:
glUniform... (glGetUniformLocation (program, "foo[1].bar"), value);
pour chaque indice.
10.1.2. Blocs d'interface
Les variables d'interface in
, out
ou uniform
peuvent devenir nombreuses.
Il peut aussi y avoir des problèmes de convention de nommage entre les out
d'un
shader et les in
du suivant dans le pipeline.
GLSL permet de regrouper des variables d'une même catégorie dans
un bloc d'interface (interface block).
Leur syntaxe ressemble à celle des struct
:
storage_qualifier block_name {
type variable;
...
} [instance_name];
Le storage qualifier peut être in
, out
ou uniform
.
Le nom de bloc block_name
est utilisé pour transmettre les données out
d'un shader
aux données in
du shader suivant. Par exemple si on regroupe les out
d'un vertex shader dans un bloc d'interface nommé VertexData
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Alors on devra déclarer les in
du fragment shader avec le même nom
de bloc d'interface :
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Remarque : dans un shader il peut y avoir plusieurs blocs d'interface avec le même nom, à condition qu'ils aient des storage qualifier différents ; cela sera très utile pour "intercaler" un geometry shader entre le vertex shader et le fragment shader, sans avoir à renommer les variables.
Le nom instance_name
est optionnel :
- s'il est absent (bloc anonyme), les noms de variables du bloc peuvent être utilisées directement dans la suite du shader (ce qui est fait dans l'exemple ci-dessus) ;
- s'il est présent, les champs du bloc doivent être préfixées par
instance_name.
lors de leur utilisation ; - le nom
instance_name
peut varier d'un shader à l'autre ; - s'il a un nom d'instance dans un shader, alors le bloc correspondant dans les autres shaders doit aussi avoir un nom d'instance.
Par exemple dans le vertex shader :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
De même dans le fragment shader :
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Remarques :
-
Il n'est pas possible de faire un bloc d'interface
in
pour un vertex shader, niout
pour un fragment shader. -
Les blocs d'interface GLSL sont transparents pour l'application, autrement dit il n'y a rien à modifier au niveau du code OpenGL.
Les blocs d'interface vont être très utiles pour les UBO et les geometry shaders vus ensuite.
10.2. Shaders multiples
Dans un programme OpenGL, il est fréquent d'avoir différents shader programs selon les objets que l'on veut dessiner.
10.2.1. Classe ShaderProg
On introduit dans l'exemple fw61-multi.cpp
une classe ShaderProg
pour faciliter l'usage de shader programs multiples ;
tout le code relatif aux shaders qui était dans la classe MyApp
est déménagé
dans cette classe :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
Chaque catégorie de shader program possède des shaders par défaut, mémorisés
dans le tableau m_default_shader_texts
.
La ligne de commande permet de les remplacer par des fichiers, avec la syntaxe
suivante :
$ ./fw61-multi --help
USAGE:
./fw61-multi [-vs|-fs|-gs categ path] [-ps categ]
categ: color|texture|diffuse|specular
Au niveau de MyApp
, les shader programs sont alloués dans load_programs
et libérés dans tear_programs
:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Ligne 4, le tableau m_shader_paths
contient les chemins issus de la ligne
de commande.
Pour dessiner un objet avec un shader program donné, il suffit d'appeler
sa méthode use_program
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
La matrice mat_world
est recalculée pour chaque dessin, pour permettre de
placer plus facilement les objets.
10.2.2. Locations des VA
La location des vertex attributes, autrement dit des variables en entrée des vertex shaders, peut varier d'un shader program à l'autre.
Si l'on veut pouvoir afficher un même objet plusieurs fois avec plusieurs shader program, il ne sert plus à rien de récupérer les locations avec
GLint pos_loc = glGetAttribLocation (program, "vPos");
Une solution consiste à fixer la location. Cela peut être fait "en dur" dans les shaders par
layout (location = 0) in vec4 vPos;
mais cela oblige à le faire dans tous les shaders.
Une solution plus souple consiste à fixer les locations au niveau du client :
// Vertex attribute location imposées
enum VA_Locations{ VPOS_LOC = 0, VCOL_LOC = 1, VNOR_LOC = 2, VTEX_LOC = 3, LAST_LOC };
const char* get_vertex_attribute_name (VA_Locations loc)
{
switch (loc) {
case VPOS_LOC : return "vPos";
case VCOL_LOC : return "vCol";
case VNOR_LOC : return "vNor";
case VTEX_LOC : return "vTex";
default : return "";
}
}
puis de propager les locations en appelant
glBindAttribLocation (program, location, variable_name);
Les locations doivent être propagées après glCreateProgram
, mais avant
glLinkProgram
. Une fois que le programme est liée, les locations ne
changent plus jusqu'à la destruction ou une nouvelle liaison
du programme.
La solution mise en place dans notre exemple est de propager toutes les
locations dans le constructeur de ShaderProg
, avec la méthode :
1 2 3 4 5 6 7 8 9 10 |
|
Cela simplifie donc l'écriture des classes de dessin car il n'y a plus à stocker de location, on utilise les constantes :
glVertexAttribPointer (VPOS_LOC, 3, GL_FLOAT, GL_FALSE,
6*sizeof(GLfloat), reinterpret_cast<void*>(0*sizeof(GLfloat)));
glEnableVertexAttribArray (VPOS_LOC);
10.2.3. Locations des uniform
La location des uniform peut également varier d'un shader program à l'autre.
Au lieu de fixer la location, on récupère celle-ci chaque fois avec la
méthode get_uniform
de ShaderProg
:
GLint get_uniform (const char* name)
{
return glGetUniformLocation (m_program, name);
}
qui renvoie -1
si la variable uniform
n'a pas été trouvée.
On peut envoyer une donnée dans une uniform
dès que le programme est lié ;
elle reste mémorisée jusqu'à la destruction ou une nouvelle liaison
du programme.
En pratique dans notre exemple, on envoie les données uniform
au moment
du rendu dans displayGL
:
1 2 3 4 |
|
10.3. Uniform Buffer Objects
Il est possible de regrouper plusieurs variables uniform
dans un
buffer object, cela s'appelle un Uniform Buffer Object, ou UBO.
L'aspect intéressant est qu'il est possible de partager un UBO entre
plusieurs shaders ; cela évite de devoir passer toutes les variables
uniform
à chaque shader.
C'est particulièrement utile lorsqu'il y a beaucoup de variables
uniform
à mettre à jour lors du rendu : il suffit de mettre le UBO
à jour, ce qui est plus efficace.
Les données qui sont propres à chaque shader, telle que mat_World
ou
mat_Nor
seront quant à elles passées de la manière habituelle.
10.3.1. Bloc uniform
Comme exemple dans fw62-ubo.cpp
,
on réécrit les shaders en regroupant des données communes dans un
bloc d'interface uniform
nommé Uniforms
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Le nom de bloc Uniforms
sera utilisé dans l'application. Les variables du bloc
sont directement utilisables sans préfixe (le bloc est anonyme).
On aurait pu aussi déclarer le bloc d'interface avec un nom d'instance, par exemple com
;
les différents champs doivent alors être préfixes par com.
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
10.3.2. Organisation mémoire
Le point crucial est l'organisation des données, qui doit être connue de l'application, en particulier l'offset de chaque champ, sa taille, et la taille de l'alignement mémoire.
Il existe 4 méthodes d'alignement mémoire, que l'on peut passer en argument
de layout
: packed
, shared
, std140
et std430
.
La méthode préconisée est std140
(elle est apparue avec GLSL 1.40) :
- elle est très simple (pas besoin de demander des offsets) ;
- son alignement mémoire est connu à l'avance, il est de 16 octets (la taille d'un
vec4
) ; - à cause de cela, il peut y avoir un gaspi de mémoire à cause du padding pour aligner les données.
Dans l'application en C++ on a besoin de définir un struct
avec le même
alignement mémoire, ce qui est facilité par le spécificateur alignas
:
// Type avec alignement GLSL std140 aligné sur 16 octets
struct UBO_Uniforms {
alignas(16) vmath::mat4 matProj;
alignas(16) vmath::mat4 matCam;
alignas(16) vmath::vec4 mousePos;
alignas(16) GLfloat time;
};
Cela facilitera grandement la mise à jour des données dans le UBO.
10.3.3. Création d'un UBO
Pour créer un UBO, il faut d'abord créer un identifiant avec glGenBuffer
,
puis le lier à la cible GL_UNIFORM_BUFFER
:
1 2 3 4 5 6 7 8 |
|
On peut ensuite réserver la taille mémoire nécessaire
(sans passer de données, d'où le NULL
), puis délier le UBO
(on se servira par la suite de son identifiant) :
9 10 |
|
À la fin, pour détruire le UBO il suffit d'écrire
1 2 3 4 5 |
|
10.3.4. Point de liaison
On peut avoir plusieurs UBO et choisir quels shaders les utilise. On relie les UBO et les shaders en leur associant un binding point (un entier >= 0).
On relie donc à un binding point notre UBO après sa création dans initGL
:
1 2 3 4 5 6 7 8 9 |
|
puis on relie chaque shader program au même binding point, en précisant
le nom du bloc d'interface uniform
, ici "Uniforms"
:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
10.3.5. Envoi des données
Tout est en place, on peut maintenant envoyer les données dans le UBO,
soit à la fin de initGL
, soit au début de chaque rendu dans displayGL
,
en appelant la fonction glBufferSubData
:
void glBufferSubData (GLenum target, // GL_UNIFORM_BUFFER
GLintptr offset,
GLsizeiptr size,
const void * data);
Le calcul de offsets et size est grandement facilité par la définition que l'on avait faite avec le bon alignement mémoire, pour rappel :
// Type avec alignement GLSL std140 aligné sur 16 octets
struct UBO_Uniforms {
alignas(16) vmath::mat4 matProj;
alignas(16) vmath::mat4 matCam;
alignas(16) vmath::vec4 mousePos;
alignas(16) GLfloat time;
};
En effet on peut utiliser directement la macro offsetof
et
l'opérateur sizeof
du C++ :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Tous les shaders liés au binding point de l'UBO auront alors accès au
données, à condition qu'ils aient déclaré le bloc d'interface uniform
prévu à cet effet.
Remarques :
-
pour le passage de matrices, on peut indiquer dans le
layout
l'ordre en mémoire :row_major
oucolumn_major
(par défaut). -
un bloc
uniform
, comme tout bloc d'interface, ne peut pas contenir de données opaques.
10.4. Geometry shader
On a vu dans le pipeline de rendu qu'il y avait plusieurs shaders optionnels entre le vertex shader (VS) et le fragment shader (FS).
Nous nous intéressons ici au geometry shader (GS), apparu à la version 3.2.
Un geometry shader prend en entrée la liste des sommets qui forment une primitive graphique : un point, une ligne ou un triangle.
Ces informations proviennent du vertex shader, et sont regroupées pour l'occasion.
Le geometry shader peut soit recopier les données en sortie (pass-through), ou modifier, supprimer ou rajouter des primitives, voire changer leur nature. Il peut aussi modifier les positions, couleurs, etc.
Toutes ces primitives seront ensuite rasterisées et passées au fragment shader.
10.4.1. Choix des primitives
Il faut commencer par indiquer au GS quelles primitives seront traitées en
entrée, au moyen d'un layout
:
layout (input_qualifier) in;
Le input qualifier peut être l'un éléments des suivants :
points
: dessins avecGL_POINTS
;lines
: dessins avecGL_LINES
ouGL_LINE_STRIP
;lines_adjacency
: dessins avecGL_LINES_ADJACENCY
ouGL_LINE_STRIP_ADJACENCY
;triangles
: dessins avecGL_TRIANGLES
,GL_TRIANGLE_STRIP
ouGL_TRIANGLE_FAN
;triangles_adjacency
: dessins avecGL_TRIANGLES_ADJACENCY
ouGL_TRIANGLE_STRIP_ADJACENCY
.
Les entrées pour points
, lines
, triangles
reçoivent 1 à 3 sommets ;
celles pour lines_adjacency
et triangles_adjacency
en reçoivent 4 ou 6.
Il faut aussi spécifier le type des primitives qui seront émises en sortie :
layout (output_qualifier, max_vertices = n) out;
Le output qualifier est à choisir parmi : points
, line_strip
, triangle_strip
.
Le nombre maximal de sommets qui seront émis est spécifié par max_vertices
;
les sommets en excès seront ignorés.
Voici un exemple de l'entête d'un geometry shader qui prend en entrée un triangle, et émet en sortie un triangle :
layout (triangles) in;
layout (triangle_strip, max_vertices=3) out;
10.4.2. Entrées-sorties
Les variables en entrée reçoivent toujours un tableau sans taille, avec une valeur par sommet.
Elles peuvent être déclarées de la manière habituelle, sans bloc d'interface :
in vec4 color[];
in vec3 normal[];
Les noms doivent correspondre aux noms des variables en sortie du vertex shader.
On peut ensuite examiner les valeurs avec l'indice de chaque sommet
de la primitive (par exemple color[0]
, color[1]
, etc).
Les noms en sortie sont des variables simples, qui doivent correspondre aux noms utilisés dans le fragment shader. Par exemple
out vec4 color;
out vec3 normal;
Problème : on n'a pas le droit de redéclarer des variables. Il faut soit renommer les variables en sortie (et en entrée dans le fragment shader), soit utiliser des interfaces de bloc, ce qui évite de modifier le fragment shader, et permet donc "d'intercaler" un GS entre un VS et un FS pour faire des essais (à condition que le VS et le FS utilisent les mêmes interfaces de bloc) :
in VertexData {
vec4 color;
vec3 normal;
} vd_in[];
out VertexData {
vec4 color;
vec3 normal;
} vd_out;
GLSL fournit de plus des variables prédéfinies en entrée :
in gl_PerVertex
{
vec4 gl_Position;
float gl_PointSize;
...
} gl_in[];
dont les valeurs proviennent du VS, et en sortie :
out gl_PerVertex
{
vec4 gl_Position;
float gl_PointSize;
...
};
Il existe encore d'autres variables prédéfinies, voir ici.
10.4.3. Utilisation
Le décor étant planté, voici comment le GS peut générer des primitives :
-
pour émettre un sommet, le GS doit renseigner toutes les variables
out
, puis appelerEmitVertex()
; -
lorsque le nombre de sommets émis est suffisant pour dessiner une primitive, on appelle
EndPrimitive()
; -
on peut continuer a émettre des sommets, ceux-ci seront destinés à la primitive suivante, jusqu'à ce qu'on appelle
EndPrimitive()
, et ainsi de suite ; -
le nombre total de sommets émis ne doit pas dépasser
max_vertices
.
Exemple de geometry shader pass-through qui reçoit un triangle, et le transmet sans le modifier :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
Utilisation avec le program shader color
de
fw62-ubo.cpp
:
./fw62-ubo -gs color tmp1.glsl
On constate que le wire cube a disparu, car il n'est pas constitué de triangles !
Deuxième exemple, où l'on va rajouter un petit triangle rouge au sommet numéro 0 de chaque triangle en entrée :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
|
À noter, les coordonnées sont données dans l'espace normalisé du viewport \([\,-1 \ldots 1,\,-1 \ldots 1,\,-1 \ldots 1\,]\).
Troisième exemple avec une animation en fonction du temps :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
|
Exemple trois bis pour le shader program diffuse
de
l'exemple, en rajoutant les normales :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
|
Quatrième exemple où on affiche les triangles en fil de fer et on affiche
les normales, pour le shader program diffuse
de l'exemple
fw62-ubo.cpp
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
|