OpenGL Moderne

Tutoriel 13 : application des normales

Opengl-tutorial.org

Traducteur

Alexandre Laurent

Bienvenue dans notre treizième tutoriel ! Aujourd'hui, on va parler de l'application des normales.

Commentez

- Titre : OpenGL Moderne

- Auteur : Opengl-tutorial.org

- Traducteur : Alexandre Laurent

- Parution : 17 juin 2014

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2014 opengl-tutorial.org. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- Navigation

- I. Introduction

- II. Textures de normales

- III. Tangente et bitangente

- IV. Préparation du VBO

- IV-A. Calcul des tangentes et bitangentes

- IV-B. Indexation

- V. Le shader

- V-A. Tampons et variables uniformes supplémentaires

- V-B. Vertex shader

- V-C. Fragment shader

- VI. Résultats

- VII. Aller plus loin

- VII-A. Orthogonalisation

- VII-B. Règle de la main droite

- VII-C. Texture spéculaire

- VII-D. Débogage avec le mode immédiat

- VII-E. Débogage avec les couleurs

- VII-F. Débogage avec les noms des variables

- VII-G. Comment créer une texture de normales

- VIII. Exercices

- IX. Outils et liens

- X. Références

- XI. Remerciements

- Navigation

Navigation

Tutoriel précédent : extensions OpenGL

Sommaire

Tutoriel suivant : rendu dans une texture

I - Introduction

Depuis le huitème tutoriel : shaders de base, vous savez comment obtenir un ombrage décent en utilisant les normales des triangles. Jusqu'à présent, il existait un inconvénient : on n'avait qu'une seule normale par sommet dans chaque triangle et elle va varier doucement, contrairement aux couleurs, qui sont échantillonnées à partir d'une texture. L'idée de base de l'application des normales (normal mapping) est de donner aux normales des variations similaires.

II - Textures de normales

Une « texture de normales » ressemble à cela :

Dans chaque texel RGB est encodé un vecteur XYZ : chacune des composantes d'une couleur est comprise entre 0 et 1 et chacune des composantes d'un vecteur est entre -1 et 1, donc la conversion d'un texel en normale s'effectue ainsi :

normal = (2*color)-1 // sur chaque composante

La texture a une teinte bleue car après tout, la normale pointe vers « l'extérieur de la surface » ; comme toujours X va vers la droite, Y vers le haut.

Cette texture est appliquée exactement comme la texture de diffusion ; le gros problème est la conversion de notre normale, qui est exprimée dans l'espace de chaque triangle (espace tangent, aussi appelé espace de l'image), vers l'espace modèle (car c'est ce que l'on utilise dans notre équation d'ombrage).

III - Tangente et bitangente

Maintenant, que vous connaissez les matrices, vous savez que pour définir un espace (dans notre cas, l'espace tangent), on a besoin de trois vecteurs. On a déjà le vecteur UP : c'est la normale, donnée par Blender ou calculée à partir du triangle à l'aide d'un produit scalaire. Elle est représentée en bleu, tout comme la teinte de la texture de normales :

Ensuite, on a besoin d'une tangente, T : un vecteur perpendiculaire à la surface. Mais il y a tellement de vecteurs correspondant à ce critère :

Lequel choisir ? En théorie, n'importe lequel, mais on doit être consistant avec ses voisins pour éviter de voir apparaître d'affreuses bordures. La méthode standard est d'orienter la tangente dans la même direction que les coordonnées de textures :

Comme on a besoin de trois vecteurs pour définir une base, on doit aussi calculer la bitangente B (qui peut être n'importe quel autre vecteur tangent, mais si tout est perpendiculaire, les mathématiques sont simplifiées) :

Voici l'algorithme : si on appelle deltaPos1 et deltaPos2 deux côtés de notre triangle et deltaUV1 et deltaUV2 les différences de coordonnées UV correspondante, on peut exprimer notre problème avec l'équation suivante :

deltaPos1 = deltaUV1.x * T + deltaUV1.y * B
deltaPos2 = deltaUV2.x * T + deltaUV2.y * B

Trouvez les solutions de ce système pour T et B et vous obtenez vos vecteurs ! Voir le code ci-dessous.

Une fois que l'on a les vecteurs T, B et N, on obtient aussi cette jolie matrice qui nous permet de passer de l'espace tangent à l'espace modèle :

Avec cette matrice TBN, on peut transformer les normales (extraites à partir de la texture) dans l'espace modèle. Par contre, habituellement c'est l'inverse que l'on fait : transformer tout de l'espace modèle vers l'espace tangent et garder les normales telles quelles. Tous les calculs sont faits dans l'espace tangent, ce qui ne change absolument rien.

Pour effectuer cette transformation inverse, on doit simplement prendre l'inverse de la matrice, ce qui dans ce cas (une matrice orthogonale, où chaque vecteur est perpendiculaire aux autres. Voir la section « Aller plus loin ».) est aussi une transposée, moins coûteuse à calculer :

invTBN = transpose(TBN)

Soit :

IV - Préparation du VBO

IV-A - Calcul des tangentes et bitangentes

Comme les tangentes et bitangentes sont nécessaires en plus des normales, on doit les calculer pour la globalité du modèle. On fait cela dans une fonction à part :

void computeTangentBasis(
// entrées
std::vector<glm::vec3> & vertices,
std::vector<glm::vec2> & uvs,
std::vector<glm::vec3> & normals,
// sorties
std::vector<glm::vec3> & tangents,
std::vector<glm::vec3> & bitangents
){

Pour chaque triangle, on calcule le côté (deltaPos) et le deltaUV.

for ( int i=0; i<vertices.size(); i+=3){

// Raccourcis pour les sommets
glm::vec3 & v0 = vertices[i+0];
glm::vec3 & v1 = vertices[i+1];
glm::vec3 & v2 = vertices[i+2];

// Raccourcis pour les UV
glm::vec2 & uv0 = uvs[i+0];
glm::vec2 & uv1 = uvs[i+1];
glm::vec2 & uv2 = uvs[i+2];

// Côtés du triangle : delta des positions
glm::vec3 deltaPos1 = v1-v0;
glm::vec3 deltaPos2 = v2-v0;

// delta UV
glm::vec2 deltaUV1 = uv1-uv0;
glm::vec2 deltaUV2 = uv2-uv0;

On peut utiliser la formule pour calculer la tangente et la bitangente :

float r = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV1.y * deltaUV2.x);
glm::vec3 tangent = (deltaPos1 * deltaUV2.y   - deltaPos2 * deltaUV1.y)*r;
glm::vec3 bitangent = (deltaPos2 * deltaUV1.x   - deltaPos1 * deltaUV2.x)*r;

Finalement, on remplit les tampons tangents et bitangents. Rappelez-vous, ces tampons ne sont pas encore indexés, donc chaque sommet possède sa propre copie.

// Définit la même tangeante pour les trois sommets du triangle.
// Ils seront rassemblés plus tard, dans vboindexer.cpp
tangents.push_back(tangent);
tangents.push_back(tangent);
tangents.push_back(tangent);

// Même chose pour les binormales
bitangents.push_back(bitangent);
bitangents.push_back(bitangent);
bitangents.push_back(bitangent);

}

IV-B - Indexation

L'indexation du VBO est très similaire à ce que l'on avait l'habitude de faire, mais il y a une légère différence.

Si on trouve un sommet similaire (même position, même normale, même coordonnées de texture), on ne souhaite pas utiliser la tangente et bitangente ; au contraire, on souhaite en faire la moyenne. Donc, modifiez un peu le vieux code :

// Essaie de trouver un sommet similaire dans out_XXXX
unsigned int index;
bool found = getSimilarVertexIndex(in_vertices[i], in_uvs[i], in_normals[i],     out_vertices, out_uvs, out_normals, index);

if ( found ){ // Un sommet similaire est déjà dans le VBO, on l'utilise à la place !
out_indices.push_back( index );

// Moyenne des tangentes et bitangentes
out_tangents[index] += in_tangents[i];
out_bitangents[index] += in_bitangents[i];
}else{ // Sinon, on doit l'ajouter dans les données de sortie.
// Faire comme d'habitude
[...]
}

Notez que l'on ne normalise rien ici. En réalité c'est pratique, car de cette façon, les petits triangles, qui ont une tangente et bitangente plus petites, auront un effet diminué sur le vecteur final par rapport aux grands triangles (qui contribueront plus à la forme finale).

V - Le shader

V-A - Tampons et variables uniformes supplémentaires

On a besoin de deux nouveaux tampons, un pour les tangentes, l'autre pour les bitangentes :

GLuint tangentbuffer;
glGenBuffers(1, &tangentbuffer);
glBindBuffer(GL_ARRAY_BUFFER, tangentbuffer);
glBufferData(GL_ARRAY_BUFFER, indexed_tangents.size() * sizeof(glm::vec3), &indexed_tangents[0], GL_STATIC_DRAW);

GLuint bitangentbuffer;
glGenBuffers(1, &bitangentbuffer);
glBindBuffer(GL_ARRAY_BUFFER, bitangentbuffer);
glBufferData(GL_ARRAY_BUFFER, indexed_bitangents.size() * sizeof(glm::vec3), &indexed_bitangents[0], GL_STATIC_DRAW);

On a aussi besoin d'une nouvelle variable uniforme pour notre nouvelle texture de normales :

[...]
GLuint NormalTexture = loadTGA_glfw("normal.tga");
[...]
GLuint NormalTextureID  = glGetUniformLocation(programID, "NormalTextureSampler");

Et d'une autre pour la matrice 3x3 de modèle-vue. Cela n'est pas strictement obligatoire, mais c'est plus simple ; plus d'informations là-dessus plus tard. On a besoin que de la partie 3 x 3 supérieure gauche car on va multiplier des directions : on peut donc se débarrasser de la partie liée à la translation.

GLuint ModelView3x3MatrixID = glGetUniformLocation(programID, "MV3x3");

Voici ce qu'est devenu le code d'affichage :

// Nettoie l'écran
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

// Utilise le shader
glUseProgram(programID);

// Calcule la matrice MVP à partir des entrées clavier et souris
computeMatricesFromInputs();
glm::mat4 ProjectionMatrix = getProjectionMatrix();
glm::mat4 ViewMatrix = getViewMatrix();
glm::mat4 ModelMatrix = glm::mat4(1.0);
glm::mat4 ModelViewMatrix = ViewMatrix * ModelMatrix;
glm::mat3 ModelView3x3Matrix = glm::mat3(ModelViewMatrix); // Take the upper-left part of ModelViewMatrix
glm::mat4 MVP = ProjectionMatrix * ViewMatrix * ModelMatrix;

// Envoie les transformations au shader actuellement lié,
// dans la variable uniforme "MVP"
glUniformMatrix4fv(MatrixID, 1, GL_FALSE, &MVP[0][0]);
glUniformMatrix4fv(ModelMatrixID, 1, GL_FALSE, &ModelMatrix[0][0]);
glUniformMatrix4fv(ViewMatrixID, 1, GL_FALSE, &ViewMatrix[0][0]);
glUniformMatrix4fv(ViewMatrixID, 1, GL_FALSE, &ViewMatrix[0][0]);
glUniformMatrix3fv(ModelView3x3MatrixID, 1, GL_FALSE, &ModelView3x3Matrix[0][0]);

glm::vec3 lightPos = glm::vec3(0,0,4);
glUniform3f(LightID, lightPos.x, lightPos.y, lightPos.z);

// Lie notre texture diffuse à l'unité de texture 0
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, DiffuseTexture);
// Définit notre échantillonneur "DiffuseTextureSampler" à l'unité de texture 0
glUniform1i(DiffuseTextureID, 0);

// Lie notre texture de normales à l'unité de texture 1
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, NormalTexture);
// Définit notre échantilloneur "Normal    TextureSampler" à l'unité de texture 1
glUniform1i(NormalTextureID, 1);

// 1er tampon d'attributs : sommets
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, vertexbuffer);
glVertexAttribPointer(
0,                  // attribut
3,                  // taille
GL_FLOAT,           // type
GL_FALSE,           // normalisé ?
0,                  // nombre d'octets séparant deux sommets dans le tampon
(void*)0            // décalage du tableau de tampon
);

// 2d tampon d'attributs : UVs
glEnableVertexAttribArray(1);
glBindBuffer(GL_ARRAY_BUFFER, uvbuffer);
glVertexAttribPointer(
1,                                // attribut
2,                                // taille
GL_FLOAT,                         // type
GL_FALSE,                         // normalisé ?
0,                                // nombre d'octets séparant deux sommets dans le tampon
(void*)0                          // décalage du tableau de tampon
);

// 3e tampon d'attributs : normales
glEnableVertexAttribArray(2);
glBindBuffer(GL_ARRAY_BUFFER, normalbuffer);
glVertexAttribPointer(
2,                                // attribut
3,                                // taille
GL_FLOAT,                         // type
GL_FALSE,                         // normalisé ?
0,                                // nombre d'octets séparant deux sommets dans le tampon
(void*)0                          // décalage du tableau de tampon
);

// 4e tampon d'attributs : tangentes
glEnableVertexAttribArray(3);
glBindBuffer(GL_ARRAY_BUFFER, tangentbuffer);
glVertexAttribPointer(
3,                                // attribut
3,                                // taille
GL_FLOAT,                         // type
GL_FALSE,                         // normalisé ?
0,                                // nombre d'octets séparant deux sommets dans le tampon
(void*)0                          // décalage du tableau de tampon
);

// 5e tampon d'attributs : bitangentes
glEnableVertexAttribArray(4);
glBindBuffer(GL_ARRAY_BUFFER, bitangentbuffer);
glVertexAttribPointer(
4,                                // attribut
3,                                // taille
GL_FLOAT,                         // type
GL_FALSE,                         // normalisé ?
0,                                // nombre d'octets séparant deux sommets dans le tampon
(void*)0                          // décalage du tableau de tampon
);

// Tampon d'indices
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, elementbuffer);

// Affiche les triangles !
glDrawElements(
GL_TRIANGLES,      // mode
indices.size(),    // nombre
GL_UNSIGNED_INT,   // type
(void*)0           // décalage du tableau de tampon
);

glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glDisableVertexAttribArray(2);
glDisableVertexAttribArray(3);
glDisableVertexAttribArray(4);

// Échange les tampons
glfwSwapBuffers();

V-B - Vertex shader

Comme dit précédemment, on va tout faire dans l'espace de la caméra, car il est plus simple d'obtenir la position du fragment dans cet espace. C'est pourquoi on multiplie nos vecteurs T, B et N avec la matrice de modèle-vue.

vertexNormal_cameraspace = MV3x3 * normalize(vertexNormal_modelspace);
vertexTangent_cameraspace = MV3x3 * normalize(vertexTangent_modelspace);
vertexBitangent_cameraspace = MV3x3 * normalize(vertexBitangent_modelspace);

Ces trois vecteurs définissent la matrice TBN, qui est créée de cette façon :

mat3 TBN = transpose(mat3(
vertexTangent_cameraspace,
vertexBitangent_cameraspace,
vertexNormal_cameraspace
)); // Vous pouvez utiliser des produits scalaire au lieu de créer cette matrice et de la transposée. Voir les références.

La matrice passe de l'espace de la caméra à l'espace tangent (la même matrice, mais avec XXX_modelspace à la place, permettrai de passer de l'espace modèle à l'espace tangent). On peut l'utiliser pour calculer la direction de la lumière et la direction de l'œil, dans l'espace tangent :

LightDirection_tangentspace = TBN * LightDirection_cameraspace;
EyeDirection_tangentspace =  TBN * EyeDirection_cameraspace;

V-C - Fragment shader

La normale, dans l'espace tangent, est immédiate à obtenir, c'est la texture :

// Normale locale, dans l'espace tangent
vec3 TextureNormal_tangentspace = normalize(texture2D( NormalTextureSampler, UV ).rgb*2.0 - 1.0);

Donc, on a tout ce dont nous avons besoin. La lumière diffuse utilise clamp(dot(n,l),0,1), avec n et l exprimé dans l'espace tangent (l'espace dans lequel vous effectuez vos produits scalaire et vectoriel n'importe pas ; la chose importante est que l et n soit tous les deux exprimés dans le même espace). La lumière spéculaire utilise clamp(dot(E,R),0, 1), où, encore une fois, E et R sont exprimés dans l'espace tangent. Super !

VI - Résultats

Voici le résultat obtenu. Vous pouvez remarquer que :

- les briques sont bosselées car on a de nombreuses variations dans les normales ;

- le ciment semble plat car la texture de normales est complètement bleue.

VII - Aller plus loin

VII-A - Orthogonalisation

Dans le vertex shader on prend la transposée au lieu de l'inverse, car c'est plus rapide. Mais cela ne fonctionne que si l'espace représenté par la matrice est orthogonal, ce qui n'est pas encore le cas. Heureusement, c'est facilement corrigeable : on doit simplement faire que la tangente soit perpendiculaire à la normale à la fin de computeTangentBasis() :

t = glm::normalize(t - n * glm::dot(n, t));

La formule peut être difficile à saisir, donc voici un petit schéma pour aider :

n et t sont presque perpendiculaire, donc on « pousse » t dans la direction de -n multiplié par dot(n,t).

Voici une petite application qui explique aussi cela (utilisez seulement deux vecteurs).

VII-B - Règle de la main droite

Vous n'avez normalement pas à vous en inquiéter, mais dans quelques cas, lorsque vous utilisez des modèles symétriques, les coordonnées UV sont orientées dans le mauvais sens et votre T possède la mauvaise orientation.

Pour vérifier si elle doit être inversée ou pas, c'est simple : TBN doit former un système de coordonnées répondant à la règle de la main droite. Par exemple, cross(n,t) doit avoir la même orientation que b.

En mathématiques, « Un vecteur A à la même orientation qu'un vecteur B » se traduit par dot(A,B) > 0, donc on doit vérifier si dot(cross(n,t),b) > 0.

Si c'est incorrect, inversez t :

if (glm::dot(glm::cross(n, t), b) < 0.0f){
t = t * -1.0f;
}

Cela est aussi effectué pour chaque sommet à la fin de la fonction computeTangentBasis().

VII-C - Texture spéculaire

Pour le fun, j'ai ajouté une texture spéculaire au code. Elle ressemble à cela :

et je l'ai utilisée pour remplacer le simple gris « vec3(0.3,0.3,0.3) » que l'on utilise pour la couleur spéculaire :

Le ciment est toujours noir : la texture indique qu'il n'y a pas de composante spéculaire.

VII-D - Débogage avec le mode immédiat

L'objectif réel de ce site Web est que vous N'utilisiez PAS le mode immédiat, qui est obsolète, lent et problématique en de nombreux aspects.

Par contre, il arrive qu'il soit très pratique pour le débogage :

Ici, on observe notre espace tangent avec les lignes dessinées dans le mode immédiat.

Pour cela, vous devez abandonner le profil core 3.3 :

glfwOpenWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_COMPAT_PROFILE);

Puis passer les matrices au vieux pipeline OpenGL (vous pouvez aussi écrire un autre shader, mais c'est plus simple de cette façon et vous ête en train de bidouiller de toute façon) :

glMatrixMode(GL_PROJECTION);
glLoadMatrixf((const GLfloat*)&ProjectionMatrix[0]);
glMatrixMode(GL_MODELVIEW);
glm::mat4 MV = ViewMatrix * ModelMatrix;
glLoadMatrixf((const GLfloat*)&MV[0]);

Désactiver les shaders :

glUseProgram(0);

Et dessiner vos lignes (dans ce cas, les normales, normalisées et multipliées par 0.1 et appliquées au bon sommet) :

glColor3f(0,0,1);
glBegin(GL_LINES);
for (int i=0; i<indices.size(); i++){
glm::vec3 p = indexed_vertices[indices[i]];
glVertex3fv(&p.x);
glm::vec3 o = glm::normalize(indexed_normals[indices[i]]);
p+=o*0.1f;
glVertex3fv(&p.x);
}
glEnd();

Rappelez-vous : n'utilisez pas le mode immédiat dans une vraie application ! Uniquement pour du débogage ! Et n'oubliez pas de réactiver le profil core après coup, cela vous assurera de ne pas faire de telles choses.

VII-E - Débogage avec les couleurs

Lors du débogage, il peut être utile de visualiser la valeur d'un vecteur. La façon la plus simple pour ce faire est d'écrire sa valeur dans le tampon d'image au lieu de la couleur actuelle. Par exemple, pour visualiser LightDiretion_tangentspace :

color.xyz = LightDirection_tangentspace;

Cela signifie que :

- sur la partie droite du cylindre, la lumière (représentée par la fine ligne blanche) est vers le HAUT (dans l'espace tangent). En d'autres mots, la lumière est dans la direction de la normale des triangles ;

- sur le milieu du cylindre, la lumière est dans la direction de la tangente (avant+X).

Quelques conseils :

- suivant ce que vous essayez de voir, vous pouvez souhaiter le normaliser ;

- si vous ne trouvez pas de signification à ce que vous voyez, visualisez toutes les composantes séparément en forçant par exemple le vert et le bleu à 0 ;

- évitez de jouer avec l'alpha, c'est trop compliqué  ;

- si vous souhaitez visualiser une valeur négative, vous pouvez utiliser la même astuce que celle pour notre texture de normales : visualisez (v+1.0)/2.0 à la place. Le noir signifie -1 et la couleur +1. Toutefois, c'est difficile d'interpréter le rendu.

VII-F - Débogage avec les noms des variables

Comme dit précédemment, il est important de connaître exactement dans quel espace vos vecteurs se trouvent. Ne faites pas le produit scalaire d'un vecteur situé dans l'espace de la caméra avec un vecteur dans l'espace modèle.

L'ajout de l'espace de chaque sommet à son nom (« ..._modelspace ») aide à la correction de terribles bogues mathématiques.

VII-G - Comment créer une texture de normales

Créé par James O'Hare. Cliquez pour agrandir :

VIII - Exercices

- Normalisez les vecteurs dans indexVBO_TBN avant l'addition et voyez ce que cela fait.

- Visualisez les autres vecteurs (par exemple, EyeDirection_tangentspace) dans le mode de débogage avec les couleurs et essayez de donner du sens à ce que vous voyez.

IX - Outils et liens

- Crazybump, un outil super pour faire des textures de normales. Payant.

- Plugin Nvidia pour Photoshop. Gratuit, mais Photoshop ne l'est pas…

- Faites vos propres textures de normales à partir de plusieurs photos.

- Faites vos propres textures de normales à partir d'une photo.

- Plus d'informations sur la transposition de matrices.

X - Références

- Lengyel, Eric. “Computing Tangent Space Basis Vectors for an Arbitrary Mesh”. Terathon Software 3D Graphics Library, 2001.

- Real Time Rendering, troisième édition.

- ShaderX4.

XI - Remerciements

Cet article est une traduction autorisée dont le texte original peut être trouvé sur opengl-tutorial.org.

Navigation

Tutoriel précédent : extensions OpenGL

Sommaire

Tutoriel suivant : rendu dans une texture