Navigation▲
Tutoriel précédent : éclairage ambiant |
Tutoriel suivant : |
I. Contexte▲
La principale différence entre la lumière ambiante et la lumière diffuse réside dans le fait que la lumière diffuse dépend de la direction des rayons de lumière, tandis que la lumière ambiante l'ignore complètement. Quand seule la lumière ambiante est présente, la scène entière est uniformément éclairée. La lumière diffuse fait que les parties des objets qui font face à la lumière sont plus claires que les parties qui lui tournent le dos.
La lumière diffuse ajoute aussi une astuce qui fait que l'angle dans lequel la lumière atteint la surface détermine la clarté de cette surface. Ce concept est illustré dans l'image suivante :
Supposons que la force des deux rayons est identique et que leur seule différence est leur direction. Le modèle de lumière diffuse dit que la surface à gauche sera plus claire que la surface à droite, car la surface à droite est atteinte avec un angle plus aigu que la surface de gauche. En fait, la surface de gauche sera la plus claire possible, car la lumière l'atteint avec un angle de 90 degrés.
Le modèle de lumière diffuse est actuellement basé sur la « Lambert's cosine law », qui dit que l'intensité de la lumière reflétée sur une surface est directement proportionnelle au cosinus de l'angle entre la direction de l'observateur et la normale à la surface. Notons que nous avons un peu changé cela, en utilisant la direction de la source lumineuse au lieu de la direction de l'observateur (que nous utiliserons dans la lumière spéculaire).
Pour calculer l'intensité de la lumière dans le modèle de lumière diffuse, nous allons simplement utiliser le cosinus de l'angle entre la lumière et la normale à la surface (alors que la loi de Lambert se réfère au concept plus général de « directement proportionnelle »). Considérons l'image suivante :
Nous voyons quatre rayons lumineux qui atteignent la surface avec des angles différents. La normale à la surface est la flèche verte qui pointe hors de la surface. Le rayon de lumière A est celui avec la plus grande force. L'angle entre A et la normale est zéro et le cosinus de zéro vaut un. Cela signifie qu'après la multiplication de l'intensité de la lumière (les trois canaux de zéro à un) par la couleur de la surface, nous multiplions le résultat par un. Nous ne pouvons pas avoir plus que cela avec la lumière diffuse. Le rayon B atteint la surface avec un angle entre 0 et 90. Cela signifie que l'angle entre B et la normale est lui aussi entre 0 et 90, et le cosinus de cet angle vaut entre zéro et un. Nous allons pondérer le résultat de la multiplication du dessus par le cosinus de cet angle, ce qui signifie que l'intensité de la lumière sera définitivement plus faible que celle du rayon A.
Les choses deviennent différentes avec les rayons C et D. C atteint la surface directement depuis le côté, avec un angle de zéro. L'angle entre C et la normale est exactement de 90 degrés, dont le cosinus vaut zéro. Le résultat est donc que C n'a aucun effet sur l'éclairage de la surface. L'angle entre D et la normale est obtus, ce qui signifie que son cosinus a une valeur négative. Le résultat final est le même que pour C : aucun effet sur l'éclairage de la surface.
À partir de cette discussion, on peut tirer une conclusion importante : afin d'avoir un effet sur la clarté de la surface, la lumière doit atteindre la surface de sorte que l'angle entre elle et la normale à la surface soit plus grand que zéro et strictement inférieur à 90 degrés.
Nous voyons que la normale à la surface joue un rôle important dans le calcul de la lumière diffuse. Les exemples ci-dessus étaient très simples : la surface était une simple ligne et il n'y avait qu'une seule normale à considérer. Dans le monde réel, nous avons des objets qui sont composés de multiples polygones et la normale à chaque polygone est différente de celle du polygone à côté. Voici un exemple :
Comme la normale est la même tout le long de la surface du polygone, elle est suffisante pour calculer la lumière diffuse dans le vertex shader. Les trois sommets du triangle auraient la même couleur, qui serait celle du triangle complet. Cependant, cela ne serait pas très joli. Nous aurons un ensemble de polygones où chacun aura une couleur particulière, légèrement différente de celle du polygone d'à côté, et nous verrons les jointures des couleurs au niveau des arêtes. Cela peut assurément être amélioré.
L'astuce est d'utiliser un concept connu sous le nom de « normale par sommet ». Une normale par sommet est la moyenne des normales de tous les triangles qui partagent le sommet. Au lieu de demander au vertex shader de calculer la lumière diffuse, nous lui passons juste la normale du sommet comme attribut, qui est ensuite transmise au fragment shader, et c'est tout. Le rasteriser va récupérer trois normales différentes et va devoir effectuer une interpolation entre elles. Le fragment shader va être appelé pour chaque pixel avec la normale spécifique à ce pixel. Nous pouvons dès lors calculer la lumière diffuse au niveau du pixel, en utilisant cette normale spécifique. Le résultat va être un effet de lumière, qui va agréablement changer au travers de la face du triangle et entre les triangles voisins. Cette technique est connue sous le nom de « Ombrage Phong ». Voici à quoi ressembleront les normales des sommets après l'interpolation :
Vous pouvez trouver que le modèle de pyramide, que nous avons utilisé lors des quelques tutoriels précédents, a un aspect étrange avec ces normales par sommet et décider de rester en normales originales. C'est compréhensible. Cependant, comme les modèles vont devenir de plus en plus complexes (et nous verrons cela dans le futur) et que leurs surfaces deviendront plus douces, je pense que vous trouverez les normales par sommet plus appropriées.
La seule chose restante dont nous devons nous occuper est l'espace de coordonnées dans lequel les calculs de lumière diffuse vont prendre place. Les sommets et leurs normales sont exprimés dans leur système local de coordonnées, puis sont transformés dans le vertex shader vers l'espace de découpe grâce à la matrice WVP que nous donnons au shader. Cependant, définir la direction de la lumière dans l'espace monde est l'approche la plus logique. Après tout, la direction de la lumière est le résultat d'une source lumineuse qui est positionnée quelque part dans le monde (même le soleil est situé dans le « monde », bien qu'à de nombreux kilomètres de distance) et répand sa lumière dans une direction particulière. Par conséquent, nous devons transformer les normales dans l'espace monde avant le calcul.
II. Explication du code▲
26.
27.
28.
29.
30.
31.
struct
DirectionalLight
{
Vector3f Color;
float
AmbientIntensity;
Vector3f Direction;
float
DiffuseIntensity;
}
;
C'est la nouvelle structure DirectionalLight. Il y a deux nouveaux membres ici : la direction est un vecteur à trois dimensions défini dans l'espace monde et l'intensité est un nombre flottant (qui sera utilisé de la même manière que l'intensité ambiante).
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
#
version
330
layout
(
location =
0
) in
vec3
Position;
layout
(
location =
1
) in
vec2
TexCoord;
layout
(
location =
2
) in
vec3
Normal;
uniform
mat4
gWVP;
uniform
mat4
gWorld;
out
vec2
TexCoord0;
out
vec3
Normal0;
void
main
(
)
{
gl_Position
=
gWVP *
vec4
(
Position, 1
.0
);
TexCoord0 =
TexCoord;
Normal0 =
(
gWorld *
vec4
(
Normal, 0
.0
)).xyz;
}
Voici le vertex shader mis à jour. Nous avons un nouvel attribut de sommet, la normale, que l'application va devoir fournir. De plus la transformation vers l'espace monde possède sa propre variable uniforme et l'application devra là aussi la fournir, en plus de la matrice WVP. Le vertex shader transforme la normale dans l'espace monde en utilisant la matrice monde et la passe au fragment shader. Notons comment la normale à trois dimensions est étendue à un vecteur quatre dimensions, multipliée par la matrice monde à quatre dimensions, puis réduite à nouveau à trois dimensions en utilisant la notation (…).xyz. Cette capacité du langage GLSL est appelée « réarrangement » et permet une grande flexibilité dans la manipulation des vecteurs. Par exemple, si nous avons un vecteur trois dimensions v(1,2,3), nous pouvons écrire : vec4 n = v.zzyy, ainsi le vecteur n contiendra (3,3,2,2). Rappelons-nous que lorsque nous étendons la normale de trois à quatre dimensions, nous devons placer zéro comme quatrième composante. Cela neutralise l'effet de la translation de la matrice monde (la quatrième colonne). La raison est que les vecteurs ne peuvent pas être déplacés comme des points. Ils peuvent uniquement être mis à l'échelle ou orientés.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
#
version
330
in
vec2
TexCoord0;
in
vec3
Normal0;
out
vec4
FragColor;
struct DirectionalLight
{
vec3
Color;
float
AmbientIntensity;
float
DiffuseIntensity;
vec3
Direction;
}
;
Voici le début du fragment shader. Il reçoit maintenant la normale interpolée, qui a été transformée dans l'espace monde par le vertex shader. La structure DirectionalLight a été modifiée pour refléter celle dans le code C++ et contient les nouveaux attributs de la lumière.
20.
21.
22.
void
main
(
)
{
vec4
AmbientColor =
vec4
(
gDirectionalLight.Color, 1
.0f
) *
gDirectionalLight.AmbientIntensity;
Il n'y a aucun changement dans le calcul du facteur de couleur ambiante. Nous le calculons et le stockons ici pour pouvoir l'utiliser dans la formule finale plus bas.
float
DiffuseFactor =
dot
(
normalize
(
Normal0), -
gDirectionalLight.Direction);
Voici le cœur du calcul de lumière diffuse. Nous calculons le cosinus de l'angle entre le vecteur lumière et la normale en faisant un produit scalaire entre eux. Trois choses à noter ici :
- La normale venue du vertex shader est normalisée avant utilisation. On le fait parce que l'interpolation, au travers de laquelle le vecteur est passé, peut avoir modifié sa longueur et il n'est donc plus un vecteur unitaire ;
- La direction de la lumière a été inversée. Lorsqu'on y réfléchit un instant, nous voyons que la lumière qui atteint la surface à angle droit est en fait à 180 degrés de la normale de la surface (qui pointe simplement vers la source lumineuse). En inversant la direction de la lumière dans ce cas, nous obtenons un vecteur qui est égal à la normale. Par conséquent l'angle entre eux vaut zéro, ce que nous souhaitons ;
- le vecteur lumière n'est pas normalisé. Ce serait un gâchis de ressources GPU que de normaliser le même vecteur encore et encore, pour chaque pixel. À la place, nous nous assurons de normaliser le vecteur que l'application passe avant que le dessin ne soit fait.
vec4
DiffuseColor;
if
(
DiffuseFactor >
0
) {
DiffuseColor =
vec4
(
gDirectionalLight.Color, 1
.0f
) *
gDirectionalLight.DiffuseIntensity *
DiffuseFactor;
}
else
{
DiffuseColor =
vec4
(
0
, 0
, 0
, 0
);
}
Ici nous calculons le terme de diffusion, qui dépend de la couleur de la lumière, l'intensité de diffusion et la direction de la lumière. Si le facteur de diffusion est inférieur ou égal à zéro, cela signifie que la lumière atteint la surface avec un angle obtus (soit « de côté », soit « de derrière »). Dans ce cas la lumière diffuse n'a pas d'effet et le vecteur DiffuseColor est initialisé à zéro. Si le facteur est supérieur à zéro, nous calculons la couleur diffuse en multipliant la couleur de base de la lumière par l'intensité de diffusion, puis pondérons le résultat par le facteur de diffusion. Si l'angle entre la lumière et la normale est zéro, le facteur de diffusion vaudra un, ce qui fournit la force maximale de lumière.
FragColor =
texture2D
(
gSampler, TexCoord0.xy) *
(
AmbientColor +
DiffuseColor);
}
Voici le calcul final de l'éclairage. Nous ajoutons les termes ambiant et diffusion, puis multiplions le résultat par la couleur échantillonnée à partir de la texture. Maintenant, nous voyons que même si la lumière diffuse n'a pas d'effet sur la surface (à cause de sa direction), la lumière ambiante peut toujours l'éclairer, si elle existe.
145.
146.
147.
148.
149.
150.
151.
152.
void
LightingTechnique::
SetDirectionalLight(const
DirectionalLight&
Light)
{
glUniform3f(m_dirLightLocation.Color, Light.Color.x, Light.Color.y, Light.Color.z);
glUniform1f(m_dirLightLocation.AmbientIntensity, Light.AmbientIntensity);
Vector3f Direction =
Light.Direction;
Direction.Normalize();
glUniform3f(m_dirLightLocation.Direction, Direction.x, Direction.y, Direction.z);
glUniform1f(m_dirLightLocation.DiffuseIntensity, Light.DiffuseIntensity);
}
Cette fonction définit les paramètres de la lumière directionnelle dans le shader. Elle a été étendue pour couvrir le vecteur direction et l'intensité de diffusion. Notons que le vecteur direction est normalisé avant d'être défini. La classe LightingTechnique récupère aussi la position des variables uniformes dans le shader, pour la direction et pour l'intensité de diffusion, ainsi que pour la matrice monde. Il y a aussi une fonction pour définir la matrice de transformation monde. Toutes ces choses sont maintenant de jolies fonctions et le code n'est pas retranscrit ici. Visionnez les sources pour plus de détails.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
struct
Vertex
{
Vector3f m_pos;
Vector2f m_tex;
Vector3f m_normal;
Vertex() {}
Vertex(Vector3f pos, Vector2f tex)
{
m_pos =
pos;
m_tex =
tex;
m_normal =
Vector3f(0.0
f, 0.0
f, 0.0
f);
}
}
;
La structure Vertex a été mise à jour et inclut maintenant la normale. Elle est initialisée automatiquement à zéro par le constructeur et nous avons une fonction dédiée qui parcourt tous les sommets et calcule les normales.
198.
199.
200.
201.
202.
203.
204.
205.
206.
207.
208.
209.
210.
211.
212.
213.
214.
215.
216.
void
CalcNormals(const
unsigned
int
*
pIndices, unsigned
int
IndexCount, Vertex*
pVertices, unsigned
int
VertexCount)
{
for
(unsigned
int
i =
0
; i <
IndexCount ; i +=
3
) {
unsigned
int
Index0 =
pIndices[i];
unsigned
int
Index1 =
pIndices[i +
1
];
unsigned
int
Index2 =
pIndices[i +
2
];
Vector3f v1 =
pVertices[Index1].m_pos -
pVertices[Index0].m_pos;
Vector3f v2 =
pVertices[Index2].m_pos -
pVertices[Index0].m_pos;
Vector3f Normal =
v1.Cross(v2);
Normal.Normalize();
pVertices[Index0].m_normal +=
Normal;
pVertices[Index1].m_normal +=
Normal;
pVertices[Index2].m_normal +=
Normal;
}
for
(unsigned
int
i =
0
; i <
VertexCount ; i++
) {
pVertices[i].m_normal.Normalize();
}
}
Cette fonction prend un tableau de sommets et un tableau d'indices, parcourt chaque triangle en fonction des indices et calcule sa normale. Dans la première boucle, nous accumulons simplement les normales des trois sommets du triangle. Pour chaque triangle, la normale est calculée comme le produit vectoriel entre deux arêtes venant du premier sommet. Avant d'accumuler la normale dans le sommet, nous nous assurons de la normaliser. La raison est que le résultat du produit vectoriel n'est pas forcément un vecteur unitaire. Dans la seconde boucle, nous parcourons le tableau de sommets directement (nous n'avons plus à nous préoccuper des indices) et normalisons la normale de chaque sommet. Cette opération est l'équivalent de faire la moyenne de la somme de toutes les normales accumulées et nous laisse avec une normale par sommet qui est de longueur unitaire. Cette fonction est appelée avant la création du tampon de sommets, de manière à avoir dans ce tampon les normales calculées, avec les autres attributs de sommets.
132.
133.
134.
135.
136.
137.
138.
const
Matrix4f&
WorldTransformation =
p.GetWorldTrans();
m_pEffect->
SetWorldMatrix(WorldTransformation);
...
glEnableVertexAttribArray(2
);
...
glVertexAttribPointer(2
, 3
, GL_FLOAT, GL_FALSE, sizeof
(Vertex), (const
GLvoid*
)20
);
...
glDisableVertexAttribArray(2
);
Voici les changements principaux de la boucle de rendu. La classe Pipeline a une nouvelle fonction qui fournit la matrice de transformation monde (en plus de la matrice WVP). La matrice monde est calculée comme la multiplication de la matrice de mise à l'échelle par la matrice de rotation et finalement par la matrice de translation. Nous activons et désactivons le troisième attribut de sommet, et spécifions le décalage de la normale à l'intérieur de chaque sommet du tampon de sommets. Le décalage vaut 20, car la normale est précédée de la position (12 octets) et des coordonnées de texture (8 octets).
Pour compléter la démonstration que nous voyons dans l'image de ce tutoriel, nous devons aussi spécifier l'intensité de diffusion et la direction de la lumière. Cela est fait dans le constructeur de la classe Tutorial18. L'intensité de diffusion est définie à 0.8 et la direction de la lumière va de gauche à droite. L'intensité de l'ambiante a été réduite à zéro pour amplifier l'effet de la lumière diffuse. Nous pouvons jouer avec les touches « z » et « x » pour contrôler l'intensité de diffusion (comme « a » et « s » du tutoriel précédent modifient l'intensité de l'ambiante).
III. Point mathématique▲
De nombreuses sources sur Internet vous diront que vous devez transposer l'inverse de la matrice monde pour transformer le vecteur normal. C'est correct, cependant nous n'avons généralement pas besoin d'aller si loin. Nos matrices monde sont toujours orthogonales (leurs vecteurs sont toujours orthogonaux). Comme l'inverse d'une matrice orthogonale est égale à sa transposée, la transposée de l'inverse est en fait la transposée de la transposée, et nous arrivons donc avec notre matrice originale. Aussi longtemps que nous évitons de faire des distorsions (pondérer un axe différemment des autres) nous pouvons continuer avec l'approche présentée ci-dessus.
IV. Sources▲
Vous pouvez télécharger les sources de ce projet en suivant ce lien :
V. Remerciements▲
Merci à Etay Meiri de nous permettre de traduire son tutoriel.
Merci à LittleWhite pour ses corrections et à ClaudeLELOUP pour sa relecture.
Navigation▲
Tutoriel précédent : éclairage ambiant |
Tutoriel suivant : |