Gestion dynamique de la lumière avec OpenGL - Partie 4 : éclairage par pixel simple

Michel de VERDELHAN

Ce tutoriel présente une version simplifiée de l'éclairage par pixel. Son but est avant tout de présenter les concepts liés à la gestion de l'éclairage par pixel, c'est-à-dire, le calcul et la gestion de l'espace local des vertex. Nous allons aussi modifier notre façon d'utiliser les modèles pour les adapter aux problèmes liés au format de fichiers utilisé.
Dans ce tutoriel et dans celui qui suit, nous verrons comment utiliser l'extension env_dot3 d'OpenGL qui est l'extension à utiliser pour pouvoir effectuer l'éclairage par pixel sans utiliser de fragment program/shaders.

- Titre : Gestion dynamique de la lumière avec OpenGL - Partie 4 : éclairage par pixel simple

- Auteur : Michel de VERDELHAN

- Parution : 6 septembre 2006

- Mise à jour : 26 novembre 2019

- 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 ® 2006 Michel de VERDELHAN. 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. Pourquoi utiliser de l'éclairage par pixel ?

- II. Explications sur le bump mapping

- II-A. Rappel sur les calculs d'éclairage

- II-B. La normal map

- II-C. L'extension env_dot3

- III. Gestion de l'espace local

- III-A. Pourquoi utiliser un espace local ?

- III-B. Modification du chargement des modèles

- III-C. Calcul de l'espace local

- IV. Modifications apportées au rendu des modèles

- V. Rendu final

- VI. Conclusions

- Liens

I - Pourquoi utiliser de l'éclairage par pixel ?

Nous avons vu dans les tutoriels précédents comment calculer l'éclairage, d'abord par vertex, puis en utilisant une light map dynamique, mais la vie est ainsi faite qu'on en souhaite toujours plus… Vous aurez peut-être remarqué que si l'éclairage par vertex présente certains problèmes, il a quand même l'avantage de prendre en compte l'orientation de la face pour calculer sa luminosité (ou plutôt, il prend en compte l'orientation des vertex). Ceci n'est pas pris en compte par le light mapping dynamique qui ne calcule que l'atténuation de la lumière, ce qui pose problème lorsqu'on souhaite avoir un éclairage réaliste. Par contre, l'éclairage par light mapping dynamique ne pose pas de problèmes liés à la tesselation (1) des modèles. L'idéal serait donc de réussir à lier les avantages des deux techniques. C'est ce que nous allons faire avec l'éclairage par pixel.

Bien que le fait de pouvoir prendre en compte l'orientation des faces sans avoir les problèmes liés à la tesselation soit intéressant, ce n'est pas non plus le principal avantage de l'éclairage par pixel. Le gros avantage de cette technique est de pouvoir, comme son nom l'indique en fait, calculer l'éclairage au niveau du pixel et non plus du vertex. On obtient ainsi une meilleure finesse dans l'éclairage. Bien évidemment, cette méthode a un coût en termes de temps de calcul.

Dans ce tutoriel, nous allons d'abord voir les principes de l'éclairage par pixel, ensuite nous étudierons plus en détail un des éléments qui est le plus important de l'éclairage par pixel : l'espace local des vertex, et finalement, nous verrons les modifications apportées au programme pour prendre en compte l'éclairage par pixel.

II - Explications sur le bump mapping

Dans cette partie, nous allons voir les principes de fonctionnement de l'éclairage par pixel. Dans un premier temps, je vais revenir sur les calculs d'éclairage, puis j'expliquerai le fonctionnement des normal maps, et pour finir, je présenterai rapidement l'extension env_dot3 qui va nous permettre d'effectuer le calcul de l'éclairage par pixel.

II-A - Rappel sur les calculs d'éclairage

Pour bien comprendre le fonctionnement de l'éclairage par pixel, je reviens rapidement sur le calcul de l'éclairage déjà vu dans le premier tutoriel sur l'éclairage par vertex.
Comme nous l'avons vu, l'éclairage d'une surface se calcule en fonction de :

- son éloignement par rapport à la lumière, c'est ce qu'on appelle l'atténuation. Nous avons vu comment calculer l'atténuation grâce au light mapping dynamique, je ne reviendrai donc pas dessus dans ce tutoriel ;

- son orientation par rapport à la lumière. Ainsi, si la surface tourne le dos à la lumière, elle n'est pas éclairée, alors que si elle est orientée en face de la lumière, elle reçoit l'ensemble de la lumière. C'est cette gestion de l'orientation de la surface qui nous intéresse plus particulièrement dans ce tutoriel.

Pour gérer l'orientation de la face par rapport à la lumière, nous avons vu dans le premier tutoriel que nous avons besoin de deux informations : le vecteur perpendiculaire à la surface aussi appelé la normale à la surface et le vecteur dirigé vers la lumière. Une fois ces deux vecteurs récupérés, il nous suffit d'effectuer le produit scalaire entre les deux vecteurs pour avoir le coefficient de luminosité perçu par la surface. C'est le fameux NdotL, avec N représentant la normale à la surface, L le vecteur vers la lumière, et dot le produit scalaire.

Les vecteurs utilisés pour calculer l'éclairage

Jusqu'à présent, nous avions utilisé la normale des vertex pour effectuer ce calcul, mais à partir de maintenant, nous allons utiliser une normal map dont je vais présenter le principe.

II-B - La normal map

Pour notre calcul, il nous faut récupérer la normale à la surface. Lorsqu'on utilise une normale par vertex, le résultat des calculs est interpolé entre les sommets du polygone, ce qui donne de mauvais résultats lorsque le polygone est trop étendu. En fait, on se retrouve dans des situations où le résultat correspond à un cas où la normale est interpolée comme sur le schéma suivant.

Résultat de l'interpolation linéaire de deux normales : la normale interpolée n'est plus normalisée

On voit clairement que le résultat de l'interpolation n'est pas valide pour le calcul de la luminosité : la normale interpolée n'est plus normalisée, on va donc avoir des problèmes d'échelle dans le résultat final (on parle de scaling problems). En effet, si la normale est de longueur 0.8, la luminosité finale sera réduite de 20 % par rapport à ce qu'elle aurait dû être.

C'est pour résoudre ce problème qu'on utilise une texture qui va stocker les normales de la surface, ainsi, on réduit les problèmes liés à l'interpolation des normales (ils ne sont pas complètement supprimés étant donné qu'une texture de trop faible résolution sera elle-même interpolée quand on s'en approche).

Une normal map est en fait une texture standard dont les composantes RBG ne sont plus interprétées comme des couleurs, mais comme des vecteurs 3D représentant la normale d'une surface. On peut donc facilement donner l'impression qu'un polygone n'est plus plat en modifiant l'orientation des normales dans la normal map. Les calculs d'éclairage vont s'effectuer sur ces normales perturbées, donnant ainsi une impression de volume virtuel.

L'utilisation d'une normal map perturbée permet de créer un volume virtuel utilisé pour créer une impression de volume sur la surface

Contrairement à ce qu'on pourrait croire, une normal map peut parfaitement se regarder à l'œil et est parfaitement compréhensible une fois qu'on sait ce qu'elle représente. Voici par exemple l'exemple de la local map utilisée dans ce tutoriel :

La local map utilisée dans le tutoriel : on voit bien les limites des volumes générés par la perturbation des normales

J'en vois dans le fond qui se demandent bien pourquoi j'utilise le terme de local map au lieu de parler de normal map comme je l'ai fait jusqu'à présent. Tout cela s'explique par le fait qu'il existe deux catégories de normal maps : les global maps et les local maps.

Les global maps sont des normal maps qui stockent des vecteurs orientés dans toutes les directions. Le gros avantage des global maps est qu'elles peuvent être utilisées directement sans avoir à passer par le calcul de l'espace local des vertex. Ceci permet donc de calculer l'éclairage plus vite étant donné que nous n'avons pas tous les calculs de projections, que nous verrons par la suite, à effectuer. Par contre, le gros problème des global maps est qu'elles ne sont pas réutilisables. C'est-à-dire qu'une global map ne peut être utilisée que pour un objet donné. Imaginez une scène ou les graphistes sont obligés de faire une global map propre à chaque polygone… Ce serait totalement impossible à gérer. En fait les global maps sont très peu utilisées, on en trouve parfois lorsqu'il faut afficher des terrains. Étant donné que le terrain a peu de chances d'être modifié, on calcule une global map au lancement, et ainsi, on gagne en temps d'exécution lors de l'affichage.

Les local maps, au contraire des global maps, ne contiennent que des normales orientées sur l'axe des Z positifs. Ainsi, une local map ne représente pas la surface d'un objet, mais une surface générique pouvant être utilisée sur plusieurs objets différents. Le fait qu'elles ne soient orientées que dans une seule direction oblige à des calculs relativement complexes à comprendre au départ, mais, en contrepartie, on peut réutiliser les local maps à volonté. Le nom de local map vient du fait que pour pouvoir utiliser une local map, il faut effectuer les calculs d'espaces locaux des vertex, que nous verrons par la suite. On peut facilement différencier une local map d'une global map en regardant la couleur générale de la texture. Une global map sera potentiellement de toutes les couleurs alors qu'une local map est généralement bleue étant donné que les vecteurs pointent vers les Z positifs (donc la composante bleu du vecteur est souvent proche de 1).

Étant donné que nous ne nous préoccupons que des local maps, j'utiliserai indifféremment le terme normal map et local map pour designer la local map par la suite.

Maintenant que nous avons vu le principe de la normal map, il nous reste une étape à voir : comment faire pour pouvoir calculer le produit scalaire pour chaque pixel ? C'est ce que nous allons voir dans la partie qui vient.

II-C - L'extension env_dot3

OpenGL ne propose pas de fonction standard pour modifier le calcul au niveau des pixels : pour ça, il faut encore une fois passer par des extensions. Ici, nous allons utiliser l'extension env_dot3 qui va nous permettre de calculer le produit scalaire entre la normale de la surface et le vecteur vers la lumière.

Contrairement aux extensions utilisées dans les tutoriels précédents, l'extension env_dot3 n'utilise pas de fonction particulière à charger, elle apporte juste des paramètres en plus à passer aux fonctions standard OpenGL. Comme ce n'est pas une utilisation commune d'une extension, je vais présenter rapidement son fonctionnement afin d'avoir une meilleure compréhension des calculs effectués par la carte graphique.

Voici donc comment faire pour configurer une unité de texture pour qu'elle réalise le calcul de DOT3 bump mapping :

Code pour configurer une unité de texture pour qu'elle effectue le produit scalaire

glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, normalMapId);
glTexEnvf(GL_TEXTURE_ENV,GL_TEXTURE_ENV_MODE,GL_COMBINE_ARB);
glTexEnvf(GL_TEXTURE_ENV,GL_RGB_SCALE_ARB,1);
glTexEnvf(GL_TEXTURE_ENV,GL_COMBINE_RGB_ARB,GL_DOT3_RGB_ARB);
glTexEnvf(GL_TEXTURE_ENV,GL_SOURCE0_RGB_ARB,GL_PREVIOUS_ARB);
glTexEnvf(GL_TEXTURE_ENV,GL_SOURCE1_RGB_ARB,GL_TEXTURE);
glTexEnvf(GL_TEXTURE_ENV,GL_OPERAND0_RGB_ARB,GL_SRC_COLOR);
glTexEnvf(GL_TEXTURE_ENV,GL_OPERAND1_RGB_ARB,GL_SRC_COLOR);

Voilà, c'est tout ce qu'il y a à faire. En fait, on signale à OpenGL qu'on souhaite que la carte graphique effectue le produit scalaire entre l'unité de texture courante et la couleur précédente dans le pipeline. Il nous suffit donc de passer le vecteur vers la lumière dans la couleur précédente (par exemple via un appel à glColor comme nous allons le faire dans ce tutoriel ou via une cube map comme nous le ferons dans le prochain tutoriel), et la carte graphique va se charger pour nous du calcul.

III - Gestion de l'espace local

Nous avons vu plus tôt que nous utilisons une local map, il nous faut donc prendre en charge la gestion de l'espace local des vertex. Dans cette partie, je vais d'abord présenter en quoi consiste l'espace local des vertex, puis j'expliquerai les problèmes que peut engendrer cet espace local au niveau du chargement des modèles, et finalement, je donnerai le code utilisé pour calculer l'espace local pour chaque vertex.

III-A - Pourquoi utiliser un espace local ?

La gestion de l'espace local est très certainement la partie la plus complexe à comprendre (et à expliquer ;-) ) du bump mapping. En effet, elle fait appel à des notions d'algèbre linéaire qui ne sont pas triviales à comprendre dans un premier temps. Je vais essayer de l'expliquer, mais si jamais vous ne comprenez pas, n'hésitez pas à allez chercher d'autres sources de documentation sur Internet. En effet, il faut parfois plusieurs explications différentes pour bien appréhender ce concept.

Le problème avec les normal maps, c'est que si on tourne l'objet sur lequel la texture est plaquée, les normales, elles, ne sont pas tournées, on se retrouve donc avec un objet qui n'est plus sur le plan XY (plan sur lequel sont générées les normal maps qui pointent donc vers les Z positifs), mais avec des normales qui sont restées sur le plan XY. Pour mieux montrer les problèmes, quelques schémas sont sûrement plus efficaces que des lignes de textes :

Cas idéal : la surface est sur le bon plan pour les calculs

Ici, on a le cas idéal : la surface est bien orientée sur le plan XY, les normales (en rouge) de la normal map pointent donc dans la bonne direction, le calcul va bien s'effectuer.

Cas souhaité : la normale est orientée en même temps que la surface

Ici, le schéma montre ce que l'on souhaiterait avoir : la surface n'est pas alignée sur le plan XY, mais les normales de la normal map ont bien été tournées, le calcul va donc bien s'effectuer. Mais malheureusement, comme je l'expliquais au-dessus, ce n'est pas ce qu'il se passe réellement. On se trouve en fait avec le cas suivant :

Mauvais cas : les calculs ne prennent pas en compte le changement d'orientation de la surface

Ici, la surface a bien été tournée, mais les normales de la normal map ne subissent pas de transformation (ce qui se passe effectivement au niveau de la carte graphique qui n'a pas de notions d'orientation des surfaces), on se retrouve donc avec un calcul faux (ici, le calcul va donner un produit scalaire supérieur à 0, donc éclairé, alors que la surface ne devrait pas l'être, car elle tourne le dos à la lumière. Étant donné qu'on ne peut pas modifier les normales de la normal map, le seul moyen d'obtenir un calcul juste est de modifier le seul vecteur modifiable : le vecteur vers la lumière, ce qui nous donne le résultat suivant :

Cas effectivement pratiqué pour le bump mapping dot3 : on ne peut pas modifier la normale, alors on transforme le vecteur vers la lumière

Ici, on a modifié le vecteur vers la lumière pour obtenir un calcul valide. En fait, sur ce schéma, l'angle entre le vecteur vers la lumière et la normale de la surface est le même que sur le deuxième schéma (aux erreurs de dessin près ;-) ). On a donc un calcul valide qui donne le même résultat que si nous avions tourné la normale de la surface comme dans le cas du deuxième schéma.

Il nous faut donc calculer ce vecteur vers la lumière (qui n'est du coup plus vraiment orienté vers la lumière ;-) ) pour prendre en compte les surfaces transformées. Si toutes nos surfaces étaient générées sur le plan XY puis tournées pour atteindre leur place finale, le calcul ne poserait pas de problème, il suffirait de faire subir la rotation inverse à notre vecteur vers la lumière pour obtenir ce que l'on souhaite. Mais malheureusement, ce n'est pas le cas. Nous n'avons généralement que trois vertex pour définir un triangle et pas de rotation ou autre à notre disposition. Il nous faut donc calculer quelque chose qui permet d'obtenir le résultat souhaité : l'espace local.

L'espace local (aussi appelé espace tangent) est un espace tridimensionnel formé de trois vecteurs et d'une position dans l'espace. Les trois vecteurs permettent d'orienter l'espace local dans l'espace global, et la position correspond à l'origine de l'espace local dans l'espace global. Oui, dit comme ça, c'est incompréhensible ;-). En fait il faut voir cet espace de la même manière que les axes standards X, Y et Z qui forment eux aussi un espace local positionné en {0,0,0}.

Bon j'ai dit que notre espace local était formé de trois vecteurs, il est temps d'en parler plus en détail. Les trois vecteurs qui forment notre espace local sont appelés Tangente, Binormale et Normale (on voit souvent le diminutif TBN utilisé pour parler de l'espace local). On connaît déjà la normale qui n'est autre que la normale du vertex. Les deux autres vecteurs sont perpendiculaires à la normale, mais surtout, ils sont proportionnels aux coordonnées de texture du polygone. En effet, il faut que notre espace prenne en compte le fait que les coordonnées de textures puissent être tournées/étirées/inversées.

Encore une fois, un petit schéma permet d'avoir les idées plus claires.

Un polygone avec l'espace local de chacun de ses vertex : quand le polygone change d'orientation, les espaces locaux suivent

Nous avons vu ce qu'est un espace local, mais il faut encore savoir à quoi il peut bien nous servir. En fait c'est lui qui va nous permettre de calculer le vecteur vers la lumière modifiée pour qu'il soit valide. Pour cela, il suffit de projeter le vecteur vers la lumière dans l'espace local. C'est ici qu'intervient l'algèbre linéaire : pour projeter un vecteur dans un espace 3D formé de trois vecteurs, il suffit, pour chaque composante du vecteur cible, de calculer le produit scalaire entre le vecteur à transformer et le bon vecteur de l'espace local (en fait il faut les prendre dans l'ordre Tangente pour la composante X, Binormale pour la composante Y et Normale pour la composante Z). Le calcul de la projection d'un vecteur correspond donc à ça :
VecteurDestination.x = vecteurSource . Tangente
VecteurDestination.y = vecteurSource . Binormale
VecteurDestination.z = vecteurSource . Normale

Maintenant que nous savons ce qu'est un espace local et à quoi il sert, nous allons pouvoir voir comment le calculer. Mais avant, je vais parler des problèmes que peut entraîner tout ceci au niveau du chargement des modèles.

III-B - Modification du chargement des modèles

Le fait de prendre les tangentes et binormales en compte nous oblige à modifier notre classe de modèle. En effet, il nous faut ajouter un tableau de vecteurs pour les tangentes et un autre tableau pour les binormales. Tout ceci se fait facilement en ajoutant au fichier Model.h ceci :

Ajout des tableaux de binormales et tangentes

/**
* le tableau de binormales (il y a autant de binormales que de normales)
*/
Vecteur * binormals;
/**
* le tableau de tangentes (il y a autant de tangentes que de normales);
*/
Vecteur * tangents;

Que l'on n’oubliera pas de supprimer dans le destructeur bien entendu.

Mais le principal problème vient du remplissage de ces tableaux. Idéalement, pour connaître la taille des tableaux, il suffit de prendre la taille du tableau de normales. En effet, si le format de modèles gère bien les normales partagées, nous aurons donc un espace local par normale partagée. Malheureusement, dans notre cas, les normales ne sont pas bien partagées. Lors de l'export en .obj, si deux normales ont les mêmes valeurs, alors l'exporteur n'en conserve qu'une seule et change les indices des faces en conséquence. Ceci pose problème, car on peut très bien avoir des normales orientées dans le même sens, mais liées à des faces ayant des coordonnées de textures différentes. Ceci entraîne de graves incohérences dans les calculs par la suite.

Dorénavant, nous n'utiliserons plus les normales contenues dans le fichier, mais nous les calculerons à la main au chargement. Le problème que pose cette méthode est que les normales seront toutes adoucies, y compris celles qui sont sur des faces avec une orientation très différente (comme sur un cube par exemple). Ceci aura des conséquences sur la qualité de l'éclairage, certaines faces pouvant tourner le dos à la lumière tout en étant toujours éclairées. J'ai choisi de ne pas régler ce problème, car il entraîne une trop forte complication du code pour un débutant, et surtout, il peut être réglé en choisissant un format de modèle qui nous assure d'une bonne gestion des normales partagées.

Maintenant nous pouvons voir le nouveau code de chargement des modèles :

Méthode de chargement des modèles modifiés

bool Model::load(const std::string & filePath)
{
// on crée un lecteur de .obj
OBJReader reader(filePath);
// on charge le model
if (reader.load())
{
// le model est bien chargé
// on alloue le tableau de vertex
vertex = new Vecteur[reader.getVertex().size()];
nbVertex = 0;
// on copie les informations des vertex
for (std::vector<OBJReader::OBJVector>::iterator it =
reader.getVertex().begin();    it != reader.getVertex().end(); ++it)
{
vertex[nbVertex].x = (*it).x;
vertex[nbVertex].y = (*it).y;
vertex[nbVertex].z = (*it).z;
nbVertex++;
}
// on alloue le tableau de normales
// les tableaux de binormales et tangentes font la
// même taille que le tableau de normales
normals = new Vecteur[nbVertex];
binormals = new Vecteur[nbVertex];
tangents = new Vecteur[nbVertex];
// on les initialise au vecteur null
for (int i = 0; i < nbVertex; i++)
{
normals[i].x = normals[i].y = normals[i].z =
binormals[i].x = binormals[i].y = binormals[i].z =
tangents[i].x = tangents[i].y = tangents[i].z = 0.0f;
}
// on alloue le tableau de coordonnées de textures
texCoords = new TexCoord[reader.getTexture().size()];
nbTexCoord = 0;
// on copie les informations de coordonnées de texture
for (std::vector<OBJReader::OBJtexCoord>::iterator it =
reader.getTexture().begin();it != reader.getTexture().end(); ++it)
{
texCoords[nbTexCoord].u = (*it).u;
texCoords[nbTexCoord].v = (*it).v;
nbTexCoord++;
}
// on alloue le tableau des faces
faces = new Face[reader.getFace().size()];
nbFaces = 0;
// on copie les informations des faces
for (std::vector<OBJReader::OBJface>::iterator it = reader.getFace().begin();
it != reader.getFace().end(); ++it)
{
faces[nbFaces].vertexIndex[0] = (*it).vertex[0];
faces[nbFaces].vertexIndex[1] = (*it).vertex[1];
faces[nbFaces].vertexIndex[2] = (*it).vertex[2];
faces[nbFaces].normalIndex[0] = (*it).normal[0];
faces[nbFaces].normalIndex[1] = (*it).normal[1];
faces[nbFaces].normalIndex[2] = (*it).normal[2];
faces[nbFaces].texCoordIndex[0] = (*it).texture[0];
faces[nbFaces].texCoordIndex[1] = (*it).texture[1];
faces[nbFaces].texCoordIndex[2] = (*it).texture[2];
nbFaces++;
}

// maintenant que nous avons toutes les informations sur les faces,
// on peut calculer l'espace local
for (int i = 0; i < nbFaces; i++)
{
computeLocalSpace(faces[i]);
}
// il ne faut pas oublier de renormaliser les tangentes et binormales
for (int i = 0; i < nbVertex; i++)
{
binormals[i].normalise();
tangents[i].normalise();
normals[i].normalise();
}
return true;
}
else
{
return false;
}
}

La méthode computeLocalSpace() correspond à la méthode de calcul de l'espace local que nous allons voir maintenant.

III-C - Calcul de l'espace local

Le calcul de l'espace local pour une face est finalement assez simple une fois qu'on a compris ce qu'est réellement l'espace local. Il suffit de calculer deux vecteurs qui suivent les bords du polygone et sont proportionnels aux coordonnées de textures. Une fois qu'on a effectué ce calcul pour la face, il ne nous reste plus qu'à additionner le résultat aux tangentes, binormales et normales des vertex du polygone. Je tiens à signaler qu'il existe plusieurs algorithmes pour calculer cet espace local, il est donc possible que vous en trouviez des différents sur le NET.

Le calcul de l'espace local pour une face donne le code suivant :

Méthode de calcul de l'espace local pour une face

void Model::computeLocalSpace(Face &face)
{
// on calcule 2 vecteurs formant les bords du triangle
// le vecteur side0 est celui allant du vertex 0 au vertex 1
Vecteur side0 = vertex[face.vertexIndex[1]] - vertex[face.vertexIndex[0]];
// le vecteur side1 est celui allant du vertex 0 au vertex 2
Vecteur side1 = vertex[face.vertexIndex[2]] - vertex[face.vertexIndex[0]];

// ici on calcule la normale à la face
Vecteur normal = side0.cross(side1);
normal.normalise();

// maintenant on calcule les coefficients des tangentes
// ces coefficients sont calculés en fonction des
// coordonnées de textures du polygone
float deltaT0 = texCoords[face.texCoordIndex[1]].v -
texCoords[face.texCoordIndex[0]].v;
float deltaT1 = texCoords[face.texCoordIndex[2]].v -
texCoords[face.texCoordIndex[0]].v;
// on effectue la même chose pour la binormale
float deltaB0 = texCoords[face.texCoordIndex[1]].u -
texCoords[face.texCoordIndex[0]].u;
float deltaB1 = texCoords[face.texCoordIndex[2]].u -
texCoords[face.texCoordIndex[0]].u;
// le facteur permettant de rendre les tangentes et binormales
// proportionnelles aux coordonnées de textures.
// nous aurons donc des vecteurs dont la norme dépend des coordonnées
// de textures.
float scale = 1/ ((deltaB0 * deltaT1) - (deltaB1 * deltaT0));

// on calcule la tangente temporaire
Vecteur tmpTangente = ((side0*deltaT1) - (side1*deltaT0))*scale;
tmpTangente.normalise();

// on calcule la binormale temporaire
Vecteur tmpBinormal = ((side0*(-deltaB1)) + (side1*deltaB0))*scale;
tmpBinormal.normalise();

// nous avons donc maintenant les tangentes, binormales et normales de la face.
// on les additionne à celles déjà présentes dans les tableaux
tangents[face.vertexIndex[0]] += tmpTangente;
tangents[face.vertexIndex[1]] += tmpTangente;
tangents[face.vertexIndex[2]] += tmpTangente;

binormals[face.vertexIndex[0]] += tmpBinormal;
binormals[face.vertexIndex[1]] += tmpBinormal;
binormals[face.vertexIndex[2]] += tmpBinormal;

normals[face.vertexIndex[0]] += normal;
normals[face.vertexIndex[1]] += normal;
normals[face.vertexIndex[2]] += normal;

// il ne faudra pas oublier de renormaliser les vecteurs par la suite.
}

Attention à bien renormaliser les vecteurs après avoir calculé l'espace local de toutes les faces.

IV - Modifications apportées au rendu des modèles

Maintenant que nous avons toutes les informations nécessaires pour pouvoir effectuer notre éclairage par pixels, nous allons voir comment le faire en pratique. Pour cela, nous allons voir les modifications apportées au programme pour rendre les modèles. Les modifications apportées ici ne sont pas importantes au niveau taille du code, mais elles sont néanmoins primordiales pour le bon fonctionnement de l'éclairage.

Je vais commencer par une petite méthode utilitaire qui va nous servir à déboguer nos calculs d'espaces locaux : la méthode drawLocalSpace qui affiche les tangentes, binormales et normales de chaque vertex. Cette méthode est très simple à comprendre, je ne rentrerai donc pas dans les détails. Tout ce qu'elle fait est d'afficher trois lignes de couleurs différentes qui suivent les trois vecteurs de notre espace local.
Le code de la méthode donne donc :

Méthode servant à dessiner l'espace local de chaque vertex

void Model::drawLocalSpace()
{
glBegin(GL_LINES);
for(int i = 0;i < nbFaces; i++)
{
for (int j = 0; j < 3; j++)
{
int vertexIndex = faces[i].vertexIndex[j];
Vecteur &v = vertex[vertexIndex];
// tangente en rouge
Vecteur tangent = tangents[vertexIndex] + v;
glColor3f(1,0,0);
glVertex3f(v.x,v.y,v.z);
glVertex3f(tangent.x,tangent.y,tangent.z);
// binormale en vert
Vecteur binormal = binormals[vertexIndex] + v;
glColor3f(0,1,0);
glVertex3f(v.x,v.y,v.z);
glVertex3f(binormal.x,binormal.y,binormal.z);
// normale en bleu
Vecteur normal = normals[vertexIndex] + v;
glColor3f(0,0,1);
glVertex3f(v.x,v.y,v.z);
glVertex3f(normal.x,normal.y,normal.z);
}
}
glEnd();
}

Maintenant, nous allons passer à la modification la plus importante de l'affichage : la méthode addLight, que j'ai renommée en addLightPerVertex pour pouvoir la différencier de la méthode que nous verrons dans le prochain tutoriel.

Cette méthode doit effectuer deux choses :

1. Calculer le vecteur transformé vers la lumière ;

2. Afficher le modèle en prenant en compte le vecteur vers la lumière.

Pour calculer notre vecteur transformé vers la lumière, nous allons utiliser le calcul de la projection d'un vecteur dans un espace 3D vu plus haut. Pour cela, nous calculons d'abord le vecteur vers la lumière standard comme ceci :

Calcul du vecteur du vertex vers la lumière

Vecteur tempVect = lightPos - vertex[vertexIndex];

Puis nous calculons sa projection dans l'espace local du vertex comme cela :

Projection du vecteur vers la lumière dans l'espace local du vertex

Vecteur vertexToLight;
vertexToLight.x = tangents[vertexIndex]*tempVect;
vertexToLight.y = binormals[vertexIndex]*tempVect;
vertexToLight.z = normals[vertexIndex]*tempVect;

Comme nous l'avons vu dans la partie sur l'extension env_dot3, il nous faut envoyer le vecteur vers la lumière dans la couleur précédant l'unité de texture configurée pour calculer le bump mapping. Pour cela, nous nous contenterons d'utiliser un simple glColor ici. Ensuite, il ne nous reste plus qu'à afficher le modèle. Le code complet de la méthode donne ça :

Méthode d'ajout d'une lumière à la scène

void Model::addLightPerVertex(Light& light)
{
Vecteur lightPos = light.getPosition();
glBegin(GL_TRIANGLES);
for(int i = 0;i < nbFaces; i++)
{
for (int j = 0; j < 3; j++)
{
int vertexIndex = faces[i].vertexIndex[j];
// on calcule le vecteur vertex->lumière normalisée
Vecteur tempVect = lightPos - vertex[vertexIndex];
Vecteur vertexToLight;
vertexToLight.x = tangents[vertexIndex]*tempVect;
vertexToLight.y = binormals[vertexIndex]*tempVect;
vertexToLight.z = normals[vertexIndex]*tempVect;
vertexToLight.normalise();
// nous avons donc maintenant le vecteur du vertex vers la lumière
// transformé dans l'espace local du vertex.
// on l'envoie sous forme de couleur a openGL
glColor3f(vertexToLight.x,vertexToLight.y,vertexToLight.z);
// maintenant, on envoie les coordonnées de textures
// la première unité de texture contient la normal map, ce sont donc
// les mêmes coordonnées de textures que la scène
TexCoord &tc = texCoords[faces[i].texCoordIndex[j]];
glMultiTexCoord2fARB(GL_TEXTURE0_ARB,tc.u,tc.v);
// la deuxième unité de texture contient la texture 3D d'atténuation,
// on calcule donc l'éclairage
Vecteur &v = vertex[vertexIndex];
Vecteur lightTexCoord = light.computeLighting(v);
glMultiTexCoord3fARB(GL_TEXTURE1_ARB,lightTexCoord.x,
lightTexCoord.y,lightTexCoord.z);
// la troisième unité de texture contient la texture diffuse,
// donc les coordonnées de texture de la scène
glMultiTexCoord2fARB(GL_TEXTURE2_ARB,tc.u,tc.v);
glVertex3f(v.x,v.y,v.z);
}
}
glEnd();
}

Comme d'habitude, ici, nous supposons qu'OpenGL est configuré de la manière suivante : l'unité de texture 0 contient la normal map configurée pour effectuer le calcul de DOT3 bump mapping, l'unité de texture 1 contient la light map 3D d'atténuation et l'unité de texture 2 contient la texture standard de la scène (aussi appelée texture diffuse).

V - Rendu final

Maintenant que nous pouvons afficher un modèle en prenant en compte l'éclairage par pixel, nous allons voir la séquence d'appels à effectuer pour rendre la scène complète. Encore une fois, le code est relativement simple et ne devrait pas poser de problèmes. On doit, dans un premier temps, rendre la composante ambiante de la scène comme ceci :

Rendu de la composante ambiante de la scène

// on configure la première unité de texture : elle contient la texture de la scène
glActiveTextureARB(GL_TEXTURE0_ARB);
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, textureId);
glTexEnvf(GL_TEXTURE_ENV,GL_TEXTURE_ENV_MODE,GL_MODULATE);

// on effectue le rendu de la lumière ambiante
model->initLighting(ambiant);

Ensuite, il faut configurer les unités de textures pour qu'elles correspondent à ce dont on a besoin dans notre méthode addLight. Ça se fait comme ceci :

Code de configuration des unités de textures

// on doit reconfigurer les unités de textures
// la première unité contient la normal map
glActiveTextureARB(GL_TEXTURE0_ARB);
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, normalMapId);
// on configure la normal map pour qu'elle effectue la passe de DOT3
glTexEnvf(GL_TEXTURE_ENV,GL_TEXTURE_ENV_MODE,GL_COMBINE_ARB);
glTexEnvf(GL_TEXTURE_ENV,GL_RGB_SCALE_ARB,1);
glTexEnvf(GL_TEXTURE_ENV,GL_COMBINE_RGB_ARB,GL_DOT3_RGB_ARB);
glTexEnvf(GL_TEXTURE_ENV,GL_SOURCE0_RGB_ARB,GL_PREVIOUS_ARB);
glTexEnvf(GL_TEXTURE_ENV,GL_SOURCE1_RGB_ARB,GL_TEXTURE);
glTexEnvf(GL_TEXTURE_ENV,GL_OPERAND0_RGB_ARB,GL_SRC_COLOR);
glTexEnvf(GL_TEXTURE_ENV,GL_OPERAND1_RGB_ARB,GL_SRC_COLOR);

// la deuxième contient la texture d'atténuation
glActiveTextureARB(GL_TEXTURE1_ARB);
glEnable(GL_TEXTURE_3D_EXT);
glBindTexture(GL_TEXTURE_3D_EXT,lightmap );
glTexEnvf(GL_TEXTURE_ENV,GL_TEXTURE_ENV_MODE,GL_MODULATE);
// la troisième contient la texture de diffuse
glActiveTextureARB(GL_TEXTURE2_ARB);
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D,textureId );
glTexEnvf(GL_TEXTURE_ENV,GL_TEXTURE_ENV_MODE,GL_MODULATE);

Et maintenant, il ne nous reste plus qu'à ajouter les lumières en activant le blending additif, puis à désactiver les unités de textures.

// on doit activer le blending additif pour pouvoir utiliser ce mode d'éclairage
glEnable(GL_BLEND);
glBlendFunc(GL_ONE,GL_ONE);
glDepthMask(GL_FALSE);

// on ajoute nos lumières
model->addLightPerVertex(light1);
model->addLightPerVertex(light2);

// on désactive la troisième unité de texture
glActiveTextureARB(GL_TEXTURE2_ARB);
glDisable(GL_TEXTURE_2D);
// on désactive la seconde unité de texture
glActiveTextureARB(GL_TEXTURE1_ARB);
glDisable(GL_TEXTURE_3D_EXT);
// on désactive la première unité de texture
glActiveTextureARB(GL_TEXTURE0_ARB);
glDisable(GL_TEXTURE_2D);

glDepthMask(GL_TRUE);
glDisable(GL_BLEND);

Ce qui nous permet d'avoir le rendu final suivant :

Le rendu final de la scène

VI - Conclusions

Voilà, nous avons un système de gestion de l'éclairage par pixel simplifié qui fonctionne. Pourquoi simplifié ? Tout simplement, car il manque quelques éléments pour pouvoir effectivement prétendre gérer correctement les lumières.

Premièrement, nous calculons le vecteur vers la lumière par vertex. Nous retombons donc sur les mêmes problèmes qu'avec l'éclairage par vertex, à savoir : l'interpolation des vecteurs va nous donner des vecteurs non normalisés au centre des polygones, et si les polygones sont trop grands, on peut tomber sur des cas où l'éclairage semble venir d'une direction alors que la lumière n'est pas située dans cette direction. C'est dommage d'avoir un éclairage par pixel et de retomber sur les mêmes problèmes que l'éclairage par vertex. Nous verrons donc dans le prochain tutoriel comment résoudre ce problème en utilisant une cube map de normalisation pour définir le vecteur vers la lumière au lieu d'utiliser la couleur du vertex.

Le second problème posé par le système actuel est qu'on ne peut plus avoir de lumières colorées. Elles sont forcément blanches. Nous verrons donc dans le prochain tutoriel une méthode basée sur un rendu multipasse pour obtenir des lumières colorées.

Liens

Vous pouvez télécharger les sources de ce tutoriel ici ou ici [http]
Une version PDF de ce tutoriel est disponible ici ou ici [http]

Tutoriel 3 : light mapping avec une texture 3D.

Tutoriel 5 : éclairage par pixel standard.

(1)La tesselation représente le niveau de découpage en polygones. Un modèle fortement tesselé est un modèle avec beaucoup de polygones alors qu'un modèle avec une faible tesselation est un modèle composé de peu de polygones.