Stratégies multi-jeux

Lors des revues de projet, en tant que consultant pour l'équipe Customer Success, je travaille souvent avec des clients qui créent des applications qui changent la donne. Ces applications disposent d'un menu principal ou d'un menu thématique, présentant plusieurs choix de jeux au joueur. Dans ces configurations, les principales préoccupations sont de savoir comment faire en sorte que le délai entre deux jeux soit le plus court possible et comment garantir des performances optimales pour tous les jeux. Dans cet article de blog, nous explorerons différentes approches basées sur les besoins du projet ainsi que quelques bonnes pratiques qui peuvent être utiles pour n'importe quel environnement de jeu, avec ou sans configuration de commutation de jeu.
Lors de la planification d'un environnement multi-applications - qu'il s'agisse de jeux, de divertissements ou de simulations industrielles - la décision la plus importante à prendre est de savoir comment gérer les exécutables de jeux. De nombreux facteurs peuvent influencer cette décision :
- Combien de jeux la plateforme pourra-t-elle prendre en charge ?
- Quelle est la taille des jeux ?
- Les jeux sont-ils réalisés avec les mêmes versions d'Unity ? Quels sont les goulets d'étranglement de l'application ?
- Les autres facteurs sont le matériel cible, la mémoire et le processeur, ainsi que la vitesse du disque (SSD vs HDD vs SD Card).
Il est essentiel de répondre à ces questions et de décider comment gérer les exécutables pour savoir si nous avons besoin d'exécutables distincts pour chaque jeu, d'un exécutable partagé pour plusieurs jeux ou d'une combinaison des deux pour garantir que les applications fonctionnent de manière optimale.
Disposer de plusieurs exécutables est une excellente option pour gérer les jeux réalisés avec différentes versions d'Unity. Cette approche permet de réduire le temps nécessaire pour passer d'un jeu à l'autre en mettant en cache l'exécutable dans la mémoire et en laissant chaque instance en arrière-plan. Cependant, conserver tous les exécutables dans la mémoire n'est pas toujours le meilleur choix, car cela peut solliciter la mémoire. Elle doit être évitée dans les cas où les jeux individuels ont une empreinte mémoire plus importante, et/ou lorsqu'il y a de nombreux jeux dans l'application de changement de jeu.
Pour alléger les contraintes de mémoire, il est possible que les jeux partagent un seul exécutable. Les jeux peuvent se trouver dans un seul projet Unity, ou avoir chacun leur propre projet, tant qu'ils partagent la même version d'Unity. Depuis Unity 2022 LTS sous Windows, il est possible d'utiliser l'argument -datafolder pour passer un chemin variable via la ligne de commande ( -datafolder <path_to_folder> ), spécifiant le dossier de données des jeux sélectionnés afin de changer. L'un des inconvénients potentiels de cette approche est la lenteur du changement de jeu ; il est donc important de suivre les meilleures pratiques de chargement pour réduire cet inconvénient.
Quelle que soit la nature du jeu que nous développons ou la plateforme sur laquelle il est développé, il est important de passer le moins de temps possible entre le moment où le jeu est sélectionné et celui où il est entièrement chargé sur l'écran. Cet objectif est particulièrement important pour les applications de commutation de jeux.
L'utilisation d'Addressables est un excellent moyen de gérer le chargement. Avec Addressables, les contenus sont téléchargés et libérés en fonction des besoins. Cette stratégie de chargement différé est le moyen le plus efficace de réduire les temps de chargement des jeux, car elle limite la quantité de données qui doivent être chargées lors du démarrage initial. En outre, il permet d'éviter les activités de fond du processeur liées aux jeux en arrière-plan, qui peuvent contribuer aux goulets d'étranglement du processeur. Addressables : Planification et meilleures pratiques article de blog est un excellent point de départ pour en savoir plus sur les Addressables et sur la façon dont ils peuvent contribuer à améliorer votre jeu.
Les API de chargement asynchrone constituent un excellent moyen de garantir un chargement plus rapide, quel que soit le nombre d'exécutables utilisés. Lors d'un chargement asynchrone, le thread principal d'Unity exécutera un processus appelé "intégration du thread principal" qui est responsable de l'initialisation des objets natifs et gérés de manière découpée dans le temps. Étant donné que ce processus effectue certaines opérations qui ne sont pas sûres pour les threads, il se déroulera sur le thread principal, et le temps d'exécution de l'intégration du thread principal est limité afin d'éviter que le jeu ne se fige pendant une longue période. Le temps qui peut être consacré aux intégrations est défini par la propriété Application.backgroundLoadingPriority. Nous vous recommandons de régler la priorité de chargement en arrière-plan sur " High" (50 ms) pendant les écrans de chargement, puis de la ramener sur " BelowNormal" (4 ms) ou " Low" (2 ms) une fois le chargement terminé.
Un autre moyen d'accélérer le chargement est le téléchargement asynchrone de textures. Le chargement de texture asynchrone peut réduire le temps de chargement en coordonnant le temps et la mémoire utilisés pour télécharger les textures et les maillages vers le GPU. L'article de blog Understanding Async Upload Pipeline fournit des informations détaillées sur le fonctionnement de ce processus.
Ces pratiques permettront d'accélérer les temps de chargement :
- Réduisez autant que possible le contenu de votre scène. Utilisez une scène d'amorçage pour charger uniquement ce qui est nécessaire pour que le jeu soit jouable, puis chargez des scènes supplémentaires lorsque c'est nécessaire.
- Désactiver les caméras pendant les écrans de chargement.
- Désactiver les toiles d'interface utilisateur pendant qu'elles sont remplies au cours du chargement.
- Paralléliser les demandes de réseau.
- Évitez les implémentations complexes de type Awake/Start et utilisez des threads de travail.
- Utilisez toujours la compression de texture.
- Diffusez en continu des fichiers multimédias volumineux (tels que des fichiers audio et des textures) au lieu de les conserver en mémoire.
- Évitez le sérialiseur JSON et utilisez plutôt des sérialiseurs binaires.
Comme nous l'avons déjà mentionné, la mémoire n'est pas la seule préoccupation des environnements multi-jeux, l'activité de l'unité centrale en arrière-plan peut également nuire à l'expérience de jeu du joueur. Lorsque les jeux ne sont pas activement joués, leur processeur est toujours en marche, ce qui entraîne des performances sous-optimales du jeu actif en créant une famine du processeur. Pour éviter que le jeu actif et les autres processus de la plateforme de backend ne s'arrêtent, réglez le lecteur Run in Background sur false (faux ) dans les paramètres d'Unity. Run in Background (Exécuter en arrière-plan) permet d'arrêter la boucle du jeu Unity lorsque le jeu n'est pas en cours d'exécution. Les paramètres peuvent également être modifiés de manière dynamique par le biais d'un script.
public class ExampleClass : MonoBehaviour
{
void Example()
{
Application.runInBackground = false;
}
}
Il est donc important de mettre en veille tous les threads des jeux qui ne jouent pas via la méthode C# Thread.Sleep. N'oubliez pas que travailler avec des threads d'arrière-plan dans Unity nécessite une programmation minutieuse. Comme ces threads n'ont pas d'accès direct à l'API d'Unity, le risque de créer des problèmes, tels que des blocages et des conditions de course, est plus élevé. Pour éviter cela, il faut une synchronisation correcte avec le fil principal d'Unity. Pour mettre en œuvre correctement le multithreading, consultez la section Limites des tâches asynchrones et attendues de la page de manuel Vue d'ensemble de .NET dans Unity et l'article MSDN sur l'utilisation des threads et du threading. Unity 6 introduit la classe Awaitable qui offre un meilleur support pour async/await.
Il peut être difficile et fastidieux d'identifier et de corriger les causes des fuites de mémoire, en particulier dans les dernières phases de développement. Aussi cliché que cela puisse paraître, la prévention vaut toujours mieux que la guérison. Voici quelques recommandations qui peuvent aider à prévenir les fuites dans n'importe quel environnement de jeu :
- Lorsque vous créez de nouveaux objets/actifs en mémoire, veillez à les supprimer lorsque vous n'en avez pas besoin. Si vous utilisez Addressables, veillez à libérer les actifs inutilisés.
- Lors du chargement/déchargement de scènes, les éléments doivent être correctement supprimés de la mémoire. Unity ne décharge pas automatiquement les assets lorsqu'un niveau est déchargé, il est donc important de s'assurer de supprimer tout accès à la mémoire. L'API Resources.UnloadUnusedAssets peut vous aider à nettoyer les actifs. Cependant, il peut provoquer des pics d'utilisation du processeur, car il renvoie un objet qui yield jusqu'à ce que l'opération soit terminée, c'est pourquoi il doit être utilisé dans des endroits non sensibles aux performances.
- Évitez d'utiliser fréquemment les fonctions Instantiate et Destroy GameObjects. Cela peut conduire à des allocations gérées inutiles, tout en étant une opération coûteuse pour l'unité centrale. Toutefois, dans les cas où l'utilisation de Destroy est nécessaire, veillez à supprimer toutes les références à l'objet afin d'éviter les fuites d'objets Shell. Lorsqu'un objet ou ses parents sont détruits via Destroy, un code C# conserve une référence à un Unity Object, en gardant en mémoire l'objet enveloppant géré - son Managed Shell. Sa mémoire native sera déchargée lorsque la scène dans laquelle il réside sera déchargée, ou lorsque le GameObject auquel il est attaché ou ses parents seront détruits par Destroy. Par conséquent, si un autre objet qui n'a pas été déchargé y fait encore référence, la mémoire gérée peut subsister sous la forme d'un objet Shell fuyant.
- Soyez vigilant lorsque vous mettez en œuvre des événements utilisant des singletons. Les instances Singleton contiennent des références à tous les objets qui se sont abonnés à leurs événements. Si ces objets ne vivent pas aussi longtemps que l'instance singleton et qu'ils ne se désabonnent pas de ces événements, ils resteront en mémoire, ce qui provoquera une fuite de mémoire. Si la source de l'événement est éliminée avant les récepteurs, la référence sera effacée, et si les récepteurs sont correctement désenregistrés, il n'y aura pas non plus de référence restante. Pour résoudre et prévenir ce problème, nous vous recommandons d'implémenter le modèle d'événement faible ou IDisposable dans tous les objets qui écoutent les événements singleton, et de vous assurer qu'ils sont correctement éliminés dans votre code. Le modèle d'événement faible est un modèle de conception qui vous aide à gérer la mémoire et le ramassage des ordures dans le cadre d'une programmation axée sur les événements, en particulier lorsqu'il s'agit d'objets à longue durée de vie. C'est particulièrement utile lorsque les abonnés sont éphémères, mais que l'éditeur l'est aussi. Gardez à l'esprit qu'il s'agit de solutions spécifiques à C# et qu'elles ne fonctionnent qu'avec des événements C# et ne sont pas directement prises en charge par UnityEvents ou le Unity UI Toolkit. Nous vous recommandons donc de ne mettre en œuvre ces solutions que dans vos scripts qui ne sont pas des MonoBehaviour.
Enfin, le profilage, la réalisation de tests CI/CD et de tests de résistance dès les premières phases de développement peuvent constituer un véritable gain de temps, car la détection des fuites dès leur apparition vous permettra de résoudre rapidement le problème, de gagner du temps lors du débogage et de garantir des performances optimales.