World of Thieves aura un monde ouvert


Salut les amis,

[Attention: Cet article est vraiment technique. Continuez à lire si vous êtes fous uniquement]

Comme promis dans le dernier post, voici un article assez technique qui explique le genre de problème que je rencontre pour vous donner une idée de comment je passe mes journées à m’arracher les cheveux. Celui-ci est particulièrement complexe et m’a occupé 2 semaines, car j’ai du changer beaucoup de choses dans le jeu, même si, à la base, je m’étais dit « C’est bon, pas de soucis, j’ai tout calculé »… Quelle naïveté…

Le but final et d’avoir un monde ouvert et continu sans temps de chargement entre les différentes zones.

Pour un monde ouvert me demanderez-vous? Parce que, lorsque j’ai commencé ce jeu, je me suis imposé 3 lignes directrices fondamentales:

– lierbté
– humour
– onirisme

Et chaque choix que je fais à chaque seconde du développement du jeu essaie au maximum de suivre ces 3 « règles ». Ca permet d’assurer une certaine cohérence et une ligne artistique au jeu, même s’il n’est évidemment pas parfait. Et donc, avoir un monde ouvert contribue ENORMEMENT au sentiment de liberté que je veux instiller dans le jeu. Et c’est un challenge technique un peu fou, et je suis quelqu’un d’un peu fou.

Comme vous l’avez peut être déjà vu dans des vidéos et démos postées sur ce cite, le monde du jeu est un immense océan recouvert d’îles (oui comme dans Zelda Windwaker). A un moment, le joueur a la possibilité de silloner l’océan à dos de tortue pour aller où il le souhaite. Cela signifie que les îles/niveaux doivent être chargés dynamiquement en fonction de la position du joueur dans le monde.

I – Les limites de Unity (oui j’ai enfin rencontré des limites)

J’utilise Unity pour créer mon jeu et (heureusement?), Unity propose 2 fonctions pour charger un nouveau niveau « en arrière-plan », de telle sorte qu’on ne remarque pas de ralentissement pendant ledit chargement:

  • LoadLevelAsync: charge le niveau en background (arrière-plan). Une fois chargé, le niveau remplace le niveau actuel.
  • LoadLevelAdditiveAsync: La même chose, mais le contenu du niveau chargé s’ajoute au niveau actuel. C’est évidemment ça qui m’intéresse ici.

Mais ça, c’est en théorie. Unity est vraiment un bon logiciel, mais plusieurs points sont encore en développement… Et ces fonctions en font partie. Et ça impacte le jeu d’une manière dont je n’avais pas idée: ça crashe l’intelligence artificielle (IA) des ennemis.

Cela se produit car l’IA utilise un « NavMesh » (= Navigation Mesh = Maillage de navigation) pour représenter les zones praticables d’un niveau. L’IA ne recherche des chemins pour les ennemis QUE sur le NavMesh. Le problème, c’est que Unity ne peut stocker qu’un seul NavMesh en mémoire simultanément et on ne peut pas charger de NavMesh avec LoadLevelAdditiveSync. Limitation technique de Unity, je ne peux rien faire.

Le NavMesh: les ennemis ne peuvent se déplacer que sur la zone bleue. Le calcul d’un NavMesh est une opération relativement complexe.

 

II – L’heure des Hacks

J’ai trouvé un « hack » (une manière pas très propre de contourner le problème) sur un forum: utiliser LoadLevelAsync (qui, lui, charge EFFECTIVEMENT le NavMesh), et tagger les objets du niveau courant comme ne devant pas être détruits (une fonctionnalité bien cool que j’ai d’ailleurs découvert grâce à ces forums. Quelle comunauté autour de ce logiciel!)
C’est censé résoudre le problème, mais en fait, ça soulève 2 autres questions:

  • LoadLevelAsync n’est en fait pas vraiment exécuté en arrière plan. Ca fige le jeu pendant quelques ms et c’est VRAIMENT visible.
  • Cool, le NavMesh est effectivement OK pour le nouveau niveau, l’IA aussi, mais qu’est-ce qu’il se passe si je reviens vers le 1er niveau, qui est toujours visible, mais qui n’a plus de NavMesh (puisque ce dernier a été remplacé par celui du nouveau niveau) et plus d’IA? Si je veux recharger uniquement le NavMesh du 1er niveau, il est impossible de le faire sans recharger l’intégralité du niveau en question, ce qui risque provoquer des clignotements des objets et des bugs, une charge inutile du CPU et surement d’autres ralentissements.

A ce stade, je suis face à des bugs de Unity que je ne peux pas corriger. Voilà les options qui s’offrent à moi:

  • Attendre une correction du bug de Unity sur ce problème NavMesh+LoadLevelAdditiveAsync. Je ne pense pas que ça arrivera avant que je sorte mon jeu. Les gars de chez Unity ont des tonnes de trucs à gérer, et ça ne semble pas être une priorité.
  • Utiliser une librairie annexe/tierce. Je dois en trouver une qui gère la génération de NavMesh et le Pathfinding (recherche de chemin), en temps réel, et qui gère le chargement dynamique des données.
  • Tout recoder. Ce n’est pas impossible (j’ai déjà codé un algo de pathfinding A* pour World of Ninjas, mais c’était une grille 2D)… mais ce n’est pas très réaliste. Des gens sérieux passent des mois à développer des systèmes bien plus fiables que ce que je pourrais faire en quelques semaines.
  • Trouver un autre hack. Je n’ai rien trouvé en passant plusieurs heures sur les forums et les FAQs
  • Abandonner. C’est réellement une option. Je peux mettre en place une navigation dans le monde à partir d’une carte en 2D où on clique sur l’endroit où on veut aller. Toutes les îles seront accessibles, et ça ne changera pas le gameplay sur chaque île. Peut-être même que c’est ce que je choisirai de faire si j’arrive à créer un monde ouvert, mais que ça n’apporte aucun intérêt au jeu.

Mais avant d’abandonner, j’ai entendu de bonnes critiques d’une librairie d’IA: la librairie « A* PathFinding » d’Aron Granberg.

Beaucoup de niveaux chargés simultanément!

 

III – Une nouvelle librairie: A*

Cool! Une nouvelle librairie pleine de promesses. Mais avant de s’engager pleinement là-dedans, il faut que je teste les fonctionnalités basiques pour vérifier que je retrouve bien ce que j’avais déjà dans Unity.

J’ai commencé avec la version gratuite… c’est à dire sans génération de NavMesh (cette fonctionnalité n’est disponible que dans la version à 100$). Bien sûr, je peux acheter la version payante, mais je ne sais même pas si la librairie résout mon problème de chargement dynamique. Je dois d’abord tester ce point.

Je sais que Unity peut générer des NavMesh (je les ai déjà utilisés). J’ai donc écrit un script pour convertir les NavMesh générés par Unity dans un format lisible par la librairie A*, ce qui m’a permis de tester le path-finding sur mes niveaux déjà faits.

Mais il y a déjà un problème: le pathfinding se comporte bizarrement et, des fois, l’IA fait de gros détours. Ca semble être un problème connu. Cela vient de la topologie du NavMesh: un « bon » NavMesh pour la librairie A* est sensé avoir une sorte de motif en grille afin d’éviter la juxtaposition de grand et de petits triangles. Pas de chance pour moi, Unity ne propose pas ce genre de paramétrage dans son interface, ce qui signifie que je ne peux pas vérifier si les résultats sont meilleurs sur un « bon » NavMesh.

 

IV – Une autre librarie : RAIN

J’ai également entendu parler d’une autre librairie: RAIN. Entièrement gratuite, mais avec peu de documentation. Je l’ai essayée principalement pour évaluer son générateur de NavMesh et… Hourra! Elle peut générer des NavMesh à motif de grille. J’ai donc écrit un autre script pour convertir les NavMesh de RAIN en NavMesh pour A*, avec le peu de documentation que j’ai trouvé. Pas facile! Et j’ai quelques erreurs lors de la conversion, mais visuellement, ça a l’air OK. J’ai testé avec A* et l’IA se comporte enfin correctement.

Donc en combinant 2 librairies différentes, j’arrive à avoir une IA basique acceptable. Je suis à peu près au même point qu’avec Unity avant.

Maintenant je dois m’attaquer au VRAI problème: le chargement dynamique.

 

V – Le chargement dynamique de NavMesh

On dirait que la librairie A* propose un moyen d’exporter les NavMesh dans un fichier texte et de les charger dynamiquement. Exactement ce qu’il me faut (en théorie). Après quelques tests, on dirait que ça marche pour les fichiers relativement petits. Mais bien sûr, là encore, le NavMesh chargé remplace le précédent. Je dois donc faire très attention lors de l’activation des IA. C’est bien engagé, mais ça signifie que je dois encore écrire un script qui converti un NavMesh en fichier texte pendant la génération du niveau.

Le niveau de test habituel pour l’IA.

 

VI – Tout intégrer

OK. Tous les tests que j’ai fait jusqu’à présent ont été réalisés sur des niveaux et des objets temporaires. Je dois maintenant réécrire le code des VRAIS ennemis que j’utilise dans mon jeu, et utiliser la librairie A* en remplacement du système de Unity. Et bien sûr, j’ai quelques soucis car A* ne propose pas exactement la même approche que Unity pour la gestion des états de calcul (le chemin est en cours de calcul, l’IA est arrivé à destination, etc…). Mais finalement, ça marche! Comme avant, mais avec le chargement dynamique des niveaux et un comportement correct de l’IA! Pfiouuuu!

 

VII – Surprise finale: activation progressive

Mais… pour des niveaux de grande taille, on dirait que le chargement « lagge » (ralentit le jeu). Comment est-ce possible? Est-ce que j’aurais fait tout ça pour me rendre compte finalement que la fonction LoadLevelAdditiveAsync n’est pas REELLEMENT exécutée en arrière-plan? Est-ce que j’ai mal fait un truc?

Et en effet, après plusieurs tests, on dirait que ce n’est pas le chargement en lui-même qui fige le jeu. C’est en réalité l’activation de tous les objets du niveau et toutes les fonctions « Start’ qui s’exécutent en même temps (IA/vegetation/animaux…).

Je dois donc désactiver tous les objets par défaut et les activer un par un à chaque affichage pour éviter cette charge CPU trop importante. Mais cela amène à 30 secondes pour activer un niveau de 1800 éléments. Je dois donc optimiser pour essayer d’activer plusieurs objets en un affichage s’ils ne sont pas trop gourmands en temps de calcul (les caisses par exemple), et n’en activer qu’un à la fois dans le cas d’objets complexes (les ennemis). Ainsi, j’ai pu réduire à 3 secondes le temps de chargement et d’activation pour ce même niveau de 1800 éléments.

 

VIII – Tout automatiser

Cool! Tout ça a l’air de marcher sur les 1ers niveaux que j’ai placés « à la main » dans le monde. Mais au final j’aurai beaucoup de niveaux, et il se peut que leur emplacement change. Je dois mettre en place un système qui fait tout ça de manière automatique pour chaque nouveau niveau.

J’ai donc créé un fichier spécial dans Blender pour positionner précisément chaque niveau dans le monde. Les niveaux eux-même sont quant à eux stockés dans des fichier Blender séparés et sont centrés en (0,0,0) au sein de ces fichiers séparés.

 

Le fichier qui localise tous les niveaux dans Blender. Les grandes et petites sphères représentent respectivement les zones de chargement et d’activation.

Quand je charge un niveau de Blender dans Unity, voici ce qui se passe de manière (presque) automatique:

  • Rechercher la position du niveau dans le monde grâce au fichier spécial
  • Bouger l’intégralité du niveau vers sa position finale dans le monde (en tenant compte des conventions de coordonnées)
  • Transformer les objets Blender en objets intelligents/scriptés dans Unity.
  • Créer un NavMesh en utilisant Unity ou RAIN
  • Convertir le NavMesh Unity/RAIN en NavMesh A*
  • Utiliser ce fichier dans le gestionnaire de pathfinding A*
  • Convertir le NavMesh A* en fichier texte
  • Désactiver tous les objets de la scène pour ne pas qu’ils s’initialisent tous en même temps après un chargement dynamique.

… Et c’est à peu près tout… Pour la partie « édition de niveau » que je fais dans Unity.

En revanche, il se passe encore plein de choses lors de l’exécution du jeu:

  • Si on rentre dans une zone de chargement, le niveau est chargé dynamiquement, mais rien n’est encore activé. Seuls sont visibles les plus gros objets qui constituent la forme principale de l’île.
  • Si on se rapproche, le fichier texte du NavMesh est chargé, et l’activation progressive démarre ensuite, donnant vie à tout le niveau en quelques secondes.
  • Quand on quitte une zone d’activation, tous les objets sont désactivés, mais le NavMesh est gardé en mémoire (au cas où on reviendrait).
  • Quand on quitte une zone de chargement, la totalité du niveau est détruit.

J’ai du énormément réfléchir sur les distances de chargement et d’activation, car je ne veux pas commencer à activer les niveaux si on est trop loin, mais je veux quand même qu’on puisse les voir d’une certaine distance. Je dois également faire attention à la distance qui sépare les îles: si 2 zones d’activation se recouvrent, plusieurs niveaux pourraient être chargés et activés en même temps, ce qui risque de sérieusement ralentir le jeu. En bref, c’est une question d’équilibre.

Enfin! Nous y sommes arrivés! Ca a été une expédition bien difficile au milieu de problèmes complexes, mais maintenant le jeu propose un monde ouvert et tourne en moyenne à 50 fps avec des chutes à 20 fps lors de chargements. Maintenant vous savez pourquoi il y a aussi peu de changements entre 2 versions consécutives du jeu!

Pour les gens courageux qui seraient arrivés jusqu’ici, voilà une vidéo du chargement dynamique des niveaux. J’utilise un super grappin pour me déplacer d’une îles à l’autre, mais ça demande une visée assez précise. Et vous pourrez remarquer que la distance entre les îles est peut-être un peu grande.

 

Pour éviter que les îles apparaissent subitement lorsque le joueur entre dans une zone de chargement, j’ai ajouté du brouillard (un truc assez répandu dans les jeux vidéo).

IX – Encore plus de problèmes!

Pour éviter de tout rendre encore plus compliqué, j’ai évité de parler de certains problèmes additionnels. Mais si certaines personnes veulent mettre en place le même procédé, vous devez savoir quelques trucs:

  • La librairie A* semble avoir une sorte de cache pour les NavMesh, et il faut parfois « rebaker/rescanner » un NavMesh après l’avoir chargé. Mais c’est très très lourd comme opération! Après plusieurs tests, on dirait que cela se produit uniquement dans l’éditeur, mais pas avec un exécutable exporté.
  • LoadLevelAdditiveAsync n’est absolument pas asynchrone dans l’éditeur. Ca fige le jeu. Il faut faire un export du jeu pour que ce soit réellement temps réel.
  • Charger plusieurs niveaux simultanément a totalement altéré le chargement automatique de séquences vidéos/dialogues ou autre qui était déjà en place. J’ai du corriger tout ce qui se déclenchait alors que j’étais très loin des îles en question.
  • Maintenant que je charge des niveaux dynamiquement, je dois enregistrer toutes les actions du joueur de manière dynamique également: si le joueur prend un item et ouvre une porte, je dois garder une trace de ces événements si le niveau est déchargé avant que le joueur ne sauve la partie.
  • Quelques objets ne supportent pas l’activation dynamique: les vêtements/tissus (« clothes » dans Unity). Ca ruine totalement la simulation physique dans le meilleur des cas, et dans le pire, ça crashe totalement le jeu. J’ai trouvé une alternative qui consiste à désactiver uniquement le composant « Clothe » et non pas l’objet, ce qui fait que mon code est très moche.

 

J’ai utilisé la simulation de vêtement pour rajouter de grands drapeaux au dessus de la Guilde des Voleurs (le seul changement graphique en 2 semaines…)

Voilà! Ce sera tout pour le problème délirant du moment.

A+

Paix!

Subscribe by email to get all the news!

Support me on Patreon

2 pensées sur “World of Thieves aura un monde ouvert”

  1. Hi there! Thank you very much for documenting this issue in such minute details. I'm going through the same problem while developing Ghost of a Tale. The additive level loading screwing up with Navmesh agents is a very big limitation.

    So far I'm trying to make do with the "solution" of setting some game objects (a sub-scene) to not be destroyed while loading a level in a non-additive way. But as you pointed out there are complications linked to that. The freeze is indeed quite visible (a quarter of a second) and it requires a careful management of the hierarchy for when a navmesh is deemed to be forgettable.

    Anyway, thanks again for the in-depth description of your process…

    Cheers,
    Seith

  2. Hi! Thanks a lot for your comment :) I'm so glad this long article can be useful to somebody!

    I hope you'll make out a solution for your game, which looks really amazing by the way! Are you alone developping it?
    By the way, if you find out how to get rid of this micro-freeze using "LoadLevelAsync", I'd be glad to hear about it :)

    For my game, I finally chose to use the "LoadLevelAdditiveAsync" with navigation from Aaron Granberg's Pathfinding library. I finally bought the pro version to get access to the NavMesh generation, and it works well. But it took me quite some time to understand some of the parameters…

    Anyways, good luck with your really cool project!
    Cheers

    Mat.

    PS: Je viens de voir qu'on aurait pu parler français en fait…

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *