Déboguer les LMK

Pour résoudre les problèmes connus et limites dans votre jeu Unity, vous devez suivre une procédure systématique :

Figure 1. Étapes à suivre pour résoudre les problèmes de mémoire insuffisante (LMK) dans les jeux Unity.

Obtenir un instantané de la mémoire

Utilisez le Profileur Unity pour obtenir un instantané de la mémoire gérée par Unity. La figure 2 montre les couches de gestion de la mémoire utilisées par Unity pour gérer la mémoire dans votre jeu.

Figure 2. Présentation de la gestion de la mémoire dans Unity.

Mémoire gérée

La gestion de la mémoire d'Unity implémente une couche de mémoire contrôlée qui utilise un tas géré et un ramasse-miettes pour allouer et attribuer de la mémoire automatiquement. Le système de mémoire gérée est un environnement de script C# basé sur Mono ou IL2CPP. L'avantage du système de mémoire gérée est qu'il utilise un récupérateur de mémoire pour libérer automatiquement les allocations de mémoire.

Mémoire non gérée C#

La couche de mémoire C# non gérée permet d'accéder à la couche de mémoire native, ce qui permet de contrôler précisément les allocations de mémoire lors de l'utilisation du code C#. Cette couche de gestion de la mémoire est accessible via l'espace de noms Unity.Collections et par des fonctions telles que UnsafeUtility.Malloc et UnsafeUtility.Free.

Mémoire native

Le cœur interne C/C++ d'Unity utilise un système de mémoire native pour gérer les scènes, les éléments, les API graphiques, les pilotes, les sous-systèmes et les tampons de plug-in. Bien que l'accès direct soit limité, vous pouvez manipuler les données en toute sécurité avec l'API C# d'Unity et bénéficier d'un code natif efficace. La mémoire native nécessite rarement une interaction directe, mais vous pouvez surveiller son impact sur les performances à l'aide du Profileur et ajuster les paramètres pour optimiser les performances.

La mémoire n'est pas partagée entre le code C# et le code natif, comme illustré dans la figure 3. Les données requises par C# sont allouées dans l'espace de mémoire géré chaque fois qu'elles sont nécessaires.

Pour que le code du jeu géré (C#) puisse accéder aux données de mémoire native du moteur, par exemple, un appel à GameObject.transform effectue un appel natif pour accéder aux données de mémoire dans la zone native, puis renvoie des valeurs à C# à l'aide de Bindings. Les liaisons garantissent des conventions d'appel appropriées pour chaque plate-forme et gèrent le marshaling automatique des types gérés dans leurs équivalents natifs.

Cela ne se produit que la première fois, car le shell géré pour accéder à la propriété transform est conservé dans le code natif. La mise en cache de la propriété de transformation peut réduire le nombre d'appels aller-retour entre le code géré et le code natif, mais l'utilité de la mise en cache dépend de la fréquence d'utilisation de la propriété. Notez également qu'Unity ne copie pas de parties de la mémoire native dans la mémoire gérée lorsque vous accédez à ces API.

Figure 3. Accès à la mémoire native à partir du code C# géré.

Pour en savoir plus, consultez Introduction à la mémoire dans Unity.

De plus, il est essentiel d'établir un budget de mémoire pour que votre jeu fonctionne correctement. L'implémentation d'un système d'analyse ou de reporting de la consommation de mémoire permet de s'assurer que chaque nouvelle version ne dépasse pas le budget de mémoire. Une autre stratégie pour mieux comprendre la consommation de mémoire consiste à intégrer les tests du mode Play à votre intégration continue (CI) afin de vérifier la consommation de mémoire dans des zones spécifiques du jeu.

Gérer les éléments

Il s'agit de la partie la plus impactante et la plus exploitable de la consommation de mémoire. Profilez dès que possible.

L'utilisation de la mémoire dans les jeux Android peut varier considérablement en fonction du type de jeu, du nombre et des types d'éléments, ainsi que des stratégies d'optimisation de la mémoire. Toutefois, les contributeurs courants à l'utilisation de la mémoire incluent généralement les textures, les maillages, les fichiers audio, les nuanceurs, les animations et les scripts.

Détecter les composants en double

La première étape consiste à détecter les assets mal configurés et les assets dupliqués à l'aide du profileur de mémoire, d'un outil de création de rapports ou de l'outil d'audit de projet.

Textures

Analysez la compatibilité de votre jeu avec les appareils et choisissez le format de texture approprié. Vous pouvez diviser les groupes de textures pour les appareils haut de gamme et bas de gamme à l'aide de Play Asset Delivery, Addressable ou d'un processus plus manuel avec AssetBundle.

Suivez les recommandations les plus connues disponibles dans Optimize Your Mobile Game Performance et dans le post de discussion Optimising Unity Texture Import Settings. Ensuite, essayez les solutions suivantes :

  • Compressez les textures avec les formats ASTC pour réduire l'empreinte mémoire et testez un taux de bloc plus élevé, comme 8x8.

    Si vous devez utiliser ETC2, compressez vos textures dans Atlas. Placer plusieurs textures dans une seule texture garantit que sa taille est une puissance de deux (POT, Power of Two), ce qui peut réduire les appels de dessin et accélérer le rendu.

  • Optimisez le format et la taille de la texture RenderTarget. Évitez les textures inutilement haute résolution. L'utilisation de textures plus petites sur les appareils mobiles permet d'économiser de la mémoire.

  • Utilisez l'empaquetage des canaux de texture pour économiser de la mémoire de texture.

Maillages et modèles

Commencez par vérifier les paramètres de base (page 27), puis vérifiez les paramètres d'importation de maillage suivants :

  • Fusionnez les maillages redondants et plus petits.
  • Réduisez le nombre de sommets des objets dans les scènes (par exemple, les objets statiques ou éloignés).
  • Générez des groupes de niveau de détail (LOD) pour les composants à géométrie élevée.

Matériaux et nuanceurs

  • Supprimez les variantes de nuanceur inutilisées de manière programmatique lors du processus de compilation.
  • Regroupez les variantes de nuanceur fréquemment utilisées dans des nuanceurs universels pour éviter la duplication des nuanceurs.
  • Activez le chargement dynamique des nuanceurs pour résoudre le problème de l'empreinte mémoire importante des nuanceurs préchargés dans la VRAM/RAM. Toutefois, faites attention si la compilation des nuanceurs provoque des saccades.
  • Utilisez le chargement dynamique des nuanceurs pour empêcher le chargement de toutes les variantes. Pour en savoir plus, consultez l'article de blog Améliorations des temps de compilation des nuanceurs et de l'utilisation de la mémoire.
  • Utilisez correctement l'instanciation de matériaux en tirant parti de MaterialPropertyBlocks.

Audio

Commencez par vérifier les paramètres fondamentaux (page 41), puis vérifiez les paramètres d'importation de maillage suivants :

  • Supprimez les références AudioClip inutilisées ou redondantes lorsque vous utilisez des moteurs audio tiers tels que FMOD ou Wwise.
  • Préchargez les données audio. Désactivez le préchargement des clips qui ne sont pas immédiatement requis lors de l'exécution ou du démarrage de la scène. Cela permet de réduire la surcharge de mémoire lors de l'initialisation de la scène.

Animations

  • Ajustez les paramètres de compression des animations d'Unity pour minimiser le nombre d'images clés et éliminer les données redondantes.
    • Réduction des images clés : supprime automatiquement les images clés inutiles
    • Compression des quaternions : compresse les données de rotation pour réduire l'utilisation de la mémoire

Vous pouvez ajuster les paramètres de compression dans les paramètres d'importation d'animation sous l'onglet Rig (Plate-forme) ou Animation.

  • Réutilisez les extraits d'animation au lieu de les dupliquer pour différents objets.

    Utilisez des Animator Override Controllers pour réutiliser un AnimatorController et remplacer des clips spécifiques pour différents personnages.

  • Faites cuire les animations basées sur la physique : si vos animations sont basées sur la physique ou procédurales, faites-les cuire dans des clips d'animation pour éviter les calculs d'exécution.

  • Optimisez le squelette : utilisez moins d'os dans votre squelette pour réduire la complexité et la consommation de mémoire.

    • Évitez d'utiliser trop d'os pour les petits objets ou les objets statiques.
    • Si certains os ne sont pas animés ou nécessaires, supprimez-les du squelette.
  • Réduisez la durée du clip d'animation.

    • Coupez les extraits d'animation pour n'inclure que les images nécessaires. Évitez de stocker des animations inutilisées ou trop longues.
    • Utilisez des animations en boucle au lieu de créer de longs extraits pour les mouvements répétés.
  • Assurez-vous qu'un seul composant d'animation est associé ou activé. Par exemple, désactivez ou supprimez les composants Legacy animation si vous utilisez Animator.

  • Évitez d'utiliser l'Animator si ce n'est pas nécessaire. Pour les effets spéciaux simples, utilisez des bibliothèques de tweening ou implémentez l'effet visuel dans un script. Le système d'animation peut être gourmand en ressources, en particulier sur les appareils mobiles bas de gamme.

  • Utilisez le système de tâches pour les animations lorsque vous gérez un grand nombre d'animations, car ce système a été entièrement repensé pour être plus économe en mémoire.

Scènes

Lorsque de nouvelles scènes sont chargées, elles importent des éléments en tant que dépendances. Toutefois, sans une gestion du cycle de vie des composants appropriée, ces dépendances ne sont pas surveillées par les compteurs de référence. Par conséquent, les éléments peuvent rester en mémoire même après le déchargement des scènes inutilisées, ce qui entraîne une fragmentation de la mémoire.

  • Utilisez le pooling d'objets d'Unity pour réutiliser les instances GameObject pour les éléments de gameplay récurrents, car le pooling d'objets utilise une pile pour contenir une collection d'instances d'objets à réutiliser et n'est pas thread-safe. La réduction des Instantiate et des Destroy améliore les performances du processeur et la stabilité de la mémoire.
  • Déchargement des éléments :
    • Déchargez les éléments de manière stratégique pendant les moments moins critiques, comme les écrans de démarrage ou de chargement.
    • L'utilisation fréquente de Resources.UnloadUnusedAssets entraîne des pics de traitement du processeur en raison des opérations de surveillance des dépendances internes volumineuses.
    • Recherchez les pics importants d'utilisation du processeur dans le repère de profil GC.MarkDependencies. Supprimez ou réduisez sa fréquence d'exécution, et déchargez manuellement des ressources spécifiques à l'aide de Resources.UnloadAsset au lieu de vous fier à la fonction globale Resources.UnloadUnusedAssets().
  • Restructurez les scènes au lieu d'utiliser constamment Resources.UnloadUnusedAssets.
  • L'appel de Resources.UnloadUnusedAssets() pour Addressables peut décharger involontairement les bundles chargés dynamiquement. Gérez soigneusement le cycle de vie des assets chargés de manière dynamique.

Divers

  • Fragmentation causée par les transitions de scène : lorsque la méthode Resources.UnloadUnusedAssets() est appelée, Unity effectue les opérations suivantes :

    • Libère de la mémoire pour les composants qui ne sont plus utilisés
    • Exécute une opération de type garbage collector pour vérifier le tas d'objets gérés et natifs à la recherche d'éléments inutilisés et les décharger.
    • Nettoie la mémoire de texture, de maillage et d'éléments à condition qu'aucune référence active n'existe
  • AssetBundle ou Addressable : les modifications dans ce domaine sont complexes et nécessitent un effort collectif de l'équipe pour mettre en œuvre les stratégies. Toutefois, une fois ces stratégies maîtrisées, elles améliorent considérablement l'utilisation de la mémoire, réduisent la taille du téléchargement et diminuent les coûts liés au cloud. Pour en savoir plus sur la gestion des éléments dans Unity avec Addressables, consultez la documentation.

  • Dépendances partagées centralisées : regroupez systématiquement les dépendances partagées, telles que les nuanceurs, les textures et les polices, dans des bundles ou des groupes Addressable dédiés. Cela réduit la duplication et garantit que les éléments inutiles sont déchargés efficacement.

  • Utilisez Addressables pour le suivi des dépendances : Addressables simplifie le chargement et le déchargement, et peut décharger automatiquement les dépendances qui ne sont plus référencées. Passer à Addressables pour la gestion du contenu et la résolution des dépendances peut être une solution viable, selon le cas spécifique du jeu. Analysez les chaînes de dépendances avec l'outil "Analyser" pour identifier les doublons ou les dépendances inutiles. Si vous utilisez des AssetBundles, vous pouvez également consulter les outils de données Unity.

  • TypeTrees : si les Addressables et AssetBundles de votre jeu sont créés et déployés à l'aide de la même version d'Unity que le lecteur, et ne nécessitent pas de rétrocompatibilité avec d'autres versions du lecteur, envisagez de désactiver l'écriture de TypeTree, ce qui devrait réduire la taille du bundle et l'empreinte mémoire des objets de fichier sérialisés. Modifiez le processus de compilation dans le paramètre de package Addressables local ContentBuildFlags sur DisableWriteTypeTree.

Écrire du code compatible avec le récupérateur de mémoire

Unity utilise la récupération de mémoire pour gérer la mémoire en identifiant et en libérant automatiquement la mémoire inutilisée. Bien que le GC soit essentiel, il peut entraîner des problèmes de performances (par exemple, des pics de fréquence d'images) s'il n'est pas géré correctement, car ce processus peut mettre le jeu en pause momentanément, ce qui entraîne des problèmes de performances et une expérience utilisateur sous-optimale.

Consultez le manuel Unity pour découvrir des techniques utiles permettant de réduire la fréquence des allocations de tas géré. Vous trouverez des exemples à la page 271 de UnityPerformanceTuningBible.

  • Réduisez les allocations du collecteur de déchets :

    • Évitez LINQ, les lambdas et les fermetures, qui allouent de la mémoire de tas.
    • Utilisez StringBuilder pour les chaînes modifiables au lieu de la concaténation de chaînes.
    • Réutilisez les collections en appelant COLLECTIONS.Clear() au lieu de les réinstancier.

    Pour en savoir plus, consultez l'e-book Ultimate Guide to Profiling Unity games (Guide de profilage des jeux Unity).

  • Gérer les mises à jour du canevas de l'UI :

    • Modifications dynamiques des éléments d'UI : lorsque des éléments d'UI tels que les propriétés Texte, Image ou RectTransform sont mis à jour (par exemple, en modifiant le contenu du texte, en redimensionnant les éléments ou en animant les positions), le moteur peut allouer de la mémoire pour les objets temporaires.
    • Allocations de chaînes : les éléments d'UI tels que "Text" nécessitent souvent des mises à jour de chaînes, car les chaînes sont immuables dans la plupart des langages de programmation.
    • Canevas sale : lorsqu'un élément d'un canevas est modifié (par exemple, en étant redimensionné, activé ou désactivé, ou en modifiant les propriétés de mise en page), l'intégralité du canevas ou une partie de celui-ci peut être marquée comme sale et être reconstruite. Cela peut déclencher la création de structures de données temporaires (par exemple, des données de maillage, des tampons de vertex ou des calculs de mise en page), ce qui augmente la génération de déchets.
    • Mises à jour complexes ou fréquentes : si le canevas comporte un grand nombre d'éléments ou est mis à jour fréquemment (par exemple, à chaque frame), ces reconstructions peuvent entraîner un brassage de mémoire important.
  • Activez le GC incrémentiel pour réduire les pics de collecte importants en répartissant les nettoyages d'allocation sur plusieurs frames. Effectuez un profilage pour vérifier si cette option améliore les performances et l'empreinte mémoire de votre jeu.

  • Si votre jeu nécessite une approche contrôlée, définissez le mode de collecte des déchets sur manuel. Ensuite, lors d'un changement de niveau ou à un autre moment sans gameplay actif, appelez le garbage collection.

  • Appelez manuellement la récupération de mémoire GC.Collect() pour les transitions d'état du jeu (par exemple, le changement de niveau).

  • Optimisez les tableaux en commençant par des pratiques de codage simples et, si nécessaire, en utilisant des tableaux natifs ou d'autres conteneurs natifs pour les grands tableaux.

  • Surveillez les objets gérés à l'aide d'outils tels que le profileur de mémoire Unity pour suivre les références d'objets non gérés qui persistent après la destruction.

    Utilisez un repère Profiler pour envoyer des données à l'outil de création de rapports sur les performances de manière automatisée.

Éviter les fuites de mémoire et la fragmentation

Fuites de mémoire

Dans le code C#, lorsqu'une référence à un objet Unity existe après la destruction de l'objet, l'objet wrapper géré, appelé Managed Shell, reste en mémoire. La mémoire native associée à la référence est libérée lorsque la scène est déchargée ou lorsque le GameObject auquel la mémoire est associée, ou l'un de ses objets parents, sont détruits à l'aide de la méthode Destroy(). Toutefois, si d'autres références à la scène ou au GameObject n'ont pas été effacées, la mémoire gérée peut persister en tant qu'objet Shell divulgué. Pour en savoir plus sur les objets Managed Shell, consultez le manuel Objets Managed Shell.

De plus, des fuites de mémoire peuvent être causées par des abonnements à des événements, des lambdas et des fermetures, des concaténations de chaînes et une gestion incorrecte des objets mis en pool :

  • Pour commencer, consultez Détecter les fuites de mémoire pour comparer correctement les instantanés de mémoire Unity.
  • Recherchez les abonnements aux événements et les fuites de mémoire. Si des objets s'abonnent à des événements (par exemple, par des délégués ou des UnityEvents), mais ne se désabonnent pas correctement avant d'être détruits, le gestionnaire ou l'éditeur d'événements peuvent conserver des références à ces objets. Cela empêche la récupération de mémoire de ces objets, ce qui entraîne des fuites de mémoire.
  • Surveillez les événements de classe globale ou singleton qui ne sont pas désenregistrés lors de la destruction de l'objet. Par exemple, désabonnez-vous ou supprimez les délégués dans les destructeurs d'objets.
  • Assurez-vous que la destruction des objets mis en pool annule complètement les références aux composants de maillage de texte, aux textures et aux GameObjects parents.
  • N'oubliez pas que lorsque vous comparez des instantanés du profileur de mémoire Unity et que vous constatez une différence de consommation de mémoire sans raison apparente, cette différence peut être due au pilote graphique ou au système d'exploitation lui-même.

Fragmentation de la mémoire

La fragmentation de la mémoire se produit lorsque de nombreuses petites allocations sont libérées dans un ordre aléatoire. Les allocations de tas sont effectuées de manière séquentielle, ce qui signifie que de nouveaux blocs de mémoire sont créés lorsque le bloc précédent manque d'espace. Par conséquent, les nouveaux objets ne remplissent pas les zones vides des anciens blocs, ce qui entraîne une fragmentation. De plus, les grandes allocations temporaires peuvent entraîner une fragmentation permanente pendant la durée de la session d'un jeu.

Ce problème est particulièrement problématique lorsque des allocations volumineuses de courte durée sont effectuées à proximité d'allocations de longue durée.

Regroupez les allocations en fonction de leur durée de vie. Idéalement, les allocations de longue durée doivent être effectuées ensemble, tôt dans le cycle de vie de l'application.

Observateurs et responsables d'événements

  • En plus du problème mentionné dans la section (Fuites de mémoire)77, les fuites de mémoire peuvent, au fil du temps, contribuer à la fragmentation en laissant de la mémoire inutilisée allouée à des objets qui ne sont plus utilisés.
  • Assurez-vous que la destruction des objets mis en commun annule complètement les références aux composants de maillage de texte, aux textures et au GameObjects parent.
  • Les responsables d'événements créent et stockent souvent des listes ou des dictionnaires pour gérer les abonnements aux événements. Si ces éléments augmentent et diminuent de manière dynamique lors de l'exécution, ils peuvent contribuer à la fragmentation de la mémoire en raison des allocations et des désallocations fréquentes.

Code

  • Les coroutines allouent parfois de la mémoire, ce qui peut être facilement évité en mettant en cache l'instruction de retour de l'IEnumerator au lieu d'en déclarer une nouvelle à chaque fois.
  • Surveillez en permanence les états du cycle de vie des objets mis en pool pour éviter de conserver des références fantômes UnityEngine.Object.

Éléments

  • Utilisez des systèmes de secours dynamiques pour les expériences de jeu basées sur du texte afin d'éviter de précharger toutes les polices pour les cas multilingues.
  • Organisez les composants (textures et particules, par exemple) par type et par cycle de vie prévu.
  • Compressez les composants avec des attributs de cycle de vie inactifs, comme les images d'interface utilisateur redondantes et les maillages statiques.

Allocations basées sur la durée de vie

  • Allouez les ressources de longue durée au début du cycle de vie de l'application pour garantir des allocations compactes.
  • Utilisez NativeCollections ou des allocateurs personnalisés pour les structures de données transitoires ou nécessitant beaucoup de mémoire (par exemple, les clusters physiques).

L'exécutable du jeu et les plug-ins ont également une incidence sur l'utilisation de la mémoire.

Métadonnées IL2CPP

IL2CPP génère des métadonnées pour chaque type (par exemple, classes, génériques et délégués) au moment de la compilation, qui sont ensuite utilisées au moment de l'exécution pour la réflexion, la vérification des types et d'autres opérations spécifiques à l'exécution. Ces métadonnées sont stockées en mémoire et peuvent contribuer de manière significative à l'empreinte mémoire totale de l'application. Le cache de métadonnées d'IL2CPP contribue de manière significative aux temps d'initialisation et de chargement. De plus, IL2CPP ne déduplique pas certains éléments de métadonnées (par exemple, les types génériques ou les informations sérialisées), ce qui peut entraîner une utilisation excessive de la mémoire. Cela est exacerbé par l'utilisation répétitive ou redondante de types dans le projet.

Vous pouvez réduire les métadonnées IL2CPP en :

  • Évitez d'utiliser les API de réflexion, car elles peuvent contribuer de manière significative aux allocations de métadonnées IL2CPP.
  • Désactiver les packages intégrés
  • Implémentation du partage générique complet Unity 2022, qui devrait contribuer à réduire la surcharge causée par les génériques. Toutefois, pour réduire encore davantage les allocations, limitez l'utilisation de génériques.

Suppression de code

En plus de réduire la taille du build, la suppression du code diminue également l'utilisation de la mémoire. Lorsque vous compilez avec le backend de script IL2CPP, la suppression de bytecode géré (activée par défaut) supprime le code inutilisé des assemblys gérés. Le processus fonctionne en définissant des assemblys racines, puis en utilisant l'analyse de code statique pour déterminer quel autre code géré ces assemblys racines utilisent. Tout code inaccessible est supprimé. Pour en savoir plus sur la suppression de code géré, consultez l'article de blog TTales from the optimization trenches: Better managed code stripping with Unity 2020 LTS et la documentation Suppression de code géré.

Allocateurs natifs

Expérimentez avec les allocateurs de mémoire natifs pour affiner les allocateurs de mémoire. Si le jeu manque de mémoire, utilisez des blocs de mémoire plus petits, même si cela implique des allocateurs plus lents. Pour en savoir plus, consultez l'exemple d'allocateur de tas dynamique.

Gérer les plug-ins et SDK natifs

  • Identifiez le plug-in problématique : supprimez chaque plug-in et comparez les instantanés de mémoire du jeu. Cela implique de désactiver de nombreuses fonctionnalités de code avec Scripting > Define Symbols et de refactoriser les classes fortement couplées avec des interfaces. Consultez Améliorer votre code avec des modèles de programmation de jeux pour faciliter le processus de désactivation des dépendances externes sans rendre votre jeu injouable.

  • Contactez l'auteur du plug-in ou du SDK : la plupart des plug-ins ne sont pas Open Source.

  • Reproduisez l'utilisation de la mémoire du plug-in : vous pouvez écrire un plug-in simple (utilisez ce plug-in Unity comme référence) qui effectue des allocations de mémoire. Inspectez les instantanés mémoire à l'aide d'Android Studio (car Unity ne suit pas ces allocations) ou appelez la classe MemoryInfo et la méthode Runtime.totalMemory() dans le même projet.

Un plug-in Unity alloue de la mémoire Java et native. Voici comment procéder :

Java

byte[] largeObject = new byte[1024 * 1024 * megaBytes];
list.add(largeObject);

Natif

char* buffer = new char[megabytes * 1024 * 1024];

// Random data to fill the buffer
for (int i = 1; i < megabytes * 1024 * 1024; ++i) {
   buffer[i] = 'A' + (i % 26); // Fill with letters A-Z
}