Réalisation d'un moteur 3D en C++ - partie IV : gestion des ressources

Laurent Gomila

Pour cette nouvelle partie, nous allons délaisser un peu nos API 3D. Nous verrons comment gérer efficacement les différents types de ressources : importation / exportation, partage, gestion de la durée de vie, et plein d'autres bonnes choses.

- Titre : Réalisation d'un moteur 3D en C++ - partie IV : gestion des ressources

- Auteur : Laurent Gomila

- Parution : 13 janvier 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. Gestion interne des ressources

- 2-1. Les ressources

- 2-1-1. Une classe de base pour tous les types de ressources

- 2-1-2. Evolution des CSmartPtr - les polices

- 2-2. Le ResourceManager

- 3. Importation et exportation

- 3-1. Les loaders

- 3-2. Le MediaManager

- 4. Conclusion

- 5. Téléchargements

- 6. Remerciements

1 - Introduction

Un moteur 3D, et plus généralement un jeu, va être amené à manipuler un grand nombre de ressources. Celles-ci peuvent être très variées : textures, shaders, modèles, matériaux, ... Tout ce petit monde devra être géré de la manière la plus efficace possible, afin de garantir une utilisation mémoire et des performances optimales. Imaginez un cas très simple : une forêt, composée de centaines d'arbres partageant la même texture. Si l'on gère ça "à la barbare", on va se retrouver à charger 100 fois la même texture, la stocker 100 fois en mémoire vidéo, et l'envoyer 100 fois de suite à l'API. Le bon partage des ressources est donc une fonctionnalité primordiale ici. Mais partage est souvent synonyme de problèmes de gestion de la mémoire. En effet, comment savoir quand telle ou telle ressource ne sera plus utilisée, et donc quand la décharger ? Comment savoir qu'une texture a déjà été chargée, et comment retrouver un pointeur vers celle-ci ? Ce sont tous ces aspects que nous allons étudier ici, au travers de différentes classes qui viendront enrichir notre moteur. Nous allons notamment séparer les traitements en deux parties : un côté "interne" (gestion et partage des ressources chargées) avec le ResourceManager, et un côté "externe" (localisation des fichiers, importation et exportation) avec le MediaManager.

Pour nous mettre un peu dans le bain, voici un schéma résumant les différents mécanismes mis en oeuvre lors du chargement d'une ressource, ainsi qu'un aperçu des classes / fonctions auxquelles nous allons faire appel à chaque étape :

2 - Gestion interne des ressources

Comme précisé en introduction, la gestion des ressources se décompose en deux tâches distinctes. Il y a le chargement et la sauvegarde des fichiers de ressources, et entre les deux bien sûr la bonne gestion de ces dernières. C'est cette gestion que nous allons détailler dans ce chapitre.

2-1 - Les ressources

2-1-1 - Une classe de base pour tous les types de ressources

Pour être gérées correctement, nos ressources devront posséder certaines caractéristiques :

- Un identifiant unique, qui permettra de les retrouver une fois chargées. Habituellement on donne aux ressources un nom, sous forme de chaîne de caractères.

- Un compteur de références, indispensable pour que plusieurs objets puissent partager une même ressource.

- Déchargement automatique lorsque plus personne n'a besoin de la ressource, de manière à ne pas encombrer la mémoire inutilement.

- Tous les types de ressources doivent dériver d'une base commune, à travers laquelle elles pourront être manipulées par le gestionnaire.

Il apparaît donc indispensable de construire une classe abstraite pour nos ressources :

class YES_EXPORT IResource
{
public :

// Constructeur par défaut
IResource();

// Destructeur
virtual ~IResource() = 0;

// Renvoie le nom associé à la ressource
const std::string& GetName() const;

// Ajoute une référence sur la ressource
void AddRef();

// Retire une référence sur la ressource
int Release();

private :

// Données membres
std::string m_Name;     // Nom associé à la ressource
int         m_RefCount; // Compteur de références
};

Par la suite, lorsque nous créerons une nouvelle classe de ressource, il suffira de la faire dériver de IResource.
On appelera AddRef() lorsqu'on voudra acquérir une ressource (ce qui incrémentera juste m_RefCount), et on appelera Release() lorsqu'on n'aura plus besoin de la ressource (ce qui décrémentera juste m_RefCount). Lorsque le compteur sera arrivé à zéro, cela voudra dire que plus personne n'a besoin de la ressource et celle-ci pourra donc se détruire sans risque.
Cette classe ne servira jamais polymorphiquement (ie. il ne sera pas nécessaire de manipuler des IResource* pointant sur des classes dérivées), c'est pourquoi elle ne possède aucune fonction virtuelle. Par contre cette classe n'a pour vocation que d'être dérivée, elle doit donc être abstraite. C'est pour cela que nous définissons son destructeur virtuel pur. Cela n'a aucun sens particulier, si ce n'est de rendre la classe non-instanciable comme nous le souhaitons. C'est une astuce permise par le C++ pour rendre abstraite une classe qui ne possède pas d'autre fonction virtuelle pure.

Un autre moyen de rendre une classe de base abstraite, est de mettre son constructeur protégé. Ainsi il sera accessible seulement des classes dérivées.

2-1-2 - Evolution des CSmartPtr - les polices

Avant toute chose, si vous n'avez pas suivi cette série d'articles depuis le début vous pourrez trouver les détails de notre classe de pointeurs intelligents dans le tout premier article.

Comme les ressources sont des données partagées, elles ne seront jamais manipulées directement mais toujours via des pointeurs. Seul le gestionnaire gèrera la durée de vie des ressources, le reste du moteur lui ne fera que venir pointer sur telle ou telle ressource déjà stockée. Mais rappelez-vous, une ressource possède un compteur de références et celui-ci doit donc être constamment à jour pour garantir un fonctionnement correct. Lorsqu'une classe voudra acquérir une ressource, il faudra donc qu'elle incrémente le compteur de références de celle-ci (via AddRef). De même lorsqu'elle n'en aura plus besoin, elle le décrémentera (via Release). Si vous êtes familiers avec la programmation DirectX voire de manière plus général Windows (les objets COM pour être exact), vous pourrez constater que le fonctionnement, tout comme la syntaxe d'ailleurs, sont similaires.
Bref, les pointeurs sont délicats à manipuler, mais ceux-ci le seront encore plus ! Un Release oublié et c'est la fuite mémoire ; un AddRef en moins et vous aurez droit au crash. La solution serait donc un genre de pointeur intelligent, mais cette fois encore plus intelligent car ces pointeurs ne devront pas être gérés de la même manière que les autres. La principale différence est que le comptage de référence est intrusif (c'est la classe pointée qui gère le compteur), alors que notre CSmartPtr gère déjà un compteur de références externe. Il va donc falloir ajouter un peu de flexibilité à notre pointeur intelligent, à savoir lui permettre de gérer tout aussi bien les pointeurs sur ressources que les pointeurs quelconques. Au passage, puisque la syntaxe est identique, nous allons pouvoir profiter de cette amélioration pour gérer aussi les objets COM (les objets DirectX notamment), c'est tout benef'.

Pour arriver à nos fins, nous allons utiliser un concept à la fois nouveau, simple et puissant (génial non ?!) : les polices (policies pour nos amis anglophones). Les polices sont des classes ou structures (c'est la même chose) qui seront utilisées par des fonctions / classes afin de personnaliser un comportement précis, sans avoir à modifier lesdites fonctions ou classes. Ainsi une police sera généralement prise comme paramètre template, et il suffira donc d'y implémenter les fonctions attendues par l'entité qui l'utilise.
Au lieu de faire un long discours voilà plutôt un exemple, totalement inutile mais simplissime, qui vous aidera à comprendre ce que sont les polices et comment on les utilise.

// Fonction qui renvoie true si les deux chaînes sont identiques
// On suppose qu'elles ont toutes deux la même taille
template <class Policy>
bool StringComp(const std::string& s1, const std::string& s2)
{
for (std::size_t i = 0; i < s1.size(); ++i)
if (Policy::Compare(s1[i], s2[i]) == false)
return false;

return true;
}

// Police de comparaison qui tient compte de la casse
struct CompCase
{
static bool Compare(char c1, char c2) {return c1 == c2;}
};

// Police de comparaison qui ne tient pas compte de la casse
struct CompNoCase
{
static bool Compare(char c1, char c2) {return tolower(c1) == tolower(c2);}
};

int main()
{
bool b1 = StringComp<CompCase>("BonJouR", "bonjour");   // False !
bool b2 = StringComp<CompNoCase>("BonJouR", "bonjour"); // True !

return 0;
}

Grâce à l'introduction d'une police dans la fonction StringComp, nous pouvons maintenant personnaliser comme bon nous semble la comparaison. En l'occurence ici on pourra comparer en tenant compte de la casse ou sans, et on pourrait imaginer toute sorte d'autre comportement. Et le plus important, c'est qu'on ne touche pas à la fonction originale : StringComp fait simplement l'hypothèse que la police contiendra une fonction Compare, prenant en paramètre 2 caractères et renvoyant un booléen. Si la police ne contient pas une telle fonction nous aurons bien sûr droit à une erreur de compilation.
Ce mécanisme est très utilisé dans la bibliothèque standard du C++. Vous ne l'avez peut-être pas remarqué car par défaut on n'a pas à s'en soucier directement, mais les classes que l'on manipule sont pleines de polices. Par exemple la classe string, qui n'est en fait rien d'autre qu'un basic_string<char, char_traits<char> >. char_traits est une police qui permet de personnaliser le comportement des chaînes : elle fournit la comparaison de caractères, la recherche, le calcul de longueur, etc... Ainsi pour fabriquer une classe de chaînes de caractères qui ne tienne pas compte de la casse (par exemple) il suffit d'écrire la police adéquate et de créer un nouveau type basic_string<char, ma_police<char> >. Aucunement besoin de recoder std::string de A à Z ! Voir par exemple l'item n°29 des GOTW si cela vous intéresse.

Revenons maintenant à nos pointeurs intelligents. Nous voulons une gestion personnalisée de la donnée pointée ? Bien, il n'y a qu'à introduire une police ! Comme pour toute police, il faut tout d'abord lui définir un rôle précis. Ici rappelez-vous, elle devra définir la copie et la destruction. Plus précisément, nous définirons deux fonctions : Clone et Release. Et une petite troisième, Swap, qui est utilisée par l'opérateur d'affectation. Enfin, notre police devra prendre un paramètre template : le même que CSmartPtr, à savoir le type de la donnée pointée. Voici donc le prototype correspondant, il est impératif que nos polices s'y conforment pour être utilisables. Notez bien que ceci n'est pas une "vraie" classe (elle n'existe pas dans le moteur), il s'agit juste d'une indication quant au contenu de nos futures classes de police, afin que celles-ci soient compatibles avec CSmartPtr. Typiquement on la retrouvera dans la documentation du moteur, par exemple.

template <class T>
struct Policy
{
// Clone la ressource
T* Clone(T*);

// Gère la libération de la ressource
void Release(T*);

// Echange deux instances
void Swap(Policy&);
};

Et voici à quoi ressemblerait maintenant notre classe de pointeurs intelligents, enrichie de cette police :

template <class T, template <class> class Ownership = CRefCount>
class CSmartPtr : private Ownership<T>
{
// ...

private :

T* m_Data;
};

Nous introduisons tout d'abord un paramètre template supplémentaire, à savoir la police que l'on souhaite utiliser. Vous remarquerez qu'on fait hériter CSmartPtr de ladite police : c'est le moyen le plus simple d'utiliser les fonctions définies dans celle-ci. Nous utilisons un héritage privé car nous n'aurons besoin d'accéder à OwnerShip<T> qu'en interne dans CSmartPtr. En fait l'héritage privé est équivalent à une composition (ie. avoir un OwnerShip<T> en donnée membre), ce que nous aurions également pu faire.
Vous remarquerez également autre chose : on donne une valeur par défaut à la police, ainsi les CSmartPtr<T> qui traînent déjà dans le reste du code n'auront pas besoin d'être modifiés, il auront par défaut la police de comptage de référence externe (ie. ce qui était codée "en dur" dans CSmartPtr avant modification). Pour les ressources et les objets COM, nous aurons maintenant des CSmartPtr<T, CResourceCOM>, CResourceCOM étant la police qui gère les... ressources et objets COM.
Dernière modification ici : on ne stocke plus que la donnée pointée, le compteur de référence sera géré par la police correspondante.

Votre compilateur ne supporte peut-être pas les paramètres templates templates, si c'est le cas vous pouvez modifer légèrement le code de la manière suivante :

template <class T, class Ownership = CRefCount<T> >
class CSmartPtr : private Ownership
{
// ...
};

// Cela introduit une petite redondance dans l'instanciation :
CSmartPtr<Truc, CRefCount<Truc> > Pointer;

Si c'est vous qui ne supportez pas les templates templates, ce n'est pas si compliqué : il suffit de garder en tête que les paramètres templates d'une classe font partie de son "type" :

// Classe template quelconque
template <class T, int I, bool B>
class Bidule {};

// Classe pouvant recevoir un Bidule en paramètre template
template <template <class, int, bool> class A>
class Truc {};

// On pourra écrire :
Truc<Bidule> Obj;

Voilà pour cette petite parenthèse.
Continuons maintenant avec les modifications dans l'implémentation de notre classe :

template <class T, template <class> class Ownership>
inline CSmartPtr<T, Ownership>::CSmartPtr(const CSmartPtr<T, Ownership>& Copy) :
Ownership<T>(Copy),
m_Data      (Clone(Copy.m_Data))
{

}

Le constructeur par copie est modifié, la copie de la donnée pointée est maintenant reléguée à la police via sa fonction Clone.

template <class T, template <class> class Ownership>
inline CSmartPtr<T, Ownership>::~CSmartPtr()
{
Release(m_Data);
}

De la même manière, dans le destructeur on ne détruit plus directement la donnée, mais on fait appel à la fonction Release de la police qui effectuera le traitement adéquat pour la libérer correctement.

template <class T, template <class> class Ownership>
inline void CSmartPtr<T, Ownership>::Swap(CSmartPtr<T, Ownership>& Ptr)
{
Ownership<T>::Swap(Ptr);
std::swap(m_Data, Ptr.m_Data);
}

Enfin il ne faut pas oublier de faire appel à Swap, au cas où la police contienne des données membres.

Nous avons maintenant besoin de polices pour nourrir notre pointeur intelligent, à savoir le comptage de référence externe (ce qu'on avait avant directement dans CSmartPtr), et le comptage de référence intrusif (objets COM et IResource). Leur implémentation ne présente aucune difficulté, il suffit dans un cas de faire appel à AddRef / Release, dans l'autre d'incrémenter / décrémenter un compteur externe.

// Comptage de référence externe
template <class T>
class CRefCount
{
public :

// Constructeur par défaut
CRefCount() : m_Counter(new int(1)) {}

// Clone la ressource
T* Clone(T* Ptr)
{
++*m_Counter;
return Ptr;
}

// Gère la libération de la ressource
void Release(T* Ptr)
{
if (--*m_Counter == 0)
{
delete m_Counter;
delete Ptr;
}
}

// Echange deux instances
void Swap(CRefCount& RefCount)
{
std::swap(RefCount.m_Counter, m_Counter);
}

private :

int* m_Counter;
};

// Objets COM et ressources du moteur - comptage de référence intrusif
template <class T>
class CResourceCOM
{
public :

// Clone la ressource
static T* Clone(T* Ptr)
{
if (Ptr)
Ptr->AddRef();
return Ptr;
}

// Gère la libération de la ressource
static void Release(T* Ptr)
{
if (Ptr)
Ptr->Release();
}

// Echange deux instances - aucune donnée membre : ne fait rien
static void Swap(CResourceCOM&) {}
};

Les fonctions de nos polices peuvent être statiques ou non, cela ne change en rien leur utilisation. Pour les polices ne comportant aucune donnée membre, il vaudra mieux tout mettre en static, ainsi on économisera un chouïa en performances (dans les fonctions statiques, l'objet implicite this n'est pas trimballé en interne).
Bien sûr si l'utilisateur souhaite gérer un type de pointeur qui diffère encore, il pourra très bien écrire sa police et l'utiliser. Notre pointeur intelligent est maintenant beaucoup plus flexible.

Si le concept de polices vous intéresse, il est très bien détaillé dans le livre Modern C++ Design d'Andrei Alexandrescu. Sa classe de pointeurs intelligents utilise abondamment les polices (police de threading, police de gestion de la mémoire, ...). Ce chapitre est justement disponible online, je vous invite donc à le consulter.

Eh bien voilà, après tant d'efforts nous voilà maintenant avec un moyen de gérer correctement et surtout de manière totalement automatique les ressources et objets COM, aussi bien que les pointeurs bruts classiques. Et croyez-moi, cela valait vraiment le coup !

2-2 - Le ResourceManager

Comme vous l'aurez certainement deviné, nos ressources auront besoin d'être correctement gérées. Elles auront donc besoin d'un... gestionnaire. Celui-ci aura plusieurs tâches à accomplir :

- Stocker toutes les ressources.

- Retrouver une ressource à partir de son identifiant.

- Référencer les ressources nouvellement créées.

- Supprimer les ressources inutiles.

Nous aurons donc besoin ici d'un genre de flyweight factory. Le fonctionnement de ce design pattern est relativement simple : nous disposons d'une fabrique qui renvoie des instances dérivées d'une classe de base, selon un identifiant. Si l'instance correspondant à l'identifiant a déjà été chargée, on la renvoie, sinon on la crée et on la stocke (et on la renvoie également, bien sûr). Ce qui est exactement ce que nous voulons, à la différence que notre gestionnaire ne va pas gérer la construction des ressources. Si une ressource n'est pas chargée alors il renverra simplement un pointeur nul, en laissant le soin à l'appelant de construire la ressource. Pourquoi ? Parce qu'une même ressource pourra être créée de différentes manières, on ne peut pas généraliser le processus. Le déchargement sera lui par contre automatisé : lorsque le compteur de références d'une ressource atteindra zéro, celle-ci se détruira et se retirera de la liste des ressources chargées.

Voici le petit diagramme pseudo-UML résumant la situation :

Nous avons maintenant tout ce qu'il faut pour mettre sur pied notre ResourceManager, voici les morceaux les plus importants :

class YES_EXPORT CResourceManager : public CSingleton<CResourceManager>
{
friend class CSingleton<CResourceManager>;

public :

// Récupère une ressource
template <class T> T* Get(const std::string& Name) const;

// Ajoute une ressource
void Add(const std::string& Name, IResource* Resource);

// Retire une ressource
void Remove(const std::string& Name);

private :

// Données membres
std::map<std::string, IResource*> m_Resources; // Table contenant les ressources associées à leur nom
};

La fonction Get renvoie un pointeur sur la ressource identifiée par Name. Si elle n'existe pas, on renvoie NULL.
La fonction Add associe un nom à une ressource et l'insère dans la table.
La fonction Remove retire de la table la ressource identifiée par Name. Cette fonction sera appelée automatiquement par IResource, nous n'aurons pas à nous en préoccuper.
On pourra enfin ajouter un bonus : dans le destructeur de CResourceManager, si la table n'est pas vide c'est qu'on a oublié de libérer des ressources. On le signale donc à l'utilisateur en inscrivant dans le log les noms des ressources incriminées avant de quitter. Plutôt pratique.
On ne détaillera pas plus le code de ces fonctions, il est relativement simple, et vous pourrez le trouver intégralement et suffisamment commenté dans le zip qui accompagne cet article.

Comme vous pouvez le voir sur le schéma ci-dessus, le retrait d'une ressource de la table (via Remove) ne sera pas effectué par l'utilisateur de la ressource, mais par la ressource elle-même lorsque plus personne n'en aura besoin. Pour cela il suffit d'appeler CResourceManager::Remove dans le destructeur de IResource :

IResource::~IResource()
{
CResourceManager::Instance().Remove(m_Name);
}

Lui même étant automatiquement appelé (c'est-à-dire, la ressource automatiquement détruite) lorsque le compteur de référence tombera à zéro, à savoir que plus personne n'a besoin de la ressource :

int IResource::Release()
{
// Décrémentation du compteur de références
int RefCount = --m_RefCount;

// S'il n'y a plus de référence sur la ressource, on la détruit
if (RefCount == 0)
delete this;

return RefCount;
}

Dans cette fonction, il est très important de passer par une variable temporaire : en effet après l'appel à delete this la variable membre m_RefCount aura été détruite, on ne peut donc plus l'utiliser.

Maintenant que nous avons tout ce qu'il faut pour gérer nos ressources, faisons un petit mix et voyons à quoi ressemblerait le chargement d'une ressource :

// Définition d'une classe de ressource : par exemple les modèles
class CModel : public IResource
{
// ...
};
typedef CSmartPtr<CModel, CResourceCOM> TModelPtr;

// Importation du modèle "Mouton.3ds"
TModelPtr Resource = ResourceManager.Get<CModel>("Mouton.3ds");
if (Resource == NULL)
{
// Chargement du modèle
Resource = ...;
ResourceManager.Add("Mouton.3ds", Resource);
}

// Maintenant si on réclame notre mouton on est sûr qu'il est chargé
TModelPtr Resource2 = ResourceManager.Get<CModel>("Mouton.3ds");

Pour compléter ce code il va nous falloir étudier de plus près ces "...", à savoir comment charger une ressource qui n'est pas encore en mémoire ; c'est ce que nous allons faire dès maintenant avec les chargeurs et le gestionnaire de médias.

3 - Importation et exportation

Après avoir vu en détail comment construire nos ressources et comment les gérer une fois chargées, il va nous falloir étudier l'aspect "extérieur" des ressources, à savoir l'importation et l'exportation à partir de fichiers.
Lorsqu'on voudra charger une ressource, idéalement on voudrait que le moteur reconnaisse son type en fonction de l'extension du fichier, et soit capable de la charger de la manière appropriée. La sauvegarde quant à elle sera nettement moins utilisée, mais ce sera le même principe : il faudra utiliser automatiquement la classe capable de sauvegarder de manière correcte la ressource qu'on va lui donner à manger.
De plus on ne pourra pas gérer tous les formats de fichiers ressources : il y en a bien trop, et il faut également garder un système suffisamment flexible pour que l'utilisateur puisse définir et utiliser ses propres formats de fichiers. Et s'il trouve que votre loader de TGA est buggé il faut également qu'il puisse le redéfinir... sait-on jamais !
Enfin il faut que tout ceci soit géré automatiquement, autrement dit il nous faudra un bon gros gestionnaire de derrière les fagots pour surveiller ce petit monde.

3-1 - Les loaders

Elément de base de l'importation / exportation de ressources, le loader aura une fonction assez simpliste : charger et décharger un type de ressource précis. That's all. En écrivant un loader pour chaque extension de fichier, ce pour chaque type de ressource, et en collectant tout ce beau monde dans le gestionnaire, nous fournirons au moteur tout ce qu'il faut pour l'importation et l'exportation des ressources. Et s'il en manque l'utilisateur pourra bien entendu écrire ses propres loaders et les enregistrer auprès du manager, ainsi ceux-ci seront automatiquement utilisés lorsque le moteur aura besoin de charger les ressources associées.

Vous devez avoir compris le principe à force, nous aurons besoin d'une classe de base pour nos loaders. A priori il faudra pouvoir charger plusieurs types de ressources : images, shaders, modèles, ... Il faudra donc autant de classes de base : les loaders d'images, les loaders de shaders, ... Mais comme nous n'avons pas l'habitude d'écrire du code pour rien, nous allons régler ça très rapidement avec une classe template :

template <class T>
class ILoader
{
public :

// Destructeur
virtual ~ILoader() = 0 {}

// Charge un T à partir d'un fichier
virtual T* LoadFromFile(const std::string& Filename)
{
throw CLoadingFailed(Filename, "Le loader enregistré pour ce format ne prend pas en charge l'importation");
}

// Enregistre un T dans un fichier
virtual void SaveToFile(const T* Object, const std::string& Filename)
{
throw CLoadingFailed(Filename, "Le loader enregistré pour ce format ne prend pas en charge l'exportation");
}
};

Le principe est simple : pour écrire un loader de T on dérive une classe de ILoader<T>. Puis on redéfinit LoadFromFile pour gérer l'importation, et SaveToFile pour l'exportation. Si l'une de ces opérations ne nous interesse pas on laissera simplement le comportement par défaut, qui lancera une exception indiquant que cela n'a pas été implémenté.
Encore une fois notre classe ne possède pas de méthode virtuelle pure mais doit être abstraite, nous utilisons donc le destructeur (le pauvre... !) pour arriver à nos fins.
Pour illustrer de manière concrète l'utilisation de cette classe, un exemple de loader de modèles est intégré au projet accompagnant cet article.

Le fait que nous devrons ensuite créer tous les loaders dont nous aurons besoin ne signifie pas forcément que nous aurons à écrire l'importation / exportation de chaque type de fichier avec nos petites mimines. Nous pourrons par exemple utiliser un seul et même loader pour tous les fichiers de type image, et simplement reléguer le boulot à une bibliothèque existante, par exemple DevIL ou FreeImage.

3-2 - Le MediaManager

Les loaders tels que nous les avons vus à l'instant sont un moyen efficace d'importer et exporter nos ressources, mais nous ne pourrons tout de même pas les utiliser directement. Pour ça nous allons avoir besoin d'un gestionnaire, dont les tâches pourraient être les suivantes :

- S'occuper de la gestion complète des loaders.

- Gérer une liste de chemins d'accès, permettant de localiser les ressources sans leur chemin complet.

- Lors d'un chargement ou d'une sauvegarde, trouver le loader adéquat et lui déléguer le travail.

- Gérer au mieux toute les sortes d'erreurs pouvant intervenir.

- Permettre à l'utilisateur d'enregistrer ses loaders persos, et de les associer à une ou plusieurs extensions de fichier.

Prenons les choses dans l'ordre : tout d'abord la gestion des loaders. A priori, notre MediaManager devra stocker une liste de loaders, et y choisir le bon à chaque importation ou exportation. Mais problème : nos loaders ne dérivent pas d'une même et unique classe, mais d'une multitude (ILoader<CModel>, ILoader<CMaterial>, ...). Nous ne pourrons donc pas avoir par exemple une classe de base ILoaderBase dont on stockerait et utiliserait des dérivées via le polymorphisme. Pourquoi pas me direz-vous ? Simplement car les deux fonctions qui nous interessent (LoadFromFile et SaveToFile) ont des prototypes différents, car dépendant du type de ressource gérée. Pour permettre une telle classe de base il nous faudrait remplacer nos T* par du void*, ou un peu mieux par un pointeur sur une classe qui servirait de base à toutes les classes gérées (pas forcément IResource, car les ressources gérées par le ResourceManager et celles gérées par le MediaManager ne seront pas forcément les mêmes. Exemple avec les textures : celles-ci seront bien des IResource, mais ne seront pas chargées directement par le MediaManager. En revanche elles pourront être construites à partir d'un CImage, qui lui peut être importé / exporté). Bref pour revenir à notre problème, on pourrait bien sûr envisager de perdre un peu du typage et le récupérer après à coup de static_cast. C'est possible. Mais puisque nous pouvons faire mieux... faisons-le non ?

Pour ne pas perdre un seul poil de typage tout au long de nos manipulations, il va donc bien nous falloir stocker plusieurs listes de loaders, à savoir une par classe gérée. Mais là encore cela pose bien des problèmes, imaginez par exemple ce code :

class CMediaManager
{
public :

template <class T> T* LoadMediaFromFile(const std::string& Filename)
{
if (???)
// Utiliser un loader dans m_ModelLoaders
else if (???)
// Utiliser un loader dans m_MaterialLoaders
else if (???)
// Utiliser un loader dans m_ImageLoaders
else ...
}

private :

conteneur_qcq<ILoader<CModel> >    m_ModelLoaders;
conteneur_qcq<ILoader<CMaterial> > m_MaterialLoaders;
conteneur_qcq<ILoader<CImage> >    m_ImageLoaders;
// Etc... pour chaque classe, pas génial niveau maintenance !
};

C'est bien le genre de code que nous voulons éviter à tout prix, et d'ailleurs celui-ci ne serait même pas envisageable : que mettre dans les if à la place des "???" ?

Pour gérer tout ceci efficacement, nous allons faire appel aux templates. Les techniques que nous allons utiliser ne sont pas évidentes à saisir, pour un complément sur le sujet vous pouvez consulter l'article traitant de la meta-programmation, plus particulièrement le dernier chapitre sur les typelists et la génération automatique de hiérarchie. Car ce sont ces deux concepts que nous allons utiliser pour coder notre MediaManager. Cela va notamment nous permettre d'écrire le code "ingérable" ci-dessus, mais... sans vraiment l'écrire (ce qui le rendra nettement plus sympathique). Mystère ? Magie ? Non, rien qu'un peu de meta-programmation !

Pour ne pas vous perdre en route nous allons étudier la conception de notre classe pas à pas. Première étape : modifier légèrement le code ci-dessus pour le rendre plus gérable :

class CMediaManager
{
public :

template <class T> T* LoadMediaFromFile(const std::string& Filename)
{
// On utilise m_Loaders<T> !
}

private :

// /!\ Syntaxe non valide !!!! ...mais on aimerait quelque chose de ce goût là
template <class T> conteneur_qcq<ILoader<T> > m_Loaders;
};

Attention je le répète : le code ci-dessus ne compilera pas, c'est juste pour se donner une idée de ce qu'on voudrait. Ce qui nous mène à la 2ème étape : transformer ce code de manière à ce que notre compilo en veuille bien. Une idée serait de faire hériter CMediaManager de structures contenant chacune juste une liste de loaders. Ainsi selon le type du media à charger, il suffirait de faire appel au loader de la classe de base appropriée :

template <class T>
struct CMediaHolder
{
conteneur_qcq<ILoader<T> > m_Loaders;
}

class CMediaManager : public CMediaHolder<CModel>,
public CMediaHolder<CMaterial>,
public CMediaHolder<CImage>,
public ...
{
public :

template <class T> T* LoadMediaFromFile(const std::string& Filename)
{
// On utilise CMediaHolder<T>::m_Loaders !
}

private :

// Plus rien
};

Là nous avons fait un grand pas en avant : ce code est fonctionnel, et plutôt flexible. Car n'oubliez pas que nous allons modifier sans cesse notre MediaManager, les types de ressources à gérer augmentant au gré de l'avancement du moteur. Prochaine étape : définir le conteneur approprié (pour ceux qui se poseraient la question : non, conteneur_qcq n'est pas une classe magique et secrète faisant partie de la bibliothèque standard). Comme nous allons devoir associer chaque loader à une ou plusieurs extensions de fichiers, une bonne idée de conteneur serait une table associative (std::map). Ainsi les clés seront des chaines (les extensions) auxquelles nous associerons un pointeur vers le loader adéquat. Pointeur ? Vous avez dit pointeur ? Libération de mémoire ? Destructeur ? Gestion de la copie et de la réaffectation ? ...A moins que votre langue n'ait fourché et que vous ne vouliez en fait dire "pointeur intelligent".

template <class T>
struct CMediaHolder
{
typedef std::map<std::string, CSmartPtr<ILoader<T> > > TLoadersMap;
TLoadersMap m_Loaders;
}

class CMediaManager : public CMediaHolder<CModel>,
public CMediaHolder<CMaterial>,
public CMediaHolder<CImage>,
public ...
{
// ...
};

Hop hop, nous nous rapprochons du but. En fait cette version est tout à fait acceptable et nous pourrions en rester là. En effet elle fera a priori parfaitement son travail (enfin pour peu que nous la fassions faire quelque chose), et pour ajouter la gestion d'une classe X il suffira de faire dériver CMediaManager de CMediaHolder<X>. Mais nous en voulons toujours plus, c'est pourquoi nous allons simplifier au maximum tout ceci, et le rendre encore plus sympathique... et au passage nous en profiterons pour concrétiser tous les beaux discours tenus dans l'article sur la meta-programmation.

L'une des seules choses que nous pouvons encore optimiser est cette liste interminable d'héritages, qui ne fera que grossir et grossir avec l'évolution du moteur, si bien qu'elle en explosera. Mais soyons fous, et écrivons quelque chose que nous pensons impossible :

typedef (CModel, CMaterial, CImage, ...) Medias;

class CMediaManager : (pour chaque type T dans Medias) public CMediaHolder<T>
{
// ...
};

Ainsi pour ajouter une classe à notre MediaManager, il suffirait de l'ajouter à Medias. Difficile de faire plus simple, non ? Mais comment allons-nous coder cette chose ?? Et bien il nous faut une liste de types ? Très bien, allons chercher les... typelists ! Les typelists sont, comme leur nom et le tutoriel sur la meta-prog l'indiquent, des listes de types. Pas de valeurs, rien que des types. On ne détaillera pas ici leur fonctionnement puisque ceci est fait dans l'article de meta-programmation, voici donc simplement le résultat :

typedef TYPELIST_3(CModel, CMaterial, CImage) Medias;

class CMediaManager : (pour chaque type T dans Medias) public CMediaHolder<T>
{
// ...
};

Reste le problème de faire hériter CMediaManager d'un CMediaHolder de chaque type de notre liste. Idéalement il nous faudrait pouvoir construire une hiérarchie automatique et récursive, un genre d'outil qui prendrait en paramètre notre liste de types et la classe de laquelle dériver avec tous ces types. Cet outil existe, c'est toujours de la meta-programmation et ça s'appelle... en fait ça n'a pas de nom, mais ça fait le boulot ce qui est bien le plus important. Pour les mêmes raisons que précédemment, nous n'allons pas développer leur fonctionnement et se concentrer sur le code résultant :

typedef TYPELIST_3(CModel, CMaterial, CImage) Medias;

class CMediaManager : public CScatteredHierarchy<Medias, CMediaHolder>
{
// ...
};

Ouf ouf ouf... Finalement nous y sommes arrivés. Nous allons pouvoir écrire, très facilement maintenant, le contenu de CMediaManager. Car tel qu'il est là, il est très intéressant niveau C++, mais pas vraiment utile pour notre moteur.

Tout d'abord il faudra pouvoir gérer une liste de chemins d'accès aux médias, et y rechercher une ressource dont on n'aura pas précisé le chemin complet. C'est une fonctionnalité qui peut se révéler très pratique à la fois pour le moteur et pour l'utilisateur.

////////////////////////////////////////////////////////////
// Constructeur par défaut
////////////////////////////////////////////////////////////
CMediaManager::CMediaManager()
{
// On insère par défaut le répertoire local dans la liste
m_Paths.insert("");
}

////////////////////////////////////////////////////////////
// Ajoute un répertoire de recherche pour les médias
////////////////////////////////////////////////////////////
void CMediaManager::AddSearchPath(const std::string& Path)
{
// Tous nos chemins doivent terminer par un slash
if (Path.empty() || (*Path.rbegin() == '\\') || (*Path.rbegin() == '/'))
m_Paths.insert(Path);
else
m_Paths.insert(Path + "\\");
}

////////////////////////////////////////////////////////////
// Cherche un fichier dans les répertoires de recherche
////////////////////////////////////////////////////////////
CFile CMediaManager::FindMedia(const CFile& Filename) const
{
// Parcours de la liste des chemins de recherche
for (std::set<std::string>::const_iterator i = m_Paths.begin(); i != m_Paths.end(); ++i)
{
CFile RetFile = *i + Filename.Fullname();
if (RetFile.Exists())
return RetFile;
}

// Si le fichier est introuvable, on lance une exception
throw CLoadingFailed(Filename.Fullname(), "Fichier introuvable dans les répertoires de recherche");
}

Reste maintenant la partie la plus intéressante : trouver le loader approprié en fonction du type et de l'extension du media, puis lui déléguer le boulot d'importation ou d'exportation. Comme notre classe est plutôt bien pensée, tout ceci ne nécessitera que quelques neurones grillés et lignes de code :

////////////////////////////////////////////////////////////
// Charge un media de type T à partir d'un fichier
////////////////////////////////////////////////////////////
template <class T>
inline T* CMediaManager::LoadMediaFromFile(const CFile& Filename)
{
// Recherche du fichier dans les répertoires enregistrés
CFile MediaPath = FindMedia(Filename);

// On délègue le boulot au loader approprié
return FindLoader<T>(MediaPath).LoadFromFile(MediaPath.Fullname());
}

////////////////////////////////////////////////////////////
// Sauvegarde un T dans un fichier
////////////////////////////////////////////////////////////
template <class T>
inline void CMediaManager::SaveMediaToFile(const T* Object, const CFile& Filename)
{
// On délègue le boulot au loader approprié
FindLoader<T>(Filename).SaveToFile(Object, Filename.Fullname());
}

////////////////////////////////////////////////////////////
// Cherche le loader correspondant à un fichier donné
////////////////////////////////////////////////////////////
template <class T>
inline ILoader<T>& CMediaManager::FindLoader(const CFile& Filename)
{
// Récupération de l'extension et mise en minuscule pour ne pas tenir compte de la casse
std::string Extension = Filename.Extension();
std::transform(Extension.begin(), Extension.end(), Extension.begin(), std::tolower);

// Recherche de l'extension dans la map de loaders de T
CMediaHolder<T>::TLoadersMap::iterator It = CMediaHolder<T>::m_Loaders.find(Extension);

// Si l'extension du fichier se trouve parmi celles reconnues on renvoie le loader associé
if ((It != CMediaHolder<T>::m_Loaders.end()) && It->second)
return *It->second;

// ...sinon on lève une exception
throw CLoadingFailed(Filename.Fullname(), "Aucun loader ne prend en charge ce format de fichier");
}

Et n'oublions pas non plus de prévoir une fonction pour enregistrer de nouveaux loaders auprès de notre gestionnaire :

////////////////////////////////////////////////////////////
// Enregistre un nouveau chargeur de media de type T
////////////////////////////////////////////////////////////
template <class T>
inline void CMediaManager::RegisterLoader(ILoader<T>* Loader, const std::string& Extensions)
{
// Découpage de la chaîne pour récupérer les extensions
std::vector<std::string> Ext;
Split(Extensions, Ext, " /\\*.,;|-_\t\n'\"");

// Ajout des extensions une à une avec le loader associé
CSmartPtr<ILoader<T> > Ptr = Loader;
for (std::vector<std::string>::iterator i = Ext.begin(); i != Ext.end(); ++i)
{
std::transform(i->begin(), i->end(), i->begin(), std::tolower);
CMediaHolder<T>::m_Loaders[*i] = Ptr;
}
}

Split est une fonction courante mais non implémentée en standard en C++, elle permet de découper une chaîne en plusieurs sous-chaînes relativement à un ou plusieurs délimiteurs. Ici nous prenons comme délimiteurs les caractères non alpha-numériques usuels, ainsi quoique l'utilisateur choisisse pour délimiter ses extensions ce sera correctement géré.
Vous pourrez également vous demander pourquoi nous passons par la variable intermédiaire Ptr. N'oubliez pas que nous stockons ici des pointeurs intelligents, et que ceux-ci détruirons la donnée pointée aussitôt qu'eux-même seront détruits. Si nous affectons à chaque élément de la map directement le pointeur brut, chacun de nos pointeurs intelligents croira en être le seul responsable et ainsi nos loaders seront détruits plusieurs fois (comme quoi, si l'on manipule mal un pointeur intelligent il peut vite devenir très bête). En passant par une variable intermédiaire, tous les éléments de la map pointant vers le même loader sauront que celui-ci est partagé par d'autres (via le compteur de références), ainsi notre loader sera correctement géré et détruit.

Si vous pensez que nous avons oublié la partie concernant la gestion des loaders c'est (heureusement) faux : comme nous utilisons des pointeurs intelligents, nos loaders seront correctement stockés et détruits quoiqu'il arrive.
Si vous pensez également que nous avons mis de coté la gestion des erreurs détrompez-vous : chaque erreur est détectée et l'exception adéquate est levée, indiquant le plus précisément possible la cause de l'erreur et le nom du media qui l'a provoqué.

Voici, en conclusion de ce chapitre, le schéma illustrant le fonctionnement du gestionnaire de medias et des loaders :

Et notre code exemple, que nous pouvons maintenant compléter avec ce que nous venons de voir :

// Définition d'une classe de ressource : par exemple les modèles
class CModel : public IResource
{
// ...
};
typedef CSmartPtr<CModel, CResourceCOM> TModelPtr;

// Définition d'un loader de modèles : par exemple les .3DS
class C3DSLoader : public ILoader<CModel>
{
// ...
};
MediaManager.RegisterLoader(new C3DSLoader, "3ds");

// Importation du modèle "Mouton.3ds"
TModelPtr Resource = ResourceManager.Get<CModel>("Mouton.3ds");
if (Resource == NULL)
{
// Chargement du modèle
Resource = MediaManager.LoadMediaFromFile<CModel>("Mouton.3ds");
ResourceManager.Add("Mouton.3ds", Resource);
}

// Maintenant si on réclame notre mouton on est sûr qu'il est chargé
TModelPtr Resource2 = ResourceManager.Get<CModel>("Mouton.3ds");

4 - Conclusion

Nous avons vu ici comment gérer nos ressources de A à Z, et surtout le plus efficacement possible. Nous pourrons maintenant importer tout type de fichier, exporter nos ressources, les manipuler dans tous les sens, sans induire de perte de mémoire et de performances. Et tout cela de manière la plus simple pour nous et pour l'utilisateur du moteur. Nous avons également donné à nos mécanismes un degré de flexibilité élevé, ainsi l'utilisateur pourra intégrer ses formats persos au moteur, et même modifier la gestion de ceux existant si l'envie lui en prend.
Pour arriver à nos fins, nous avons une fois de plus exploité des concepts poussés du C++ : le polymorphisme dynamique (via classes abstraites et fonction virtuelles), et le polymorphisme statique (via les templates et la meta-prog).

Tout ceci nous permet d'avoir au final un code :

- Extensible : c'est très important, car nous allons très certainement enrichir nos ressources ainsi que les types gérés.

- Léger : aucune gestion de mémoire, seulement deux managers pour prendre en charge tous les types, et automatisation de la gestion des ressources.

- Sûr : grace au typage fort, à la gestion correcte des erreurs et à la réutilisation / amélioration des outils que nous avions déjà développé.

Dans une prochaine partie, nous verrons en détail la gestion d'une ressource : la texture, qui est également un élément de base du moteur et du rendu 3D en général.

5 - 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és 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 cet article (zip, 1.15 Mo)

Screenshot de l'application exemple

Une version PDF de cet article est disponible : Télécharger (159 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 !

6 - Remerciements

Un grand merci à toutes les personnes qui m'aident, de près ou de loin, à réaliser ou améliorer cette série d'article : Stoomm, Nico, Vincent, Rolka, toute l'équipe de developpez.com, les lecteurs bien sûr, et enfin Mathieu pour sa grande contribution.