Programmation Graphique : CM séance 05
9. Rendu de lumière
Les lumières et les ombres sont très importantes pour que notre cerveau puisse interpréter une scène en 3D à partir de l'image projetée en 2D sur la rétine ; le mouvement aide également.
Modéliser la lumière est très complexe, car il y a de nombreuses interactions entre les sources de lumières et les matériaux éclairés.
Les interactions sont assez bien comprises au niveau équations physiques, mais le calcul effectif de toutes les équations est souvent prohibitif.
→ Pour obtenir un rendu en temps réel on utilise des approches très simplifiées de modèles physiques.
9.1 Sources et composantes
On distingue les sources de lumières suivantes :
- la source ponctuelle, qui émet dans toutes les directions (une ampoule) ;
- la source directionnelle (point à l'infini, soleil) ;
- la source conique (projecteur, lampe de poche).
L'énergie de la source peut être infinie, ou diminuer avec la distance.
Les objets éclairés réfléchissent différentes composantes de lumière selon leur matériaux :
- la lumière spéculaire (effet miroir),
- la lumière diffuse (effet mat),
- la lumière réfractée (de l'autre côté),
- la lumière ambiante.
Le rendu représente la somme des lumières réfléchies dans la direction de la caméra. Elles sont calculées pour chaque pixel dans le fragment shader.
9.2. Réflexion diffuse
C'est la composante de la lumière qui réfléchie dans toutes les directions, et son énergie est redistribuée dans une multitude de rayons réfléchis.
Les matériaux mats ne renvoient pas de lumière spéculaire, mais uniquement de la lumière diffuse. Les miroirs font l'inverse.
L'intensité perçue de la réflexion diffuse
-
dépend de l'inclinaison de la surface : elle est d'autant plus élevée que la source de lumière est perpendiculaire à la surface ;
-
ne dépend pas de la position de l'observateur (sauf si la face est du mauvais côté !).
La loi de Lambert permet d'exprimer la quantité de lumière diffuse \(D\) à partir des éléments suivants :
- \(N\) = la normale à la surface, normalisée ;
- \(L\) = la direction vers la source de lumière, normalisée ;
- \(I\) = intensité de la source de lumière ;
- \(C\) = couleur du matériaux.
Selon la loi de Lambert, la lumière diffuse est : \(D = C \cdot I \cdot \cos \theta\) , où \(\theta = \widehat{NL}\).
Propriétés :
- \(L\) presque perpendiculaire → \(\cos \theta\) proche de 1 (intensité maximale)
- \(L\) "rasant" la surface → \(\cos \theta\) proche de 0 (intensité minimale)
- \(L\) "sous" la surface → \(\cos \theta < 0\) → on borne à 0
On peut calculer \(\cos \theta\) très efficacement avec le produit scalaire :
- on a \(L \cdot N = ||L|| \cdot ||N|| \cdot \cos(\theta)\) ,
- et aussi \(L \,.\,N = L_x N_x + L_y N_y + L_z N_z\) .
\(L\) et \(N\) normalisés → \(\cos(\theta) = L_x N_x + L_y N_y + L_z N_z\) .
9.3. Exemple du Kite
Notre exemple va consister à afficher un cerf-volant (kite) formé de 2 triangles.
9.3.1. Coordonnées et normales
Les normales doivent être "devant" par rapport à l'observateur ; il faut donc bien orienter les faces. Dans notre exemple les normales sont :
Le calcul des normales est effectué avec le produit vectoriel \(\wedge\) :
Le module vmath.h
implémente le produit vectoriel
de deux vecteurs u
et v
avec la méthode cross
:
vmath::vec3 n_uv = vmath::cross (u, v);
9.3.2. Données pour le VBO
Le code source de la classe Kite
est contenu dans le fichier
fw51-light.cpp
.
Les positions et couleurs sont données sous forme de listes par sommet unique et de listes d'indices par triangle :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
On calcule ensuite la normale normalisée pour chaque triangle, en établissant les coordonnées des vecteurs ligne 7, puis en faisant le produit vectoriel lignes 10-11, et enfin la normalisation lignes 13-14 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Les normales normalisées sont alors stockées sous la même forme que les positions et couleurs :
1 2 3 4 5 |
|
On peut maintenant assembler les données qui seront stockées dans le VBO :
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Il n'y a plus qu'à créer un VAO et un VBO, copier les données dans le VBO avec le code habituel, puis associer les données aux attributes du vertex shader à l'aide des VAA suivants :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Ceci achève la construction des données dans le constructeur.
La méthode draw
de la classe Kite
est des plus classiques ;
tout le travail sera fait dans les shaders.
1 2 3 4 5 6 |
|
9.4. Matrices de transformation
Dans les exemples des cours précédents, la matrice générale matrix
stockait
à la fois :
- la projection (Projection),
- la position de la caméra (Vue),
- la position de la scène (Model ou World).
Or, le calcul de \(\cos\theta\) ne doit pas dépendre de la projection ni de la caméra, dont ne dépendent pas la lumière diffuse, mais uniquement de la position de la scène.
Il faut donc calculer deux matrices :
- la matrice
mat_MVP = mat_proj * mat_cam * mat_world
de dimension 4, pour les transformations appliquées aux positions ; - la matrice normale
mat_Nor
de dimension 3 pour les transformations appliquées aux normales.
9.4.1. Matrice normale
La matrice normale est calculée à partir du mineur 3x3 de la
matrice mat_world
, obtenu en enlevant la dernière colonne (qui peut
contenir les termes d'une translation) et de la dernière ligne.
Cette matrice peut être une combinaison de rotations et de transformation d'échelle (scale), or les transformations d'échelle ne conservent pas toujours les angles droits :
Soit \(T\) un vecteur tangeant à la surface et \(N\) un vecteur normal ; comme ils sont perpendiculaires on a \(N \cdot T = 0\). Soit \(M\) la matrice des transformations géométriques à appliquer, et \(G\) la matrice normale recherchée. On note \(T'= M T\) et \(N' = G N\) les résultats des transformations.
On cherche \(G\) telle que \(N'\) et \(T'\) sont aussi perpendiculaires, ce qui s'écrit
Le produit scalaire de deux vecteurs \(U\) et \(V\) peut aussi s'écrire comme produit de matrices avec transposition, par \(U \cdot V = U^T V\), donc on a
De plus, pour deux matrices \(A\) et \(B\) on a \((AB)^T = B^T A^T\), donc
La solution est de poser \(G = (M^{-1})^T\) ; en effet on a
9.4.2. Implémentation
On réécrit la fonction set_projection
en décomposant les transformations :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
|
La fonction vmath::normal
calcule la matrice normale, qui est
la transposée de l'inverse de la sous-matrice \(3 \times 3\) de mat_world
.
Petit soucis, le module vmath.h
n'intègre pas les calculs
nécessaires, c'est pourquoi nous nous donnons un petit module complémentaire
vmath-et.h
qui contient :
mat3 vmath::minor3 (mat4& mat)
: renvoie le mineur demat
;float vmath::det (mat3& mat)
: renvoie le déterminant demat
;mat3 vmath::inv (mat3& mat)
: renvoie la matrice inverse demat
;mat3 vmath::normal (mat4& mat)
: renvoie la matrice normale ;void vmath::print (mat3& mat)
: affiche une matrice.
Remarque 1 : avec la librairie GLM, utiliser
la fonction glm::inverseTranspose
.
Remarque 2 : il est possible de reporter le calcul de la matrice normale dans le vertex shader, mais ce serait très inefficace, car l'inversion d'une matrice est une opération coûteuse, et elle serait effectuée sur chaque sommet ! Il est beaucoup plus efficace de la calculer une seule fois et de la transmettre.
9.5. Les shaders
Les deux matrices sont mémorisées dans les variables uniform
matMVP
et matNor
.
Dans l'exemple fw51-light.cpp
on peut afficher les
shaders par défaut avec l'option -ps
(print shader), et modifier les
shaders avec les options -vs vs_file
et -fs fs_file
.
La touche U
recharge les shaders sans quitter le programme.
9.5.1. Vertex Shader
Le vertex shader reçoit pour chaque sommet :
la position vPos
, la couleur vCol
et la normale vNor
.
Il transforme la position avec la matrice matMVP
dans gl_Position
,
et transmet la couleur dans la variable color
;
enfin il calcule la normale transformée trNor
en multipliant la normale
vNor
avec la matrice normale matNor
ligne 14 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
9.5.2. Fragment shader
Le fragment shader reçoit la couleur color
interpolée
entre les couleurs des sommets du triangle, et la normale trNor
elle aussi interpolée entre les normales transformées du triangles.
Comme on a donné les mêmes normales au sommet du triangle, la normale interpolée est le même vecteur pour tous les fragments.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
Pour tester les normales, on peut afficher leurs coordonnées avec des couleurs :
//vec4 result = vec4(diffuse, 1.0) * color;
vec4 result = vec4(nor3, 1.0);
On peut également afficher les variations de luminosité :
//vec4 result = vec4(diffuse, 1.0) * color;
vec4 result = vec4(diffuse, 1.0);
On peut encore déplacer la source de lumière avec la variable
uniform vec4 mousePos
:
vec3 lightDir = normalize(vec3(
2*mousePos[0]/mousePos[2] -1.0, // x / w in [0..1]
2*mousePos[1]/mousePos[3] -1.0, // y / h in [0..1]
1.0 ));
9.5.3. Lumière ambiante
On peut rajouter une lumière ambiante, de manière à voir également les faces arrières :
// Lumière ambiante
vec3 ambiant = vec3(0.3);
// Somme des lumières
vec3 sumLight = diffuse + ambiant;
// Couleur de l'objet éclairé
//vec4 result = vec4(diffuse, 1.0) * color;
vec4 result = vec4(sumLight, 1.0) * color;
Mais cela augmente la luminosité générale.
Pour éviter cela on peut diminuer lightColor
de manière à ce que
lightColor
+ ambiant
ne dépasse pas trop 1 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
9.5. Lissage de Phong
Si on modélise une surface par des triangles dans le but de ne pas les voir, on peut soit trianguler la surface avec beaucoup de petits triangles, ce qui est coûteux, soit effectuer un lissage.
Les deux principaux lissages utilisés sont :
-
le lissage de Gouraud, où on interpole les couleurs d'un triangle à l'autre ;
-
le lissage de Phong, où on interpole les normales d'un triangle à l'autre.
Le lissage de Phong donne de bien meilleurs résultats avec l'éclairage, d'autant qu'il ne "coûte pas cher", car le fragment shader reçoit déjà les normales interpolées des sommets du triangle pour chaque fragment !
→ Il suffit d'interpoler les normales des sommets qui sont communs à des triangles, avant de les stoker dans le VBO :
-
Sommet commun à 2 triangles \(T\) et \(T'\) :
\[N = (N_{T} + N_{T'})/2\] -
Sommet commun à 3 triangles \(T\), \(T'\) et \(T''\) :
\[N = (N_{T} + N_{T'} + N_{T''})/3\]
Implémentation du lissage de Phong
Dans l'exemple fw52-phong.cpp
,
dans le constructeur de Kite
on remplace le stockage des normales
nACB
et nABD
:
1 2 3 4 5 |
|
par celui-ci, où on rajoute la normale interpolée nAB
de l'arrête :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
9.6. Réflexion spéculaire
Il s'agit d'une réflexion de la lumière dans un cône autour de la direction réfléchie \(R\) de la lumière, qui est fonction de la source de lumière \(L\) et de la normale \(N\) à la surface.
La quantité de lumière réfléchie vers l'observateur est fonction de l'angle \(\gamma\) entre la direction réfléchie \(R\) et la direction \(V\) de l'observateur : plus l'angle \(\gamma\) est grand, moins il y a de lumière spéculaire.
La lumière spéculaire permet de créer des taches lumineuses, mais aussi un aspect métallique ou laqué.
La composante spéculaire peut être évaluée par la formule :
où \(S\) est la couleur spéculaire de la surface, \(C\) est l'intensité de la lumière incidente, \(m\) est l'exposant spéculaire. En normalisant tous les vecteurs on a \(\cos\gamma = R \cdot V\).
S'il y a plusieurs sources de lumières, on fait la somme des composantes spéculaires.
9.6.1 Direction réfléchie
On commence par calculer la direction réfléchie \(R\).
Soit \(A\) le vecteur obtenu en projetant \(L\) sur \(N\). On a \(||A|| = ||L|| \cos \theta\), or \(A\) est colinéaire à \(N\), donc
D'autre part on a \(L \cdot N = ||L||\,||N||\,\cos\theta\) , donc \(\displaystyle \cos\theta = \frac{L \cdot N}{||L||\,||N||}\) et de là,
Or \(L\) et \(R\) sont symétriques par rapport à \(N\), donc on a \(L+R = 2A\), d'où
Pour appliquer la formule de la composante spéculaire on normalise les vecteurs, ce qui simplifie l'expression de \(R\) (qui n'est pas encore normalisé) :
En GLSL la fonction reflect
donne directement le résultat attendu
avec R = reflect (-L, N)
(on inverse \(L\) pour que ce soit un vecteur incident,
et \(N\) doit être normalisé).
9.6.2 Position du fragment
On a aussi besoin de connaître la position trPos
du fragment dans
l'espace, pour calculer le vecteur \(V\) du fragment vers l'observateur
(à ne pas confondre avec gl_FragCoord
qui sont les coordonnées du
fragment par rapport à la fenêtre).
On obtient trPos
en multipliant la position des sommets par le matrice
mat_world
.
Dans l'exemple fw53-specular.cpp
,
on rajoute la matrice mat_world
dans les paramètres de sortie de set_projections
,
puis on la transmet comme une variable uniform vec4 matWorld
.
Dans le vertex shader, on récupère matWorld
puis on calcule trPos
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
9.6.3. Calculs d'un fragment
Le calcul de la lumière spéculaire est effectué dans le fragment shader :
#version 330
in vec4 color;
in vec4 trPos;
in vec3 trNor;
out vec4 fragColor;
void main()
{
// Couleur et direction de lumière, normalisée
vec3 lightColor = vec3(0.6);
vec3 lightDir = normalize(vec3(0.0, -1.0, 10.0));
// Normale du fragment, normalisée
vec3 nor3 = normalize(trNor);
// Cosinus de l'angle entre la normale et la lumière
float cosTheta = dot(nor3, lightDir);
// Lumière diffuse
vec3 diffuse = lightColor * max(cosTheta, 0.0);
// Lumière ambiante
vec3 ambiant = vec3(0.2);
// Lumière spéculaire
vec3 eyePos = vec3(0.0, 0.0, 1.0);
vec3 specColor = vec3(1.0, 1.0, 1.0);
float spec_S = 0.2;
float spec_m = 32.0;
vec3 specular = vec3(0);
if (cosTheta > 0.0) {
vec3 R = reflect (-lightDir, nor3);
vec3 V = eyePos - vec3(trPos);
float cosGamma = dot(normalize(R), normalize(V));
specular = specColor * spec_S * pow(max(cosGamma, 0.0), spec_m);
}
// Somme des lumières
vec3 sumLight = diffuse + specular + ambiant;
// Couleur de l'objet éclairé
vec4 result = vec4(sumLight, 1.0) * color;
fragColor = clamp(result, 0.0, 1.0);
}