OpenGL Moderne

Tutoriel 5 : un cube texturé

Dans ce tutoriel, vous allez apprendre :

  • ce que sont les coordonnées UV ;
  • comment charger une texture vous-même ;
  • comment les utiliser dans OpenGL ;
  • ce que sont le filtrage et les MIP maps et comment les utiliser ;
  • comment charger une texture plus efficacement avec GLFW.

Commentez Donner une note à l'article (5)

Article lu   fois.

Les deux auteur et traducteur

Site personnel

Traducteur : Profil ProSite personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

Navigation

Tutoriel précédent : un cube coloré

 

Sommaire

 

Tutoriel suivant : clavier et souris

I. Introduction

Dans ce tutoriel, vous allez apprendre :

  • ce que sont les coordonnées UV ;
  • comment charger une texture vous-même ;
  • comment les utiliser dans OpenGL ;
  • ce que sont le filtrage et les MIP maps et comment les utiliser ;
  • comment charger une texture plus efficacement avec GLFW.

II. À propos des coordonnées UV

Lors de l'application d'une texture sur un modèle, vous avez besoin d'une méthode pour indiquer à OpenGL quelle partie de l'image doit être utilisée pour chaque triangle. Cela se fait grâce aux coordonnées UV.

Chaque sommet possède, en plus de sa position, une paire de floats, U et V. Ces coordonnées sont utilisées pour accéder à la texture de la manière suivante :

Schéma d'application d'une texture sur un triangle

Remarquez comment la texture est déformée sur le triangle.

III. Charger des images .BMP vous-même

Connaître le format de fichier BMP n'est pas important : nombreuses sont les bibliothèques pouvant le faire pour vous. Mais il est très simple et il peut vous aider à comprendre comment les choses fonctionnent en interne. Donc, on va écrire un chargeur de fichier BMP à partir de rien, afin que vous sachiez comment il fonctionne et ne plus jamais l'utiliser.

Voici la déclaration de la fonction de chargement :

 
Sélectionnez
GLuint loadBMP_custom(const char * imagepath);

Et elle s'utilise comme ça :

 
Sélectionnez
GLuint image = loadBMP_custom("./my_texture.bmp");

Maintenant, comment lire un fichier BMP ?

Premièrement, on a besoin de quelques données. Ces variables seront définies lors de la lecture du fichier.

 
Sélectionnez
// Données lues à partir de l'en-tête du fichier BMP
unsigned char header[54]; // Chaque fichier BMP débute par un en-tête de 54 octets
unsigned int dataPos;     // Position dans le fichier  les données débutent
unsigned int width, height; 
unsigned int imageSize;   // = width*height*3 
// les données RBG
unsigned char * data;

On doit maintenant ouvrir le fichier :

 
Sélectionnez
// Ouverture du fichier
FILE * file = fopen(imagepath,"rb"); 
if (!file)                              {printf("Image could not be opened\n"); return 0;}

La première chose dans le fichier est un en-tête de 54 octets. Il contient les informations telles que « est-ce vraiment un fichier BMP ? », la taille de l'image, le nombre de bits par pixel, etc. Donc, on lit l'en-tête :

 
Sélectionnez
if ( fread(header, 1, 54, file)!=54 ){ // S'il n'est pas possible de lire 54 octets : problème
    printf("Not a correct BMP file\n"); 
    return false; 
}

L'en-tête démarre toujours avec BM. En fait, voici ce que vous obtenez lorsque vous ouvrez un fichier .BMP dans un éditeur hexadécimal :

Vue d'un BMP dans un éditeur hexadécimal

Donc, on doit vérifier si les deux premiers octets valent vraiment 'B' et 'M' :

 
Sélectionnez
if ( header[0]!='B' || header[1]!='M' ){ 
    printf("Not a correct BMP file\n"); 
    return 0; 
}

Maintenant, on peut lire la taille de l'image, l'emplacement des données dans le fichier, etc :

 
Sélectionnez
// Lit des entiers à partir du tableau d'octets
dataPos    = *(int*)&(header[0x0A]); 
imageSize  = *(int*)&(header[0x22]); 
width      = *(int*)&(header[0x12]); 
height     = *(int*)&(header[0x16]);

On doit générer des informations pour les cas où elles sont manquantes :

 
Sélectionnez
// Certains fichiers BMP sont mal formés, on devine les informations manquantes
if (imageSize==0)    imageSize=width*height*3; // 3 : un octet pour chaque composante rouge, vert et bleu
if (dataPos==0)      dataPos=54; // l'en-tête BMP est fait de cette façon

Maintenant que la taille de l'image est connue, on peut allouer de la mémoire pour la remplir avec l'image lue :

 
Sélectionnez
// Crée un tampon
data = new unsigned char [imageSize]; 
 
// Lit les données à partir du fichier pour les mettre dans le tampon
fread(data,1,imageSize,file); 
 
// Tout est en mémoire maintenant, le fichier peut être fermé
fclose(file);

On arrive à la section OpenGL. La création de textures est très similaire à la création de tampons : créer une texture, la lier, la remplir et la configurer.

Dans glTexImage2D, GL_RGB indique que l'on utilise une couleur ayant trois composantes et GL_BGR indique comment les données sont réellement disposées en mémoire. En fait, le fichier BMP ne stocke pas les pixels en Rouge->Vert->Bleu mais Bleu->Vert->Rouge, donc nous en devons informer OpenGL.

 
Sélectionnez
// Crée une texture OpenGL
GLuint textureID; 
glGenTextures(1, &textureID); 
 
// "Lie" la nouvelle texture : toutes les fonctions agissant sur les textures suivantes vont modifier cette texture
glBindTexture(GL_TEXTURE_2D, textureID); 
 
// Donne l'image à OpenGL
glTexImage2D(GL_TEXTURE_2D, 0,GL_RGB, width, height, 0, GL_BGR, GL_UNSIGNED_BYTE, data); 
 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);

On va expliquer les deux dernières lignes plus tard. Dorénavant, du côté C++, vous pouvez utiliser la nouvelle fonction pour charger une texture :

 
Sélectionnez
GLuint Texture = loadBMP_custom("uvtemplate.bmp");

Il est important d'utiliser des textures ayant des tailles en puissances de deux :

  • correct : 128 * 128, 256 * 256, 1024 * 1024, 2 * 2… ;
  • mauvais : 127 * 128, 3 * 5… ;
  • correct mais étrange : 128 * 256…

IV. Utiliser une texture dans OpenGL

On commence par le fragment shader. Il est globalement simple :

 
Sélectionnez
#version 330 core 
 
// Valeurs interpolées à partir du vertex shader
in vec2 UV; 
 
// Données en sortie
out vec3 color; 
 
// Valeurs qui sont constantes pour l'ensemble du modèle.
uniform sampler2D myTextureSampler; 
 
void main(){ 
 
    // Couleur de sortie = couleur de la texture pour les coordonnées UV spécifiées
    color = texture( myTextureSampler, UV ).rgb; 
}

Trois choses :

  • le fragment shader a besoin des coordonnées UV. Cela semble logique ;
  • il a aussi besoin d'un « sampler2D » pour savoir à quelle texture accéder (vous pouvez accéder à plusieurs textures dans le même shader) ;
  • finalement, l'accès à la texture est effectué avec texture(), renvoyant un vec4 contenant (R,G,B,A). On va voir ce qu'est le A, prochainement.

Le vertex shader est simple aussi, vous devez juste passer les coordonnées UV au fragment shader :

 
Sélectionnez
#version 330 core 
 
// Données d'entrée du sommet, diffèrent pour chaque exécution de ce shader. 
layout(location = 0) in vec3 vertexPosition_modelspace; 
layout(location = 1) in vec2 vertexUV; 
 
// Données de sortie ; vont être interpolées pour chaque fragment. 
out vec2 UV; 
 
// Valeurs constantes pour l'ensemble du modèle. 
uniform mat4 MVP; 
 
void main(){ 
 
    // Position de sortie du sommet, dans l'espace de découpe : MVP * position
    gl_Position = MVP * vec4(vertexPosition_modelspace,1); 
 
    // Coordonnées UV du sommet. Pas d'espace spécifique de coordonnées pour celles-ci. 
    UV = vertexUV; 
}

Vous souvenez-vous du « layout(location = 1) in vec2 vertexUV » du quatrième tutoriel ? Eh bien, on doit faire exactement la même chose ici, mais au lieu de donner un tampon de triplets (R,G,B), nous allons donner une paire (U,V).

 
Sélectionnez
// Deux coordonnées UV pour chaque sommet. Les coordonnées ont été créées avec Blender. Vous allez bientôt apprendre comment les générer vous-même. 
static const GLfloat g_uv_buffer_data[] = { 
    0.000059f, 1.0f-0.000004f, 
    0.000103f, 1.0f-0.336048f, 
    0.335973f, 1.0f-0.335903f, 
    1.000023f, 1.0f-0.000013f, 
    0.667979f, 1.0f-0.335851f, 
    0.999958f, 1.0f-0.336064f, 
    0.667979f, 1.0f-0.335851f, 
    0.336024f, 1.0f-0.671877f, 
    0.667969f, 1.0f-0.671889f, 
    1.000023f, 1.0f-0.000013f, 
    0.668104f, 1.0f-0.000013f, 
    0.667979f, 1.0f-0.335851f, 
    0.000059f, 1.0f-0.000004f, 
    0.335973f, 1.0f-0.335903f, 
    0.336098f, 1.0f-0.000071f, 
    0.667979f, 1.0f-0.335851f, 
    0.335973f, 1.0f-0.335903f, 
    0.336024f, 1.0f-0.671877f, 
    1.000004f, 1.0f-0.671847f, 
    0.999958f, 1.0f-0.336064f, 
    0.667979f, 1.0f-0.335851f, 
    0.668104f, 1.0f-0.000013f, 
    0.335973f, 1.0f-0.335903f, 
    0.667979f, 1.0f-0.335851f, 
    0.335973f, 1.0f-0.335903f, 
    0.668104f, 1.0f-0.000013f, 
    0.336098f, 1.0f-0.000071f, 
    0.000103f, 1.0f-0.336048f, 
    0.000004f, 1.0f-0.671870f, 
    0.336024f, 1.0f-0.671877f, 
    0.000103f, 1.0f-0.336048f, 
    0.336024f, 1.0f-0.671877f, 
    0.335973f, 1.0f-0.335903f, 
    0.667969f, 1.0f-0.671889f, 
    1.000004f, 1.0f-0.671847f, 
    0.667979f, 1.0f-0.335851f 
};

Les coordonnées UV ci-dessus correspondent à ce modèle :

Coordonnées de texture dans Blender

Le reste est évident. Générer le tampon, le lier, le remplir, le configurer et dessiner le tampon de sommet comme d'habitude. Soyez prudent et utilisez 2 comme second paramètre (la taille) de la fonction glVertexAttribPointer au lieu de 3.

Voici le résultat :

Un cube texturé

et en zoomant un peu :

Zoom sur le cube texturé

V. Que sont le filtrage et les MIP maps et comment les utiliser

Comme vous pouvez le voir dans la capture ci-dessus, la qualité de la texture n'est pas superbe. Cela est dû à ce que l'on a écrit dans la fonction loadBMP_custom :

 
Sélectionnez
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);

Cela signifie que dans notre fragment shader, la fonction texture() prend le texel qui est aux coordonnées (U, V) et continue joyeusement avec :

Schéma du filtrage nearest

Il y a de plusieurs choses que l'on peut faire pour améliorer cela.

V-A. Filtrage linéaire

Avec le filtrage linéaire, texture() regarde aussi les texels autour et mélange les couleurs suivant la distance de chaque centre. Cela évite les bordures nettes vues précédemment.

Schéma filtrage linéaire

C'est beaucoup mieux et c'est beaucoup utilisé, mais si vous voulez une meilleure qualité, vous pouvez aussi utiliser le filtrage anisotrope, qui est un peu plus lent.

V-B. Filtrage anisotrope

Celui-ci se rapproche de la partie de l'image qui est réellement vue dans le fragment. Par exemple, si la texture suivante est vue sur le côté et légèrement tournée, le filtrage anisotrope calculera la couleur contenue dans le rectangle bleu en prenant un nombre fixe d'échantillons (le niveau d'anisotropie) suivant sa direction principale :

Schéma du filtrage anisotrope

V-C. MIP maps

Les filtrages linéaire et anisotrope ont tous les deux un souci. Si la texture est vue de très loin, le mélange de quatre texels ne suffira pas. En fait, si votre modèle 3D est très loin et qu'il ne prend qu'un fragment sur l'écran, TOUS les texels de l'image vont être pris en compte pour calculer la moyenne afin de produire la couleur finale. Évidemment, cela n'est pas fait pour préserver les performances. À la place, on introduit les MIP maps :

Exemple de mipmaps (source Wikipedia)
Exemple de mipmap (source Wikipedia)
  • à la tuile d'initialisation, vous diminuez votre image d'un facteur 2, successivement, jusqu'à atteindre une image 1 x 1 (qui correspond à la moyenne de tous les texels de l'image) ;
  • lorsque vous dessinez un modèle, vous sélectionnez la MIP map la plus appropriée à utiliser suivant la taille à laquelle devrait être le texel ;
  • vous échantillonnez cette MIP map avec l'un des filtrages vus précédemment ;
  • pour une qualité supérieure, vous pouvez aussi échantillonner deux MIP maps et mélanger le résultat.

Heureusement, tout cela est très simple à faire, OpenGL sait le faire si vous lui demandez gentiment :

 
Sélectionnez
// Lorsque l'on agrandit l'image (aucune MIP map plus grande n'est disponible), utiliser le filtrage LINÉAIRE
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 
// Lorsque l'on rétrécit l'image, utiliser un fondu linéaire entre deux MIP maps, chacune étant aussi filtrée linéairement
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); 
// Générer les MIP maps. 
glGenerateMipmap(GL_TEXTURE_2D);

VI. Charger les images avec GLFW

La fonction loadBMP_custom est bien, car on la fait à partir de rien. Mais l'utilisation d'une bibliothèque dédiée est mieux. GLFW peut aussi faire cela (mais uniquement pour les fichiers TGA) :

Le chargement des fichiers TGA dans GLFW est dépréciée depuis la version 3.

 
Sélectionnez
GLuint loadTGA_glfw(const char * imagepath){ 
 
    // Crée une texture OpenGL
    GLuint textureID; 
    glGenTextures(1, &textureID); 
 
    // "Lie" la nouvelle texture créée : tous les appels suivants aux fonctions de texture vont modifier cette texture
    glBindTexture(GL_TEXTURE_2D, textureID); 
 
    // Lit le fichier, appel glTexImage2D avec les bons paramètres
    glfwLoadTexture2D(imagepath, 0); 
 
    // Joli filtrage trilinéaire. 
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); 
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); 
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); 
    glGenerateMipmap(GL_TEXTURE_2D); 
 
    // Retourne l'identifiant de la texture que l'on vient de créer
    return textureID; 
}

VII. Les textures compressées

À ce point, vous vous demandez probablement comment charger les fichiers JPEG à la place des TGA.

Réponse courte : ne le faites pas, il y a de meilleures options.

VII-A. Créer une texture compressée

  • Téléchargez l'outil d'ATI : The Compressonator.
  • Chargez une texture en puissance de deux avec.
  • Compressez-la en DXT1, DXT3 ou DXT5 (plus d'informations sur les différences des formats sur Wikipedia) :
    Outil ATI : Compressonator
  • Générez les MIP maps afin de ne pas avoir à le faire à l'exécution.
  • Exportez le résultat dans un fichier .DDS.

À ce moment, votre image est compressée dans un format qui est directement compatible avec le GPU. Pour n'importe quel appel à texture() dans un shader, le GPU décompressera la texture à la volée. Cela peut sembler lent, mais comme cela prend TELLEMENT moins de mémoire, moins de données ont besoin d'être transférées, sachant que les transferts mémoire sont lents, et que la décompression de texture est gratuite (il y a des puces dédiées à cela). Généralement, l'utilisation de la compression de texture augmente les performances de 20 %.

VII-B. Utiliser la texture compressée

Voici comment charger l'image. C'est très proche du code pour le BMP, sauf que l'en-tête est organisé différemment :

 
Sélectionnez
GLuint loadDDS(const char * imagepath){ 
 
    unsigned char header[124]; 
 
    FILE *fp; 
 
    /* essaie d'ouvrir le fichier */ 
    fp = fopen(imagepath, "rb"); 
    if (fp == NULL) 
        return 0; 
 
    /* vérifie le type du fichier */ 
    char filecode[4]; 
    fread(filecode, 1, 4, fp); 
    if (strncmp(filecode, "DDS ", 4) != 0) { 
        fclose(fp); 
        return 0; 
    } 
 
    /* récupère la description de la surface */ 
    fread(&header, 124, 1, fp); 
 
    unsigned int height      = *(unsigned int*)&(header[8 ]); 
    unsigned int width         = *(unsigned int*)&(header[12]); 
    unsigned int linearSize     = *(unsigned int*)&(header[16]); 
    unsigned int mipMapCount = *(unsigned int*)&(header[24]); 
    unsigned int fourCC      = *(unsigned int*)&(header[80]);

Les données de l'image se trouvent après l'en-tête : tous les niveaux de MIP maps, les uns après les autres. On peut les lire en une fois :

 
Sélectionnez
    unsigned char * buffer; 
    unsigned int bufsize; 
    /* quelle va être la taille des données incluant les MIP maps ? */ 
    bufsize = mipMapCount > 1 ? linearSize * 2 : linearSize; 
    buffer = (unsigned char*)malloc(bufsize * sizeof(unsigned char)); 
    fread(buffer, 1, bufsize, fp); 
    /* fermer le pointeur de fichier */ 
    fclose(fp);

Ici, on doit gérer trois formats différents : DXT1, DXT3 et DXT5. On doit convertir l'indicateur « fourCC » en une valeur que comprend OpenGL.

 
Sélectionnez
    unsigned int components  = (fourCC == FOURCC_DXT1) ? 3 : 4; 
    unsigned int format; 
    switch(fourCC) 
    { 
    case FOURCC_DXT1: 
        format = GL_COMPRESSED_RGBA_S3TC_DXT1_EXT; 
        break; 
    case FOURCC_DXT3: 
        format = GL_COMPRESSED_RGBA_S3TC_DXT3_EXT; 
        break; 
    case FOURCC_DXT5: 
        format = GL_COMPRESSED_RGBA_S3TC_DXT5_EXT; 
        break; 
    default: 
        free(buffer); 
        return 0; 
    }

La création de texture est effectuée comme d'habitude :

 
Sélectionnez
    // Crée une texture OpenGL
    GLuint textureID; 
    glGenTextures(1, &textureID); 
 
    // "Lie" la nouvelle texture : tous les futurs appels aux fonctions de texture vont modifier cette texture
    glBindTexture(GL_TEXTURE_2D, textureID);

Et maintenant, on peut remplir chaque MIP map l'une après l'autre :

 
Sélectionnez
    unsigned int blockSize = (format == GL_COMPRESSED_RGBA_S3TC_DXT1_EXT) ? 8 : 16; 
    unsigned int offset = 0; 
 
    /* charge les MIP maps */ 
    for (unsigned int level = 0; level < mipMapCount && (width || height); ++level) 
    { 
        unsigned int size = ((width+3)/4)*((height+3)/4)*blockSize; 
        glCompressedTexImage2D(GL_TEXTURE_2D, level, format, width, height, 
            0, size, buffer + offset); 
 
        offset += size; 
        width  /= 2; 
        height /= 2; 
    } 
    free(buffer); 
 
    return textureID;

VII-C. Inverser les coordonnées UV

La compression DXT vient du monde DirectX, où les coordonnées de texture UV sont inversées par rapport à OpenGL. Donc, si vous utilisez les textures compressées, vous devez utiliser (coord.u, 1.0-coord.v) pour récupérer le texel adéquat. Vous pouvez le faire quand vous le souhaitez : dans votre script d'exportation, dans votre chargeur, dans votre shader…

VIII. Conclusion

Vous venez d'apprendre à créer, charger et utiliser les textures avec OpenGL.

En général, vous devez utiliser uniquement les textures compressées, car elles sont plus petites à stocker, chargées presque instantanément et sont plus rapides à utiliser ; le principal inconvénient est que vous devez convertir vos images avec The Compressonator.

IX. Exercices

  • Le chargeur DDS est implémenté dans le code source, mais pas la modification des coordonnées de texture. Changez le code à l'emplacement adéquat pour afficher le cube correctement.
  • Expérimentez avec les différents formats DDS. Est-ce qu'ils donnent le même résultat ? Des ratios de compression différents ?
  • Essayez de ne pas générer les MIP maps avec The Compressonator. Quel est le résultat ? Donnez trois méthodes différentes pour corriger cela.

X. Références

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 : un cube coloré

 

Sommaire

 

Tutoriel suivant : clavier et souris

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

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.