Réalisation d'un moteur 3D en C++ - partie I : les outils

Laurent Gomila

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.

- Titre : Réalisation d'un moteur 3D en C++ - partie I : les outils

- Auteur : Laurent Gomila

- Parution : 24 novembre 2004

- 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 ® 2004 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 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 :

//----------------------------------------------------------
// Destructeur
//----------------------------------------------------------
virtual ~ILogger();

//----------------------------------------------------------
// Change l'instance du logger
//----------------------------------------------------------
static void SetLogger(ILogger* Logger);

//----------------------------------------------------------
// Log un message (façon C)
//----------------------------------------------------------
static void Log(const char* Format, ...);

//----------------------------------------------------------
// Log un message (façon C++)
//----------------------------------------------------------
static ILogger& Log();
template <class T> ILogger& operator <<(const T& ToLog);

private :

//----------------------------------------------------------
// Inscrit un message - à redéfinir dans les classes dérivées
//----------------------------------------------------------
virtual void Write(const std::string& Message) = 0;

//----------------------------------------------------------
// Données membres
//----------------------------------------------------------
static ILogger* s_Instance; // Pointeur sur le logger actuel
};

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

//----------------------------------------------------------
// Dans la fenêtre de debug
//----------------------------------------------------------
class CLoggerDebug : public ILogger
{
virtual void Write(const std::string& Message);
{
OutputDebugString((Message + '\n').c_str());
}
};

//----------------------------------------------------------
// Dans des boîtes de dialogue
//----------------------------------------------------------
class CLoggerMsgBox : public ILogger
{
virtual void Write(const std::string& Message);
{
MessageBox(NULL, Message.c_str(), "Yes::Engine", MB_OK);
}
};

//----------------------------------------------------------
// Dans un fichier
//----------------------------------------------------------
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, ...)
{
// Formatage du message dans une chaîne de caractère
char sBuffer[512];
va_list Params;
va_start(Params, Format);
vsprintf(sBuffer, Format, Params);
va_end(Params);

// Loggization
s_Instance->Write(sBuffer);
}

// Exemple
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());

// On n'oublie pas de toujours renvoyer notre instance, pour pouvoir chaîner les appels à <<
return Log();
}

ILogger& ILogger::Log()
{
return *s_Instance;
}

// Exemple
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 :

//----------------------------------------------------------
// Ajoute une allocation mémoire
//----------------------------------------------------------
void* Allocate(std::size_t Size, const CFile& File, int Line, bool Array);

//----------------------------------------------------------
// Retire une allocation mémoire
//----------------------------------------------------------
void Free(void* Ptr, bool Array);

//----------------------------------------------------------
// Sauvegarde les infos sur la désallocation courante
//----------------------------------------------------------
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 :

// Même code que ci-dessus

//----------------------------------------------------------
// Renvoie l'instance de la classe
//----------------------------------------------------------
static CMemoryManager& Instance();

private :

//----------------------------------------------------------
// Constructeur par défaut
//----------------------------------------------------------
CMemoryManager();

//----------------------------------------------------------
// Destructeur
//----------------------------------------------------------
~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;  // Taille allouée
CFile       File;  // Fichier contenant l'allocation
int         Line;  // Ligne de l'allocation
bool        Array; // Est-ce un objet ou un tableau ?
};

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; // Blocs de mémoire alloués

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;        // Fichier de sortie
std::stack<TBlock> m_DeleteStack; // Pile contenant les infos sur les prochaines désallocations

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)
{
// Allocation de la mémoire
void* Ptr = malloc(Size);

// Ajout du bloc à la liste des blocs alloués
TBlock NewBlock;
NewBlock.Size  = Size;
NewBlock.File  = File;
NewBlock.Line  = Line;
NewBlock.Array = Array;
m_Blocks[Ptr]  = NewBlock;

// Loggization
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)
{
// Recherche de l'adresse dans les blocs alloués
TBlockMap::iterator It = m_Blocks.find(Ptr);

// Si le bloc n'a pas été alloué, on génère une erreur
if (It == m_Blocks.end())
{
// En fait ça arrive souvent, du fait que le delete surchargé
// est pris en compte même là où on n'inclue pas DebugNew.h,
// mais pas la macro pour le new
// Dans ce cas on détruit le bloc et on quitte immédiatement
free(Ptr);
return;
}

// Si le type d'allocation (tableau / objet) ne correspond pas, on génère une erreur
if (It->second.Array != Array)
{
throw CBadDelete(Ptr, It->second.File.Filename(), It->second.Line, !Array);
}

// Finalement, si tout va bien, on supprime le bloc et on loggiz tout ça
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();

// Libération de la mémoire
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())
{
// Aucune fuite, bravo !
m_File << "     No leak detected, congratulations !  " << std::endl;
}
else
{
// Fuites mémoires =(
m_File << "   Oops... Some leaks have been detected  " << std::endl;

ReportLeaks();
}
}

void CMemoryManager::ReportLeaks()
{
// Détail des fuites
std::size_t TotalSize = 0;
for (TBlockMap::iterator i = m_Blocks.begin(); i != m_Blocks.end(); ++i)
{
// Ajout de la taille du bloc au cumul
TotalSize += i->second.Size;

// Inscription dans le fichier des informations sur le bloc courant
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;

// Libération de la mémoire
free(i->first);
}

// Affichage du cumul des fuites
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>

// Contenu de votre en-tête

#include <DebugNewOff.h>

Dans les fichiers source

#include <...>
#include <DebugNew.h>

// Contenu de votre fichier

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

//==========================================================
// Classe de base pour les exceptions
//==========================================================
class CException : public std::exception
{
public :

// Constructeur par défaut
CException(const std::string& Message = "");

// Destructeur
// throw() indique que la fonction ne lèvera pas d'exception
// certains compilos râlent si on l'omet (g++), d'autres non (VC++)
virtual ~CException() throw();

// Renvoie le message associé à l'exception
virtual const char* what() const throw();

protected :

// Données membres
std::string m_Message; // Message associé à l'exception
};

//==========================================================
// Exception lancée si une condition n'est pas vérifiée
//==========================================================
struct CAssertException : public CException
{
CAssertException(const std::string& File, int Line, const std::string& Message);
};

//==========================================================
// Anomalie d'allocation mémoire
//==========================================================
struct CBadDelete : public CException
{
CBadDelete(const void* Ptr, const std::string& File, int Line, bool NewArray);
};

//==========================================================
// Exception lancée lors d'erreur de chargement de fichiers
//==========================================================
struct CLoadingFailed : public CException
{
CLoadingFailed(const std::string& File, const std::string& Message);
};

//==========================================================
// Exception lancée lors de saturations de mémoire
//==========================================================
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

// Exemple d'utilisation
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 :

//----------------------------------------------------------
// Constructeur à partir d'un std::string
//----------------------------------------------------------
CFile(const std::string& Name = "unknown");

//----------------------------------------------------------
// Constructeur à partir d'un const char*
//----------------------------------------------------------
CFile(const char* Name);

//----------------------------------------------------------
// Indique si le fichier existe ou non
//----------------------------------------------------------
bool Exists() const;

//----------------------------------------------------------
// Renvoie le nom du fichier avec son chemin complet
//----------------------------------------------------------
const std::string& Fullname() const;

//----------------------------------------------------------
// Renvoie le nom du fichier sans son chemin
//----------------------------------------------------------
std::string Filename() const;

//----------------------------------------------------------
// Renvoie le nom du fichier sans extension ni chemin
//----------------------------------------------------------
std::string ShortFilename() const;

//----------------------------------------------------------
// Renvoie l'extension du fichier
//----------------------------------------------------------
std::string Extension() const;

private :

//----------------------------------------------------------
// Données membres
//----------------------------------------------------------
std::string m_Name; // Chemin complet du fichier
};

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 :

//----------------------------------------------------------
// Constructeur par défaut
//----------------------------------------------------------
CPlugin();

//----------------------------------------------------------
// Destructeur
//----------------------------------------------------------
~CPlugin();

//----------------------------------------------------------
// Charge la DLL et récupère un pointeur sur l'objet
//----------------------------------------------------------
T* Load(const std::string& Filename);

private :

//----------------------------------------------------------
// Types
//----------------------------------------------------------
typedef T* (*PtrFunc)();

//----------------------------------------------------------
// Données membres
//----------------------------------------------------------
HMODULE m_Library; // Handle de la DLL
};

// Exemple d'utilisation
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)
{
// Chargement de la bibliothèque dynamique
m_Library = LoadLibrary(Filename.c_str());
if (!m_Library)
throw CLoadingFailed(Filename, "Impossible de charger la bibliothèque dynamique");

// Récupération de la fonction
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

// Une classe bien lourde à écrire, juste pour gérer un seul pointeur
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;
};

// Une fonction sans fuite mais lourde à gérer
void FonctionSansFuite()
{
Truc* MonTruc = new Truc;

try
{
FonctionQuiPeutLeverUneException();
}
catch (...)
{
delete MonTruc;
throw;
}

delete MonTruc;
}

AVEC pointeur intelligent

// Une classe équivalente, mais déjà bien plus sympathique
class Resource
{
private :

CSmartPtr<Truc> Ptr;
};

// Une fonction sans fuite mais cette fois plus légère
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.

Pour plus d'informations sur les pointeurs intelligents, et notamment les difficultés que l'on peut rencontrer en les codant, je vous invite à lire cet excellent passage du livre Modern C++ Design : Les pointeurs intelligents, par Andrei Alexandrescu

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 :

//----------------------------------------------------------------
// Renvoie l'instance unique de la classe
//----------------------------------------------------------------
static T& Instance()
{
if (!Inst)
Inst = new T;

return *Inst;
}

//----------------------------------------------------------------
// Détruit l'instance unique de la classe
//----------------------------------------------------------------
static void Destroy()
{
delete Inst;
Inst = NULL;
}

protected :

//----------------------------------------------------------------
// Constructeur par défaut
//----------------------------------------------------------------
CSingleton() {}

//----------------------------------------------------------------
// Destructeur
//----------------------------------------------------------------
~CSingleton() {}

private :

//----------------------------------------------------------------
// Données membres
//----------------------------------------------------------------
static T* Inst; // Instance de la classe

//----------------------------------------------------------------
// Copie interdite
//----------------------------------------------------------------
CSingleton(CSingleton&);
void operator =(CSingleton&);
};

//----------------------------------------------------------------
// Déclaration de notre variable statique
//----------------------------------------------------------------
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.

Télécharger les sources de cet article (zip, 15 Ko)

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