Réalisation d'un moteur 3D en C++, partie VII : la console
Date de publication : 08/09/2005 , Date de mise à jour : 14/09/2005
Par
Laurent Gomila (Autres articles)
Nous allons dans cette nouvelle partie nous éloigner un peu du coeur du moteur. Loin d'être une étape primordiale, la conception d'une
console est toutefois souvent dans les préoccupations des codeurs, et peut se révèler bien pratique pour la suite du développement. Nous
en profiterons également pour étudier quelques bons morceaux de C++.
1. Introduction
2. Le coeur de la console
3. Encapsulation des fonctions
3.1. Les foncteurs
3.2. Implémentation des foncteurs "fourre-tout"
3.3. Fonctions de binding
4. Personnalisation de la console
5. Conclusion
6. Téléchargements
1. Introduction
La conception d'une console est aujourd'hui presque un classique. Cependant la façon de la coder n'a elle pas beaucoup évolué. Il
s'agit la plupart de temps d'associer des commandes (sous forme de chaînes) à des fonctions de type prédéfini. Au choix, on peut
trouver : des fonctions prenant un void*, des fonctions prenant un tableau de chaînes, des fonctions prenant une chaîne et un
void*, ... Tout ça pour dire qu'on ne sait pas comment passer correctement n'importe quel type de paramètre. Et c'est là le
principal problème des consoles : elles sont incapables de s'adapter aux fonctions qu'on voudrait leur faire appeler. Au lieu de
cela, c'est l'utilisateur qui doit adapter ses fonctions afin qu'elles soient compatibles avec le type attendu par la console.
Vous l'aurez donc deviné, nous ne nous arrêterons pas là et essayerons d'avoir une gestion la plus transparente possible pour
l'utilisateur. En gros, nous allons faire une console qui accepte de manière homogène à peu près tout et n'importe quoi à
appeler.
Un autre aspect important dans le développement d'une console est la séparation des tâches. On serait vite tenté de créer une
classe CConsole effectuant magiquement tout le sale boulot, mais ce serait une erreur. La gestion d'une console fait intervenir
plusieurs tâches distinctes, qu'il vaudra mieux séparer pour une meilleure maintenance et une meilleure flexibilité. En outre,
nous allons développer 3 "modules" bien séparés mais intéragissant entre eux : le coeur de la console (traitement de la saisie
et des commandes), le mécanisme d'encapsulation des fonctions, et pour finir, l'aspect visuel.
2. Le coeur de la console
Une console n'est en soi pas très difficile à implémenter. Comme expliqué en intro, il ne s'agit que d'un mécanisme
d'association entre des identifiants (les noms des commandes) et des fonctions. Lorsqu'on saisit une certaine commande, hop on
appelle la fonction qui lui est associée.
La première chose à faire est donc de fournir à notre console une table associant des commandes (ie. des chaînes) à des
fonctions (std::map est toujours parfait pour ça), ainsi qu'une fonction d'ajout de commande.
Une console a également besoin de savoir ce qui est saisi au clavier. L'erreur serait d'intégrer à notre console directement la
gestion du clavier, et ceci pour deux raisons : la saisie est typiquement un aspect lié à l'OS et donc non-portable, et de plus
la console ne sera certainement pas la seule à en avoir besoin. Gardez toujours en tête qu'une bonne séparation des tâches est
primordiale. Pour en revenir à notre console, nous allons donc simplement lui fournir un moyen de récupérer la saisie
utilisateur, peut importe d'où elle vient.
Enfin, il faudra également penser à une pitite fonction pour activer ou désactiver notre console.
class YES_EXPORT CConsole : public CSingleton<CConsole>
{
friend class CSingleton<CConsole>;
public :
void RegisterCommand(const std::string& Name, const Console::CFunctor& Function);
void SendChar(char Character);
void Enable(bool Enabled);
private :
void ProcessCurrent();
typedef std::map<std::string, Console::CFunctor> TCommandTable;
TCommandTable m_Commands;
std::string m_Current;
bool m_Enabled;
}; |
Pour l'instant nous ne savons pas encore comment nous allons stocker nos fonctions. Nous savons juste que ce sera dans la classe
CFunctor, et que celle-ci sera capable de relayer correctement l'appel ainsi que les paramètres via son opérateur ().
La fonction d'enregistrement de commande se passe de commentaire :
void CConsole::RegisterCommand(const std::string& Name, const Console::CFunctor& Function)
{
m_Commands[Name] = Function;
} |
Le traitement des caractères est lui aussi plutôt simple : on ajoute le caractère reçu à la chaîne courante. Il faut juste gérer
2 caractères spéciaux : le saut de ligne, qui marque la validation de la commande, et le retour arrière qui efface le dernier
caractère.
void CConsole::SendChar(char Character)
{
if (!m_Enabled)
return;
switch (Character)
{
case '\n' :
case '\r' :
if (!m_Current.empty())
{
ProcessCurrent();
m_Current.clear();
}
break;
case '\b' :
if (!m_Current.empty())
m_Current.erase(m_Current.size() - 1);
break;
default :
m_Current += Character;
break;
}
} |
La fonction la plus importante est sans doute celle-ci : elle s'occupe de "parser" la ligne saisie, pour y extraire la commande
et les paramètres. Une fois tout ceci récupéré, on peut appeler la fonction correspondant à la commande.
void CConsole::ProcessCurrent()
{
std::string Command;
std::istringstream iss(m_Current);
iss >> Command;
TCommandTable::iterator It = m_Commands.find(Command);
if (It != m_Commands.end())
{
std::string Params;
std::getline(iss, Params);
It->second(Params);
}
else
{
}
} |
Il est important de remarquer que les paramètres sont toujours passés sous forme de chaînes de caractères. Ceci pour une raison
bien simple : nous ne pouvons pas savoir quel est le prototype de la fonction sous-jacente stockée dans notre CFunctor. Elle
peut très bien prendre en paramètre un entier, deux floats, rien du tout, ou n'importe quoi d'autre. Mais à ce niveau cela ne
nous regarde pas encore ; souvenez-vous que les tâches sont clairement séparées et que celle-ci n'est pas du ressort de CConsole.
Le mécanisme de correspondance et de conversion sera abordé dans le prochain chapitre, c'est-à-dire... maintenant.
3. Encapsulation des fonctions
Quittons à présent le coeur de notre console, qui fonctionne maintenant parfaitement, et penchons nous sur la partie la plus
importante de ce tutoriel : le mécanisme d'encapsulation des fonctions. Si nous avions implémenté notre console comme 95% des
consoles, alors nos CFunctor seraient seulement des pointeurs vers fonction de type prédéfini, et tout serait terminé. Mais
nous avons à notre disposition un langage puissant, l'envie d'apprendre des techniques sympas, et un peu de temps devant nous.
Et, surtout, nous voulons fournir à l'utilisateur (et à nous même) un fonctionnement qui soit le plus sympathique possible !
Ainsi donc nous allons mettre sur pied un mécanisme permettant de faire non pas ceci :
struct Application
{
static void Add(const string& Args)
{
int x, y;
istringstream iss(Args);
if (!(iss >> x >> y))
throw CBadConversion("Pas cool...");
int z = x + y;
ostringstream oss;
oss << z;
Console.Print(oss.str());
}
};
Console.RegisterFunction("add", &Application::Add); |
... mais ceci :
struct Application
{
static int Add(int x, int y)
{
return x + y;
}
};
Console.RegisterFunction("add", Bind(&Application::Add)); |
Ainsi c'est au mécanisme de la console qu'est relégué le boulot de conversion des paramètres, de vérification des erreurs et
d'affichage du résultat ; tout ceci est invisible pour l'utilisateur ce qui est finalement le plus important.
3.1. Les foncteurs
Avant de rentrer dans le détail du code et ses explications, il est important de s'arrêter sur les foncteurs. Il s'agit en
effet d'un concept important du C++, souvent peu connu lorsqu'on débute. Mais qu'est-ce qu'on foncteur exactement ? A quoi
ça ressemble ? Et à quoi cela peut-il bien servir ?
-> Ce que c'est
Un foncteur est tout simplement "quelque chose" qui peut servir de fonction, c'est-à-dire auquel on peut appliquer
l'opérateur () d'appel de fonction. C'est bien sûr le cas pour les pointeurs de fonctions "classiques", mais aussi, et c'est
là que c'est intéressant, pour les instances de classes surchargeant l'opérateur (). On appelle de telles instances des
"objets-fonction". Cependant, on commet habituellement l'abus de langage de parler de foncteur pour désigner uniquement les
objets-fonction. On pourra également noter que les foncteurs possèdent typiquement une sémantique de valeur (ce sont des
objets copiables).
Les objets-fonction se révèlent bien plus puissants que les pointeurs de fonction classiques : autant au niveau performance
(possibilité de tout inliner) que de la flexibilité (l'objet-fonction étant une classe, on peut lui passer des paramètres
via un constructeur par exemple).
-> A quoi ça ressemble
Comme je vous l'ai dit, un foncteur peut être un pointeur sur fonction classique, mais ce qui nous intéresse ici sont les
objets-fonction. Un tel objet est habituellement une structure / classe, possédant donc un opérateur (), éventuellement
un constructeur prenant des paramètres, ainsi que des données membres pour stocker ceux-ci. Le tout en un seul bloc,
afin de tirer partie de l'inlining. La signature de l'opérateur () peut varier, selon l'utilisation que l'on aura du
foncteur. Par exemple :
struct Functor
{
Functor (int Param) : m_Param(Param) {}
int operator ()(int x1, int x2) const
{
return x1 + x2 + m_Param;
}
private :
int m_Param;
};
Functor F(10);
int z = F(5, 6); |
-> A quoi ça sert
Les foncteurs et surtout les objets-fonction sont très utilisés, notamment dans la STL. On peut les utiliser pour spécifier
un critère de tri, pour appliquer une action à tous les éléments d'un conteneur, ou encore filtrer certaines valeurs
ne répondant pas à un critère donné, par exemple.
Voici un petit exemple de leur utilisation avec la STL :
struct TriDecroissant
{
bool operator ()(int x1, int x2) const
{
return x1 > x2;
}
};
struct Plus
{
Plus(int Inc) : m_Inc(Inc) {}
int operator ()(int x) const
{
return x + m_Inc;
}
private :
int m_Inc;
};
std::vector<int> v;
std::transform(v.begin(), v.end(), v.begin(), Plus(5));
std::sort(v.begin(), v.end(), TriDecroissant()); |
3.2. Implémentation des foncteurs "fourre-tout"
Ici, nous allons nous servir de foncteurs (d'objets-fonctions pour être précis) afin d'encapsuler de manière homogène
tout type de fonction ou de foncteur. Plus précisément, nous allons construire une classe CFunctor qui sera l'interface
entre notre console et "tout et n'importe quoi". Le fait de pouvoir fourrer n'importe quel type de fonction / foncteur
dans une seule classe apportera sans conteste une très grande flexibilité, mais afin d'y parvenir il faudra déployer
un peu plus de neurones que d'habitude, et surtout du C++ plein de templates comme on les aime.
Commençons par la définition de cette fameuse classe CFunctor :
class YES_EXPORT CFunctor
{
public :
CFunctor(IFunction* Func = NULL) : m_Function(Func) {}
std::string operator ()(const std::string& Params = "") const
{
if (!m_Function)
throw CException("Tentative d'effectuer un appel à une fonction nulle via un foncteur");
return m_Function->Execute(Params);
}
private :
CSmartPtr<IFunction> m_Function;
}; |
Rien de bien étrange pour un foncteur : un constructeur prenant un paramètre, une donnée membre pour le stocker, et un
opérateur () pour faire le boulot. En fait, cette classe n'aura pour but que de relayer l'appel de fonction à une certaine
classe abstraite IFunction. Ce n'est donc pas à ce niveau qu'interviennent les manipulations magiques ; regardons
alors du côté de IFunction :
class IFunction
{
public :
virtual ~IFunction() {}
virtual std::string Execute(const std::string& Params) = 0;
}; |
Il s'agit d'une classe abstraite ne définissant qu'une fonction virtuelle : celle qui va relayer (une fois de plus --
heureusement qu'on ne doit pas s'occuper des performances) l'appel de fonction. En fait, elle représente une
"fonction abstraite", ce sont ses classes dérivées qui vont effectivement stocker nos pointeurs de fonctions / foncteurs.
Il y aura en l'occurence une classe pour chaque "type" de fonction, et comme vous vous en doutez il y en aura un paquet
(fonctions membres, libres, const, sans paramètre, à 1 paramètre, ...).
Voici par exemple la classe dérivée qui va s'occuper de stocker et executer les fonctions libres à 1 paramètre :
template <typename Ret, typename Arg1>
class CFunction1 : public IFunction
{
private :
virtual std::string Execute(const std::string& Params)
{
typename Base<Arg1>::Type a1;
CStringExtractor(Params.c_str())(a1).ThrowIfEOF();
return CallFun<Ret>::Do(m_Func, a1);
}
typedef Ret (*TFuncType)(Arg1);
TFuncType m_Func;
public :
CFunction1(TFuncType Func) : m_Func(Func) {}
}; |
Et la classe dérivée qui va elle s'occuper des fonctions membres (sans paramètre, par exemple) :
template <typename ParamType, typename FuncType, typename Ret>
class CFunctionMem0 : public IFunction
{
private :
virtual std::string Execute(const std::string& Params)
{
CStringExtractor(Params.c_str()).ThrowIfEOF();
return CallMemFun<Ret, ParamType>::Do(m_Obj, m_Func);
}
FuncType m_Func;
ParamType m_Obj;
public :
CFunctionMem0(FuncType Func, ParamType Obj) : m_Func(Func), m_Obj(Obj) {}
}; |
Le contenu des fonctions Execute peut vous sembler bizarre, mais pas de panique nous allons l'étudier en détail sans
plus tarder.
La déclaration des paramètres à extraire ne se fait pas par un simple "Arg1 a1", mais via un template nommé
Base. Pourquoi cette complication ? Il s'agit en fait d'une astuce permettant de gérer les situations où Arg1 serait
un type référence ou constant (ou les deux) ; en effet dans ce cas il sera soit impossible de déclarer notre variable,
soit de s'en servir. Le template Base permet de régler ce problème en "extrayant" le type de base correspondant à
un type donné, c'est-à-dire sans les const et/ou les références.
template <typename T> struct Base {typedef T Type;};
template <typename T> struct Base<T&> {typedef T Type;};
template <typename T> struct Base<const T> {typedef T Type;};
template <typename T> struct Base<const T&> {typedef T Type;};
|
L'extraction des paramètres (conversion depuis string vers le bon type) passe par une classe CStringExtractor.
Il s'agit simplement d'une classe utilitaire (elle sera bien utile ailleurs également) qui extrait des variables à partir
d'une chaîne, le tout avec une syntaxe simplifiée et en gérant les différentes erreurs d'extraction. Ce sont notamment ces
erreurs que va récupérer et afficher la console, dans le cas où une commande aurait mal été saisie.
L'appel de fonction et le renvoi du résultat s'effectue finalement via un autre template, CallFun ou
CallMemFun selon le type de la fonction. Encore une fois, à quoi cela sert-il ? Simplement à gérer le cas des
fonctions ne renvoyant rien (void), sans avoir à spécialiser la totalité des classes foncteurs. Ainsi notre template
ressemblera à ceci :
template <typename Ret>
struct CallFun
{
template <typename FuncType>
static std::string Do(FuncType Func)
{
return CStringBuilder(Func());
}
};
template <>
struct CallFun<void>
{
template <typename FuncType>
static std::string Do(FuncType Func)
{
Func();
return "";
}
}; |
La conversion de la valeur de retour en string fait lui appel à une autre classe utilitaire : CStringBuilder.
C'est en fait la copine de CStringExtractor, qui effectue le boulot inverse. Ces deux classes ne sont d'ailleurs
que des petits wrappers pour les stringstream, avec une syntaxe plus légère et une gestion des erreurs.
3.3. Fonctions de binding
Il nous faut maintenant un moyen de construire facilement des instances de ces structures. C'est là qu'est toute la beauté du
C++ : grace à la généricité, à la détection automatique et à la surcharge de fonction, nous allons pouvoir le faire très
simplement, et surtout pour n'importe quel type de fonction. Contrairement aux solutions classiques à base de "simple"
pointeur sur fonction, nous allons pouvoir manipuler absolument tout et n'importe via une seule fonction.
Le principe est simple : nous allons mettre sur pied une fonction Bind prenant en paramètre ce qu'il faut (foncteur,
fonction, ou fonction + instance) et renvoyant un CFunctor. En fait nous n'allons pas écrire une seule fonction, mais
plusieurs : il faudra une surcharge pour chaque cas à distinguer.
Commençons par le cas de base : une fonction libre, ne prenant aucun paramètre.
template <typename Ret>
inline CFunctor Bind(Ret (*Func)())
{
return new CFunction0<Ret>(Func);
} |
Le paramètre template est le type de retour de la fonction, étant donné que seul lui peut varier.
Un peu plus compliqué : une fonction libre, mais prenant cette fois un paramètre.
template <typename Ret, typename Arg1>
inline CFunctor Bind(Ret (*Func)(Arg1))
{
return new CFunction1<Ret, Arg1>(Func);
} |
Et finalement, la même chose avec 2 paramètres :
template <typename Ret, typename Arg1, typename Arg2>
inline CFunctor Bind(Ret (*Func)(Arg1, Arg2))
{
return new CFunction2<Ret, Arg1, Arg2>(Func);
} |
Attention maintenant, autre difficulté : les fonctions membres, sans paramètre pour débuter.
template <typename Class, typename ParamType, typename Ret>
inline CFunctor Bind(Ret (Class::*Func)(), ParamType& Obj)
{
return new CFunctionMem0<ParamType&, Ret (Class::*)(), Ret>(Func, Obj);
} |
Ici nous prenons en paramètre non seulement la fonction à appeler, mais aussi l'instance de la classe qu'il faudra utiliser
pour l'appel. N'oubliez pas de prendre le paramètre par référence, afin de ne pas effectuer de copie qui créerait d'éventuels
bugs sournois. Vous remarquerez également que notre fonction traite deux types de classe : Class et ParamType ;
alors qu'en théorie, ces deux types devraient être identiques. En pratique ce sera presque toujours le cas sauf dans le cas
de l'héritage, par exemple pour une instance qui voudrait appeler une fonction d'une de ses classes de base. Voici une
illustration de ce problème :
struct Base
{
void F() {}
};
struct Derivee : Base
{
};
Derivee d;
Bind(&Derivee::F, d);
Bind(&Base::F, d);
Bind(&Base::F, static_cast<Base&>(d)); |
Pour les fonctions à 1 ou 2 paramètres, c'est exactement la même chose que plus haut.
Seconde difficulté : les fonctions membres constantes. Et oui, le const d'une fonction fait partie de son type, il faut donc
créer les surcharges correspondantes. Qui plus est, notre instance devra elle aussi être const pour pouvoir appeler la
fonction, il faudra donc la prendre par référence constante.
template <typename Class, typename ParamType, typename Ret>
inline CFunctor Bind(Ret (Class::*Func)() const, const ParamType& Obj)
{
return new CFunctionMem0<const ParamType&, Ret (Class::*)() const, Ret>(Func, Obj);
} |
Enfin dernière difficulté : les objets-fonction. Un tel objet étant presque toujours un temporaire (car construit à la volée
-- cf. les divers exemples), il ne faut cette fois plus en stocker une référence comme nous le faisions, mais une copie.
Diantre, nous venons de faire en sorte de stocker des références justement pour éviter les copies ! Et bien hop, il n'y a
plus qu'à écrire de nouvelles surcharges :
template <typename Class, typename Ret>
inline CFunctor BindCopy(Ret (Class::*Func)(), Class Obj)
{
return new CFunctionMem0<Class, Ret (Class::*)(), Ret>(Func, Obj);
}
|
Nous sommes ici obligés de changer le nom de la fonction, car des surcharges identiques existent déjà. Pas de souci
cependant, l'utilisateur n'aura en théorie pas besoin de les appeler directement. C'est notre surcharge gérant les
objets-fonction qui va le faire :
template <typename T>
inline CFunctor Bind(T Obj)
{
return BindCopy(&T::operator (), Obj);
} |
Voilà, arrivé ici nous gérons a priori tous les cas possibles et imaginables pouvant se présenter. Un peu long à écrire
(même si ce n'est que du copié collé), mais une fois terminé nous n'aurons plus besoin d'y toucher et nous aurons à notre
disposition une flexibilité plus qu'appréciable. Voici ce que l'on peut à présent écrire :
class MaClasse
{
static std::string StaticFun(int x1, int x2);
int MemFun();
void ConstMemFun(double d) const;
};
void FreeFun(const MaClasse& x);
struct Functor
{
bool operator ()(const MaClasse& c1, const MaClasse& C2) const
{
}
};
MaClasse C;
Bind(&MaClasse::StaticFun);
Bind(&MaClasse::MemFun, C);
Bind(&MaClasse::ConstMemFun, C);
Bind(&FreeFun);
Bind(Functor());
Bind(&std::min<int>);
Bind(std::plus<int>());
Bind(std::multiplies<float>());
Bind(Bind(Bind(Bind(Bind(&FreeFun))))); |
4. Personnalisation de la console
Arrivé à ce point, nous avons le coeur de notre console ainsi qu'un mécanisme pour lui envoyer toute sorte de fonction à
executer. Mais qu'en est-il de l'affichage ? A quel moment va-t-on choisir quoi faire lors d'une erreur, ou lors de l'execution
d'une commande ? La réponse est simple : ça dépend. A ce niveau on ne peut pas décider à la place de l'utilisateur, car le style
visuel de la console et son comportement ne sont pas uniques ; il doit pouvoir les spécifier. Bien sûr on ne va pas laisser
notre pauvre utilisateur dans l'embarras, et nous allons lui offrir de quoi faire tout ça simplement.
En C++, le meilleur moyen de spécifier un comportement est de dériver une classe abstraite. Une fois de plus nous n'allons donc
pas nous en priver, et construire la classe de base qui va bien pour ce que l'on veut faire : la classe ILook. Voyons donc
ce que nous allons bien pouvoir fourrer dedans afin d'offrir à l'utilisateur un grand choix de personnalisation, tout en gardant
quelque chose de simple et qui ne peut pas altérer le comportement interne de notre console.
class ILook
{
public :
virtual ~ILook() {}
virtual void Update() = 0;
virtual void Draw() const = 0;
virtual void Show(bool Visible) = 0;
virtual void CommandCalled(const std::string& Result) = 0;
virtual void TextChanged(const std::string& NewText) = 0;
virtual void Error(const std::string& Message) = 0;
}; |
Comme vous le voyez, les fonctions que nous avons définies pour l'interface de notre classe restent élémentaires, et
l'utilisateur pourra sans trop d'efforts définir sa console perso. Pour vous en convaincre, un exemple d'implémentation est livré
dans le code source accompagnant ce tutoriel.
Il ne reste plus maintenant qu'à faire intéragir cette classe avec le coeur de notre console. La première chose à faire est
d'y stocker un pointeur vers un ILook, qui correspondra au look perso défini par l'utilisateur, ainsi que de fournir de quoi
changer celui-ci.
class YES_EXPORT CConsole : public CSingleton<CConsole>
{
public :
void ChangeLook(Console::ILook* NewLook);
void Update();
void Draw() const;
private :
CSmartPtr<Console::ILook> m_Look;
}; |
... puis de placer judicieusement les appels aux fonctions virtuelles de ILook :
void CConsole::ChangeLook(Console::ILook* NewLook)
{
Assert(NewLook != NULL);
m_Look = NewLook;
}
void CConsole::SendChar(char Character)
{
m_Look->TextChanged(m_Current);
}
void CConsole::Update()
{
m_Look->Update();
}
void CConsole::Draw() const
{
m_Look->Draw();
}
void CConsole::Enable(bool Enabled)
{
m_Enabled = Enabled;
m_Look->Show(Enabled);
}
void CConsole::ProcessCurrent()
{
if (It != m_Commands.end())
{
try
{
m_Look->CommandCalled(It->second(Params));
}
catch (std::exception& E)
{
m_Look->Error(E.what());
}
}
else
{
m_Look->Error("Commande \"" + Command + "\" inconnue");
}
} |
5. Conclusion
Bien que loin d'être primordiale au sein du moteur, la conception d'une console nous a permis d'aborder certains points
intéressants. Et puis, elle n'est certes pas essentielle mais elle reste bien pratique : maintenant que nous avons une
console, nous n'allons pas nous en priver pour la suite du développement (affichage de stats, d'infos de debugging, interactions
avec l'utilisateur, ...).
Nous avons vu qu'il était important de correctement séparer les tâches, notamment l'aspect fonctionnel de l'aspect visuel.
Au travers de cette console, nous avons également vu que le C++ était un langage puissant, et qu'il pouvait nous permettre de
coder des choses étonnantes si l'on s'en donnait les moyens. Le code produit peut paraître lourd et compliqué, mais encore une
fois il faut regarder non pas les moyens mais les résultats. Et de ce côté il n'y a pas photo : l'utilisateur dispose d'un
mécanisme flexible et fiable ainsi que d'un typage fort, tout cela via une interface simplifiée à l'extrême.
Enfin, il ne faut pas oublier qu'il ne s'agissait ici que d'un détour dans notre moteur, et que beaucoup de concepts primordiaux
sont encore à voir : shaders, graphes de scène, gestion et optimisation du rendu, partitionnement spatial, ... De quoi occuper
encore pour longtemps nos journées !
6. Téléchargements
Les sources fournies dans les précédents tutoriels sont entièrement intégrées à celles-ci, ainsi qu'une petite démo
reprenant tous les concepts vus, de manière à ce que vous ayiez toujours un package complet et à jour.
Les codes sources livrés tout au long de cette série d'articles ont été réalisés sous Visual Studio.NET 2003 ; des test ont
également été effectués avec succès sur DevC++ 4.9.9.1. Quant aux autres compilateurs vous ne devriez pas rencontrer de
problème (pour peu qu'ils soient suffisamment récents), le code respectant autant que possible le standard.
 Screenshot de l'application exemple
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 !
 
|