Debug LMK

La risoluzione dei problemi noti nel tuo gioco Unity è un processo sistematico:

Figura 1. Passaggi per risolvere i problemi di Low Memory Killers (LMK) nei giochi Unity.

Ottenere uno snapshot della memoria

Utilizza Unity Profiler per ottenere un'istantanea della memoria gestita da Unity. La Figura 2 mostra i livelli di gestione della memoria utilizzati da Unity per gestire la memoria nel tuo gioco.

Figura 2. Panoramica della gestione della memoria di Unity.

Memoria gestita

La gestione della memoria di Unity implementa un livello di memoria controllato che utilizza un heap gestito e un garbage collector per allocare e assegnare automaticamente la memoria. Il sistema di gestione della memoria è un ambiente di scripting C# basato su Mono o IL2CPP. Il vantaggio del sistema di gestione della memoria è che utilizza un garbage collector per liberare automaticamente le allocazioni di memoria.

Memoria non gestita C#

Il livello di memoria C# non gestito fornisce l'accesso al livello di memoria nativo, consentendo un controllo preciso delle allocazioni di memoria durante l'utilizzo del codice C#. È possibile accedere a questo livello di gestione della memoria tramite lo spazio dei nomi Unity.Collections e tramite funzioni come UnsafeUtility.Malloc e UnsafeUtility.Free.

Memoria nativa

Il core C/C++ interno di Unity utilizza un sistema di gestione della memoria nativo per gestire scene, asset, API grafiche, driver, sottosistemi e buffer dei plug-in. Sebbene l'accesso diretto sia limitato, puoi manipolare i dati in modo sicuro con l'API C# di Unity e usufruire di un codice nativo efficiente. La memoria nativa raramente richiede un'interazione diretta, ma puoi monitorare l'impatto della memoria nativa sulle prestazioni utilizzando Profiler e regolare le impostazioni per ottimizzare le prestazioni.

La memoria non è condivisa tra C# e il codice nativo, come mostrato nella Figura 3. I dati richiesti da C# vengono allocati nello spazio di memoria gestito ogni volta che sono necessari.

Affinché il codice del gioco gestito (C#) acceda ai dati della memoria nativa del motore, ad esempio una chiamata a GameObject.transform effettua una chiamata nativa per accedere ai dati della memoria nell'area nativa e poi restituisce i valori a C# utilizzando Bindings. I binding garantiscono convenzioni di chiamata appropriate per ogni piattaforma e gestiscono il marshalling automatico dei tipi gestiti nei loro equivalenti nativi.

Ciò accade solo la prima volta, poiché la shell gestita per accedere alla proprietà transform viene conservata nel codice nativo. La memorizzazione nella cache della proprietà transform può ridurre il numero di chiamate di andata e ritorno tra il codice gestito e quello nativo, ma l'utilità della memorizzazione nella cache dipende dalla frequenza con cui viene utilizzata la proprietà. Inoltre, tieni presente che Unity non copia parti della memoria nativa nella memoria gestita quando accedi a queste API.

Figura 3. Accesso alla memoria nativa dal codice gestito C#.

Per saperne di più, consulta Introduzione alla memoria in Unity.

Inoltre, stabilire un budget di memoria è fondamentale per garantire il corretto funzionamento del gioco e l'implementazione di un sistema di analisi o report sul consumo di memoria assicura che ogni nuova release non superi il budget di memoria. L'integrazione dei test Play Mode con l'integrazione continua (CI) per verificare il consumo di memoria in aree specifiche del gioco è un'altra strategia per ottenere informazioni migliori.

Gestire le risorse

Questa è la parte più efficace e attuabile del consumo di memoria. Profilo il prima possibile.

L'utilizzo della memoria nei giochi per Android può variare in modo significativo a seconda del tipo di gioco, del numero e dei tipi di asset e delle strategie di ottimizzazione della memoria. Tuttavia, i fattori che contribuiscono maggiormente all'utilizzo della memoria includono in genere texture, mesh, file audio, shader, animazioni e script.

Rilevare gli asset duplicati

Il primo passo consiste nel rilevare asset configurati in modo errato e asset duplicati utilizzando il Profiler di memoria, uno strumento di report di build o Project Auditor.

Trame

Analizza il supporto dei dispositivi del tuo gioco e decidi il formato della texture corretto. Puoi dividere i bundle di texture per dispositivi di fascia alta e bassa utilizzando Play Asset Delivery, Addressable o una procedura più manuale con un AssetBundle.

Segui i consigli più noti disponibili in Ottimizzare il rendimento dei giochi per dispositivi mobili e nel post di discussione Ottimizzazione delle impostazioni di importazione delle texture di Unity. Poi prova queste soluzioni:

  • Comprimi le texture con i formati ASTC per ridurre l'impronta di memoria e prova con una velocità di blocco più elevata, ad esempio 8x8.

    Se è necessario utilizzare ETC2, comprimi le texture in Atlas. L'inserimento di più texture in una singola texture garantisce la sua potenza di due (POT), può ridurre le chiamate di disegno e può accelerare il rendering.

  • Ottimizza il formato e le dimensioni della texture RenderTarget. Evita texture con risoluzione inutilmente elevata. L'utilizzo di texture più piccole sui dispositivi mobili consente di risparmiare memoria.

  • Utilizza il packing dei canali delle texture per risparmiare memoria delle texture.

Mesh e modelli

Inizia controllando le impostazioni fondamentali (pagina 27) e verifica queste impostazioni di importazione della mesh:

  • Unisci mesh ridondanti e più piccole.
  • Riduci il numero di vertici per gli oggetti nelle scene (ad esempio, oggetti statici o distanti).
  • Genera gruppi di livello di dettaglio (LOD) per asset con geometria elevata.

Materiali e shader

  • Rimuovi le varianti di shader inutilizzate a livello di programmazione durante il processo di compilazione.
  • Consolida le varianti di shader utilizzate di frequente in uber shader per evitare la duplicazione degli shader.
  • Attiva il caricamento dinamico degli shader per risolvere il problema dell'ampio footprint di memoria degli shader precaricati in VRAM/RAM. Tuttavia, presta attenzione se la compilazione degli shader causa problemi di frame.
  • Utilizza il caricamento dinamico degli shader per impedire il caricamento di tutte le varianti. Per ulteriori informazioni, consulta il post del blog Miglioramenti dei tempi di compilazione degli shader e dell'utilizzo della memoria.
  • Utilizza correttamente l'istanza del materiale sfruttando MaterialPropertyBlocks.

Audio

Inizia controllando le impostazioni fondamentali (pagina 41) e verifica queste impostazioni di importazione della mesh:

  • Rimuovi i riferimenti AudioClip non utilizzati o ridondanti quando utilizzi motori audio di terze parti come FMOD o Wwise.
  • Precaricare i dati audio. Disattiva il precaricamento per i clip che non sono necessari immediatamente durante l'esecuzione o l'avvio della scena. Ciò consente di ridurre il sovraccarico di memoria durante l'inizializzazione della scena.

Animazioni

  • Modifica le impostazioni di compressione delle animazioni di Unity per ridurre al minimo il numero di keyframe ed eliminare i dati ridondanti.
    • Riduzione dei fotogrammi chiave: rimuove automaticamente i fotogrammi chiave non necessari
    • Compressione dei quaternioni: comprime i dati di rotazione per ridurre l'utilizzo della memoria

Puoi modificare le impostazioni di compressione in Impostazioni di importazione animazione nelle schede Rig o Animazione.

  • Riutilizza i clip di animazione anziché duplicarli per oggetti diversi.

    Utilizza Animator Override Controllers per riutilizzare un Animator Controller e sostituire clip specifici per personaggi diversi.

  • Esegui il rendering delle animazioni basate sulla fisica: se le tue animazioni sono basate sulla fisica o procedurali, esegui il rendering in clip di animazione per evitare calcoli in fase di runtime.

  • Ottimizza il rig dello scheletro: utilizza meno ossa nel rig per ridurre la complessità e il consumo di memoria.

    • Evita un numero eccessivo di ossa per oggetti piccoli o statici.
    • Se alcuni scheletri non sono animati o necessari, rimuovili dal rig.
  • Ridurre la durata del clip di animazione.

    • Taglia i clip di animazione in modo da includere solo i fotogrammi necessari. Evita di memorizzare animazioni inutilizzate o eccessivamente lunghe.
    • Utilizza animazioni in loop anziché creare clip lunghi per movimenti ripetuti.
  • Assicurati che sia collegato o attivato un solo componente dell'animazione. Ad esempio, disattiva o rimuovi i componenti Animazione legacy se utilizzi Animator.

  • Evita di utilizzare Animator se non è necessario. Per effetti visivi semplici, utilizza librerie di tweening o implementa l'effetto visivo in uno script. Il sistema di animazione può richiedere molte risorse, in particolare sui dispositivi mobili di fascia bassa.

  • Utilizza il sistema di job per le animazioni quando gestisci un numero elevato di animazioni, in quanto questo sistema è stato completamente riprogettato per essere più efficiente in termini di memoria.

Scene

Quando vengono caricate nuove scene, gli asset vengono importati come dipendenze. Tuttavia, senza una corretta gestione del ciclo di vita degli asset, queste dipendenze non vengono monitorate dai contatori di riferimenti. Di conseguenza, gli asset potrebbero rimanere in memoria anche dopo che le scene inutilizzate sono state scaricate, causando la frammentazione della memoria.

  • Utilizza il pooling di oggetti di Unity per riutilizzare le istanze di GameObject per elementi di gioco ricorrenti, perché il pooling di oggetti utilizza uno stack per contenere una raccolta di istanze di oggetti per il riutilizzo e non è thread-safe. La riduzione al minimo di Instantiate e Destroy migliora sia le prestazioni della CPU sia la stabilità della memoria.
  • Scaricamento degli asset:
    • Scarica gli asset in modo strategico durante i momenti meno critici, come le schermate iniziali o di caricamento.
    • L'uso frequente di Resources.UnloadUnusedAssets causa picchi nell'elaborazione della CPU a causa di grandi operazioni di monitoraggio delle dipendenze interne.
    • Controlla la presenza di picchi elevati di CPU nel marcatore del profilo GC.MarkDependencies. Rimuovi o riduci la frequenza di esecuzione e scarica manualmente risorse specifiche utilizzando Resources.UnloadAsset anziché affidarti all'ampio Resources.UnloadUnusedAssets().
  • Ristruttura le scene anziché utilizzare costantemente Resources.UnloadUnusedAssets.
  • La chiamata di Resources.UnloadUnusedAssets() per Addressables può scaricare involontariamente i bundle caricati dinamicamente. Gestisci con attenzione il ciclo di vita degli asset caricati dinamicamente.

Vari

  • Frammentazione causata dalle transizioni di scena: quando viene chiamato il metodo Resources.UnloadUnusedAssets(), Unity esegue le seguenti operazioni:

    • Libera memoria per gli asset non più in uso
    • Esegue un'operazione simile a un garbage collector per controllare l'heap di oggetti gestiti e nativi per gli asset inutilizzati e li scarica
    • Pulisce la memoria di texture, mesh e asset a condizione che non esista alcun riferimento attivo
  • AssetBundle o Addressable: apportare modifiche in questo ambito è complesso e richiede uno sforzo collettivo da parte del team per implementare le strategie. Tuttavia, una volta acquisite queste strategie, migliorano significativamente l'utilizzo della memoria, riducono le dimensioni dei download e abbassano i costi del cloud. Per ulteriori informazioni sulla gestione degli asset in Unity, consulta Addressables.

  • Dipendenze condivise centralizzate: raggruppa sistematicamente le dipendenze condivise, come shader, texture e caratteri, in bundle o gruppi Addressable dedicati. In questo modo si riduce la duplicazione e si garantisce che gli asset non necessari vengano scaricati in modo efficiente.

  • Utilizza Addressables per il monitoraggio delle dipendenze: Addressables semplifica il caricamento e lo scaricamento e può scaricare automaticamente le dipendenze a cui non viene più fatto riferimento. La transizione a Addressables per la gestione dei contenuti e la risoluzione delle dipendenze potrebbe essere una soluzione praticabile, a seconda del caso specifico del gioco. Analizza le catene di dipendenze con lo strumento Analizza per identificare duplicati o dipendenze non necessari. In alternativa, consulta Unity Data Tools se utilizzi AssetBundles.

  • TypeTrees - se Addressables e AssetBundles del gioco sono creati e implementati utilizzando la stessa versione di Unity del giocatore e non richiedono la compatibilità con le versioni precedenti di altre build del giocatore, valuta la possibilità di disattivare la scrittura di TypeTree, in modo da ridurre le dimensioni del bundle e l'utilizzo della memoria degli oggetti dei file serializzati. Modifica la procedura di build nell'impostazione del pacchetto Addressables locale ContentBuildFlags impostando DisableWriteTypeTree.

Scrivere codice compatibile con il garbage collector

Unity utilizza la garbage collection (GC) per gestire la memoria identificando e liberando automaticamente la memoria inutilizzata. Sebbene la Garbage Collection sia essenziale, se non gestita correttamente, può causare problemi di prestazioni (ad esempio picchi di frame rate), in quanto questo processo può mettere in pausa momentaneamente il gioco, causando problemi di prestazioni e un'esperienza utente non ottimale.

Consulta il manuale di Unity per tecniche utili per ridurre la frequenza delle allocazioni dell'heap gestito e la UnityPerformanceTuningBible, pagina 271, per esempi.

  • Riduci le allocazioni del Garbage Collector:

    • Evita LINQ, espressioni lambda e chiusure, che allocano memoria heap.
    • Utilizza StringBuilder per le stringhe modificabili anziché la concatenazione di stringhe.
    • Riutilizza le raccolte chiamando COLLECTIONS.Clear() anziché crearne di nuove.

    Per saperne di più, consulta l'ebook Guida definitiva alla profilazione dei giochi Unity.

  • Gestisci gli aggiornamenti del canvas dell'interfaccia utente:

    • Modifiche dinamiche agli elementi UI: quando vengono aggiornate le proprietà di elementi UI come Testo, Immagine o RectTransform (ad esempio, modifica del contenuto del testo, ridimensionamento degli elementi o animazione delle posizioni), il motore potrebbe allocare memoria per gli oggetti temporanei.
    • Allocazioni di stringhe: gli elementi dell'interfaccia utente come il testo spesso richiedono aggiornamenti delle stringhe, poiché le stringhe sono immutabili nella maggior parte dei linguaggi di programmazione.
    • Canvas sporco: quando qualcosa su un canvas cambia (ad esempio, ridimensionamento, attivazione e disattivazione di elementi o modifica delle proprietà del layout), l'intero canvas o una parte di esso potrebbe essere contrassegnato come sporco e ricostruito. Ciò può attivare la creazione di strutture di dati temporanee (ad esempio, dati mesh, buffer di vertici o calcoli del layout), che contribuiscono alla generazione di garbage.
    • Aggiornamenti complessi o frequenti: se il canvas contiene un numero elevato di elementi o viene aggiornato frequentemente (ad esempio, ogni frame), queste ricostruzioni possono comportare un significativo churn della memoria.
  • Abilita GC incrementale per ridurre i picchi di raccolta di grandi dimensioni distribuendo le pulizie dell'allocazione su più frame. Profilo per verificare se questa opzione migliora le prestazioni e l'utilizzo della memoria del gioco.

  • Se il gioco richiede un approccio controllato, imposta la modalità di garbage collection su manuale. Poi, a un cambio di livello o in un altro momento senza gameplay attivo, chiama la garbage collection.

  • Richiama la garbage collection manuale GC.Collect() per le transizioni dello stato del gioco (ad esempio, il cambio di livello).

  • Ottimizza gli array a partire da semplici pratiche di codifica e, se necessario, utilizzando array nativi o altri contenitori nativi per array di grandi dimensioni.

  • Monitora gli oggetti gestiti utilizzando strumenti come Unity Memory Profiler per tenere traccia dei riferimenti a oggetti non gestiti che persistono dopo la distruzione.

    Utilizza un indicatore del profiler per inviare i dati allo strumento di generazione di report sul rendimento per un approccio automatizzato.

Evitare perdite di memoria e frammentazione

Perdite di memoria

Nel codice C#, quando esiste un riferimento a un oggetto Unity dopo che l'oggetto è stato distrutto, l'oggetto wrapper gestito, noto come Managed Shell, rimane in memoria. La memoria nativa associata al riferimento viene rilasciata quando la scena viene scaricata o quando il GameObject a cui è associata la memoria o uno dei relativi oggetti principali vengono distrutti tramite il metodo Destroy(). Tuttavia, se altri riferimenti alla scena o al GameObject non sono stati cancellati, la memoria gestita potrebbe persistere come oggetto shell con perdita di memoria. Per ulteriori dettagli sugli oggetti shell gestiti, consulta il manuale Oggetti shell gestiti.

Inoltre, le perdite di memoria possono essere causate da sottoscrizioni di eventi, espressioni lambda e chiusure, concatenazioni di stringhe e gestione impropria di oggetti in pool:

  • Per iniziare, consulta Trovare perdite di memoria per confrontare correttamente gli snapshot della memoria di Unity.
  • Controlla la presenza di abbonamenti agli eventi e perdite di memoria. Se gli oggetti si iscrivono agli eventi (ad esempio, tramite delegati o UnityEvents) ma non annullano correttamente l'iscrizione prima di essere distrutti, il gestore o l'editore di eventi potrebbe conservare i riferimenti a questi oggetti. Ciò impedisce la garbage collection di questi oggetti, causando perdite di memoria.
  • Monitora gli eventi di classi globali o singleton che non vengono annullati durante l'eliminazione dell'oggetto. Ad esempio, annullare l'iscrizione o scollegare i delegati nei distruttori di oggetti.
  • Assicurati che la distruzione degli oggetti in pool annulli completamente i riferimenti ai componenti mesh di testo, alle texture e ai GameObject padre.
  • Tieni presente che quando confronti gli snapshot di Unity Memory Profiler e osservi una differenza nel consumo di memoria senza un motivo chiaro, la differenza potrebbe essere causata dal driver della scheda grafica o dal sistema operativo stesso.

Frammentazione della memoria

La frammentazione della memoria si verifica quando molte piccole allocazioni vengono liberate in un ordine casuale. Le allocazioni dell'heap vengono eseguite in sequenza, il che significa che vengono creati nuovi blocchi di memoria quando lo spazio del blocco precedente si esaurisce. Di conseguenza, i nuovi oggetti non riempiono le aree vuote dei vecchi chunk, causando la frammentazione. Inoltre, le allocazioni temporanee di grandi dimensioni possono causare una frammentazione permanente per la durata della sessione di un gioco.

Questo problema è particolarmente problematico quando vengono effettuate allocazioni di grandi dimensioni di breve durata vicino a quelle di lunga durata.

Raggruppa le allocazioni in base alla loro durata. Idealmente, le allocazioni di lunga durata devono essere effettuate insieme, all'inizio del ciclo di vita dell'applicazione.

Osservatori e gestori degli eventi

  • Oltre al problema menzionato nella sezione (Perdite di memoria)77, nel tempo le perdite di memoria possono contribuire alla frammentazione lasciando memoria inutilizzata allocata a oggetti non più in uso.
  • Assicurati che la distruzione degli oggetti in pool annulli completamente i riferimenti a componenti mesh di testo, texture e GameObjects padre.
  • I gestori di eventi spesso creano e archiviano elenchi o dizionari per gestire le iscrizioni agli eventi. Se queste crescono e si riducono dinamicamente durante l'esecuzione, possono contribuire alla frammentazione della memoria a causa di allocazioni e deallocazioni frequenti.

Codice

  • Le coroutine a volte allocano memoria, il che può essere facilmente evitato memorizzando nella cache l'istruzione return di IEnumerator anziché dichiararne una nuova ogni volta.
  • Monitora continuamente gli stati del ciclo di vita degli oggetti in pool per evitare di mantenere riferimenti fantasma UnityEngine.Object.

Asset

  • Utilizza sistemi di fallback dinamici per le esperienze di gioco basate su testo per evitare il precaricamento di tutti i caratteri per i casi multilingue.
  • Organizza gli asset (ad esempio, texture e particelle) in base al tipo e al ciclo di vita previsto.
  • Comprimi gli asset con attributi del ciclo di vita inattivi, come immagini dell'interfaccia utente ridondanti e mesh statiche.

Allocazioni basate sulla durata

  • Alloca asset di lunga durata all'inizio del ciclo di vita dell'applicazione per garantire allocazioni compatte.
  • Utilizza NativeCollections o allocatori personalizzati per strutture di dati transitorie o che richiedono molta memoria (ad esempio, cluster fisici).

Anche l'eseguibile e i plug-in del gioco influiscono sull'utilizzo della memoria.

Metadati IL2CPP

IL2CPP genera metadati per ogni tipo (ad esempio classi, generici e delegati) al momento della compilazione, che vengono poi utilizzati in fase di runtime per la reflection, il controllo dei tipi e altre operazioni specifiche del runtime. Questi metadati vengono memorizzati in memoria e possono contribuire in modo significativo all'ingombro totale della memoria dell'applicazione. La cache dei metadati di IL2CPP contribuisce in modo significativo ai tempi di inizializzazione e caricamento. Inoltre, IL2CPP non deduplica determinati elementi di metadati (ad esempio, tipi generici o informazioni serializzate), il che può comportare un utilizzo eccessivo della memoria. Questo è esacerbato dall'utilizzo ripetitivo o ridondante dei tipi nel progetto.

I metadati IL2CPP possono essere ridotti:

  • Evitare l'utilizzo delle API di reflection, in quanto possono contribuire in modo significativo alle allocazioni dei metadati IL2CPP
  • Disattivazione dei pacchetti integrati
  • Implementazione della condivisione generica completa di Unity 2022, che dovrebbe contribuire a ridurre l'overhead causato dai generici. Tuttavia, per ridurre ulteriormente le allocazioni, riduci l'utilizzo di generici.

Rimozione del codice

Oltre a ridurre le dimensioni della build, l'eliminazione del codice riduce anche l'utilizzo della memoria. Quando viene compilato il backend di scripting IL2CPP, l'eliminazione del bytecode gestito (attivata per impostazione predefinita) rimuove il codice inutilizzato dagli assembly gestiti. Il processo funziona definendo gli assembly radice e poi utilizzando l'analisi del codice statico per determinare quale altro codice gestito utilizzano questi assembly radice. Qualsiasi codice non raggiungibile viene rimosso. Per saperne di più sulla rimozione del codice gestito, consulta il post del blog TTales from the optimization trenches: Better managed code stripping with Unity 2020 LTS e la documentazione Rimozione del codice gestito.

Allocatori nativi

Sperimenta con gli allocatori di memoria nativi per ottimizzare gli allocatori di memoria. Se il gioco ha poca memoria, utilizza blocchi di memoria più piccoli, anche se ciò comporta allocatori più lenti. Per saperne di più, consulta l'esempio di allocatore heap dinamico.

Gestire plug-in e SDK nativi

  • Trova il plug-in problematico: rimuovi ogni plug-in e confronta gli snapshot della memoria del gioco. Ciò comporta la disattivazione di molte funzionalità del codice con Scripting Define Symbols e il refactoring di classi altamente accoppiate con interfacce. Consulta Migliora il tuo codice con i pattern di programmazione di giochi per facilitare il processo di disattivazione delle dipendenze esterne senza rendere il gioco ingiocabile.

  • Contatta l'autore del plug-in o dell'SDK. La maggior parte dei plug-in non è open source.

  • Riproduci l'utilizzo della memoria del plug-in: puoi scrivere un plug-in semplice (utilizza questo plug-in Unity come riferimento) che esegue allocazioni di memoria. Ispeziona gli snapshot della memoria utilizzando Android Studio (in quanto Unity non tiene traccia di queste allocazioni) o chiama la classe MemoryInfo e il metodo Runtime.totalMemory() nello stesso progetto.

Un plug-in Unity alloca la memoria Java e nativa. Ecco come fare:

Java

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

Nativo

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
}