Réalisation d'un moteur 3D en C++ - partie I : les outils
Date de publication : 24/11/2004 , Date de mise à jour : 01/09/2005
Par
Laurent Gomila (Autres articles)
Dans ce tout premier article, nous ne nous intéresserons pas encore au moteur en lui-même, mais à la réalisation de différentes
classes et outils qui vont nous aider à le coder.
Sont également au menu quelques concepts intéressants de C++ et de programmation objet.
1. Introduction
2. Les outils de debugging
2.1. Le gestionnaire de log
2.2. Le gestionnaire de mémoire
2.2.1. Les opérateurs new, new[], delete et delete[]
2.2.2. CMemoryManager
2.3. Les exceptions
3. Les classes utiles
3.1. Les fichiers
3.2. Les plugins
3.3. Les pointeurs intelligents
3.4. Les singletons
4. Comment bien générer sa DLL
4.1. Options de configuration
4.2. Exportation de classes / fonctions
5. Conclusion
6. Téléchargements
1. Introduction
Un moteur 3D est réputé pour être difficile et long à réaliser. En effet c'est un projet qui compte souvent plusieurs dizaines
(voire centaines) de milliers de lignes de code, étalées sur plusieurs mois ou années. Comme dans tout projet de cette ampleur,
il convient donc de ne pas se précipiter dans le coding, et de se mettre dans les meilleures conditions possibles. Il faudra
à tout prix et dès le début éviter les erreurs, les fuites de mémoires et les comportements indeterminés qui pourront mener
un jour à des situations plus que gênantes. Debugger un code de 100 000 lignes pour débusquer la fuite mémoire ou le comportement
indéterminé peut se réveler très difficile voire impossible.
Un autre aspect important dans ce genre de projet est la modularité et la réutilisation de code. Inutile de vous dire que réécrire
50 fois le même code, avec 50 fois la même gestion des erreurs et pourquoi pas 50 fois le même bug, est la dernière chose à faire.
C'est pour toutes ces raison que l'une des premières choses à coder est un panel d'outils, fiable, robuste, performant et qui vous
permettra de coder votre moteur l'esprit tranquille. Ces outils seront variés : gestion de la mémoire, journalisation d'évènements,
création de classes réutilisables, ... Nous allons étudier les plus importants d'entre eux, et ainsi avoir de quoi coder notre
moteur en toute tranquillité.
2. Les outils de debugging
L'une des tâches les plus ardues lorsqu'on programme un projet de ce genre, est le debugging. Combien de temps passons nous
à traquer la segfault, la fuite mémoire ou parfois même l'arrêt brutal de la machine (oui oui, ça arrive !) ?
C'est pour cette raison que la conception au préalable de differents outils est nécessaire, pour nous faire économiser du temps
et des nerfs. Ces outils sont bien souvent des "traqueurs" de mémoire, des mécanismes de journalisation d'évènements (logs),
ou une gestion poussée et précise des erreurs ; ce sont ces outils que nous allons maintenant détailler et expliquer.
2.1. Le gestionnaire de log
Vous voulez savoir où votre code plante ? Connaître les détails de l'execution du programme ? Savoir dans quelles fonctions
celui-ci est passé ? Afficher des informations sur le hardware ? Lister les textures chargées ? Dire "coucou" ?
Comme vous le voyez, le gestionnaire de logs peut faire beaucoup de choses, c'est un outil quasi-indispensable à la réalisation
d'un projet de ce type. Mais pour qu'il montre toute sa puissance, il convient de bien le concevoir ; voici ce qu'on est en droit
d'attendre de lui :
- Pratique : son utilisation doit nous permettre de notifier des informations rapidement et facilement, pas le contraire.
- Flexible : l'utilisateur doit pouvoir logger ce qu'il veut, où il veut et comme il veut.
- Léger : bien qu'étant un outil de debugging, il ne faut pas que son utilisation ralentisse le programme.
- Extensible : il faut laisser à l'utilisateur la possibilité d'ajouter des fonctionnalités, sans toucher au code du moteur.
Un bout de code étant plus parlant qu'un long discours, voici à quoi ressemble la bestiole (mais ne vous inquiétez
pas, le long discours suit immédiatemment après) :
| classe ILogger | class ILogger
{
public :
virtual ~ILogger();
static void SetLogger(ILogger* Logger);
static void Log(const char* Format, ...);
static ILogger& Log();
template <class T> ILogger& operator <<(const T& ToLog);
private :
virtual void Write(const std::string& Message) = 0;
static ILogger* s_Instance;
}; |
Pour permettre d'avoir différents loggers, on va faire de notre ILogger une classe abstraite. Il suffira donc
d'en dériver pour créer un nouveau type de logger. On pourra ainsi créer un logger qui inscrit dans un fichier, un autre
qui affiche dans la console de debugging, ou encore un qui utilise des boîtes de dialogue. Cette utilisation n'est bien sûr
pas reservée au créateur du moteur : l'utilisateur pourra tout aussi bien créer sa classe de log. Par exemple un élément de la scène
qui afficherait les informations directement à l'écran, ou encore un logger qui sortirait une jolie page HTML.
Tout est possible. Notez qu'il est très simple de coder les classes dérivées : seule la fonction virtuelle Write est
à redéfinir. Elle reçoit en paramètre la chaîne que l'utilisateur souhaite logger, il suffit donc de l'afficher comme
bon nous semble.
Voici quelques exemples de classes dérivées (juste les parties significatives, le code complet est à télécharger au bas de la page):
| Les différents gestionnaires de logs du moteur |
class CLoggerDebug : public ILogger
{
virtual void Write(const std::string& Message);
{
OutputDebugString((Message + '\n').c_str());
}
};
class CLoggerMsgBox : public ILogger
{
virtual void Write(const std::string& Message);
{
MessageBox(NULL, Message.c_str(), "Yes::Engine", MB_OK);
}
};
class CLoggerFile : public ILogger
{
public :
CLoggerFile(const std::string& Filename = "Output.log") : m_File(Filename)
{
}
private :
virtual void Write(const std::string& Message);
{
m_File << Message;
}
std::ofstream m_File;
}; |
Pour changer de logger, il suffit d'appeler la fonction statique ILogger::SetLogger. Elle détruira l'instance
précédente et affectera la nouvelle à la variable statique correspondante ILogger::s_Instance. Et bien sûr on
fournit dans le moteur un logger par défaut : écriture des évènements dans un fichier "Out.log"
| Quelque part au fin fond des initialisations... | ILogger::SetLogger(new CLoggerFile("Out.log")); |
Intéressons-nous à présent au loggage (traduction perso © Loulou). Comme vous l'aurez deviné, c'est la
fonction ILogger::Log qui s'en occupe. Mais vous aurez aussi remarqué qu'il existe deux versions de cette fonction :
une pour logger façon C (liste d'arguments variables, style printf) et l'autre pour logger façon C++
(chaînage d'appels à l'opérateur <<, style std::cout). Les deux sont à mons avis nécessaires, pour plus de souplesse :
la version C est parfois plus pratique pour formater les messages, alors que la version C++ peut être plus adaptée si l'on a
défini l'affichage d'objets (matrices, vecteurs, ...) via l'opérateur <<.
La version C est implémentée à l'aide des macros habituelles lorsqu'on a affaire à des paramètres variables :
| Version C | void ILogger::Log(const char* Format, ...)
{
char sBuffer[512];
va_list Params;
va_start(Params, Format);
vsprintf(sBuffer, Format, Params);
va_end(Params);
s_Instance->Write(sBuffer);
}
ILogger::Log("Loaded : %s, Id : %d\n", "texture", 24); |
La version C++ est elle un peu plus subtile : on surcharge l'opérateur (template) << de notre ILogger de sorte qu'il
transforme en chaîne tout ce qu'on lui passe pour le logger, et la fonction Log() n'est alors plus qu'un accesseur
renvoyant une référence sur l'instance du logger :
| Version C++ | template <class T> ILogger& ILogger::operator <<(const T& ToLog)
{
std::ostringstream Stream;
Stream << ToLog;
Write(Stream.str());
return Log();
}
ILogger& ILogger::Log()
{
return *s_Instance;
}
ILogger::Log() << "Loaded : " << "texture" << ", Id : " << 24 << "\n"; |
Eh bien voilà, notre gestionnaire de log est maintenant prêt à fonctionner, y a plus qu'à ! Comme vous le voyez, la
réalisation de cet outil n'a pas nécessité beaucoup de code et d'efforts (hum, surtout pour vous), la clé étant de bien
définir ses caractéristiques et d'employer à bon escient les moyens que le C++ nous offre.
On peut bien entendu ajouter quelques fonctionnalités utiles, par exemple des fonctions CurrentDate()
et CurrentTime(), qui renvoient respectivement la date et l'heure courantes et peuvent être utilisées dans les
classes dérivées (ces fonctions sont incluses dans la version finale du code).
 | Le code source complet de ces classes est téléchargeable au bas de la page, dans la partie "Téléchargements"
|
2.2. Le gestionnaire de mémoire
Lorsque les lignes de code commencent à s'accumuler dans votre projet, le risque de provoquer des fuites de mémoire
grandit rapidement. Comment retrouver les variables non détruites, dans un code de 50 000 lignes ? C'est en cela que
le gestionnaire de mémoire vous simplifiera la tâche, en plus d'effectuer automatiquement les libérations que vous
aurez oubliées.
2.2.1. Les opérateurs new, new[], delete et delete[]
En C++, le moyen le plus simple de suivre l'évolution des allocations / désallocations est de surcharger les
opérateurs new, delete, new[] et delete[]. Pour être vraiment complet on peut
également surcharger les fonctions de type malloc et free. Je ne l'ai pas fait ici car je n'aurais pas
à utiliser ces fonctions, mais en toute rigueur il faudrait le faire. Le contenu serait bien entendu identique aux
surcharges de new et delete.
Au passage, puisqu'on crée nos propres opérateurs d'allocation / désallocation, profitons-en pour les munir de quelques
fonctionnalités utiles, telles que l'ajout de la ligne de code et du fichier source où ils apparaissent. Cela permettra
plus tard de localiser avec précision dans le code les variables qui ne sont pas détruites.
Voici les versions surchargées de new :
| operateurs new | inline void* operator new(std::size_t Size, const char* File, int Line)
{
return Yes::CMemoryManager::Instance().Allocate(Size, File, Line, false);
}
inline void* operator new[](std::size_t Size, const char* File, int Line)
{
return Yes::CMemoryManager::Instance().Allocate(Size, File, Line, true);
}
#define new new(__FILE__, __LINE__) |
Comme vous le voyez, on ne fait rien d'autre que déléguer le travail à notre gestionnaire de mémoire, c'est lui qui va ensuite
faire tout le boulot nécessaire à savoir effectuer l'allocation et s'en souvenir quelque part.
Malheureusement, maintenant que nos opérateurs new ont des paramètres supplémentaires nos appels à new devront
être modifiés : par exemple Classe* Ptr = new(__FILE__, __LINE__) Classe. C'est plutôt fastidieux.
C'est pourquoi on définit une macro qui s'en chargera pour nous, ainsi notre surcharge devient totalement invisible.
Si vous vous demandez à quoi sert le dernier paramètre de Allocate, il sert à faire la différence entre les tableaux
et les objets simples. Nous y reviendrons plus tard lors de l'étude du gestionnaire de mémoire.
Passons maintenant à nos opérateurs delete. Comme rien n'est simple en C++, on ne peut cette fois pas spécifier
de paramètres supplémentaires lors de l'appel à delete ou delete[]. Nous allons devoir ruser pour parvenir à nos fin :
| operateurs delete | inline void operator delete(void* Ptr)
{
Yes::CMemoryManager::Instance().Free(Ptr, false);
}
inline void operator delete[](void* Ptr)
{
Yes::CMemoryManager::Instance().Free(Ptr, true);
}
#define delete Yes::CMemoryManager::Instance().NextDelete(__FILE__, __LINE__), delete |
De la même manière que pour new, on ne fait ici que déléguer le travail au gestionnaire de mémoire. Malgré le manque de
paramètres supplémentaires dans nos surcharges, il faut tout de même spécifier la ligne et le fichier source des futures
désallocations : c'est le rôle de notre macro. C'est un peu du bricolage mais c'est je pense la meilleure solution : partout où
l'on va appeler delete, elle va insérer un appel à une fonction de notre gestionnaire de mémoire, qui va permettre de stocker
la ligne et le fichier source correspondants. Pour ce faire l'opérateur virgule est tout à fait approprié, puisqu'on aura
toujours au final qu'une seule instruction pour nos deux appels, cela ne perturbera pas le code avoisinant.
Arrivé à ce stade, on se dit certainement qu'on en a terminé avec les opérateurs et qu'on peut maintenant passer au
gestionnaire de mémoire. Et bien pas tout à fait, il nous manque ici encore deux surcharges. En effet, il est précisé dans la
norme que si un appel à new[] échoue, tous les objets tu tableau précédemment alloués seront désalloués avec la version correspondante de
delete[], à savoir celle qui prend les mêmes paramètres qu'à pris new[]. Même règle pour new, dans le cas où le constructeur
de l'objet alloué lève une exception. D'ailleurs si vous réglez votre niveau de warning suffisamment haut, votre
compilateur vous signalera certainement l'oubli de ces surcharges.
Voici donc nos surcharges de delete manquantes :
| operateurs delete supplémentaires | inline void operator delete(void* Ptr, const char* File, int Line)
{
Yes::CMemoryManager::Instance().NextDelete(File, Line);
Yes::CMemoryManager::Instance().Free(Ptr, false);
}
inline void operator delete[](void* Ptr, const char* File, int Line)
{
Yes::CMemoryManager::Instance().NextDelete(File, Line);
Yes::CMemoryManager::Instance().Free(Ptr, true);
} |
On ne pourra pas appeler ces opérateurs nous-même mais le compilateur le fera systèmatiquement dans les deux cas cités
plus haut, c'est pourquoi elles sont indispensables. A noter aussi que notre macro ne sera pas prise en compte ici,
il faut donc ajouter aux fonctions l'appel à NextDelete, ce qui ne pose pas de problème puisqu'ici nos surcharges
récupèrent bien la ligne et le fichier en paramètre. Les valeurs de ces paramètres seront simplement celles qu'on
aura passées au new ou new[] correspondant.
2.2.2. CMemoryManager
Après avoir surchargé correctement tous nos opérateurs new et delete, voici donc à quoi devrait ressembler notre
gestionnaire de mémoire :
| Classe CMemoryManager | class CMemoryManager
{
public :
void* Allocate(std::size_t Size, const CFile& File, int Line, bool Array);
void Free(void* Ptr, bool Array);
void NextDelete(const CFile& File, int Line);
}; |
Rien de plus que ce qui a déjà été vu précédemment : une fonction pour allouer, une pour libérer, et une pour "marquer"
la ligne et le fichier du prochain delete. La classe CFile sert simplement à fournir quelques fonctions utiles sur les
noms de fichier, nous la verrons plus en détail dans le chapitre 3.
Notre CMemoryManager sera bien entendu un singleton, puisqu'il ne devra exister qu'en un seul exemplaire. Détail important :
on ne doit en aucun cas utiliser d'allocation dynamique pour notre CMemoryManager, car lors de sa destruction notre
operateur delete utiliserait son instance qui ne serait alors plus valide. C'est donc la version "automatique"
du singleton que nous allons utiliser ici, a priori ça ne gêne pas puisque notre gestionnaire de mémoire ne dépend
d'aucun autre singleton dans le moteur.
Si le concept de singleton ne vous est pas familier ne vous inquiétez pas, je l'explique plus en détail dans la section 3.4.
Complétons donc notre CMemoryManager pour en faire un singleton :
class CMemoryManager
{
public :
static CMemoryManager& Instance();
private :
CMemoryManager();
~CMemoryManager();
};
CMemoryManager& CMemoryManager::Instance()
{
static CMemoryManager Inst;
return Inst;
} |
Voilà pour la forme, interessons nous maintenant à la gestion de la mémoire à proprement parler, plus précisément
aux fonctions Allocate et Free. Que vont-elles faire exactement ? Et bien principalement mémoriser les différentes
allocations et désallocations effectuées, et bien sûr réaliser celles-ci. Pour sauvegarder les allocations nous aurons
besoin d'une structure appropriée :
struct TBlock
{
std::size_t Size;
CFile File;
int Line;
bool Array;
}; |
Nous aurons également besoin d'un conteneur approprié pour stocker ces blocs : std::map fera parfaitement l'affaire,
avec comme clé un void* représentant l'adresse du bloc stocké. Cela nous permettra lors de la désallocation de retrouver
et supprimer le bloc concerné très facilement.
typedef std::map<void*, TBlock> TBlockMap;
TBlockMap m_Blocks; |
Enfin, pour compléter la définition de notre CMemoryManager, nous aurons également besoin d'une pile pour stocker
les paires ligne/fichier des désallocations, ainsi que d'un fichier pour consigner tout ça.
std::ofstream m_File;
std::stack<TBlock> m_DeleteStack; |
Voyons maintenant le détail de nos fonctions Allocate et Free :
| Fonction Allocate | void* CMemoryManager::Allocate(std::size_t Size, const CFile& File, int Line, bool Array)
{
void* Ptr = malloc(Size);
TBlock NewBlock;
NewBlock.Size = Size;
NewBlock.File = File;
NewBlock.Line = Line;
NewBlock.Array = Array;
m_Blocks[Ptr] = NewBlock;
m_File << "++ Allocation | 0x" << Ptr
<< " | " << std::setw(7) << std::setfill(' ')
<< static_cast<int>(NewBlock.Size) << " octets"
<< " | " << NewBlock.File.Filename()
<< "(" << NewBlock.Line << ")" << std::endl;
return Ptr;
} |
| Fonction Free | void CMemoryManager::Free(void* Ptr, bool Array)
{
TBlockMap::iterator It = m_Blocks.find(Ptr);
if (It == m_Blocks.end())
{
free(Ptr);
return;
}
if (It->second.Array != Array)
{
throw CBadDelete(Ptr, It->second.File.Filename(), It->second.Line, !Array);
}
m_File << "-- Désallocation | 0x" << Ptr
<< " | " << std::setw(7) << std::setfill(' ')
<< static_cast<int>(It->second.Size) << " octets"
<< " | " << m_DeleteStack.top().File.Filename()
<< " (" << m_DeleteStack.top().Line << ")" << std::endl;
m_Blocks.erase(It);
m_DeleteStack.pop();
free(Ptr);
} |
Si vous ne voyez pas à quoi servent les manipulation avec les variables Array, c'est en fait pour détecter
les erreurs du type allocation avec new et destruction avec delete[], et vice-versa. La norme spécifie que ce
genre d'erreur mène a un comportement indéfini, donc notre code ne fonctionnera pas à tous les coups, mais de toute
façon ce n'est que du bonus : on n'en demandait pas tant à notre gestionnaire de mémoire. Donc si ça marche tant mieux,
sinon tant pis !
A part ça rien de bien trappu : lors d'une allocation on ajoute dans notre map les informations sur le bloc alloué,
et lors de la désallocation on le supprime. Ce qu'il restera dans la map à la fin du programme sera donc... nos fameuses
fuites ! Il suffira alors de le signaler à l'utilisateur, et de libérer ces blocs avant de quitter.
| Rapport des fuites mémoire | CMemoryManager::~CMemoryManager()
{
if (m_Blocks.empty())
{
m_File << " No leak detected, congratulations ! " << std::endl;
}
else
{
m_File << " Oops... Some leaks have been detected " << std::endl;
ReportLeaks();
}
}
void CMemoryManager::ReportLeaks()
{
std::size_t TotalSize = 0;
for (TBlockMap::iterator i = m_Blocks.begin(); i != m_Blocks.end(); ++i)
{
TotalSize += i->second.Size;
m_File << "-> 0x" << i->first
<< " | " << std::setw(7) << std::setfill(' ')
<< static_cast<int>(i->second.Size) << " octets"
<< " | " << i->second.File.Filename()
<< " (" << i->second.Line << ")" << std::endl;
free(i->first);
}
m_File << std::endl << std::endl << "-- "
<< static_cast<int>(m_Blocks.size()) << " blocs non-libéré(s), "
<< static_cast<int>(TotalSize) << " octets --"
<< std::endl;
} |
...Ah oui et n'oublions pas notre fonction NextDelete au passage, dont le rôle est simplement d'empiler une
paire ligne/fichier pour la prochaine désallocation :
void CMemoryManager::NextDelete(const CFile& File, int Line)
{
TBlock Delete;
Delete.File = File;
Delete.Line = Line;
m_DeleteStack.push(Delete);
} |
Et voilà, tout est maintenant prêt à fonctionner. La seule chose qui nous reste à faire est de systématiquement inclure
l'en-tête contenant les surcharges des opérateurs new / delete (nommé DebugNew.h dans le moteur) dans tous les fichiers
sources du projet.
Important : il est capital de toujours inclure ce fichier en dernier, pour éviter d'interferer avec des en-têtes
extérieurs par exemple. Si vous avez besoin de l'inclure dans un en-tête c'est possible, mais dans ce cas il faut annuler
la définition de nos macros à la fin de l'en-tête concerné. Un fichier est également prévu à cet effet : DegubNewOff.h.
Pour résumer :
| Dans les en-têtes | #include <...>
#include <DebugNew.h>
#include <DebugNewOff.h> |
| Dans les fichiers source | #include <...>
#include <DebugNew.h>
|
2.3. Les exceptions
Maintenant que l'on gère efficacement la mémoire et la journalisation des évènements, il nous faut également une gestion
correcte des erreurs. La première stratégie à éviter dans ce cas est la gestion des erreurs par la valeur de retour.
Nécessité de tester chaque retour de fonction, de comparer avec des constantes prédéfinies (= code bourré de if et de
switch), monopolisation de la valeur de retour qui n'est donc plus exploitable, ... cela peut très vite devenir
fastidieux. Une meilleure solution est d'utiliser les exceptions : la valeur de retour des fonctions n'est plus
utilisée, et on peut intercepter les exceptions où l'on veut et comme on veut (ie. je n'ai en général dans mon code
qu'un gros bloc try / catch dans la fonction main(), ce qui est relativement léger).
La création de nos classes d'exceptions est relativement simple : on crée d'abord une classe "générale" d'exceptions
CException, qui va servir de base à toutes les exceptions levées par le moteur, puis la dériver en autant de
classes que l'on souhaite, une par type d'erreur en général. Bien sûr on n'oublie pas de faire dériver CException de
std::exception pour une compatibilité optimale.
L'interêt de créer une classe par type d'exception est double : d'une part l'utilisateur peut filtrer certains types
d'erreur pour les traiter différemment ou ne pas les traiter, et d'autre part cela nous permet de factoriser une certaine
partie du code, comme par exemple la mise en forme du message d'erreur.
Voici quelques exemples de classes d'exceptions implémentées dans le moteur :
| Exceptions dans le Yes::Engine |
class CException : public std::exception
{
public :
CException(const std::string& Message = "");
virtual ~CException() throw();
virtual const char* what() const throw();
protected :
std::string m_Message;
};
struct CAssertException : public CException
{
CAssertException(const std::string& File, int Line, const std::string& Message);
};
struct CBadDelete : public CException
{
CBadDelete(const void* Ptr, const std::string& File, int Line, bool NewArray);
};
struct CLoadingFailed : public CException
{
CLoadingFailed(const std::string& File, const std::string& Message);
};
struct COutOfMemory : public CException
{
COutOfMemory(const std::string& Message);
}; |
Un petit mécanisme est associé aux exceptions de type CAssertException : les assertions. Si vous connaissez déjà
la macro assert définie dans la bibliothèque standard du C, c'est la même chose. La macro Assert prend
en paramètre une condition, l'évalue puis lève une exception si elle n'est pas satisfaite. Ce genre de test est très
pratique, et utilisé très fréquemment dans ce projet. Notez qu'on n'active cette fonctionnalité qu'en mode debug, en
release on ne teste rien pour ne pas nuire aux performances. La fonction DoNothing est une petite feinte :
elle sert à nous assurer que la condition, qui peut tout aussi bien être un appel de fonction, sera tout de même
évaluée (mais pas testée).
| Les assertions | #ifdef _DEBUG
# define Assert(condition) if (!(condition)) \
throw CAssertException(__FILE__, __LINE__, "Condition non satisfaite\n\n" #condition)
#else
inline void DoNothing(bool) {}
# define Assert(condition) DoNothing(!(condition))
#endif
void Fonction(int* Ptr)
{
Assert(Ptr != NULL);
*Ptr = 5;
} |
Voilà pour ce qui est de nos exceptions, il ne reste plus maintenant qu'à utiliser les bonnes et aux endroits appropriés.
 |
Un petit mot pour clore cette section : de nombreux programmes freewares similaires sont disponibles et pourront venir
compléter vos outils, ne les oubliez pas !
|
3. Les classes utiles
Outre les outils de debugging et de gestion d'erreur, nous aurons aussi besoin de nombreuses autres classes dans ce projet.
Cela peut être tout et n'importe quoi : gestion des dates, des pointeurs intelligents, des couleurs, ... Le but étant
simplement de factoriser un maximum de code pour au final en écrire moins. Laissons maintenant parler la feignasse qui sommeille
en nous, et examinons quelques exemples de classes qui pourront nous être utiles tout au long du codage de notre moteur.
3.1. Les fichiers
Vous aurez peut-être remarqué une certaine classe CFile dans les exemples se trouvant plus haut, et bien ô surprise,
elle sert à gérer les.... fichiers ! En fait les noms de fichier, pour être exact. Pour la gestion des fichiers en eux-même
on utilisera les flux std::fstream pour l'écriture / lecture, et une bibliothèque du genre boost::filesystem
pour d'autres opérations. Avec cette classe on s'occupera simplement des chemins (complet, relatif), des extensions, et des
tests d'accessibilité des fichiers.
Voici à quoi elle ressemble, vous pourrez trouver l'implémentation complète dans le zip tout au bas de cette page.
| Classe CFile | class CFile
{
public :
CFile(const std::string& Name = "unknown");
CFile(const char* Name);
bool Exists() const;
const std::string& Fullname() const;
std::string Filename() const;
std::string ShortFilename() const;
std::string Extension() const;
private :
std::string m_Name;
}; |
L'implémentation de cette classe repose essentiellement sur les manipulations de std::string, à base de find et substr.
3.2. Les plugins
Dans tout projet, et encore plus dans un moteur 3D, la modularité est un concept primordial. Imaginez : les shaders 4.0
sont sortis, ils sont beaux, ils sont chouettes, mais malheureusement ils n'ont pas été prévus dans votre moteur.
Et bien sûr votre code est trop vieux et trop énorme pour que vous remettiez les mains dedans. Les plugins sont une
solution efficace à ce problème : codez une DLL, liez la à votre application via le moteur, et hop voilà votre nouvelle
fonctionnalité ajoutée. De la même manière pour l'API graphique : imaginez le jour où vous souhaiterez supporter OpenGL
2.0, si votre moteur est suffisamment modulaire vous n'aurez qu'à créer une DLL en dérivant une classe du moteur. Vous
l'aurez donc compris, la notion de plug-in est extrêmement importante ici. Je reviendrais plus en détail sur ce sujet
dans un prochain article, voici simplement une classe permettant d'exploiter nos futurs plug-ins à moindre frais :
| Classe CPlugin - définition | template <class T>
class CPlugin
{
public :
CPlugin();
~CPlugin();
T* Load(const std::string& Filename);
private :
typedef T* (*PtrFunc)();
HMODULE m_Library;
};
CPlugin<IRenderer> RendererPlugin;
IRenderer* Renderer = RenderPlugin.Load("DirectX9.dll"); |
| Classe CPlugin - implémentation | template <class T>
inline CPlugin<T>::CPlugin() :
m_Library(NULL)
{
}
template <class T>
inline CPlugin<T>::~CPlugin()
{
if (m_Library)
FreeLibrary(m_Library);
}
template <class T>
inline T* CPlugin<T>::Load(const std::string& Filename)
{
m_Library = LoadLibrary(Filename.c_str());
if (!m_Library)
throw CLoadingFailed(Filename, "Impossible de charger la bibliothèque dynamique");
PtrFunc Function = reinterpret_cast<PtrFunc>(GetProcAddress(m_Library, "StartPlugin"));
if (!Function)
throw CLoadingFailed(Filename, "Impossible de trouver la fonction 'StartPlugin'");
return Function();
} |
Bien entendu ce code est basé sur une marche à suivre qu'on aura au préalable défini pour nos plug-ins. Ceux-ci devront
renvoyer un objet, via une fonction exportée StartPlugin qui effectuera toutes les initialisations nécessaires. De
manière similaire, une fonction StopPlugin pourra être exportée, pour effectuer le nettoyage de la DLL avant fermeture.
C'est relativement léger mais pour l'instant cela nous suffit amplement, ce qu'on demande à notre classe étant surtout
d'encapsuler de manière élégante la gestion Win32 des bibliothèques dynamiques (LoadLibrary, GetProcAddress, FreeLibrary),
ainsi que la gestion correcte des erreurs qui peuvent en découler. Cette classe pourra par la suite être enrichie lorsqu'on
s'attaquera de plus près au système de plug-ins.
Notez bien que si un jour nous voulons porter notre moteur sur d'autres plateformes, Unix par exemple, nous
n'aurons qu'à écrire la version correspondante de cette classe, il n'y aura rien à modifier dans tout le reste du code.
C'est aussi à cela que sert l'encapsulation.
3.3. Les pointeurs intelligents
Oh my god, mais qu'est-ce donc ?! Des pointeurs qui font le café ? Ou qui résolvent des équations différentielles du
second degré ?? Malheureusement non, rien de tout cela. Un pointeur intelligent (smart pointer en anglais) est
un objet qui se comporte comme un pointeur sans en être un. Leur syntaxe est similaire (via surcharge des opérateurs
->, *, etc...) mais en tant qu'objets ils peuvent fournir bon nombre de fonctionnalités interessantes par rapport à un
pointeur brut classique. La première et certainement la plus répandue est la gestion automatique de la mémoire. En effet,
qui n'a jamais oublié un delete quelque part ? Qui ne s'est jamais dit "boarf, cette fonction ne lèvera jamais d'exception,
pas la peine de faire un bloc try/catch pour y libérer ma ressource dynamique" "...et puis s'il y a une fuite mémoire
ce n'est pas grave après tout !" ? Oui mais non. Une fuite c'est une fuite, si elle se produit à chaque frame votre
application ne tournera pas longtemps. Et puis même si cela ne se voyait pas, ça ne fait jamais sérieux dans une application
un tant soit peu professionnelle. Et puis sans parler de fuite, ce genre de stratégie peut tout aussi bien mener à
d'innombrables segmentation fault et joyeusetés du même genre. Définitivement à éviter.
On peut également parler d'un problème fréquent : le partage de ressource. Imaginez une ressource qui serait utilisée
à différents endroits du code totalement indépendants, comment savoir à quel moment plus personne ne l'utilise,
et donc quand la libérer ? Heureusement les pointeurs intelligents savent gérer de manière élégante ce genre de
problème, pour notre plus grand bonheur.
Il existe d'innombrables classes de pointeurs intelligents, voire des classes "paramètrables" qui peuvent s'adapter à
toute sorte d'utilisation (cf. Loki::SmartPtr), mais nous n'aurons besoin dans notre moteur que d'un type bien précis.
C'est ce qu'on appelle le comptage de référence, l'une des techniques les plus pratiques et sûres.
Le principe est simple : notre pointeur intelligent va maintenir un compteur qui indiquera combien de pointeurs
(toujours intelligents) pointent sur la ressource stockée. A chaque fois qu'un nouveau pointeur va venir pointer sur
notre ressource, ce compteur sera incrémenté. Opération inverse lors de la destruction d'un pointeur intelligent : le
compteur associé sera décrémenté. Lorsque le compteur atteint 0, c'est que plus aucun pointeur ne pointe sur la
ressource, elle peut donc être détruite en tout sécurité par le dernier pointeur intelligent (via delete).
On peut envisager d'innombrables utilisations de ce type de pointeur : stockage de pointeurs dans un conteneur sans
se soucier de la bonne gestion mémoire de ceux-ci, membres de classes pour envelopper nos pointeurs bruts dans un
objet automatique (et donc éviter d'avoir à écrire le constructeur par défaut, par copie, le destructeur et l'opérateur
d'affectation), ou encore simplement automatiser la destruction d'une variable dynamique au sein d'une fonction qui
peut lever des exceptions.
Tout ceci doit rester très abstrait pour vous, voici donc quelques exemples bien concrets pour vous mettre dans le bain :
| SANS pointeur intelligent |
class Resource
{
public :
Resource() : Ptr(NULL)
{
}
Resource(const Resource& Copy) : Ptr(NULL)
{
if (Copy.Ptr)
Ptr = new Truc(*Copy.Ptr);
}
~Resource()
{
delete Ptr;
}
const Resource& operator =(const Resource& Copy)
{
Resource Temp(Copy);
std::swap(Temp.Ptr, Ptr);
return *this;
}
private :
Truc* Ptr;
};
void FonctionSansFuite()
{
Truc* MonTruc = new Truc;
try
{
FonctionQuiPeutLeverUneException();
}
catch (...)
{
delete MonTruc;
throw;
}
delete MonTruc;
} |
| AVEC pointeur intelligent |
class Resource
{
private :
CSmartPtr<Truc> Ptr;
};
void FonctionSansFuite()
{
CSmartPtr<Truc> MonTruc(new Truc);
FonctionQuiPeutLeverUneException();
} |
Et ne vous détrompez pas, ces 2 morceaux de code sont bien équivalents ! Dans la seconde partie nous gérons tout aussi
bien (si ce n'est mieux) nos objets dynamique, et l'on peut être certain qu'aucune fuite ou bug ne surviendra, sans
avoir eu à écrire une seule ligne de code supplémentaire.
Que demande le peuple ?
Notre moteur contient sa propre classe de pointeurs intelligents, CSmartPtr. Je ne parle pas de son code ici mais
il est entièrement inclus au zip que vous pourrez trouver en fin d'article. La raison pour laquelle j'ai codé cette classe
est double : d'une part pouvoir la personnaliser selon les besoins du moteur, et d'autre part mieux cerner et maitriser
les aspects de ce concept fondamental, en mettant les mains dedans. Ce code vous sera donc utile tout autant
qu'à moi ;). Cependant il faut savoir que le codage de ce genre de classe peut s'avérer très fourbe, et devenir un
vrai casse-test de conception. C'est pourquoi le meilleur conseil que je puisse vous donner est d'utiliser autaut que
possible des classes existantes dans des bibliothèques exterieures, par exemple shared_ptr (et ses petits frères)
chez boost ou SmartPtr chez Loki. Elles ont été rédigées par des personnes compétentes, testées par des milliers
d'utilisateurs, sont bien codées et efficaces. Bien mieux que ce que vous (et moi) pourrez toujours écrire.
3.4. Les singletons
Hmm... Encore un mot barbare ?! Si ce concept vous est aussi familier que les planches de ouï-ja ou les applications
bilinéaires symétriques, cette petite introduction vous expliquera tout ce que vous aurez besoin de savoir avant d'entamer
la décortication du code source. Sinon, vous pouvez passer directement à l'implémentation de notre classe plus bas.
La planche de ouï-ja est donc un instrument mystique qui permet de... hum hum desolé (quel humour désopilant n'est-ce pas ?).
Donc, le singleton. Le singleton est ce qu'on appelle en conception objet un design pattern, ou encore pour les
puristes de la langue française, un motif de conception. Je ne vais pas vous détailler ici cette notion, si vous n'en
avez vraiment jamais entendu parler le net regorge de ressources très interessantes, et il existe également de très bons livres
traitant de ce sujet. Revenons donc à notre singleton. A quoi sert-il ? Très simplement à limiter le nombre d'instances
possibles d'une classe, en général une seule. Dans ce cas on pourra alors l'assimiler à une grosse variable globale,
sauf qu'elle sera parfaitement encapsulée et qu'on ne pourra vraiment en avoir qu'un exemplaire ! Les cas d'utilisation
d'un tel outil sont multiples : imaginez votre gestionnaire d'erreur (comme celui qu'on a vu plus haut), votre gestionnaire
de textures, votre gestionnaire de modeles, votre gestionnaire de niveau, ... (et en général tout ce qui contient le mot
gestionnaire) : hop tous des singletons ! En effet nous n'en voudrons qu'une et une seule instance tout au long du
programme, et pour certaines il faudra que celle-ci soit accessible sans se la trimballer de constructeur en
constructeur (ceux qui ont rencontré ce probleme savent de quoi je parle, pour les autres et bien... vous avez assurément
loupé un grand moment de C++ !).
Un outil qui sera utilisé aussi souvent nécessite très certainement un code adapté, au moins pour ne pas avoir à écrire
des dizaines et des dizaines de fois la même chose. Et puis une fois qu'on aura un code qui fonctionne, on sait qu'il
fonctionnera partout sans risque. Qu'allons-nous donc créer ? Et bien je vous le donne en mille : une classe CSingleton,
de laquelle il suffira de dériver toute classe que nous souhaiterons faire singleton. C'est aussi simple.
| Classe CSingleton | template <class T>
class CSingleton
{
public :
static T& Instance()
{
if (!Inst)
Inst = new T;
return *Inst;
}
static void Destroy()
{
delete Inst;
Inst = NULL;
}
protected :
CSingleton() {}
~CSingleton() {}
private :
static T* Inst;
CSingleton(CSingleton&);
void operator =(CSingleton&);
};
template <class T> T* CSingleton<T>::Inst = NULL; |
Tout d'abord, notre classe CSingleton est template : le paramètre T sera la classe à rendre singleton. En effet
ce sera des instances de CMemoryManager, CTextureManager, CModelManager, ... que nous devrons avoir, pas des instances
de CSingleton. Ensuite les deux seules méthodes publiques sont statiques et sont Instance() et Destroy().
La première servira à récupérer notre fameuse instance unique, la seconde sera utilisée pour la détruire. Mais comment
donc s'assurer qu'il n'existera qu'une et une seule instance de nos classes ? Eh bien en empêchant leur construction,
et plus particulièrement en rendant leurs constructeur / destructeur inaccessibles. Il faut également empêcher leur
copie : on rend le constructeur par copie et l'opérateur d'affectation également inaccessibles. Le constructeur par
défaut et le destructeur devront tout de même être appelés par les classes dérivées (qui seront je le rappelle nos
singletons), on doit donc leur laisser accessibles en les déclarant protected. Le constructeur par copie et
l'opérateur = ne seront eux jamais appelés : on peut les mettre en private. Encore mieux : on peut même ne pas les
implémenter, cela ne servirait de toute façon à rien. Le seul but ici est de générer une erreur de compilation si
quelqu'un essayait de les utiliser.
Voyons maintenant notre fonction Instance(). La première chose qu'elle va faire est regarder si l'instance de la
classe existe déjà : si ce n'est pas le cas elle va la créer, sinon elle va simplement la renvoyer. That's all !
Regardons maintenant de quelle manière nous allons rendre nos classes singleton en nous aidant de cette classe. Comme
je vous l'ai déjà dit, il va falloir dériver de CSingleton. Mais... CSingleton ne prend-elle pas justement en paramètre
template la classe à rendre singleton ?! On se mord la queue là ?? Eh bien oui, et c'est tout à fait voulu et valide.
C'est ce qu'on appelle le Curiously Reccuring Template Pattern : on dérive A d'une classe qui dépend de A. Voici le genre
de code qu'on trouvera donc dans notre moteur :
| Exemple de classe singleton | #include <Singleton.h>
class CTextureManager : public CSingleton<CTextureManager>
{
friend class CSingleton<CTextureManager>;
}; |
Bon, j'avoue que j'ai encore omis un détail : ce fameux friend class CSingleton<...> que vous pouvez voir. En effet, tout comme
CTextureManager aura besoin d'accéder aux constructeur / destructeur de CSingleton (car elle en hérite), ce dernier aura
également besoin de pouvoir accéder aux constructeur / destructeur de CTextureManager ! Pourquoi ? Parce que c'est lui qui
va construire et détruire notre instance unique. Rappelez vous : un new T dans CSingleton::Instance(), et un delete dans
CSingleton::Destroy(). Voilou voilou, une fois cette petite directive ajoutée nous pouvons maintenant utiliser notre classe
sans la moindre erreur, et elle sera bien un singleton. Nous récupérerons son instance via CTextureManager::Instance(),
et nous la détruirons avec CTextureManager::Destroy() lorsque nous n'en aurons plus besoin. Mission accomplie !
 |
Voilà, ce chapitre ne présente pas toutes les classes utiles intégrées au moteur, mais vous donne les plus importantes et
vous explique au passage quelques concepts fondamentaux.
Bien sûr si vous utilisez des bibliothèques externes (boost, Loki, ...) c'est encore mieux : n'oubliez pas que le but
est de se fatiguer le moins possible (on aura déjà suffisamment à faire avec le moteur en lui-même !).
|
4. Comment bien générer sa DLL
4.1. Options de configuration
Lorsque l'on crée une bibliothèque dynamique, il n'y a rien ou presque de particulier à spécifier dans les options
du projet. Une seule chose est importante : il faut lier avec la version dynamique de la bibliothèque standard.
En effet lorsque plusieurs DLL sont chargées, chacune possède sa zone d'allocation de mémoire. Ainsi une variable allouée
dans une DLL ne pourra être détruite que dans cette même DLL. Toute tentative de le faire ailleurs entraînera un
comportement indéterminé (un bon gros plantage dans la plupart des cas). Si on manipule des éléments de la bibliothèque
standard du C++, il peut arriver qu'on alloue de la mémoire pour une de ces variables dans une DLL, et qu'on la détruise
dans une autre, sans pouvoir y faire grand chose. La solution est alors de lier avec la bibliothèque standard dynamique,
ainsi toutes les allocations et désallocations correspondantes seront centralisées dans cette même DLL. Il ne faudra alors
pas oublier de livrer la DLL correspondante (MSVCRT.DLL pour Visual Studio.NET) avec notre bibliothèque,
et ne pas oublier également que celle-ci est différente pour chaque compilateur.
Sous Visual Studio.NET vous trouverez cette option dans :
Propriétés du projet -> C / C++ -> Génération de code -> Bibliothèque runtime -> DLL (de débogage) multithread
4.2. Exportation de classes / fonctions
Puisque nous écrivons une bibliothèque, nous aurons donc à exporter certaines classes ou fonctions. Typiquement on définit
une macro qui va exporter lorsqu'on compile le moteur, et importer lorsqu'on compile une application cliente.
#ifdef YESENGINE_EXPORTS
# define YES_EXPORT __declspec(dllexport)
#else
# define YES_EXPORT __declspec(dllimport)
#endif |
Pour exporter une classe ou une fonction il suffit maintenant d'ajouter YES_EXPORT dans sa déclaration :
class YES_EXPORT ILogger
{
};
YES_EXPORT void Fonction(); |
 |
Attention, cette manière d'exporter n'est pas portable !
|
5. Conclusion
Et bien voilà, nous sommes arrivés au bout de ce premier article. Comme vous pouvez le voir, la réalisation d'un moteur 3D
n'est pas aisée, et la conception d'outils et de classes utiles est plus que nécessaire si vous voulez coder dans de
bonnes conditions. J'espère vous avoir donné ici quelques bonnes idées, et montré de bonnes techniques et procédés de C++ et plus
généralement de programmation objet. N'oubliez pas : le temps que vous passerez à bien outiller votre moteur, c'est autant de
temps et de lignes de code que vous économiserez tout au long du développement.
Si cet article vous a paru trop "loin" de la réalisation du moteur 3D en lui-même, et que vous êtes impatients d'entrer dans
le vif du sujet ne vous inquiétez pas : dans les prochaines parties les choses sérieuses commenceront, avec entre autre le
codage du système de rendu multi-API, les classes de base du moteur (vertex/index buffers, textures, ...), le gestionnaire de
ressources, et bien d'autres choses encore !
6. Téléchargements
Les codes source livrés tout au long de cette série d'articles ont été réalisé sous Visual Studio.NET 2003. Aucun test n'a
été effectué sur d'autres compilateurs, mais vous ne devriez pas rencontrer de problème si vous possédez un compilateur récent,
le code respectant autant que possible le standard.
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 !
 
|