23 juin 2007

Exploration de XNA : planches de sprites et lecture de fichiers

Ce billet est le cinquième d'une série consacrée à la plateforme XNA de Microsoft. 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.

Le billet précédent expliquait la gestion des planches de sprites de taille fixe (tous les sprites ont la même taille dans la planche) mais laissait en suspend le cas plus complexe des planches où les sprites ont une taille variable, en expliquant qu'à ces planches sont nécessairement attachées des méta-données décrivant la plache elle même. Il est temps d'entrer un peu plus dans le détail.

Le code accompagnant 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]

Les raisons

Puisqu'elle requiert une gestion plus complexe, quelle est la raison d'être de ces planches ? Si je devais résumer le problème en un mot, je choisirais celui-là : performance. Et pour expliquer ce choix, une courte explication du fonctionnement de XNA et des couches inférieures (DirectX, driver de la carte graphique) s'impose

Lorsque vous demandez l'affichage d'un sprite via l'appel à la méthode SpriteBatch.Draw(), XNA stocke les différentes données associée au sprite dans un buffer interne. Lorsque vous appelez la méthode SpriteBatch.End(), l'instance de SpriteBacth parcours son buffer interne pour construire N buffers de coordonnées (des vertex buffers, dans le jargon DirectX) qui seront plus tard envoyé à la carte graphique. Ce vertex buffer est probablement composé d'entrées comportant:

  • quatre float (32 bits chaque) pour les coordonnées écran: x, y, z, w. Le couple (x,y) représentre la coordonnée à l'écran d'un sommet du rectangle, tandis que z et w représente la profondeur à laquelle le sprite sera dessiné (le paramètre depthLayer de la méthode SpriteBatch.Draw()).
  • deux float (tu,tv) (32 bits chaque) pour les coordonnée du point correspondant dans la texture du sprite.
  • et peut être un quadruplet rgba représentant la couleur du sprite.

Une fois le vertex buffer créé, il est envoyé en même temps que la texture utilisée au driver de la carte vidéo, qui envoie alors ces données à la carte vidéo (près les avoir traité si besoin). Si vous relisez bien cette phrase, vous y trouverez un ensemble de termes importants: en même temps que la texture utilisée. Ce qui signifie deux choses :

  • si vous effectuez le rendu des sprites A, B, A, C, B, A (chaque lettre représentant un sprite unique sur une texture unique), XNA va envoyer par défaut 3 fois la texture A, 2 fois la texture B et une fois la texture C. Une bande passante non négligeable est ainsi gaspillée - surtout si les sprites sont très détaillés, comme cela peut arriver pour des jeux du type Mortal Kombat.
  • de plus, XNA sera obligé de fractionner le rendu en 6 opérations distinctes, chacune nécessitant un changement dans la machine d'état de DirectX (parce la texture change), ce qui peut entraîner des attentes dans le driver - qui attendra alors que les données de l'opération de rendu précédente ait été reçue par la carte graphique.

Ce système est loin d'être efficace - mais on peut lui trouver une parade: il suffit de dessiner les sprites en les classant par texture utilisée - c'est à dire de dessiner A, A, A, B, B, C. Dans ce cas, la magie de XNA opère et au lieu de créer un vertex buffer par sprite, XNA va créer trois vertex buffer différents: un qui sera associé à la texture A, un autre qui sera associé à la texture B et un troisième qui sera associé à la texture C. La texture A ne sera envoyé qu'une seule fois, de même que B et C - et seules trois opérations de rendu sont nécessaires. Mais un problème subsite : quid de la gestion de la transparence ?

La gestion de la transparence (alpha blending) n'est pas une opération associative - un peu de math suffit à le prouver. Supposons que cf soit la couleur de fond de l'image, c1 la couleur du premier sprite (associé à a1, son coefficient alpha de transparence) et c2 (associé à a2) la couleur du second sprite.

Si on dessine d'abord c1, la couleur affichée ca sera:

     ca = cf.(1 - a1) + c1.a1
<==> ca = cf - cf.a1 + c1.a1

On dessine ensuite c2, on obtient:

     ca = (cf - cf.a1 + c1.a1).(1 - a2) + c2.a2
<==> ca = cf - cf.a1 + c1.a1 - cf.a2 + cf.a1.a2 - c1.a1.a2 + c2.a2
<==> ca = cf.(1 - a1 - a2 + a1.a2) + c1.a1 + c2.a2 - c1.a1.a2

Imaginons maintenant qu'on dessine c2 puis c1

     ca = (cf - cf.a2 + c2.a2).(1 - a1) + c1.a1
<==> ca = cf - cf.a2 + c2.a2 - cf.a1 + cf.a1.a2 - c2.a1.a2 + c1.a1
<==> ca = cf.(1 - a1 - a2 + a1.a2) + c1.a1 + c2.a2 - c2.a1.a2

La différence se situe dans le dernier terme - qui, de -c1.a1.a2 passe à -c2.a1.a2. Si a1 et a2 représentent des transparences de 50%, c1 étant du blanc (1,1,1) et c2 du noir (0,0,0), le premier nous donne (0.25,0.25,0.25) tandis que le second reste (0,0,0)[1].

Exemple d'alpha-blending

Conclusion : dès lors que des effets de transparence rentrent en jeu, je ne peux plus me permettre de réordonner le dessin de mes sprites, sous peine d'avoir des problèmes de validité du rendu.

Reste une autre alternative: mettre les sprites A, B, C dans une seule et même texture. Le résultat est alors édifiant :

  • je n'envoie qu'une seule texture et une seul vertex buffer à la carte vidéo. La quantité de donnée ne change pas, mais je n'ai plus à gérer de changements d'états - donc au final, je gagne quand même un peu en performances.
  • je suis maintenant capable de dessiner mes sprites dans l'ordre ou je dois les dessiner - ce qui me permet d'éliminer le problème de la gestion de la transparence.

Bien évidemment, il est courant de devoir gérer des sprites dont les tailles diffèrent, mais ce n'est pas pour autant une raison pour na pas regrouper ces sprites au sein d'une même planche. Ce qui ne veut pas dire, loin de là, qu'il faille regrouper tous les sprites d'un jeu dans une unique planche de sprites. Il ne faut en effet pas oublier que les sprites sont, comme toutes les ressources d'un jeu vidéo et au même titre que le code, sujets à des modifications régulières. Tout comme le programmeur ne va pas mettre son code source dans un unique fichier, l'artiste essaiera de composer avec les problème de maintenance de son oeuvre graphique.

Lecture des fichiers de méta-données

Il est évident qu'il peut y avoir plusieurs manières d'encoder les méta-données associée à chaque sprites. Certains préfèreront les fichiers textes (bruts ou dans un format basé sur XML), plus facile à mettre à jour. D'autres préfèreront les fichiers binaires qui offrent plus de facilités à la lecture et à l'écriture - si celle ci est faite par un programme. La méthode importe peu, mais je vais faire le tour rapide de ces trois solutions.

Toutefois, avant de commencer, il me faut décrire un point particulier du framework XNA. Comme vous le savez, un jeu XNA peut être exécuté sur PC ou sur la console XBox 360. Or le système de fichier de cette console est différent de celui des PC. Avec le framework XNA, les données qu'un programme peut lire sont réparties en deux groupes :

  • le stockage utilisateur : il est utilisé pour enregistrer les données propres à chaque utilisateurs. Ce sont par exemple les sauvegardes du jeu, le fichier des meilleurs scores, etc.
  • le stockage titre : c'est ici que seront stockées les fichiers exécutables et les données propres au jeu (fichiers générés par le Content Manager ou fichiers de configuration du jeu).

Le chemin vers la zone de stockage titre peut être récupéré en lisant la propriété statique StorageContainer.TitleLocation (sur PC, il s'agira du répertoire du jeu). Il est ensuite possible d'utiliser les classes de System.IO ou System.Xml pour créer, lire, modifier ou détruire des fichiers. Un billet ultérieur sera entièrement dédié à l'accès aux données du stockage utilisateur.

Un exemple vaut mieux qu'un long discours: voici donc le code permettant la lecture d'un fichier XML contenu dans le sous-répertoire Content du programme.

// I need these using directives.
// don't forget to reference system.xml.dll in your project
using System.Xml;
using System.IO;
// ...
void ReadFileInContent(string fileName) { string fullPath = StorageContainer.TitleLocation; fullPath += @"\Content\"; fullPath += fileName;
FileStream stream = File.OpenRead(fullPath);
try { XmlReader xmlReader = XmlReader.Create(stream);
while (xmlReader.Read()) { // analyze the current node }
xmlReader.Close(); } catch (XmlException exception) { // process exception here } }

Pour ajouter le fichier XML au gestionnaire de contenu, rien de plus simple : il suffit de l'ajouter comme n'importe quelle ressource. Il sera par contre nécessaire de modifier ses propriétés, afin d'une part d'éviter qu'il ne soit traité par le Content Manager et d'autre part de s'assurer qu'il sera bien copié dans le répertoire destination au moment de la compilation. Pour ma part, j'ai essayé avec succès la combinaison suivante :

  • Build Action: none (il n'y a rien à faire)
  • Copy to Output Directory : Copy always (Copy if newer fonctionne aussi; en choisissant de toujours copier, je me mets à l'abri des erreurs de date de fichier qui peuvent se produire parfois lorsqu'on utilise un SCM ou lorsque, comme moi, on travaille sur plusieurs PC qui n'ont pas tous la même heure).

System.Xml.XmlReader lit le fichier XML élément par élément. Si il rencontre une erreur, il génèrera une exception du type System.Xml.XmlException - le message de l'exception vous donnant la cause exacte de l'erreur - jusqu'à la position précise de l'erreur dans le fichier. Pour lire un élément dans le flux XML, il suffit d'appeler la méthode XmlReader.Read() tel que montré dans l'exemple ci-dessus. Cette méthode renvoie false dès qu'il n'y a plus rien à lire. L'analyse des propriétés de XmlReader nous permet d'interpréter le contenu.

De manière typique, on effectuera un switch sur XmlReader.NodeType - qui nous renseignera alors sur le type de noeud (élément/fin d'élément, commentaire, white space, texte, etc). Si on prends par exemple le fichier XML suivant:

<?xml version="1.0"?>
<root>
  <element_1>This is a text</element_1>
  <element_2 />
  <element_3 name="value">
    <element_4>
      This is also a text
    </element_4>
  </element_3>
</root>

La liste complète des types des noeuds (XmlNodeType) obtenus par lectures successives est: XmlDeclaration (xml, AttributeCount=1), Whitespace, Element (root), Whitespace, Element (element_1), Text (Value="This is a text"), EndElement (element_1), Whitespace, Element (element_2, IsEmptyElement=true), Whitespace, Element (element_3, AttributeCount=1), Whitespace, Element (element_4), Text (Value="\n This is also a text\n ", EndElement (element_4), Whitespace, EndElement (element_3), Whitespace, EndElement (root), Whitespace.

Dans cet exemple, le type de noeud a été complété par le nom de l'élément (XmlReader.Name) et, le cas écheant, par d'autres propriétés nommés. On notera que si on a du texte entre deux balises XML, ce texte contient les espaces blancs - c'est une notion à prendre en compte si le texte doit être traité.

Implémentation dans SpriteLib

Dans mon dernier billet, je vous indiquais que l'architecture actuelle de SpriteLib était trop simple pour être véritablement utile, et qu'à terme SpriteLib ressemblerait plus à ce qui est présenté dans le fichier StarUML accompagnant l'archive contenant le code source. L'opération de mise à niveau de SpriteLib a été effectué et vous pouvez dès a présent télécharger le nouveau code. Dans ce code, vous trouverez une classe SpriteLib.SpriteSheetLayoutBmFontXml qui est utilisé pour parser les fichiers XML accompagnant les fontes générées par l'utilitaire BMFont 1.8 d'AngelCode. Je vous suggère d'y jeter un coup d'oeil pour en apprendre un peu plus.

A l'aide de BMFont, j'ai généré ce fichier de fonte:

Fichier de fonte généré par BMFont

La classe SpriteLib.Font, utilisée en conjonction avec SpriteLib.SpriteSheetLayoutBmFontXml me permet maintenant d'utiliser une fonte à espacement proportionnel.

Exemple d'utilisation d'une fonte à espacement proportionnel
Exemple d'utilisation d'une fonte à espacement proportionnel

Conclusion

Il existe d'autres outils permettant de générer des fontes à espacement proportionnel. L'équipe XNA a créé Bitmap Font Maker (BMF), qui utilise une technique différente - mais assez répendue - consistant à encoder les méta-données dans la texture elle même. BMF s'utilise en conjonction avec la classe Microsoft.Xna.Framework.Graphics.SpriteFont qui a été ajoutée dans la version refresh du framework XNA (que je n'ai pas encore abordé).

Il nous reste encore à apprendre des petites choses sur les sprites (notamment concernant la gestion des fonds d'écran et la création d'effets spéciaux). Ces sujets seront abordés dans les billets à venir. A bientôt donc, pour de nouvelles aventures !

Notes

[1] à toutes fins utiles, vous pouvez aussi faire le calcul en précisant des couleurs opaques - a1=a2=1. Dans ce cas, le résultat est encore plus net: si on dessine c1 puis c2, le résultat sera c2; si on dessine c2 puis c1, le résultat sera c1.

Ajouter un commentaire

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

Fil des commentaires de ce billet