Aller au contenu

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.

Light 3

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é !).

Light 1

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}\).

Light 2

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

Light 4

\[ \begin{array}{cccc} & x & y & z \\ A & 0.2 & 0.5 & 0.2 \\ B & 0.1 & -0.6 & 0.1 \\ C & -0.8 & 0.1 & -0.3 \\ D & 0.8 & -0.1 & -0.1 \\ \end{array} \]

Les normales doivent être "devant" par rapport à l'observateur ; il faut donc bien orienter les faces. Dans notre exemple les normales sont :

\[ \begin{array}{c@{\;}c@{\;}ccccc} & & & & x & y & z \\ n_{ACB} & = & \vec{AC} \wedge \vec{AB} & = & -0.433 & -0.042 & 0.900 \\ n_{ABD} & = & \vec{AB} \wedge \vec{AD} & = & 0.349 & -0.116 & 0.930 \\ \end{array} \]

Le calcul des normales est effectué avec le produit vectoriel \(\wedge\) :

\[ \vec{u}\wedge\vec{v} \;=\; \det\left( \begin{array}{ccc} u_1 & u_2 & u_3 \\ v_1 & v_2 & v_3 \\ \vec{i} & \vec{j} & \vec{k} \end{array} \right) \;=\; \left( \begin{array}{c} u_2 v_3 - u_3 v_2 \\ u_3 v_1 - u_1 v_3 \\ u_1 v_2 - u_2 v_1 \end{array} \right) \]

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 :

Light 4

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
Kite (...)
{
    // Positions
    GLfloat positions[] = {
        0.2,  0.5,  0.2,    // 0 A
        0.1, -0.6,  0.1,    // 1 B
       -0.8,  0.1, -0.3,    // 2 C
        0.8, -0.1, -0.1,    // 3 D
    };
                     // A  C  B  A  B  D
    GLint ind_pos[] = { 0, 2, 1, 0, 1, 3 };

    // Couleurs
    GLfloat colors[] = {
        1.0, 0.6, 0.6,      // triangle 0
        0.7, 1.0, 0.5,      // triangle 1
    };
                   // A  C  B  A  B  D
    int ind_col[] = { 0, 0, 0, 1, 1, 1 };

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
    // Conversion des positions en vec3
    auto pos_to_v = [&](int i) { 
        return vmath::vec3( positions[i*3], positions[i*3+1], positions[i*3+2]); 
    };
    vmath::vec3 pA = pos_to_v(0), pB = pos_to_v(1),
                pC = pos_to_v(2), pD = pos_to_v(3);
    vmath::vec3 vAB = pB-pA, vAC = pC-pA, vAD = pD-pA;

    // Calcul des normales
    vmath::vec3 nACB = vmath::cross (vAC, vAB),
                nABD = vmath::cross (vAB, vAD );
    // Normalisation
    vmath::normalize (nACB);
    vmath::normalize (nABD);

Les normales normalisées sont alors stockées sous la même forme que les positions et couleurs :

1
2
3
4
5
    // Normales normalisées
    GLfloat normals[] = { nACB[0], nACB[1], nACB[2],
                          nABD[0], nABD[1], nABD[2] };
                   // A  C  B  A  B  D
    int ind_nor[] = { 0, 0, 0, 1, 1, 1 };

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
    // Création d'une structure de données à plat
    std::vector<GLfloat> vertices;
    for (int i = 0; i < 6; i++) {
        // Positions sommets
        for (int j = 0; j < 3; j++)
            vertices.push_back (positions[ind_pos[i]*3+j]);
        // Couleurs sommets
        for (int j = 0; j < 3; j++)
            vertices.push_back (colors[ind_col[i]*3+j]);
        // Normales sommets
        for (int j = 0; j < 3; j++)
            vertices.push_back (normals[ind_nor[i]*3+j]);
    }

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
    // VAA associant les données à la variable vPos du shader, avec l'offset 0
    glVertexAttribPointer (m_vPos_loc, 3, GL_FLOAT, GL_FALSE, 
        9*sizeof(GLfloat), reinterpret_cast<void*>(0*sizeof(GLfloat)));
    glEnableVertexAttribArray (m_vPos_loc);  

    // VAA associant les données à la variable vCol du shader, avec l'offset 3
    glVertexAttribPointer (m_vCol_loc, 3, GL_FLOAT, GL_FALSE, 
        9*sizeof(GLfloat), reinterpret_cast<void*>(3*sizeof(GLfloat)));
    glEnableVertexAttribArray (m_vCol_loc);  

    // VAA associant les données à la variable vNor du shader, avec l'offset 6
    glVertexAttribPointer (m_vNor_loc, 3, GL_FLOAT, GL_FALSE, 
        9*sizeof(GLfloat), reinterpret_cast<void*>(6*sizeof(GLfloat)));
    glEnableVertexAttribArray (m_vNor_loc);  

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
    void draw ()
    {
        glBindVertexArray (m_VAO_id);
        glDrawArrays (GL_TRIANGLES, 0, 6);
        glBindVertexArray (0);
    }

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 :

Normal 1

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

\[N' \cdot T' = (GN) \cdot (MT) = 0\,.\]

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

\[(GN) \cdot (MT) = (GN)^T (MT)\,.\]

De plus, pour deux matrices \(A\) et \(B\) on a \((AB)^T = B^T A^T\), donc

\[(GN) \cdot (MT) = (GN)^T (MT) = (N^T G^T) (MT) = N^T G^T M T\,.\]

La solution est de poser \(G = (M^{-1})^T\) ; en effet on a

\[N^T G^T M T = N^T ((M^{-1})^T)^T M T = N^T M^{-1} M T = N^T I T = N^T T = N \cdot T = 0\,.\]

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
void set_projection (vmath::mat4& mat_MVP, vmath::mat3& mat_Nor)
{
    vmath::mat4 mat_proj = vmath::mat4::identity();

    GLfloat hr = m_cam_r, wr = hr * m_aspect_ratio;
    switch (m_cam_proj) {
    case P_ORTHO :
        mat_proj = vmath::ortho (-wr, wr, -hr, hr, m_cam_near, m_cam_far);
        break;
    case P_FRUSTUM :
        mat_proj = vmath::frustum (-wr, wr, -hr, hr, m_cam_near, m_cam_far);
        break;
    default: ;
    }

    vmath::vec3 eye {0, 0, (float)m_cam_z}, center {0, 0, 0}, up {0, 1., 0};
    vmath::mat4 mat_cam = vmath::lookat (eye, center, up);

    // Rotation de la scène pour l'animation, tout en float pour le template
    vmath::mat4 mat_world = vmath::rotate (m_anim_angle, 0.f, 1.f, 0.15f);

    // Matrice Model Vue Projection
    mat_MVP = mat_proj * mat_cam * mat_world;

    // Matrice normale = transposée de l'inverse du mineur
    mat_Nor = vmath::normal (mat_world);
}

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 de mat ;
  • float vmath::det (mat3& mat) : renvoie le déterminant de mat ;
  • mat3 vmath::inv (mat3& mat) : renvoie la matrice inverse de mat ;
  • 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
#version 330
in vec4 vPos;
in vec4 vCol;
in vec3 vNor;
out vec4 color;
out vec3 trNor;
uniform mat4 matMVP;
uniform mat3 matNor;

void main()
{
    gl_Position = matMVP * vPos;
    color = vCol;
    trNor = matNor * vNor;
}

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
#version 330
in vec4 color;
in vec3 trNor;
out vec4 fragColor;

void main()
{
    // Couleur et direction de lumière, normalisée
    vec3 lightColor = vec3(1.0);
    vec3 lightDir = normalize(vec3(0.0, 0.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);

    // Couleur de l'objet éclairé
    vec4 result = vec4(diffuse, 1.0) * color;

    fragColor = clamp(result, 0.0, 1.0);
}

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
#version 330
in vec4 color;
in vec3 trNor;
out vec4 fragColor;

void main()
{
    // Couleur et direction de lumière, normalisée
    vec3 lightColor = vec3(0.8);
    vec3 lightDir = normalize(vec3(0.0, 0.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.3);

    // Somme des lumières
    vec3 sumLight = diffuse + ambiant;

    // Couleur de l'objet éclairé
    vec4 result = vec4(sumLight, 1.0) * color;

    fragColor = clamp(result, 0.0, 1.0);
}

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 :

Light 4

1
2
3
4
5
        // Stockage des normales normalisées
        GLfloat normals[] = { nACB[0], nACB[1], nACB[2],
                              nABD[0], nABD[1], nABD[2] };
                       // A  C  B  A  B  D
        int ind_nor[] = { 0, 0, 0, 1, 1, 1 };

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
        std::vector<GLfloat> normals;
        std::vector<int> ind_nor;
        if (m_phong) {
            vmath::vec3 nAB = (nACB + nABD) / 2.0;

            // Stockage des normales normalisées pour lissage de Phong
            normals = { nACB[0], nACB[1], nACB[2],
                        nABD[0], nABD[1], nABD[2],
                        nAB [0], nAB [1], nAB [2] };
                     // A  C  B  A  B  D
            ind_nor = { 2, 0, 2, 2, 2, 1 };
        }
        else {
            // Stockage des normales normalisées
            normals = { nACB[0], nACB[1], nACB[2],
                        nABD[0], nABD[1], nABD[2] };
                     // A  C  B  A  B  D
            ind_nor = { 0, 0, 0, 1, 1, 1 };
        }

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.

Light 5

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 :

\[ S * C * \max\{\, \cos\gamma \,,\,0 \,\}^m \quad \text{si}\; N \cdot L > 0\,,\; \text{sinon}\; 0 \]

\(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\).

Light 6

Soit \(A\) le vecteur obtenu en projetant \(L\) sur \(N\). On a \(||A|| = ||L|| \cos \theta\), or \(A\) est colinéaire à \(N\), donc

\[A = ||A|| \frac{N}{||N||} = N \frac{||A||}{||N||} = N \frac{||L||}{||N||} \cos \theta\,.\]

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à,

\[A = N \frac{||L||}{||N||} \cos\theta = N \frac{||L||}{||N||} \frac{L \cdot N}{||L||\,||N||} = N \frac{L \cdot N}{||N||^2}\,.\]

Or \(L\) et \(R\) sont symétriques par rapport à \(N\), donc on a \(L+R = 2A\), d'où

\[R = 2A - L = 2N \frac{L \cdot N}{||N||^2} - L\,.\]

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é) :

\[R = 2N (L \cdot N) - L\,.\]

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
#version 330
in vec4 vPos;
in vec4 vCol;
in vec3 vNor;
out vec4 color;
out vec4 trPos;
out vec3 trNor;
uniform mat4 matMVP;
uniform mat4 matWorld;
uniform mat3 matNor;

void main()
{
    gl_Position = matMVP * vPos;
    color = vCol;
    trPos = matWorld * vPos;
    trNor = matNor * vNor;
}

9.6.3. Calculs d'un fragment

Le calcul de la lumière spéculaire est effectué dans le fragment shader :

Light 5

#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);
}