18 juin 2007

Exploration de XNA : les planches de sprites

Ce billet est le quatriè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.

Nous avons vu dans le dernier billet comment charger et afficher un sprite. Je vous ai aussi vaguement expliqué ce qu'était une sprite sheet, et à quoi cela pouvait bien servir. Nous allons maintenant passer une vitesse et étudier plus précisément ce qu'on peut vraiment faire avec des sprites - pour finir par la création d'une petite librairie XNA basée sur ces idées.

Vous pouvez télécharger le code correspondant à ce billet 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]

Deux types de sprites

Vous l'avez compris, il existe deux types de sprites : les sprites simple - un seul sprite est stocké dans une image - et les sprite sheets - plusieurs sprites sont stockés dans une seule image. Les sprites sheets existent aussi en deux variétés :

  • Dans la plus simple, tous les sprites ont la même taille. C'est le plus souvent le cas lorsqu'on souhaite gérer une animation - mais d'autres utilisations sont possibles.
  • Il existe une variété de sprite sheet où les sprites présents dans l'image ne font pas tous la même taille. Il est alors nécessaire d'associer à ce fichier images un ensemble de méta données décrivant chaque sprites (par exemple, dans un fichier XML).

Puisque j'ai déjà traité les sprites les plus simples (une sprite par image) dans mon billet précédent, je ne vais pas m'étendre sur le sujet. Si vous avez des difficultés à suivre[1], n'hésitez pas à vous y reporter. Reste donc à traiter le cas des sprites sheet - et vous allez voir, il n'y a rien de plus simple.

Le principe

Commençons par le cas le plus simple, c'est à dire celui où tous les sprites contenus dans l'image ont la même taille. Dans ce cas, les sprites sont arrangés séquentiellement, l'un à coté de l'autre. On peut imagine plusieurs arrangements possibles - mettre tous les sprites sur la même ligne, ou les organiser en planche. Dans le cas d'une organisation sur une seule ligne, la position d'un sprite particulier dans l'image est donnée par le rectangle spriteSourceRect = new Rectange(spriteIndex * spriteWidth, 0, spriteWidth, spriteHeight) - les seules données dont nous avons besoin sont donc la taille en X et en Y d'un sprite.

Dans le cas d'une planche, ce n'est guère plus complexe, mais nous avons besoin de savoir le nombre de sprites dans une ligne - ce paramètre peut soit être calculé si la largeur de l'image est un multiple de la largeur d'un sprite, soit être défini d'un autre manière (en chargeant un fichier de description de la planche de sprites ou plus simplement via une constante). Le rectangle source dans l'image est définit ainsi:

spriteSourceRect = new Rectangle(
  (spriteIndex % spritesPerLine) * spriteWidth,   // X
  (spriteIndex / spritesPerLine) * spriteHeight,  // Y
  spriteWidth,                                    // Width
  spriteHeight                                    // Height
  );

L'autre cas - celui d'une sprite sheet contenant des sprites de tailles différentes - n'est guère plus complexe, à ceci près qu'on ne peut pas utiliser une formule pour retrouver la position d'un sprite. Il est donc nécessaire de connaitre les positions par avance, soit qu'on les ait intégrée au code source sous forme de constantes (ce que je déconseille fortement), soit que ces données soit récupérées au runtime d'une manière ou d'une autre, par exemple via la lecture d'un fichier XML décrivant la planche de sprite.

A noter que même si l'adage "qui peut le plus peut le moins" semble s'appliquer, il est quand même préférable de gérer les deux cas séparément - il est évident qu'un système ne gérant que des sprites à taille variable pourra aussi gérer des sprites à taille constante, mais il le fera moins efficacement. Or l'efficacité est une notion importante dans un jeu vidéo, et il est souvent hors de question de perdre son temps à exécuter du code trop générique si il est possible d'écrire un code moins général mais plus efficace.

Dessiner les sprites

Connaitre la position d'un sprite ne nous dit pas comment le dessiner. For heureusement, les développeurs de XNA ont pensé à tout. Rappelez vous, dessiner un sprite se fait en utilisant une instance de la classe SpriteBatch, et en appelant sa méthode Draw(). Nous avons vu ensemble une version de cette méthode, mais en fait il existe 7 versions surchargées différentes.

  • SpriteBatch.Draw(Texture2D sheet, Vector2 destPos, Color tint) - c'est la version que nous avons utilisé
  • SpriteBatch.Draw(Texture2D sheet, Rectangle destRect, Color tint)
  • SpriteBatch.Draw(Texture2D sheet, Rectangle destRect, Nullable<Rectangle> sourceRect, Color tint)
  • SpriteBatch.Draw(Texture2D sheet, Rectangle destRect, Nullable<Rectangle> sourceRect, Color tint, Single rotation, Vector2 origine, SpriteEffects effect, Single depth)
  • SpriteBatch.Draw(Texture2D sheet, Vector2 destPos, Nullable<Rectangle> sourceRect, Color tint)
  • SpriteBatch.Draw(Texture2D sheet, Vector2 destPos, Nullable<Rectangle> sourceRect, Color tint, Single rotation, Vector2 origine, Single uniScale, SpriteEffects effect, Single depth)
  • SpriteBatch.Draw(Texture2D sheet, Vector2 destPos, Nullable<Rectangle> sourceRect, Color tint, Single rotation, Vector2 origine, Vector2 scale, SpriteEffects effect, Single depth)

Je sais, celà fait beaucoup. Examinons l'ensemble de ces paramètres:

  • sheet : c'est la texture qui contient le sprite
  • destPos : c'est la position ou sera dessiné le coin haut-gauche du sprite (coordonnées écran)
  • destRect : c'est le rectangle destination du sprite (coordonnées écran). On notera que si ce rectangle est plus grand ou plus petit que le sprite lui-même, un facteur d'échelle est appliqué sur le sprite au moment du rendu. On peut ainsi réaliser simplement des effets de zoom ou d'élongation.
  • sourceRect : c'est le paramètre qui nous interesse, puisqu'il permet de spécifier les coordonnées du rectangle source dans l'image du sprite. Les coordonnées sont exprimées dans l'espace de l'image (et puisque cette image est une texture, on appelle cette unité le texel, pour texture element). A noter que sourceRect peut être null - dans ce cas, l'intégralité de l'image est prise en compte.
  • tint : la couleur de modulation utilisée au moment du rendu. La formule suivante est appliquée (dans cette formule, les couleurs sont exprimée sur un échelle de 0 à 1):
destColor.r = texel.r * tint.r
destColor.g = texel.g * tint.g
destColor.b = texel.b * tint.b
destColor.alpha = texel.alpha * tint.alpha
  • rotation : un angle de rotation exprimé en radians
  • origine : l'origine du sprite - c'est à dire le centre de rotation de celui-ci. (0,0) est le coin haut gauche du sprite.
  • uniScale : un coefficient d'échelle global, qui sera appliqué sur les deux dimensions du sprite, ce qui permet d'implémenter un effet de zoom.
  • scale : un vecteur 2D de coefficients d'échelle, permettant d'appliquer une élongation différente sur les composantes X et Y du sprite.
  • effect : ce paramètre est un énumération permettant d'effectuer un flip horizontal ou vertical du sprite avant le rendu
  • depth : Compris entre 0 et 1, ce paramètre représente la couche sur laquelle le sprite est dessiné. Nous reviendrons sur ce paramètre dans un billet ultérieur.

La conclusion s'impose d'elle même: pour effectuer le rendu du sprite, il suffit d'utiliser une surcharge qui prends en paramètre le rectangle source du sprite.

Implémentation

En soit, le code n'est pas tellement différent de celui présenté dans le dernier billet.

using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Storage;
namespace SpriteLib { public class CuteWorld : Microsoft.Xna.Framework.Game { GraphicsDeviceManager graphics; ContentManager content; SpriteBatch spriteBatch; Texture2D sprite; int spritesPerLine = 0; int spriteWidth = 101; int spriteHeight = 171; int grassTileIndex = 0; int mudTileIndex = 1; int screenWidth = 320; int screenHeight = 200;
public CuteWorld() { graphics = new GraphicsDeviceManager(this); content = new ContentManager(Services);
graphics.PreferredBackBufferWidth = screenWidth; graphics.PreferredBackBufferHeight = screenHeight; }
protected override void Initialize() { spriteBatch = new SpriteBatch(graphics.GraphicsDevice);
base.Initialize(); }
protected override void LoadGraphicsContent(bool loadAllContent) { if (loadAllContent) { sprite = content.Load<Texture2D>("Content\\tiles"); spritesPerLine = sprite.Width / spriteWidth; } }
protected override void UnloadGraphicsContent(bool unloadAllContent) { if (unloadAllContent == true) { content.Unload(); } }
protected override void Update(GameTime gameTime) { if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) { this.Exit(); }
base.Update(gameTime); }
private Rectangle GetSpriteRectFromIndex(int index) { return new Rectangle( (index % spritesPerLine) * spriteWidth, (index / spritesPerLine) * spriteHeight, spriteWidth, spriteHeight); }
protected override void Draw(GameTime gameTime) { graphics.GraphicsDevice.Clear(Color.CornflowerBlue);
// compute the tiles source rectangles Rectangle grassRect = GetSpriteRectFromIndex(grassTileIndex); Rectangle mudRect = GetSpriteRectFromIndex(mudTileIndex);
// compute destination positions Vector2 grassPos = new Vector2(screenWidth / 2 - 50, 0); Vector2 mudPos;
// -1 because of the tiles themselves // they have a dark border. mudPos.X = grassPos.X + spriteWidth - 1; mudPos.Y = grassPos.Y;
// draw two tiles spriteBatch.Begin(); spriteBatch.Draw(sprite, grassPos, grassRect, Color.White); spriteBatch.Draw(sprite, mudPos, mudRect, Color.White); spriteBatch.End();
base.Draw(gameTime); } } }

Exemple: le sol provient d'une sprite sheet

Et maintenant, on écrit du texte !

Sous Windows, l'écriture de texte se fait en référançant une fonte, bein souvent vectorielle (du type TrueType la plupart du temps). Compte tenu de la complexité de ces fontes, leur instanciation en vue d'une utilisation future est une opération couteuse - en mémoire et en temps CPU. Cela n'est pas très génant pour une application Windows, qui dispose des ressources nécessaires pour ce faire, mais celà peut poser un problème sur une console comme la XBox 360 - sans compter que les fontes système de Windows ne sont pas disponible sur la console. De fait, on préferera utiliser une autre technique, qui mixe l'utilisation des fontes bitmaps et la gestion d'une sprite sheet : il s'agit de créer une planche ou shaque sprite est une lettre différente. Dans le jargon du jeu vidéo, on appelle ce type de fonte une fonte bitmap.

Encore une fois, deux possibilités s'offrent à nous - se limiter à une police à espacement fixe (l'espacement entre le début de chaque caractère est constant) ou gérer une police à espacement proportionnelle (l'espacement entre deux caractères dépends de ces caractères). Une police fixe peut être implémentée en utilisant une sprite sheet où les sprites ont une taille constante, tandis qu'une police proportionnelle nous impose de gérer des sprites / caractères de tailles différentes - et donc l'utilisation d'une méthode de description de ces sprites.

Dans le code accompagnant cet article, j'ai choisi de me limiter à la gestion d'une police fixe, pour plus de simplicité. Le principe de base est le suivant : pour chaque caractère d'un objet String, nous dessinons le sprite correspondant en provenance de la planche de sprites. Les seules données qu'il nous est nécessaire de connaître sont la taille de chaque sprite (qui est constante dans ce cas) et l'ordre dans lequel les caractères ont été encodés. Mais puisqu'il s'agit d'une fonte, ont peut aisément se passer ce des données pour les calculer au runtime. En effet, en encodant dans la planche les 256 caratères possibles (les caractères qui ne peuvent pas être dessinés sont représentés par des sprites vides), et en s'imposant des règles simples (par exemple, 16 lignes de 16 caractères), on peut facilement retrouver ces informations. Au final, l'index d'un caractère dans la planche de sprites est le code ASCII de ce caractère - il est donc très facile de retrouver sa position dans la planche.

Il existe sur internet plusieurs outils qui permettent de créer des fontes bitmaps, mais étant donné que l'avenir est aux fontes à espacement proportionnel, vous aurez du mal à trouver un outil qui génère des fontes à espacement fixe. J'ai donc implémenté un petit outil - que vous pouvez télécharger ici. Cet outil (écrit en C#, et nécessitant donc l'installation du framework .NET) très simple vous permettra de créer des fontes bitmap de 256 caractère au format PNG. A noter que si vous souhaitez aller plus loin dans la gestion des fontes, je vous conseille l'excellent bmfont de AngelCode, qui est capable de générer une image PNG et un fichier XML de description pour une fonte à espacement proportionnel.

J'ai ensuite utilisé la classe SpriteLib.Font (que vous retrouverez dans le code accompagnant ce billet) pour dessiner une chaine de caractère dans un programme XNA.

Exemple d'affichage de chaine de caractère

Le code de la méthode Font.Draw() - qui dessine le texte - est relativement simple:

public void Draw(String text, Vector2 atPosition)
{
  char[] array = text.ToCharArray();
fontSheet.Begin(); for (int i = 0; i < array.Length; ++i) { // we are using Unicode, so let's make sure that we // don't request the drawing of characters we don't have // in our bitmap font char character = array[i]; if (character >= 0 && character <= 127) { fontSheet.Draw(fontBatch, array[i], atPosition); } // next character atPosition.X += fontSheet.SpriteWidth; } fontSheet.End(); }

La classe SpriteLib.Font se base sur un objet du type SpriteLib.SpriteMulti pour gérer la fonte bitmap. Elle dessine chaque caractère de la chaine - sauf si il s'agit d'une chaine Unicode contenant des caractères qui sont hors de la table ASCII.

Le code

J'ai légèrement modifié le code qui accompagnait le dernier billet XNA afin de l'étendre un peu - j'ai notamment transformé le code en une librairie XNA plutôt qu'un jeu, et j'ai tout passé dans le namespace SpriteLib.

Les classes implémentées sont:

  • SpriteLib.SpriteBase: classe de base de gestion d'un sprite. Effectue le chargement de la texture.
  • SpriteLib.SpriteSimple: un sprite, dans sa plus simple expression - c'est à dire un sprite unique dans l'image.
  • SpriteLib.SpriteMulti: gestion d'une planche de sprites; les sprites ont une taille constante. Il est possible de créer une instance de SpriteMulti soit en précisant le nombre de sprite par ligne (et le nombre de sprites total), soit en précisant la taille d'un sprite dans la planche.
  • SpriteLib.Font: gestion d'une fonte bitmap et affichage d'une chaine de caractère.

Diagramme de classe de SpriteLib

Vous pouvez télécharger le code correspondant ici[2].

La prochaine fois...

Bien évidemment, il nous reste encore beaucoup de choses à couvrir sur les sprites - en plus de la question de l'animation qui reste posée. Nous devons aussi voir quels effets on peut implémenter en utiilsant l'interface SpriteBatch ainsi que d'autres utilisation des sprites et des sprite sheets.

A bientôt donc pour de nouvelles aventures !

Notes

[1] mais non. Vous êtes intelligent, n'est-ce pas ?

[2] A noter que l'architecture courante de SpriteLib est extrèmement simplifiée par rapport à ce qu'elle devrait être. Dans l'archive se trouve un fichier UML lisible par StarUML qui montre ce vers quoi on doit tendre.

Commentaires

1. Le dimanche, février 21 2010, 07:28 par emu boots

Votre écriture est très élégant, très vivante et animée, j'ai vraiment comme vous, vous souhaite de continuer à écrire des articles de meilleure qualité, je vais souvent essayer de préoccupation, oh!

Ajouter un commentaire

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

Fil des commentaires de ce billet