Bien que la capacité de mémoire RAM physique des appareils mobiles modernes continue de croître, 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 au premier plan, pour résoudre les pénuries de mémoire à l'échelle du système.
- Perte 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 au niveau du traitement.
- Limitation thermique et décharge de la batterie : l'allocation, 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. Étant donné que 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 (allocateurs de blocs natifs et tas géré) pour s'adapter à une charge de pointe é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 de récupération de mémoire 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 OOM. 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 oblige tous les assets référencés à se charger 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 types de chargement des clips 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. |
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 d'interface utilisateur : pour les caméras d'interface utilisateur où les données de profondeur ne sont pas
nécessaires, 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 pas activement utilisés par un matériau du
projet, tandis que les variantes #multi_compile sont incluses de force, quelle que soit
leur utilisation.
C. Architecture de suppression automatisée personnalisée
(IPreprocessShaders) L'analyse statique ne pouvant pas
détecter les mots clés modifiés de manière dynamique à 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.
Catégorie C : éliminer les allocations de récupération de mémoire inutiles
Les allocations de récupération de mémoire dans le tas géré entraînent une fragmentation de la mémoire, des expansions de tas qui ne diminuent jamais et des pertes 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 tas 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 tas 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 de récupération de mémoire
- Évitez d'utiliser
Camera.allCameras, car cela génère un nouveau tableauCamera[]sur le tas de mémoire à 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 de récupération de mémoire. 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 une bonne 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 de types génériques unique, IL2CPP génère une version spécialisée du code. Une utilisation excessive de génériques complexes peut entraîner une "explosion combinatoire" du 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 fuites de shells gérés
Chaque UnityEngine.Object, tel que MonoBehaviour, Texture ou GameObject, possède un wrapper "Managed Shell" C# qui communique avec le moteur C++ natif.
- 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 de scène, annulez explicitement l'abonnement aux é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.