OpenGL : les textures

Introduction

Autant vous le dire tout de suite : le texturing est un sujet tellement vaste qu'il ne suffirait pas d'un seul tutorial pour en faire le tour, ni même 3 ou 4. Les possibilités qu'offrent OpenGL sont tellement vastes dans ce domaine que les applications de cet outils sont quasi infinies. Donc là, on va juste voir les bases (ce qui vous permettra quand même de faire des trucs bien sympa), c'est-à-dire comment déclarer une texture à OpenGL, l'appliquer sur un polygone grâce aux TexCoord, et aussi quelques outils qui vont avec tel que le filtering, la répétition... Ca en fait déjà pas mal je pense, et pour ce qui est du mipmapping, de la transparence ou des textures d'environnement, on verra ça plus tard, OK ?

 

1. C'est parti

Commencons par le commencement, c'est-à-dire par activer la gestion des textures. Si vous avez bien tout pigé, vous aurez deviné qu'il suffit de faire un petit glEnable() avec le paramètre approprié. Vous avez gagné ! Rajoutez donc dans notre petite InitGL() la ligne suivante :

glEnable(GL_TEXTURE_2D);

Ca active la gestion des textures 2D (ben ouais, ca existe les textures 1D ou 3D). Nous devons maintenant créer un nouvel objet texture. Pour nous, ce sera juste un numéro, mais c'est ce numéro qui nous permettra de spécifier à OpenGL toutes les paramètre de la texture, la texture elle-même, et aussi de lui dire quelle texture on veut mapper. Pour cela il faut utiliser glGenTextures(), qui nous renvoie autant de numéros que de textures demandées. Ici on veut qu'une seule texture, donc on va récupérer un numéro, à bien retenir.
Ensuite, nous décidons quelle est la texture courante grâce à glBindTexture(). La texture courante, c'est celle sur laquelle OpenGL va effectuer les modifs lorsqu'on lui demandera, et c'est aussi la texture qiu sera mappée sur les objets. Donc si vous avez des objets avec des textures différentes, il suffira d'appeler glBindTexture() avec le bon numéro de texture juste avant de dessiner l'objet sur lequel devra être mappé la texture.
Et enfin, nous allons transmettre à OpenGL toutes les caractéristiques de la texture : largeur, hauteur, format, etc... et bien sûr l'image elle-même, grâce à glTexImage2D(). Pour l'instant, pour simplifier les choses, j'ai choisi d'appliquer à notre objet une texture de 2 pixels par 2 pixels, en forme d'échiquier (et ouais, je me suis pas foulé). Voilà donc tout ce que nous devons écrire :

GLubyte Texture[16] =
{
0,0,0,0, 0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF, 0,0,0,0
};

//Image (2x2)
GLuint Nom;
void InitGL()
{
glClearColor(.5,.5,.5,0); //Change la couleur du fond
glEnable(GL_DEPTH_TEST); //Active le depth test
glEnable(GL_TEXTURE_2D); //Active le texturing
glGenTextures(1,&Nom); //Génère un n° de texture
glBindTexture(GL_TEXTURE_2D,Nom); //Sélectionne ce n°
glTexImage2D (
GL_TEXTURE_2D, //Type : texture 2D
0, //Mipmap : aucun
4, //Couleurs : 4
2, //Largeur : 2
2, //Hauteur : 2
0, //Largeur du bord : 0
GL_RGBA, //Format : RGBA
GL_UNSIGNED_BYTE, //Type des couleurs
Texture //Addresse de l'image
);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
}

Voilà, j'ai fait ce que j'avais annoncé : J'appelle glGenTextures() avec le nombre de textures voulues et le tableau dans lequel il doit les renvoyer (ici il n'y en a qu'une donc autant mettre &Nom). J'appelle ensuite glBindTexture() pour lui dire avec quelle texture je vais travailler. Et enfin, glTexImage2D(), pour lui balancer toutes les infos dont il a besoin. Les commentaire vous disent déjà à quoi correspondent les paramètres, mais je vais quand même insister sur 2 ou 3 choses :

  • Pour ce qui est du mipmap, j'ai dit qu'on verra ca plus tard, donc je n'en parlerais pas. Contentez-vous de mettre ce paramètre à 0
  • Le 3e paramètre c'est le nombre de composantes de couleurs par pixel. Ici il y en a 4 : R,G, B et A (pour Rouge, Vert, Bleu et Alpha, pour ceux qu'auraient pas encore tout pigé). On peux en avoir de 1 à 4.
  • Ensuite viennent la largeur et la hauteur. Elles ne sont pas forcément égales, mais doivent être impérativement de la forme 2^n. Si une de ces valeurs n'est pas une puissance de 2, ca ne marchera pas. Si vous êtes obligé de bosser sur des textures batardes, utilisez gluScaleImage() pour les mettre à l'échelle (voyez avec votre compilateur ou le Reference Manual pour en savoir plus sur cette fonction).
  • Le paramètre suivant est la largeur du bord de la texture. En effet, on peut rajouter une bordure d'un pixel autour de l'image, et d'une couleur unique. Cela peut servir pour éviter d'avoir des résultat bizarre sur les bords avec le filtering, mais on ne l'utilise pas souvent.
  • Vient ensuite le format de stockage. Ici, j'ai rangé les octets de ma texture dans le format classique RGBA, donc je dis à OpenGL de les lire suivant le format RGBA. On peut mettre GL_COLOR_INDEX, GL_RED, GL_GREEN, GL_BLUE, GL_ALPHA, GL_RGB, GL_RGBA, GL_BGR_EXT, GL_BGRA_EXT, GL_LUMINANCE, et GL_LUMINANCE_ALPHA. Amusez-vous à changer ce paramètre pour voir ce que ca donne.
  • Le 8e paramètre définit sous quel type sont enregistrées les composantes. Ici, ma texture est un tableau de GLubyte (c'est-à-dire unsigned char), donc je dit à OpenGL de les lire en tant que GLubyte. Ce paramètre peut être GL_UNSIGNED_BYTE, GL_BYTE, GL_BITMAP, GL_UNSIGNED_SHORT, GL_SHORT, GL_UNSIGNED_INT, GL_INT, ou GL_FLOAT.
  • Et enfin, on donne à OpenGL l'endroit où est stockée l'image, histoire qu'il puisse la mapper.

Remarque : Vous vous êtes peut-être demandé pourquoi j'utilise une texture RGBA et pas RGB, alors qu'on a même pas envore vu ce qu'était la composante A. En fait, je fais ça pour deux raisons : d'abord, comme on bosse sur des processeurs 32 bits, la gestion de la mémoire est optimisée pour les types 32 bits. De plus, si vous essayez de mettre une texture RGB à la place, il y de fortes chances pour que le résultat ne soit pas celui que vous attendez. En effet, par défaut, OpenGL considère que chaque pixel occupe 4 octets. Donc il va lire les pixels de l'image 4 par 4, ce qui n'est pas vraiment ce qu'on veut. Pour résoudre ce problème, il faut appeler glPixelStorei(GL_UNPACK_ALIGNMENT,1), mais je n'en dirais pas plus, car si on veut quelque chose d'optimisé et de compatible, on utilise des textures RGBA.

Je vous entends déjà dire "Et les deux dernières lignes ?". Pour l'instant je n'en parle pas, contentez-vous de les recopier bêtement, on verra ça plus tard.

 

2. Coordonnées de textures (TexCoord)

Et ben ça y est, mine de rien, notre texture est chargée et prète à être apliquée : on n'a plus qu'à dessiner un truc sur lequel l'appliquer. Pourquoi pas un joli p'tit cube ? Et ben allons-y alors !

void Draw()
{
glClear( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT ); //Efface le framebuffer et le depthbuffer
glMatrixMode(GL_MODELVIEW); //Un petit gluLookAt()...
glLoadIdentity();
gluLookAt(3,2,3,0,0,0,0,1,0);
glBegin(GL_QUADS); //Et c'est parti pour le cube !

glTexCoord2i(0,0);glVertex3i(-1,-1,-1);
glTexCoord2i(1,0);glVertex3i(+1,-1,-1);
glTexCoord2i(1,1);glVertex3i(+1,+1,-1);
glTexCoord2i(0,1);glVertex3i(-1,+1,-1);

//1 face

glTexCoord2i(0,0);glVertex3i(-1,-1,+1);
glTexCoord2i(1,0);glVertex3i(+1,-1,+1);
glTexCoord2i(1,1);glVertex3i(+1,+1,+1);
glTexCoord2i(0,1);glVertex3i(-1,+1,+1);

//2 faces

glTexCoord2i(0,0);glVertex3i(+1,-1,-1);
glTexCoord2i(1,0);glVertex3i(+1,-1,+1);
glTexCoord2i(1,1);glVertex3i(+1,+1,+1);
glTexCoord2i(0,1);glVertex3i(+1,+1,-1);

//3 faces

glTexCoord2i(0,0);glVertex3i(-1,-1,-1);
glTexCoord2i(1,0);glVertex3i(-1,-1,+1);
glTexCoord2i(1,1);glVertex3i(-1,+1,+1);
glTexCoord2i(0,1);glVertex3i(-1,+1,-1);

//4 faces

glTexCoord2i(1,0);glVertex3i(-1,+1,-1);
glTexCoord2i(1,1);glVertex3i(+1,+1,-1);
glTexCoord2i(0,1);glVertex3i(+1,+1,+1);
glTexCoord2i(0,0);glVertex3i(-1,+1,+1);

//5 faces

glTexCoord2i(1,0);glVertex3i(-1,-1,+1);
glTexCoord2i(1,1);glVertex3i(+1,-1,+1);
glTexCoord2i(0,1);glVertex3i(+1,-1,-1);
glTexCoord2i(0,0);glVertex3i(-1,-1,-1);

//6 faces
glEnd();
SwapBuffers(DC); //glutSwapBuffers(); pour GLUT
glutPostRedisplay(); //Uniquement pour GLUT
}

Et voilà. Si vous lancez le programme maintenant, ca va donner une magnifique image de cube mappé avec une texture d'échiquier, comme ça :

Euh, bah non en fait elle est pas si magnifique que ca l'image : y s'passe des trucs bizarres. En fait, c'est parce que OpenGL y va un peu trop à la barbare pour appliquer les textures. En effet, si on réfléchit bien, on se rend compte que pour que l'effet de mapping soit réaliste, il faut tenir compte de la profondeur. Prenons l'exemple d'un triangle dont un des sommets est très éloigné de l'utilisateur par rapport aux deux autres, comme ça :
Là la texture est correctement appliquée, et on a bien l'impression que le triangle est aligné sur l'axe Z.

Et bien si on ne tient pas compte de la profondeur, la texture est appliquée comme si le triangle était face à nous, comme OpenGL le fait actuellement pour notre cube, et ca donnerait ça :
Et là, c'est carrément pas ce qu'on veut. Donc il faut dire à OpenGL d'effectuer une correction de perspective sur les textures (et par la même occasion sur les couleurs). Pour cela, rajoutez dans InitGL() la ligne suivante :

glHint(GL_PERSPECTIVE_CORRECTION_HINT,GL_NICEST);

Et maintenant, voilà la magnifique image qu'on obtient. C'est quand même un petit peu mieux, non ?
Bon maintenant voyons un peu ce que notre fonction a dans la bide : ici on découvre une nouvelle fonction : glTexCoord{1234}{sifd}[v](). C'est elle qui nous permet de dire à OpenGL quel morceau de la texture est attaché à quel sommet. Ici on a une texture 2 dimensions, donc on utilise glTexCoord2*(). On lui passe en paramètre 2 valeurs, qui sont les coordonnées du point de la texture à attacher au sommet qui va suivre. Le point (0,0) correspond au coin supérieur gauche de la texture, et (1,1) au coin inférieur droit. Mais on peut aussi choisir n'importe quel point de la texture (ou texel) entre 0 et 1. Par exemple si on avait voulu mettre que du blanc, il aurait suffit de mapper la partie supérieure gauche de la texture, donc de remplacer les 1 par des 0.5 (et aussi d'utiliser glTexCoord2f(), parce que sinon les TexCoord auraient été transformées en int).

 

3. Filtering

Imaginez que vous êtes dans un simulateur de vol dernier cri : si vous regardez le sol, et même si vous êtes très près, normalement vous ne disinguez pas les pixels qui composent la texture. Alors que si vous jouez à un vieux je du genre de Duke Nukem ou Quake, si vous vous collez à un mur vous verrez distinctement les pixels de la texture, comme dans notre exemple. Mais dans Quake 3, ou dans un simulateur de vol, vous ne verrez jamais la séparation nette entre les pixels : il y aura toujours un dégradé entre le pixel et son voisin. Cette petite révolution du monde de la 3D s'appelle le bilinear filtering, et est parfaitement gérée par OpenGL. Vous vous rappelez les deux lignes que je n'ai pas expliqué tout-à-l'heure ? Et bien elles disaient à OpenGL de ne pas faire de bilinear filtering, mais de prendre le texel le plus proche (d'où le terme GL_NEAREST) lorsqu'il dessine le pixel d'un polygone. Changez donc ce paramètre en GL_LINEAR dans les deux lignes, et voyez le résultat à droite.
On voit bien qu'il y a un dégradé entre chaque texel, d'ailleurs on le voit un peu trop bien, puisque là on a une texture de 4 pixels (2x2), ça fait un peu juste, mais bon. Voyons plutôt cette fonction glTexParameter{if}[v]() : avec elle on règle le filtering, mais en 2 fois. Pourquoi ? Et bien en fait il y a 2 filtering : un lorsqu'il y a magnification (GL_MAG), c'est-à-dire lorsque un texel s'étend sur plusieurs pixels, comme ici, en un lorsqu'il y a minification (GL_MIN), c'est-à-dire lorsqu'un pixel contient plusieurs texels. Dans le cas de la magnification, on l'a vu, le pixel est le résultat de la moyenne des texels environnants. Dans le cas de la minification, s'il y a filtering, le pixel est le résultat de la moyenne des texels contenus.
 

4. Répétition

Mais il y a quand même un truc bizarre : sur les bords des faces, la texture subi un dégradé vers le gris, alors que normalement elle devrait subi un dégradé vers la coumeur du pixel de la texture (blanc ou noir). Pourquoi ? Et bien parce qu'OpenGL est configuré pour répéter la texture. C'est-à-dire que si on avait mis une valeur n>1 pour les texcoords, il aurait répété n fois la texture (essayez pour voir). Et lorsqu'il est au bord, il fait un filtering avec le bord opposé de la texture, ce qui conduit à rendre tous les bords... gris !
En mode GL_CLAMP ("bridé"), les coordonnées sont ramenées entre 0 et 1 et sur les côtés de la texture, le filtering est fait entre la texture et le bord (celui que l'on spécifie dans glTexImage2D() ). Pour choisir le mode de texuring , il faut appeler glTexParameter() avec comme premier paramètre GL_TEXTURE_2D, comme second paramètre GL_TEXTURE_WRAP_S (pour affecter les coordonnées horizontales) ou GL_TEXTURE_WRAP_T (pour les coord. verticales), et comme troisième paramètre GL_CLAMP (pour bridé) ou GL_REPEAT (pour répété). Et si on est en mode répété, on obtient bien le résultat que voici. Ici, comme la couleur du bord est (0,0,0,0) par défaut, il y a un dégradé vers le noir sur les bords des faces.
Vous êtes en train de vous demander comment ne pas faire de filtering sur les bord ? Et ben vous pourrez chercher longtemps, parce que... on peut pas. Hé non ! Donc le meilleur moyen est encore de se mettre en GL_REPEAT de de faire des textures dont les bords opposés sont semblables, ou d'utiliser le bord en mode GL_CLAMP si les couleurs ne varient pas trop.
[Rectification : Maintenant, avec l'extension ARB_texture_border_clamp, il est possible de ne pas faire de filtering sur les bords. Il suffit d'utiliser la constante CLAMP_TO_BORDER_ARB (ou CLAMP_TO_BORDER_ARB, suivant votre version d'OpenGL) à la place de GL_REPEAT ou GL_CLAMP.]
Ah oui j'oubliais : pour changer la couleur du bord, on appelle glTexParameter{if}v() avec GL_TEXTURE_BORDER_COLOR comme 2e paramètre et un tableau de 4 valeurs (RGBA pour changer) comme 3e paramètre.
 

Conclusion

On en a déjà vu pas mal dans ce tutorial, et pourtant il reste encore beaucoup à dire sur le sujet : il y a le mipmapping, le blending... Et saviez-vous par exemple que les textures possèdent aussi leur propre matrice ? Ben ouais, après tout, appliquer une texture, c'est une simple affaire de projection, non ? (ouh là, je commence à me Weberiser, moi - private joke). Donc tout ça, on le verra plus tard, parce que moi, là, j'ai faim. Alors amusez-vous bien avec les sources que vous pouvez télécharger en bas, et avec l'annexe que voici.

 

Annexe : charger des images BMP

Bon OK je dois bien l'avouer, y'a pas que des échiquers de 4 pixels dans le monde de la 3D. Généralement, y'a plutôt des textures assez complexe qu'on aurait du mal à la main, mais qui, bizarrement, sont plus facilement éditables sous formes d'images (.bmp par exemple...). Hé hé, je vous vois venir : "cool, il va nous filer un code tout prêt pour loader des bmp !!". Et ben nan, na ! D'abord je me suis fais chier à décrire le format BMP sur ce site (allez, cherchez un peu), alors c'est pas pour des prunes, vous pouvez faire un loader vous-mêmes, ca devrait durer 15 min à tout casser. Mais bon pour les quiches qui auront le courage de pomper le tutorial et les sources, je vais quand même inclure une petite fonction de chargement de BMP, histoire de pas être trop salaud (vous pouvez voir le résultat à droite).
Et si vous n'avez vraiment pas envie de vous cassez le cul, il existe dans la librairie glaux.h une fonction toute faite pour ce genre de choses. Allez sur le site de Nehe pour voir comment l'utiliser.
Sinon, pour ceux qui auraient l'intention de charger leurs images tous seuls, quelques remarques :

  • D'abord, n'oubliez pas que l'image est inversée. Cela veut non seulement dire que la ligne du bas est stockée en premier, mais aussi que le format est donc BGR, et non RGB. Donc logiquement il suffirait d'appeler glTexImage2d() avec comme format GL_BGR_EXT et paf ca marche. En effet, théoriquement, ça marche. Mais pas partout. En effet, sur certaines cartes 3D (des ATI All-InWonder, pour ne pas le nommer), la texture était quand même chargée en RGB. Donc préférez inerser les composantes R et B de chaque pixel avant d'envoyer la texture à OpenGL, ca vous évitera de faire des patchs. Vous pouvez aussi par la même occasion la mettre sens dessus-dessous (je veux dire faire un 'flip' pour la remettre dans le bon sens).
  • Ensuite, gare au champ 'Taille de l'image' à l'offset 0x22 dans les fichiers BMP : il n'est pas toujours rempli par tous les logiciels, car on peu le retrouver grâce à la largeur et la hauteur. Donc éviter de l'utiliser.
Antoche
 


← Les spots↑ Tutoriaux OpenGL ↑L'input avec GLUT →