Les shaders dans OpenGL

Introduction à la programmation de shaders GLSL

Alexandre Laurent

Cet article vous apprendra à utiliser les shaders OpenGL, pièces essentielles de toute application 3D moderne. Ces shaders vous permettront de programmer votre carte graphique afin d'effectuer une multitude d'effets graphiques allant du calcul de lumières, d'ombres, de bump mapping en passant par des techniques telles que le Cell Shading.

Vous pouvez commenter l'article en suivant le lien suivant: Commentez .

- Titre : Les shaders dans OpenGL

- Auteur : Alexandre Laurent

- Parution : 24 mai 2011

- Mise à jour : 6 mai 2012

- Public visé :

- Licence : 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 ® 2011 Alexandre Laurent. Aucune reproduction, même partielle, ne peut être faite de ce site et 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.

Sommaire

- I. Introduction

- I-A. Historique

- I-A-1. Pipeline fixe

- I-A-2. Pipeline dynamique

- I-A-3. Évolution du pipeline dynamique

- I-B. Explication

- I-C. Fonctionnement

- II. Mise en place des shaders dans un programme

- II-A. Utilisation des shaders (Documentation OpenGL 2.1)

- II-B. Lecture d'un fichier texte pour l'intégrer comme shader

- III. Étude du GLSL

- III-A. Le langage GLSL

- III-A-1. Les types de données

- III-A-2. Les qualificatifs de données

- III-A-3. Le préprocesseur

- III-A-4. Autres mots clés

- III-A-5. Variables prédéfinies du vertex shader

- III-A-6. Variables prédéfinies du fragment shader

- III-B. Communication entre l'application et les shaders

- III-B-1. Les variables uniformes

- III-B-2. Les attributs de sommet

- III-B-3. Les textures

- IV. Étude des shaders

- IV-A. Techniques de base

- IV-A-1. Éclairage

- IV-A-1-a. La lumière ambiante

- IV-A-1-b. La lumière diffuse

- IV-A-1-c. La lumière spéculaire

- IV-A-1-d. Application de nos différentes lumières

- IV-A-1-e. Lampe directionnelle

- IV-A-1-f. Point de lumière

- IV-A-1-g. Spot

- IV-A-1-h. Matériaux

- IV-A-1-i. Conclusion sur les lumières

- IV-A-2. Application d'une texture

- IV-A-2-a. Différents types d'application

- IV-A-2-b. Génération automatique de coordonnées de texture

- IV-A-3. Brouillard

- IV-A-4. Clipping

- V. Pour aller plus loin

- V-A. Outils

- V-A-1. Création de shaders

- V-A-2. Débogage des shaders

- V-B. Liens

- V-C. Remerciements

I - Introduction

Les shaders sont des programmes exécutés par la carte graphique afin de calculer le rendu des pixels à l'écran. Ce tutoriel a pour but d'expliquer le fonctionnement des shaders et leur utilisation dans un programme OpenGL. Les seuls prérequis dont vous avez besoin sont de savoir ouvrir une fenêtre OpenGL (avec WinAPI ou encore SDL ou Glut) et de connaître le fonctionnement des extensions (notamment si vous travaillez sous Windows). Il est aussi conseillé de savoir ce qu'est le pipeline graphique utilisé dans OpenGL, mais je reviendrai dessus un peu plus tard.

Vous pouvez télécharger le projet de base (Code::Blocks / Windows) permettant l'affichage d'un cube rotatif en 3D utilisant des shaders en cliquant ici.

I-A - Historique

I-A-1 - Pipeline fixe

Durant les années 1990, les premières cartes graphiques dédiées capables de générer des images 3D en temps réel ont vu le jour. Par la même occasion, les bibliothèques OpenGL et DirectX ont été créées. À cette époque, le pipeline graphique permettant d'afficher le rendu était fixe. Le diagramme suivant décrit ce pipeline :

Pipeline fixe (Source: http://www.khronos.org/ )

Avec le pipeline fixe, les constructeurs de cartes graphiques peuvent optimiser le calcul de toutes ces étapes en créant un matériel spécifiquement conçu pour cette tâche.

Par contre, comme son nom l'indique, le pipeline fixe est statique, l'utilisateur ne peut pas modifier un des algorithmes utilisés.

I-A-2 - Pipeline dynamique

Au fur et à mesure des besoins, notamment dans les productions professionnelles telles que les films d'animation, le pipeline fixe se trouve très limitant. La solution était soit d'utiliser le CPU pour appliquer les algorithmes voulus, processus très lent sachant que le CPU n'est pas spécialement optimisé pour de telles tâches, soit d'utiliser des cartes graphiques professionnelles ayant de nouveaux algorithmes ou même des algorithmes pouvant être modifiés. Avec la forte demande des professionnels tels que les sociétés de films d'animation, mais aussi les développeurs de jeux vidéo, les cartes configurables sont devenues monnaie courante. Ce nouveau pipeline graphique est présenté dans le diagramme suivant :

Pipeline dynamique (Source: http://www.khronos.org/ )

Les blocs vertex shader et fragment shader sont ceux que nous pouvons programmer.

L'avantage de ce nouveau pipeline est de pouvoir intégrer notre propre algorithme pour définir le rendu voulu. Grâce à cela, une multitude d'effets ont pu voir le jour, tels que le Cell Shading, le bump mapping, mais aussi l'implémentation de nouvelles techniques pour le calcul des lumières et des ombres.

La carte graphique est moins optimisée en comparaison de celles conçues pour un pipeline fixe, mais le fait de pouvoir définir un algorithme précis à la place d'un algorithme plus généraliste est un avantage.

Souvent les cartes graphiques supportant les shaders et le pipeline fixe, utilisent simplement des shaders pour reproduire le comportement du pipeline fixe.

I-A-3 - Évolution du pipeline dynamique

Du point de vue des bibliothèques graphiques, les shaders ont été intégrés à partir d'OpenGL 2.0 (OpenGL 1.4 par le biais des extensions) et dans DirectX 8. Depuis ce temps, les langages des shaders ne font qu'évoluer pour devenir de plus en plus puissants et performants.

Pour chaque version différente de shader, nous parlons de modèles de shader (shader models (SM)). Il est possible de préciser la version que nous voulons utiliser dans notre shader. Nous verrons comment faire cela un peu plus loin.

Rapide historique (je ne citerai que les cartes des constructeurs ATI et NVidia) :

Année

Version des shaders

DirectX

OpenGL

ATI

Nvidia

Info

2001

SM 1.x

DirectX8

OpenGL 2.0

Radeon R200

Geforce séries 3

2003

SM 2.x

DirectX9.0

Radeon R300 / R420

Geforce FX

2004

SM 3.0

DirectX9.0c

Radeon R520

Geforce séries 6 / 7

2006

SM 4.0

DirectX10

OpenGL 3.2

Radeon R600

Geforce séries 200 / 300

Apparition des Geometry Shader

2009

SM 5.0

DirectX11

OpenGL 4.1

Radeon R800

Geforce séries 400

Tesselation

I-B - Explication

Un shader est un programme que l'on exécute directement dans le cœur même de la carte graphique. La carte est optimisée pour tous les calculs nécessaires à la 3D (tels que la multiplication de matrices). Grâce aux shaders, certaines parties de la carte graphique sont programmables et nous pouvons en faire ce que nous voulons. Par exemple, dans le pipeline fixe, nous n'avions que huit sources de lumière. En reprogrammant l'algorithme utilisé pour le calcul des lumières, nous pouvons imaginer l'intégration d'une neuvième lumière. De plus, nous pouvons même changer l'algorithme afin de créer un effet totalement différent de ce que nous forçait le constructeur.

- Le vertex shader est exécuté pour chaque sommet.

- Le fragment shader est exécuté sur chaque pixel dessiné.

I-C - Fonctionnement

Comme il a été dit, nous allons pouvoir programmer directement nos cartes graphiques. Pour cela de nouveaux langages ont été inventés. Au tout début, c'était un assembleur spécialisé pour les cartes graphiques. Pour OpenGL, le langage s'appelle le GLSL pour OpenGL Shading Language (langage de shading pour OpenGL) et pour DirectX, le HLSL pour High Level Shading Language (langage de shading de haut niveau).

Comme nous l'avons vu dans le diagramme du pipeline dynamique, nous avons besoin des blocs Vertex Shader et Fragment Shader pour reconstituer le processus complet de rendu. Cela signifie qu'il va falloir développer deux programmes et que ceux-ci vont suivre un processus similaire à la création d'un programme, qui est composé de :

- deux compilations ;

- une édition de liens.

Il y a deux compilations, car nous devons compiler le Vertex Shader et le Fragment Shader. Par contre, ils sont liés ensemble pour constituer un programme (Program Shader), ce qui reconstituera l'ensemble de notre pipeline graphique.

Il est tout à fait possible de charger plusieurs Program Shader dans un programme OpenGL, mais il n'est pas possible d'en utiliser plusieurs pour le même objet à dessiner.

II - Mise en place des shaders dans un programme

II-A - Utilisation des shaders (Documentation OpenGL 2.1)

Comme pour tout élément qui s'intègre dans OpenGL (textures, buffers…) il faut créer des objets OpenGL pour accueillir l'élément voulu (n'oubliez pas que, pour un programme shader, nous avons deux sources : une pour le vertex shader ; une pour le fragment shader, donc nous avons besoin de deux objets).

- GLuint glCreateShader (GLenum shaderType)

shaderType : doit contenir GL_VERTEX_SHADER pour créer un vertex shader.
Retourne l'identifiant du shader.

- GLuint glCreateShader (GLenum shaderType)

shaderType : doit contenir GL_FRAGMENT_SHADER pour créer un fragment shader.
Retourne l'identifiant du shader.

Lecture des fichiers, le contenu est passé à (encore une fois, il faut faire deux fois l'appel à la commande) :

- void glShaderSource (GLuint shader, GLsizei count, const GLchar ** string, const GLint * length)

shader : contient l'identifiant du shader, retourné par glCreateShader().
Count : indique le nombre d'éléments dans string et length.
string : est un pointeur sur le code source de notre ou nos shaders.
length : indique la taille de notre ou nos shaders.

Comme tout programme, nous compilons le code (deux appels nécessaires, encore) :

- void glCompileShader (GLuint shader)

shader : contient l'identifiant de notre shader.

La vérification des erreurs est très importante, sans quoi, vous ne savez pas si votre compilation fonctionne ou pas :

- void glGetShader (GLuint shader, GLenum pname, GLint * params)

shader : contient l'identifiant de notre shader.
pname : contient un identifiant définissant la valeur qui va être retournée. Ici nous utilisons GL_COMPILE_STATUS.
params : contiendra la valeur retournée par OpenGL.

Et pour connaître l'erreur (si elle est présente) :

- void glGetShaderInfoLog (GLuint shader, GLsizei maxLength, GLsizei * length, GLchar * infoLog)

shader : contient l'identifiant de notre shader.
maxLength : contient la taille que vous avez allouée pour recevoir le message d'erreur.
length : contiendra la taille du message retourné.
infoLog : la chaîne de caractères qui contiendra le message d'erreur.

Maintenant que nos deux (vertex et fragment) shaders sont compilés, il faut créer le programme. Encore une fois, il nous faut un objet OpenGL et son identifiant :

- GLuint glCreateProgram (void)

Retourne l'identifiant du Program Shader.

Une fois l'ID créé, vous devez dire à OpenGL quel shader vous voulez mettre dans le programme (opération de liage ('linking' en anglais)) :

- void glAttachShader (GLuint program, GLuint shader)

program : contient l'identifiant du Program Shader auquel nous voulons attacher le shader.
shader : contient l'identifiant du shader que nous voulons attacher.

Nos deux shaders liés au programme vont être mis ensemble pour former le programme. Cette dernière étape se fait avec la commande :

- void glLinkProgram(GLuint program)

program : contient l'identifiant du Program shader que nous voulons lier.

Enfin, le shader devrait être prêt à être utilisé. Mais je conseille vivement de vérifier les erreurs avec :

- void glGetProgram (GLuint program, GLenum pname, GLint * params)

program : contient l'identifiant du Program Shader.
pname : contient un enum déterminant ce que nous voulons en retour. Ici nous utilisons GL_LINK_STATUS.
params : contiendra la valeur retournée par OpenGL.

Si une erreur est rapportée, vous pouvez en connaître la cause avec :

- void glGetProgramInfoLog (GLuint program, GLsizei maxLength, GLsizei * length, GLchar * infoLog)

program : contient l'identifiant du Program Shader.
maxLength : contient la taille que vous avez allouée pour recevoir le message d'erreur.
length : contiendra la taille du message retourné.
infoLog : la chaîne de caractères qui contiendra le message d'erreur.

Maintenant, vous pouvez utiliser le shader avec :

- void glUseProgram (GLuint program)

program : contient l'identifiant du Program Shader.

Si vous ne voulez plus utiliser de shader, vous pouvez appeler cette commande en passant 0.

Une fois que vous avez fini d'utiliser avec vos shaders, vous allez devoir les détruire.

Première chose à faire, nous arrêtons de les utiliser :

- void glUseProgram (GLuint program)

program : contient l'identifiant du Program Shader. Ici nous mettons 0 pour ne plus utiliser de shader.

Après, nous détachons les shaders du programme :

- void glDetachShader (GLuint program, GLuint shader)

program : contient l'identifiant du Program Shader.
shader : contient l'identifiant du shader à détacher du programme.

Puis nous détruisons le programme :

- void glDeleteProgram (GLuint program)

program : contient l'identifiant du Program Shader.

Puis les deux shaders :

- void glDeleteShader (GLuint shader)

shader : contient l'identifiant du shader à détruire.

Sachez que :

- si des shaders sont encore liés à un programme, l'appel à glDeleteShader() va mettre les shaders en attente pour suppression et ils seront supprimés lorsqu'ils seront détachés (glDetachShader()) ;

- si le programme est encore utilisé, l'appel à glDeleteProgram() va mettre le programme en attente pour suppression, et il sera supprimé une fois inutilisé (glUseShader(0)) ;

- le fait de supprimer un programme détache automatiquement les shaders qui lui sont attachés.

En prenant en considération tout ce qui vient d'être dit, il est conseillé de :

- supprimer les shaders dès que vous les avez attachés (si vous ne voulez pas les attacher une autre fois, bien sûr) ;

- supprimer le programme une fois qu'il n'y en a plus besoin. La suppression du programme détachera les shaders, ce qui fera qu'ils seront supprimés tout de suite avec le programme.

II-B - Lecture d'un fichier texte pour l'intégrer comme shader

Comme nous avons pu le voir, la commande glShaderSource() accepte un tableau de chaines de caractères et non le fichier directement. Ceci est fait pour que le code d'OpenGL soit le plus portable possible (par exemple nous pouvons mettre directement notre code source dans une variable dans le code de notre programme). Ici, nous allons voir comment nous pouvons lire un fichier pour OpenGL.

La première chose à faire sur notre fichier est de récupérer la taille du fichier. Une méthode consiste à mettre le pointeur du fichier directement sur la fin est à récupérer sa position :

Récupérer la taille d'un fichier

long getFileSize(FILE* pFile)
{
long length = 0;

fseek(pFile,0,SEEK_END);

length = ftell(pFile);
// Ne pas oublier de mettre le fichier à son début, sinon on ne peut pas le lire
fseek(pFile,0,SEEK_SET);
return length;
}

Ensuite, il faut récupérer le contenu du fichier en lui-même :

Récupérer les données du fichier

char* fileContent = (char*)malloc(fileLength+1); // +1 pour terminer la chaine proprement
if ( fileContent == NULL )
{
return -3;
}
fread(fileContent,fileLength,1,pFile);
// Termine le tableau qui contient le shader
fileContent[fileSize] = '\0';
// Utilisation avec glShaderSource()
free(fileContent);

Vous pouvez voir que l'on réutilise la taille du fichier trouvé plus tôt.

Ci-dessous, nous allons voir les sections les plus importantes de ce code. Afin de simplifier le code, je n'ai pas fait une vérification minutieuse des erreurs.

Fonction de vérification de la compilation.

// Pour plus de simplicité, j'ajoute une fonction qui vérifie la compilation des shaders
char checkShaderCompilation(GLuint shaderID)
{
GLint compilationStatus = 0;

// Vérification de la compilation pour le vertex shader
glGetShaderiv(vertexID, GL_COMPILE_STATUS, &compilationStatus);
if ( compilationStatus != GL_TRUE )
{
// Nous savons que la compilation a échoué, donc nous devons savoir pourquoi
// Nous devons récupérer la taille du log
GLint logLength = 0;
GLchar* log = NULL;

glGetShaderiv(shaderID, GL_INFO_LOG_LENGTH, &logLength);

// Maintenant que nous avons la taille, nous pouvons allouer la mémoire suffisante pour ce log
log = (GLchar*)malloc(logLength);
if ( log == NULL )
{
fprintf(stderr,"Erreur d'allocation de mémoire pour le log de la compilation du shader\n");
return 0;
}

glGetShaderInfoLog(shaderID, logLength, &logLength, log);

// On peut afficher le message
fprintf(stderr,"Erreur de compilation:\n%s",log);

// Et on n'oublie pas de libérer la mémoire
free(log);
return 0;
}
return 1; // Pas d'erreur
}

Le code qui suit charge et compile nos shaders :

Chargement et compilation des shaders

void loadShader()
{
// Lecture des fichiers
// Certaines personnes aiment avoir le fichier du vertex shader avec l'extension .vert
// et le fichier du fragment shader avec l'extension .frag
GLchar* vertexSource = (GLchar*)readFile("data/simple.vert");
GLchar* fragmentSource = (GLchar*)readFile("data/simple.frag");
GLint programState = 0;
GLint vertexSize = 0;
GLint fragmentSize = 0;

// Création des ID
vertexID = glCreateShader(GL_VERTEX_SHADER);
fragmentID = glCreateShader(GL_FRAGMENT_SHADER);

// Vérification des fichiers
if ( !vertexSource || !fragmentSource )
{
// Ici, il faudrait faire en sorte que le programme s'arrête
deleteShader();    // Nettoyage
return;
}

// Chargement des sources dans OpenGL
vertexSize = strlen(vertexSource);
fragmentSize = strlen(fragmentSource);
glShaderSource(vertexID, 1, (const GLchar**)(&vertexSource), &vertexSize);
glShaderSource(fragmentID, 1, (const GLchar**)(&fragmentSource), &fragmentSize);

// Compilation du vertex shader
glCompileShader(vertexID);
glCompileShader(fragmentID);

// Vérification des erreurs
if ( !checkShaderCompilation(vertexID) || !checkShaderCompilation(fragmentID))
{
deleteShader();
return;
}

// Création de l'ID pour le programme
programID = glCreateProgram();

// On attache les shaders ensemble
glAttachShader(programID, vertexID);
glAttachShader(programID, fragmentID);

// On peut enfin passer à la liaison.
glLinkProgram(programID);

// Et encore une fois on vérifie si tout se passe bien
glGetProgramiv(programID , GL_LINK_STATUS  , &programState);
if ( programState != GL_TRUE)
{
// On récupère la taille du log
GLint logSize = 0;
GLchar* log = NULL;

glGetProgramiv(programID, GL_INFO_LOG_LENGTH, &logSize);

// On peut allouer la mémoire, une fois que l'on a la taille du log
log = (GLchar*)malloc(logSize);
if ( log == NULL )
{
fprintf(stderr,"Erreur d'allocation de mémoire pour le log de la compilation du programme\n");
deleteShader();
return;
}

// Et on récupère le log
glGetProgramInfoLog(programID, logSize, &logSize, log);

// On affiche
fprintf(stderr,"Erreur lors du liage du shader:\n%s",log);
free(log);
deleteShader();
return;
}
// Voilà, nous sommes prêts
glUseProgram(programID);
}

Et pour notre premier essai, nous utilisons le vertex shader ci-dessous. Ce vertex shader reproduit le comportement du pipeline fixe afin de définir la position du sommet. La deuxième ligne copie la couleur du sommet au fragment shader.

Vertex shader

void main (void)
{
gl_Position = ftransform();
gl_FrontColor = gl_Color;
}

Le fragment shader suivant copie la couleur attribuée aux sommets dans les pixels.

Fragment shader

void main(void)
{
gl_FragColor = gl_Color;
}

Si vous lancez le programme, vous remarquez que rien de spectaculaire ne se passe. Il n'y a que le cube qui tourne et il a les couleurs comme indiqué dans le programme OpenGL. En plus, si vous supprimez tout le code lié au shader, ou même, plus simple, si vous enlevez le code glUseShader() de la fonction loadShader(), vous aurez exactement le même résultat :

Notre première application utilisant des shaders !

Vous pouvez télécharger le projet (Code::Blocks / Windows) en cliquant ici.

Voilà, nous avons le premier programme OpenGL utilisant des shaders en état de marche. Certes le résultat n'est pas spectaculaire, mais cela est un très bon début.

Maintenant, vous allez remplacer votre vertex shader, par le suivant :

Vertex shader 2

void main (void)
{
gl_Position = ftransform();
gl_Position = vec4(gl_Position.xyz * 0.3,gl_Position.w);
gl_FrontColor = gl_Color;
}

Voilà, notre cube est devenu tout petit. Effectivement, le vertex shader que j'ai fait rétrécit les dimensions des vertex :

Un plus petit cube

Vous pouvez reprendre le premier vertex shader que je vous ai présenté, mais remplacer le fragment shader par le suivant :

Fragment shader 2

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

Maintenant, notre cube est tout rouge alors que je n'ai touché aucune ligne de code du programme OpenGL :

Un cube rouge

Mais, sachez que les shaders ne permettent pas uniquement de changer la taille ou la couleur des objets, ils permettent beaucoup plus et nous allons le voir dans la suite de ce tutoriel.

III - Étude du GLSL

Maintenant que nous savons comment faire fonctionner nos shaders dans un programme OpenGL, nous allons pouvoir apprendre le langage utilisé avec les shaders. Je vous invite à tester les différentes fonctionnalités que je présente. Vous pouvez retrouver une feuille récapitulative de tous les mots clés du GLSL ici (version 150, OpenGL 4.1), et ici (version 150 OpenGL 3.2).

III-A - Le langage GLSL

Je ne vais pas m'étendre sur la théorie du langage (conditions, boucles…), car j'imagine que si vous lisez cet article c'est que vous connaissez déjà la programmation. Pour information, la syntaxe du GLSL est souvent rapprochée à celle du C.

III-A-1 - Les types de données

Dans le GLSL, de nombreux types ont été implémentés afin de faciliter les calculs des opérations graphiques.

- float : représente un nombre à virgule flottante (ex: 0.4543).

- vec2 / vec3 / vec4 : représentent des vecteurs (à 2 / 3 / 4 dimensions) de float.

- int : représente un nombre entier (ex. : 42).

- ivec2 / ivec3 / ivec4 : représentent des vecteurs (à 2 / 3 / 4 dimensions) de int.

- bool : représente un booléen (soit vrai, soit faux).

- bvec2 / bvec3 / bvec4 : représentent des vecteurs (à 2 / 3 / 4 éléments) de booléens.

- mat2 / mat3 / mat4 : représentent des matrices (2x2 / 3x3 / 4x4) de floats.

- void : ne représente rien (Type non spécifié ou absence de valeur).

- sampler1D / sampler2D / sampler3D : représentent une texture (à 1 / 2 / 3 dimensions).

- samplerCube : représente une texture cube (équivalent à six textures).

- sampler1DShadow / sampler2DShadow : représente une texture de profondeur pour le calcul d'ombre (à 1 / 2 dimensions).

La déclaration des vecteurs peut se faire comme suit :

Déclaration d'un vecteur en GLSL

vec2 texture(0.3f,0.2f);
vec3 couleur(1.0f,0.0f,0.0f);
vec4 position(0.4f,0.3f,0.2f,1.0f);

L'accès aux composantes des vecteurs peut se faire de la manière suivante :

Accès aux membres des vecteurs

vec4 position;
position.x ; positions.y ; position.z ; position.w;
vec4 colour;
colour.r ; colour.g ; colour.b ; colour.a;
vec4 texture;
texture.s ; texture.t ; texture.p ; texture.q;

Les coordonnées 4D sont dans un repère x, y, z, w.

Les couleurs sont r, g, b, a pour red, green, blue, alpha

Les coordonnées pour les textures sont s, t, p, q. (p et non r à cause du conflit avec les couleurs.)

Vous pouvez aussi récupérer plusieurs composantes à la fois :

Accès à de multiples composantes d'un vecteur

vec4 position; // xyzw
position.xy ; position.xz ; position.xyz;
Vec4 colour; // rgba
colour.rg ; colour.ba ; colour.gba;
Vec4 texture; // stpq
texture.st ; texture.tp ; texture.pts

Et même faire des déclarations avec cet accès multiple :

Déclaration d'un vecteur se basant sur d'autres

vec2 t1(1.0f, 0.0f);
vec2 t2(0.0f, 0.5f);
vec4 position(t1.xy, t2.xy);

Il n'est pas possible de mixer les composants (ex. : colour.stx ; colour.rgz ; …)

III-A-2 - Les qualificatifs de données

Il existe quelques mots clés permettant de donner des indications au compilateur sur la variable déclarée.

À la déclaration de la variable (en dehors des fonctions) :

- uniform : indique que la variable est une donnée venant du programme OpenGL pour le vertex shader et/ou le fragment shader. (LECTURE UNIQUEMENT) ;

- attribute : indique une variable par vertex, venant du programme OpenGL. Ne peut être placée que dans le vertex shader. (LECTURE UNIQUEMENT). Obsolète à partir de la version 150 du GLSL, remplacé par 'in' ;

- varying : donnée en sortie du vertex shader (LECTURE et ÉCRITURE), envoyée au fragment shader après avoir été interpolée (LECTURE UNIQUEMENT). Obsolète à partir de la version 150 du GLSL, remplacé par 'out' ;

- const : indique une variable qui est constante (LECTURE UNIQUEMENT).

Dans la déclaration des fonctions :

- in : la variable est initialisée en entrée, mais n'est pas copiée en retour (qualificatifs par défaut) ;

- out : la variable est copiée en retour, mais non initialisée en entrée ;

- inout : la variable est initialisée en entrée, copiée en retour ;

- const : variable d'entrée constante.

Les variables qui sont échangées entre le vertex shader et le fragment shader (varying) sont interpolées par le pipeline (la précision de l'interpolation est configurable). Ainsi, en ne renseignant que des données pour chaque vecteur, nous allons recevoir cette donnée pour tous les pixels et elle peut être légèrement différente entre deux points.

III-A-3 - Le préprocesseur

Bien que je n'en aie pas du tout parlé jusqu'à présent, les shaders ont aussi leur préprocesseur.

Si vous connaissez le C, vous allez retrouver beaucoup de mots clés identiques :

- #define NOM définition : définit une macro 'NOM' avec pour valeur 'définition' pour le préprocesseur. Si 'NOM' est appelé dans le code, 'définition' va être copié à la place de 'NOM' lors de la lecture par le préprocesseur ;

- #undef NOM : permet d'enlever la déclaration de 'NOM' effectué avec #define ;

- #if : test logique ;

- #ifdef NOM : teste si 'NOM' est défini ;

- #ifndef NOM : teste si 'NOM' n'est pas défini ;

- #else : sinon, pour le #if ;

- #elif : sinon si, suivi d'un test logique ;

- #endif : fin de test (pour fermer le bloc commencé par #if) ;

- #error : provoque une erreur lors de la compilation ;

- #pragma : actionne une commande qui dépend de l'implémentation ;

- #line : modifie le compteur de lignes du compilateur (pour changer le numéro de ligne du rapport d'erreurs) ;

- __LINE__ : macro qui sera remplacée par le numéro de ligne courante ;

- __FILE__ : macro indiquant le fichier courant ;

- __VERSION__ : macro indiquant la version actuelle du GLSL ;

- #version 'numéro_de_version' : indique au compilateur la version de GLSL nécessaire (ou voulue) pour ce shader. Exemple :

// Pour forcer le GLSL en version 1.50
#version 150

- #extension {nom_de_l_extension | all} : {require | enable | warn |disable} : permet de connaître si une extension est présente, mais aussi de ne compiler que si celle-ci est présente. Par défaut, toutes les extensions sont désactivées (#extension all : disable). Exemple d'utilisation :

#extension GL_EXT_gpu_shader4 : enable

III-A-4 - Autres mots clés

Il reste encore quelques mots clés qui ne vont pas dans les catégories précédentes :

- struct : définit une structure. Exemple :

Définition d'une structure en GLSL

struct MyVector
{
vec4 position;
vec4 colour;
};

L'accès aux membres se fait de la manière suivante :

Accès aux membres d'une structure

MyVector.position;
MyVector.colour;

III-A-5 - Variables prédéfinies du vertex shader

Le GLSL permet d'accéder à des variables prédéfinies (builtin). Par prédéfinies, je sous-entends qu'elles existent et sont accessibles, mais qu'elles peuvent ne pas contenir de données essentielles (dépendantes de votre programme OpenGL).

Variables de sorties (LECTURE et ÉCRITURE) :

- vec4 gl_Position : position du vertex dans l'espace écran. Votre vertex shader est obligé d'écrire quelque chose dans cette variable ;

- float gl_PointSize : détermine la taille des points à dessiner. Le programme doit avoir GL_VERTEX_PROGRAM_POINT_SIZE activé ;

- vec4 gl_ClipVertex : doit être rempli par la position du vertex, afin d'effectuer les opérations de découpage (clipping). Explication détaillée dans la section 4.A.4.

Variables d'attributs (LECTURE UNIQUEMENT) :

- attribute vec4 gl_Vertex : position du vertex ;

- attribute vec4 gl_Normal : normale du vertex ;

- attribute vec4 gl_Color : couleur du vertex ;

- attribute vec4 gl_SecondaryColour : couleur secondaire du vertex ;

- attribute vec4 gl_MultiTexCoord0 : coordonnées de la texture 0 (le 0 peut être remplacé par un chiffre de 0 à 7 compris) ;

- attribute float gl_FogCoord : coordonnées du brouillard.

Variables 'varying' (LECTURE et ÉCRITURE) :

- varying vec4 gl_FrontColor : couleur de la face avant ;

- varying vec4 gl_BackColor : couleur de la face arrière (GL_VERTEX_PROGRAM_TWO_SIDE doit être activé) ;

- varying vec4 gl_FrontSecondaryColor : couleur secondaire de la face avant ;

- varying vec4 gl_BackSecondaryColor : couleur secondaire de la face arrière ;

- varying vec4 gl_TexCoord[] : tableau des coordonnées de texture. La taille du tableau maximum est : gl_MaxTextureCoords ;

- varying float gl_FogFragCoord : coordonnée du brouillard.

III-A-6 - Variables prédéfinies du fragment shader

Variables de sorties (LECTURE et ÉCRITURE) :

- vec4 gl_FragColor : couleur du pixel ;

- vec4 gl_FragData[] : donnée des buffers (la taille du tableau maximum est : gl_MaxDrawBuffers) ;

- float gl_FragDepth : la profondeur du pixel (par défaut : glFragCoord.z).

Variables 'varying' (LECTURE UNIQUEMENT) :

- varying vec4 gl_Color : la couleur du pixel, venant du vertex shader ;

- varying vec4 gl_SecondaryColor : la couleur secondaire du pixel, venant du vertex shader ;

- varying vec4 gl_TexCoord[] : coordonnées des textures (la taille maximale du tableau est gl_MaxTextureCoords) ;

- varying float gl_FogFragCoord : coordonnée du brouillard.

Variables d'entrées (LECTURE UNIQUEMENT) :

- vec4 gl_FragCoord : coordonnées du pixel en coordonnées relatives à l'écran ;

- bool gl_FrontFacing : true si nous voyons la face avant.

Il y a aussi des variables constantes utiles pour récupérer des informations telles que le nombre de variables uniformes, le nombre de lumières et autres…

Des variables uniformes dans lesquelles les informations sur les lumières, les matériaux, le brouillard transitent.

Et finalement, il y a aussi des fonctions implémentées comme les fonctions sinus, cosinus, des opérations sur les vecteurs, les matrices, mais aussi des fonctions pour récupérer les couleurs d'une texture, ou certaines pour créer du bruit.

Pour trouver la liste, vous pouvez utiliser la carte de référence, la spécification GLSL ou le livre orange.

III-B - Communication entre l'application et les shaders

Il existe plusieurs moyens complémentaires pour envoyer des informations de notre application OpenGL aux shaders.

Dans ces méthodes nous pouvons citer :

- les variables uniformes (uniform) ;

- les attributs de sommets (attribute) ;

- les textures (astuce un peu plus avancée).

III-B-1 - Les variables uniformes

Les variables uniformes (uniform) sont des variables qui sont constantes pour toutes les instances du shader. Lorsque nous parlons de globalité du shader, cela correspond au fait que cette variable ne changera pas de valeur selon les vertex et que pour toute la surface, elle aura la même valeur.

Dans le shader, cette variable doit être déclarée avec la ligne suivante :

Déclaration d'une variable uniforme en GLSL

uniform type nom;

Cette ligne ne doit pas être dans une fonction du shader. Comme il est écrit plus haut, c'est une variable qui peut être dans le vertex shader ou dans le fragment shader.

Maintenant que nous savons comment la représenter dans notre shader, il ne nous reste plus qu'à l'envoyer à partir de notre programme OpenGL.

Les fonctions pour ce faire sont :

- GLint glGetUniformLocation (GLuint program, const GLchar * name)

program : contient l'identifiant du Program Shader.
name : le nom de la variable dans le shader.

Retourne un identifiant pour la variable contenue dans le shader.

- void glUniform* (GLint location…)

location : l'identifiant que l'on a obtenu avec glGetUniformLocation().

J'ai mis une étoile dans le nom de glUniform(), car il y a toute une série de fonctions glUniform() et chacune définit l'emploi d'une variable d'un type spécifique (rappel : en C, il n'y a pas de surcharges de fonctions).

La fonction glGetUniformLocation() demande le programme shader sur lequel nous voulons envoyer la variable ainsi que le nom de la variable. Elle va nous retourner un ID sur cette variable que nous pourrons utiliser dans une des fonctions glUniform(), pour remplir la variable en question.

Il est conseillé de vérifier s'il y a eu une erreur, à l'aide de glGetError() après les appels à ces deux commandes.

Par rapport au premier code, j'ai ajouté ce code, dans la fin de la fonction loadShader() :

c

uniformId  = glGetUniformLocation(programID, "couleurBleu");
errorState = glGetError();
if ( uniformId == -1 || errorState != GL_NO_ERROR )
{
fprintf(stderr,"Erreur (%d) lors de la récupération de l'id de la variable uniforme 'couleur'\n",errorState);
}
// Voilà, nous sommes prêts
glUseProgram(programID);
glUniform1f(uniformId,couleurBleu);
errorState = glGetError();
if ( errorState != GL_NO_ERROR )
{
fprintf(stderr,"Erreur (%d) lors de l'envoi de valeur à la variable uniforme\n",errorState);
}

Pour ce premier essai, le fragment shader est le suivant :

Fragment shader acceptant la composante bleu du programme.

uniform float couleurBleu = 0.0;
void main(void)
{
gl_FragColor = vec4(gl_Color.xy,couleurBleu,1.0);
}

Le fragment shader attend couleurBleu du programme OpenGL (nous lui donnons une valeur par défaut juste au cas où le programme OpenGL ne ferait pas tout ce que l'on attend de lui) et utilise cette variable pour écrire la couleur des pixels.

Le résultat est le suivant :

Encore un cube

Il faut savoir que les échanges entre le programme OpenGL (CPU) et le shader (GPU), sont lents, c'est pourquoi il faut passer le plus de données en une fois (en remplissant une matrice 3x3 si vous avez 9 variables à la place de faire 9 appels à glUniform*())

III-B-2 - Les attributs de sommet

Les attributs de sommet (attribute) sont des variables que l'on passe avec chaque sommet. Cela veut dire que nous pouvons avoir une valeur différente pour chaque vertex. Grâce à cela vous pouvez ajouter des informations à vos sommets en complément de la position (qui est obligatoire), la couleur, les coordonnées de texture. Par contre, elles ne sont disponibles que dans les vertex shader (il est toujours possible de les faire transiter dans le fragment shader à l'aide d'une variable supplémentaire déclarée en varying).

Dans le GLSL, une telle variable se définit de la façon suivante :

Déclaration d'une variable 'attribute'

attribute vec3 nom;

Du côté de notre application OpenGL, nous allons utiliser les variables suivantes :

- GLint glGetAttribLocation (GLuint program, const GLchar * name)

program : contient l'identifiant du Program Shader.

name : contient le nom de la variable du shader.

Retourne un identifiant sur la variable contenu dans le shader.

- void glVertexAttrib*(GLuint index)

index : l'identifiant de la variable à remplir.

Comme pour les variables uniformes, nous allons dans un premier temps, récupérer un identifiant sur la variable que nous voulons remplir. La fonction accepte l'identifiant sur le programme shader, ainsi que le nom de la variable.

Une fois que nous avons l'identifiant de notre attribut, nous pouvons le remplir avec glVertexAttrib*() lors du dessin de nos polygones. Il faut appeler la fonction autant de fois que nous avons de sommets. Dans l'exemple, étant donné que je dessinais mon cube avec les VBO, je dois passer tout un tableau (telles les autres données du VBO).

Une fois de plus, il est conseillé de vérifier les erreurs avec glGetError().

Code exemple :

Envoi d'attribut au vertex shader

attributeId = glGetAttribLocation(programID, "couleur");
errorState = glGetError();
if ( attributeId == -1 || errorState != GL_NO_ERROR )
{
fprintf(stderr,"Erreur (%d) lors de la récupération de l'id de la variable attribute 'couleur'\n",errorState);
}

// Voilà, nous sommes prêts
glUseProgram(programID);

glEnableVertexAttribArray(attributeId);
errorState = glGetError();
glVertexAttribPointer(attributeId,
3,
GL_FLOAT,
GL_TRUE,
0,
cubeColours);
if ( errorState != GL_NO_ERROR )
{
fprintf(stderr,"Erreur (%d) lors du passage du tableau de valeur à la variable attribute 'couleur'\n",errorState);
}

Notez que comme j'ai décidé d'utiliser les attributs pour passer la couleur de mes sommets au shader, le glColorPointer() ainsi que les autres fonctions liées sont devenues obsolètes dans cet exemple.

Bien sûr, dans la fonction quitScene() j'ai ajouté la ligne suivante :

Désactivation du tableau d'attributs

glDisableVertexAttribArray(attributeId);

Le shader accompagnant notre programme :

Vertex shader

attribute vec3 couleur = vec3(0.0f,1.0f,0.0f);
void main (void)
{
gl_Position = ftransform();
gl_FrontColor = vec4(couleur,1.0);
}

Ce qui donne le résultat suivant :

Un cube utilisant les attributs de sommet

Effectivement, il ressemble à notre tout premier cube, mais contrairement à la première fois, nous utilisons maintenant des attributs de sommet.

Pour pouvoir vous rappeler des différences entre les attributs de sommets et les variables uniformes, vous pouvez vous référer à ce diagramme :

Organisations des attribute et varying

III-B-3 - Les textures

Cette technique utilise simplement les textures pour transférer des informations. La technique est la même que lorsque nous voulons appliquer une texture.

IV - Étude des shaders

IV-A - Techniques de base

IV-A-1 - Éclairage

L'éclairage est une partie importante pour toute application graphique. Nous allons reproduire le comportement du pipeline fixe lors du calcul des lumières.

Pour rappel, nous distinguons trois types de sources :

- La lampe directionnelle ;

- Le spot ou projecteur (voyons-le comme un cône ayant sa pointe à la source de la lampe) ;

- Le point de lumière (point light) (pensez à une luciole).

Lors du calcul de l'éclairage, nous distinguons trois types de lumière :

- la lumière ambiante représentant une lumière globale ;

- la lumière diffuse représentant les rayons rebondissant sur nos objets des différentes sources de lumière ;

- la lumière spéculaire représentant le reflet des lumières.

Avant de commencer les calculs, il faut déterminer les besoins afin de décrire les lumières. Les informations à passer à notre shader sont les suivantes :

- la position de la lumière. Effectivement, avec la position de la lumière, il sera plus facile de calculer la zone éclairée par la lumière ;

- la couleur ambiante de la lumière ;

- la couleur diffuse de la lumière ;

- la couleur spéculaire de la lumière.

Seulement pour les spots et les points de lumière :

- atténuation constante ;

- atténuation linéaire ;

- atténuation quadratique.

Seulement pour les spots :

- une direction ;

- un angle d'ouverture ;

- un coefficient d'exposition (un objet en plein dans l'angle du spot sera plus éclairé qu'un objet à la limite de la zone éclairée par le spot) ;

- une limite d'éclairage (pour définir là où le spot n'éclaire plus, car l'objet est trop loin).

Il est toujours possible de ne pas utiliser toutes ces informations afin de simplifier et/ou d'optimiser les calculs.

Pour les exemples de code qui suivent, nous allons partir du principe que nous utilisons la structure suivante :

Structure des informations pour la lumière

struct Lumiere
{
vec3 position;
vec4 couleurAmbiante;
vec4 couleurDiffuse;
vec4 couleurSpeculaire;
float shininess ; // Seulement pour la lumière spéculaire
// Spécifique aux spots et points
float attenuationConstante ;
float attenuationLineaire ;
float attenuationQuadratique ;
// Spécifique aux spots
vec3 direction ;
float angle ;
float exposition;
float limite;
}

Afin de faire les calculs de lumière diffuse et de lumière spéculaire, nous avons besoin des normales pour chaque vertex de nos objets. Finalement, il est nécessaire de connaître l'inverse de la matrice de vue, pour le calcul de la lumière spéculaire.

IV-A-1-a - La lumière ambiante

Certainement la plus simple à appliquer, celle-ci est simplement une application de la couleur ambiante sur l'objet à éclairer.

Calcul de la lumière ambiante

couleurAmbiante = lumiere.ambiant;

IV-A-1-b - La lumière diffuse

Schématisation de la lumière diffuse

La lumière diffuse est une application de la formule de la réflexion de Lambert :

- intensité = couleur_de_la_lumière_diffuse * cos(angle_entre_le_rayon_de_la_lumière_et_la_normale_de_l'objet)

Comme vous pouvez le constater, la couleur que l'on applique sur le pixel n'est qu'une fraction de la lumière de la lampe. La fraction dépend de l'angle entre le rayon lancé par la lampe sur l'objet et de l'orientation de l'objet (sa normale).

Le rayon de la lumière n'est autre que le vecteur entre la lumière et le point d'impact de celle-ci. Il est simple de connaître ce vecteur, car nous avons la position de la source de lumière, mais aussi la position du point de rebond. Ce point est exactement celui dont nous calculons l'intensité de la lumière. L'opération sera donc :

Calcul du vecteur entre le vertex et la lumière

Vec3 rayon = lumiere.position &#150; vertex;

(Note : la position de la lumière doit être normalisée.)

Pour connaître le cosinus de l'angle entre deux vecteurs, nous allons utiliser le produit scalaire (en anglais : dot product). Le GLSL nous fournit une fonction appelée 'dot' pour appliquer ce calcul. Nous aurons donc dans notre code :

Calcul de l'angle entre le rayon de la lumière et la normale de l'objet

float angleLumiere = dot(normal, rayon);

Finalement, nous pouvons appliquer la formule de la réflexion de Lambert :

Calcul de la lumière diffuse

Vec3 couleurDiffuse = lumiere.diffuse * angleLumiere;

IV-A-1-c - La lumière spéculaire

Schématisation de la lumière spéculaire

La lumière spéculaire est le reflet de la lampe sur l'objet que nous dessinons. Pour calculer celle-ci, nous utilisons le modèle simplifié Blinn-Phong. La formule de ce modèle est la suivante :

- spec = (Normale . (œil  rayon_lumière))shininess * couleur_de_la_lumière_speculaire

Pour connaître la position de l'œil, nous allons utiliser la position du vertex que nous sommes actuellement en train de calculer pour lui appliquer la matrice inverse de vue. Pour rappel, la matrice de vue, permet de transformer des coordonnées de l'espace monde à l'espace de la caméra. La multiplication avec la matrice inverse fait donc l'opération inverse, ainsi, nous pouvons retrouver la position de la caméra. Le code de cette opération est le suivant :

Calcul de la position de la caméra

Vec3 oeil = matrice_inverser_vue * vertex;

Le rayon_lumière, qui représente le vecteur entre la lumière et l'objet se calcule en soustrayant la position de la lumière à celle du vertex :

Calcul du vecteur de lumière

Vec3 ray = lumiere.position &#150; vertex;

Le reste de la formule ne pose pas de problème. Pour rappel, afin d'effectuer les calculs sur les puissances le GLSL fournit la fonction pow (pour 'power' qui signifie puissance en anglais). La variable appelée 'shininess' correspond à la force du reflet.

Calcul de la lumière spéculaire

vec3 couleurSpeculaire = pow(dot(normale,(oeil-ray)),lumiere.shininess) * lumiere.couleurSpeculaire;

Voici les résultats avec un shininess de 8, 64 et 128 :

Shininess à 8

Shininess à 64

Shininess à 128

IV-A-1-d - Application de nos différentes lumières

Le calcul final de la couleur se résume à la somme des différentes lumières. Toutefois, la lumière spéculaire ne doit pas apparaître si l'angle de la lumière (défini lors du calcul de la lumière diffuse) est inférieur à zéro (il serait dommage de voir le reflet de la lampe alors que celle-ci éclaire l'autre face de l'objet).

Calcul de la couleur finale

gl_FrontColor = couleurAmbiante + couleurDiffuse + couleurSpeculaire;

Résultat de l'éclairage

Maintenant que nous savons comment calculer les différentes lumières, nous allons découvrir comment reproduire les différents types de lampes. Les formules que nous avons apprises juste ci-dessus sont appliquées dans les trois cas, seulement des effets d'atténuation, ou de limite sont ajoutés.

IV-A-1-e - Lampe directionnelle

Schématisation d'une lampe directionnelle

La lampe directionnelle peut être comparée au soleil. Ainsi nous pouvons prétendre que la lumière est à une distance infinie, ce qui nous permet de considérer tous les rayons parallèles et ne subissant aucune atténuation. Pour l'appliquer, nous n'avons besoin que de la direction de ses rayons (ou de la position de la lampe). Cela nous simplifie énormément les calculs.

En fait, comme il s'agit d'une lampe sans atténuation, qui éclaire le monde entier, les calculs de la lumière peuvent être effectués tels qu'ils ont été présentés précédemment.

IV-A-1-f - Point de lumière

Schématisation d'un point de lumière

Cette lampe ne se base que sur la distance des objets. Selon cette distance, l'objet sera plus ou moins éclairé. De plus, cette lampe éclaire dans toutes les directions.

Comme vous avez pu le deviner, il nous faut calculer la distance de la lampe à l'objet :

Calcul de la distance des objets

Vec3 vLumObj = lumiere.position &#150; vertex ;
float distance = length(vLumObj);

Puis, encore une fois, nous allons calculer l'atténuation selon cette distance :

Calcul de l'atténuation selon la distance

float attenuation = lumiere.attenuationConstante  +
lumiere.attenuationLineaire * d +
lumiere.attenuationQuadratique * d * d;

Finalement, nous n'avons plus qu'à appliquer cette atténuation sur la lumière :

Calcul de la lumière selon l'atténuation du point de lumière

couleurAmbiante = couleurAmbiante * attenuation ;
couleurDiffuse = couleurDiffuse * attenuation ;
couleurSpeculaire = couleurSpeculaire * attenuation;

IV-A-1-g - Spot

Schématisation d'un spot

Le spot peut être représenté par un cône ayant la pointe à la source de la lampe. Si l'objet éclairé est au centre du cône, il reçoit la lumière « forte », mais si celui-ci est sur les bords, la couleur de la lumière est atténuée.

Déjà vous pouvez vous douter des besoins d'une telle lampe. Il nous faut :

- la direction de la lumière ;

- l'angle d'ouverture du cône ;

- atténuation constante ;

- atténuation linéaire ;

- atténuation quadratique ;

- une limite d'éclairage ;

- un coefficient d'exposition.

Commençons par récupérer la distance entre notre lampe et notre objet. Lors du calcul du rayon de la lumière, nous avons vu comment récupérer le vecteur entre la lumière et l'objet. Pour calculer la distance à partir d'un vecteur, le GLSL nous fournit une fonction toute prête : length() (qui signifie 'distance' en anglais).

Calcul de la distance entre la lumière et l'objet

Vec3 vLumObj = lumiere.position &#150; vertex ;
float distance = length(vLumObj);

Maintenant, nous allons calculer l'atténuation selon la distance de l'objet. Pour cela, nous allons reprendre la formule utilisée par OpenGL dans son pipeline fixe.

Calcul de l'atténuation selon la distance

float attenuation = lumiere.attenuationConstante  +
lumiere.attenuationLineaire * d +
lumiere.attenuationQuadratique * d * d;

Ensuite nous devons savoir si l'objet est dans le cône de lumière de la lampe. Pour cela nous allons calculer l'angle entre le vecteur de la position de la lampe à l'objet et le vecteur entre l'objet et la direction de la lampe. Encore une fois, nous utilisons le produit scalaire :

Calcul de l'angle entre l'objet et la lampe et la direction de la lampe

float angle = dot(-vLumObj, normalize(lumiere.direction));

Nous allons calculer une deuxième atténuation qui, cette fois, dépend de l'angle entre le spot et l'objet.

Atténuation selon l'angle

float spotAttenuation = 0.0f
if ( angle >= lumiere.limite )
{
spotAttenuation = pow(angle,lumiere.exposition);
}

Nous appliquons cette atténuation sur la première que nous avions trouvée :

Application de l'atténuation

attenuation *= spotAttenuation;

Pour finir, nous appliquons l'atténuation dans notre calcul de la lumière :

Calcul de la lumière selon l'atténuation du spot

couleurAmbiante = couleurAmbiante * attenuation ;
couleurDiffuse = couleurDiffuse * attenuation ;
couleurSpeculaire = couleurSpeculaire * attenuation;

IV-A-1-h - Matériaux

Il nous reste une toute petite partie (optionnelle) lors du calcul de l'effet de lumière sur notre objet. Actuellement, dans nos calculs de lumière nous n'avons pas pris en compte la couleur de nos objets. Plus précisément, nous parlons de matériaux, car les objets peuvent réfléchir de différentes manières la lumière.

Pour décrire un matériau, il faut définir :

- la couleur ;

- la couleur diffuse (celle qui est renvoyée à l'œil) ;

- la couleur spéculaire (une altération du reflet de la lumière, par le matériau).

Pour appliquer un matériau, il faut juste suivre la formule suivante :

(Je pars du principe, que les différentes couleurs contiennent les valeurs précédentes de nos calculs de lumière.) La structure pour les matériaux peut être la suivante :

Structure des informations pour les matériaux

struct Materiaux
{
vec4 ambiante;
vec4 diffuse;
vec4 speculaire;
}

Calcul de la lumière en prenant en compte le matériau

couleurAmbiante = couleurAmbiante * mat.ambiante;
couleurDiffuse = couleurDiffuse * mat.diffuse;
couleurSpeculaire = couleurSpeculaire * mat.speculaire;

Notez qu'encore une fois, il faut que la lumière spéculaire ne soit visible que si l'angle est supérieur à 0.

IV-A-1-i - Conclusion sur les lumières

Nous avons vu les différentes lumières et les différents types d'éclairages qui étaient présents dans le pipeline fixe d'OpenGL. Vous pouvez implémenter vos propres effets afin d'avoir le résultat qui vous convient le mieux. Le site Wikipedia regorge d'informations sur ce sujet en perpétuelle évolution.

Les algorithmes décrits dans cette section peuvent être appliqués dans le vertex shader ou dans le pixel shader. Sachant que le pixel shader est exécuté pour tous les pixels dessinés, l'exécution sera plus lente, par contre, ce sera plus beau.

L'ancien pipeline était limité à huit lampes. Maintenant vous pouvez faire plus, mais cela va ralentir l'exécution. Il existe une méthode appelée « rendu différé » (Deferred Shading) permettant de mettre des centaines de lampes dans une scène.

IV-A-2 - Application d'une texture

Du côté OpenGL, le code pour appliquer une texture est le même que celui qui n'utilise pas de shader.

Pour les textures, il y a quatre variables à gérer :

- la matrice de la texture : gl_TextureMatrix ;

- les coordonnées de la texture : gl_MultiTexCoord0 ;

- les coordonnées de la texture (interpolées) : gl_TexCoord[ gl_MaxTextureCoords ] ;

- l'échantillon de la texture (les couleurs), qui est une variable uniforme.

La matrice de texture est un tableau, ayant au maximum gl_MaxTextureCoords. gl_MultiTexCoord0 représente les coordonnées de la première texture, pour le vertex actuel. Le zéro peut être remplacé par un chiffre de 0 à 7.

L'échantillon 'sampler' est un type qui représente la texture dans le fragment shader.

Ce qui nous donne le processus suivant :

Vertex shader : transforme nos coordonnées de texture et passe le résultat au fragment shader.

Vertex shader d'application d'une texture

void main (void)
{
gl_TexCoord[0] = gl_TextureMatrix[0] * gl_MultiTexCoord0;
gl_Position = ftransform();
//gl_FrontColor = vec4(couleur,1.0);
}

Fragment shader : accède à notre texture pour récupérer la couleur. Pour cela, nous utilisons texture2D, qui nous retournera la couleur de la texture à l'endroit indiqué par les coordonnées de texture.

Fragment shader d'application d'une texture

uniform sampler2D tex;
void main(void)
{
gl_FragColor = texture2D(tex,gl_TexCoord[0].st);
}

Cube texturé

IV-A-2-a - Différents types d'application

Dans le pipeline fixe d'OpenGL, nous avions la possibilité de choisir la façon dont la couleur de la texture allait être appliquée sur notre objet. Je vais retranscrire rapidement les différentes formules.

GL_REPLACE :

Remplace la couleur actuelle par la couleur de la texture. C'est exactement le calcul que j'ai fait dans l'exemple précédent.

Application de texture avec GL_REPLACE

Couleur = texture2D(tex0, gl_TexCoord[0].xy) ;

Effet du GL_REPLACE

GL_MODULATE :

Multiplie la couleur venant du vertex shader avec celle de la texture. Cela peut être utile si le calcul de la lumière est effectué dans le vertex shader, et que cette lumière doit avoir un effet sur l'application de la texture.

Application de texture avec GL_MODULATE

couleur *= texture2D(tex0, gl_TexCoord[0].xy) ;

Effet du GL_MODULATE

GL_DECAL :

Utile dans le cas où vous voulez afficher la texture d'un logo sur une surface. La valeur alpha de la texture est utilisée pour faire une interpolation entre la couleur venant du vertex shader (gl_Color), et la couleur de la texture. Finalement, la valeur alpha du vertex shader est utilisée.

Application de texture avec GL_DECAL

Vec4 couleurText = texture2D(tex0, gl_TexCoord[0].xy) ;
vec3 coul = mix(color.rgb, couleurText.rgb, couleurText.a) ;
couleur = vec4(coul, color.a);

Effet du GL_DECAL

GL_BLEND :

Ce mode prend en compte la couleur de la texture de l'environnement, en faisant une interpolation entre la couleur venant du vertex shader et celle-ci. Le facteur pour l'interpolation est la texture. Finalement la couleur alpha est déterminée par la multiplication de l'alpha entrant et celui de la texture.

Application de texture avec GL_BLEND

Vec4 couleurText = texture2D(tex0, gl_TexCoord[0].xy) ;
vec3 coul = mix(color.rgb, gl_TextureEnvColor[0].rgb, couleurText.rgb) ;
couleur = vec4(coul, color.a * couleurText.a);

Effet du GL_BLEND

GL_ADD :

Ajoute simplement les couleurs. La composante alpha est multipliée pour calculer la composante alpha résultante. Finalement nous remettons les valeurs dans l'intervalle [0, 1], car le calcul peut engendrer des résultats trop grands.

Application de texture avec GL_ADD

Vec4 couleurText = texture2D(tex0, gl_TexCoord[0].xy) ;
color.rgb *= couleurText.rgb;
color.a *= couleurText.a ;
couleur = clamp(color,0.0, 1.0);

Effet du GL_ADD

Depuis OpenGL 1.3, il y a de nouveaux modes d'application de texture. Ils ne sont pas décrits ici, car cela sort du cadre de ce tutoriel. De plus, il est facile de deviner leur implémentation selon leur description.

IV-A-2-b - Génération automatique de coordonnées de texture

Dans OpenGL, nous pouvions demander au pipeline de générer automatiquement les coordonnées de texture. Si vous voulez toujours utiliser cette méthode (qui présente quelques défauts), voici comment faire.

Pour rappel, OpenGL définit cinq types de générations :

- GL_OBJECT_LINEAR : utile lorsque la texture est fixée à l'objet (par exemple : un terrain) ;

- GL_EYE_LINEAR : utile pour produire des contours dynamiques aux objets ;

- GL_SPHERE_MAP : peut générer des coordonnées pour l'application d'environnement ;

- GL_REFLECTION_MAP : utilise le vecteur de réflexion comme coordonnées de texture ;

- GL_NORMAL_MAP : utilise la normale comme coordonnées.

Le calcul pour une sphère, tel qui est décrit dans la spécification d'OpenGL est le suivant :

Calcul de coordonnées de texture pour GL_SPHERE_MAP

vec2 sphereMap(in vec3 cameraPosition, in vec3 normal)
{
float m ;
vec3 r, u ;
u = normalize(cameraPosition) ;
r =reflect(u, normal) ;
m = 2.0 * sqrt(r.x * r.x + r.y * r.y + (r.z + 1.0) * (r.z + 1.0)) ;
return vec2(r.x / m + 0.5, r.y / m + 0.5);
}

Le calcul utilisé pour GL_REFLECTION_MAP est :

Calcul de coordonnées de texture pour GL_REFLECTION_MAP

vec3 reflectionMap(in vec3 cameraPosition, in vec3 normal)
{
vec3 u = normalize(cameraPosition) ;
return (reflect(u,normal));
}

Finalement, selon le choix que vous faites pour votre génération, le code généraliste pour avoir les coordonnées de texture est le suivant :

Génération coordonnées texture

vec2 sphereMapCoord ;
vec3 reflectionCoord;

If ( isTexSphere )
{
sphereMapCoord = sphereMap(cameraPosition,normal);
}

if ( isTexReflection )
{
reflectionCoord = reflectionMap(cameraPostion,normal);
}

// Pour toutes les textures activées
for ( int i = 0 ; i < nombreTexture ; i++ )
{
if ( isTexObject )
{
gl_TexCoord[i].s = dot(gl_Vertex, gl_ObjectPlaneS[i]) ;
gl_TexCoord[i].t = dot(gl_Vertex, gl_ObjectPlaneT[i]) ;
gl_TexCoord[i].p = dot(gl_Vertex, gl_ObjectPlaneR[i]) ;
gl_TexCoord[i].q = dot(gl_Vertex, gl_ObjectPlaneQ[i]);
}

if ( isTexEye )
{
gl_TexCoord[i].s = dot(gl_Vertex, gl_EyePlaneS[i]) ;
gl_TexCoord[i].t = dot(gl_Vertex, gl_EyePlaneT[i]) ;
gl_TexCoord[i].p = dot(gl_Vertex, gl_EyePlaneR[i]) ;
gl_TexCoord[i].q = dot(gl_Vertex, gl_EyePlaneQ[i]);
}

if ( isTexSphere )
{
gl_TexCoord[i] = vec4(sphereMapCoord, 0.0, 1.0);
}
if ( isTexReflection )
{
gl_TexCoord[i] = vec4(reflectionCoord, 1.0);
}
if ( isTexNormal )
{
gl_TexCoord[i] = vec4(normal, 1.0);
}
}

IV-A-3 - Brouillard

Le brouillard est un effet qui altère la couleur de l'objet selon la distance entre celui-ci et la caméra.

Dans le pipeline fixe, nous pouvions retrouver trois algorithmes différents pour le calcul du brouillard. Le premier pouvait être sélectionné en indiquant GL_LINEAR à glFogf(GL_FOG_MODE).

La formule utilisée est la suivante :

- f = fin  z / fin  début

Les variables doivent être dans l'espace de coordonnées de la vue. Toutes ces variables sont intégrées dans le GLSL :

début : gl_Fod.debut
fin : gl_Fod.end
z : gl_FogFragCoord

Le GLSL nous facilite quelque peu la tâche en nous proposant le résultat de gl_Fog.end  gl_Fog.debut dans la variable gl_Fog.scale. Effectivement, ce calcul ne dépend pas de la position du vertex que nous sommes en train de calculer, donc le mieux est de ne faire qu'une seule fois le calcul.

La formule dans notre code sera donc :

Calcul d'un brouillard linéaire

Fog = (gl_Fod.end &#150; gl_FogFragCoord) * gl_Fog.scale ;

Brouillard linéaire

Le deuxième brouillard présent dans OpenGL est un brouillard exponentiel donnant un résultat plus convaincant.

La formule utilisée est la suivante :

- f = e-(densité * z)

Z est toujours le même que précédemment (soit gl_FogFragCoord) et la densité est une variable uniforme donnée par le programme. Pour le calcul de l'exponentielle, nous allons utiliser la fonction exp() incluse dans le langage.

Le code résultant en GLSL est le suivant :

Calcul du brouillard exponentiel (première méthode)

Fog = exp(-gl_Fog.density * gl_FogFragCoord) ;

Brouillard exponentiel

Finalement, le troisième algorithme présent dans le pipeline fixe (correspondant à GL_EXP2) est une amélioration (graphique) de l'algorithme précédent.

La nouvelle formule est la suivante :

- fog = e-(densité * z)²

Qui correspond au code suivant :

Calcul du brouillard exponentiel (seconde méthode)

Fog = exp(-gl_Fog.density * gl_FogFragCoord * gl_Fog.density * gl_FogFragCoord);

2e Brouillard exponentiel

Pour appliquer le brouillard, il faut être sûr que la variable fog est entre 0 et 1. Cela peut se faire en utilisant une fonction du GLSL appelée clamp :

Limitation du brouillard

fog = clamp(fog, 0.0,1.0);

Finalement, nous pouvons appliquer le brouillard sur la couleur calculée pour l'objet. Le brouillard a une couleur propre contenue dans gl_Fog.color :

Application du brouillard

couleur = mix(vec3(gl_Fog.color), couleur, fog);

IV-A-4 - Clipping

Le clipping est l'action de découper des morceaux de rendu final afin de ne pas les afficher. Cette fonctionnalité est toujours intégrée de manière fixe dans le pipeline graphique (entre le processeur de vertex et le processeur de fragment).

Si vous utilisez, le clipping, vous allez devoir indiquer la position de vertex afin qu'OpenGL puisse savoirs'il doit l'afficher ou non. Pour cela il suffit d'assigner une valeur à la variable gl_ClipVertex. Habituellement, les utilisateurs stockent le rectangle à découper dans l'espace de coordonnées de l'œil. Cela veut donc dire que nous devons transformer notre vecteur afin de l'avoir dans cet espace :

Calcul du clipping

gl_ClipVertex = gl_ModelViewMatrix * gl_Vertex;

V - Pour aller plus loin

V-A - Outils

V-A-1 - Création de shaders

- ShaderToy : avec un navigateur Internet compatible avec WebGL (Google Chrome 9 ou Firefox 4), vous allez pouvoir créer rapidement quelques petits shaders en vue d'une implémentation future.

- Shader Maker : un EDI multiplateforme pour les shaders.

- GPU ShaderAnalyzer : outil d'AMD vérifiant la compatibilité des shaders avec leur cartes graphiques.

- GLIntercept : permet de tracer tous les appels OpenGL faits par le programme. Malheureusement, l'outil n'est pas à jour et donc ne va pas pouvoir détecter les fonctions implémentées dans les dernières spécifications d'OpenGL.

- Nshader : plugin pour Visual Studio permettant d'ajouter la coloration syntaxique pour les shaders.

V-A-2 - Débogage des shaders

- GlSlDevil : outil gratuit pouvant tracer les appels OpenGL et déboguer les shaders.

- gDEBugger : outil multiplateforme permettant le débogage, mais aussi le profilage des applications OpenGL.

V-B - Liens

Spécification OpenGL 4.1
Spécification GLSL (version 4.10)
Référence rapide du GLSL (version 4.10)
Référence rapide du GLSL (version 3.3)
Man pages pour OpenGL 4
Man pages pour OpenGL 3.3
Man pages pour OpenGL 2.1
Red Book (Livre de référence sur OpenGL)
Orange Book (Livre de référence sur GLSL)
Red Book toute première version (OpenGL 1.1 ; vieux, mais on peut le lire librement pour apprendre tout un tas de choses sur le calcul de scène 3D)

V-C - Remerciements

Je tiens à remercier raptor70 pour m'avoir motivé à écrire des articles ainsi qu'à gbdivers, gusgus, khayyam90 pour leurs nombreux retours et finalement à ram-0000 et jacques_jean pour leurs corrections.