29 juin 2007

Exploration de XNA : les sprites animés

Nouveau billet dans notre série consacrée à la plateforme XNA de Microsoft - le sixième. 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.

Les sprites animés : il ne nous reste guère que ce type de sprites à considérer avant de passer à la suite (pas tout à fait quand même). Dans les billets précédents, nous avons étudié les sprites simples, les planches de sprites et les fontes - il est donc temps de croquer un morceau un peu plus sérieux.

Le code correspondant à cet article peut être téléchargé ici.

xna_logo.png [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]

De l'utilisation de concepts connus

Ce qu'il faut bien comprendre, c'est que les sprites animés ne sont qu'une utilisation de concepts déjà vu dans les précédents billets. Un sprite animé est un sprite comme les autres - il est donc chargé de la même manière et affiché de la même manière. Ce qui change, c'est la façon dont on va choisir le sprite qu'on souhaite afficher.

Si il est bien sur possible de stocker les animations d'un sprite sur des images séparées, il est préférable - à la fois pour des raisons pratiques et pour des raisons de performance, notamment si le même sprite est affiché plusieurs fois à différents stades de son animation - d'utiliser des planches de sprites. Une planche d'animation est composée de plusieurs images montrant le même personnage ou objet à différents stades de son évolution. Tout comme il est possible de stocker différents sprites sur une même planche, il est aussi possible de stocker les animations de plusieurs sprites sur la même planche.

Voici un planche de sprite typique tirée des Reiner's Tileset, un bibliothèque de sprites libres d'utilisation[1]. Chacune des images de l'animation fait 128x128 pixels.

Planche de sprites animée

L'important à noter est qu'au niveau purement physique, nous savons déja gérer les sprites animés. Il ne nous reste qu'une chose à appréhender : comment, une fois chargée, trouver les différentes animations stockées et les jouer devant les yeux ébahis de l'utilisateur de notre programme ?

Encore des méta-données

Avec la juste la planche, il est impossible de savoir à quelle animation correspond quelle image (à moins de mettre en place un système rigide de règles, ce qui est tout à fait possible, mais qui nous empêche d'utiliser tout le potentiel qui s'offre à nous). La conclusion s'impose d'elle même: il nous faut lire quelque part les informations dont nous avons besoin.

Dans le billet précédent, nous avons vu comment lire un fichier XML. Nous allons reprendre la même tactique - notre seul problème ici est que nous n'avons pas d'outils pour générer ce fichier XML; qu'importe, créons le à la main.

J'ai choisi la structure suivante :

<?xml version="1.0"?>
<animations>
  <spritesheets>
    <spritesheet sheet="0" name="barbarian-walk-se.png" 
        xcount="4" ycount="2" />
  </spritesheets>
  <animation name="walk-se" duration="1000">
    <frame sheet="0" sprite="0" xdelta="0" ydelta="0" />
    <frame sheet="0" sprite="1" xdelta="0" ydelta="0" />
    <frame sheet="0" sprite="2" xdelta="0" ydelta="0" />
    <frame sheet="0" sprite="3" xdelta="0" ydelta="0" />
    <frame sheet="0" sprite="4" xdelta="0" ydelta="0" />
    <frame sheet="0" sprite="5" xdelta="0" ydelta="0" />
    <frame sheet="0" sprite="6" xdelta="0" ydelta="0" />
    <frame sheet="0" sprite="7" xdelta="0" ydelta="0" />
  </animation>
</animations>

Les balises spritesheet nous donne le nombre de sprites en X et Y qui sont présents sur la planche de sprites (ce qui nous permettra alors de retrouver la position d'un sprite en fonction de son index). L'attribut name contient le nom de la ressource dans le Content Manager. animation définit une animation et lui associe un nom et une durée. Cette durée est exprimée en millisecondes, et correspond au temps qu'il faut pour afficher les (n) sprites de l'animation pendant une durée égale - chaque sprite est donc affiché pendant d / n millisecondes. Chaque étape de l'animation est représentée par une balise frame, qui associe à cette étape un index de sprite dans l'un des planches. Il est tout à fait possible de compliquer le système et d'assigner à chaque frame une durée ou un temps auquel elle doit être jouée[2]. D'autres données sont assignées à chaque frame: dans notre exemple, on y associé un déplacement (x,y) du sprite, sous la forme d'un delta par rapport à la frame précédente. En effet, la plupart des animations induisent non seulement un mouvement, mais aussi un déplacement. Sans connaissance de ce déplacement, une application ne peut pas faire un rendu correcte de l'animation (pensez par exemple à l'animation d'un personnage qui court : si je ne bouge pas le sprite, il donnera l'impression de courrir sur place; si je le bouge trop ou pas assez, ses pieds donneront l'impression de glisser).

On donnera à chaque animation un nom compréhensible, comme walk ou fight. C'est ce que j'ai fait pour ce fichier barbarian-walk-se.anim correspondant à la planche de sprite précédente, qui est contenu dans le code associé à cet article, projet ReinerAnim.

Puisque vous savez maintenant comment lire les fichiers XML avec le framework XNA, je vous épargne les détails codesque pour aller à l'essentiel : l'interprétation de ces données.

Jouer avec le temps

A l'idée d'animation est nécessairement accolée l'idée de temps qui passe - sans notion de temps, rien ne peut bouger. Lorsqu'on utilise DirectX ou OpenGL, c'est à nous de gérer cette grandeur. Vous l'aurez compris, les développeurs de XNA ont pris les devants et nous fournissent les services dont nous avons besoin sans même que nous ayons à nous en préoccuper.

Si vous vous rappelez le second billet de la série, le framework XNA nous propose de surcharger deux méthodes qui reçoivent en paramètre un objet du type GameTime : il s'agit des méthodes Update() et Draw(). Leurs fonctions respectives sont assez évidentes : la première met à jour le modèle interne de l'application tandis que la seconde effectue le rendu de ce modèle.

Pour bien vous faire comprendre le mécanisme, je dois vous expliquer comment est généré cette instance de GameTime - et sa relation avec Update() et Draw().

Tout se passe dans la boucle principale du jeu - dans la méthode Game.Run(). A chaque itération de la boucle, le framework appelle la fonction Game.Tick(). Celle-ci récupère l'heure système (avec un précision annoncée d'une milliseconde) et appelle successivement Update() puis Draw(). Une variable importante rentre toutefois en compte à ce moment : la valeur de la propriété Game.IsFixedTimeStep. Cette propriété (accessible en lecture et en écriture) définit le comportement de la méthode Game.Tick() : si elle est mise à false, Update() et Draw() seront appelée aussi souvent que possible. Si cette propriété vaut true, Draw() continue d'être appelée aussi souvent que possible, mais Update() est appelée à intervalle régulier (cet intervalle est défini par la valeur de Game.TargetElapsedTime). La raison de ce comportement est simple : sur PC, les différences de hardware existantes ne permettent bien souvent pas de savoir combien de temps sera nécessaire à la mise à jour et au rendu d'une scène. Sur console, ce problème n'existe pas : toutes les consoles sont supposées identiques d'un point de vue matériel, on peut donc connaître avec une grande précision le temps nécessaire à la mise à jour des données et du rendu - il suffit de le mesurer. Bien entendu, cela ne veut pas dire que sur PC il faut initialiser Game.IsFixedTimeStep à false et l'initialiser true sur console. Au final, vous restez libre de vos choix. Fait intéressant si vous travaillez en intervalles fixes : si le laps de temps écoulé entre deux appels successifs à Update() est supérieur à Game.TargetElapsedTime, l'instance GameTime qui sera passée en paramètre lors du prochain appel à Update() aura sa propriété GameTime.IsRunningSlowly initialisée à true - c'est généralement signe qu'il faut alléger la mise à jour ou le rendu.

L'instance de GameTime reflète cette dualité du comportement - elle possède deux groupes de propriétés distinctes. Le premier donne les informations relative au temps passé dans le domaine du jeu - ces informations sont dépendantes de Game.TargetElapsedTime lorsque Game.IsFixedTimeStep est à true. Il s'agit des propriétés GameTime.ElapsedGameTime (temps passé depuis la dernière mise à jour) et GameTime.TotalGameTime (temps passé depuis le lancement du jeu). L'autre groupe donne les valeurs réelles du temps passé - en provenance directe de l'horloge système. Il s'agit de GameTime.ElapsedRealTime et GameTime.TotalRealTime. Toutes ces propriétés sont du type TimeSpan, qui définit des propriétés telles que TimeSpan.Days ou TimeSpan.TotalSeconds (je vous encourage à lire l'aide pour en savoir plus).

Revenons à notre animation de sprites : comment prendre en compte cette information de temps ?

Nous l'avons vu plus haut, toute animation s'exécute dans un temps donné. Puisque c'est à nous de lancer l'animation, nous connaissons donc la date exacte de début. En fonction de la date de début et de la date actuelle, il est aisé de retrouver quelle frame doit être affichée à un moment T - une simple interpolation linéaire nous donne le résultat.

N = nombre de sprites dans l'animation
D = durée de l'animation
T0 = date de début de l'animation
T = date courante

==> sprite_to_display = (int)(N * (T - T0) / D)

Le choix de l'animation dépends quand à lui soit des actions de l'utilisateur, soit de l'état courant du modèle (c'est à dire que l'animation est choisie par le programme en fonction d'un certain nombre de donnée; par exemple, un monstre va se diriger vers le personnage si il l'aperçoit). Puisqu'il ne s'agit pas d'une opération de rendu, la sélection de l'animation va se faire pendant l'exécution de la méthode Update - c'est à dire pendant la mise à jour du modèle.

On obtient donc le pseudo-code suivant :

class Pawn
{
  private GameTime timeBegin;
  private int spriteCount;
  private int animDuration;
  private string animName;
public void SelectAnimation(string name, GameTime gameTime) { timeBegin = gameTime; animName = name; spriteCount = anim.Get(animName).SpriteCount; animDuration = anim.Get(animName).Duration; }
public void Draw(GameTime gameTime) { int elapsedTime = gameTime.TotalMilliseconds - timeBegin.TotalMilliseconds;
int spriteToDisplay = ((spriteCount + 1) * elapsedTime) / animDuration;
anim.Get(animName).DrawSprite(spriteToDisplay); } }
class MyGame { public void Update(GameTime gameTime) { pawn.SelectAnimation("anim_name", gameTime); }
public void Draw(GameTime gameTime) { pawn.Draw(gameTime); } }

Exemple: ReinerAnim - un barbare qui marche...
ReinerAnim - animation d'un barbare

Evolutions depuis la version précédente

Comme d'habitude, je code et je design dans la même foulée - ou plus exactement, je design puis je code dans la même foulée. Ce qui ne m'empêche pas de faire faire des erreurs, notamment à cause d'une mauvais vision des fonctionnalités souhaitées. Lorsque je me rends compte que j'ai fait un erreur d'architecture, je la corrige aussitôt - c'est ce que j'ai fait sur la gestion des fontes pour cette version du code.

La version précédente passait par un SpriteLib.SpriteStatic pour implémenter les fontes, en lui fournissant un SpriteSheetLayout compatible avec cette fonte. La nouvelle version ne fait plus dépendre SpriteLib.Font de SpriteLib.SpriteStatic mais ajoute une interface SpriteLib.IFontLayout qui définit l'ensemble des éléments permettant de gérer la fonte. SpriteLib.FontLayoutXmlBmFont implémente cette interface, ce qui lui permet de gérer des fontes éclatées sur plusieurs planches. Vous pouvez voir le résultat dans le code associé à cet article, où je charge une fonte (morpheus_64_regular) qui est réparties sur 3 planches de 256x256 pixels (bien évidemment, il s'agit plus de démontrer cette nouvelle fonctionnalité que d'une utilisation réelle). La fonctionnalité est d'importance, puisqu'elle permet de gérer les fontes Unicode dans des langages moins aisé à appréhender que le français ou l'anglais. Cette nouvelle architecture nous permettra aussi d'étendre les fonctionnalités de la classe SpriteLib.Font dans le futur - par exemple pour gérer le kerning (espacement réduits entre deux caractères consécutifs lorsque ceux-ci sont "compatibles").

Les sprites animés sont implémentés avec une philosophie similaire. La classe SpriteLib.SpriteAnimated référence un objet qui dérive de l'interface IAnimationStorage. Une implémentation de cette interface capable de lire les fichiers .anim décrit plus haut de trouve dans SpriteLib.AnimationStorageXmlAnim (pour le coup, on se sert dans ce cas de SpriteLib.SpriteStatic, mais ce n'est qu'un détail d'implémentation). Je ne vous cache pas cependant qu'il reste du travail à faire sur ces classes - et notamment implémenter deux fonctionnalités importantes : le chainage des animations et la gestion des pauses.

Conclusion

A co(d)eur vaillant rien d'impossible - même si le concept de base parait non trivial, on s'aperçoit après un peu d'étude que ce n'est en fait guère compliqué. En fait, une fois la barrière organisationnelle franchie (stockage, gestion du temps), il ne reste que des concepts très simples (choix d'un sprite dans une animation, et rendu de ce sprite).

Nous avons presque terminé notre tour d'horizon des sprites - maintenant que nous savons les afficher et les animer, il nous faut leur donner un environnement dans lequel s'épanouir. Mais nous verrons ça très prochainement, je vous le promet.

Notes

[1] cette planche a été créée à partir des fichiers contenus dans l'archive du personnage "barbarian" disponible sur Reiner's Tileset. La couche alpha a notamment été rajoutée, et les images séparées ont été mises ensembles pour créer la planche que vous voyez.

[2] ce qui sera très probablement fait dans une version ultérieure

Commentaires

1. Le mardi, février 26 2008, 17:53 par Martin

Bonjour, j'ai un problème avec les fichiers code associés à cet article.
Je n'arrive pas à exécuter le projet correctement.
J'utilise visual c# 2005 express avec le game studio 2.0, je me dis que ca peut venir du fait que j'utilise le 2.0 alors que l'article à l'air d'être fait avec le 1.0, est ce une erreur possible ?
Merci de votre aide.

2. Le mercredi, février 27 2008, 11:03 par Emmanuel Deloget

Ce n'est pas impossible...

Je vais regarder ça plus précisément. En attendant, est-ce que tu peux m'en dire plus ? (quelle erreur de compilation si il y a, quelle exception générée).

Je vais faire un re-release des différents programmes XNA décrit dans ces pages - de manière à ce qu'ils soient compatibles avec XNA 2.0 - d'ici quelques jours. Dans les tuyaux, j'ai un article très long sur XNA (il est aussi relativement difficile à mettre au point), la deuxième partie de "Valider et Corriger une architecture logicielle" et un article sur certaines extensions de la librairie standard de la future version du C++. Je dois pouvoir intercaler une mise à jour vers XNA 2 au milieu... :)

3. Le lundi, mars 3 2008, 15:26 par Martin

Il n'y a ni erreur de compilation ni exception, le projet ne s'ouvre tout simplement pas ...
L'erreur à l'ouverture est : "this project file "c:\....\project.csproj" cannot be opened.

The project type is not supported by this installation.

Voilà ce que j'ai.
Et après avoir cliqué sur Ok j'arrive dans l'exploreur classique mais le projet n'est pas accessible, rien n'a été chargé ...
J'ai le même soucis avec d'autres projets, le point commun entre tous ces projets est que c'est du xna 1.0, alors que j'ai seulement installé le 2.0
Merci pour votre aide.

4. Le lundi, mars 3 2008, 15:43 par Emmanuel Deloget

Je vais mettre à jour les fichiers projet et le code source sous peu. Le problème peut aussi venir du fait que le SP pour VC# 2005 est installé sur ma machine. Normallement, tu dois aussi l'avoir installé sur ton PC (si ce n'est pas le cas, ça peut expliquer aussi... quoi que). Je revérifie tout ça, et je te tiens au courant.

5. Le lundi, mars 3 2008, 16:24 par Martin

J'ai aussi installé le SP1 de VC# 2005 express, car sinon je n'aurais pas pu installer le game studio 2.0 de Xna.
Je vais essayer de désinstaller le 2.0 et mettre le 1.0 pour voir si le soucis vient bien de là.

6. Le vendredi, mars 28 2008, 17:21 par Martin

J'ai résolu mon soucis, j'arrive à lancer l'application avec VC# 2005 express. Pour résoudre j'ai installé le game studio 1.0 express et le game studio 2.0. En installant les 2 il n'y a plus de soucis, je peux ouvrir n'importe quel projet.
Le soucis que j'ai maintenant c'est qu'il n'existe pas la fonction "clean solution" sous VC# 2005 express contrairement à Visual studio..
J'aimerais utiliser cette fonction, comment faire ?
Ou sinon j'aimerai pouvoir ouvrir un projet 1.0 sous visual studio ce qui n'est pas possible j'ai l'impression. Car le game studio 1.0 express ne s'installe pas sous Visual studio, et apparement il n'existe pas de version non-express...
Cela fait beaucoup de questions, j'espère que vous allez pouvoir m'aider.

7. Le lundi, mars 31 2008, 19:02 par Emmanuel Deloget

Hello Martin,

Je vais vérifier ça. Normallement, même si tu n'as pas de fonction clean (ce qui me semble assez étonnant), tu dois avoir une fonction "Rebuild" (qui effectue un clean, puis un build). Dans le cas contraire, il te suffit d'effacer les fichiers stockés dans les répertoires obj pour faire un "clean manuel". Tu peux aussi créer un fichier .bat pour effectuer cette tâche, et utiliser le système de build intégré pour créer une action "Clean" (ça doit être possible, même si j'avoue ne pas être complètement à l'aise avec ce point)

La méthode qui consiste à installer GSEE 1.0 refresh pour ouvrir les projets n'est pas vraiment une bonne méthode. En faisant ça, tu te complexifies la vie dès lors que tu souhaite passer à GS 2.0. Pour simplifier le passage à la nouvelle version de l'IDE, j'ai mis en ligne (ici) les sources et les fichiers projet compatibles avec GS 2.0. Le portage n'est pas complet (il l'est dans ma version, que je compte mettre en ligne d'ici la fin avril), mais les modifications qu'il faut apporter pour être complètement correct sont relativement aisée.

Sinon, tu peux me tutoyer :)

8. Le lundi, mars 31 2008, 20:10 par Martin

Merci pour votre aide, j'ai résolu mon soucis, j'ai utilisé une solution qui est plutôt simple :
Créer un nouveau projet et insérer tous les différents fichiers du projet en xna 1.0.

J'aurais une autre "petite" question, j'aimerais me servir du GPU pour faire tourner mon application car elle est plutôt gourmande en ressource processeur.
Aurais tu un lien pour un tuto qui me permetterais de comprendre comment faire ??
Merci d'avance !

9. Le vendredi, avril 4 2008, 14:15 par Emmanuel Deloget
J'aurais une autre "petite" question, j'aimerais me servir du GPU pour faire tourner mon application car elle est plutôt gourmande en ressource processeur.

Dois-je comprendre que tu souhaites utiliser les ressources GPU pour exécuter du code "normal" (ce qu'on appelle General Purpose computation using GPU, ou GPGPU) ?

XNA n'est pas véritablement conçu pour ça, mais en créant des shaders de manière un peu intelligente, on peut quand même utiliser ces préceptes. Cela fonctionnera correctement sur PC (mais attentions : les transferts mémoire GPU vers mémoire centrale risque d'être pénalisants).

En ce qui concerne les tutoriaux et autres ressources,

D'autres recherches google peuvent t'en apprendre davantage.

A bientôt !

10. Le samedi, avril 5 2008, 00:58 par Martin

Merci vraiment pour votre aide.
Je vais me renseigner sur le GPGPU avec ces sites.
Je place un lien de ce que j'ai fait pour le moment : youtube.com/watch?v=0jxCb... et youtube.com/watch?v=pGLra... ainsi que youtube.com/watch?v=bZ_Pd...
Voilà.

11. Le vendredi, avril 18 2008, 15:21 par OmegaBahamut

Le code ne doit pas être mis dans draw mais dans update pour éviter de faire des calculs superflus et afin d'être conforme à la logique de l'architecture.

12. Le vendredi, avril 18 2008, 23:59 par Emmanuel Deloget

C'est entièrement vrai, mais dans le même temps, l'ajout d'une méthode Pawn.Update() aurait complexifié le code:

  • j'aurais du maintenir un état supplémentaire (l'index du sprite sélectionné), ce qui aurait eu pour effet de rendre le code de Pawn moins lisible.
  • une méthode Pawn.Update() ajoute peu d'information par rapport à une méthode Pawn.Draw() qui encapsule le choix du sprite et le dessin. Pawn.Update() aurait, du fait de sa simple existence, dilué l'explication un peu plus - ce qui n'était pas vraiment nécessaire

Ceci dit, comme vous le remarquez, il est plus logique de sélectionner le sprite à afficher dans une méthode Update() que dans une méthode Draw(). Une telle vision a en outre l'intérêt de préserver la cohérence de l'état du jeu (si Update() est appelé moins souvent que Draw(), notamment si on est en fixed time step. Il serait peu agréable de voir l'état du sprite évoluer alors qu'aucune mise à jour n'a eu lieu.)

Merci pour cette remarque ;)

13. Le samedi, avril 26 2008, 15:29 par Florian

Bonjour,

J'ai téléchargé l'archive rar de la source de ce tutorial et j'ai un problème pour l'extraire (j'ai la dernière version de winrar), j'ai un message d'erreur qui me dit que l'archive est endommagée ou inconnue. (Cela me fait ça pour toutes vos archives du site).

14. Le lundi, avril 28 2008, 13:13 par Emmanuel Deloget

Ah oui. J'ai récemment mis à jour le plugin DLM pour dotclear (c'est le plugin qui me permet de gérer la liste des download), mais il semblerait qu'un problème se soit passé. Je corrige cette erreur aussi vite que possible. Désolé pour le dérangement.

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet