I. But de ce tutoriel▲
Cette série de tutoriels a pour but d'étudier la mise en œuvre des systèmes d'éclairage dans un moteur 3D.
Il existe de très nombreuses façons de gérer son éclairage dans un moteur 3D. Dans cette série de tutoriels, nous aborderons les techniques d'éclairage les plus connues. Néanmoins, nous ne traiterons que les techniques permettant de gérer un éclairage dynamique de nos scènes, et nous ne parlerons que des lumières ponctuelles omnidirectionnelles à rayon fini. Nous n'aborderons donc pas les sources de lumière infinie, ou les spots de lumière.
Cette série de tutoriels se décomposera en six parties où nous verrons :
- L'éclairage par vertex ;
- Le light mapping dynamique simple ;
- Le light mapping dynamique avec texture 3D ;
- L'éclairage par pixel simple ;
- L'éclairage par pixel standard ;
- La création d'un système d'éclairage complexe basé sur les shaders.
Pour la mise en œuvre, nous utiliserons les bibliothèques OpenGL et GLUT pour gérer l'affichage. Néanmoins, l'ensemble des principes étudiés ici est facilement portable sous Direct3D.
II. Pourquoi mettre en place son propre système de gestion de la lumière▲
Une question souvent posée sur les forums est de savoir comment gérer les lumières dans un moteur 3D. En effet, même si OpenGL propose une gestion des lumières standard en interne, celle-ci n'est pas réellement utilisable dans un moteur complexe. Bien qu'accélérées par le matériel, les lumières OpenGL posent plusieurs problèmes lorsqu'on souhaite les utiliser dans un moteur complexe.
- L'éclairage est calculé par vertex.
Cette façon de gérer l'éclairage oblige à avoir un nombre de polygones élevé pour obtenir une qualité d'éclairage intéressante. En effet, l'éclairage étant interpolé entre les sommets d'un polygone, si les polygones sont trop étendus, l'éclairage ne sera pas bon au centre du polygone. La seule solution pour résoudre ce problème est de découper les grands polygones en plusieurs petits polygones (on parle d'augmentation de la tesselation). Cette solution résout relativement bien le problème de l'interpolation de la lumière, mais elle a aussi comme conséquence d'augmenter fortement la géométrie ce qui a un impact important sur les performances, notamment au niveau de :
- l'envoi de la géométrie à la carte 3D ;
- l'utilisation de certains algorithmes (par exemple : les collisions) qui seront plus coûteux, ou encore pour le calcul des ombres volumétriques qui dépendent fortement de la géométrie. - L'éclairage étant calculé par la carte graphique, le nombre de lumières utilisables simultanément est limité par le matériel. C'est principalement cette limitation qui impose la mise en place de systèmes de gestion de la lumière dans les moteurs 3D. En effet, on imagine mal un jeu moderne qui ne permet d'avoir que huit lumières dans un niveau. Il existe bien entendu des méthodes qui permettent d'activer/désactiver des lumières quand elles ne sont plus visibles. Ceci permet de repousser la limite du nombre de lumières, mais cette limite existe toujours. Imaginez un très grand hangar avec seulement huit lumières là où on en aurait mis plusieurs dizaines dans la réalité, le résultat risque de ne pas être très crédible.
Tous ces problèmes ont contraint les créateurs de moteurs 3D à mettre en place des systèmes de gestion de la lumière plus ou moins complexes.
III. Les différents modèles d'éclairage▲
Il existe de nombreuses manières d'appréhender la lumière. La plupart des systèmes de gestion de la lumière sont basés sur les modèles de Phong et de Blinn. Néanmoins, de nombreux moteurs 3D n'implémentent pas ces modèles dans leur totalité.
Je ne détaille pas ici les modèles de Phong et Blinn, mais le net regorge de sites qui les traitent très bien.
Le principe de base d'un système de gestion de la lumière est de pouvoir noircir plus ou moins les éléments d'une scène en fonction de la position et de l'orientation du pixel par rapport à la lumière.
Dans la plupart des modèles d'éclairage utilisés, la lumière se décompose généralement en quatre composantes :
- La composante ambiante : elle correspond à la luminosité minimum de la scène.
Elle permet en fait de simuler les sources de lumière indirectes. En effet, dans la réalité, les objets sont éclairés par deux types de sources lumineuses : les sources directes et les sources indirectes. Les sources directes sont les générateurs de lumière (ampoule, soleil et autre), et les sources indirectes sont des objets qui ont reçu de la lumière et en restituent une partie.
Comme les algorithmes de calcul de restitution de la lumière indirecte ont une complexité temporelle trop importante pour être utilisés en temps réel, le résultat est approché en utilisant une couleur ambiante commune à toute la scène ; -
La composante diffuse : elle correspond à la lumière reçue par l'objet. Elle varie en fonction de la distance et de l'orientation de la surface par rapport à la lumière. Le calcul de la lumière diffuse est généralement
diffuse = N.L * atténuation
Où diffuse est la couleur diffuse, N est le vecteur normal à la surface, L le vecteur allant de la surface à la lumière, atténuation est le facteur d'atténuation en fonction de la distance, et le . représente le produit scalaire entre les deux vecteurs.Ce calcul est généralement nommé NdotL (dot représentant le produit scalaire) dans la littérature, nous utiliserons donc ce terme au cours des tutoriels.
-
La composante spéculaire : elle correspond à la réflexivité de la surface. Par exemple, une surface métallique brillante aura une forte composante spéculaire alors qu'un pneu de voiture n'en aura pas ;
- La composante émissive : plus anecdotique, la composante émissive correspond à la lumière émise par la surface. Cette composante est toujours affichée même quand la surface est ombrée.
Généralement, les modèles d'éclairage considèrent que la lumière est additive. Ce qui signifie que si une surface est éclairée par deux lumières, le résultat final sera l'addition des deux éclairages. Ainsi une surface éclairée par une lumière rouge {1,0,0} et par une lumière bleue {0,0,1} aura la même couleur que si elle avait été éclairée par une lumière violette {1,0,1}. Ceci est particulièrement important pour déterminer la façon dont seront affichées les lumières par la suite.
Maintenant que nous avons vu cette introduction basique des modèles d'éclairage, nous allons voir des systèmes classiques pour les mettre en œuvre.
Voici les types de systèmes de gestion de l'éclairage dynamiques les plus connus.
III-A. L'éclairage par vertex▲
C'est, historiquement, le premier modèle d'éclairage en temps réel utilisé. En effet, l'éclairage en temps réel étant coûteux, lorsque les ordinateurs n'avaient pas de carte accélératrice, il fallait utiliser un système très rapide, même s'il présente les désavantages présentés précédemment.
C'est ce modèle dont nous allons étudier la mise en œuvre dans ce premier tutoriel.
III-B. Le light mapping▲
Comme son nom l'indique (si vous n'êtes pas anglophobe), le light mapping consiste à stocker l'éclairage dans une texture. Cet algorithme se décompose en deux étapes :
- Le rendu de la scène en utilisant la texture d'éclairage. Les parties dans l'ombre sont affichées en noir alors que le reste est affiché dans la couleur de l'éclairage local ;
- Le rendu de la scène avec la texture normale, en multipliant la couleur précédente. Ainsi, si la couleur précédente était du noir, le résultat final sera du noir, donc une zone ombrée. Si la couleur précédente était du blanc, la couleur finale obtenue est celle de la texture, donc une zone éclairée.
Cette technique a comme gros avantage de permettre de précalculer la texture d'éclairage pour les lumières statiques, et étant basé sur l'utilisation de textures, d'utiliser principalement les ressources de la carte graphique.
Le light mapping est à la base un système de gestion des lumières statiques, mais il est facile de lui ajouter une gestion de l'éclairage dynamique.
Cette méthode sera vue dans les tutoriels 2 et 3.
III-C. L'éclairage par pixel▲
Basé sur des extensions comme le DOT3 bump mapping ou les shaders, l'éclairage par pixel est possible de nos jours, car les cartes graphiques ayant évolué, le développeur peut maintenant intervenir sur la façon dont sont calculés les pixels avant qu'ils ne soient ajoutés à la scène. Cette méthode de gestion de l'éclairage permet un très bon rendu graphique, tout en étant très flexible. Le choix peut donc être fait entre la qualité graphique ou la performance, en implémentant seulement une partie d'un modèle d'éclairage par exemple.
Cette méthode permettant de nombreuses implémentations, nous l'étudierons durant trois tutoriels.
III-D. Autres modèles▲
Il existe d'autres méthodes (qui sont généralement calculées par pixel) comme les spherical harmonics par exemple, mais ces techniques étant plus anecdotiques dans l'industrie 3D, nous ne les étudierons pas ici.
IV. Un premier système de gestion de la lumière : l'éclairage par vertex▲
Nous allons maintenant voir comment mettre en œuvre un système d'éclairage par vertex, mais avant de voir sa mise en œuvre précise, je vais essayer d'en expliquer rapidement le principe.
IV-A. Principe de l'éclairage par vertex▲
L'éclairage par vertex consiste simplement à effectuer l'ensemble des calculs d'éclairage au niveau des vertex. Ce système est très simple et peut se résumer à l'algorithme en pseudocode suivant :
initialiser la luminosité de tous les vertex à la lumière ambiante
pour chaque lumière
pour chaque face
pour chaque vertex de la face
vertex.couleur = lumière.couleur * (NdotL) * atténuation
fin pour
fin pour
fin pour
rendre la scène
L'algorithme peut être simplifié en ne prenant pas en compte les vertex des faces, mais directement la géométrie, mais cela a pour conséquence de ne pas pouvoir avoir de vertex à normales multiples.
Un vertex à normales multiples est un point de l'espace qui est partagé entre plusieurs faces ayant des orientations très différentes. Par exemple un cube a huit vertex, mais ses faces ont des orientations suffisamment différentes pour qu'on ne souhaite pas lisser les normales de ces vertex (on parle de normal smoothing), le vertex aura alors plusieurs normales (dans le cas du cube, une par face qui utilise le vertex). Généralement la gestion des normales lissées est laissée à la discrétion de l'artiste qui crée la scène via un outil de modélisation. Ici, nous utiliserons donc les normales contenues dans le fichier.
Pour commencer l'étude de la mise en œuvre, nous allons voir la structure de données utilisée par nos calculs, ensuite nous aborderons le problème du calcul de l'atténuation de la luminosité, et pour finir, nous parlerons de l'ombrage.
Nous ne traiterons pas des composantes spéculaire et émissive de la lumière, mais leur implémentation est relativement facile à mettre en place.
IV-B. Structure de données▲
Ici, nous allons parler des structures de données utilisées pour le calcul de la luminosité de la scène. J'utilise dans ce tutoriel des classes basiques pour gérer mes vecteurs, couleurs et coordonnées de textures. Ces classes ne seront pas détaillées ici, mais elles sont disponibles avec le code.
Pour le tutoriel nous utilisons une scène stockée dans un fichier .obj. Le chargement de ce fichier n'entre pas en compte dans ce tutoriel, mais il nous permet de récupérer les informations de géométrie sous la forme de quatre tableaux qui contiennent :
- Les positions des vertex ;
- Les normales ;
- Les coordonnées de texture ;
- Les faces.
Les faces sont des structures qui contiennent les indices des vertex/normales/coordonnées de texture de chacun des points de la face. Ici nous ne traitons que des faces triangulaires.
Nous avons donc besoin d'une structure pour stocker ces faces
struct
Face
{
unsigned
int
vertexIndex[3
];
unsigned
int
texCoordIndex[3
];
unsigned
int
normalIndex[3
];
Color color[3
];
}
;
Ici, la face contient aussi une couleur pour chacun de ses vertex, c'est la couleur qui sera utilisée pour déterminer l'éclairage final du vertex. Cette information est propre à ce tutoriel et disparaîtra dans les tutoriels suivants.
Nous avons besoin d'une classe qui va gérer notre géométrie. Cette classe doit pouvoir stocker les informations sur les vertex, normales, coordonnées de texture et sur les faces. Elle doit aussi permettre de charger un fichier de géométrie, gérer l'éclairage et le rendu de la scène.
class
Model
{
private
:
Vecteur *
vertex;
Vecteur *
normals;
TexCoord *
texCoords;
Face *
faces;
int
nbVertex;
int
nbNormals;
int
nbTexCoord;
int
nbFaces;
public
:
Model(void
);
virtual
~
Model(void
);
bool
load(const
std::
string &
filePath);
void
initLighting(const
Color&
ambiant);
void
addLight(Light&
light);
void
render();
}
;
La méthode load() sert à charger la géométrie depuis un fichier, elle n'est pas particulièrement intéressante ici, la seule information qui nous intéresse est que le chargement prend en compte les normales partagées, nous avons donc un modèle dont certains vertex ont plusieurs normales et donc un rendu cohérent des cubes et autres formes avec des angles importants entre les faces.
La méthode initLighting prend en paramètre la couleur ambiante et l'assigne à tous les vertex de la scène. Cette méthode donne donc :
void
Model::
initLighting(const
Color&
ambiant)
{
for
(int
i =
0
; i <
nbFaces; i++
)
{
faces[i].color[0
] =
ambiant;
faces[i].color[1
] =
ambiant;
faces[i].color[2
] =
ambiant;
}
}
La méthode addLight prend en paramètre une lumière, effectue les calculs d'éclairage et les applique aux vertex. Le code donne donc :
void
Model::
addLight(Light&
light)
{
// pour chaque face
for
(int
i =
0
; i <
nbFaces; i++
)
{
// pour chaque vertex de la face
for
(int
j =
0
; j <
3
; j++
)
{
// on calcule la lumière
Color c =
light.computeLighting(vertex[faces[i].vertexIndex[j]],normals[faces[i].normalIndex[j]]);
// et on l'additionne avec la lumière précédente
faces[i].color[j].r +=
c.r;
faces[i].color[j].g +=
c.g;
faces[i].color[j].b +=
c.b;
}
}
}
Avec computeLighting, la fonction de calcul de la luminosité du vertex. Cette méthode sera vue plus loin.
Ici la luminosité calculée est additionnée à celle calculée précédemment, car, comme nous l'avons vu, la lumière est additive.
Il ne nous reste plus que la méthode permettant le rendu de la scène. Elle est relativement simple, et ici, je n'utilise aucune optimisation à base de tableaux de vertex ou autre, j'effectue un simple rendu en mode immédiat.
void
Model::
render()
{
glBegin(GL_TRIANGLES);
// pour chaque face
for
(int
i =
0
;i <
nbFaces; i++
)
{
// pour chaque vertex de la face
for
(int
j =
0
; j <
3
; j++
)
{
// on récupère la couleur, les coordonnées de texture et la position
Color &
c =
faces[i].color[j];
TexCoord &
tc =
texCoords[faces[i].texCoordIndex[j]];
Vecteur &
v =
vertex[faces[i].vertexIndex[j]];
// et on les envoie à openGL
glColor3f(c.r,c.g,c.b);
glTexCoord2f(tc.u,tc.v);
glVertex3f(v.x,v.y,v.z);
}
}
glEnd();
}
Maintenant que nous avons notre classe de gestion de la géométrie, nous avons besoin d'une classe qui gère une lumière.
Pour nous simplifier la gestion des calculs, c'est cette classe qui va calculer l'éclairage des vertex en fonction de leur position et de leur normale.
class
Light
{
private
:
float
radius;
Color color;
Vecteur position;
public
:
Light(void
);
virtual
~
Light(void
);
void
setRadius(float
r);
float
getRadius();
void
setColor(float
r, float
g, float
b);
Color getColor();
void
setPosition(float
x, float
y, float
z);
Vecteur getPosition();
Color computeLighting(const
Vecteur &
position, const
Vecteur &
normal);
}
;
Notre lumière contient simplement une position, un rayon et une couleur. Mis à part les accesseur et modificateur, cette classe ne contient qu'une méthode intéressante : computeLighting qui prend en paramètre la position d'un vertex et sa normale et retourne sa couleur d'éclairage. C'est cette méthode qui permet de calculer l'éclairage du vertex comme nous allons le voir.
IV-C. Gestion de l'atténuation▲
La première chose à prendre en compte dans notre calcul de la luminosité est l'atténuation de la lumière. En effet plus notre vertex est loin de la lumière, moins il est éclairé, et s'il dépasse le rayon d'action de la lumière, il n'est plus du tout éclairé.
Il existe plusieurs équations pour calculer l'atténuation (linéaire, quadratique et autres). Ici, nous utiliserons une équation linéaire. C'est-à-dire qu'un vertex situé à mi-distance du rayon de la lumière recevra la moitié de sa luminosité.
L'équation donne donc
atténuation = max ( 0, 1-(distance / rayon))
où atténuation est le facteur d'atténuation de la lumière en fonction de la distance, distance est la distance du vertex par rapport à la position de la lumière et rayon est le rayon maximum de la lumière.
Le code du calcul de l'atténuation donne donc :
// on calcule le vecteur allant du vertex à la lumière.
Vecteur vertexToLight =
this
->
position -
position;
float
attenuation =
std::
max<
float
>
(0.0
f,1.0
f -
(vertexToLight.getLength() /
radius));
Nous calculons le vecteur allant du vertex à la lumière. Ici, le sens du vecteur n'a pas d'importance étant donné que ce qui nous intéresse est sa longueur, mais il est néanmoins important de le calculer dans le bon sens, car il est réutilisé par la suite dans la gestion de l'ombrage.
IV-D. Gestion de l'ombrage▲
Ce qui est appelé ombrage en infographie est à différencier des ombres portées. L'ombre portée correspond à l'ombre générée par un objet sur un autre objet. L'ombrage lui est juste le fait qu'une face est moins éclairée si elle ne fait pas face à la lumière et est complètement ombrée si elle tourne le dos à la lumière.
Nous voulons donc obtenir ce comportement pour nos vertex. Pour cela, nous allons justement utiliser le fameux calcul NdotL. Mais d'abord un petit rappel de maths sur le produit scalaire s'impose.
Le produit scalaire entre deux vecteurs normalisés (de longueur 1) a une propriété intéressante.
Si les deux vecteurs sont identiques, le produit scalaire vaut 1. Si les deux vecteurs ont la même direction, le produit scalaire est compris dans ]0..1]. Si les deux vecteurs sont perpendiculaires, le produit scalaire vaut 0, s'ils sont de sens opposés, le produit scalaire est compris dans [-1..0[, et s'ils sont complètement opposés, le produit scalaire vaut -1.
Appliqué à notre problème, si le produit scalaire entre la normale et le vecteur vertexToLight est supérieur à 0, c'est que la face est éclairée, sinon, elle est dans l'ombre. Et, encore plus intéressant, si la normale est exactement identique au vecteur vertexToLight (c'est-à-dire si le polygone fait exactement face à la lumière), le produit scalaire vaut 1. Nous avons donc, à moindre coût, le calcul exact de l'ombrage de nos faces avec un simple produit scalaire (et une normalisation de vecteur, car il faut que le vecteur vertexToLight soit normalisé pour que ce calcul fonctionne). Nous avons donc retrouvé notre NdotL, mais nous l'appliquons par vertex et non par faces.
Notre fonction de calcul de la luminosité devient donc :
// on calcule le verteur allant du vertex à la lumière.
Vecteur vertexToLight =
this
->
position -
position;
float
attenuation =
1.0
f -
(vertexToLight.getLength() /
radius);
// on normalise le vecteur
vertexToLight.normalise();
// on calcule le produit scalaire NdotL avec N = normal et L = vertexToLight
attenuation *=
vertexToLight*
normal;
attenuation =
std::
max<
float
>
(0.0
f,attenuation);
Maintenant que nous avons calculé la luminosité de notre vertex, nous devons calculer sa couleur. Ceci se fait simplement en multipliant la couleur de la lumière par la luminosité du vertex. Nous obtenons donc le code final de la méthode computeLighting :
Color Light::
computeLighting(const
Vecteur &
position, const
Vecteur &
normal)
{
// on calcule le vecteur allant du vertex à la lumière.
Vecteur vertexToLight =
this
->
position -
position;
float
attenuation =
1.0
f -
(vertexToLight.getLength() /
radius);
// on normalise le vecteur
vertexToLight.normalise();
// on calcule le produit scalaire
attenuation *=
vertexToLight*
normal;
attenuation =
std::
max<
float
>
(0.0
f,attenuation);
Color ret =
this
->
color;
ret.r *=
attenuation;
ret.g *=
attenuation;
ret.b *=
attenuation;
return
ret;
}
IV-E. Le rendu final▲
Maintenant que nous avons tout ce qu'il faut pour calculer notre éclairage, il nous faut l'afficher. Ceci se fait très simplement en respectant la séquence suivante :
scene->
initLighting(ambiant);
// on ajoute nos lumières
scene->
addLight(light1);
scene->
addLight(light2);
// on active la texture
glEnable(GL_TEXTURE_2D);
// on affiche la scène
scene->
render();
Ce qui donne le résultat suivant :
V. Conclusions▲
La méthode d'éclairage par vertex est simple à mettre en œuvre et présente comme avantage important par rapport aux techniques que nous étudierons plus tard de dessiner la scène qu'une seule fois. Nous économisons donc du temps sur l'envoi de la géométrie, mais c'est du coup le processeur qui prend en charge la majorité des calculs.
Un autre défaut vu précédemment est le problème des artefacts d'éclairage sur les polygones trop étendus. Cet artefact se produit principalement au niveau du calcul de l'atténuation. En effet, lorsqu'on place une lumière sur un polygone trop étendu, on peut tomber sur la situation où le rayon de la lumière n'est pas suffisant pour éclairer les vertex du polygone, il n'est donc pas éclairé alors que la lumière le touche.
Pour résoudre ces problèmes, nous allons devoir changer de méthode d'éclairage et passer au light mapping que nous verrons dans le prochain tutoriel.