Navigation▲
Tutoriel précédent : un cube coloré |
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 :
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 :
GLuint loadBMP_custom(const
char
*
imagepath);
Et elle s'utilise comme ça :
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.
// 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 où 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 :
// 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 :
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 :
Donc, on doit vérifier si les deux premiers octets valent vraiment 'B' et 'M' :
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 :
// 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 :
// 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 :
// 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.
// 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 :
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 :
#
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 :
#
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).
// 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.000059
f, 1.0
f-
0.000004
f,
0.000103
f, 1.0
f-
0.336048
f,
0.335973
f, 1.0
f-
0.335903
f,
1.000023
f, 1.0
f-
0.000013
f,
0.667979
f, 1.0
f-
0.335851
f,
0.999958
f, 1.0
f-
0.336064
f,
0.667979
f, 1.0
f-
0.335851
f,
0.336024
f, 1.0
f-
0.671877
f,
0.667969
f, 1.0
f-
0.671889
f,
1.000023
f, 1.0
f-
0.000013
f,
0.668104
f, 1.0
f-
0.000013
f,
0.667979
f, 1.0
f-
0.335851
f,
0.000059
f, 1.0
f-
0.000004
f,
0.335973
f, 1.0
f-
0.335903
f,
0.336098
f, 1.0
f-
0.000071
f,
0.667979
f, 1.0
f-
0.335851
f,
0.335973
f, 1.0
f-
0.335903
f,
0.336024
f, 1.0
f-
0.671877
f,
1.000004
f, 1.0
f-
0.671847
f,
0.999958
f, 1.0
f-
0.336064
f,
0.667979
f, 1.0
f-
0.335851
f,
0.668104
f, 1.0
f-
0.000013
f,
0.335973
f, 1.0
f-
0.335903
f,
0.667979
f, 1.0
f-
0.335851
f,
0.335973
f, 1.0
f-
0.335903
f,
0.668104
f, 1.0
f-
0.000013
f,
0.336098
f, 1.0
f-
0.000071
f,
0.000103
f, 1.0
f-
0.336048
f,
0.000004
f, 1.0
f-
0.671870
f,
0.336024
f, 1.0
f-
0.671877
f,
0.000103
f, 1.0
f-
0.336048
f,
0.336024
f, 1.0
f-
0.671877
f,
0.335973
f, 1.0
f-
0.335903
f,
0.667969
f, 1.0
f-
0.671889
f,
1.000004
f, 1.0
f-
0.671847
f,
0.667979
f, 1.0
f-
0.335851
f
}
;
Les coordonnées UV ci-dessus correspondent à ce modèle :
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 :
et en zoomant un peu :
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 :
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 :
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.
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 :
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 :
- à 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 :
// 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.
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) :
- 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 :
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 :
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.
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 :
// 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 :
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▲
- Using texture compression in OpenGL, Sébastien Domine, NVIDIA
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é |
Tutoriel suivant : clavier et souris |