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.
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 :
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 :
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 :
float
texCoords[] =
{
0.0
f, 0.0
f, // côté bas gache
1.0
f, 0.0
f, // côté bas droit
0.5
f, 1.0
f // 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 :
Chacune de ces options peut être choisie pour chacun des axes de coordonnées (s, t (r si texture 3D)), avec la fonction glTexParameter* :
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 :
float
borderColor[] =
{
1.0
f, 1.0
f, 0.0
f, 1.0
f }
;
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 :
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 :
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) :
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() :
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 :
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 :
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++ :
#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() :
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 :
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 :
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() :
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 :
stbi_image_free(data);
Le code complet pour générer une texture va donc ressembler à ceci :
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 :
float
vertices[] =
{
// positions // colors // texture coords
0.5
f, 0.5
f, 0.0
f, 1.0
f, 0.0
f, 0.0
f, 1.0
f, 1.0
f, // top right
0.5
f, -
0.5
f, 0.0
f, 0.0
f, 1.0
f, 0.0
f, 1.0
f, 0.0
f, // bottom right
-
0.5
f, -
0.5
f, 0.0
f, 0.0
f, 0.0
f, 1.0
f, 0.0
f, 0.0
f, // bottom left
-
0.5
f, 0.5
f, 0.0
f, 1.0
f, 1.0
f, 0.0
f, 0.0
f, 1.0
f // top left
}
;
Il faut ensuite mettre à jour le format des sommets :
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 :
#
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.
#
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 :
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 :
En cas de problème, vous pouvez consulter le code : source code.
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 :
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 :
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() :
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().
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 :
#
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é :
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 :
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 :
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 :
stbi_set_flip_vertically_on_load(true
);
Le résultat est alors le suivant :
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.