Architecture logicielle & Développement

Exploration de XNA : Anatomie d'un projet XNA | 5 vote(s)

Ce billet est le second d'une série consacrée à la plateforme XNA de Microsoft (le premier billet est ici. Au cours de cette série, nous étudierons plusieurs aspects du développement de jeux vidéo, tant au niveau de l'architecture qu'au niveau de la programmation elle même - avec au final un but : vous permettre de vous lancer à la poursuite de vos rêves. Enfin, si programmer un jeu est votre rêve.

Logo XNA [Télécharger Visual C# 2005 Express Edition] [Télécharger XNA Game Studio 1.0 Express Edition] [Télécharger le Service Pack 1 pour VC# 2005 EE] [TorqueX, de GarageGames.com] [XNA Resources] [Forum MSDN XNA Game Studio Express] [GameDev.Net - Plenty of 1s and 0s]

Dive into the code!

Lorsqu'on demande à XNAGS[1] de générer un projet XNA - que ce soit un jeu XBox360 ou un jeu Windows - un certain nombre de classes sont pré-générées et forment le squelette de l'application. Ce squelette est commun à toutes les applications XNA, et permet d'exécuter les différentes phases obligatoires du programme :

  • initialisation : c'est durant cette phase que la carte graphique va être initialisée et que les ressources les plus importantes seront chargées. On discutera plus loin de la gestion des ressources.
  • boucle principale : pendant l'exécution de la boucle principale, qui est appelée plusieurs fois par seconde, on effectue la mise à jour du modèle (au sens modèle-vue-contrôleur) du monde et le rendu de celui-ci.
  • finalisation : c'est la phase de libération des ressources qui n'ont pas été libérées par le garbage collector du runtime .NET.

Le générateur de code de XNAGS crée une classe Game1 (que vous pouvez renommer par la suite; dans le cadre de ce billet, je vais continuer à utiliser le nom Game1, mais cela n'a pour but que d'éviter de répéter à chaque fois votre classe dérivée de Microsoft.XNA.Framework.Game) qui dérive de Microsoft.Xna.Framework.Game. C'est cette classe qui forme la structure de l'application. Une instance de Game1 est créée dans la méthode statique Main de la la classe Program - dans la plupart des cas, vous n'aurez pas besoin de modifier cette classe

Initialisation

La phase d'initialisation est en partie écrite par le générateur de code de XNAGS - il s'agit donc seulement de la modifier légèrement afin d'obtenir l'effet souhaité.

Game1 est à l'heure actuelle composée de deux objets uniquement : une instance de Microsoft.Xna.Framework.Content.ContentManager et une instance de Microsoft.Xna.Framework.GraphicsDeviceManager, qui sont toute deux créées dans le constructeur de Game1. Le ContentManager permet d'accéder au contenu répertorié dans le gestionnaire de contenu via ses méthodes Load<>(). Le GraphicsDeviceManager permet d'initialiser la carte graphique via un certain nombre de propriétés telles que la résolution (taille du back buffer) que le jeu utilisera, ou la précision du z-Buffer[2].

public Game1()
{
  graphics = new GraphicsDeviceManager(this);
  content = new ContentManager(Services);
  graphics.IsFullScreen = false;
  graphics.PreferredBackBufferWidth = 640;
  graphics.PreferredBackBufferHeight = 480;
}

Outre le constructeur de Game1, on a aussi accès à une méthode Game1.Initialize(), qui peut être surchargée. Il est conseillé de modifier le corps de cette méthode plutôt que le constructeur pour toute initialisation ne concernant pas l'initialisation des outils de base (ContentManager et GraphicDeviceManager). On peut par exemple l'utiliser pour charger un fichier de configuration ou toute autre ressource non graphique, pré-calculer des valeurs, etc.

protected override void Initialize()
{
  // TODO: Add your initialization logic here
base.Initialize(); }

Finalisation

Oui, je sais, je parle de la finalisation avant de parler de la boucle principale, mais que voulez vous, dans la plupart des cas, initialisation et finalisation vont de pair. Dans un sens, c'est aussi le cas ici - à une différence près, car C# et son garbage collecor va prendre en charge la majeure partie de le procédure de finalisation. De fait, cette partie n'est utilisée que si vous avez une tâche particulière à accomplir (par exemple, écrire le fichier des meilleurs scores ou mettre à jour la configuration).

Vous pouvez modifier la finalisation en surchargeant la méthode Game.Finalize() dans Game1.

Boucle principale

Les jeux vidéos graphiques ont été créés bien avant l'avènement de Windows comme plateforme de jeux. De fait, des techniques particulières ont vu le jour, et la boucle principale est l'une d'elle. En un sens, elle est l'ancêtre de la pompe à message si chère aux programmeurs Windows du monde entier: il s'agit d'une boucle qui capture les évènements utilisateurs (en provenance des claviers, souris, joysticks) et modifie le comportement du programme en fonction de ces entrées. Dans le monde Windows, il a fallut revoir légèrement cette boucle principale pour l'adapter aux nécessités de la plateforme, mais dans l'ensemble, son fonctionnement n'a guère évolué.

Gestion des périphériques d'entrées

Ainsi, on pourrait croire que c'est maintenant Windows qui gère les entrées utilisateurs - et bien figurez vous que dans la plupart des cas, la réponse est non. La plupart des jeux Windows (même ceux qui sont en mode fenêtrés) effectue une capture totale des périphériques d'entrée. Dans le jargon DirectX, on appelle cela le mode "exclusive": c'est au programmeur de gérer l'ensemble des évènements liés aux périphériques d'entrées, tant au niveau de la capture de l'état que de l'exécution des actions associées à un état particulier. La mémoire tampon du clavier est ignorée, seuls les déplacements de la souris (et non plus sa position) et l'état de ses boutons peuvent être lus, etc. Vous pouvez vous en douter, cela peut devenir assez difficile dans certains cas.

Fort heureusement, le framework XNA cache un peu tout ça, en nous offrant la possibilité de manipuler des interfaces simplifiées. Ainsi, Microsoft.Xna.Framework.Input.GamePad nous permet de gérer efficacement les différents contrôleurs XBox360 attaché à la plateforme (PC ou XBox360). On peut lire l'état de 4 contrôleurs, et même modifier l'état des contrôleurs eux même en activant les vibrations. De la même manière, Microsoft.Xna.Framework.Input.Keyboard nous permet de lire l'état (instantané) du clavier sous la forme d'une instance de Microsoft.Xna.Framework.Input.KeyboardState et Microsoft.Xna.Framework.Input.Mouse nous permet de faire de même pour la souris.

On remarquera que la seule chose qu'on peut lire reste l'état instantané : les tampons ne sont pas accessibles. Ainsi, si entre deux lectures de l'état du clavier l'utilisateur a appuyé puis relâché une série de touches, XNA (et votre application) n'en saura rien. Il convient donc de raccourcir au maximum le laps de temps entre deux lectures des périphériques d'entrées, de façon à être sûr qu'aucune entrée de l'utilisateur n'a été manquée. La seule exception (en un sens) est la souris, qui accumule ses vecteurs de déplacement entre deux lectures - ainsi, le vecteur de déplacement lu correspond à l'ensemble des déplacements effectués depuis la dernière lecture du périphérique. L'état des boutons reste quand uniquement accessible via une lecture en instantané.

Réponse aux actions utilisateur

Il existe deux phases dans la réponse aux entrées utilisateur. La première consiste à mettre à jour l'état du modèle de l'application : c'est le moment de changer la position de chaque entité imprimée d'un mouvement, de gérer les collisions entre les entités, ou même de calculer l'état de l'intelligence artificielle qui gère les entités contrôlées par le programme.

la seconde phase est la phase de rendu graphique - il s'agit, en fonction du nouvel état du modèle, d'envoyer à la carte graphique les informations nécessaires à la représentation de ce modèle.

Il est toujours conseillé de faire en sorte que la première phase soit indépendante de la seconde - en d'autres termes, d'appliquer la triade Model/View/Controler afin de séparer le modèle de sa représentation, pour des raisons évidente[3]. Pour simplifier la mise en oeuvre de ce type d'architecture, les ingénieurs de Microsoft ont externalisé la méthode de rendu, qui est donc séparée de la méthode de mise à jour (Game1.Update() et Game1.Draw(), voir ci-dessous).

Tout mettre ensemble

XNA simplifie grandement l'écriture de la boucle principale - en fait, la seule chose que vous avez à faire, c'est de modifier le comportement de Game1.Update() afin de l'adapter à vos besoins. Cette méthode est appelée en boucle à plus haut niveau, ce n'est donc plus au programmeur de gérer la boucle elle même. Pour stopper la boucle principale, il suffit d'appeler Game.Exit() et de sortir de la fonction. Voici par exemple la fonction créée par XNAGSE lorsque vous créez un nouveau projet de jeu.

protected override void Update(GameTime gameTime)
{
  // Allows the default game to exit on Xbox 360 and Windows
  if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
    this.Exit();
// TODO: Add your update logic here
base.Update(gameTime); }
protected override void Draw(GameTime gameTime) { // clear the back buffer graphics.GraphicsDevice.Clear(Color.CornflowerBlue);
// TODO: Add your drawing code here
// present the back buffer base.Draw(gameTime); }

Mais la boucle principale s'occupe aussi d'autre chose, à l'insu de l'utilisateur (et pour son plus grand bien, croyez moi). Rappelez vous, dans le premier billet de cette série, je vous parlais de cette problématique des pertes de device - qui entrainent l'apparition d'une erreur DEVICE_LOST. Bonne nouvelle ici, c'est le système XNA qui gère la réception de cette erreur. Le programmeur n'a plus qu'à coder le chargement et le déchargement des ressources (textures, modèles 3D, sprites) touchées par cette erreur. Une amélioration supplémentaire a même été introduite puisque les fonctions virtuelles qui sont censées effectuée ces chargements/déchargement de ressources sont aussi appelées pour charger et décharger les ressources à la fin de l'initialisation et au début de la finalisation.

protected override void LoadGraphicsContent(bool loadAllContent)
{
  if (loadAllContent == true)
  {
    // TODO: Load any ResourceManagementMode.Automatic content
  }
// TODO: Load any ResourceManagementMode.Manual content }
protected override void UnloadGraphicsContent(bool unloadAllContent) { if (unloadAllContent == true) { content.Unload(); } }

Le garbage collector

C# dissimule sous sa peau dorée un garbage collector. On a longtemps cru, dans le domaine du jeu vidéo, que cette simple caractéristique le rendait impropre à la programmation de jeux. C'est faux, mais il faut toutefois faire très attention à ce qu'on fait, de manière a limiter l'exécution du garbage collector - et surtout à le contrôler au mieux. Ainsi, on ne peut pas lui permettre de fonctionner lorsque lui le souhaite - au risque de voir l'application interactive devenir soudain plus lente pendant quelques secondes, ce qui est inacceptable pour bon nombre de jeux vidéo. A lire à ce sujet l'excellent article de Shawn Hargreaves.

Nous reviendrons sur le garbage collector de XNA dans un autre billet.

Conclusion

Maintenant qu'on a vu la base de l'architecture d'un programme XNA, il ne nous reste plus qu'à étudier un peu la gestion des ressources graphiques (textures, modèles 3d, etc) avant de pouvoir entrer dans le vif du sujet - la réalisation d'un premier prototype de proto-jeu. Préparez vous à un grand choc: programmer un jeu n'est en effet pas si compliqué que ça. A la prochaine !

Notes

[1] C'est l'acronyme de XNA Game Studio.

[2] ah, on commence à employer des termes propres à la 3D. Le back buffer est une représentation virtuelle de l'écran - une zone mémoire ou l'on écrit la prochaine image qui sera affichée. Une fois celle-ci totalement construite, on la présente (terminologie DirectX), ce qui transforme le back buffer en front buffer. Le front buffer précédent devient le nouveau back buffer. Cette technique s'appelle le double buffering. Le z-buffer est une zone de mémoire ou l'on stocke la distance (valeur Z) entre la caméra et un pixel affiché. Ces données sont ensuite réutilisées pour calculer la visibilité des objets en 3D (un pixel de l'objet étant visible si sa valeur Z est inférieure à la valeur Z d'un même pixel déjà représenté. Vous pouvez en apprendre plus ici.

[3] Comment ça, non ? Bon, on reverra MVC dans un billet ultérieur, afin de se rafraichir la mémoire.

Trackbacks

Aucun trackback.

Les trackbacks pour ce billet sont fermés.

Commentaires

Aucun commentaire pour le moment.

Ajouter un commentaire

Si votre navigateur est compatible, vous pouvez vous aider de la barre d'outils placée au-dessus de la zone de saisie pour enrichir vos commentaires.