Youtube fourmille de démonstrations de ce qu'on l'on peut faire avec ce genre de scrollings, et je vous en ai sélectionné deux exemples:
Le second exemple est une variante, et ne permet pas un scrolling infini, comme le premier, toutefois le principe de base reste le même. Nous allons donc aujourd'hui apprendre à réaliser ce genre de défilement avec XNA, et en créer un composant réutilisable dans nos jeux.
Les principes du parallax side scrolling
Une illustration de l'effet que l'on souhaite obtenir.
Les pointillés représentent la limite de la texture.
Nous allons réutiliser la classe SpriteBatch dont on avait expliqué le fonctionnement de base lors de notre tutoriel sur le SplashScreen. Sans rentrer dans les détails ici, SpriteBatch permet l'affichage d'une texture à l'écran.
Problème: l'effet que nous allons mettre en œuvre nécessite d'afficher plusieurs textures côte à côte, en répétition, chose que l'on nomme le tiling.
Toutefois, la classe SpriteBatch ne permet pas d'effectuer, par défaut, cette répétition de texture. L'on pourrait effectuer plusieurs fois le rendu de la texture l'une a coté de l'autre, mais ce n'est pas la méthode la plus efficace.
Il existe en effet un moyen de contourner cette limitation: la technique nous est donnée dans la documentation MSDN de Microsoft.
L'illustration ci dessous montre ce que l'utilisateur ne voit pas dans le système de défilement: en effet, une grosse partie est cachée, ce qui nous permet de simuler un défilement infini en faisant sauter les textures aux endroits nécessaires:

En regardant l'image telle quelle, et en voyant le fonctionnement interne, on a la désagréable sensation que l'image "saute"; toutefois, cette sensation n'arrive que lorsque l'utilisateur voit les calques sauter; en effet, la petite illustration à gauche est exactement la même que celle ci-dessus!
Plus de détails sur le tiling de textures
Nous allons donc devoir répéter un certain nombre de fois les textures pour pouvoir les déplacer sans que l'utilisateur final ne remarque rien. La seule question est: combien de fois doit on répéter une texture?
Pour répondre à cette question, nous devons nous dire qu'à un certain point, on doit pouvoir faire déplacer la texture sans que l'utilisateur ne remarque rien. Il faut donc déjà que la texture soit périodique (qu'elle se répète) et que donc la couleur des pixels de son bord gauche doit correspondre à la couleur des pixels de son bord droit.
Si, en plus, on veut pouvoir défiler dans l'autre direction, le même raisonnement doit être appliqué au bord haut et bas de la texture (on peut d'ailleurs étendre le raisonnement et seulement effectuer un défilement vertical plutôt que horizontal, le principe reste le même).
Supposons maintenant que nous souhaitions faire un tiling horizontal, avec des textures de 512 pixels de large et de 1024 pixels de haut, sur un écran full HD 1920x1020 pixels: dans 1920 pixels, on peut mettre 3.75 fois la texture en largeur. Nous sommes obligés de prendre la valeur directement supérieure (nous sommes obligé de prendre un multiple de 512 pour que les bords de la texture correspondent) donc 4 fois.
N'oublions pas non plus qu'il faut absolument rajouter une largeur supplémentaire (marge de manœuvre), sinon nous allons à un moment ou a un autre faire un trou lorsque nous ferons déplacer la texture. La formule pour connaitre la largeur de répétition est donc:
width = (Math.Ceil( game.Window.ClientBounds.Width / texture.Width ) + 1) * texture.Width;
On peut optimiser cette formule, car les largeurs et les longueurs sont exprimés dans le type entier (les divisions sont donc toujours tronquées à l'entier inférieur. Nous avons donc:
width = ((game.Window.ClientBounds.Width / texture.Width) + 2) * texture.Width;
Dans notre cas, cela fera: 512 x 5 = 2560 pixels. Le même principe peut être appliqué pour les défilements verticaux.
Le composant ParallaxSideScroller
Maintenant que nous avons vu une partie de la théorie sur le dimensionnement des textures, il nous faut commencer à coder la classe qui va gérer l'affichage et le positionnement de ces dernières.
Nous allons implémenter le composant ParallaxSideScroller en deux parties distinctes. D'abord le composant en lui même, qui sert de couche de haut niveau et d'interfacage avec le jeu ou les autres composants, et la classe utilitaire ScrollingLayer, qui s'occupe de gérer un calque spécifique.
{ #region Using statements using System; using System.Collections.Generic; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using XnaConnection.Components.Graphics; #endregion /// <summary> /// This is a GameComponent that implements a parallax side scrolling system /// </summary> public class ParallaxSideScroller : Base2DGameComponent { #region Fields /// <summary> /// Does the scrolling component allow to loop texture horizontally? /// </summary> private bool constraintOnX; /// <summary> /// Does the scrolling component allow to loop texture vertically? /// </summary> private bool constraintOnY; /// <summary> /// Stores the list of layers /// </summary> private List<ScrollingLayer> layers; #endregion
Nous créons donc trois champs dans notre classe. Le champ le plus évident est List<ScrollingLayer> qui va stocker les références à tous les calques que nous allons utiliser. Les deux booléens ont une fonction très simple: ils permettent de limiter le défilement dans une direction, ou l'autre (ou même les deux à la fois), en fonction de la taille de l'image de fond.
Cela ne limite donc pas le déplacement sur l'axe en question, mais cela permet d'éviter de reboucler la texture sur l'axe en question (la vidéo de démonstration en bas de ce tutoriel montre les effets de la limitation, et une vidéo vaut mieux qu'un grand discours).
On voit aussi que nous réutilisons la classe Base2DGameComponent que nous avions vu lors de notre premier tutoriel sur un composant graphique. Je vous laisse y référer pour plus de détails sur le fonctionnement de cette classe.
#region Constructors /// <summary> /// Initializes a new instance of the ParallaxSideScroller class. /// </summary> /// <param name="game">The game class that will use the ParallaxSideScroller</param> /// <param name="constraintOnX">Should the X movement be constrained?</param> /// <param name="constraintOnY">Should the Y movement be constrained?</param> { this.constraintOnX = constraintOnX; this.constraintOnY = constraintOnY; } #endregion
Pas grand chose à dire sur le constructeur de notre classe. On initialise la liste des calques et on assigne les deux booléens de contrainte sur les axes.
#region Properties /// <summary> /// Gets a value indicating whenever the component allow a texture loop on X-Axis /// </summary> public bool ConstraintOnX { get { return this.constraintOnX; } } /// <summary> /// Gets a value indicating whenever the component allow a texture loop on Y-Axis /// </summary> public bool ConstraintOnY { get { return this.constraintOnY; } } #endregion
Encore une fois ici, pas grand chose à dire: nous laissons les booléens de contrainte en lecture seule pour les autres classes. Ces propriétés seront notamment utilisées par les classes ScrollingLayer.
/// <summary> /// Adds a new layer to the parallax side scroller /// </summary> /// <param name="asset">The texture asset name</param> public void Add(string asset) { // Add the layer // Sort the layers by Depth property this.layers.Sort(delegate(ScrollingLayer a, ScrollingLayer b) { return a.Depth > b.Depth ? 1 : a.Depth < b.Depth ? -1: 0; }); }
La méthode Add() de la classe ParallaxSideScroller permet d'ajouter un nouveau calque de défilement à notre système. La classe ScrollingLayer nécessite plusieurs paramètres en entrée pour fonctionner:
- La classe
ParallaxSideScrollercourante (pour l'accès aux propriétés de contraintes, mais aussi pour l'accès à la classeGame). - L'objet
Texture2Dchargée par leContentManager. - Et une propriété flottante
depthqui indique la "profondeur" visuelle du calque. Cette propriété sert notamment à indiquer le coefficient multiplicateur à utiliser lorsque qu'on applique un mouvement à nos calques (c'est la différence de vitesse entre les calques qui donne l'impression de profondeur). Dans notre cas, le coefficient multiplicateur démarre à 1 pour le premier calque et est ensuite à chaque fois divisé par deux pour les calques suivants.
Il nous reste encore à trier les calques ajoutés dans l'ordre décroissant de leur profondeur (on affiche les calques les moins profonds en premier), chose faite par le delegate appliqué à la méthode Sort() de la liste.
/// <summary> /// Draws all layers /// </summary> /// <param name="gameTime">Provides a snapshot of game times values</param> { // See the <cref="http://msdn.microsoft.com/en-us/library/bb975153.aspx">MSDN</cref> for this trick // Force texture wrapping // Loop each layer and renders them foreach (ScrollingLayer layer in this.layers) { } // Stop the rendering sequence this.Renderer.End(); }
La méthode Draw() de notre composant va être chargé de faire le rendu de chaque calque en bouclant la liste des ScrollingLayer. Comme je l'avais évoqué plus haut, par défaut, la classe SpriteBatch n'est pas capable de faire des répétitions de textures en un seul appel.
Nous utilisons donc l'astuce fournie dans la MSDN pour effectuer du tiling. Nous passons en effet le paramètre SpriteSortMode.Immediate à l'initialisation de la passe de rendu, ce qui a pour effet d'appliquer les options personnelles du GraphicsDevice (par défaut, le SpriteBatch effectue des opérations complexes afin d'optimiser le rendu entre les méthodes Begin() et End(), en appliquant une configuration par défaut du GraphicsDevice).
Bien que théoriquement plus lente, l'utilisation de SpriteSortMode.Immediate dans notre cas accélère le rendu, car au lieu d'effectuer plusieurs appel pour faire le rendu de la texture côte à côte, un seul appel est effectué.
Je reviens toutefois sur ces deux lignes, qui nécessitent quelques explications:
// Force texture wrapping
Le GraphicsDevice représente la carte graphique, qui elle même est une machine à états. Quand on effectue un rendu de quelque chose à l'écran, on règle les options (états) que l'on souhaite et une fois réglée, on invoque la méthode de rendu de la carte graphique (c'est version simplifiée de la chose, mais vous n'avez pas besoin d'en savoir plus pour le moment).
Il existe des myriades d'options pour les rendus, qui ont été élaborés au fil des conceptions de cartes graphiques (et les lister ici n'est clairement pas le but du tutoriel). Toutefois, je vais quand même expliquer les deux options utilisées ici.
On voit que dans notre cas, nous réglons la propriété AddressU et AddressV du premier SamplerState en mode TextureAddressMode.Wrap. Un sampler est en fait une petite partie de la puce de la carte graphique qui s'occupe de gérer les textures. Le SamplerState est donc la classe qui gère les états de ce sampler et contient tous les paramètres de rendu de la texture.
Une carte graphique dispose de plusieurs samplers (les premières n'en avaient que un) mais la plupart du temps, nous utiliserons toujours le premier sampler (les autres sont utilisés dans le cas de multi-texturing).
Au sein de la carte graphique, les coordonnées de la texture sont adressées via des vecteurs (représentés par la structure Vector2D en XNA) comme pour des coordonnées écrans. Les propriétés AddressU et AddressV indiquent comment ce vecteur doit se comporter quand il sort des coordonnées de la texture.
TextureAddressMode.Wrap va lui dire d'effectuer un modulo sur les coordonnées. Cela veut dire qu'un pixel à la coordonnée (520;10) sur une texture de 512x512
pixels aura la même couleur que le pixel à la coordonnée (8;10).
C'est cette propriété qui nous permet de reboucler la texture indéfiniment et ainsi, faire ce tiling tant souhaité. D'autres méthodes d'adressages existent et je vous conseille d'aller jeter un coup d'œil sur l'énumération TextureAddressMode pour en savoir plus.
/// <summary> /// Moves each layer in a particuliar direction /// </summary> /// <param name="direction">The direction where to move the layer</param> { foreach (ScrollingLayer layer in this.layers) { layer.MoveBy(direction); } } #endregion } }
Nous finissons notre classe par la méthode MoveBy() qui va décaler tous les calques dans une certaine direction. Elle ne fait que transmettre le paramètre à chaque calque. Ce sera au calque, en fonction de sa profondeur et de sa position courante, de gérer son changement de position.
La classe utilitaire ScrollingLayer
La classe ScrollingLayer représente un calque de notre système de scrolling. Une grosse partie mathématique se retrouve dans cette classe qui est notamment chargée de positionner les calques correctement.
{ #region Using statements using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; #endregion public class ScrollingLayer { #region Fields /// <summary> /// Stores the depth of the layer /// </summary> private float depth; /// <summary> /// Stores the top left position of the texture /// </summary> /// <summary> /// Stores the scroller object needed to compute the size of the scrolling layer /// </summary> private ParallaxSideScroller scroller; /// <summary> /// Stores the texture for the layer /// </summary> #endregion #region Constructors /// <summary> /// Initializes a new instance of the ScrollingLayer class. /// </summary> /// <param name="scroller">The ParallaxSideScroller object that contains this ScrollingLayer</param> /// <param name="texture">The texture for the layer</param> /// <param name="depth">The depth of the layer. Should not equals to 0</param> { this.depth = depth; this.scroller = scroller; this.texture = texture; this.position = this.Middle; } #endregion
Aucune surprise ici, nous retrouvons nos champs et nos paramètres que nous avions vu lors de la méthode Add() du composant ParallaxScrollingLayer. Le seul point important à noter est que nous positionnons notre texture sur l'écran grâce à la propriété Middle de notre classe (voir ci-dessous).
#region Properties /// <summary> /// Gets the depth of the layer /// </summary> public float Depth { get { return this.depth; } } /// <summary> /// Gets the height of the tiled texture /// </summary> public int Height { } /// <summary> /// Gets the position of the texture to set it in middle of the screen, in pixels /// </summary> private Vector2 Middle { get { } } /// <summary> /// Gets the position of the tiled texture /// </summary> { get { return this.position; } } /// <summary> /// Gets the texture of the layer /// </summary> { get { return this.texture; } } /// <summary> /// Gets the width of the tiled texture /// </summary> public int Width { } #endregion
La plupart des propriétés de notre classe ne sont que des accesseurs en lecture seule vers les champs de la classe. Toutefois, trois propriétés méritent qu'on s'attarde un peu sur elles: Middle, Width et Height.
Width et Height représentent la largeur et la hauteur totale de la texture, en pixels, et avec les répétitions. Nous avions vu au début du tutoriel comment calculer cette largeur et cette hauteur afin que toute la surface de l'écran soit recouverte par la texture.
La propriété Middle indique les coordonnées du coin supérieur gauche de la texture de telle sorte à ce que la texture soit positionnée au milieu de l'écran.
Supposons que nous ayons une texture de 512x1024: la coordonnée Y de cette texture, de telle sorte que le point central de la texture (aux coordonnées 256;512) soit au centre de l'écran (aux coordonnées 400x300 sur un écran par défaut de 800x600) est correspond donc à:
window.Height / 2 - texture.Height / 2
Ce qui peut être refactorisé en:
(window.Height - texture.Height) / 2
Le même raisonnement peut être appliqué pour la position gauche de notre texture. Les coordonnées finales sont donc:
((800-512)/2;(600-1024)/2) = (144;-212)
Normalement, les coordonnées doivent toujours être comprises entre -texture.Width et 0 pour les abscisses et -texture.Height et 0 pour les ordonnées pour éviter d'avoir un trou dans le rendu (voir la seconde figure illustrative). Il nous faut donc encore moduler notre résultat par la largeur et la hauteur de la texture, ce qui nous donne au final les coordonnées (-368;-212).
Dans notre code, nous utilisons la méthode Modulate, privée et statique, dont la signature est la suivante:
/// <summary> /// Modulates coordinates so the coordinates are in the [-modulo;0] interval /// </summary> /// <param name="value">The value to modulate</param> /// <param name="modulo">The interval</param> /// <returns></returns> private static float Modulate(float value, float modulo) { return ((value - modulo) % modulo); }
Nous devons maintenant nous attaquer au gros morceau mathématique de la classe, à savoir la fonction MoveBy():
#region Methods /// <summary> /// Move the layer in a direction, wrapping where necessary /// </summary> /// <param name="direction">The direction of the movement</param> { this.position += direction * this.depth; if (this.scroller.ConstraintOnX) { this.position.X = MathHelper.Clamp(this.Position.X, this.Middle.X + (this.Middle.X * this.depth), this.Middle.X - (this.Middle.X * this.depth)); } if (this.scroller.ConstraintOnY) { this.position.Y = MathHelper.Clamp(this.Position.Y, this.Middle.Y + (this.Middle.Y * this.depth), this.Middle.Y - (this.Middle.Y * this.depth)); } // Re-position the texture with a modulo } #endregion
La méthode MoveBy()
de notre classe s'occupe non seulement de positionner correctement
la texture, mais aussi de contraindre ses mouvements.
La première étape consiste à rajouter la direction passée en
paramètre à notre position. La direction est multipliée par le scalaire depth,
afin que les couches les plus éloignées (et qui ont donc une valeur depth
de plus en plus petite) se déplacent plus lentement.
Viennent ensuite les contraintes de mouvement: nous limitons, suivant
l'état des booléens de contrainte de notre classe ParallaxSideScroller,
la coordonnée X ou Y de la position grâce à la méthode d'aide MathHelper.Clamp().
Les valeurs minimum et maximum du Clamp doivent être expliquées, car
la formule n'est pas forcément très triviale: pour chaque calque,
suivant sa profondeur nous voulons limiter son déplacement par rapport
au centre de la texture. La première couche (ayant une valeur depth
de 1) doit donc pouvoir bouger à partir du centre, de toute sa hauteur.
Or, on se rappelle que les coordonnées de Middle représente la
position de la texture telle qu'elle est positionnée au centre (donc à
mi-hauteur et a mi-largeur), mais en tenant compte du décalage
nécessaire à son positionnement hors écran!
Le maximum étant obtenu lorsque la texture est a sa coordonnée 0 (quand le haut de la texture touche le haut de l'écran), nous avons donc la formule, pour le maximum:
Middle - Middle * depth
La multiplication par le coefficient depth nous assure que les calques étant à une profondeur plus important que notre premier calque s'arrêteront de bouger quand le premier calque aura atteint ses limites. N'oublions pas non plus que Middle est un nombre négatif, c'est pourquoi nous faisons une soustraction pour avoir le maximum à 0. Dans notre cas précédent, si l'on remplace par des chiffres, cela donnerait:
-212 - (-212) * 1 = 0
Vu que Middle représente les coordonnées de la texture centrée, la borne minimum est donc:
Middle + Middle * depth
Ce qui, si l'on remplace par des chiffres:
-212 +(-212) * 1 = -424
Ce chiffre peut sembler peu intuitif de prime abord, mais correspond tout simplement à l'amplitude de mouvement de la texture sur l'axe des ordonnées. En effet, su un écran de 600 pixels de haut, si notre texture fait 1024, nous pouvons bouger de 424 pixels! On retombe donc sur nos pieds, comme par magie.
Nous avons maintenant fini notre classe qui s'occupe de gérer un des calques de notre composant. Il ne nous reste plus qu'à nous occuper de l'implémentation de celui ci au sein d'un jeu.
Initialisation de notre composant
Il ne nous reste plus qu'à initialiser le composant. Généralement cette initialisation se fera dans un autre composant qui utilise notre ParallaxSideScroller (en effet, il faut piloter le composant grâce à notre méthode MoveBy()), mais ici, pour la démonstration, nous allons directement l'initialiser dans la classe Game:
protected override void Initialize() { // Initializes a new parallax side scroller, and allow only a scroll on X axis // Add three textures as layers this.scroller.Add("Layer1"); this.scroller.Add("Layer2"); this.scroller.Add("Layer3"); // Add the component to the game's component list this.Components.Add(this.scroller); base.Initialize(); }
Ici, nous initialisons le composant en limitant le défilement sur l'axe des Y (le paramètre constraintOnY est à true). Le composant permet par contre un bouclage infini sur l'axe X. C'est la configuration typique utilisée dans un jeu de plateforme à la Super Mario Bros ou un jeu comme R-Type.
Nous ajoutons ensuite, via la méthode Add(), les noms des médias textures qui serviront à faire le rendu. On se rappellera que l'ordre par défaut est défini du plus proche au plus lointain, donc dans notre cas, la texture nommée "Layer1" se trouve être la plus proche visuellement (avant plan), et la texture "Layer3" est en arrière plan.
Il ne reste plus qu'à ajouter le composant à la liste des composants du jeu, et le tour est joué!
Notre version finie du défilement parallaxe
Naturellement, cette version finie ne paie pas de mine grâce à mon talent d'artiste très "particulier". Un artiste professionnel sera capable de tirer le meilleur de ce genre d'effets.


Commentaires
Super article! A conserver!
an early version of our game "Among the Clouds" has been picked as an example for a french #XNA tutorial site: http://bit.ly/bveaxE
Tout simplement excellent et les exemples sont très réussis.
Bravo !