Pengoptimalan memori game Unity di Android

Meskipun kapasitas RAM fisik perangkat seluler modern terus bertambah, konsumsi memori game melampaui pertumbuhan ini karena aset beresolusi tinggi dan pipeline rendering yang kompleks.

Masalah utama yang disebabkan oleh penggunaan memori yang berlebihan

  • Penghentian low memory killer (LMK): OS menutup paksa aplikasi latar belakang atau bahkan aplikasi latar depan untuk mengatasi kekurangan memori di seluruh sistem.
  • Penurunan frame dan stuttering (jank): Lonjakan garbage collection (GC) yang sering terjadi di Managed Heap atau pertukaran memori tingkat OS akan membuat bottleneck pemrosesan.
  • Thermal throttling dan pengurasan baterai: Alokasi memori yang berkelanjutan, dealokasi, dan commit halaman memori akan menimbulkan overhead CPU yang signifikan, sehingga menyebabkan peningkatan panas dan pengurasan baterai yang lebih cepat.

Update manajemen memori Android

  • Android 17: Pengenalan MemoryLimiter: Android 17 memperkenalkan MemoryLimiter yang secara aktif memantau konsumsi memori aplikasi terhadap batas khusus perangkat. Aplikasi yang melebihi batas memori akan segera dihentikan di tingkat sistem. Karena mekanisme ini lebih ketat daripada LMK tradisional, pengelolaan penggunaan memori puncak kini lebih penting daripada sebelumnya.

Mengurangi penggunaan memori di Unity

Di Unity, setelah mesin memperluas kumpulan memori internalnya (Native Block Allocators dan Managed Heap) untuk mengakomodasi beban puncak yang tinggi, mesin akan mempertahankan halaman memori tersebut, bukan langsung mengembalikannya ke OS. Akibatnya, meskipun setelah aset yang berat di-unloading, Resident Memory dasar tetap meningkat, sehingga aplikasi sangat rentan terhadap penghentian proses OS Android (seperti LMK atau MemoryLimiter).

Untuk mencegah penerapan tingkat OS, pengoptimalan memori harus didekati melalui tiga pilar utama:

  • Kategori A: Mengurangi penggunaan memori puncak
  • Kategori B: Menghilangkan jejak aset dan sistem yang tidak diperlukan
  • Kategori C: Menghilangkan alokasi GC yang tidak diperlukan

Kategori A: Mengurangi penggunaan memori puncak

Pengalokasi Unity mempertahankan halaman memori yang dibebaskan untuk digunakan kembali, bukan langsung mengembalikannya ke OS, sehingga memori dasar cenderung mencerminkan puncak tertinggi yang dicapai, bukan penggunaan saat ini. Oleh karena itu, mencegah lonjakan sejak awal lebih efektif daripada mengandalkan pembersihan setelahnya.

1. Menghindari AssetBundle yang terlalu besar

Karena mekanisme Bundle Loading Unity, AssetBundle yang sangat besar menyebabkan bloat memori yang parah dan error OOM. Buat bundle tetap modular untuk memastikan pengelolaan resource yang efisien.

Masalah utama

  • Overhead memori yang tinggi: Meminta satu aset kecil akan memaksa Unity untuk memuat seluruh file bundle (termasuk header, metadata, dan buffer streaming) ke dalam RAM.
  • Unload trap: Jika ada aset dalam bundle yang sedang digunakan secara aktif, seluruh bundle tidak dapat di-unloading, sehingga data yang tidak digunakan akan terperangkap di RAM.

Praktik terbaik

  • Buat bundle tetap modular: Kelompokkan aset secara logis berdasarkan adegan atau siklus proses.
  • Tips Unity 6.6+: Gunakan Content Directories untuk mencegah dependensi lintas-bundle yang tidak diinginkan.

2. Mengoptimalkan referensi aset di ScriptableObjects

Kolom serial UnityEngine.Object langsung di ScriptableObject membuat referensi hard langsung, sehingga memaksa semua aset yang direferensikan untuk dimuat ke dalam RAM segera setelah ScriptableObject itu sendiri dimuat atau dibuat instance-nya.

// 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. Mengonfigurasi klip dan channel audio

Memuat semua klip audio yang tidak dikompresi langsung ke dalam memori akan membuat puncak memori permanen yang besar. Setelan pemuatan audio harus dikonfigurasi berdasarkan profil penggunaannya:

Kategori audio Jenis pemuatan Alasan
BGM (Musik Latar Belakang) Streaming Mengalirkan audio dari disk dalam buffer kecil untuk menghilangkan lonjakan memori.
SFX Panjang Dikirim dalam Memori Mempertahankan overhead RAM yang rendah dan mendekompresi audio secara langsung selama pemutaran.
SFX Singkat &Sering Decompress On Load Mendekompresi audio ke dalam RAM saat dimuat untuk menghindari overhead CPU runtime selama pemutaran.

Selain jenis pemuatan, terapkan konfigurasi audio berikut untuk lebih mengurangi overhead memori:

  • Aktifkan Force To Mono untuk audio non-directional: Banyak aset audio—seperti efek suara UI, voice-over, dan efek suara spasial 3D—tidak memerlukan pemisahan channel stereo. Aktifkan Force To Mono di AudioClip setelan impor untuk aset ini guna mengurangi jejak memori dan ukuran buffer menjadi setengahnya (pengurangan 50%).
  • Batasi channel audio aktif berdasarkan tingkat perangkat: Setiap suara audio aktif mengalokasikan mixer DSP native dan buffer dekompresi. Pada perangkat dengan memori rendah, kurangi jumlah maksimum channel aktif simultan—misalnya, dengan menurunkan Max Real Voices (seperti dari 32 menjadi 16) menggunakan AudioSettings.Reset di Unity, atau dengan membatasi channel pemutaran software di mesin audio pihak ketiga.

4. Menerapkan strategi pengumpulan dan rilis objek

Instance yang tidak dirilis dan tetap berada di dalam Object Pools di seluruh transisi adegan akan mempertahankan Reserved Memory tanpa batas waktu, sehingga jejak memori dasar meningkat secara tidak perlu.

  • Tindakan: Hapus atau pangkas objek gabungan yang tidak digunakan secara berkala selama transisi adegan atau periode aktivitas rendah, sehingga Unity dapat mengembalikan atau menggunakan kembali ruang yang dicadangkan untuk alokasi lain.

Kategori B: Menghilangkan penggunaan memori yang tidak diperlukan

Menghilangkan aset grafis yang berlebihan dan merender buffer target secara langsung akan menurunkan jejak memori dasar.

1. Mengoptimalkan tekstur render dan kedalaman kamera

  • Menghapus buffer kedalaman/stensil: Tetapkan Depth Stencil Format ke None untuk Render Textures yang hanya memerlukan data warna.
  • Menonaktifkan tekstur kedalaman kamera UI: Untuk kamera UI yang tidak memerlukan data kedalaman, nonaktifkan pembuatan tekstur kedalaman di setelan kamera URP untuk menghilangkan CopyDepth Pass dan memori tekstur GPU terkait.

2. Mengoptimalkan tekstur dan mesh

Kategori Panduan pengoptimalan
Kompresi tekstur Selalu terapkan format kompresi platform target (misalnya, ASTC untuk Android).
Read/Write diaktifkan Tetap nonaktif kecuali jika diperlukan. Mengaktifkan ini akan menduplikasi memori tekstur di seluruh RAM CPU dan GPU.
Mipmap Nonaktifkan Mipmap untuk tekstur atau objek UI yang ditetapkan pada jarak kamera konstan, sehingga menghemat memori tekstur ~33%.
Kompleksitas mesh Kurangi jumlah poligon dan aliran vertex yang tidak diperlukan untuk menurunkan jejak memori GPU dan native.

3. Menghapus varian shader dan mengoptimalkan memori

Uber-shader (misalnya, URP Lit Shader) merangkum banyak fitur menggunakan kata kunci #multi_compile dan shader_feature. Tanpa pengoptimalan, ledakan kombinatorial akan membuat puluhan ribu varian shaderunik, sehingga menyebabkan ukuran build meningkat, konsumsi memori native yang besar, dan gangguan kompilasi driver GPU selama gameplay.

A. Mekanisme overhead memori varian shader

  • Ledakan kombinatorial: Total varian yang mungkin tumbuh secara eksponensial dengan setiap grup kata kunci yang ditambahkan.
  • Arsitektur alokasi potongan: Unity mengelompokkan varian biner yang dikompilasi ke dalam blok memori terkompresi yang disebut Potongan (default: 4 MB).
  • Bloat memori native: Saat kode runtime meminta satu varian di dalam potongan, seluruh potongan 4 MB akan didekompresi ke dalam RAM. Jika tidak dioptimalkan, ribuan varian yang tidak digunakan dan dikemas dalam potongan tersebut akan menempati memori native secara permanen.

B. Pipeline penghapusan multi-tahap bawaan Unity: Unity secara otomatis menghapus varian yang tidak diperlukan pada waktu build berdasarkan setelan grafis platform target dan fitur mesin yang tidak digunakan (misalnya, Fog, Lightmaps, dan setelan XR ). Selain itu, varian shader_feature akan otomatis difilter jika kata kuncinya tidak digunakan secara aktif oleh materi apa pun dalam project, sedangkan varian #multi_compile akan disertakan secara paksa, terlepas dari penggunaannya. Namun, jika fitur rendering atau pasca-pemrosesan (seperti Additional Lights, bayangan real-time, atau efek UberPost yang tidak digunakan) tetap diaktifkan di setelan URP atau pipeline, Unity tidak dapat menentukan apakah konten Anda benar-benar menggunakannya dan menghasilkan semua varian #multi_compile yang sesuai. Audit dan nonaktifkan setelan pipeline yang tidak digunakan sehingga sesuai dengan persyaratan rendering sebenarnya dan mencegah pembuatan varian yang tidak diperlukan.

C. Arsitektur penghapusan otomatis kustom (IPreprocessShaders) Karena analisis statis tidak dapat mendeteksi kata kunci yang diubah secara dinamis menggunakan skrip C# runtime (Material.EnableKeyword), penghapusan standar sering kali tidak memadai. Untuk memastikan hanya varian yang benar-benar digunakan yang disertakan, Anda dapat mengumpulkan varian selama suite pengujian QA menggunakan Player.log (mengaktifkan Log Shader Compilation di setelan Editor) atau Profiler Traces (Shader.CreateGPUProgram penanda). Kemudian, terapkan IPreprocessShaders.OnProcessShader dalam skrip Editor untuk memfilter varian yang tidak pernah dieksekusi selama runtime, sehingga hanya varian yang diperlukan yang disimpan dalam build.

D. Menggabungkan shader yang berlebihan: Uber-shader seperti Lit dan Simple Lit berisi banyak direktif #multi_compile dan menggunakan memori yang besar saat membuat banyak varian:

  • Membuat profil penggunaan simultan: Buat profil penggunaan memori Anda untuk memeriksa apakah kedua shader menggunakan memori yang signifikan pada saat yang sama, dan verifikasi apakah project Anda benar-benar memerlukan keduanya.
  • Menstandarkan satu shader: Jika rendering berbasis fisik (PBR) tidak diperlukan, konfigurasi aset Anda untuk menghilangkan penggunaan shader Lit yang berlebihan dan menstandarkan Simple Lit (misalnya, periksa apakah model FBX yang diekspor memiliki materi Lit default yang tidak sengaja diterapkan selama impor).

Kategori C: Menghilangkan alokasi GC yang tidak diperlukan

Alokasi Garbage Collection (GC) di Managed Heap menyebabkan fragmentasi memori, perluasan heap yang tidak pernah menyusut, dan penurunan frame yang parah selama jeda GC.

1. Mencegah alokasi penutupan lambda

Saat ekspresi lambda mengambil variabel lokal luar, C# akan membuat class tampilan implisit di Heap. Menjalankan ini di dalam Update akan mengalokasikan instance penutupan setiap frame.

// 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. Menggunakan stackalloc dan Span

Hindari alokasi Heap untuk array sementara yang berumur pendek dengan menggunakan memori Stack.

// 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. Mengoptimalkan iterasi koleksi

Hindari boxing dan alokasi heap Enumerator yang disebabkan oleh ekstensi LINQ atau pengakses 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. Aturan pencegahan alokasi GC tambahan

  • Hindari penggunaan Camera.allCameras karena akan menghasilkan array Camera[] baru di heap pada setiap panggilan. Sebagai gantinya, cache array kamera dan teruskan ke Camera.GetAllCameras(_allCameras).
  • Hindari foreach di IReadOnlyList<T>: Melakukan iterasi pada antarmuka akan menyebabkan boxing enumerator struct, sehingga menghasilkan alokasi GC. Gunakan loop for standar sebagai gantinya.
  • Objek coroutine cache: Cache WaitForSeconds instance, bukan membuat instance yield return new WaitForSeconds(time); berulang kali.
  • Kunci struct kustom dalam kamus: Menggunakan struct kustom sebagai kunci Dictionary akan memanggil Equals default, sehingga memicu boxing objek. Terapkan IEqualityComparer<T> dan teruskan ke konstruktor Dictionary.
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. Meminimalkan penggunaan generik dan refleksi

Meskipun metode generik memberikan penggunaan kembali dan pemeliharaan kode yang sangat baik, penggunaannya yang berlebihan dapat memengaruhi project Anda secara negatif dalam konteks backend Unity IL2CPP (Intermediate Language to C++).

  • Bloat kode IL2CPP: Untuk setiap kombinasi jenis generik yang unik, IL2CPP akan menghasilkan versi kode khusus. Penggunaan generik kompleks yang berlebihan dapat menyebabkan "ledakan kombinatorial" kode C++ yang dihasilkan, sehingga meningkatkan ukuran biner aplikasi dan jejak memori native secara signifikan.
  • Overhead refleksi: Metode yang menggunakan refleksi, seperti System.Reflection API, secara inheren lambat dan sering menyebabkan alokasi heap selama runtime.
  • Praktik terbaik: Gunakan generik dengan bijak—prioritaskan untuk kejelasan arsitektur, bukan aplikasi yang luas dan tidak diskriminatif. Jika performa sangat penting, pilih jenis konkret atau polimorfisme berbasis antarmuka. Untuk refleksi, cache hasil seperti MethodInfo atau FieldInfo selama inisialisasi, bukan mengkuerinya dalam loop update.

6. Menghindari shell terkelola yang bocor

Setiap UnityEngine.Object, seperti MonoBehaviour, Texture, atau GameObject, memiliki wrapper "Managed Shell" C# yang berkomunikasi dengan mesin C++ native.

  • Masalah: Jika Managed Shell disimpan dalam memori oleh referensi statis, langganan peristiwa persisten, atau penutupan yang tidak dibersihkan, GC tidak dapat mengklaim kembali memori. Meskipun objek native dihancurkan, wrapper terkelola akan tetap ada, sehingga menyebabkan kebocoran memori "hantu" yang meningkatkan Managed Heap.
  • Resolusi: Selalu terapkan pola pembersihan yang kuat. Saat menghancurkan objek atau melakukan transisi adegan, batalkan langganan peristiwa secara eksplisit menggunakan operator -= dan batalkan referensi statis ke jenis UnityEngine.Object. Pembersihan ini memastikan GC dapat berhasil mengumpulkan wrapper setelah mesin native merilis handle-nya.

7. Menggunakan katalog biner untuk Addressables

Di Unity 2022 dan versi sebelumnya, Addressables menggunakan katalog JSON, bukan katalog biner (yang merupakan default di Unity 2023 dan versi yang lebih baru). Jika project Anda menggunakan katalog JSON, memuat katalog besar saat startup akan membaca seluruh file ke dalam satu string C# besar dan mengalokasikan ribuan string terkelola dan array byte[] sementara selama penguraian JSON dan decoding Base64. Hal ini menyebabkan lonjakan alokasi GC yang besar dan memperluas Managed Heap secara permanen, jadi beralihlah ke katalog biner untuk melakukan streaming data ringkas secara langsung.