Optymalizacja pamięci w grach Unity na Androidzie

Chociaż pojemność fizycznej pamięci RAM w nowoczesnych urządzeniach mobilnych stale rośnie, zużycie pamięci przez gry rośnie jeszcze szybciej ze względu na zasoby o wysokiej rozdzielczości i złożone potoki renderowania.

Główne problemy spowodowane nadmiernym wykorzystaniem pamięci

  • Zamykanie aplikacji przez mechanizm LMK (low memory killer): system operacyjny zamyka aplikacje działające w tle a nawet na pierwszym planie, aby rozwiązać problemy z niedoborem pamięci w całym systemie.
  • Spadki liczby klatek i zacinanie się (jank): częste skoki odśmiecania (GC) w stercie zarządzanej lub wymiana pamięci na poziomie systemu operacyjnego powodują wąskie gardła przetwarzania.
  • Ograniczanie wydajności ze względu na temperaturę i szybkie zużycie baterii: ciągła alokacja pamięci, zwalnianie i zatwierdzanie stron pamięci powodują znaczne obciążenie procesora, co prowadzi do wzrostu temperatury i szybszego wyczerpywania się baterii.

Aktualizacje zarządzania pamięcią w Androidzie

  • Android 17: wprowadzenie MemoryLimiter: Android 17 wprowadza MemoryLimiter, który aktywnie monitoruje zużycie pamięci przez aplikację w porównaniu z progami specyficznymi dla urządzenia. Aplikacje przekraczające limit pamięci są natychmiast zamykane na poziomie systemu. Ponieważ ten mechanizm jest bardziej rygorystyczny niż tradycyjny LMK, zarządzanie szczytowym wykorzystaniem pamięci jest teraz ważniejsze niż kiedykolwiek.

Zmniejszanie wykorzystania pamięci w Unity

Gdy silnik Unity rozszerzy swoje wewnętrzne pule pamięci (Native Block Allocators i Managed Heap) w celu dostosowania się do dużego obciążenia szczytowego, zachowuje te strony pamięci zamiast natychmiast zwracać je do systemu operacyjnego. W rezultacie nawet po zwolnieniu dużych zasobów bazowa pamięć rezydentna pozostaje zawyżona, co sprawia, że aplikacja jest bardzo podatna na zamykanie procesów przez system operacyjny Android (np. przez LMK lub MemoryLimiter).

Aby zapobiec egzekwowaniu zasad na poziomie systemu operacyjnego, optymalizację pamięci należy przeprowadzić w 3 kluczowych obszarach:

  • Kategoria A: zmniejszenie szczytowego wykorzystania pamięci
  • Kategoria B: wyeliminowanie niepotrzebnych zasobów i śladów systemowych
  • Kategoria C: wyeliminowanie niepotrzebnych alokacji GC

Kategoria A: zmniejszenie szczytowego wykorzystania pamięci

Alokatory Unity zachowują zwolnione strony pamięci do ponownego użycia zamiast natychmiast zwracać je do systemu operacyjnego, dlatego bazowe zużycie pamięci odzwierciedla najwyższy osiągnięty szczyt, a nie bieżące zużycie. Zapobieganie skokom jest zatem skuteczniejsze niż czyszczenie po fakcie.

1. Unikaj zbyt dużych pakietów AssetBundle

Ze względu na mechanizm wczytywania pakietów w Unity duże pakiety AssetBundle prowadzą do znacznego zwiększenia zużycia pamięci i awarii z powodu braku pamięci. Aby zapewnić efektywne zarządzanie zasobami, pakiety powinny być modułowe.

Główne problemy

  • Duże obciążenie pamięci: żądanie pojedynczego małego zasobu zmusza Unity do wczytania do pamięci RAM całego pliku pakietu (w tym nagłówków, metadanych i buforów przesyłania strumieniowego).
  • Pułapka zwalniania: jeśli jakikolwiek zasób w pakiecie jest aktywnie używany, nie można zwolnić całego pakietu, co powoduje, że nieużywane dane pozostają w pamięci RAM.

Sprawdzone metody

  • Pakiety powinny być modułowe: grupuj zasoby logicznie według sceny lub cyklu życia.
  • Wskazówka dotycząca Unity 6.6 i nowszych wersji: używaj katalogów treści, aby zapobiec niezamierzonym zależnościom między pakietami.

2. Optymalizuj odwołania do zasobów w ScriptableObjects

Bezpośrednie serializowane pola UnityEngine.Object w ScriptableObject tworzą bezpośrednie odwołania, co powoduje, że wszystkie odwoływane zasoby są wczytywane do pamięci RAM, gdy tylko ScriptableObject zostanie wczytany lub utworzony.

// 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. Skonfiguruj typy wczytywania klipów audio

Wczytywanie wszystkich klipów audio bez kompresji bezpośrednio do pamięci powoduje duże, trwałe skoki zużycia pamięci. Ustawienia wczytywania dźwięku należy skonfigurować na podstawie profilu ich użycia:

Kategoria dźwięku Typ obciążenia Przyczyna
Muzyka w tle Streaming Przesyła strumieniowo dźwięk z dysku w małych buforach, aby wyeliminować skoki zużycia pamięci.
Długie efekty dźwiękowe Compressed In Memory Utrzymuje niskie obciążenie pamięci RAM i dekompresuje dźwięk na bieżąco podczas odtwarzania.
Krótkie i częste efekty dźwiękowe Decompress On Load Dekompresuje dźwięk do pamięci RAM podczas wczytywania, aby uniknąć obciążenia procesora podczas odtwarzania.

4. Wdrażaj strategie puli obiektów i zwalniania

Niezwolnione instancje pozostające w pulach obiektów podczas przejść między scenami zajmują pamięć zarezerwowaną na czas nieokreślony, co powoduje niepotrzebne zwiększenie bazowego wykorzystania pamięci.

  • Działanie: okresowo czyść lub przycinaj nieużywane obiekty w puli podczas przejść między scenami lub okresów niskiej aktywności, co pozwoli Unity zwrócić lub ponownie wykorzystać zarezerwowaną przestrzeń na inne alokacje.

Kategoria B: wyeliminowanie niepotrzebnego wykorzystania pamięci

Wyeliminowanie zbędnych zasobów graficznych i buforów docelowych renderowania bezpośrednio zmniejsza bazowe wykorzystanie pamięci.

1. Optymalizuj tekstury renderowania i głębię kamery

  • Usuń bufory głębi i szablonu: ustaw format głębi i szablonu na Brak w przypadku tekstur renderowania, które wymagają tylko danych kolorów.
  • Wyłącz teksturę głębi kamery interfejsu: w przypadku kamer interfejsu, w których dane głębi są niepotrzebne, wyłącz generowanie tekstury głębi w ustawieniach kamery URP, aby wyeliminować przekazywanie CopyDepth i powiązaną pamięć tekstury GPU.

2. Optymalizuj tekstury i siatki

Kategoria Wskazówka dotycząca optymalizacji
Kompresja tekstur Zawsze stosuj formaty kompresji platformy docelowej (np. ASTC w przypadku Androida).
Włączony odczyt/zapis Wyłączaj, chyba że jest to konieczne. Włączenie tej opcji powoduje duplikowanie pamięci tekstury w pamięci RAM procesora i GPU.
Mipmapy Wyłącz mipmapy w przypadku tekstur interfejsu lub obiektów stałych w stałej odległości od kamery, co pozwala zaoszczędzić ok. 33% pamięci tekstury.
Złożoność siatki Zmniejsz niepotrzebną liczbę wielokątów i strumieni wierzchołków, aby zmniejszyć wykorzystanie pamięci GPU i pamięci natywnej.

3. Usuwaj warianty cieniowania i optymalizuj pamięć

Cieniowania Uber (np. URP Lit Shader) obejmują wiele funkcji korzystających ze słów kluczowych #multi_compile i shader_feature. Bez optymalizacji, eksplozja kombinatoryczna tworzy dziesiątki tysięcy unikalnych wariantów cieniowania, co prowadzi do zwiększenia rozmiaru kompilacji, znacznego zużycia pamięci natywnej i problemów z kompilacją sterowników GPU podczas rozgrywki.

A. Mechanika obciążenia pamięci wariantów cieniowania

  • Eksplozja kombinatoryczna: łączna liczba możliwych wariantów rośnie wykładniczo z każdym dodanym zestawem słów kluczowych.
  • Architektura alokacji fragmentów: Unity grupuje skompilowane warianty binarne w skompresowane bloki pamięci nazywane fragmentami (domyślnie: 4 MB).
  • Zwiększenie zużycia pamięci natywnej: gdy kod środowiska wykonawczego zażąda nawet jednego wariantu w fragmencie, cały 4-megabajtowy fragment zostanie zdekompresowany do pamięci RAM. Jeśli nie zostanie zoptymalizowany, tysiące nieużywanych wariantów spakowanych w tych fragmentach będą na stałe zajmować pamięć natywną.

B. Wbudowany w Unity wieloetapowy potok usuwania: Unity automatycznie usuwa niepotrzebne warianty podczas kompilacji na podstawie ustawień graficznych platformy docelowej i nieużywanych funkcji silnika (np. ustawień mgły, map światła i XR ustawień). Ponadto warianty shader_feature są automatycznie odfiltrowywane, jeśli ich słowa kluczowe nie są aktywnie używane przez żaden materiał w projekcie, natomiast warianty #multi_compile są wymuszane niezależnie od użycia.

C. Niestandardowa zautomatyzowana architektura usuwania (IPreprocessShaders) Ponieważ analiza statyczna nie może wykryć słów kluczowych modyfikowanych dynamicznie za pomocą skryptów C# środowiska wykonawczego (Material.EnableKeyword), standardowe usuwanie jest często niewystarczające. Aby mieć pewność, że uwzględniane są tylko faktycznie używane warianty, możesz zbierać warianty podczas testów QA za pomocą Player.log (włączając Log Shader Compilation w ustawieniach edytora) lub Profiler Traces (Shader.CreateGPUProgram znaczniki). Następnie zaimplementuj IPreprocessShaders.OnProcessShader w skrypcie edytora, aby odfiltrować warianty, które nigdy nie były wykonywane w środowisku wykonawczym, i zachować w kompilacji tylko te, które są potrzebne.

Kategoria C: wyeliminowanie niepotrzebnych alokacji GC

Alokacje odśmiecania (GC) w stercie zarządzanej prowadzą do fragmentacji pamięci, rozszerzania sterty, które nigdy się nie zmniejsza, i poważnych spadków liczby klatek podczas wstrzymywania GC.

1. Zapobiegaj alokacjom zamknięcia lambda

Gdy wyrażenie lambda przechwytuje zewnętrzne zmienne lokalne, C# generuje niejawną klasę wyświetlania w stercie. Wykonanie tej operacji w Update powoduje alokowanie instancji zamknięcia w każdej klatce.

// 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. Używaj stackalloc i Span

Unikaj alokacji sterty w przypadku krótkotrwałych tablic tymczasowych, używając pamięci stosu.

// 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. Optymalizuj iterację kolekcji

Unikaj alokacji sterty opakowywania i enumeratora spowodowanych przez rozszerzenia LINQ lub akcesory 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. Dodatkowe reguły zapobiegania alokacjom GC

  • Unikaj używania Camera.allCameras, ponieważ przy każdym wywołaniu generuje ono nową tablicę Camera[] w stercie. Zamiast tego zapisz w pamięci podręcznej tablicę kamer i przekaż ją do Camera.GetAllCameras(_allCameras).
  • Unikaj foreach w IReadOnlyList<T>: iterowanie po interfejsie powoduje opakowywanie enumeratora struktury, co generuje alokacje GC. Zamiast tego użyj standardowej pętli for.
  • Zapisuj w pamięci podręcznej obiekty współprogramów: zapisuj w pamięci podręcznej instancje WaitForSeconds zamiast wielokrotnie tworzyć instancje yield return new WaitForSeconds(time);.
  • Niestandardowe klucze struktury w słownikach: używanie niestandardowych struktur jako kluczy słownika powoduje wywołanie domyślnej funkcji Equals, co wywołuje opakowywanie obiektu. Zaimplementuj IEqualityComparer<T> i przekaż go do konstruktora słownika.
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. Minimalizuj użycie typów ogólnych i odbicia

Chociaż metody ogólne zapewniają doskonałe ponowne wykorzystanie kodu i łatwość utrzymania, ich nadmierne używanie może negatywnie wpłynąć na projekt w kontekście backendu Unity IL2CPP (Intermediate Language to C++).

  • Zwiększenie rozmiaru kodu IL2CPP: w przypadku każdej unikalnej kombinacji typu ogólnego IL2CPP generuje wyspecjalizowaną wersję kodu. Nadmierne używanie złożonych typów ogólnych może prowadzić do „eksplozji kombinatorycznej” generowanego kodu C++, co znacznie zwiększa rozmiar pliku binarnego aplikacji i wykorzystanie pamięci natywnej.
  • Obciążenie odbicia: metody korzystające z odbicia, takie jak System.Reflection interfejsy API, są z natury wolne i często powodują alokacje sterty podczas środowiska wykonawczego.
  • Sprawdzona metoda: używaj typów ogólnych z rozwagą – priorytetowo traktuj je pod kątem przejrzystości architektury, a nie szerokiego, nieograniczonego zastosowania. W przypadku, gdy wydajność jest krytyczna, preferuj typy konkretne lub polimorfizm oparty na interfejsach. W przypadku odbicia zapisuj w pamięci podręcznej wyniki, takie jak MethodInfo lub FieldInfo, podczas inicjowania zamiast wysyłać do nich zapytania w pętli aktualizacji.

6. Unikaj wycieków zarządzanych powłok

Każdy UnityEngine.Object, taki jak MonoBehaviour, Texture lub GameObject, ma otokę „Managed Shell” w C#, która komunikuje się z natywnym silnikiem C++.

  • Problem: jeśli powłoka zarządzana jest przechowywana w pamięci przez odwołanie statyczne, a trwałą subskrypcję zdarzenia lub nieoczyszczone zamknięcie, GC nie może odzyskać pamięci. Nawet jeśli obiekt natywny zostanie zniszczony, otoka zarządzana pozostaje, co prowadzi do „duchowych” wycieków pamięci, które zwiększają rozmiar sterty zarządzanej.
  • Rozwiązanie: zawsze wdrażaj niezawodne wzorce czyszczenia. Podczas niszczenia obiektów lub przechodzenia między scenami wyraźnie anuluj subskrypcję zdarzeń za pomocą operatora -= i ustaw odwołania statyczne do typów UnityEngine.Object na wartość null. To czyszczenie zapewnia, że GC może pomyślnie zebrać otokę, gdy silnik natywny zwolni swój uchwyt.