« Une intéressante épopée dans la création d’un platformer isométrique en 2D » par Sven Duval

Notre jeu, intitulé « Une intéressante épopée de Monsieur Paf« , est né suite à des expérimentations autour du concept de l’isométrie 2D. Comme la 3D n’était pas compatible avec nos envies et nos choix artistiques, l’idée originale était d’essayer de gérer un axe vertical en plus du plan horizontal dans le but de pouvoir créer des ponts ou autres éléments avec lesquels les personnages pourraient à la fois marcher dessus ou bien passer en dessous. Au fil de nos tests, le résultat devenant de plus en plus convainquant, nous avons donc développé un gameplay autour de ce moteur en même temps que nous le développions.


Article et schémas par Sven Duval – Design et graphisme par Antoine Schmoll – Studio Ernestine – Strasbourg, FR – Plus d’information autour du jeu sur @Monsieur_PAF – Développé sur Unity3D 5.6

J’ai été très surpris en lisant l’article de Martin Pane sur la création de leur jeu en isométrie 2D. Son approche de la problématique est très intéressante et en même temps très différente de la mienne car il ne semble pas du tout utiliser le concept de TileMap, et de fait, les performances du jeu doivent être bien meilleures. Mon approche, quant à elle, est beaucoup plus conventionnelle et inspirée de tutoriels et autres exemples trouvés sur le web. C’est pourquoi, dans un esprit de partage et pour faire écho à ses réflexions, j’ai décidé de donner un aperçu de mon travail en espérant que cela inspirera d’autres projets. A vous ensuite de choisir la méthode la plus adaptée, voire même de faire un mix des deux.

Avant tout, si vous souhaitez créer un platformer isométrique, je ne saurais trop vous conseiller de vous orienter vers la 3D plutôt que vers une approche 2D. Vous réduirez ainsi considérablement le temps de développement en utilisant une caméra orthographique, en bénéficiant de la physique et des autres outils très puissants proposés de base sur Unity, et le résultat en sera d’autant plus optimisé. Cependant, si vous ne pouvez pas ou ne voulez pas, mais vraiment pas, travailler en 3D, alors ce qui suit pourra peut-être vous être utile.


Fig. 1 : Définition de la taille d’une tuile

Première étape : Création d’une TileMap

La méthode classique pour créer un univers en isométrie 2D passe par l’utilisation d’une TileMap. Ce principe vous permet de simuler une vue 3D à partir d’un système de coordonnées en 2D. Vous trouverez beaucoup de tutoriels expliquant son fonctionnement sur le web. C’est un concept presque aussi vieux que le jeu vidéo, c’est pourquoi je ne m’attarderai pas sur ce sujet et les théories qui en découlent.

Pour notre moteur isométrique, nous avons choisi d’utiliser des tuiles (ou « tiles ») en forme de losange avec un ratio de 2/1 :

Ensuite nous avons défini l’origine et les axes isométriques de manière à rester cohérent avec ce qui se fait couramment dans un environnement 3D :

Fig. 2 : Définition des axes de la TileMap

J’ai ensuite ajouté un axe vertical nommé « K », définissant son unité comme la hauteur d’un cube isométrique. 1K est donc égal à la hauteur d’une tuile.Contrairement aux axes I et J qui peuvent uniquement être définis par des nombres entiers, l’axe K est un réel (ou flottant), ce qui est nécessaire pour implémenter la gravité par la suite.

Fig. 3 : Définition de l’axe K

Au niveau code, on pourrait être tenté, à juste titre, de créer un tableau à deux dimensions pour référencer les tuiles. Cependant il y a une bien meilleure solution qui consiste à convertir les coordonnées isométriques (i, j) en un index entier et qui permet ainsi d’utiliser un simple tableau. La conversion est basée sur la taille de la TileMap :

Fig. 4 : Conversion des coordonnées (i,j) en index unique

Pour définir et référencer les objets dans l’environnement isométrique, j’ai créé un composant représentatif de chaque entité indiquant sa position sur les axes ainsi que sa taille. Je l’ai nommé « Mapper ». On peut alors positionner un mapper sur la scène en fonction de ses coordonnées isométriques :

Fig. 5 : Positionner un élément

Chaque mapper est donc référencé dans l’environnement isométrique sur une ou plusieurs tuiles en fonction de sa taille. Cependant, au lieu de créer une entité (classe) pour chaque tuile, je les ai regroupée dans l’entité « TileMap » afin d’augmenter les performances. Elle ne contient donc pas un tableau d’entités « Tile » mais simplement un tableau de listes d’objets « Mapper ». La notion de tuile devient donc une simple structure CSharp (et non une classe ! Voir ici pour plus d’explications) contenant des méthodes « raccourcis » vers l’entité « TileMap ». Le code devient donc beaucoup plus rapide à l’exécution et utilise beaucoup moins de mémoire.


Deuxième étape : Gestion de la logique et construction à l’aide objets primitifs

Une fois la « TileMap » opérationnelle, on peut alors commencer à construire notre univers. Le sprite, qui est la représentation visuelle de l’objet logique, est associé au mapper à l’aide du composant « SpriteRenderer ». J’ai également fait en sorte que d’éventuels GameObject enfants comportant un « SpriteRenderer » soient pris en compte afin que l’on puisse utiliser plusieurs sprites pour un seul mapper, et même des systèmes de particules.

Comme pour tout projet de jeu vidéo, il nous fallait un éditeur de niveau, car attendre que les images soient prêtes pour construire les niveaux n’est pas une option viable. J’ai donc créé une entité primitive qui nous permet de construire et de tester le level design sans avoir besoin d’attendre que les graphismes soient achevés.

Dans Unity, un sprite n’est ni plus ni moins qu’un plan 3D auquel on applique un « material » avec une texture. Comme tout objet 3D, il est donc défini par un mesh, soit des vertex, des triangles, et un mapping « UV ». On peut facilement le vérifier en important et en le mettant sur la scène. Changez ensuite le « Draw Mode » sur « Wireframe » pour visualiser les vertex du mesh définissant le sprite.

Fig. 6 : Les différentes options du « Draw Mode » de Unity
Fig. 7 : Édition des vertex dans l’éditeur de sprite.

Note : il semble beaucoup plus efficace en terme de performance d’ajouter des vertex pour détourer une image plutôt que de demander au GPU de traiter un nombre important de pixels transparents. Quand vous importez une image, Unity détecte les pixels transparents et calcule automatiquement les vertex réalisant le détourage. Depuis la version 5.6, l’éditeur de sprite contient une option « Edit Outline » qui vous permet de modifier ces vertex. Cela peut être très utile si vous avez besoin de réduire le nombre de vertex et de triangles pour optimiser le temps de rendu.

Une primitive est donc la représentation visuelle d’un cube dont on peut changer la taille et la couleur. Un cube en vue isométrique contient trois faces visibles, « Top » (haut), « Left » (gauche) et « Right » (droite). On pourrait ne créer qu’un seul mesh pour ces trois faces, mais j’ai préféré en créer un pour chaque face pour pouvoir ajouter un contour, nuancer les faces et ainsi rendre la scène plus lisible.

Fig. 8 : Hiérarchie d’une primitive

Vous trouverez plus d’information sur la création de mesh à partir du code en lisant ce post.


Troisième étape : déterminer l’ordre d’affichage des objets

Comme l’explique Martin Pane, la priorité d’affichage lorsqu’on rend un sprite est la suivante, du plus prioritaire au moins prioritaire :

  • « Sorting layer » (calque de rendu) du composant « Renderer »
  • « Order In Layer » (ordre d’affichage) du composant « Renderer »
  • La position « z » du « GameObject »

Si deux sprites ont le même « Sorting Layer » et le même « Order In Layer », celui le plus proche de la caméra sera affiché devant l’autre.

L’univers isométrique est fait de sprites 2D. Cela implique d’écrire un algorithme qui détermine l’ordre d’affichage de tous les sprites. Si tous les objets étaient statiques, cela ne serait pas très problématique, mais comme les personnages se déplacent dans cet univers, les choses deviennent bien plus compliquées.

Fig. 10 : déplacement d’un personnage dans l’environnement isométrique

L’ordre d’affichage est défini par la position et la taille des objets dans la « TileMap ». Il est alors possible d’établir un ordre exhaustif d’affichage des objets à partir du système de coordonnées isométrique. Cet ordre est ensuite renseigné aux composants « SpriteRenderer » par la propriété « Order In Layer » (depuis le code, cette propriété s’appelle « sortingOrder » ), allant de 0, le plus éloigné, à N, le plus proche. La position « z » du « GameObject » peut également être utilisée pour organiser les objets enfants et aussi animer un objet contenant plusieurs « Renderer ».

Note : Les propriétés d’ordre d’affichage (« sortingLayerId », « sortingLayerName », « sortingOrder », etc) ne sont pas spécifiques au composant « SpriteRenderer » mais sont héritées du composant « Renderer ». On peut donc y accéder et les modifier via le code même pour un composant « MeshRenderer » ou un système de particules. (voir ici)

Travailler en isométrie 2D peut facilement vous faire oublier que vous n’êtes pas dans un environnement 3D et donc que certaines choses ne sont pas possibles. Par exemple :

Fig. 11 : Assemblage d’objets 3D

Si vous tentez de réaliser cette figure en 2D, vous allez rencontrer un problème : en considérant 1 sprite par cube, le cube bleu doit être affiché devant le vert, le cube vert devant le rouge, mais le rouge doit être devant le bleu… On se retrouve avec ce que nous avons appelé un « triangle infini », rappelant le triangle de Penrose. « Infini » car nous obtenons le résultat suivant à l’exécution du jeu :

Fig. 12 : Rendu de l’assemblage en isométrie 2D

Lorsqu’on construit les niveaux, il est assez facile de les éviter. Mais lorsque les personnages se déplacent, ils peuvent apparaître à des endroits inattendus. Au bout d’un moment, on arrive à anticiper la plupart des cas de figure, mais il y en a toujours qui nous échappent.

Afin de déterminer l’ordre d’affichage, j’ai écris plusieurs algorithme en essayant de minimiser les comparaisons entre les objets. Mais finalement, celui qui semblait être le moins performant s’est avéré être le seul réellement efficace. Il consiste en l’utilisation d’une méthode récursive qui détermine l’ordre d’affichage d’un objet après avoir déterminé celui des objets situés visuellement derrière :

//Déclaration du compteur à incrémenter
private int _currentSortingOrder = 0 ;

//Exécution à chaque frame
private void Update(){

//Récupération de la liste des mappers référencés sur la TileMap
List mappers = Tilemap.GetAllMappers() ;

//Initisation des mappers pour le rendu
for(int m=0 ; m {
//Méthode définissant la propriété « Rendered » à « false », entre autres choses
mappers.InitializeRendering() ;
}

//Initialisation du compteur
this._currentSortingOrder = 0 ;

//Rendu de tous les mappers
for(int m=0 ; m {
this.RenderMapper(mappers) ;
}
}

private void RenderMapper(Mapper toRender)
{
//Ignore si le mapper est déjà rendu
if(toRender.rendered) return ;

//Liste les mappers situés visuellement derrière
List mappersBehind = this.GetMappersBehindOf(toRender) ;
for(int m=0 ; m {
RenderMapper(mappersBehind) ;
}

//Renseigne l’ordre d’affichage au mapper et défini la propriété « rendered » à « true »
toRender.RenderAt(this._currentSortingOrder) ;
this._currentSortingOrder++ ;
}

private List GetMappersBehindOf(Mapper target)
{
//Initialisation de la liste
List mappersBehind = new List() ;

//Liste les mappers étant visuellement derrière

//Et retourne la liste
return mappersBehind ;
}

L’inconvénient de cette approche est qu’elle doit être exécutée à chaque frame. C’est pourquoi la micro-optimisation du code est de rigueur pour que l’algorithme ne soit pas trop long à l’exécution (par exemple, préférer l’utilisation d’instructions « for » au lieu de « foreach » pour autre chose qu’un tableau, ou encore éviter de boucler sur un « Dictionary ». Voir ici). Si vous voulez que le jeu tourne à 60 fps, chaque frame doit être calculée en moins de 16 millisecondes. En prenant en compte le temps de rendu de la carte graphique, d’autres algorithmes potentiellement gourmands comme le pathfinder et le fait que nous travaillons avec des machines plutôt performantes, nous avons défini un temps de calcul maximal de 10 millisecondes pour cet algorithme.

De plus, son temps d’exécution augmente en fonction du nombre d’objets, de leur taille et aussi de la taille de la TileMap. Nous nous sommes donc rapidement retrouvés limités par la taille de la TileMap. Pour une grille de 25 par 25 avec une cinquantaine d’objets situés en 0K et 7K en hauteur, nous arrivons à un temps de rendu compris entre 8 et 9 millisecondes. Si le jeu est une succession de petites pièces, cela fonctionne. Mais nous souhaitions avoir des zones relativement étendues dans lesquels le joueur pourrait se déplacer un peu comme dans un « open world » / « RPG ». Pour ce faire, nous avons trouvé une solution : le multi-TileMap.

Au lieu d’avoir une grille de 100 par 100 ayant un temps de rendu complètement absurde de 50 millisecondes, nous l’avons divisé en plusieurs TileMap d’environ 15 par 15. Chacune a donc un temps de rendu compris entre 1 et 3 millisecondes. L’astuce consiste ensuite à appliquer un second algorithme pour déterminer l’ordre d’affichage des TileMap et de ne rendre que les TileMap étant visible à lécran.

Le résultat final n’est pas si mal, et s’avère même être plutôt efficace (je l’ai testé sur mon ordinateur portable vieux de 10 ans). Mais il y aura toujours des limitations, tout dépend de comment on construit les niveaux, plus précisément de s’ils s’étendent beaucoup sur la hauteur ou non, mais aussi du gameplay, des interactions que l’on souhaite mettre en place, de la gestion de la physique et du déplacement des personnages.


Dernière étape : le reste du jeu…

A partir de là, le moteur peut être adapté à différent type de gameplay, que ce soit un pointé cliqué, un RPG, un platformer ou bien ce que vous voulez. Notre jeu étant un puzzle platformer, j’ai donc dû implémenter la physique sur l’axe vertical ainsi que la gestion des déplacements sur les axes horizontaux. Je ne doublerai pas la taille de cet article en vous expliquant comment cela fonctionne, mais je vais vous donner quand même quelques pistes :

  • Pour rappel, utilisez la fonction « FixedUpdate » pour la gestion de la physique et des mouvements, et non la fonction « Update ».
  • Utilisez un seul appel à la fonction « FixedUpdate » pour tout le moteur physique. C’est elle qui ensuite appellera les méthodes permettant de mettre à jour les objets qui doivent l’être. Ainsi, si les objets sont interdépendants, vous pourrez contrôler dans quel ordre les objets doivent être mis à jour.
  • Si vous gérez un axe vertical en plus des axes horizontaux, appliquez d’abord le déplacement horizontal, le déplacement vertical, et évaluez ensuite la nouvelle position de l’objet et son état.
  • Ne croyez pas tout ce que je vous ai raconté, il y a probablement des solutions plus efficaces pour arriver au même résultat. Faites des recherches sur le web, ou mieux encore… imaginez les.

4 réflexions au sujet de “« Une intéressante épopée dans la création d’un platformer isométrique en 2D » par Sven Duval”

  1. Salut, un très bon article donnant de bonnes pistes pour la création de jeux en isométrique.
    J’étais déjà tombé sur cet article mais sur gamesutra, datant un peu je n’avais pas osé poser de question !
    Cet article étant plus récent, je me permets d’en poser deux/trois =D
    Je travail aussi à la réalisation d’un jeu en isométrique et après avoir un peu écumé internet quant aux méthodes de réalisation d’univers 2.5D sur unity, j’ai trouvé ta méthode très sympa et surtout plutôt dans l’idée quand j’en avais avec l’utilisation d’une tilemap. J’ai déjà essayé de faire un truc dans mon coin mais je n’étais pas satisfait du résultat et surtout de la cohérence globale.
    Du coup pour mes questions, la grille dans la première partie, comment elle est représenté au sein de unity ?
    De ce que j’ai compris tu crées un Mapper pour chaque élément que tu disposes sur la grille, Mapper qui contient entre autre le script de primitive (avec une référence à la grille) et les 3 faces en enfant. Tous ces Mappers sont référencés dans une liste/tableau dans TileMap. Si j’ai bien compris ? =O
    Et pour finir la division de la primitive en 3 faces, est-ce que cela induit de devoir diviser un sprite en 3 (top, right, left) comme par exemple celui de la figure 7 ?

    Merci d’avance pour tes réponses !

    Sinon l’éditeur qui est présenté sur Gamesutra envoie vraiment du lourds, mon objectif serait d’avoir un truc dans ce genre la car cela a l’air très pratique pour les phase de level design / prototypage 😉

    Merci d’avoir partagé cela en tous cas !

    Répondre
    • Bonjour Shizuna,

      Merci beaucoup pour ton retour.

      Concernant la grille, elle est représentée dans Unity par un GameObject contenant un script « MonoBehaviour » gérant l’entité « Grille ». Dans ce script, je définis la taille de la grille à l’aide de propriétés sérialisées et j’ai également plusieurs méthodes permettant de convertir un Vector2 ou Vector3 en coordonnées par rapport à la grille et inversement en fonction de la position du GameObject sur la scène. C’est également dans ce script que je recense tous les « Mapper » présents sur la grille, et par extension, j’ai aussi les fonctions permettant de trier et de comparer les « Mapper » entre eux afin de mettre à jour le « Sorting Order » des composants « Renderer ». Cependant, le tri est contrôlé par une entité extérieure basée sur le pattern singleton qui utilise ensuite les méthodes de l’entité grille.

      Concernant les « Mapper », il s’agit également d’un script « MonoBehaviour ». Dans la méthode « Start » du « Mapper », je liste d’abord tous les composants « Renderer » du GameObject et de ses enfants et je le recense ensuite auprès de la grille. Au niveau de la hiérarchie, tous les « Mapper » sont des enfants du GameObject portant l’entité « Grille ».

      Pour le recensement, vu que les dimensions de la grille ne sont pas censés être modifiées pendant l’exécution du jeu, j’utilise un « Array » plutôt qu’une liste pour gagner un peu en performance. C’est donc un « Array » de listes (List[]) en utilisant la méthode décrite dans l’article (figure 4).

      La primitive, de son côté, est un « Mapper » avec des spécificités qui lui sont propre. Au niveau CSharp, le script « Primitive » étend donc le script « Mapper » et gère les 3 faces du cube (3 GameObjects enfants de la primitive composés d’un MeshFilter et d’un MeshRenderer. Les Meshes sont générés par le script « Primitive ». Au final, la primitive n’utilise pas de sprite, la figure 7 est juste un exemple de sprite qui est utilisé avec un « Mapper ».)

      Sinon l’éditeur est effectivement très pratique pour le level design et le prototypage. Tu peux suivre @gotakat_schmoll qui poste régulièrement de nouveau essais d’utilisation du moteur / éditeur. Il manque dans cette version de l’article les gifs illustrant le fonctionnement de l’éditeur, je vais voir pour les rajouter prochainement.

      J’espère t’avoir éclairé un peu mieux, n’hésite pas si tu as d’autres questions !

      Répondre
  2. Salut ! Merci pour ta réponse.
    Je suis partie sur un truc dans ce genre là du coup.
    https://ibb.co/kFY9Od
    Je vais essayer de voir pour améliorer cela avec les précisions que tu m’as données là.
    Je ne gère pas encore la partie taille sur un objet.
    Je n’ai pas encore l’aspect « smooth » au niveau de l’éditeur que je voudrais (Pour l’instant, il faut appuyer sur q pour afficher chaque bloc), pas de gestion de la taille des blocs et …
    Travaillant sur un Tactical, j’essaye de custom le truc un peu selon mes besoins.
    Par exemple j’aime bien avoir toutes les cases accessibles de manière délié (pour afficher par exemple la zone de déplacement…), mais il faut que je vois pour optimiser le trucs comme tu le soulignes car pour la carte actuelle c’est déjà un peu ricrac !

    D’ailleurs pour l’aspect éditeur de niveau, je pense que tu utilises la motion [ExecuteInEditMode] pour rendre la chose possible ? Cela pour être intéressant de le souligner je pense.

    J’ai aussi un petit soucis côté de l’utilisation de cinemachine, le « Dampering » sur la caméra donne un effet de glitch assez impressionnant, je ne sais pas si tu as déjà rencontré le soucis ou pas ?

    Merci encore pour les précisions, je reviendrais vers toi au besoin !
    Bonne chance pour votre nouveau jeu aussi 😉

    Ps: Vous ne recruteriez pas ? =P *Bah quoi cela ne coûte rien de demander!*

    Répondre
    • D’après l’image que tu as postée, tu sembles plutôt bien partie !

      Pour optimiser, il faut pouvoir jouer sur la taille des blocs (relis bien la deuxième partie et le lien vers le post à la fin), car plus il y a de blocs, plus il y aura de comparaisons. Tu peux limiter également les comparaisons sur la hauteur si ce n’est pas nécessaire, dans ton exemple ce n’est pas encore vraiment pertinent de le faire. Pour les hauts murs au fond, s’ils ne sont qu’un seul bloc (enfin 4..), il y a de l’espace vide entre le mur et le bout de la grille sur l’axe V, ce qui peut potentiellement provoquer des boucles inutiles selon ton algorithme et augmenter son temps d’exécution.

      Pour la partie tactical, tu peux éventuellement créer une nouvelle entité (Script Mono) que tu ajouterais en plus du « Mapper » pour gérer la logic du tactical en fonction de la taille des blocs. Cela éviterait d’avoir à créer des blocs de 1 sur 1 et ainsi réduire le nombre de comparaisons, à tester.

      Sinon je n’utilise pas l’attribut [ExecuteInEditMode]. J’ai préféré utiliser une classe statique avec l’attribut [InitializeOnLoad] dans un dossier « Editor », puis dans le constructeur, ajouter un callback au « SceneView.onSceneGUIDelegate » (à faire attention en utilisant cette méthode, bien vérifier que le callback en question n’est pas déjà présent avant de l’ajouter). Ensuite l’algorithme est exécuté uniquement pour les « events » de type « Repaint ». Cela me permet de dissocier les parties Editeur et RunTime car je gère d’autres choses qui sont propres à chaque partie, notamment l’appel aux « Handles » dessinant la grille dans l’éditeur afin de l’avoir en permanence.

      Concernant cinemachine, je ne peux pas t’aider, je n’ai pas encore eu l’occasion de l’utiliser.

      Je te remercie pour ton soutiens !
      Content de savoir que l’article a pu t’aider.

      PS : Bien essayé ^^ , mais malheureusement nous n’avons pas les moyens d’embaucher pour le moment, j’espère que ça viendra rapidement..

      Répondre

Laisser un commentaire