Gestion dynamique de la lumière avec OpenGL - Partie 4 : éclairage par pixel simpleDate de publication : 06/09/2006
Par
Michel de VERDELHAN (mdeverdelhan.developpez.com)
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.
I. Pourquoi utiliser de l'éclairage par pixel ?
II. Explications sur le bump mapping
II-1. Rappel sur les calculs d'éclairage
II-2. La normal map
II-3. L'extension env_dot3
III. Gestion de l'espace local
III-1. Pourquoi utiliser un espace local ?
III-2. Modification du chargement des modèles
III-3. Calcul de l'espace local
IV. Modifications apportées au rendu des modèles
V. Rendu final
VI. Conclusions
I. Pourquoi utiliser de l'éclairage par pixel ?
Nous avons vu dans les tutoriaux 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 serai 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 terme de temps de
calculs.
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-1. 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 calcul en fonction de :
-
Son éloignement par rapport à la lumière, c'est ce qu'on
appel 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-2. 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 faite 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'oeil et est parfaitement
compréhensible une fois qu'on sait ce quelle représente.
Voici par exemple l'exemple de la local map utilisé 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. Etant donné que le terrain a peu de chance
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 soit 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 et
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).
Etant 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-3. L'extension env_dot3
OpenGL ne propose pas de fonctions 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 tutoriaux
précédents, l'extension env_dot3 n'utilise pas de fonctions
particulières à 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); |
Voila, 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-1. 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 souhaiterais 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 transformations (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 devrais pas l'être car elle tourne le dos à la
lumière. Etant 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ée 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 surface transformées. Si toutes
nos surfaces étaient générées sur le plan XY puis tournées
pour atteindre leur places finales, 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 dis que notre espace local était formé de trois
vecteurs, il est temps d'en parler plus en détails. 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-2. 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 |
Vecteur * binormals;
Vecteur * tangents; |
Que l'on 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équences. 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
calcules 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
choisis 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)
{
OBJReader reader(filePath);
if (reader.load())
{
vertex = new Vecteur[reader.getVertex().size()];
nbVertex = 0;
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++;
}
normals = new Vecteur[nbVertex];
binormals = new Vecteur[nbVertex];
tangents = new Vecteur[nbVertex];
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;
}
texCoords = new TexCoord[reader.getTexture().size()];
nbTexCoord = 0;
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++;
}
faces = new Face[reader.getFace().size()];
nbFaces = 0;
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++;
}
for (int i = 0; i < nbFaces; i++)
{
computeLocalSpace(faces[i]);
}
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-3. 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)
{
Vecteur side0 = vertex[face.vertexIndex[1]] - vertex[face.vertexIndex[0]];
Vecteur side1 = vertex[face.vertexIndex[2]] - vertex[face.vertexIndex[0]];
Vecteur normal = side0.cross(side1);
normal.normalise();
float deltaT0 = texCoords[face.texCoordIndex[1]].v -
texCoords[face.texCoordIndex[0]].v;
float deltaT1 = texCoords[face.texCoordIndex[2]].v -
texCoords[face.texCoordIndex[0]].v;
float deltaB0 = texCoords[face.texCoordIndex[1]].u -
texCoords[face.texCoordIndex[0]].u;
float deltaB1 = texCoords[face.texCoordIndex[2]].u -
texCoords[face.texCoordIndex[0]].u;
float scale = 1/ ((deltaB0 * deltaT1) - (deltaB1 * deltaT0));
Vecteur tmpTangente = ((side0*deltaT1) - (side1*deltaT0))*scale;
tmpTangente.normalise();
Vecteur tmpBinormal = ((side0*(-deltaB1)) + (side1*deltaB0))*scale;
tmpBinormal.normalise();
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;
} |
 |
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 à debugger 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 rentrerais 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];
Vecteur tangent = tangents[vertexIndex] + v;
glColor3f(1,0,0);
glVertex3f(v.x,v.y,v.z);
glVertex3f(tangent.x,tangent.y,tangent.z);
Vecteur binormal = binormals[vertexIndex] + v;
glColor3f(0,1,0);
glVertex3f(v.x,v.y,v.z);
glVertex3f(binormal.x,binormal.y,binormal.z);
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é 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 :
-
Calculer le vecteur transformé vers la lumière.
-
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é 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];
Vecteur tempVect = lightPos - vertex[vertexIndex];
Vecteur vertexToLight;
vertexToLight.x = tangents[vertexIndex]*tempVect;
vertexToLight.y = binormals[vertexIndex]*tempVect;
vertexToLight.z = normals[vertexIndex]*tempVect;
vertexToLight.normalise();
glColor3f(vertexToLight.x,vertexToLight.y,vertexToLight.z);
TexCoord &tc = texCoords[faces[i].texCoordIndex[j]];
glMultiTexCoord2fARB(GL_TEXTURE0_ARB,tc.u,tc.v);
Vecteur &v = vertex[vertexIndex];
Vecteur lightTexCoord = light.computeLighting(v);
glMultiTexCoord3fARB(GL_TEXTURE1_ARB,lightTexCoord.x,
lightTexCoord.y,lightTexCoord.z);
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 |
glActiveTextureARB(GL_TEXTURE0_ARB);
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, textureId);
glTexEnvf(GL_TEXTURE_ENV,GL_TEXTURE_ENV_MODE,GL_MODULATE);
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 de unités de textures |
glActiveTextureARB(GL_TEXTURE0_ARB);
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);
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);
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.
glEnable(GL_BLEND);
glBlendFunc(GL_ONE,GL_ONE);
glDepthMask(GL_FALSE);
model->addLightPerVertex(light1);
model->addLightPerVertex(light2);
glActiveTextureARB(GL_TEXTURE2_ARB);
glDisable(GL_TEXTURE_2D);
glActiveTextureARB(GL_TEXTURE1_ARB);
glDisable(GL_TEXTURE_3D_EXT);
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
Voila, 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 aux centres des
polygones, et si les polygones sont trop grands, on peut tomber
sur des cas ou 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éments blanches.
Nous verrons donc dans le prochain tutoriel une
méthode basée sur un rendu multi passes pour obtenir des
lumières colorées.
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]
| (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.
|
 
|