Déboguer les LMK

La résolution des LMK dans votre jeu Unity est un processus 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 de 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 dans Unity implémente une couche de mémoire contrôlée qui utilise un tas de mémoire géré et un récupérateur de mémoire pour allouer et attribuer automatiquement de la mémoire. 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 non gérée C# 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 de code C# . Vous pouvez accéder à cette couche de gestion de la mémoire via l' espace de noms Unity.Collections et 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é à la figure 3. Les données requises par C# sont allouées dans l'espace de mémoire gérée chaque fois qu'elles sont nécessaires.

Pour que le code du jeu géré (C#) accède 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 les valeurs à C# à l'aide Bindings. Les liaisons garantissent des conventions d'appel appropriées pour chaque plate-forme et gèrent le marshalling 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 les 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 géré C#.

Pour en savoir plus, consultez la présentation de 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 garantit que chaque nouvelle version ne dépasse pas le budget de mémoire. L'intégration de tests en mode Play Mode à votre intégration continue (CI) pour vérifier la consommation de mémoire dans des zones spécifiques du jeu est une autre stratégie pour obtenir de meilleures informations.

Gérer les éléments

Il s'agit de la partie la plus percutante et la plus exploitable de la consommation de mémoire. Effectuez le profilage 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 éléments en double

La première étape consiste à détecter les assets mal configurés et les assets en double à l'aide du Profileur de mémoire, d'un outil de rapport de compilation ou de l'auditeur de projet.

Textures

Analysez la compatibilité de votre jeu avec les appareils et choisissez le format de texture approprié. Vous pouvez diviser les bundles 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 un AssetBundle.

Suivez les recommandations les plus connues disponibles dans le Optimiser les performances de votre jeu mobile et dans le Optimiser les paramètres d'importation de textures Unity post de discussion. Essayez ensuite ces solutions :

  • Compressez les textures avec les formats ASTC pour réduire l'empreinte mémoire et expérimentez avec un taux de blocs plus élevé, par exemple 8x8.

    Si l'utilisation d'ETC2 est requise, compressez vos textures dans Atlas. Le fait de placer plusieurs textures dans une seule texture garantit sa puissance de deux (POT), 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 à résolution inutilement élevée. L'utilisation de textures plus petites sur les appareils mobiles permet d'économiser de la mémoire.

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

Maillages et modèles

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

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

Matériaux et nuanceurs

  • Supprimez les variantes de nuanceur inutilisées par programmation lors du processus de compilation.
  • Regroupez les variantes de nuanceur fréquemment utilisées dans des nuanceurs uber pour éviter la duplication des nuanceurs.
  • Activez le chargement dynamique des nuanceurs pour résoudre le problème de l'espace mémoire utilisé important des nuanceurs préchargés dans la VRAM/RAM. Toutefois, faites attention si la compilation des nuanceurs provoque des problèmes de frames.
  • Utilisez le chargement dynamique des nuanceurs pour éviter 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) et 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 pour les 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

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

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

    Utilisez les contrôleurs de remplacement d'animateur pour réutiliser un contrôleur d'animateur 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 rig de squelette : utilisez moins d'os dans votre rig 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 rig.
  • Réduisez la longueur des clips d'animation.

    • Coupez les clips d'animation pour n'inclure que les frames nécessaires. Évitez de stocker des animations inutilisées ou trop longues.
    • Utilisez des animations en boucle au lieu de créer de longs clips 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 d'animation hérités si vous utilisez Animator.

  • Évitez d'utiliser l'animateur s'il n'est pas nécessaire. Pour les effets visuels simples, utilisez des bibliothèques d'interpolation ou implémentez l'effet visuel dans un script. Le système d'animateur 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 efficace en termes de mémoire.

Scènes

Lorsque de nouvelles scènes sont chargées, elles apportent des éléments en tant que dépendances. Toutefois, sans une gestion appropriée du cycle de vie des éléments, 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 de Instantiate et Destroy améliore à la fois 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 lors de moments moins critiques, comme les écrans de démarrage ou les écrans 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 importantes.
    • Recherchez les pics de processeur importants dans le GC.MarkDependencies marqueur de profil. 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 éléments chargés dynamiquement.

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 éléments qui ne sont plus utilisés
    • Exécute une opération de type récupérateur de mémoire pour vérifier le tas d'objets gérés et natifs afin de détecter les éléments inutilisés et de les décharger
    • Nettoie la mémoire des textures, des maillages et des éléments à condition qu'aucune référence active n'existe
  • AssetBundle ou Addressable : apporter des modifications dans ce domaine est complexe et nécessite 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 des téléchargements et diminuent les coûts cloud. Pour en savoir plus sur la gestion des éléments dans Unity avec, consultez Addressables.

  • 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 dédiés ou des groupes Addressable. 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 simplifient le chargement et le déchargement, et peuvent décharger automatiquement les dépendances qui ne sont plus référencées. La transition vers Addressables pour la gestion des contenus et la résolution des dépendances peut être une solution viable, en fonction du cas spécifique du jeu. Analysez les chaînes de dépendances avec l'outil d'analyse pour identifier les doublons ou les dépendances inutiles. Vous pouvez également consulter les outils de données Unity si vous utilisez des AssetBundles.

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

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

Unity utilise la récupération de mémoire (GC) pour gérer la mémoire en identifiant et en libérant automatiquement la mémoire inutilisée. Bien que la récupération de mémoire soit essentielle, elle peut entraîner des problèmes de performances (par exemple, des pics de fréquence d'images) si elle n'est pas gérée 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 obtenir des techniques utiles permettant de réduire la fréquence des allocations de tas de mémoire géré et la page 271 de UnityPerformanceTuningBible pour obtenir des exemples.

  • Réduisez les allocations du récupérateur de mémoire :

    • Évitez LINQ, les lambdas et les fermetures, qui allouent de la mémoire de tas.
    • Utilisez StringBuilder pour les chaînes mutables 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.

  • Gérez les mises à jour du canevas de l'interface utilisateur :

    • Modifications dynamiques des éléments de l'interface utilisateur : lorsque des éléments d'interface utilisateur tels que les propriétés Texte, Image ou RectTransform sont mis à jour (par exemple, modification du contenu textuel, redimensionnement des éléments ou animation des positions), le moteur peut allouer de la mémoire pour les objets temporaires.
    • Allocations de chaînes : les éléments d'interface utilisateur tels que le texte nécessitent souvent des mises à jour de chaînes, car les chaînes sont immuables dans la plupart des langages de programmation.
    • Canevas sale : lorsque quelque chose change sur un canevas (par exemple, redimensionnement, activation et désactivation d'éléments ou modification des propriétés de mise en page), l'ensemble du canevas ou une partie de celui-ci peut être marqué comme sale et être reconstruit. Cela peut déclencher la création de structures de données temporaires (par exemple, des données de maillage, des tampons de sommets 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 une saturation importante de la mémoire.
  • Activez la récupération de mémoire incrémentale 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'espace mémoire utilisé de votre jeu.

  • Si votre jeu nécessite une approche contrôlée, définissez le mode de récupération de mémoire sur manuel. Ensuite, lors d'un changement de niveau ou à un autre moment sans gameplay actif, appelez la récupération de mémoire.

  • 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 code 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 marqueur de profileur pour envoyer à l'outil de reporting des performances pour une approche 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é shell géré, 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, est détruit à 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 ayant une fuite. Pour en savoir plus sur les objets shell gérés, consultez le manuel Objets shell gérés.

De plus, les 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 Rechercher les fuites de mémoire pour comparer correctement les instantanés de mémoire Unity.
  • Recherchez les abonnements à des é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 d'événements ou l'éditeur peut 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 d'objets. 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, la différence peut être due au pilote graphique ou au système d'exploitation lui-même.

Fractionnement 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 allocations temporaires importantes peuvent entraîner une fragmentation permanente pendant la durée d'une session de jeu.

Ce problème est particulièrement problématique lorsque des allocations importantes 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, au début du cycle de vie de l'application.

Observateurs et gestionnaires d'événements

  • En plus du problème mentionné dans la section Fuites de mémoire, les fuites de mémoire peuvent contribuer à la fragmentation au fil du temps 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 pool annule complètement les références aux composants de maillage de texte, aux textures et aux GameObjects.
  • Les gestionnaires d'événements créent et stockent souvent des listes ou des dictionnaires pour gérer les abonnements aux événements. S'ils 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 return 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 les 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 éléments (par exemple, les textures et les particules) par type et par cycle de vie prévu.
  • Condensez les éléments 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 des éléments 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 gourmandes en mémoire (par exemple, les clusters physiques).

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

Métadonnées IL2CPP

IL2CPP génère des métadonnées pour chaque type (par exemple, les classes, les génériques et les délégués) au moment de la compilation, qui sont ensuite utilisées lors 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. Ce problème est exacerbé par l'utilisation répétitive ou redondante de types dans le projet.

Les métadonnées IL2CPP peuvent être réduites en procédant comme suit :

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

Suppression de code

En plus de réduire la taille de la compilation, la suppression de code réduit également l'utilisation de la mémoire. Lors de la compilation par rapport au backend de script IL2CPP, la suppression de bytecode géré (activée par défaut) supprime le code inutilisé des assemblages gérés. Le processus fonctionne en définissant des assemblages racines, puis en utilisant l'analyse de code statique pour déterminer quel autre code géré ces assemblages racines utilisent. Tout code qui n'est pas accessible 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 native 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 heap allocator example.

Gérer les plug-ins et les SDK natifs

  • Recherchez 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 interfaces. Consultez Améliorez votre code avec des modèles de programmation de jeux patterns 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 de 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
}