Détecter des collisions par pixel

La série des tutoriels sur XNA et MonoGame continue et cette fois-ci nous allons voir  une technique redoutablement efficace permettant de détecter des collisions grâce aux pixels d’une image, pour ce faire nous allons réaliser une petite scène 2D avec un circuit et une voiture de course. Le résultat est sous vos yeux (j’arrive à lire la magie dans vos yeux).

Un début de jeu de course
Un début de jeu de course

A première vue c’est relativement simple, on a une image avec le circuit et une autre avec la voiture. La voiture peut se déplacer librement sur la scène, la seule contrainte est qu’elle doit rester sur la route.. On utilise souvent des hit box pour détecter des collisions car c’est facile à mettre en place et c’est peu consommateur. Cependant cette technique montre vite ses limites car il est impossible de faire de la collision parfaite avec des boites. C’est pour ça qu’en complément d’une détection par boite on peut mettre en place de la détection par pixel !

Parlons du circuit si vous voulez bien car tout le secret réside dans cette fantastique image… Les bordures de la route sont blanches (le code hexadécimal est 0xFFFFFF) et c’est justement ce qui doit en théorie retenir la voiture. L’idée est donc de récupérer la couleur du pixel du circuit à la position de la voiture et de vérifier que cette couleur n’est pas blanche (0xFFFFFF). Alors vous allez me dire que mon damier au début du circuit comporte du blanc et que ça ne marchera donc pas ? C’est vrai et faux.. Mon damier est fait de petits cubes rouge (là c’est bon) et blanc.. mais pas totalement car j’ai utilisé un blanc légèrement foncé avec un code de couleur 0xFFFFFA ! Du coup c’est invisible à l’œil nue et ça n’empêchera pas la voiture de passer.

1. Mise en place du projet

Nous avons besoin de deux images, une grande qui va représenter le circuit et une pour la voiture. Vous pouvez créer vous même vos ressources ou alors récupérer celles de cet exemple. On va commencer en douceur

using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace ColorCollider
{
    public class CarsGame : Game
    {
       	 GraphicsDeviceManager graphics;
	 SpriteBatch spriteBatch;
	 KeyboardState keyboard;

        // Objets pour la voiture
        Texture2D car;
        Vector2 carOrigin;
        Vector2 carPosition;
        Vector2 carNextPosition;
        float carSpeed;
        float carRotation;

	Texture2D circuit;
        Color[] colorCircuitArray;

        public CarsGame()
            : base()
        {
            graphics = new GraphicsDeviceManager(this);
            graphics.PreferredBackBufferWidth = 800;
            graphics.PreferredBackBufferHeight = 600;
            Content.RootDirectory = "Content";
	    Window.Title = "XNA/MonoGame - Détection de collision par pixel";
        }

        protected override void Initialize()
        {
            base.Initialize();
            // On place l'origine de la voiture au centre
            carOrigin = new Vector2(car.Width / 2, car.Height / 4);
            // On place la voiture devant la ligne d'arrivée
            carPosition = new Vector2(500, 550);
            carNextPosition = Vector2.Zero;
            // On l'incline correctement
            carRotation = (float)(-Math.PI / 2);
            carSpeed = 0.15f;
        }

        protected override void LoadContent()
        {
            base.LoadContent();

            spriteBatch = new SpriteBatch(GraphicsDevice);

            // On charge la texture du circuit et de la voiture
            circuit = Content.Load("circuit");
            car = Content.Load("car");
        }

        protected override void Update(GameTime gameTime)
        {
            base.Update(gameTime);

            keyboard = Keyboard.GetState();

            if (keyboard.IsKeyDown(Keys.Escape))
                Exit();
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.Black);

            spriteBatch.Begin();
            spriteBatch.Draw(circuit, new Rectangle(0, 0, 800, 600), Color.White);
            spriteBatch.Draw(car, carPosition, null, Color.White, carRotation, carOrigin, Vector2.One, SpriteEffects.None, 1.0f); 
            spriteBatch.End();

            base.Draw(gameTime);
        }

        public static void Main(string[] args)
        {
            using (CarsGame game = new CarsGame())
                game.Run();
        }
    }
}

Ce code est relativement simple, on dérive de la classe Game et on affiche le circuit en fond avec la voiture par dessus. J’ai ajouté plusieurs variables pour travailler avec la voiture, elles permettent de jouer sur la position et la rotation. Vous remarquerez d’ailleurs que j’ai changé le point d’origine de la voiture pour que les rotations se réalisent depuis le centre, en avant de la voiture, cela donnera un effet un peu plus réaliste au gameplay (une voiture ne tourne pas sur elle même). Enfin un des éléments les plus importants de ce tutoriel : Le tableau de couleur du circuit. Nous allons stocker dans ce tableau à une dimension, toutes les informations de couleurs de l’image du circuit. Typiquement avec la méthode qui va bien nous pourrons savoir à n’importe quel moment quelle couleur est présente à une position donnée.

2. Travailler avec les couleurs

Récupérer les informations de couleur n’est vraiment pas compliqué avec XNA et une méthode suffit pour faire ça.

colorCircuitArray = new Color[circuit.Width * circuit.Height];
circuit.GetData(colorCircuitArray);

Facile pas vrai ? Une image peut être représentée par un tableau de Color dont la taille est égale à sa largeur multipliée par sa hauteur. Nous ne sommes pas obligé d’utiliser des Color, on peut aussi travailler directement avec des byte mais là il faudra connaitre le format de l’image pour allouer la bonne taille du tableau. Par exemple la représentation en tableau de byte d’une image en RGBA aura pour taille Width * Height * 4 (4 pour les 4 composantes RGBA). La méthode GetData permet de copier les informations des couleurs de l’image dans un tableau (En Anglais nous aurions dit « Un Beuuffeur »). C’est là qu’on spécifie le type Color. La question que tout le monde se pose maintenant est : Comment récupérer des informations de ce tableau ?

Color color = colorCircuitArray[x + y * circuit.Width];

Le tableau ne contient que des instances de Color, le premier pixel du tableau correspond au pixel à la position X = 0 et Y = 0. Un tableau à une dimension n’est pas pratique à utiliser, pour l’utiliser comme un tableau à deux dimensions on utilise la formule suivante : X + Y * Width ou X et Y correspondent à une position dans l’image (attention aux débordements) et Width à la largeur de l’image.

Passons maintenant au plus intéressent, la détection de collision !

private Color GetColorAt(int x, int y)
{
    Color color = Color.White;

    // La position doit être valide
    // On passe d'un tableau 1D à 2D avec la formule x + y * texture.Width
    if (x >= 0 && x < circuit.Width && y >= 0 && y < circuit.Height)
        	color = colorCircuitArray[x + y * circuit.Width];

    return color;
}

private bool CanMove(int x, int y)
{
    // On évite le blanc (0xFFFFFF)
    return GetColorAt(x, y) != Color.White;
}

La méthode GetColorAt va travailler avec le tableau de couleurs du circuit. On part du principe que la couleur est blanche, puis si les coordonnées entrées sont correctes (il ne faut pas déborder car sinon vous aurez une belle ArgumentOutOfRangeException) alors on lit dans le tableau la valeur de la couleur. Si la position passée à la fonction n'est pas dans l'image alors c'est la couleur Color.White qui est renvoyée (qui correspond à une collision dans notre cas).

Pour savoir si la voiture peut avancer on récupère la couleur à sa position, si celle-ci est différente de blanc (0xFFFFFF) alors on peut avancer, sinon on stop !

3. La logique de déplacement

protected override void Update(GameTime gameTime)
{
    base.Update(gameTime);

    keyboard = Keyboard.GetState();

    if (keyboard.IsKeyDown(Keys.Escape))
	        Exit();

    // Prochaine position à testerm
    carNextPosition = carPosition;

    // On avance ou on recule par rapport à la rotation de la voiture
    if (keyboard.IsKeyDown(Keys.Up))
    {
        	carNextPosition += new Vector2(
	            (float)(Math.Sin(-carRotation)),
	            (float)(Math.Cos(carRotation))) * -carSpeed * gameTime.ElapsedGameTime.Milliseconds;
    }
    else if (keyboard.IsKeyDown(Keys.Down))
    {
	        carNextPosition += new Vector2(
	            (float)(Math.Sin(-carRotation)),
	            (float)(Math.Cos(carRotation))) * carSpeed * gameTime.ElapsedGameTime.Milliseconds;
    }

    // Rotation de la voiture
    if (keyboard.IsKeyDown(Keys.Left))
        	carRotation -= 0.1f;    
    else if (keyboard.IsKeyDown(Keys.Right))
	        carRotation += 0.1f;

    // Si la prochaine position est bien sur la route 
    // et pas dans le vert on utilise ces coordoonées
    if (CanMove((int)carNextPosition.X, (int)carNextPosition.Y))
	        carPosition = carNextPosition; 
}

Il y a deux choses à voir ici, la première est la petite formule de trigonométrie qui permet de déplacer correctement la voiture avec les touches du clavier, la deuxième est la détection de collision et la mise à jour de la position de la voiture. Au début la variable carNextPosition est égale à la prochaine position de la voiture, elle est ensuite testée avec la méthode CanMove. Si la prochaine position de la voiture est valide (elle n'est pas sur un pixel totalement blanc) alors la prochaine position est valide et la position courante et mise à jour, sinon on ne fait rien et la voiture conserve sa position actuelle.

Nous en avons terminé avec ce tutoriel, vous pouvez récupérer le projet complet sur mon dépôt github dédié à ce blog.

4. Des pistes d'amélioration

Vous remarquerez que la grosse faille dans cette technique est que l'on fait un test par point, or si la voiture recule, la détection de collision se fait au niveau du capot et pas du coffre. Pour remédier à ça vous pouvez ajouter une nouvelle variable qui contiendra la position cible et vous l'ajusterez en fonction de la position de la voiture.

L'image du circuit peut contenir autant de couleurs que vous voulez, vous pouvez donc vous en servir pour détecter que le joueur à passé la ligne d'arrivée.

Le dernier point intéressent à mettre en place est une collision glissante, typiquement il faut détecter la prochaine bonne position à faire adopter à la voiture.