OpenGL

Geometry shaders

Alexander Overvoorde

Traducteur

Alexandre Laurent

- Titre : OpenGL

- Auteur : Alexander Overvoorde

- Traducteur : Alexandre Laurent

- Parution : 7 février 2017

- Licence : Le contenu de cet article est rédigé par Alexander Overvoorde et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Partage dans les Mêmes Conditions 3.0 non transposé. Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright ® 2013 Developpez.com.

Sommaire

- Navigation

- I. Geometry shaders

- II. Paramétrage

- III. Geometry shader de base

- III-A. Types d'entrées

- III-B. Types de sorties

- III-C. Sommets en entrée

- III-D. Sommets en sortie

- IV. Créer un geometry shader

- V. Geometry shaders et attributs de sommet

- VI. Générer dynamiquement des géométries

- VII. Conclusion

- VIII. Exercices

- IX. Remerciements

- Navigation

Navigation

Tutoriel précédent : tampons de rendu

Sommaire

Tutoriel suivant : feedback de transformation

I - Geometry shaders

Jusqu'à présent, nous avons utilisé les vertex et fragment shaders pour transformer en pixels les sommets en entrée. Depuis OpenGL 3.2, il y a un troisième type de shader, optionnel, qui se place entre le vertex shader et le fragment shader : nommé geometry shader. Ce shader a la particularité de créer de nouvelles géométries à la volée en utilisant la sortie du vertex shader en entrée.

Comme nous avons oublié depuis un trop long moment le chaton des chapitres précédents, il s'est enfui. Cela nous donne une bonne occasion pour commencer à partir de zéro. À la fin de ce chapitre, nous allons obtenir cette démonstration :

Cela ne semble pas amusant… sauf si vous prenez en compte que le résultat ci-dessus a été obtenu avec un unique appel de dessin :

glDrawArrays(GL_POINTS, 0, 4);

Notez que tout ce que le geometry shader peut faire peut aussi être réalisé d'autres manières, mais ils sont capables de générer des géométries à partir de très peu de données d'entrées et vous permette donc de réduire l'utilisation de la bande passante entre le CPU et le GPU.

II - Paramétrage

Commençons par écrire un simple code qui dessine juste quatre points rouges à l'écran.

// Vertex shader
const char* vertexShaderSrc = GLSL(
in vec2 pos;

void main()
{
gl_Position = vec4(pos, 0.0, 1.0);
}
);

// Fragment shader
const char* fragmentShaderSrc = GLSL(
out vec4 outColor;

void main()
{
outColor = vec4(1.0, 0.0, 0.0, 1.0);
}
);

Nous allons commencer par déclarer deux shaders très simples au début du fichier. Le vertex shader transmet les attributs de position de chaque point et le fragment shader retourne toujours rouge. Rien de particulier ici.

J'ai utilisé une macro. Voici sa définition :

#define GLSL(src) "#version 150 core\n" #src

C'est bien plus pratique que d'utiliser la syntaxe multiligne que nous avions utilisée auparavant. Attention à ce que les sauts de lignes soient ignorés, ce pour quoi la directive #version du préprocesseur est séparée.

Nous allons ajouter une fonction d'aide pour créer et compiler un shader :

GLuint createShader(GLenum type, const GLchar* src) {
GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &src, nullptr);
glCompileShader(shader);
return shader;
}

Dans la fonction principale, créez une fenêtre et le contexte OpenGL avec la bibliothèque de votre choix et initialisez GLEW, compilez et activez les shaders :

GLuint vertexShader = createShader(GL_VERTEX_SHADER, vertexShaderSrc);
GLuint fragmentShader = createShader(GL_FRAGMENT_SHADER, fragmentShaderSrc);

GLuint shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
glUseProgram(shaderProgram);

Après cela, créez un tampon contenant les coordonnées des points :

GLuint vbo;
glGenBuffers(1, &vbo);

float points[] = {
-0.45f,  0.45f,
0.45f,  0.45f,
0.45f, -0.45f,
-0.45f, -0.45f,
};

glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(points), points, GL_STATIC_DRAW);

Nous avons quatre points, chacun avec des coordonnées écran X et Y. Rappelez-vous que les coordonnées de l'écran vont de -1 à 1, de la gauche vers la droite et du bas vers le haut, donc chaque coin aura un point.

Ensuite, créez un VAO et définissez le format des sommets :

// Création du VAO
GLuint vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);

// Spécification de l'agencement des données
GLint posAttrib = glGetAttribLocation(shaderProgram, "pos");
glEnableVertexAttribArray(posAttrib);
glVertexAttribPointer(posAttrib, 2, GL_FLOAT, GL_FALSE, 0, 0);

Finalement arrive la boucle de rendu :

glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);

glDrawArrays(GL_POINTS, 0, 4);

Avec ce code, vous devriez maintenant voir quatre points rouges sur un fond noir, comme ci-dessous :

Si vous avez des problèmes, regardez ce code source.

III - Geometry shader de base

Pour comprendre comment le geometry shader fonctionne, regardons cet exemple :

layout(points) in;
layout(line_strip, max_vertices = 2) out;

void main()
{
gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.0, 0.0, 0.0);
EmitVertex();

gl_Position = gl_in[0].gl_Position + vec4(0.1, 0.0, 0.0, 0.0);
EmitVertex();

EndPrimitive();
}

III-A - Types d'entrées

Que ce soit un vertex shader traitant des sommets ou un fragment shader traitant des fragments, un geometry shader traite des primitives entières. La première ligne décrit quel type de primitive notre shader doit traiter.

layout(points) in;

Les types disponibles sont listés ci-dessus, avec leur type de commande de dessin correspondante :

- points - GL_POINTS (1 sommet)

- lines - GL_LINES, GL_LINE_STRIP, GL_LINE_LIST (2 sommets)

- lines_adjacency - GL_LINES_ADJACENCY, GL_LINE_STRIP_ADJACENCY (4 sommets)

- triangles - GL_TRIANGLES, GL_TRIANGLE_STRIP, GL_TRIANGLE_FAN (3 sommets)

- triangles_adjacency - GL_TRIANGLES_ADJACENCY, GL_TRIANGLE_STRIP_ADJACENCY (6 sommets)

Comme nous utilisons GL_POINTS, le type adéquat est points.

III-B - Types de sorties

La prochaine ligne décrit les données en sorties du shader. Ce qui est intéressant à propos des geometry shader est qu'ils peuvent produire un type de géométrie différent et le nombre de primitives générées peut aussi varier !

layout(line_strip, max_vertices = 2) out;

La deuxième ligne indique le type en sortie et le nombre maximum de sommets qu'il peut produire. C'est un nombre maximum par invocation de shader et non pour une simple primitive (line_strip dans ce cas).

Les types de sorties disponibles sont les suivants :

- points

- line_strip

- triangle_strip

Ces types semblent contraignants, mais si vous y réfléchissez, ces types sont suffisants pour couvrir tous les types possibles de primitives. Par exemple, un triangle_strip avec seulement trois sommets est équivalent à un triangle.

III-C - Sommets en entrée

La variable gl_Position, comme défini dans le vertex shader, peut être accédée en utilisant la variable gl_in_array dans le geometry shader. C'est un tableau de structure qui ressemble à :

in gl_PerVertex
{
vec4 gl_Position;
float gl_PointSize;
float gl_ClipDistance[];
} gl_in[];

Notez que les attributs de sommets tels que pos et color ne sont pas compris, nous allons voir comment y accéder plus tard.

III-D - Sommets en sortie

Le geometry shader peut appeler deux fonctions spéciales pour générer des primitives : EmitVertex et EndPrimitive. Chaque fois que le shader appelle EmitVertex, un sommet est ajouté à la primitive. Lorsque tous les sommets ont été ajoutés, le shader appelle EndPrimitive pour générer la primitive.

void main()
{
gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.0, 0.0, 0.0);
EmitVertex();

gl_Position = gl_in[0].gl_Position + vec4(0.1, 0.0, 0.0, 0.0);
EmitVertex();

EndPrimitive();
}

Avant d'appeler EmitVertex, les attributs du sommet doivent être assignés aux variables telles que gl_Position, tout comme dans le vertex shader. Nous allons voir comment définir les attributs comme color pour le fragment shader, plus tard.

Maintenant que vous connaissez la signification de chaque ligne, pouvez-vous expliquer ce que fait ce geometry shader ?

IV - Créer un geometry shader

Il ne reste plus grand-chose à expliquer, les geometry shaders sont créés et activés de la même manière que les autres shaders. Ajoutons un geometry shader qui ne fait rien pour nos quatre points.

const char* geometryShaderSrc = GLSL(
layout(points) in;
layout(points, max_vertices = 1) out;

void main()
{
gl_Position = gl_in[0].gl_Position;
EmitVertex();
EndPrimitive();
}
);

Ce geometry shader doit être clair. Pour chaque point en entrée, il génère un point équivalent en sortie. C'est le code minimum nécessaire pour afficher les points à l'écran.

Avec la fonction d'aide, créer un geometry shader est facile :

GLuint geometryShader = createShader(GL_GEOMETRY_SHADER, geometryShaderSrc);

Il n'y a rien de spécial à attacher le shader au programme :

glAttachShader(shaderProgram, geometryShader);

Lorsque vous exécutez le programme, il doit toujours afficher les points, comme précédemment. Vous pouvez vérifier que votre geometry shader est fonctionnel en retirant le code de sa fonction main. Vous allez voir qu'il n'y a plus de point, car aucun n'est généré !

Maintenant, essayez de remplacer le code du geometry shader avec le code de la précédente section, générant des lignes :

layout(points) in;
layout(line_strip, max_vertices = 2) out;

void main()
{
gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.0, 0.0, 0.0);
EmitVertex();

gl_Position = gl_in[0].gl_Position + vec4(0.1, 0.0, 0.0, 0.0);
EmitVertex();

EndPrimitive();
}

Même si nous n'avons fait aucun changement à nos appels de dessin, le GPU s'est mis à dessiner des petites lignes à la place des points !

Essayez d'expérimenter un peu pour mieux sentir son fonctionnement. Par exemple, essayez de produire des rectangles avec triangle_strip.

V - Geometry shaders et attributs de sommet

Ajoutons quelques variations aux lignes qui sont dessinées en ajoutant une couleur unique à chacune d'entre elles. En ajoutant une variable d'entrée au vertex shader, nous pouvons spécifier la couleur pour chaque sommet et donc, pour chaque ligne générée.

in vec2 pos;
in vec3 color;

out vec3 vColor; // Output to geometry (or fragment) shader

void main()
{
gl_Position = vec4(pos, 0.0, 1.0);
vColor = color;
}

Mettez à jour la spécification des sommets dans le code du programme :

GLint posAttrib = glGetAttribLocation(shaderProgram, "pos");
glEnableVertexAttribArray(posAttrib);
glVertexAttribPointer(posAttrib, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), 0);

GLint colAttrib = glGetAttribLocation(shaderProgram, "color");
glEnableVertexAttribArray(colAttrib);
glVertexAttribPointer(colAttrib, 3, GL_FLOAT, GL_FALSE,
5 * sizeof(float), (void*) (2 * sizeof(float)));

Et mettez à jour les données des points pour inclure la couleur RGB pour chaque point :

float points[] = {
-0.45f,  0.45f, 1.0f, 0.0f, 0.0f, // Red point
0.45f,  0.45f, 0.0f, 1.0f, 0.0f, // Green point
0.45f, -0.45f, 0.0f, 0.0f, 1.0f, // Blue point
-0.45f, -0.45f, 1.0f, 1.0f, 0.0f, // Yellow point
};

Comme le vertex shader n'est plus suivi du fragment shader mais du geometry shader, nous devons gérer la variable vColor comme entrée.

layout(points) in;
layout(line_strip, max_vertices = 2) out;

in vec3 vColor[]; // Sortie du vertex shader, pour chaque sommet

out vec3 fColor; // Sortie du fragment shader

void main()
{
...

Vous pouvez voir que la gestion des entrées est proche de celle effectuée dans le fragment shader. La seule différence est que les entrées doivent maintenant être des tableaux, car le geometry shader peut recevoir des primitives avec plusieurs sommets, chacun avec ses propres valeurs d'attributs.

Comme les couleurs doivent être passées au fragment shader, nous allons les ajouter comme sorties du geometry shader. Nous pouvons maintenant assigner des valeurs à celles-ci, tout comme nous l'avons fait pour la variable gl_Position.

void main()
{
fColor = vColor[0]; // Un point n'a qu'un seul sommet

gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.1, 0.0, 0.0);
EmitVertex();

gl_Position = gl_in[0].gl_Position + vec4(0.1, 0.1, 0.0, 0.0);
EmitVertex();

EndPrimitive();
}

Maintenant, chaque fois que EmitVertex est appelé, un sommet est émis avec la valeur actuelle de fColor comme attribut de couleur. Nous pouvons maintenant accéder à cet attribut dans le fragment shader :

in vec3 fColor;

out vec4 outColor;

void main()
{
outColor = vec4(fColor, 1.0);
}

Donc, lorsque vous spécifiez un attribut pour un sommet, il est d'abord passé comme entrée au vertex shader. Le vertex shader peut choisir de l'envoyer au geometry shader. Ensuite, le geometry shader peut choisir de l'envoyer au fragment shader.

Par contre, cette démonstration n'est pas très intéressante. Nous pouvons facilement la refaire en créant un tampon de sommets en une ligne et produire un ensemble d'appels de dessin avec différentes couleurs et positions grâce aux variables uniformes.

VI - Générer dynamiquement des géométries

Le vrai pouvoir d'un geometry shader repose sur la possibilité de générer un nombre variable de primitives, donc créons une démonstration pour profiter de cette capacité.

Disons que vous faites un jeu vidéo où le monde est représenté par des cercles. Vous pouvez utiliser un seul modèle de cercle et le dessiner de nombreuses fois, mais cette approche n'est pas idéale. Si vous êtes trop proche, ces « cercles » seront d'imparfaits polygones et si vous êtes trop loin, votre carte graphique gâchera des ressources à dessiner des objets complexes que vous ne pouvez pas voir.

Nous pouvons faire mieux avec les geometry shader ! Nous pouvons écrire un shader qui génère des cercles avec la résolution appropriée en temps réel. Premièrement modifions le geometry shader pour dessiner un polygone à 10 côtés pour chaque point. Si vous vous rappelez de la trigonométrie, cela devrait être simple :

layout(points) in;
layout(line_strip, max_vertices = 11) out;

in vec3 vColor[];
out vec3 fColor;

const float PI = 3.1415926;

void main()
{
fColor = vColor[0];

for (int i = 0; i <= 10; i++) {
// Angle entre chaque côté en radians
float ang = PI * 2.0 / 10.0 * i;

// Décalage à partir du centre
vec4 offset = vec4(cos(ang) * 0.3, -sin(ang) * 0.4, 0.0, 0.0);
gl_Position = gl_in[0].gl_Position + offset;

EmitVertex();
}

EndPrimitive();
}

Le premier point est répété pour fermer la boucle de ligne, ce qui nous donne onze sommets. Le résultat est comme prévu :

Il est maintenant trivial d'ajouter un attribut de sommet pour contrôler le nombre de côtés. Ajoutez le nouvel attribut aux données et à la spécification :

float points[] = {
//  Coordinates  Color             Sides
-0.45f,  0.45f, 1.0f, 0.0f, 0.0f,  4.0f,
0.45f,  0.45f, 0.0f, 1.0f, 0.0f,  8.0f,
0.45f, -0.45f, 0.0f, 0.0f, 1.0f, 16.0f,
-0.45f, -0.45f, 1.0f, 1.0f, 0.0f, 32.0f
};

...

// Spécification du format des points
GLint posAttrib = glGetAttribLocation(shaderProgram, "pos");
glEnableVertexAttribArray(posAttrib);
glVertexAttribPointer(posAttrib, 2, GL_FLOAT, GL_FALSE,
6 * sizeof(float), 0);

GLint colAttrib = glGetAttribLocation(shaderProgram, "color");
glEnableVertexAttribArray(colAttrib);
glVertexAttribPointer(colAttrib, 3, GL_FLOAT, GL_FALSE,
6 * sizeof(float), (void*) (2 * sizeof(float)));

GLint sidesAttrib = glGetAttribLocation(shaderProgram, "sides");
glEnableVertexAttribArray(sidesAttrib);
glVertexAttribPointer(sidesAttrib, 1, GL_FLOAT, GL_FALSE,
6 * sizeof(float), (void*) (5 * sizeof(float)));

Modifiez le vertex shader pour passer la valeur au geometry shader :

in vec2 pos;
in vec3 color;
in float sides;

out vec3 vColor;
out float vSides;

void main()
{
gl_Position = vec4(pos, 0.0, 1.0);
vColor = color;
vSides = sides;
}

Et utilisez la variable dans le geometry shader au lieu du nombre magique pour les côtés. C'est aussi nécessaire de définir la valeur appropriée max_vertices pour notre entrée, sinon les cercles avec plus de sommets seront tronqués.

layout(line_strip, max_vertices = 64) out;

...

in float vSides[];

...

// OK, les nombres flottants peuvent représenter un petit nombre entier avec exactitude
for (int i = 0; i <= vSides[0]; i++) {
// Engle entre chaque côté en radian
float ang = PI * 2.0 / vSides[0] * i;

...

Vous pouvez créer des cercles avec n'importe quel nombre de côtés que vous voulez simplement en ajoutant plus de points !

Sans le geometry shader, nous aurions à reconstruire l'intégralité du tampon de sommets chaque fois qu'un cercle aurait changé. Maintenant, nous avons simplement à modifier la valeur des attributs de sommets. Dans les paramètres d'un jeu, cet attribut peut être modifié suivant la distance du joueur comme indiqué précédemment. Vous pouvez trouver l'intégralité du code ici.

VII - Conclusion

Il est vrai que les geometry shaders n'ont pas autant de cas concrets que les tampons de rendu ou les textures, mais ils peuvent effectivement aider avec la création de contenu sur le GPU.

Si vous avez besoin de répéter un modèle simple plusieurs fois, comme un cube dans un jeu en voxel, vous pouvez créer un geometry shader pour générer les cubes à partir de points. Par contre, pour des cas où chaque modèle est exactement le même, il y a d'autres méthodes plus efficaces comme l'instanciation.

Finalement, par rapport à la portabilité, les derniers standards WebGL et OpenGL ES ne supportent pas encore les geometry shaders, gardez donc à l'esprit ce point si vous développez une application pour le Web ou les mobiles.

VIII - Exercices

- Essayez d'utiliser un geometry shader dans un scénario 3D pour créer des modèles complexes comme des cubes à partir de points (Solution).

IX - Remerciements

Cet article est une traduction autorisée dont le texte original peut être trouvé sur open.gl.

Navigation

Tutoriel précédent : tampons de rendu

Sommaire

Tutoriel suivant : feedback de transformation