Réalisation d'un moteur 3D en C++, partie V : les textures![]() Date de publication : 01/08/2005 , Date de mise à jour : 01/09/2005
Par
Laurent Gomila (Autres articles) Dans cette partie nous étudierons le dernier élément essentiel du coeur de notre moteur : les textures. Nous verrons comment les gérer et les intégrer efficacement au moteur, et comment fournir un maximum de fonctionnalités à l'utilisateur ; nous verrons également comment charger et sauver facilement des images à l'aide de DevIL. 1. Introduction 2. Les formats de pixels 2.1. Généralités 2.2. Conversions 3. La classe CImage 4. Utilisation de la bibliothèque DevIL 4.1. Installation et configuration 4.2. Utilisation - chargement et sauvegarde 5. Les textures 5.1. Côté API 5.2. Côté renderer 5.2.1. OpenGL 5.2.2. DirectX 5.3. Côté utilisateur 6. Gestion du support hardware 6.1. Pourquoi ? 6.2. Comment ? 7. Exemple - chargement d'image, création de texture et utilisation 8. Conclusion 9. Téléchargements 1. Introduction
Les textures sont un élément essentiel de tout moteur graphique. Leur gestion peut être simple si l'on se cantonne à des choses
très limitées, mais dès que l'on veut passer au niveau supérieur dans la flexibilité et la performance, les choses se
compliquent malheureusement très vite. Il faut penser au chargement des différents formats de fichier, aux nombreux formats de
pixels que l'on va fournir à l'utilisateur, à la gestion de la mémoire, ainsi qu'à garder des performances optimales tout en
offrant de nombreuses fonctionnalités.
La conception présentée dans ce tutoriel n'a pas la prétention d'être la "meilleure", elle correspond simplement a certains
choix. Comme notre but est de construire un moteur 3D destiné a priori à l'utilisation la plus large, nous allons essayer de
fournir un mécanisme de textures satisfaisant au maximum tous les points cités plus haut, à savoir fournir des fonctionnalités
les plus complètes possibles en gardant des performances optimales. Bien sûr tout ceci toujours dans le même esprit :
nous allons utiliser intelligemment le langage (et ce que nous avons développé précédemment) pour minimiser les erreurs et
proposer une interface sûre et intuitive à l'utilisateur.
2. Les formats de pixels2.1. Généralités
La première chose à définir lorsqu'on parle de textures et de pixels, est le format de ces pixels. Mais qu'est-ce donc ?
On appelle format de pixel la manière dont est stocké en mémoire un pixel (une couleur). Cela concerne le nombre de
composantes, leur ordre ainsi que le nombre de bits alloués à chacune d'elle. Par exemple, un format A1R5G5B5 aura une
composante alpha codée sur 1 bit, une composante rouge sur 5 bits, une composante verte sur 5 bits, et une composante bleue
sur 5 bits également. Ce qui nous fait au total 16 bits pour ce format de pixel. Bien sûr, plus le nombre de bits d'un
format sera élevé meilleure sera la qualité de l'image. Les API 3D telles que OpenGL ou DirectX fournissent une (très longue) liste de formats de pixels. La plupart du temps le format standard A8R8G8B8 sera suffisant, mais il existe de nombreux cas où un format un peu moins courant s'impose. Par exemple, si l'on veut cibler les vieilles configurations ou gagner en performances, on pourra choisir l'un des nombreux formats 16 bits. Pour gagner en place et en performance sur des configurations plus récentes, on peut également utiliser un format compressé (DXTC). Ou encore si l'on travaille avec des lightmaps, nous n'aurons besoin que d'une composante de luminosité sur 8 bits. Plus récemment, l'introduction du HDRL (High Dynamic Range Lighting) a nécessité d'utiliser des formats "new generation" avec des composantes en nombre flottants ou sur 32 bits. Comme vous le voyez, le large éventail de formats qui nous est proposé n'est pas là pour rien.
Voici les formats proposés par le Yes::Engine :
Cette liste peut sembler relativement courte par rapport à tous les formats proposés par DirectX et OpenGL, mais elle fournit
les plus fréquents et couvre à peu près les utilisations les plus courantes. Il manque les formats "new generation"
(flottants, 128 bits, ...) mais, et c'est ici l'un des désavantages du développement amateur, on n'a pas toujours à
disposition le matériel approprié pour implémenter les derniers gadgets à la mode. L'important dans ce cas est de concevoir
le moteur de sorte qu'on puisse y greffer très facilement d'autres formats de pixels par la suite.
2.2. Conversions
Lorsqu'on manipule autant de formats de pixels, l'un des problèmes qui apparaît très rapidement est la conversion entre tous
ces formats. Bien sûr il faudra (autant nous que l'utilisateur) veiller à en effectuer le moins possible, car c'est une
source de ralentissement conséquente. Afin de limiter au mieux les pertes de performances, il convient de mettre sur pied du
code optimisé pour gérer toutes ces conversions. Et c'est là que cela devient pénible, car cela signifie gérer au cas par cas
tous les couples de formats, et s'amuser avec un peu d'arithmétique sur les bits.
On commence par construire une fonction template, dont les paramètres templates sont les deux formats mis en jeux dans la
conversion. Cette version ne sera appelée que lorsque la conversion entre les deux formats n'aura pas été écrite, on lance
donc dans ce cas une exception pour le signifier à l'utilisateur.
On va ensuite spécialiser cette fonction pour tous les couples de formats qu'on souhaite optimiser. Ainsi si elle existe ce
sera la spécilisation correspondante qui sera appelée, sinon ce sera la fonction générique définie plus haut, qui lèvera donc
une exception. Pas très beau, mais efficace :
Là où cela devient moins efficace, c'est que les paramètres templates sont résolus à la compilation alors que nos formats
à convertir ne seront eux connus qu'à l'execution. Il faut donc une fonction pas très belle (deux gros switch imbriqués) qui
appelle la bonne version de notre fonction template selon les formats reçus en paramètres. Rien de dramatique cependant,
l'utilisation de switchs garantit que le coût supplémentaire à l'execution ne sera que de trois sauts quelque soient les
formats (pour ceux à qui cela ne parle pas, dites-vous simplement que c'est négligeable).
Pas très beau, mais il faut parfois faire des sacrifices lorsqu'on touche à des fonctionnalités aussi sensibles sur les
performances. Niveau maintenabilité, il faudra farfouiller à quelques endroits pour effectuer des modifications mais
lorsqu'on veut un code optimisé au cas par cas, on doit sacrifier un peu de flexibilité.
3. La classe CImage
Avant d'aborder les textures à proprement parler, nous aurons besoin d'une classe pouvant manipuler facilement des buffers de
pixels. Une classe d'images, quoi. Et comme elle porte bien son nom, celle-ci s'appellera CImage (attention si vous avez
du MFC ou autre qui traîne dans le coin, CImage est un nom très répandu pour ce genre de classe -- d'où l'utilité des espaces
nommés pour éviter les conflits de noms).
Cette classe sera bien entendu très utile à qui voudra manipuler des images (ce qui peut s'avérer assez fréquent lorsqu'on fait
une application 2D ou 3D), mais elle nous servira également d'interface entre la partie moteur des textures et la partie API.
En gros, les textures ne possèderont qu'une fonction de mise à jour à partir d'une image, tout le reste (modification ou
récupération de pixels, changement de taille, de format, ...) sera pris en charge par CImage.
Notre classe CImage contiendra donc un buffer de pixels, le format de ceux-ci, ainsi que la taille de l'image.
Les pixels sont stockés sous forme de buffer d'unsigned char. Etant le type de plus petit (souvent 1 octet), cela nous permet
d'accéder aux pixels quelque soit leur format. Si nous nous étions contentés d'un format unique, par exemple A8R8G8B8, nous
aurions pu stocker les pixels directement sous forme d'entiers 32 bits non signés. Mais comme nous allons manipuler des
formats ayant toute sorte de taille (de 8 à 32 bits, voire plus par la suite) il faut garder un type le plus générique possible.
Ensuite, il faudra fournir plusieurs constructeurs afin de pouvoir créer nos images de différentes façons. Typiquement il
faudra pouvoir créer une image par défaut, une image dimensionnée mais vide, et une image dimensionnée et remplie. Bien sûr rien
ne vous empêche d'en ajouter d'autres pour satisfaire les besoins de votre application.
Puis viennent quelques accesseurs, pour accéder aux propriétés de notre image. Il est important que l'accès direct aux pixels
soit en lecture seule (renvoi d'un pointeur constant), afin d'être sûr que personne ne pourra mettre le boxon dans notre buffer
de pixel.
La tâche d'une telle classe étant la manipulation de pixels, il est impératif de fournir des facilités de lecture et d'écriture.
En l'occurence, un bon vieux couple GetPixel / SetPixel.
Ici nous avons en fait deux versions de chaque fonction. La première manipule des CColor et est donc très sympathique à
utiliser ; par contre cela implique une conversion systématique, donc des performances dégradées. La seconde version bosse
avec des unsigned char* afin de pouvoir lire ou ecrire les pixels directement dans leur format natif ; c'est très rapide mais
pas toujours évident à manipuler, surtout avec les formats les plus exotiques (A1R5G5B5 par exemple). Ne jamais trancher entre
performances et facilité est important, cela permet de ne pas limiter le moteur.
Viennent enfin des fonctions un peu plus travaillées, permettant d'effectuer toute sorte de trucs sympas sur nos images.
Pêle-mêle : retournement (horizontal ou vertical) de l'image, remplissage avec une couleur, extraction d'une sous-image,
conversion à partir d'une autre image. Ce dernier point, effectué comme son nom ne l'indique pas par la fonction CopyImage, est
très important : c'est lui qui met en oeuvre le processus de conversion que nous avons vus au début de ce tutoriel, ainsi qu'un
processus de redimensionnement. Ce sont deux fonctionnalités qui seront utilisées intensivement par le moteur, ainsi il est
primordial qu'elles soient performantes et pratiques à utiliser.
La structure d'une telle classe n'est pas figée, plus vous pourrez fournir de facilités mieux ce sera pour votre moteur !
4. Utilisation de la bibliothèque DevIL
Avant de pouvoir remplir une texture avec une image, il faudra dans un premier temps charger celle-ci à partir d'un fichier.
Le moteur devra prendre en charge les formats les plus courants (bmp, jpg, tga, png), et pourra idéalement gérer un maximum
d'autres formats. Si vous avez le goût du risque vous pouvez toujours tenter d'écrire un loader pour chacun de ces formats --
avec beaucoup de bonne volonté et le site www.wotsit.org, vous devriez y arriver
avant de mourir. Mais comme il existe de nombreuses bibliothèques gratuites et bien foutues qui font déjà tout cela, nous
n'allons pas nous priver. L'une des plus connues est certainement DevIL,
gratuite, sous licence LGPL et gérant bien plus de formats d'images qu'il ne nous en faudra. Cerise sur le gâteau : elle est
parfaitement portable. Et comme nous n'allons nous en servir que pour le chargement et la sauvegarde de fichiers, son
utilisation sera relativement aisée.
4.1. Installation et configuration
DevIL est une bibliothèque portable et de ce fait disponible sur de nombreux systèmes (Windows, Linux, Mac, ...), cependant
cette partie ne couvrira que l'utilisation sous Windows. Pour les autres systèmes, la marche à suivre est quasiment identique
et les instructions disponibles sur le site ne vous laisseront pas dans l'embarras. DevIL est composée de trois bibliothèques : IL (le coeur) ainsi que ILU et ILUT (utilitaires divers, à l'image de GLU et GLUT pour OpenGL). Ici seule IL nous intéresse, étant donné que nous n'aurons besoin que de la manipulation de base des images. Dans le processus d'installation / compilation, vous pourrez donc ignorer ILU et ILUT.
La première chose à faire est de télécharger les fichiers adéquats : dans la section "Download" du site, choppez :
Enfin, pour utiliser DevIL dans un projet (ici notre moteur 3D), il suffira d'inclure le fichier d'en-tête il.h
et de lier avec la bibliothèque d'importation. Ce sera DevIL.lib pour Visual C++, pour les autres compilos (MinGW pour
Dev-C++ par exemple) il faudra la construire nous-même. Cependant pas de panique, c'est très simple. Voici la procédure à
suivre pour MinGW :
Dans le répertoire bin de votre installation (de Dev-C++ ou de MinGW), vous trouverez un outil nommé dlltool. Il sert à
générer une bibliothèque d'importation (.a) à partir d'un .dll et d'un .def. Voici comment l'invoquer pour créer le fichier
qui nous intéresse :
Vous voici maintenant muni de libil.a, avec lequel vous pourrez lier votre projet Dev-C++ pour utiliser DevIL.
Bien sûr, il ne faudra pas oublier de copier DevIL.dll soit dans le répertoire de la DLL du moteur, soit dans un
répertoire du PATH, et de le fournir avec votre application si vous la distribuez.
Pour ceux qui n'aiment pas trimballer plein de DLLs à côté de leur bibliothèque, il est tout à fait possible d'utiliser
DevIL en version statique. Les projets et binaires pour Visual C++ sont fournis dans les sources de DevIL, ainsi qu'une
version précompilée des bibliothèques utilisées (libjpeg, libpng, etc...), pour une recompilation avec Visual C++ toujours.
Pour ce qui est des autres compilos, il est possible de recompiler DevIL en bibliothèque statique à partir de ses sources.
Pour cela, il faudra faire attention à plusieurs détails :
4.2. Utilisation - chargement et sauvegarde
Une fois correctement installée et configurée pour notre compilateur préféré, nous allons pouvoir utiliser DevIL pour
ce qui nous intéresse : le chargement et la sauvegarde d'images.
Dans le précédent tutoriel, nous avons mis en
place le coeur du système de gestion des ressources. En l'occurence nous avons un gestionnaire de medias
(CMediaManager) et une classe modèle (ILoader) servant de base à tous les chargeurs que nous allons développer.
Ici nous allons coder un chargeur d'images, il va donc falloir constuire une classe dérivée de ILoader<CImage>.
La première chose à faire est d'initialiser DevIL, ainsi que de configurer quelques options qui nous seront utiles. Puisqu'il
n'existera qu'une seule et unique instance de notre classe, tout ceci pourra être fait dans son constructeur.
La première instruction à appeler avant toute autre est ilInit(), elle initialise DevIL. Aucune autre instruction ne
fonctionne tant que celle-ci n'a pas été appelée. Les instructions qui suivent sont optionnelles : elles servent à parametrer correctement la bibliothèque. Ici nous indiquons à DevIL que l'origine des images -- le pixel (0, 0) -- se trouve sur le coin haut-gauche. Sans cela, nos images apparaîtraient à l'envers. Puis, nous autorisons DevIL à écraser un fichier si lors d'une sauvegarde celui-ci existe déjà. Enfin nous forçons DevIL à charger les images dans un format bien précis : 32 bits BGRA. Si nous omettons cette instruction, DevIL chargera les images dans leur format natif, et rien ne nous garantit que celui-ci sera toujours le même.
Même combat pour le destructeur : celui-ci sera chargé de fermer DevIL, qui s'occupera de libérer proprement toute la
mémoire qu'il utilisait. Comme pour l'initialisation, une instruction suffit :
Rien de bien compliqué pour le moment.
Intéressons nous à présent au chargement des images :
Si vous avez l'habitude de travailler avec OpenGL, vous remarquerez tout de suite que le fonctionnement de DevIL est
très similaire. On commence tout d'abord par générer une texture valide (mais complétement vide) à l'aide de ilGenImages. Puis nous indiquons à DevIL que nous allons travailler sur celle-ci avec la fonction ilBindImage. Tout ceci est quasiment identique à la manipulation de textures avec OpenGL : glGenTextures, glBindTexture. La prochaine étape est la plus importante : on va remplir la texture fraîchement générée avec une image contenue dans un fichier. C'est là qu'on voit l'intérêt d'une telle bibliothèque : le format de l'image sera déterminé automatiquement via son extension, et DevIL utilisera le chargeur approprié. Tout ce dont nous devons nous préoccuper est de fournir un nom de fichier valide à la fonction. En cas d'échec de chargement, celle-ci renvoie false et nous pouvons lever l'exception appropriée. A ce stade nous savons que la texture a été chargée à l'endroit et en 32 bits BGRA, puisque nous avons parametré DevIL en conséquence.
Une fois l'image chargée, nous disposons d'une image DevIL. Or, ce que nous voulons est une image de type CImage. La
prochaine étape va donc être de construire une CImage à partir de l'image chargée par DevIL. On commence par récupérer
les dimensions de celles-ci, via la fonction ilGetInteger avec en paramètres IL_IMAGE_WIDTH et IL_IMAGE_HEIGHT. Là encore,
on pourra noter la similarité avec la fonction OpenGL glGetIntegerv. Puis, on récupère un pointeur vers les pixels de l'image
avec la fonction ilGetData. Vient enfin la création du CImage : nous lui passons simplement en paramètre la taille, le format
(toujours le même -- celui dans lequel sont nos pixels) et... les pixels. Dernière étape : puisque nous n'avons plus besoin de l'image DevIL, nous pouvons détruire celle-ci via la fonction ilDeleteImages.
Voyons maintenant la sauvegarde d'une image dans un fichier :
Le processus de sauvegarde est à peu près l'inverse du processus de chargement : nous allons créer une image DevIL,
la remplir à partir de notre CImage, puis enregistrer celle-ci dans un fichier. Comme notre image peut avoir n'importe quel format, pas forcément compatible avec DevIL, la première chose que nous allons faire est de la copier dans une image intermédiaire ayant un format plus sympathique : 32 bits ARGB. On aurait pu choisir l'un des quelques autres formats compatibles avec DevIL, mais là au moins on est sûr que l'on ne va rien perdre en qualité, quelque soit le format d'origine. Au passage, nous en profitons pour la retourner, sans quoi elle apparaîtrait à l'envers dans le fichier. Ensuite, comme pour le chargement, il va nous falloir créer une image DevIL. Là rien de mystique, c'est comme tout à l'heure. Une fois l'image intérmédiaire créée, nous pouvons copier les pixels de celle-ci dans l'image DevIL que nous avons générée : cela se fait à l'aide de la fonction ilTexImage. Celle-ci demande les dimensions de l'image, la profondeur de pixel, le format des données, et finalement un pointeur vers les pixels (notez là encore le cast barbare en void*, même combat que précédemment). En cas d'échec elle nous renverra false, et nous pourrons lever l'exception correspondante. Enfin, il ne nous reste plus qu'à sauvegarder l'image obtenue dans un fichier. C'est la fonction ilSaveImage qui va se charger de cela, et de la même manière que ilLoadImage celle-ci va déterminer automatiquement le format de l'image via son extension et utiliser le bon loader. En cas d'échec toujours pareil : on récupère false et on lève une exception. La gestion des erreurs est très importante ici, ne l'oubliez pas. C'est elle notamment qui permettra à l'utilisateur de savoir quels fichier n'ont pas pu être chargés ou sauvés. Il ne nous reste donc plus qu'à détruire l'image DevIL, via ilDeleteImages toujours.
A ce stade, notre loader d'images est complet et prêt à servir. Tout ce qu'il nous reste à faire est de l'enregistrer
auprès de notre gestionnaire de médias, en lui associant les extensions gérées : à savoir
bmp, dds, jpg, pcx, png, pnm, raw, sgi, tga et tif. DevIL supporte un peu plus de formats pour le chargement, mais comme
notre moteur ne fait pas la différence on se contentera de ceux supportés pour la sauvegarde, ce qui suffit très largement.
La dernière chose à faire est de ne pas oublier de dire au CMediaManager de gérer les CImage, en ajoutant cette dernière à la
liste des medias
Par la suite, lorsque nous voudrons charger une image de n'importe lequel des types gérés, notre loader basé sur DevIL sera
appelé par le gestionnaire automatiquement et l'image sera correctement importée. Nous pourrons ensuite créer une texture à
partir de cette image, et envoyer cette dernière au gestionnaire de ressources (CResourceManager).
Pour plus de précisions sur les fonctions de DevIL utilisées, ou sur les autres, n'hésitez pas à consulter la documentation
de référence et les différents tutoriels disponibles sur le site officiel.
5. Les textures5.1. Côté API
Après tous ces détours (vous avez été prévenu, la gestion complète des textures demande beaucoup de travail) nous arrivons
enfin à ce qui nous intéresse : les textures. Nous avons tout de même bien travaillé jusqu'ici : nous disposons d'un procédé
de chargement et de sauvegarde des images, de conversions optimisées entre formats, ainsi qu'une classe nous permettant de
manipuler facilement les pixels. Il ne reste plus qu'à construire quelque chose de solide et performant pour faire le lien
entre tout ça et nos APIs 3D.
Comme à l'accoutumée, nous allons utiliser un design de classes permettant d'une part de bien séparer les tâches, et d'autre
part de fournir à l'utilisateur quelque chose de facile et sûr à manipuler (ie. une belle classe enveloppe et non des
pointeurs bruts).
La première chose à concevoir est la partie API : une classe abstraite ITextureBase qui va être spécialisée par chaque
renderer, et qui va contenir le code spécifique à chaque API, réduit à son strict minimum. Souvenez-vous, moins on en écrit
mieux on se porte. En l'occurence, la seule tâche qui ne peut pas être effectuée indépendamment de l'API est le remplissage
de la texture ; notre classe ITextureBase contiendra donc une fonction virtuelle Update.
Quelques remarques sur ce code :
Comme vous le voyez, les fonctionnalités de cette classe ont été réduites à leur minimum : nous n'avons fourni que la mise à
jour du contenu de la texture. Comme évoqué dans le chapitre sur les CImage, tout le reste sera effectué par cette dernière
classe. C'est important : cela permet de minimiser les éventuelles erreurs (et la manipulation des APIs 3D en est une grande
source), ainsi que de minimiser les différences de comportement entre les APIs.
La spécialisation de ITextureBase pour chaque API ne présente a priori pas de difficulté majeure : seule la mise à jour des
pixels est à écrire. Cependant, et vous allez le voir, c'est déjà une tâche qui demande quelques efforts (d'où l'utilité de
limiter le code à écrire dans la partie API).
La spécialisation OpenGL est la plus courte (mais pas la plus simple) à écrire : les conversions de format (rares,
étant donné que c'est déjà géré par le moteur) et le mécanisme de verrouillage sont gérés automatiquement par l'API. Il faut
tout de même distinguer 3 cas :
La spécialisation DirectX quand à elle est plus lourde, mais plus simple à gérer. Il faudra gérer le verrouillage et
la copie des pixels à la main, étape qui ne comporte en soi rien de difficile mais qu'il faudra coder avec soin.
Dans le cas de formats source et destination différents, il faudra créer une surface temporaire en mémoire système et la
copier dans notre texture (ou plutôt sa surface de niveau 0) via la fonction D3DXLoadSurfaceFromSurface, qui se
chargera d'effectuer toutes les conversions nécessaires pour nous. Enfin, la gestion du mip-mapping est très simple : si
le système supporte la génération automatique on appelle simplement m_Texture->GenerateMipSubLevels(), sinon on confie
le boulot à D3DXFilterTexture.
5.2. Côté renderer
Nous avons maintenant des textures prêtes à être manipulées et remplies, mais qu'en est-il de leur création et de leur
utilisation ? Ceci se passe au niveau du renderer, et cela s'appelle (très bizarrement) CreateTexture et
SetTexture.
5.2.1. OpenGL
Commençons avec OpenGL et la fonction CreateTexture. Rien de magique, mais quelques explications détaillées permettront
de bien comprendre ce qu'il faut faire.
On commence par générer une nouvelle texture et indiquer à OpenGL que l'on va travailler dessus, de manière identique à
DevIL.
Ensuite, on définit quelques paramètres de la texture : bordure, filtrage, etc...
Vient ensuite la gestion des niveaux de mip-mapping. Il est important de définir le nombre de niveaux de mip-mapping
et de créer toutes les surfaces correspondantes avec glTexImage2D, ainsi pour mettre la texture à jour on pourra
par la suite utiliser glTexSubImage2D qui est bien plus rapide. Souvenez-vous en : glTexImage2D pour créer,
glTexSubImage2D pour mettre à jour.
La fonction HasCapability n'a pour l'instant pas été expliquée, pour le moment dites-vous simplement qu'elle
permet de savoir si le système supporte telle ou telle fonctionnalité. Nous expliquerons plus en détail ce système
un peu plus tard.
On peut finalement désactiver la texture et créer un COGLTexture à partir de celle-ci.
Quant à SetTexture, rien de bien méchant :
5.2.2. DirectX
Côté DirectX, c'est plus ou moins la même chose avec moins de lignes de code. On commence par déterminer les options de
mipmapping selon les capacités du système et des options de création de la texture.
Ceci étant fait, on peut d'ores-et-déjà créer la texture, avec les paramètres qui vont bien (n'oubliez pas : si vous
avez un trou concernant l'un des paramètres d'une quelconque fonction, pensez à la doc du SDK !). Bien que nous vérifions
au préalable tous les paramètres de création (avant d'appeler CreateTexture), nous utilisons D3DXCreateTexture
qui, au contraire de IDirect3DDevice9::CreateTexture, vérifie les paramètres et effectue des modifications
sur ceux-ci si nécessaire. Deux protections valent mieux qu'une !
Finalement, on peut créer la CDX9Texture correspondante et la renvoyer.
Pour ce qui est de SetTexture ça tient en deux lignes : récupération de la texture Dx9, et envoi à l'API.
5.3. Côté utilisateur
Comme prévu, nous allons maintenant créer une jolie enveloppe pour gérer de manière plus sûre et plus conviviale nos
ITextureBase : la classe CTexture.
Celle-ci possède une sémantique de valeur (ie. ses instances sont copiables et assignables), afin d'éviter à l'utilisateur
de se trimballer des pointeurs, ou de dupliquer / perdre par inadvertance la texture interne. Si vous avez bien suivi
jusqu'ici, vous pensez immédiatement à constructeur par défaut, constructeur par copie, destructeur,
et opérateur d'affectation. Mais si vous avez encore mieux suivi vous pensez alors tout de suite à pointeur
intelligent. Du coup la seule chose à faire pour donner à notre classe une sémantique de valeur, est de bien envelopper
notre ITextureBase* dans un CSmartPtr. Par contre, nous aurons aussi besoin de comparer facilement des textures. Nous allons
donc ajouter également quelques opérateurs pratiques.
Souvenez-vous : ITextureBase est une ressource, il faut donc que notre pointeur intelligent la manipule via la police
CResourceCOM. Je vous renvoie au tutoriel précédent si vous n'avez pas suivi toute cette histoire.
Nous aurons ensuite besoin de quelques accesseurs, afin de pouvoir récupérer ses propriétés de base (format, taille, ...).
Viennent enfin les fonctions qui nous intéressent le plus : chargement, mise à jour et sauvegarde. Il est possible de créer
une texture vide, à partir d'un fichier, ou encore à partir d'une image (CImage).
La dernière fonction, privée, sert à factoriser le code de création qui sera commun aux 3 fonction CreateXXX.
Voyons à présent le détail de ces fonctions, à commencer par cette fonction Load effectuant le chargement de la texture.
Avant toute chose, on commence par effectuer les vérifications nécessaires quant au format et aux dimensions passées en
paramètre. Si cela pose problème au système, on effectue les modifications nécessaires et on le notifie dans le log.
On fait ensuite appel au Renderer et à sa fonction CreateTexture, afin de créer la texture interne. Peut-être remarquez-vous
qu'on ne libère jamais la texture précédente, ce qui pourrait poser problème dans le cas de plusieurs chargements
successifs avec la même instance de CTexture ; en fait il n'y a aucun souci puisque le pointeur intelligent fait déjà
correctement tout ce boulot.
Enfin, on ajoute la texture correctement chargée au gestionnaire de ressource en lui associant le nom souhaité, on initialise
la copie système des pixels et on envoie tout ça au hardware via la fonction Update, qui a correctement été redéfinie pour
chaque API.
Les différentes fonctions de création ne sont ensuite plus ou moins que des appels à la fonction Load :
Quant aux autres fonctions, elle ne posent aucun problème particulier et vous pourrez bien sûr trouver leur code source
intégral dans l'exemple qui accompagne ce tutoriel.
6. Gestion du support hardware
Ce chapitre est une petite parenthèse aux textures, il s'agit ici de la gestion des fonctionnalités supportées ou non par le
hardware. Pas de quoi en faire un tutoriel complet, et puisque nous en avons besoin ici autant mettre l'explication qui va avec
sans plus tarder.
6.1. Pourquoi ?
Dans le monde parfait du codeur, les choses sont extraordinairement simples. Le système, quel qu'il soit, supporte sans
ronchonner toutes les fonctionnalités qu'on lui donne a manger. D'ailleurs tout le monde possède la même carte graphique,
le même processeur, le même système d'exploitation, la même API 3D, etc. D'ailleurs il n'existe qu'un modèle de tout ça :
pas la peine d'en inventer d'autres puisqu'ils sont parfaits.
Malheureusement personne ne vit dans ce monde parfait, pas même Ilona. Il est donc nécessaire, avant de coder les
derniers effets à la mode, de gérer correctement ce que le système peut et ne peut pas faire. Heureusement, les APIs 3D
nous fournissent toujours de quoi nous renseigner suffisamment sur ce qu'elles supportent (d'elles-mêmes ou via le hardware).
Nous allons donc exploiter ces informations et mettre sur pied un système permettant à tout moment de savoir ce que notre
système supporte ou non, et comme ci-dessus pour les texture, coder en conséquence.
6.2. Comment ?
La première chose à faire est d'identifier les fonctionnalités à risque, celles susceptibles de nous embêter. Pour les
trouver il n'y a pas de miracle : bien lire la doc, bien lire la doc, et bien relire la doc. On peut donc commencer
par écrire dans notre code une liste de ces fonctionnalités, pour lesquelles on aimerait bien avoir des infos plus précises.
Voici pour le moment les fonctionnalités pointées du doigt par notre code actuel, celles qu'il faudra surveiller de près.
Ensuite, il faut déterminer si oui ou non chacune de ces fonctionnalités est supportée par le système. Puisque c'est une
tâche spécifique à l'API, nous allons désormais travailler dans le renderer. La première chose à lui coller est un
moyen de stocker ces fonctionnalités, de les initialiser, ainsi que de les rendre disponible pour le reste du moteur :
Il ne nous reste plus ensuite qu'à redéfinir la fonction virtuelle CheckCaps pour chaque renderer, et le tour sera joué.
Cette fonction sera par la suite appelée automatiquement par IRenderer lors de l'initialisation, pas de souci à se faire de
ce côté (cf. le code source pour les détails).
DirectX met à notre disposition une structure ainsi qu'une fonction pour récupérer et consulter les fonctionnalités
supportées par le système. Il s'agit de la structure D3DCAPS9, pour laquelle vous pourrez trouver une large
documentation dans la doc du SDK. La fonction à appeler pour en récupérer une instance valide est simplement
IDirect3DDevice9::GetDeviceCaps. A noter que pour les quelques fonctionnalités nécessitant d'être vérifiées avant la
création du device, il existe sa petite soeur IDirect3D9::GetDeviceCaps.
Pour tout ce qui touche aux formats et options de surfaces, textures, buffers, etc... DirectX met aussi à notre disposition la fonction IDirect3D9::CheckDeviceFormat. Combinée à la structure D3DCAPS9, elle va nous permettre de savoir tout ce que l'on veut sur le support hardware.
OpenGL quant à lui possède un système plus basique : à chaque fonctionnalité est associée une extension, qu'il suffira
donc de tester pour savoir si elle est supportée ou non. Les extensions (au moins les plus communes) sont suffisamment bien
documentées pour que vous puissiez les identifier sans peine.
Nous voilà donc avec une gestion des fonctionnalités, qu'il suffira d'enrichir au grès de l'avancée du code. Par la suite il
suffira d'appeler Renderer.HasCapability(...) pour savoir si oui ou non on peut utiliser notre fonctionnalité préférée.
Ceci ferme donc cette petite parenthèse, nous pouvons maintenant revenir et conclure sur nos textures.
7. Exemple - chargement d'image, création de texture et utilisation
Après avoir écrit du code bien long et assez compliqué, nous allons pouvoir juger du résultat de nos efforts, à savoir
l'utilisation de tout cela en situation réelle.
Comme d'habitude, un exemple complet avec les sources intégrales du moteur accompagne ce tutoriel.
8. Conclusion
Comme vous avez pu le constater, la gestion poussée des textures n'est pas simple. C'est un élément primordial du moteur, pour
lequel on ne peut faire de concession ni sur les performances, ni sur les fonctionnalités.
Ces classes, bien que déjà assez complexes, ne constituent en réalité que le point de départ de la gestion des textures, et
d'autres fonctionnalités viendront certainement encore se greffer à ce système. Notamment les divers autres types de textures
qui pourront nous être utiles : textures de rendu, textures 1D ou 3D, cubemaps, etc... Toutes ces jolies bestioles seront
décortiquées dans un prochain tutoriel !
9. Téléchargements
Les sources fournies dans les précédents tutoriels sont entièrement intégrées à celles-ci, ainsi qu'une petite démo
reprenant tous les concepts vus, de manière à ce que vous ayiez toujours un package complet et à jour.
Les codes sources livrés tout au long de cette série d'articles ont été réalisé sous Visual Studio.NET 2003 ; des test ont
également été effectués avec succès sur DevC++ 4.9.9.1. Quant aux autres compilateurs vous ne devriez pas rencontrer de
problème (pour peu qu'ils soient suffisamment récents), le code respectant autant que possible le standard. ![]() Screenshot de l'application exemple
Une version PDF de cet article est disponible : Télécharger (187 Ko)
Si vous avez des suggestions, remarques, critiques, si vous avez remarqué une erreur, ou bien si vous souhaitez des
informations complémentaires, n'hésitez pas à me contacter !
|
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 oeuvre intellectuelle protégée par les droits d'auteurs. Copyright © 2005 Laurent Gomila. 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'à 3 ans de prison et jusqu'à 300 000 E de dommages et intérêts. Cette page est déposée à la SACD.