OGLdev 04 – Shaders

Introduction

Dans ce tutoriel, vous allez apprendre à charger un shader afin qu’il soit exécuter pendant le rendu de votre triangle.

Contexte

À partir de ce tutoriel, chaque effet et technique que nous implémenterons le sera en utilisant les shaders.
Les shaders sont la méthode moderne pour faire des graphiques 3D.
D’une certaine manière vous pourriez prétendre que c’est un retour en arrière, en effet la plupart des fonctionnalités 3D qui étaient fournies par le pipeline fixe et ne nécessitaient du développeur que la définition de paramètres de configuration (attributs de lumières, rotations…), doivent maintenant être implémentées par le développeur (via les shaders).
Cependant cette programmation permet une plus grande flexibilité et innovation.

Le pipeline programmable d’OpenGL peut être visualisé comme suit :

Pipeline-OpenGL

Le processeur de sommets exécute le vertex shader sur chaque sommet qui passe à travers le pipeline (et dont le nombre est déterminé par les paramètres donnés à la fonction de dessin).
Les vertex shaders n’ont aucune connaissance de la topologie des primitives rendues. En outre, vous ne pouvez pas vous débarrasser d’un quelconque sommet dans le processeur de sommets. Chaque sommet entre dans le processeur de sommets exactement une fois, subit les transformations et continue dans le pipeline.

L’étape suivante est le « processeur de géométries ».
Dans cette étape, la connaissance de la primitive complète (c’est-à-dire de tous ses sommets) ainsi que des sommets voisins est fournie au shader. Cela permet des techniques qui doivent prendre en compte les informations additionnelles en plus du sommet lui-même.
Le geometry shader a aussi la capacité de changer la topologie de sortie vers une autre que celle choisie lors de l’appel de la fonction de dessin.
Par exemple, vous pourriez lui fournir une liste de points et générer deux triangles (c’est-à-dire un rectangle) à partir de chaque point (technique connue sous le nom de « billboarding »).
De plus, vous avez la possibilité d’émettre de multiples sommets pour chaque appel du geometry shader et ainsi créer de multiples primitives selon la topologie de sortie choisie.

L’étape suivante dans le pipe est le « clipper ».
C’est une unité fixe avec une tâche précise : il découpe les primitives selon la boîte normalisée décrite dans le tutoriel précédent. Il les découpe aussi aux plans Z proche et lointain.
Il est aussi possible de fournir des plans de clipping et faire que le clipper coupe selon ces plans.
Les positions des sommets qui ont survécu au clipper sont maintenant mappées en coordonnées écran et le rasterizer les affiche sur l’écran selon leur topologie.
Par exemple, dans le cas de triangles, ça signifie trouver tous les points qui sont dans le triangle. Pour chaque point, le rasterizer appelle le processeur de fragment. Ici vous avez la possibilité de déterminer la couleur du pixel en la récupérant depuis une texture ou n’importe quelle technique voulue.

Les trois étapes programmables (processeur de sommets, géométrie et fragment) sont optionnelles. Si vous ne leur attachez pas de shader, la fonctionnalité par défaut sera exécutée.

La gestion de shaders est très similaire à la création d’un programme C/C++.
Tout d’abord vous écrivez le texte du shader et le rendez disponible dans votre application. Ça peut être fait simplement en écrivant le texte dans une chaîne de caractères dans le code source même, ou en le chargeant depuis un fichier texte externe (là aussi dans une chaîne de caractères).
Ensuite vous compilez les shaders un par un dans des objets de shader.
Puis vous liez les shaders dans un unique programme et le chargez sur le GPU. Lier les shaders donne au pilote l’opportunité de les optimiser en fonction des relations qu’ils ont entre eux.
Par exemple, vous pourriez coupler un vertex shader qui émet une normale avec un fragment shader qui l’ignore. Dans ce cas le compilateur GLSL du driver peut supprimer les fonctionnalités liées à la normale et permettre une exécution plus rapide du vertex shader. Si plus tard ce shader est couplé avec un fragment shader qui utilise la normale, alors lier l’autre programme générera un vertex shader différent.

Explication du code

GLuint ShaderProgram = glCreateProgram();

Nous démarrons le procédé de mise en place de nos shader par la création d’un programme. Nous lierons tous les shaders ensembles à ce programme.

GLuint ShaderObj = glCreateShader(ShaderType);

Nous créons deux shaders en utilisant l’appel ci-dessus. L’un d’eux aura pour type de shader GL_VERTEX_SHADER et l’autre aura pour type GL_FRAGMENT_SHADER.

Le procédé pour spécifier la source et compiler le shader est le même pour les deux.

const GLchar* p[1];
p[0] = pShaderText;
GLint Lengths[1];
Lengths[0]= strlen(pShaderText);
glShaderSource(ShaderObj, 1, p, Lengths);

Avant la compilation du shader, nous devons spécifier son code source.

La fonction glShaderSource prend le shader comme paramètre et vous laisse une certaine liberté quant à la manière de spécifier la source.

La source peut être définie dans plusieurs chaînes de caractères et vous devez alors fournir un tableau de pointeurs sur ces chaînes ainsi qu’un tableau d’entiers où chaque valeur est la longueur de la chaîne correspondante. Par simplicité, nous utilisons une simple chaîne de caractères pour la source complète et nous utilisons deux tableaux de un élément chacun, pour la source et pour sa longueur.

Le second paramètre de l’appel est le nombre d’emplacements des deux tableaux (juste un dans notre cas).

glCompileShader(ShaderObj);

La compilation du shader est très facile …

GLint success;
glGetShaderiv(ShaderObj, GL_COMPILE_STATUS, &success);
if (!success)
{
	GLchar InfoLog[1024];
	glGetShaderInfoLog(ShaderObj, sizeof(InfoLog), NULL, InfoLog);
	fprintf(stderr, "Error compiling shader type %d: '%s'\n", ShaderType, InfoLog);
}

… cependant, vous obtiendrez généralement quelques erreurs de compilation.Le bout de code ci-dessus récupère le statut de compilation et affiche toutes les erreurs que le compilateur a rencontrées.

glAttachShader(ShaderProgram, ShaderObj);

Finalement nous attachons notre shader au programme. C’est très similaire à la spécification de la liste des objets pour lier dans un makefile. Comme nous n’avons pas de makefile ici, nous émulons ce comportement par programmation. Seuls les shaders attachés prennent part au procédé d’édition de lien.

glLinkProgram(ShaderProgram);

Après avoir compilé tous nos shaders et les avoir attachés au programme, nous pouvons finalement le linker.

Notez qu’après le link du programme, vous pouvez vous débarrasser des shaders intermédiaires en appelant glDeleteShader pour chacun d’entre eux.

glGetProgramiv(ShaderProgram, GL_LINK_STATUS, &Success);
if (Success == 0)
{
	glGetProgramInfoLog(ShaderProgram, sizeof(ErrorLog), NULL, ErrorLog);
	fprintf(stderr, "Error linking shader program: '%s'\n", ErrorLog);
}

Notez que nous vérifions les erreurs en relation avec le programme (comme les erreurs de liaison) d’une manière un peu différente de celles liées aux shaders. Au lieu d’utiliser glGetShaderiv nous utilisons glGetProgramiv et au lieu de glGetShaderInfoLog nous utilisons glGetProgramInfoLog.

glValidateProgram(ShaderProgram);

Vous pouvez vous demander pourquoi nous avons besoin de valider le programme après qu’il ait été correctement lié.

La différence est que le l’édition des liens vérifie les erreurs basées sur la combinaison des shaders alors que l’appel ci-dessus vérifie si le programme peut s’exécuter dans l’état actuel du pipeline.

Dans une application complexe avec de multiples shaders et de nombreux changements d’état il est conseillé de valider avant chaque appel de la fonction de dessin. Pour notre application basique, nous ne le vérifions qu’une seule fois. Aussi, vous pouvez vouloir faire cette vérification uniquement pendant le développement et éviter la surcharge dans le produit final.

glUseProgram(ShaderProgram);

Finalement, pour utiliser le programme lié, vous l’affectez au pipeline en utilisant l’appel ci-dessus.

Ce programme restera actif pour tous les appels à la fonction de dessin jusqu’à ce que vous le remplaciez par un autre ou que vous le désactiviez explicitement (et activiez le pipeline fixe) en appelant glUseProgram avec NULL.

Si vous avez créé un programme qui contient un seul type de shader alors les autres étapes fonctionnent en utilisant leur fonctionnalité fixe par défaut.

Nous avons terminé l’explication des appels OpenGL relatifs à la gestion de shader.

Le reste de ce tutoriel est relatif au contenu des vertex et fragment shaders (contenus dans les variables pVS et pFS).

#version 330

Cette ligne dit au compilateur que nous ciblons la version 3.3 de GLSL. Si le compilateur ne la supporte pas il va émettre une erreur.

layout (location = 0) in vec3 Position;

Cette déclaration apparaît dans le vertex shader.

Elle déclare qu’un attribut spécifique au sommet, un vecteur de trois flottants, sera connu sous le nom « Position » dans le shader. « Spécifique au sommet » signifie que pour tous les appels du shader dans le GPU la valeur d’un nouveau sommet venant du tampon sera fournie.

La première section de la déclaration, « layout (location=0) », crée un lien entre le nom de l’attribut et l’attribut dans le tampon. C’est requis pour les cas où notre sommet contient plusieurs attributs (position, normale, coordonnées de texture…). Nous informons le compilateur quel attribut du sommet dans le tampon doit être affecté à l’attribut déclaré dans le shader.

Il y a deux méthodes pour faire cela.

  • Nous pouvons le déclarer explicitement comme nous le faisons ici (à zéro).
    Dans ce cas nous pouvons utiliser la valeur en dur dans notre application (ce que nous avons fait avec le premier paramètre de l’appel à glVertexAttributePointer).
  • Ou nous pouvons le laisser indéfini (et simplement déclarer « in vec3 Position » dans le shader) et demander sa position à l’exécution de l’application en utilisant glGetAttribLocation.
    Dans ce cas nous devrons fournir la valeur retournée à glVertexAttributePointer au lieu de la valeur en dur.

Nous avons choisi la méthode simple mais dans des applications plus complexes il vaut mieux laisser le compilateur déterminer les indices des attributs et les demander à l’exécution. Cela rend plus facile l’intégration de shaders à partir de sources multiples sans avoir à les adapter à la disposition de votre tampon.

void main()

Vous pouvez créer votre shader en liant ensemble de multiples shaders. Cependant il n’y a qu’une fonction « main » pour chaque étape de shader (VS, GS ou FS) qui est utilisée comme point d’entrée du shader.

Par exemple vous pouvez créer une bibliothèque de gestion de l’éclairage avec plusieurs fonctions et la lier avec votre shader, si tant est qu’aucune de ses fonctions n’est nommée « main ».

gl_Position = vec4(0.5 * Position.x, 0.5 * Position.y, Position.z, 1.0);

Ici nous effectuons des transformations codées en dur sur la position des sommets qui arrivent. Nous divisons par deux les valeurs X et Y et laissons Z inchangé.

gl_Position est une variable prédéfinie spéciale qui est définie pour contenir des coordonnées homogènes (composantes X, Y, Z et W) pour la position du sommet. Le rastériseur va regarder dans cette variable et l’utiliser comme position dans l’espace écran (après quelques transformations supplémentaires). Diviser X et Y par deux signifie que nous allons voir un triangle dont la taille est le quart de celle du triangle du précédent tutoriel.

Notez que nous mettons aussi W à 1.0. C’est extrêmement important afin que le triangle soit affiché correctement.

Effectuer la projection de la 3D vers la 2D est en réalité une action accomplie en deux étapes séparées.

  • Tout d’abord il faut multiplier tous les sommets par la matrice de projection (que nous développerons d’ici quelques tutoriels).
  • Ensuite le GPU effectue ce qui est connu comme le « perspective divide » à l’attribut position avant qu’elle n’atteigne le rastériseur.
    Cela signifie qu’il divise toutes les composante de gl_Position par sa composante W.

Dans ce tutoriel, nous ne faisons pas encore de projection dans le vertex shader mais l’étape de « perspective divide » est quelque chose que nous ne pouvons pas désactiver. Quelle que soit la valeur de gl_Position que nous sortons du vertex shader, ses composantes XYZ vont être divisées par le GPU en utilisant sa composante W.

Nous devons nous souvenir que sans celle-ci nous n’aurons pas les résultats que nous attendons. Afin de contourner l’effet de cette division, nous définissons W à 1.0. Une division par 1.0 n’affectera pas les autres composantes de la position du vecteur qui restera alors dans notre boîte normalisée.

Si tout s’est passé correctement, trois sommets avec les valeurs (-0.5, -0.5), (0.5, -0.5) et (0.0, 0.5) atteignent le rastériseur.

Le clipper n’a pas besoin de faire quoi que ce soit, car tous les sommets sont bien dans la boîte normalisée. Ces valeurs sont mappées en coordonnées dans l’espace écran et le rastériseur commence à parcourir tous les points dans le triangle. Pour chaque point le fragment shader est exécuté.

Le code de shader qui suit est pris du fragment shader.

out vec4 FragColor;

Habituellement le travail du fragment shader est de déterminer la couleur du fragment (pixel).

En plus, le fragment shader peut complètement se débarrasser du pixel ou changer sa valeur en Z (ce qui affectera le résultat du test de profondeur ultérieur).

La production de la couleur se fait en déclarant la variable ci-dessus. Les quatre composantes représentent R, G, B et A (pour alpha).

La valeur que vous placez dans cette variable sera reçue par le rastériseur et enfin écrite dans le tampon d’image.

FragColor = vec4(1.0, 0.0, 0.0, 1.0);

Dans le précédent couple de tutoriels, il n’y avait pas de fragment shader donc tout était dessiné avec la couleur par défaut qui est le blanc. Ici nous définissons FragColor à rouge.

Résultat

OGLdev04

Remerciements

Merci à Etay Meiri de me permettre de traduire ses tutoriels.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *