Apprendre OpenGL moderne


précédentsommairesuivant

8. Textures

Nous avons vu comment utiliser la couleur pour chaque sommet, cependant pour des applications plus réalistes, nous aurons à traiter de nombreux sommets et de nombreuses couleurs, ce qui deviendra vite ingérable dans une scène réelle.

Les artistes et les programmeurs utilisent en général ce qu’on appelle une texture. Il s’agit d’une image 2D (les textures 1D et 3D existent aussi) que l’on peut voir comme un papier peint, par exemple l’image d’une brique, qui sera plaquée sur le modèle 3D d’une maison, ce qui donnera l’image d’un mur en briques. Une image permet de représenter des choses très variées et détaillées, ce qui donne l’illusion que l’objet 3D est lui-même très détaillé, sans pour autant ajouter des sommets.

Image non disponible

Les textures peuvent aussi être utilisées pour archiver de grandes quantités de données à transmettre aux shaders, mais nous verrons cela plus loin.

Ci-dessous, l’image d’un mur de briques projetée (map) sur notre triangle :

Image non disponible

Pour appliquer une texture au triangle, nous devons préciser à quel sommet du triangle correspond quelle partie de la texture. Chaque sommet doit avoir une coordonnée de texture associée qui spécifie quel point de la texture lui correspond, l’interpolation entre fragments fera le reste pour les autres fragments.

Les coordonnées de texture sont dans l’intervalle [0.0, 1.0] sur les axes x et y (pour une texture en 2D). Retrouver la couleur de la texture en utilisant les coordonnées texture s’appelle l’échantillonnage (sampling). (0, 0) est le point en bas à gauche tandis que (1, 1) est en haut à droite de l’image, comme le montre l’image ci-dessous :

Image non disponible

Le coin en bas à gauche du triangle correspondra au point en bas à gauche de la texture ; de la même façon pour le point en bas à droite. Le point en haut du triangle correspond au point (0.5, 1.0) de la texture. Nous passerons ces points au vertex shader, qui les passera au fragment shader qui interpolera ensuite les coordonnées pour tous les fragments.

Les coordonnées de la texture auront donc cette forme :

 
Sélectionnez
float texCoords[] = {
    0.0f, 0.0f,  // côté bas gache
    1.0f, 0.0f,  // côté bas droit
    0.5f, 1.0f   // côté haut
};

L’échantillonnage (sampling) peut se faire de différentes façons, il faudra donc préciser à OpenGL comment le faire.

8-1. Texture Wrapping

Que se passe-t-il si on donne des coordonnées de texture en dehors de l’intervalle [0, 1] ? le comportement par défaut d’OpenGL est de répéter les images de texture (on ignore la partie entière des réels représentant les coordonnées de la texture), mais d’autres options sont possibles :

  • GL_REPEAT: par défaut, répétition de l’image.
  • GL_MIRRORED_REPEAT: pareil que GL_REPEAT mais l’image est inversée à chaque répétition.
  • GL_CLAMP_TO_EDGE: limite les coordonnées entre 0 et 1. les coordonnées plus grandes seront limitées aux bords, résultant en une forme étendue jusqu’aux bords.
  • GL_CLAMP_TO_BORDER: les coordonnées en dehors de l’intervalle seront remplies avec une couleur spécifiée par l’utilisateur.

On peut voir sur les images ci-dessous le résultat de chacune de ces options :

Image non disponible

Chacune de ces options peut être choisie pour chacun des axes de coordonnées (s, t (r si texture 3D)), avec la fonction glTexParameter* :

 
Sélectionnez
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);

Le premier argument spécifie la cible, ici GL_TEXTURE_2D. Le second argument spécifie l’option et l’axe à traiter (ici WRAP pour s et t). Le dernier argument donne le mode à utiliser, ici GL_MIRRORED_REPEAT.

Si on choisit l’option GL_CLAMP_TO_BORDER, il faudra indiquer aussi une couleur, en utilisant la version fv (float vector) de la fonction glTexParameter(). Nous passerons un vecteur de réels pour indiquer la couleur :

 
Sélectionnez
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

8-2. Interpolation de texture (texture filtering)

Les coordonnées de texture ne dépendent pas de la résolution, mais peuvent prendre toute valeur réelle, OpenGL doit donc décider quel pixel de la texture (appelé texel) doit être projeté sur le point considéré. Cela devient essentiel si vous avez un grand objet et une texture de faible résolution. OpenGL offre aussi des options pour cela. Nous présenterons les plus importantes : GL_NEAREST et GL_LINEAR.

GL_NEAREST (aussi appelée nearest neighbour filtering) est la méthode par défaut. Dans ce cas OpenGL choisit le pixel dont le centre est le plus proche de la coordonnée de texture. Dans l’exemple ci-dessous, la croix représente la coordonnée de texture ; le texel en haut à gauche est le plus proche de la croix, il est donc utilisé comme couleur :

Image non disponible

GL_LINEAR (aussi appelée interpolation bilinéaire, bilinear filtering) choisit une valeur résultant de l’interpolation des texels voisins, donnant une couleur approchante. La distance des texels voisins donne le poids correspondant de la couleur dans le résultat final :

Image non disponible

Quel est le résultat visuel de l’interpolation de texture ? Voyons cela avec une texture de faible résolution sur un grand objet (la texture est étirée et les texels sont visibles) :

Image non disponible

GL_NEAREST donne un résultat où l’on peut clairement distinguer les pixels de la texture, tandis que GL_LINEAR produit une image plus réaliste, mais certains développeurs préféreront un aspect 8-bits et choisiront l’option GL_NEAREST.

L’interpolation de texture peut être choisie pour les opérations de grossissement (magnifying) ou de réduction (minifying), indépendamment l’une de l’autre. Il faut spécifier l’option pour chacune des deux opérations, avec glTexParameter() :

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

8-2-1. Mipmaps

Supposons que nous ayons une grande scène avec des milliers d’objets, chacun ayant une texture. Certains objets seront loin et auront une texture de grande résolution comme les objets qui sont au premier plan. Ces objets éloignés ne sont composés que de peu de fragments et OpenGL aura du mal à déterminer la bonne couleur pour ces fragments à partir d’une texture haute résolution. Cela produira des défauts visuels sur les petits objets, sans compter le gaspillage de la mémoire lié à une haute résolution inutile.

Pour traiter ce problème, OpenGL propose le concept des mipmaps : un ensemble de textures dans lequel chaque texture est deux fois plus petite que la précédente. Si l’objet se trouve plus loin qu’un certain seuil, OpenGL utilisera une texture plus petite, adaptée à l’éloignement de l’objet, sans que cela ne soit perceptible par le spectateur. On améliore aussi les performances. Voyons une texture mipmap :

Image non disponible

Créer un ensemble de textures mipmap est fastidieux, mais OpenGL se charge de ce travail au moyen de l’appel glGenerateMipmaps() à effectuer après la création de la texture. Nous verrons cela plus loin dans le tutoriel.

Lorsque l’on change de niveaux de mipmaps pendant le rendu, OpenGL affiche certains effets gênants comme des bords tranchants entre deux niveaux. Complétant l’interpolation de texture, il est aussi possible de choisir la méthode d’interpolation entre les niveaux de mipmap :

  • GL_NEAREST_MIPMAP_NEAREST: choisit la mipmap la plus proche pour la taille du pixel et utilise le voisin le plus proche pour la couleur du texel.
  • GL_LINEAR_MIPMAP_NEAREST: choisit la mipmap la plus proche pour la taille du pixel et utilise un filtrage bilinéaire pour la couleur.
  • GL_NEAREST_MIPMAP_LINEAR: interpolation linéaire entre les niveaux de mipmap et utilise le voisin le plus proche pour la couleur du texel.
  • GL_LINEAR_MIPMAP_LINEAR: interpolation linéaire entre les niveaux de mipmap et utilise un filtrage bilinéaire pour la couleur.

Pour choisir la méthode d’interpolation, on a le choix entre les quatre méthodes, avec le suffixe i :

 
Sélectionnez
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

Une erreur courante est de choisir une de ces options d’interpolation pour le grossissement des textures. Cela n’a pas d’effet puisque les mipmaps sont utiles lorsque les textures sont réduites en taille. La mise à l’échelle des textures n’utilise pas les mipmaps et fournir une option de mipmap donnera un code d’erreur OpenGL GL_INVALID_ENUM.

8-3. Charger et créer les textures

La première chose à faire pour utiliser une texture est de la charger dans l’application. Les images peuvent être mémorisées dans de nombreux formats, chacun ayant sa propre structure. Une solution serait de choisir un format comme .png et de convertir notre image en un grand tableau d’octets. Il faudrait alors écrire un module de chargement pour chaque format.

La meilleure solution est plutôt d’utiliser une bibliothèque de chargement qui supporte la plupart des formats, comme stb_image.h.

8-3-1. stb_image.h

stb_image.h est un fichier en-tête très populaire, écrit par Sean Barrett. Ce fichier fait le travail et est facile à intégrer dans vos projets : on le trouve ici. Téléchargez ce fichier et ajoutez-le à vos projets. Puis ajoutez le code suivant dans un nouveau fichier C++ :

 
Sélectionnez
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

Avec la définition de STB_IMAGE_IMPLEMENTATION, le préprocesseur modifie le fichier d’en-tête de façon à ce qu’il ne contienne que le code source approprié, modifiant le fichier d’en-tête en fichier .cpp. Il suffit ensuite d’inclure stb_image.h dans votre programme et de compiler.

Dans la suite, nous utilisons l’image d’un container en bois. Pour charger l’image, nous utilisons la fonction stbi_load() :

 
Sélectionnez
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);

On précise le chemin d’accès au fichier image, la fonction renvoyant trois entiers : la largeur de l’image, la hauteur et le nombre de canaux de couleur.

8-3-2. Générer une texture

Comme tout objet dans OpenGL, une texture est accessible avec un identifiant :

 
Sélectionnez
unsigned int texture;
glGenTextures(1, &texture);

Cette fonction demande de préciser le nombre de textures à créer et archive leurs identifiants dans un tableau d’unsigned int. Comme nous l’avons déjà vu, nous devons attacher la texture et ainsi les commandes suivantes seront exécutées sur cette texture :

 
Sélectionnez
glBindTexture(GL_TEXTURE_2D, texture);

Lorsqu’une texture est attachée, nous pouvons initialiser cette texture avec l’image chargée, au moyen de la fonction glTexImage2D() :

 
Sélectionnez
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);

Examinons les paramètres de cette fonction :

  • Le premier argument spécifie la cible : GL_TEXTURE_2D implique que la texture visée sera la texture attachée à la cible 2D (les textures ayant pour cible GL_TEXTURE_1D ou GL_TEXTURE_3D ne seront pas affectées).
  • Le second argument spécifie le niveau de mipmap pour lequel on souhaite créer la texture si l’on voulait établir chaque niveau de mipmap manuellement, mais ici nous laissons cela au niveau de base, soit 0.
  • Le troisième argument précise le format d’archivage de la texture. Notre image n’a que des valeurs RGB, nous choisissons donc GL_RGB.
  • Ensuite, nous passons la largeur et hauteur de l’image.
  • L’argument suivant est toujours 0.
  • Les deux arguments suivants spécifient le format et le type de données pour l’image source.
  • Le dernier argument pointe sur les données effectives de l’image.

Après cet appel, la texture en cours possède une image qui lui est liée. Cependant, seul le niveau de base de l’image lui est attaché, et si nous voulons utiliser les mipmaps, nous devons spécifier manuellement les images, mais il est plus simple de générer automatiquement tous les mipmaps requis, avec glGenerateMipmap().

Après cela, il est bien vu de libérer l’espace mémoire contenant l’image :

 
Sélectionnez
stbi_image_free(data);

Le code complet pour générer une texture va donc ressembler à ceci :

 
Sélectionnez
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
// définit les options de la texture actuellement liée
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_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// charge et génère la texture
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
if (data)
{
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
    glGenerateMipmap(GL_TEXTURE_2D);
}
else
{
    std::cout << "Failed to load texture" << std::endl;
}
stbi_image_free(data);

8-4. Appliquer les textures

Pour la suite, nous utiliserons le rectangle construit à la fin du chapitre Hello Triangle. Nous devons informer OpenGL sur la manière d’échantillonner la texture, pour cela nous ajoutons les coordonnées de la texture aux données de sommets :

 
Sélectionnez
float vertices[] = {
    // positions          // colors           // texture coords
     0.5f,  0.5f, 0.0f,   1.0f, 0.0f, 0.0f,   1.0f, 1.0f,   // top right
     0.5f, -0.5f, 0.0f,   0.0f, 1.0f, 0.0f,   1.0f, 0.0f,   // bottom right
    -0.5f, -0.5f, 0.0f,   0.0f, 0.0f, 1.0f,   0.0f, 0.0f,   // bottom left
    -0.5f,  0.5f, 0.0f,   1.0f, 1.0f, 0.0f,   0.0f, 1.0f    // top left 
};

Il faut ensuite mettre à jour le format des sommets :

Image d'un VBO avec les données de position, couleur et texture entremêlées
 
Sélectionnez
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);

Notons qu’il faut ajuster le stride à la valeur 8*sizeof(float) pour les deux autres attributs (position et couleur).

Il faut ensuite modifier le vertex shader pour prendre en compte les coordonnées de texture comme attributs de sommets et passer ces coordonnées au fragment shader :

 
Sélectionnez
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aTexCoord;

out vec3 ourColor;
out vec2 TexCoord;

void main()
{
    gl_Position = vec4(aPos, 1.0);
    ourColor = aColor;
    TexCoord = aTexCoord;
}

Le fragment shader prendra la variable de sortie TexCoord en entrée.

Le fragment shader doit aussi pouvoir accéder à l’objet texture, mais comment lui passer cet objet ? GLSL possède un type de donnée pour les textures appelé sampler (échantillonneur), précisant en suffixe de quel type de texture il s’agit : sampler1D, sampler3D ou comme ici sampler2D. On peut ainsi ajouter une texture au fragment shader en déclarant simplement une variable uniforme sampler2D et nous y placerons ensuite notre texture.

 
Sélectionnez
#version 330 core
out vec4 FragColor;
  
in vec3 ourColor;
in vec2 TexCoord;

uniform sampler2D ourTexture;

void main()
{
    FragColor = texture(ourTexture, TexCoord);
}

Pour échantillonner la couleur de la texture, nous utilisons la fonction GLSL texture() qui prend en premier argument un sampler et en second argument les coordonnées. La sortie du fragment shader sera ensuite déterminée par la texture.

Il reste à attacher la texture avant d’appeler glDrawElements(), cela assignera automatiquement la texture au sampler du fragment shader :

 
Sélectionnez
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

Si tout a été fait correctement, voilà le résultat :

Image non disponible

En cas de problème, vous pouvez consulter le code : source code.

Image non disponible

Si votre programme ne fonctionne pas ou affiche un objet noir, continuez votre lecture et travaillez le dernier exemple qui doit fonctionner. Avec certains pilotes graphiques, il est nécessaire d’utiliser une unité de texture pour chaque variable uniforme de type sampler, ce que nous allons voir maintenant.

Pour obtenir une image plus folklo, on peut mixer la texture avec les couleurs des sommets. On multiplie simplement la couleur de la texture avec la couleur du sommet dans le fragment shader :

 
Sélectionnez
FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);

Le résultat devrait être un mélange entre la couleur du sommet et la couleur de la texture :

Image non disponible

8-5. Unités de texture

On peut se demander pourquoi la variable sampler2D est une variable uniforme, et que l’on ne lui a pas assigné de valeur. En utilisant glUniform1i(), on peut assigner un emplacement (location) au sampler pour utiliser plusieurs textures à la fois dans un fragment shader. L’emplacement d’une texture est appelé unité de texture (texture unit). L’unité par défaut est 0, qui est l’unité active par défaut, ce qui explique que nous ne l’avons pas spécifiée jusqu’ici ; tous les pilotes n’assignent pas de valeur par défaut, ce qui peut entraîner des dysfonctionnements si on ne traite pas ce point.

Le but principal des unités de texture est de permettre l’utilisation de plusieurs textures dans les shaders. En assignant une unité aux samplers, on peut attacher plusieurs textures à la fois, du moment que l’on active l’unité de texture correspondante juste avant. Il suffit de passer l’unité en paramètre de glActiveTexture() :

 
Sélectionnez
glActiveTexture(GL_TEXTURE0); // active l’unité de texture avant la liaison de la texture
glBindTexture(GL_TEXTURE_2D, texture);

Après avoir activé l’unité de texture, nous attachons la texture voulue à l’unité courante de texture. L’unité GL_TEXTURE0 est toujours activée par défaut, nous n’avions donc pas à le faire dans le code précédent avant d’utiliser glBindTexture().

Image non disponible

OpenGL permet de disposer d’un minimum de 16 unités de texture, que l’on peut activer en utilisant GL_TEXTURE0 jusqu’à GL_TEXTURE15. On peut accéder à ces objets par une opération d’addition : GL_TEXTURE8 est identique à GL_TEXTURE0 + 8 par exemple, ce qui est utile en cas d’itération.

Il faut cependant éditer le fragment shader pour accepter un autre sampler, ce qui assez direct :

 
Sélectionnez
#version 330 core
...

uniform sampler2D texture1;
uniform sampler2D texture2;

void main()
{
    FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);
}

La couleur finale sera ici un mélange de deux textures. La fonction mix de GLSL prend deux valeurs en entrée et interpole linéairement entre elles en fonction du troisième argument, qui indique le poids relatif de la seconde texture. 0.0 ne conserve que la première texture, 0.2 donnerait 80 % pour la première et 20 % pour la seconde.

Nous voulons charger et créer une deuxième texture ; nous utilisons ici une image de votre expression faciale lorsque vous apprenez OpenGL.

Pour utiliser cette nouvelle texture avec la première, nous devons modifier un peu la procédure en attachant les deux textures à leur unité :

 
Sélectionnez
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);

glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

Nous devons également dire à OpenGL à quelle unité de texture appartient chaque sampler, au moyen de glUniform1i(). Il suffit de faire cela une seule fois, on peut donc le faire avant la boucle de rendu :

 
Sélectionnez
ourShader.use(); // n’oubliez pas d’activer le shader avant de définir les variables uniformes
glUniform1i(glGetUniformLocation(ourShader.ID, "texture1"), 0); // définition manuelle
ourShader.setInt("texture2", 1); // ou avec la classe de shader
  
while(...) 
{
    [...]
}

Le résultat est le suivant :

Image non disponible

Vous avez remarqué que la texture est affichée à l’envers. Cela est dû à ce qu’OpenGL positionne le sens de l’axe Y vers le haut alors que les images sont codées avec le sens de l’axe Y vers le bas. stb_image.h permet d’inverser le sens de l’axe Y pendant le chargement de l’image, il faut lui préciser de cette façon avant le chargement :

 
Sélectionnez
stbi_set_flip_vertically_on_load(true);

Le résultat est alors le suivant :

Image non disponible

En cas de problème le code est ici : source.

8-6. Exercices

Pour mieux comprendre les textures, nous vous conseillons ces exercices :

  • Faire en sorte que seule l’image « happy face » soit inversée en modifiant le fragment shader : solution.
  • Essayer les différentes méthodes de wrapping en spécifiant les coordonnées de texture dans l’intervalle [0.0, 2.0]. Essayer d’afficher quatre smileys sur un seul container limité à son bord : solution, resultat. Essayer d’autres méthodes de wrapping.
  • Essayer d’afficher seulement les pixels du centre de l’image sur le rectangle de façon à ce que les pixels deviennent visibles, cela en changeant les coordonnées de texture. Essayer la méthode d’échantillonnage GL_NEAREST pour mieux voir les pixels : solution.
  • Utiliser une variable uniforme comme troisième paramètre de la fonction mix, pour faire varier le poids relatif de chaque texture. Utiliser les touches haut et bas pour afficher plus ou moins le smiley : solution.

8-7. Remerciements

Ce tutoriel est une traduction réalisée par Jean-Michel Fray dont l’original a été écrit par Joey de Vries et qui est disponible sur le site Learn OpenGL.


précédentsommairesuivant

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 © 2018 Joey de Vries. Aucune reproduction, même partielle, ne peut être faite de ce site ni 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.