Bien que la capacité de RAM physique des appareils mobiles modernes continue d'augmenter, la consommation de mémoire des jeux dépasse cette croissance en raison des assets haute résolution et des pipelines de rendu complexes.
Principaux problèmes causés par une utilisation excessive de la mémoire
- Arrêts dus au tueur de mémoire faible (LMK) : l'OS ferme de force les applications en arrière-plan , voire les applications au premier plan, pour résoudre les pénuries de mémoire à l'échelle du système.
- Chutes d'images et saccades (jank) : les pics fréquents de récupération de mémoire (GC) dans le tas géré ou l'échange de mémoire au niveau de l'OS créent des goulots d'étranglement de traitement.
- Limitation thermique et décharge de la batterie : l'allocation de mémoire, la désallocation et les commits de pages mémoire continus entraînent une surcharge importante du processeur, ce qui augmente la chaleur et accélère la décharge de la batterie.
Mises à jour de la gestion de la mémoire Android
- Android 17 : introduction de
MemoryLimiter: Android 17 introduitMemoryLimiter, qui surveille activement la consommation de mémoire des applications par rapport à des seuils spécifiques à l'appareil. Les applications qui dépassent leur limite de mémoire sont immédiatement arrêtées au niveau du système. Comme ce mécanisme est plus strict que le LMK traditionnel, la gestion de l'utilisation maximale de la mémoire est plus importante que jamais.
Réduire l'utilisation de la mémoire dans Unity
Dans Unity, une fois que le moteur étend ses pools de mémoire internes (Native Block Allocators et Managed Heap) pour s'adapter à une charge maximale élevée, il conserve ces pages mémoire au lieu de les renvoyer immédiatement à l'OS.
Par conséquent, même après le déchargement des assets volumineux, la mémoire résidente de référence reste gonflée, ce qui rend l'application très vulnérable aux arrêts de processus de l'OS Android (tels que LMK ou MemoryLimiter).
Pour éviter l'application au niveau de l'OS, l'optimisation de la mémoire doit être abordée selon trois piliers clés :
- Catégorie A : Réduire l'utilisation maximale de la mémoire
- Catégorie B : Éliminer les empreintes inutiles des assets et du système
- Catégorie C : Éliminer les allocations GC inutiles
Catégorie A : Réduire l'utilisation maximale de la mémoire
Les allocateurs d'Unity conservent les pages mémoire libérées pour les réutiliser au lieu de les renvoyer immédiatement à l'OS. La mémoire de référence a donc tendance à refléter le pic le plus élevé atteint plutôt que l'utilisation actuelle. Il est donc plus efficace d'empêcher les pics que de s'appuyer sur le nettoyage après coup.
1. Éviter les AssetBundles trop volumineux
En raison du mécanisme de chargement de bundle d'Unity, les AssetBundles géants entraînent un gonflement important de la mémoire et des plantages dus à une mémoire insuffisante. Conservez les bundles modulaires pour garantir une gestion efficace des ressources.
Problèmes clés
- Surcharge de mémoire élevée : la demande d'un seul petit asset oblige Unity à charger l'intégralité du fichier de bundle (y compris les en-têtes, les métadonnées et les tampons de streaming) dans la RAM.
- Le piège du déchargement : si un asset d'un bundle est activement utilisé, l'intégralité du bundle ne peut pas être déchargée, ce qui piège les données inutilisées dans la RAM.
Bonnes pratiques
- Conserver les bundles modulaires : regroupez les assets de manière logique par scène ou cycle de vie.
- Conseil pour Unity 6.6 et versions ultérieures : utilisez les répertoires de contenu pour éviter les dépendances croisées involontaires entre les bundles.
2. Optimiser les références d'assets dans ScriptableObjects
Les champs sérialisés UnityEngine.Object directs dans un ScriptableObject créent des références matérielles directes, ce qui force le chargement de tous les assets référencés dans la RAM dès que le ScriptableObject lui-même est chargé ou instancié.
// BEFORE: Loading SceneRequiredAssets forces _worldAsset and _spawnSettings into RAM immediately
public class SceneRequiredAssets : ScriptableObject
{
public string sceneName;
public Object _worldAsset;
public Object _spawnSettings;
}
// AFTER: Use AssetReference to enable asynchronous, on-demand loading using Addressables
public class SceneRequiredAssets : ScriptableObject
{
public string sceneName;
public AssetReference _worldAsset;
public AssetReference _spawnSettings;
}
3. Configurer les clips et les canaux audio
Le chargement direct de tous les clips audio non compressés dans la mémoire crée des pics de mémoire importants et permanents. Les paramètres de chargement audio doivent être configurés en fonction de leur profil d'utilisation :
| Catégorie audio | Type de chargement | Motif |
|---|---|---|
| BGM (musique de fond) | Streaming | Diffuse l'audio à partir du disque dans de petits tampons pour éliminer les pics de mémoire. |
| SFX longs | Compressé en mémoire | Maintient une faible surcharge de RAM et décompresse l'audio à la volée pendant la lecture. |
| SFX courts et fréquents | Décompresser au chargement | Décompresse l'audio dans la RAM lors du chargement pour éviter la surcharge du processeur d'exécution pendant la lecture. |
En plus des types de chargement, appliquez les configurations audio suivantes pour réduire davantage la surcharge de mémoire :
- Activer "Forcer en mono" pour l'audio non directionnel : de nombreux assets audio, tels
que les effets sonores de l'interface utilisateur, les voix off et les effets sonores spatialisés en 3D, ne
nécessitent pas de séparation des canaux stéréo. Activez Forcer en mono dans les
AudioClipparamètres d'importation pour ces assets afin de réduire de moitié leur empreinte mémoire et la taille de leur tampon (réduction de 50 %). - Limiter les canaux audio actifs par niveau d'appareil : chaque voix audio active
alloue des tampons de mixage et de décompression DSP natifs. Sur les appareils à faible mémoire,
réduisez le nombre maximal de canaux actifs simultanés, par exemple en
diminuant Max Real Voices (de 32 à 16, par exemple) à l'aide de
AudioSettings.Resetdans Unity, ou en limitant les canaux de lecture logicielle dans les moteurs audio tiers.
4. Implémenter des stratégies de mise en pool et de libération d'objets
Les instances non libérées qui restent dans les pools d'objets lors des transitions de scène conservent la mémoire réservée indéfiniment, ce qui gonfle inutilement l'espace mémoire utilisé de référence.
- Action : effacez ou supprimez régulièrement les objets mis en pool inutilisés lors des transitions de scène ou des périodes de faible activité, ce qui permet à Unity de renvoyer ou de réutiliser cet espace réservé pour d'autres allocations.
Catégorie B : Éliminer l'utilisation inutile de la mémoire
L'élimination des assets graphiques redondants et le rendu direct des tampons cibles réduisent l'empreinte mémoire de référence.
1. Optimiser les textures de rendu et la profondeur de la caméra
- Supprimer les tampons de profondeur/stencil : définissez le format de stencil de profondeur sur Aucun pour les textures de rendu qui ne nécessitent que des données de couleur.
- Désactiver la texture de profondeur de la caméra de l'interface utilisateur : pour les caméras d'interface utilisateur où les données de profondeur sont
inutiles, désactivez la génération de textures de profondeur dans les paramètres de la caméra URP pour
éliminer le pass
CopyDepthet la mémoire de texture GPU associée.
2. Optimiser les textures et les maillages
| Catégorie | Consignes d'optimisation |
|---|---|
| Compression de texture | Appliquez toujours les formats de compression de la plate-forme cible (par exemple, ASTC pour Android). |
| Lecture/écriture activée | Laissez cette option désactivée, sauf si nécessaire. L'activation de cette option duplique la mémoire de texture entre la RAM du processeur et celle du GPU. |
| Mipmaps | Désactivez les mipmaps pour les textures d'interface utilisateur ou les objets fixés à une distance constante de la caméra, ce qui permet d'économiser environ 33% de mémoire de texture. |
| Complexité du maillage | Réduisez le nombre de polygones et les flux de sommets inutiles pour réduire l'espace mémoire utilisé du GPU et de la mémoire native. |
3. Supprimer les variantes de shader et optimiser la mémoire
Les uber-shaders (par exemple, URP Lit Shader) encapsulent de nombreuses fonctionnalités à l'aide des mots clés #multi_compile et shader_feature. Sans optimisation,
l'explosion combinatoire crée des dizaines de milliers de
variantes de shader uniques, ce qui entraîne une augmentation de la taille des builds, une consommation massive de mémoire native
et des problèmes de compilation des pilotes GPU pendant le jeu.
A. Mécanismes de surcharge de mémoire des variantes de shader
- Explosion combinatoire : le nombre total de variantes possibles augmente de manière exponentielle avec chaque groupe de mots clés ajouté.
- Architecture d'allocation de blocs : Unity regroupe les variantes binaires compilées dans des blocs de mémoire compressés appelés chunks (par défaut : 4 Mo).
- Gonflement de la mémoire native : lorsque le code d'exécution demande même une seule variante dans un chunk, l'intégralité du chunk de 4 Mo est décompressée dans la RAM. Si elles ne sont pas optimisées, des milliers de variantes inutilisées regroupées dans ces chunks occupent en permanence la mémoire native.
B. Pipeline de suppression multi-étapes intégré à Unity: Unity supprime automatiquement
les variantes inutiles au moment de la compilation en fonction des paramètres graphiques de la plate-forme cible
et des fonctionnalités de moteur inutilisées (par exemple, les paramètres de brouillard, de lightmaps et de XR
settings). De plus, les variantes shader_feature sont automatiquement filtrées si leurs mots clés ne sont utilisés activement par aucun matériau du projet, tandis que les variantes #multi_compile sont incluses de force, quelle que soit leur utilisation. Toutefois, si les fonctionnalités de rendu ou de post-traitement (telles que
les lumières supplémentaires, les ombres en temps réel ou les effets UberPost inutilisés) restent
activées dans les paramètres URP ou de pipeline, Unity ne peut pas déterminer si votre
contenu les utilise réellement et génère toutes les variantes #multi_compile
correspondantes. Vérifiez et désactivez les paramètres de pipeline inutilisés afin qu'ils correspondent à vos
exigences de rendu réelles et empêchent la génération de variantes inutiles.
C. Architecture de suppression automatisée personnalisée
(IPreprocessShaders) L'analyse statique ne pouvant pas
détecter les mots clés modifiés dynamiquement à l'aide de scripts C# d'exécution
(Material.EnableKeyword), la suppression standard est souvent insuffisante. Pour vous assurer que seules les variantes réellement utilisées sont incluses, vous pouvez collecter des variantes lors des suites de tests d'assurance qualité à l'aide de Player.log (en activant Log Shader Compilation dans les paramètres de l'éditeur) ou de Profiler Traces (Shader.CreateGPUProgram marqueurs). Ensuite, implémentez
IPreprocessShaders.OnProcessShader dans un script d'éditeur pour filtrer les
variantes qui n'ont jamais été exécutées lors de l'exécution, en ne conservant que celles qui sont nécessaires
dans le build.
D. Consolider les shaders redondants : les uber-shaders tels que Lit et Simple
Lit contiennent de nombreuses directives #multi_compile et consomment une quantité importante de mémoire
lors de la génération de nombreuses variantes :
- Profiler l'utilisation simultanée : profiler votre utilisation de la mémoire pour vérifier si les deux shaders consomment une quantité importante de mémoire en même temps et si votre projet a réellement besoin des deux.
- Standardiser sur un seul shader : lorsque le rendu basé sur la physique (PBR) n'est pas requis, configurez vos assets pour éliminer l'utilisation redondante du shader Lit et standardisez sur Simple Lit (par exemple, vérifiez si les modèles FBX exportés ont des matériaux Lit par défaut appliqués involontairement lors de l'importation).
Catégorie C : Éliminer les allocations GC inutiles
Les allocations de récupération de mémoire (GC) dans le tas géré entraînent une fragmentation de la mémoire, des expansions de tas qui ne diminuent jamais et des chutes d'images importantes lors des pauses de récupération de mémoire.
1. Empêcher les allocations de fermeture lambda
Lorsqu'une expression lambda capture des variables locales externes, C# génère une classe d'affichage implicite sur le tas. L'exécution de cette opération dans Update alloue des instances de fermeture à chaque image.
// Bad: Capturing local variable 'targetId' allocates a new closure object on the Heap every frame
void Update()
{
int targetId = 100;
Monster target = monsterList.Find(m => m.Id == targetId);
}
// Good 1: Replace with a standard 'for' loop (Recommended: 0 B allocation)
void Update()
{
int targetId = 100;
Monster target = null;
for (int i = 0; i < monsterList.Count; i++)
{
if (monsterList[i].Id == targetId)
{
target = monsterList[i];
break;
}
}
}
// Good 2: Use a static lambda (C# 9.0+) if no outer variables are captured
Monster target = monsterList.Find(static m => m.Id == 100);
2. Utiliser stackalloc et Span
Évitez les allocations de segments de mémoire pour les tableaux temporaires de courte durée en utilisant la mémoire de pile.
// Before: Allocates an array on the Heap every call (GC Target)
Vector2[] pos = new Vector2[4];
// After: Utilizes Stack memory using System.Span (0 B Heap Allocation)
System.Span<Vector2> pos = stackalloc Vector2[4];
3. Optimiser l'itération de la collection
Évitez le boxing et les allocations de segments de mémoire d'énumérateur causés par les extensions LINQ ou les accesseurs ReadOnlyCollection.
// Before: LINQ Count() causes internal GetEnumerator() heap allocations
bool hasData = component != null && component.parameters.Count(parameter => parameter.overrideState) > 0;
// After: Replaced with indexer and direct loop iteration
bool hasData = HasDataOptimized(component);
private bool HasDataOptimized(TestComponent component)
{
if (component == null) return false;
var count = component.parameters.Count;
for (var i = 0; i < count; ++i)
{
if (component.parameters[i].overrideState)
return true;
}
return false;
}
4. Règles supplémentaires de prévention des allocations GC
- Évitez d'utiliser
Camera.allCameras, car cela génère un nouveau tableauCamera[]sur le tas à chaque appel. Mettez plutôt en cache un tableau de caméras et transmettez-le àCamera.GetAllCameras(_allCameras). - Évitez
foreachsurIReadOnlyList<T>: l'itération sur une interface entraîne le boxing de l'énumérateur de structure, ce qui génère des allocations GC. Utilisez plutôt une boucleforstandard. - Mettre en cache les objets de coroutine : mettez en cache les instances
WaitForSecondsau lieu d' instancieryield return new WaitForSeconds(time);de manière répétée. - Clés de structure personnalisées dans les dictionnaires : l'utilisation de structures personnalisées comme clés de dictionnaire
appelle la valeur par défaut
Equals, ce qui déclenche le boxing d'objets. ImplémentezIEqualityComparer<T>et transmettez-le au constructeur de dictionnaire.
public struct TypeKey
{
public int v1;
public int v2;
public class TypeKeyComparer : IEqualityComparer<TypeKey>
{
public bool Equals(TypeKey x, TypeKey y) => x.v1 == y.v1 && x.v2 == y.v2;
public int GetHashCode(TypeKey obj) => obj.v1.GetHashCode() ^ obj.v2.GetHashCode();
}
}
// Pass custom comparer during Dictionary initialization to prevent boxing
public readonly Dictionary<TypeKey, int> _typeKeyDictionary = new(new TypeKey.TypeKeyComparer());
5. Réduire l'utilisation des génériques et de la réflexion
Bien que les méthodes génériques offrent une excellente réutilisation et maintenabilité du code, leur utilisation excessive peut avoir un impact négatif sur votre projet dans le contexte du backend Unity IL2CPP (langage intermédiaire vers C++).
- Gonflement du code IL2CPP : pour chaque combinaison unique de types génériques, IL2CPP génère une version spécialisée du code. L'utilisation excessive de génériques complexes peut entraîner une "explosion combinatoire" de code C++ généré, ce qui augmente considérablement la taille binaire de l'application et l'empreinte mémoire native.
- Surcharge de réflexion : les méthodes utilisant la réflexion, telles que les API
System.Reflection, sont intrinsèquement lentes et entraînent souvent des allocations de tas lors de l’exécution. - Bonne pratique : utilisez les génériques avec discernement. Privilégiez-les pour la
clarté architecturale plutôt que pour une application large et indifférenciée. Lorsque les performances sont essentielles, privilégiez les types concrets ou le polymorphisme basé sur l'interface. Pour la réflexion, mettez en cache les résultats tels que
MethodInfoouFieldInfolors de l'initialisation au lieu de les interroger dans la boucle de mise à jour.
6. Éviter les shells gérés qui fuient
Chaque UnityEngine.Object, tel que MonoBehaviour, Texture ou GameObject, possède un wrapper "Managed Shell" C# qui communique avec le moteur C++ natif.
- Le problème : si un shell géré est conservé en mémoire par une référence statique, un abonnement à un événement persistant ou une fermeture non nettoyée, le GC ne peut pas récupérer la mémoire. Même si l'objet natif est détruit, le wrapper géré persiste, ce qui entraîne des fuites de mémoire "fantômes" qui gonflent le tas géré.
- Résolution : implémentez toujours des modèles de nettoyage robustes. Lorsque vous détruisez des objets ou que vous effectuez une transition entre des scènes, désabonnez-vous explicitement des événements à l'aide de l'opérateur
-=et annulez les références statiques aux typesUnityEngine.Object. Ce nettoyage garantit que le GC peut collecter le wrapper une fois que le moteur natif a libéré son handle.
7. Utiliser des catalogues binaires pour les Addressables
Dans Unity 2022 et versions antérieures, les Addressables utilisent par défaut des catalogues JSON plutôt que des catalogues binaires (qui sont la valeur par défaut dans Unity 2023 et versions ultérieures). Si votre projet utilise des catalogues JSON, le chargement d'un catalogue volumineux au démarrage lit l'intégralité du fichier dans une seule chaîne C# massive et alloue des milliers de chaînes gérées et de tableaux byte[] temporaires lors de l'analyse JSON et du décodage Base64. Cela provoque un pic d'allocation GC important qui étend de manière permanente le tas géré. Passez donc à un catalogue binaire pour diffuser directement des données compactes.