Réalisation d'un moteur 3D en C++, partie V : les textures

Laurent Gomila

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.

- Titre : Réalisation d'un moteur 3D en C++, partie V : les textures

- Auteur : Laurent Gomila

- Parution : 1 août 2005

- Mise à jour : 1 septembre 2005

- Licence : 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 ® 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'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- 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 pixels

2-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 :

enum TPixelFormat
{
PXF_L8,       ///< Luminosité 8 bits
PXF_A8L8,     ///< Alpha et luminosité 16 bits
PXF_A1R5G5B5, ///< ARGB 16 bits 1555
PXF_A4R4G4B4, ///< ARGB 16 bits 4444
PXF_R8G8B8,   ///< RGB 24 bits 888
PXF_A8R8G8B8, ///< ARGB 32 bits 8888
PXF_DXTC1,    ///< Format compressé DXT1 8 bits
PXF_DXTC3,    ///< Format compressé DXT3 16 bits
PXF_DXTC5     ///< Format compressé DXT5 16 bits
};

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.

template <TPixelFormat SrcFmt, TPixelFormat DestFmt>
inline void ConvertPixel(const unsigned char* Src, unsigned char* Dest)
{
// Comportement par défaut : si la spécialisation n'existe pas
// c'est que la conversion n'est pas prise en charge
throw CUnsupported(std::string("Conversion software de format de pixel (") +
FormatToString(SrcFmt) +
" -> " +
FormatToString(DestFmt) +
")");
}

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 :

template <>
inline void ConvertPixel<PXF_L8, PXF_L8>(const unsigned char* Src, unsigned char* Dest)
{
*Dest = *Src;
}

template <>
inline void ConvertPixel<PXF_L8, PXF_A8L8>(const unsigned char* Src, unsigned char* Dest)
{
Dest[0] = *Src;
Dest[1] = 0xFF;
}

...

template <>
inline void ConvertPixel<PXF_A8L8, PXF_A1R5G5B5>(const unsigned char* Src, unsigned char* Dest)
{
*reinterpret_cast<unsigned short*>(Dest) = ((Src[1] >> 7) << 15) |
((Src[0] >> 3) << 10) |
((Src[0] >> 3) <<  5) |
((Src[0] >> 3) <<  0);
}

template <>
inline void ConvertPixel<PXF_A8L8, PXF_A4R4G4B4>(const unsigned char* Src, unsigned char* Dest)
{
Dest[0] = (Src[0] & 0xF0) | (Src[0] >> 4);
Dest[1] = (Src[1] & 0xF0) | (Src[0] >> 4);
}

...

// 6 formats : 6x6 = 36 spécialisations
// (les formats compressés ne sont pas pris en charge, c'est impossible avec cette stratégie :
// il faut les traiter par blocs de 2x2 pixels)

Attention à la manière dont vous allez manipuler vos pixels ! Sur les machines en little-endian, la composante alpha d'un pixel A8R8G8B8 ne sera pas forcément là où vous l'attendez par exemple. La gestion correcte de l'endianness ("boutisme" en français) est primordiale pour garantir un code portable. En l'occurence, le code ci-dessus ne fonctionne que sur les modèles little-endian. Pour ne pas faire de jaloux (notamment les Mac OS qui sont en big-endian) il faudrait faire deux versions de ces conversions, une pour chaque type de plateforme. La version à utiliser étant choisie à la compilation via des directives préprocesseur.

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).

inline void ConvertPixel(TPixelFormat SrcFmt, const unsigned char* Src,
TPixelFormat DestFmt, unsigned char* Dest)
{
// Définition d'une macro évitant d'avoir un code de 50 000 lignes de long
#define CONVERSIONS_FOR(Fmt) \
case Fmt : \
{ \
switch (DestFmt) \
{ \
case PXF_L8 :       ConvertPixel<Fmt, PXF_L8>(Src, Dest);       break; \
case PXF_A8L8 :     ConvertPixel<Fmt, PXF_A8L8>(Src, Dest);     break; \
case PXF_A1R5G5B5 : ConvertPixel<Fmt, PXF_A1R5G5B5>(Src, Dest); break; \
case PXF_A4R4G4B4 : ConvertPixel<Fmt, PXF_A4R4G4B4>(Src, Dest); break; \
case PXF_R8G8B8 :   ConvertPixel<Fmt, PXF_R8G8B8>(Src, Dest);   break; \
case PXF_A8R8G8B8 : ConvertPixel<Fmt, PXF_A8R8G8B8>(Src, Dest); break; \
case PXF_DXTC1 :    ConvertPixel<Fmt, PXF_DXTC1>(Src, Dest);    break; \
case PXF_DXTC3 :    ConvertPixel<Fmt, PXF_DXTC3>(Src, Dest);    break; \
case PXF_DXTC5 :    ConvertPixel<Fmt, PXF_DXTC5>(Src, Dest);    break; \
} \
break; \
}

// Gestion de la conversion - appelle la version optimisée de la conversion
// pour les deux formats mis en jeu
switch (SrcFmt)
{
CONVERSIONS_FOR(PXF_L8)
CONVERSIONS_FOR(PXF_A8L8)
CONVERSIONS_FOR(PXF_A1R5G5B5)
CONVERSIONS_FOR(PXF_A4R4G4B4)
CONVERSIONS_FOR(PXF_R8G8B8)
CONVERSIONS_FOR(PXF_A8R8G8B8)
CONVERSIONS_FOR(PXF_DXTC1)
CONVERSIONS_FOR(PXF_DXTC3)
CONVERSIONS_FOR(PXF_DXTC5)
}

// On tue la macro une fois qu'on n'en a plus besoin
#undef CONVERIONS_FOR
}

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.

class YES_EXPORT CImage
{
private :

TVector2I                  m_Size;
TPixelFormat               m_Format;
std::vector<unsigned char> m_Pixels;

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.

public :

CImage(const TVector2I& Size = TVector2I(1, 1), TPixelFormat Format = PXF_A8R8G8B8);
CImage(const TVector2I& Size, TPixelFormat Format, const unsigned char* Pixels);

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.

const TVector2I& GetSize() const;
TPixelFormat GetFormat() const;
const unsigned char* GetData() const;

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.

void SetPixel(int x, int y, const unsigned char* Pix);
void SetPixel(int x, int y, const CColor& Color);
void GetPixel(int x, int y, unsigned char* Pix) const;
CColor GetPixel(int x, int y) const;

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.

void CopyImage(const CImage& Src);
CImage SubImage(const CRectangle& Rect) const;
void Fill(const CColor& Color);
void Flip();
void Mirror();
};

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 :

- Le SDK Windows (Devil-SDK-1.6.7.zip), contenant :
- il.h : l'en-tête à inclure pour utiliser DevIL dans vos sources C/C++
- DevIL.dll : la dll à fournir avec votre programme
- DevIL.lib : la bibliothèque d'importation pour Visual C++, avec laquelle il faut lier votre programme utilisant DevIL

- Les sources de DevIL (DevIL.zip), contenant :
- il.def : le fichier de définition pour construire les bibliothèques d'importations avec d'autres compilos.

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 :

dlltool -D Devil.dll -d il.def -l libil.a

Vous voici maintenant muni de libil.a, avec lequel vous pourrez lier votre projet Dev-C++ pour utiliser DevIL.

Personnellement, la DLL téléchargée sur le site officiel et le .a généré avec la méthode précédente ne fonctionnaient pas à 100%. Après quelques comportements aléatoires, le moteur finissait toujours par planter. Avec une version fraîchement recompilée de la DLL (voir plus bas), tout fonctionnait sans problème. Mauvaise manip de ma part ou méthode foireuse ?

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 :

- Bien sûr créer un projet C "bibliothèque statique"

- Définir dans les options du compilo le symbole "IL_STATIC_LIB", autant pour compiler DevIL que pour l'utiliser

- Lier avec les différentes bibliothèques utilisées par DevIL : libjpeg, libpng, libmng, libtiff, zlib, ... Pour Dev-C++, le package ImageLib vous en fournira quelques unes

Vous pouvez explorer un peu les différents répertoires fournis avec ce que vous venez de télécharger, il y a de nombreux projets et exemple bâtis autour de DevIL (wrapper C++, MFC, GDI+, versions Delphi, Python, VB, ...). Jetez-y un oeil !

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>.

class CImagesLoader : public ILoader<CImage>
{
public :

// Constructeur par défaut
CImagesLoader();

// Destructeur
~CImagesLoader();

// Charge une image à partir d'un fichier
virtual CImage* LoadFromFile(const std::string& Filename);

// Enregistre une image dans un fichier
virtual void SaveToFile(const CImage* Object, const std::string& Filename);
};

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.

CImagesLoader::CImagesLoader()
{
// Initalisation de DevIL
ilInit();

// On indique que l'origine des images se trouve sur le coin haut-gauche
ilOriginFunc(IL_ORIGIN_UPPER_LEFT);
ilEnable(IL_ORIGIN_SET);

// On autorise l'écrasement de fichiers déjà existants, pour la sauvegarde
ilEnable(IL_FILE_OVERWRITE);

// On force le chargement des images en 32 bits BGRA
ilSetInteger(IL_FORMAT_MODE, IL_BGRA);
ilEnable(IL_FORMAT_SET);
}

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 :

CImagesLoader::~CImagesLoader()
{
// Fermeture de DevIL
ilShutDown();
}

Rien de bien compliqué pour le moment.

Intéressons nous à présent au chargement des images :

CImage* CImagesLoader::LoadFromFile(const std::string& Filename)
{
// Génération d'une nouvelle texture
ILuint Texture;
ilGenImages(1, &Texture);
ilBindImage(Texture);

// Chargement de l'image
if (!ilLoadImage(const_cast<ILstring>(Filename.c_str())))
throw CLoadingFailed(Filename, "Erreur DevIL : l'appel à ilLoadImage a échoué");

// Récupération de la taille de l'image
TVector2I Size(ilGetInteger(IL_IMAGE_WIDTH), ilGetInteger(IL_IMAGE_HEIGHT));

// Récupération des pixels de l'image
const unsigned char* Pixels = ilGetData();

// Création de l'image
CImage* Image = new CImage(Size, PXF_A8R8G8B8, Pixels);

// Suppression de la texture
ilBindImage(0);
ilDeleteImages(1, &Texture);

// On log le chargement
ILogger::Log() << "Chargement de l'image : " << Filename << "\n";

return Image;
}

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.

Petite parenthèse purement C++ : si vous avez remarqué le const_cast barbare en ILstring, alors vous avez gagné un bon point. Il s'agit sans doute simplement d'une petite erreur de coding des concepteurs de la bibliothèque (cette fonction devrait prendre un const char* et non un char*).

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 :

void CImagesLoader::SaveToFile(const CImage* Object, const std::string& Filename)
{
// On crée une copie de l'image dans un format compatible avec DevIL (ARGB 32 bits)
CImage Image(Object->GetSize(), PXF_A8R8G8B8);
Image.CopyImage(*Object);

// On retourne l'image, sans quoi elle apparaîtrait à l'envers
Image.Flip();

// Génération d'une nouvelle texture
ILuint Texture;
ilGenImages(1, &Texture);
ilBindImage(Texture);

// Récupération des dimensions de l'image
const TVector2I& Size = Image.GetSize();

// Dimensionnement et remplissage de la texture avec les pixels convertis
if (!ilTexImage(Size.x, Size.y, 1, GetBytesPerPixel(Image.GetFormat()),
IL_BGRA, IL_UNSIGNED_BYTE, (void*)Image.GetData()))
throw CLoadingFailed(Filename, "Erreur DevIL : l'appel à ilTexImage a échoué");

// Sauvegarde de la texture
if (!ilSaveImage(const_cast<ILstring>(Filename.c_str())))
throw CLoadingFailed(Filename, "Erreur DevIL : l'appel à ilSaveImage a échoué");

// Suppression de la texture
ilBindImage(0);
ilDeleteImages(1, &Texture);

// On log la sauvegarde
ILogger::Log() << "Sauvegarde de l'image : " << Filename << "\n";
}

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.

// Quelque part dans les initialisations du moteur...
CMediaManager& MediaManager = CMediaManager::Instance();
MediaManager.RegisterLoader(new CImagesLoader, "bmp, dds, jpg, pcx, png, pnm, raw, sgi, tga, tif");

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

// Liste des medias gérés (d'autres s'ajouteront par la suite)
typedef TYPELIST_1(CImage) MediaList;

class CMediaManager : public CSingleton<CMediaManager>,
public CScatteredHierarchy<MediaList, CMediaHolder>
{
...
};

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 textures

5-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.

class YES_EXPORT ITextureBase : public IResource
{
public :

// Destructeur
virtual ~ITextureBase();

protected :

// Amis
friend class CTexture;

// Constructeur
ITextureBase(const TVector2I& Size, TPixelFormat Format, bool HasMipmaps, bool AutoMipmaps);

// Met à jour les pixels de la texture
virtual void Update(const CRectangle& Rect) = 0;

// Données membres
TPixelFormat m_Format;      ///< Format de pixel de la texture
TVector2I    m_Size;        ///< Dimensions de la texture
CImage       m_Data;        ///< Copie système du contenu de la texture
bool         m_HasMipmaps;  ///< Indique si la texture possède des niveaux de mipmapping
bool         m_AutoMipmaps; ///< Indique si la texture peut générer ses mips en hardware
};

Quelques remarques sur ce code :

- Tout d'abord, cette classe CTexture qui nous est inconnue. Et bien ce sera la classe enveloppe, celle qui va manipuler les pointeurs sur ITextureBase et offir des fonctionnalités supplémentaires. Comme cela sera la seule à manipuler des ITextureBase, nous pouvons mettre toutes les fonctions membres en accès protégé et déclarer CTexture en amie. Ainsi on est certain que personne d'autre ne pourra faire joujou (intentionnellement ou par mégarde) avec les mécanismes internes de nos textures. Choses d'autant plus importante qu'on a vite fait de provoquer un plantage du système avec une mauvaise manip sur le remplissage de la texture.

- Ensuite, vous remarquerez que chaque texture va stocker une copie système de son contenu, sous forme de CImage. Cela aura plusieurs rôles : permettre à l'utilisateur de faire mumuse sur les pixels "gratuitement" -- le seul coût sera lors de l'appel à Update, de pouvoir reconstruire la texture sans rien demander à l'utilisateur, dans le cas où celle-ci serait déchargée de la mémoire video (perte du device avec Dx, etc.), ainsi que de faire des lectures des pixels sans réclamer ceux-ci à la mémoire vidéo à chaque fois. Attention cependant, cette stratégie peut se réveler inefficace si l'application utilise intensivement la mémoire vive : dans ce cas, laisser ce choix à l'utilisateur peut être une solution. Mais pas de panique, c'est une situation qui ne devrait se présenter que dans des cas exceptionnels.

- La fonction Update est l'élément principal de cette classe : c'est cette fonction (virtuelle) qui va se charger de mettre à jour le contenu de la texture, à partir d'une partie de l'image stockée en mémoire système (membre m_Data).

- Nos textures dérivent de IResource et à juste titre : elles vont nécessiter la gestion spéciale mise en place pour les ressources dans le tutoriel précédent, à savoir une identification unique qui permettra de ne pas les charger inutilement plusieurs fois et de les retrouver facilement.

- Dernière remarque, les membres HasMipmaps et AutoMipmaps servent à la gestion du mip-mapping. Nous verrons cet aspect plus en détail plus tard.

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 :

1. Les pixels à copier sont dans un format compressé : dans ce cas il faut utiliser une extension, glCompressedTexSubImage2DARB

2. La texture ne possède pas de niveaux de mip-mapping, ou peut les générer en hardware : on utilise glTexSubImage2D

3. La texture possède des niveaux de mip-mapping et ne peut les générer en hardware : on relègue ce boulot à gluBuild2DMipmaps

Attention : l'utilisation de gluBuild2DMipmaps avec les formats internes les plus "exotiques" (GL_UNSIGNED_SHORT_1_5_5_5_REV, ...) nécessite GLU 1.3, qui est n'est pas toujours disponible. Les fichiers permettant d'utiliser GLU 1.3 sont fournis avec le moteur.

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.

ITextureBase* COGLRenderer::CreateTexture(const TVector2I& Size, TPixelFormat Format,
unsigned long Flags) const
{
unsigned int Texture;
glGenTextures(1, &Texture);
glBindTexture(GL_TEXTURE_2D, Texture);

Ensuite, on définit quelques paramètres de la texture : bordure, filtrage, etc...

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_NEAREST);

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.

// Détermination du nombre de niveaux de mipmapping
int NbMipmaps = Flags & TEX_NOMIPMAP ? GetNbMipLevels(Size.x, Size.y) : 0;
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, NbMipmaps);

// S'il y a plusieurs niveaux de mipmaps et si le système le supporte,
// on active la génération hardware des mipmaps
if ((NbMipmaps > 0) && (HasCapability(CAP_HW_MIPMAPPING)))
glTexParameteri(GL_TEXTURE_2D, GL_GENERATE_MIPMAP_SGIS, GL_TRUE);

// Allocation de la mémoire pour tous les niveaux de mipmapping
int Width  = Size.x;
int Height = Size.y;
for (int i = 0; i <= NbMipmaps; ++i)
{
// Création du i-ème niveau
glTexImage2D(GL_TEXTURE_2D, i, COGLEnum::Get(Format).Internal,
Width, Height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);

// Division par 2 des dimensions
if (Width > 1)  Width  /= 2;
if (Height > 1) Height /= 2;
}

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.

// Désactivation de la texture
glBindTexture(GL_TEXTURE_2D, 0);

return new COGLTexture(Size, Format, NbMipmaps > 0,
HasCapability(CAP_HW_MIPMAPPING), Texture);
}

Quant à SetTexture, rien de bien méchant :

void COGLRenderer::SetTexture(unsigned int Unit, const ITextureBase* Texture) const
{
glActiveTextureARB(GL_TEXTURE0_ARB + Unit);
const COGLTexture* OGLTexture = static_cast<const COGLTexture*>(Texture);

if (Texture)
{
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, OGLTexture->GetOGLTexture());
}
else
{
glDisable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, 0);
}
}

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.

ITextureBase* CDX9Renderer::CreateTexture(const TVector2I& Size, TPixelFormat Format,
unsigned long Flags) const
{
// Détection du nombre de niveaux de mips et du mipmapping automatique
bool HasMipmaps  = (Flags & TEX_NOMIPMAP) != 0;
bool AutoMipmaps = HasCapability(CAP_HW_MIPMAPPING) &&
m_Direct3D->CheckDeviceFormat(D3DADAPTER_DEFAULT,
D3DDEVTYPE_HAL,
D3DFMT_X8R8G8B8,
D3DUSAGE_AUTOGENMIPMAP,
D3DRTYPE_TEXTURE,
CDX9Enum::Get(Format)) != D3D_OK;

// Ajustement de l'usage
unsigned long Usage = AutoMipmaps ? D3DUSAGE_AUTOGENMIPMAP : 0;

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 !

// Création de la texture Dx9
LPDIRECT3DTEXTURE9 Texture = NULL;
if (FAILED(D3DXCreateTexture(m_Device, Size.x, Size.y, 0, Usage,
CDX9Enum::Get(Format), D3DPOOL_MANAGED, &Texture)))
throw CDX9Exception("D3DXCreateTexture", "CreateTexture");

Finalement, on peut créer la CDX9Texture correspondante et la renvoyer.

return new CDX9Texture(Size, Format, HasMipmaps, AutoMipmaps, Texture);
}

Pour ce qui est de SetTexture ça tient en deux lignes : récupération de la texture Dx9, et envoi à l'API.

void CDX9Renderer::SetTexture(unsigned int Unit, const ITextureBase* Texture) const
{
const CDX9Texture* DxTexture = static_cast<const CDX9Texture*>(Texture);
m_Device->SetTexture(Unit, DxTexture ? DxTexture->GetDxTexture() : NULL);
}

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.

class YES_EXPORT CTexture
{
private :

// Données membres
CSmartPtr<ITextureBase, CResourceCOM> m_Texture; ///< Pointeur sur la texture

public :

// Opérateurs de comparaison
bool operator ==(const CTexture& Texture) const;
bool operator !=(const CTexture& Texture) const;

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, ...).

// Accesseurs
CImage& GetPixels();
const TVector2I& GetSize() const;
TPixelFormat GetFormat() const;
const std::string& GetName() const;
const ITextureBase* GetTexture() const;

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).

// Crée la texture
void Create(const TVector2I& Size, TPixelFormat Format,
unsigned long Flags = 0, const std::string& Name = "");

// Crée la texture à partir d'un fichier
void CreateFromFile(const std::string& Filename, TPixelFormat Format,
unsigned long Flags = 0);

// Crée la texture à partir d'une image
void CreateFromImage(const CImage& Image, TPixelFormat Format,
unsigned long Flags = 0, const std::string& Name = "");

// Sauvegarde la texture dans un fichier
void SaveToFile(const std::string& Filename) const;

// Met à jour les pixels de la texture
void Update(const CRectangle& Rect = CRectangle(-1, -1, -1, -1));

private :

// Crée la texture et la remplit avec les pixels passés en paramètre
void Load(const CImage& Image, TPixelFormat Format,
unsigned long Flags, const std::string& Name);
};

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.

void CTexture::Load(const CImage& Pixels, TPixelFormat Format,
unsigned long Flags, const std::string& Name)
{
// Si le format est compressé et que la compression n'est pas supportée,
// on passe à un format standard (PXF_A8R8G8B8)
if (FormatCompressed(Format) && !Renderer.HasCapability(CAP_DXT_COMPRESSION))
{
Format = PXF_A8R8G8B8;
ILogger::Log() << "WARNING - "
<< "Format compressé choisi, mais non supporté par le système de rendu. "
<< "Le format utilisé sera PXF_A8R8G8B8."
<< (Name != "" ? " (texture : \"" + Name + "\")" : "") << "\n";
}

// Si le système requiert des dimensions en puissances de 2 et que ce n'est pas le cas,
// on modifie celles-ci
TVector2I Size(NearestPowerOfTwo(Pixels.GetSize().x), NearestPowerOfTwo(Pixels.GetSize().y));
if ((Size != Pixels.GetSize()) && !Renderer.HasCapability(CAP_TEX_NON_POWER_2))
{
ILogger::Log() << "WARNING - Dimensions de texture non-puissances de 2, "
<< "mais non supporté par le système de rendu. "
<< "Les dimensions seront ajustées. "
<< "(" << Pixels.GetSize().x << "x" << Pixels.GetSize().y
<< " -> " << Size.x << "x" << Size.y << ")" << "\n";
}
else
{
Size = Pixels.GetSize();
}

try
{
// Création de la texture
m_Texture = Renderer.CreateTexture(Size, Format, Flags);
}
catch (const std::exception& E)
{
// IRenderer::CreateTexture ne connait pas le nom de la texture,
// ainsi si l'on veut mieux renseigner l'utilisateur il faut rattraper
// les exceptions et en relancer une plus appropriée (avec le nom inside)
throw CLoadingFailed(Name, E.what());
}

// Ajout aux ressources
if (Name != "")
CResourceManager::Instance().Add(Name, m_Texture);

// Si le format est compressé on change le format de la copie système de la
// texture (PXF_A8R8G8B8) -- la compression software n'est pas supportée par le moteur
if (FormatCompressed(Format))
GetPixels() = CImage(GetSize(), PXF_A8R8G8B8);

// Copie des pixels
GetPixels().CopyImage(Pixels);
Update();
}

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 :

void CTexture::Create(const TVector2I& Size, TPixelFormat Format,
unsigned long Flags, const std::string& Name)
{
// Création de la texture vide --
// afin qu'elle ait un contenu valide on la remplit avec une image par défaut
CreateFromImage(CImage(Size, Format), Format, Flags, Name);
}

void CTexture::CreateFromFile(const std::string& Filename, TPixelFormat Format,
unsigned long Flags)
{
// On regarde si la texture n'a pas déjà été chargée
m_Texture = CResourceManager::Instance().Get<ITextureBase>(Filename);

// Si elle ne l'est pas, on la charge
if (!m_Texture)
{
// Chargement de l'image
CSmartPtr<CImage> Image = CMediaManager::Instance().LoadMediaFromFile<CImage>(Filename);

// Création de la texture
Load(*Image, Format, Flags, Filename);
}
}

void CTexture::CreateFromImage(const CImage& Image, TPixelFormat Format,
unsigned long Flags, const std::string& Name)
{
// On regarde si la texture n'a pas déjà été chargée
m_Texture = CResourceManager::Instance().Get<ITextureBase>(Name);

// Si elle ne l'est pas, on la charge
if (!m_Texture)
Load(Image, Format, Flags, Name);
}

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.

enum TCapability
{
CAP_HW_MIPMAPPING,   ///< Mipmapping automatique en hardware
CAP_DXT_COMPRESSION, ///< Compression de texture DXTC
CAP_TEX_NON_POWER_2, ///< Dimensions de textures pas nécessairement en puissances de 2
};

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 :

class YES_EXPORT IRenderer
{
public :

// ...

// Indique si une fonctionnalité est supportée par le renderer
bool HasCapability(TCapability Cap) const;

protected :

// Vérifie les fonctionnalités supportées et met à jour la table des fonctionnalités
virtual void CheckCaps() = 0;

// Données membres
std::map<TCapability, bool> m_Capabilities; ///< Table des fonctionnalités

// ...
};

bool IRenderer::HasCapability(TCapability Cap) const
{
// Ici on ne peut pas utiliser l'opérateur [], bicoze on se trouve dans une fonction const

Assert(m_Capabilities.find(Cap) != m_Capabilities.end());

return m_Capabilities.find(Cap)->second;
}

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.

void CDX9Renderer::CheckCaps()
{
// Récupération des caps Dx9
D3DCAPS9 Caps;
m_Device->GetDeviceCaps(&Caps);

// Support de la génération de mipmaps hardware
m_Capabilities[CAP_HW_MIPMAPPING] = (Caps.Caps2 & D3DCAPS2_CANAUTOGENMIPMAP) != 0;

// Support de la compression de texture
m_Capabilities[CAP_DXT_COMPRESSION] = CheckFormat(D3DFMT_DXT1, D3DRTYPE_TEXTURE) &&
CheckFormat(D3DFMT_DXT3, D3DRTYPE_TEXTURE) &&
CheckFormat(D3DFMT_DXT5, D3DRTYPE_TEXTURE);

// Support des dimensions de texture non-puissances de 2
m_Capabilities[CAP_TEX_NON_POWER_2] = !(Caps.TextureCaps & D3DPTEXTURECAPS_POW2) &&
!(Caps.TextureCaps & D3DPTEXTURECAPS_NONPOW2CONDITIONAL);
}

bool CDX9Renderer::CheckFormat(D3DFORMAT Format, D3DRESOURCETYPE ResourceType, unsigned long Usage)
{
Assert(m_Direct3D != NULL);

return SUCCEEDED(m_Direct3D->CheckDeviceFormat(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL,
D3DFMT_X8R8G8B8,
Usage, ResourceType, Format));
}

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.

void COGLRenderer::CheckCaps()
{
// Support de la génération de mipmaps hardware
m_Capabilities[CAP_HW_MIPMAPPING] = CheckExtension("GL_SGIS_generate_mipmap");

// Support de la compression de texture
m_Capabilities[CAP_DXT_COMPRESSION] = CheckExtension("GL_ARB_texture_compression") &&
CheckExtension("GL_EXT_texture_compression_s3tc");

// Support des dimensions de texture non-puissances de 2
m_Capabilities[CAP_TEX_NON_POWER_2] = CheckExtension("GL_ARB_texture_non_power_of_two");
}

bool COGLRenderer::CheckExtension(const std::string& Extension) const
{
return m_Extensions.find(Extension) != std::string::npos;
}

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.

// Déclaration de la texture
CTexture Texture;

// Chargement de l'image
Texture.CreateFromFile("Mouton.dds", PXF_DXTC5);

// On va changer un pixel, juste pour le fun
CImage& Pixels = Texture.GetPixels();
Pixels.SetPixel(0, 0, CColor(0, 128, 128));
Texture.Update();

// Finalement, on peut l'envoyer à l'API pour le rendu
Renderer.SetTexture(0, &Texture);

// Tiens, et pourquoi pas en monochrome et en plus petit ?
CTexture NewTexture;
CImage NewImage(TVector2I(64, 64), PXF_L8);
NewImage.CopyImage(Texture.GetPixels());
NewTexture.CreateFromImage(NewImage, PXF_L8);

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.

Télécharger les sources de ce tutoriel (zip, 1.74 Mo)

Screenshot de l'application exemple

Pour compiler, vous aurez également besoin des bibliothèques externes (zip)

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 !