15.3 Memory & Asset Optimization

Phase 15 · Optimization & Mobile · Study time: 25–45 h

Keeping memory in budget — texture compression, streaming, object pooling and avoiding garbage-collection spikes in C#.

Every optimization chapter so far has been about speed: fewer draw calls, cheaper collision checks, less CPU work per frame. This chapter is about a different budget: memory. A game that runs at a smooth 60 frames per second can still crash instantly if it asks the device for more RAM than the operating system will give it — no exception, no stack trace most of the time, just a silent kill. Mobile is where this bites hardest: a phone might only let your app use somewhere between 500 MB and 2 GB before the OS decides your game is misbehaving and terminates it.

Back in the C chapter, memory was just numbered bytes you tracked by hand with malloc and free. In a real game built on Unity you don't track every byte yourself, but the bytes are still there, and they still run out. This chapter is about knowing where they go — textures, meshes, audio, and the C# objects your code creates — and about the habits that keep a long play session from slowly eating all of them. We will also connect back to the C# garbage-collection chapter: garbage collection is not free, and the best way to deal with it is to give it less work to do in the first place.

1. Where Your Game's Memory Actually Goes

"Optimize memory" is too vague to act on. The useful first question is: for a typical 3D game, which categories eat the most RAM, and in what order? For almost every game, the order looks like this: textures first (by a wide margin), then meshes (3D models — the vertex and triangle data), then audio, then code and objects (the C# managed heap plus native engine bookkeeping). There are exceptions — a game with huge open-world terrain data or a game built almost entirely from audio — but for the kind of 3D game most beginners build first, textures dominate.

Suppose you are targeting a mid-range Android phone, and after accounting for the OS and background apps, your game realistically has about 700 MB to work with before things get risky. A reasonable starting budget, split by category, looks like this:

MEMORY BUDGET -- mid-range mobile target, 700 MB total ================================================================ Category Share Budget Bar ---------------------------------------------------------------- Textures 55% 385 MB [###################] Meshes 15% 105 MB [#####] Audio 12% 84 MB [####] Code / objects 10% 70 MB [####] Other (UI, fonts) 8% 56 MB [###] ---------------------------------------------------------------- TOTAL 100% 700 MB ================================================================ Order confirmed: Textures > Meshes > Audio > Code/objects > Other

This is not a law of physics, it is a starting point you measure against. The rest of this chapter goes category by category: how to compute what something actually costs, and what to do when a category blows past its share.

Tip Set a rough budget like this table before you have a memory problem, not after. When a category goes over, you find out from a number, not from a crash report two weeks before launch.

2. Texture Memory: The Real Math

A texture's file size on disk (a compressed .png or .jpg) tells you almost nothing about how much RAM it uses once loaded. What matters at runtime is the uncompressed, GPU-ready footprint, and there is a simple formula for it:

texture memory (bytes) = width * height * bytes_per_pixel * mip_factor

bytes_per_pixel depends on the GPU texture format you chose in the import settings, not the source file format. mip_factor accounts for mip maps (mipmaps — smaller, pre-shrunk copies of the same texture, used automatically when an object is far from the camera so the GPU does not sample a huge texture for a tiny handful of pixels on screen). A full mip chain adds roughly one third more memory on top of the base image, because each smaller mip level is a quarter the size of the one before it, and the series 1 + 1/4 + 1/16 + 1/64 + ... converges to 4/3. So mip_factor ≈ 1.33 with mips on, or 1.0 with mips off.

Let's compute a real example: a 2048x2048 texture, the kind of resolution used for a hero character or a large environment surface.

UNCOMPRESSED (RGBA32, 4 bytes per pixel) ----------------------------------------- base = 2048 * 2048 * 4 = 16,777,216 bytes = 16.00 MB + mips = 16.00 MB * 4/3 = 21.33 MB vs. ASTC 4x4 (compressed, 1 byte per pixel effective) ----------------------------------------- base = 2048 * 2048 * 1 = 4,194,304 bytes = 4.00 MB + mips = 4.00 MB * 4/3 = 5.33 MB RESULT: same texture, same resolution, same mip chain -- 21.33 MB uncompressed vs 5.33 MB compressed. That is a 4x reduction from changing ONE import setting.

ASTC (Adaptive Scalable Texture Compression) is the GPU texture compression format used on modern mobile GPUs (and supported on desktop too). Unlike a .png, which the CPU decompresses into a full uncompressed buffer before the GPU can use it, ASTC stays compressed in GPU memory and is sampled directly — the GPU decodes small blocks on the fly, in hardware, as it reads pixels. That is why ASTC's "bytes per pixel" is a real, permanent number, not just a disk-size trick.

ASTC lets you choose a block size, which trades quality for size. A smaller block (4x4) keeps more detail per pixel; a larger block (8x8) packs more pixels into the same compressed data, at a quality cost:

FORMAT COMPARISON -- 2048x2048 texture ========================================================================== Format Bytes/pixel Base size With mip chain (x 4/3) -------------------------------------------------------------------------- RGBA32 (raw) 4.00 16.00 MB 21.33 MB RGB24 (raw) 3.00 12.00 MB 16.00 MB ASTC 4x4 1.00 4.00 MB 5.33 MB ASTC 6x6 0.44 1.78 MB 2.37 MB ASTC 8x8 0.25 1.00 MB 1.33 MB ==========================================================================

You can check a texture's real runtime footprint at any time with Profiler.GetRuntimeMemorySizeLong, which asks Unity directly instead of trusting the math by hand:

using UnityEngine;
using UnityEngine.Profiling;

public class TextureMemoryCheck : MonoBehaviour
{
    public Texture2D texture;

    void Start()
    {
        long bytes = Profiler.GetRuntimeMemorySizeLong(texture);
        float megabytes = bytes / 1024f / 1024f;
        Debug.Log(texture.name + " uses " + megabytes.ToString("F2") + " MB");
    }
}

With the import setting left at "None" (uncompressed RGBA32) on our 2048x2048 example, this prints RockDiffuse uses 21.33 MB. Switch the Texture Import Settings compression to ASTC 4x4 and press Play again, and the same script prints RockDiffuse uses 5.33 MB — the exact numbers from the math above, because the math is what Unity is doing internally.

Common mistake Importing an artist's source texture at its original resolution (often 4096x4096 or larger) even though the object only ever appears small on screen — a UI icon shown at 64 pixels does not need a 2048x2048 source texture. Check the Max Size import setting against how large the texture actually appears, not against how large the source file happens to be.
Tip Prefer power-of-two, square textures (256x256, 512x512, 1024x1024...). Some compression formats and mip streaming systems require or strongly prefer power-of-two dimensions, and non-power-of-two textures can silently fall back to an uncompressed format.

3. Mesh Memory: Vertex Count x Vertex Layout

A mesh (the triangles that make up a 3D model) costs memory in two buffers: the vertex buffer (one entry per vertex, holding everything the GPU needs to know about that point — position, normal, etc.) and the index buffer (a list of integers saying which three vertices make each triangle, so shared vertices are not duplicated).

mesh memory (bytes) = vertexCount * bytesPerVertex + indexCount * bytesPerIndex

bytesPerVertex is not fixed — it depends on which channels (attributes) each vertex carries. A typical, fully-featured vertex layout looks like this:

FULL VERTEX LAYOUT ====================================================== Attribute Type Bytes ------------------------------------------------------ Position float3 (x,y,z) 12 Normal float3 (x,y,z) 12 Tangent float4 16 UV0 (base texture) float2 8 UV1 (lightmap) float2 8 Color 4 x byte 4 ------------------------------------------------------ TOTAL PER VERTEX 60 bytes ======================================================

Every one of those channels costs bytes for every single vertex, whether or not anything actually uses it. Tangent data is only needed if a shader reads a normal map. UV1 is only needed for baked lightmaps. Vertex color is only needed if a shader reads it (for blending terrain textures, for example). If a mesh does not need a channel, stripping it out of the Model Import Settings (unchecking Normals, Tangents, or Vertex Colors, or removing an unused UV set) is free memory — it costs nothing to remove and nothing renders differently.

Take a character mesh with 20,000 vertices and about 30,000 triangles (90,000 indices). With the full 60-byte layout:

FULL LAYOUT (60 bytes/vertex) vertex buffer = 20,000 * 60 = 1,200,000 bytes = 1.14 MB index buffer = 90,000 * 2 (ushort) = 180,000 bytes = 0.17 MB TOTAL = 1.32 MB STRIPPED LAYOUT -- position + normal + UV0 only (32 bytes/vertex) vertex buffer = 20,000 * 32 = 640,000 bytes = 0.61 MB index buffer = 90,000 * 2 (ushort) = 180,000 bytes = 0.17 MB TOTAL = 0.78 MB Removing tangent, UV1, and vertex color cut this one mesh from 1.32 MB to 0.78 MB -- about 41% smaller, for free.

You can check a mesh's real layout cost the same way we checked textures: ask the engine directly instead of guessing.

using UnityEngine;
using UnityEngine.Rendering;

public class MeshMemoryCheck : MonoBehaviour
{
    public MeshFilter target;

    void Start()
    {
        Mesh mesh = target.sharedMesh;
        int bytesPerVertex = 0;
        foreach (VertexAttributeDescriptor d in mesh.GetVertexAttributes())
        {
            bytesPerVertex += ComponentByteSize(d.format) * d.dimension;
        }

        long vertexBufferBytes = (long)bytesPerVertex * mesh.vertexCount;
        int indexBytes = (mesh.indexFormat == IndexFormat.UInt16) ? 2 : 4;
        long indexBufferBytes = (long)mesh.GetIndexCount(0) * indexBytes;
        float totalMB = (vertexBufferBytes + indexBufferBytes) / 1024f / 1024f;

        Debug.Log(mesh.name + ": " + bytesPerVertex + " bytes/vertex, total " +
                   totalMB.ToString("F2") + " MB");
    }

    int ComponentByteSize(VertexAttributeFormat format)
    {
        switch (format)
        {
            case VertexAttributeFormat.Float32: return 4;
            case VertexAttributeFormat.Float16: return 2;
            case VertexAttributeFormat.UNorm8:  return 1;
            default: return 4;
        }
    }
}

Before stripping channels, this prints CharacterMesh: 60 bytes/vertex, total 1.32 MB. After unchecking Tangents, the second UV set, and Vertex Colors in the Model Import Settings and re-importing, it prints CharacterMesh: 32 bytes/vertex, total 0.78 MB — matching the hand computation exactly, because it is the same formula.

Tip Mesh data is shared per asset, not per instance. If you have 200 copies of the same enemy in a scene, they all point at one shared mesh in memory — you pay the 0.78 MB once, not 200 times. Where mesh memory really adds up is having hundreds of different, unique meshes, each with wasted channels.

4. Audio Memory: Streaming vs Decompressed

Audio memory works differently from textures and meshes because you get to choose, per clip, whether the sound lives in RAM as raw numbers or gets decoded on demand. Unity's import setting for this is the Load Type, with three options: Decompress On Load (the whole clip is decoded into raw PCM samples in RAM the moment it loads, ready for instant, cheap playback), Compressed In Memory (the compressed bytes stay in RAM and get decoded a little at a time during playback — smaller footprint, a bit more CPU per frame), and Streaming (the compressed bytes are not even fully loaded — they are read and decoded straight from disk in small chunks as the clip plays, so RAM usage stays tiny and roughly constant no matter how long the clip is).

The size of a fully decompressed clip is straightforward to compute:

decompressed size (bytes) = duration_seconds * sampleRate * channels * bytesPerSample

Let's compute two very different clips at CD-quality settings (44100 Hz, stereo, 16-bit samples = 2 bytes per sample):

SHORT SFX -- 2 second explosion sound bytes = 2 * 44100 * 2 * 2 = 352,800 bytes = 0.34 MB decompressed MUSIC TRACK -- 180 second (3 minute) background theme bytes = 180 * 44100 * 2 * 2 = 31,752,000 bytes = 30.28 MB decompressed The SFX is small enough that "Decompress On Load" costs almost nothing and buys instant, glitch-free playback. The music track, fully decompressed, is nearly 100x bigger than the SFX and would eat a huge share of the whole audio budget -- this is exactly why long clips use Streaming instead.
using UnityEngine;

public class AudioMemoryCheck : MonoBehaviour
{
    public AudioClip clip;

    void Start()
    {
        long samples = clip.samples;
        int channels = clip.channels;
        long bytes = samples * channels * 2; // 16-bit PCM = 2 bytes/sample
        float megabytes = bytes / 1024f / 1024f;
        Debug.Log(clip.name + ": " + megabytes.ToString("F2") + " MB decompressed");
    }
}

Run this on the explosion clip and it prints explosion: 0.34 MB decompressed. Run it on the music track and it prints theme_music: 30.28 MB decompressed — matching the worked math, and showing exactly why one of these should never be fully decompressed into RAM at once.

Rule of thumb: short, frequently-repeated sounds (footsteps, gunshots, UI clicks) use Decompress On Load — the RAM cost is small and playback needs to start instantly with zero decoding delay. Long, rarely-repeated audio (music, ambient loops, long dialogue) uses Streaming — the RAM cost stays flat no matter the length. Compressed In Memory sits in between, useful for medium-length clips that play occasionally but are not worth streaming from disk.

Common mistake Leaving a 3-minute music track on the default "Decompress On Load" setting. Instead of a near-constant streaming footprint of a few hundred KB, it eats over 30 MB of RAM for the entire time it exists in memory — and if a game has several tracks all imported this way, that adds up to a large, silent chunk of the audio budget for no audible benefit.

5. Object Pooling: Stop Allocating, Start Reusing

Bullets, particles, hit-effects, enemies that spawn and despawn constantly — anything created and destroyed at a high rate is a memory problem even if each individual instance is small. Every Instantiate and Destroy call has real cost: engine-side construction and teardown of a GameObject and its components, plus, on the managed side, heap allocations that later become garbage. Do this dozens of times in one frame — a shotgun firing 12 pellets, an explosion spawning 40 particles — and you get an allocation spike: a sudden burst of memory work concentrated on one frame that shows up as a visible stutter.

The fix is object pooling: instead of destroying an object when you are done with it, deactivate it and put it back in a pool (a container of ready-to-reuse objects) so a future request can reuse it instead of creating a new one. Here is a full, generic pool:

using System.Collections.Generic;
using UnityEngine;

public class ObjectPool<T> where T : Component
{
    private readonly Queue<T> pool = new Queue<T>();
    private readonly T prefab;
    private readonly Transform parent;

    public ObjectPool(T prefab, Transform parent, int prewarmCount)
    {
        this.prefab = prefab;
        this.parent = parent;

        for (int i = 0; i < prewarmCount; i++)
        {
            T obj = Object.Instantiate(prefab, parent);
            obj.gameObject.SetActive(false);
            pool.Enqueue(obj);
        }
    }

    public T Get()
    {
        T obj = pool.Count > 0 ? pool.Dequeue() : Object.Instantiate(prefab, parent);
        obj.gameObject.SetActive(true);
        return obj;
    }

    public void Return(T obj)
    {
        obj.gameObject.SetActive(false);
        pool.Enqueue(obj);
    }
}

Prewarming (creating the objects once, up front, in prewarmCount) pays the expensive Instantiate cost during a loading screen instead of during gameplay. After that, Get() and Return() only flip SetActive and shuffle a queue — no engine construction, no new managed allocations, in steady state.

Using the Pool

A bullet needs to know how to give itself back to the pool when its lifetime ends, without creating a new closure every time it fires (more on why that matters in section 7):

using UnityEngine;

public class Bullet : MonoBehaviour
{
    private ObjectPool<Bullet> pool;
    private float lifetime;
    private float timer;

    public void Init(ObjectPool<Bullet> ownerPool, float life)
    {
        pool = ownerPool;
        lifetime = life;
    }

    void OnEnable()
    {
        timer = 0f;
    }

    void Update()
    {
        timer += Time.deltaTime;
        if (timer >= lifetime)
        {
            pool.Return(this);
        }
    }
}

public class BulletSpawner : MonoBehaviour
{
    public Bullet bulletPrefab;
    private ObjectPool<Bullet> bulletPool;

    void Awake()
    {
        bulletPool = new ObjectPool<Bullet>(bulletPrefab, transform, 30);
    }

    public void Fire(Vector3 position, Quaternion rotation)
    {
        Bullet bullet = bulletPool.Get();
        bullet.transform.SetPositionAndRotation(position, rotation);
        bullet.Init(bulletPool, 3f);
    }
}
OBJECT POOL ====================================================== POOL (inactive, waiting) ACTIVE (in the scene) +-----+ +-----+ +-----+ +-----+ +-----+ | obj | | obj | | obj | | obj | | obj | +-----+ +-----+ +-----+ +-----+ +-----+ -------- Get() -------> (dequeue, SetActive(true)) <------ Return() ------ (SetActive(false), enqueue) ====================================================== No Instantiate() and no Destroy() happen after prewarming -- objects just move between the two groups.

The Profiler Difference

Open Window > Analysis > Profiler, enable the CPU module, and watch the GC Alloc column while bullets fire. Without pooling — calling Instantiate on a bullet prefab and Destroy on expiry — every single shot shows a nonzero GC Alloc entry, and firing many bullets in one frame produces a visible spike in that frame's total. With the pool in place, the same test shows GC Alloc: 0 B for the Get()/Return() calls during steady gameplay, because nothing after prewarming ever touches the managed heap.

Tip Unity ships a built-in generic pool, UnityEngine.Pool.ObjectPool<T>, with the same idea plus extra hooks (OnGet, OnRelease, OnDestroy, a max size that destroys overflow instead of growing forever). Once you understand the pool above, it is usually the better production choice — but knowing how to build one yourself means you are never stuck when a project's needs do not fit the built-in version.
Common mistake Forgetting to reset an object's state in Get() or Return(). A pooled bullet that keeps its old velocity, a pooled particle that keeps its old color, or a pooled enemy that keeps its old health from three fights ago will produce bugs that look completely unrelated to pooling. Always reset every piece of state a reused object depends on.

6. Unity's Garbage Collector: What It Is and Why It Hurts

Recall from the C# chapter: a garbage collector (GC) automatically frees heap memory once nothing reachable points to it anymore, so you never call anything like C's free yourself. That is convenient, but it is not free — the GC has to do work to figure out what is still reachable, and that work costs CPU time, taken away from your game's frame budget.

Concretely, the GC's job happens in two phases: mark (starting from a set of roots — static fields, local variables currently on the stack, and so on — walk every reference and flag every object that is still reachable) and sweep (reclaim the memory of everything that was not flagged). Older, non-incremental collectors did this as one big pause: your game froze completely while the GC finished marking and sweeping the entire heap, which could take several milliseconds — long enough to drop a frame outright. Newer Unity versions default to an Incremental Garbage Collector, which spreads that same work across several frames in small slices instead of one giant pause. This smooths things out, but it does not make the work free — it still costs the same total CPU time, and a slice can still land on a frame that is already tight on budget.

NON-INCREMENTAL GC INCREMENTAL GC frame time (ms) frame time (ms) | | | ____ | _ _ _ _ | | | | | | | | | | | | |_______| |______ |___| |_| |_| |_| |__ | one huge stop-the-world | several small slices | pause -- one dropped frame | spread across frames -- | | smoother, same total cost

Either way, the amount of work the GC has to do scales with how much garbage (unreachable heap memory) your game produces, and, for the mark phase, with how many objects are alive to trace through in the first place. Section 7 covers the specific coding patterns that quietly generate the most garbage.

7. What Actually Creates Garbage

Most garbage in a real Unity project does not come from one obvious spot — it comes from small, easy-to-miss patterns repeated every frame. Here are the four worst offenders.

String Concatenation in Update

Recall from the C# basics chapter: strings are immutable — you can never change a string in place, so every concatenation builds a brand-new string object on the heap and throws the old one away.

// BEFORE -- allocates a new string every single frame
void Update()
{
    scoreText.text = "Score: " + score + " / " + maxScore;
}

In the Profiler, this line shows roughly 40-50 bytes of GC Alloc on every frame this label is visible — at 60 FPS that is a few kilobytes of garbage per second, forever, even on a frame where the score never actually changed.

// AFTER -- only rebuilds the string when the score actually changes
private readonly StringBuilder scoreLabel = new StringBuilder(32);
private int lastDisplayedScore = -1;

void Update()
{
    if (score != lastDisplayedScore)
    {
        scoreLabel.Clear();
        scoreLabel.Append("Score: ").Append(score).Append(" / ").Append(maxScore);
        scoreText.text = scoreLabel.ToString();
        lastDisplayedScore = score;
    }
}

The dirty flag (lastDisplayedScore) means the expensive work only happens on the frame the score changes, not on every frame it is merely displayed. StringBuilder also avoids creating an intermediate throwaway string for each + the way plain concatenation does. There is still one allocation from ToString() — but only when the value changes, not 60 times a second.

Boxing

Boxing is what happens when a value type (like int or a struct) needs to be treated as an object reference — the runtime wraps a heap-allocated box around the value just so it can be handled the way reference types are. This is invisible in the code but very visible in the profiler.

// BEFORE -- ArrayList stores everything as object, so every int gets boxed
ArrayList scores = new ArrayList();
scores.Add(100); // 100 is boxed into a new heap object right here
// AFTER -- List<int> stores the int inline, no boxing at all
List<int> scores = new List<int>();
scores.Add(100); // no allocation -- the int is stored directly

The same problem shows up any time a value type is passed where an object is expected — a non-generic method signature, for instance:

// BEFORE -- boxes the int on every call
void PrintValue(object value) { Debug.Log(value); }
PrintValue(42);
// AFTER -- generic method, T is resolved to int at compile time, no boxing
void PrintValue<T>(T value) { Debug.Log(value); }
PrintValue(42);

LINQ in Hot Paths

LINQ (Where, Select, ToList, and friends) is convenient, but most of its operators allocate — an iterator object for Where, a brand-new list for ToList — every single time the line runs. That is fine for code that runs once, on a menu click. It is a steady garbage source inside Update.

// BEFORE -- allocates an iterator AND a new List<Enemy> every single frame
void Update()
{
    var aliveEnemies = enemies.Where(e => e.IsAlive).ToList();
    foreach (var e in aliveEnemies)
    {
        e.Tick();
    }
}
// AFTER -- a plain loop, zero allocation
void Update()
{
    for (int i = 0; i < enemies.Count; i++)
    {
        if (enemies[i].IsAlive)
        {
            enemies[i].Tick();
        }
    }
}

LINQ is not banned — using it once when a level loads is completely fine. The rule is narrower: do not put an allocating LINQ chain inside a method that runs every frame.

Closures That Capture Variables

A closure is a function value (a lambda or delegate) that references a variable from the method around it. To keep that variable alive after the outer method returns, the compiler generates a hidden object on the heap to hold it — that hidden object is exactly what gets allocated every time the lambda is created.

// BEFORE -- each call allocates a new hidden object to hold "enemyId"
void SetupButton(Button button, int enemyId)
{
    button.onClick.AddListener(() => KillEnemy(enemyId));
}

Spawn 50 buttons at once when a level loads (a common wave-select or shop screen pattern) and this line alone produces 50 small heap allocations, all in the same frame — an allocation spike from something that looks completely harmless in the code.

// AFTER -- no capture: the id lives on the button itself, listener never changes
public class EnemyButton : MonoBehaviour
{
    public int EnemyId;

    void Awake()
    {
        GetComponent<Button>().onClick.AddListener(HandleClick);
    }

    void HandleClick()
    {
        KillEnemy(EnemyId);
    }
}

The fixed version's listener method captures nothing — it reads EnemyId off the same object it already lives on, so the same non-capturing delegate can be reused instead of building a new closure per button. The bullet's Init(ownerPool, life) pattern back in section 5 uses the exact same trick on purpose: store data as fields, read it back inside a method that captures nothing.

8. Why Avoiding Garbage Beats Collecting It

It is tempting to think of the GC as a safety net that makes allocation cost irrelevant — after all, it cleans up automatically. But the mark phase has to trace every live object to prove it is still reachable, not just the recently created ones, so a scene with a large live object count makes every single GC pass more expensive, regardless of how much fresh garbage you produced since the last one. Producing less garbage helps in two separate ways: the GC needs to run less often (the heap fills up more slowly), and each run traces through less accidental short-lived clutter.

The best possible number of GC-caused frame hitches is zero, and the only way to guarantee zero is to not allocate in the first place. An incremental GC, object pooling, and non-allocating code are not competing strategies — they stack. Pooling and the fixes in section 7 reduce how much garbage exists at all; the incremental GC then has less work to smooth out, and smooths out what remains. The short version: the fastest garbage collection is the collection that never has to run.

Tip Keep the Profiler's CPU module open with the GC Alloc column visible while you write gameplay code, not just when you are hunting a specific bug. Seeing a nonzero number appear the moment you add a line is the cheapest possible way to catch a habit before it ships.

9. Asset Bundles and Addressables: Load AND Unload

AssetBundles are Unity's mechanism for packaging content (textures, prefabs, whole scenes, audio) into separate files that load at runtime instead of being baked directly into the game's initial build. This keeps the first download small and lets content be updated without resubmitting the whole app. Addressables is the modern system built on top of AssetBundles: instead of holding a direct reference to an asset, you refer to it by an address (a string key), and the system finds, downloads, and loads it for you.

The part that trips up beginners is that Addressables is reference-counted: loading an asset increments a counter, and the asset only actually leaves memory once that counter drops back to zero via a matching release call. Nothing frees itself automatically just because you stopped using it.

using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using System.Collections;

public class LevelLoader : MonoBehaviour
{
    private AsyncOperationHandle<GameObject> handle;

    public IEnumerator LoadLevel(string address)
    {
        handle = Addressables.LoadAssetAsync<GameObject>(address);
        yield return handle;

        GameObject level = handle.Result;
        Instantiate(level);
    }

    public void UnloadLevel()
    {
        Addressables.Release(handle); // without this call, the level's
                                       // textures, meshes, and audio
                                       // stay resident forever
    }
}

For every LoadAssetAsync there must be exactly one matching Release — the same discipline as malloc and free from the C chapter. The difference is what a mistake looks like: forgetting free in a small C program usually shows up fast; forgetting Addressables.Release shows up slowly, as a memory leak that only becomes visible after real players spend a long session in your game.

THE CLASSIC BUG -- LoadLevel called without a matching UnloadLevel ======================================================================== enter Level A -> Load (ref count 1) -> play -> leave (no Release!) enter Level B -> Load (ref count 1) -> play -> leave (no Release!) enter Level C -> Load (ref count 1) -> play -> leave (no Release!) | v memory resident: Level A + Level B + Level C, all at once, even though the player is only looking at Level C right now. After 10 level transitions in one session: 10 levels' worth of textures, meshes, and audio sitting in RAM. Eventually the OS kills the app for using too much memory -- looks like a random crash with no exception in the logs. ========================================================================
Common mistake Calling LoadAssetAsync every time a level starts but only calling Release in one specific exit path (say, a "Return to Menu" button) and forgetting the other paths (dying, a scripted level transition, an app-backgrounding event). Every unhandled path is a leak that only shows up after enough playtime to notice.

10. Memory Fragmentation

Memory fragmentation happens when a heap has plenty of total free memory, but that free memory is scattered across many small gaps instead of one contiguous block — so a request for one large allocation can fail even though the sum of all the free gaps would easily cover it.

FRAGMENTED HEAP ========================================================== [ A: 2MB ][free: 0.5MB][ B: 4MB ][free: 0.3MB][ C: 1MB ][free: 0.4MB] Total free space: 0.5 + 0.3 + 0.4 = 1.2 MB A new 1 MB texture needs ONE contiguous 1 MB gap. The largest single gap here is only 0.5 MB -- the allocation FAILS despite 1.2 MB being free overall. ==========================================================

This matters most for Unity's native (unmanaged) heap — the memory backing textures, meshes, and audio buffers — because that heap is not compacted (rearranged to merge free gaps back together) the way parts of the managed C# heap can be. A long play session that repeatedly loads and unloads many differently-sized assets, over and over, can fragment that native heap over time, eventually causing allocation failures even while a memory overview shows plenty of "total free" space. Loading and unloading in a more predictable, consistent order, and unloading unused Addressables content between big transitions (like a level or scene change) rather than piecemeal mid-level, both reduce how fragmented the heap gets over a long session.

11. Hunting a Leak With the Memory Profiler

When memory usage climbs over time and nothing you have checked so far explains it, the Memory Profiler (Window > Analysis > Memory Profiler, a separate package) answers a direct question: what got loaded that never got released? The workflow is always the same shape — snapshot, act, snapshot, compare.

SNAPSHOT COMPARE -- what a leak looks like ======================================================== Object type Snapshot A Snapshot B (after 5x) -------------------------------------------------------- Texture2D (level) 12 60 <- leak! Mesh (level) 8 40 <- leak! AudioClip (level) 4 4 <- fine, unloads correctly ======================================================== Textures and meshes scaled with the repeat count (12 x 5 = 60). Audio did not grow, so audio is releasing correctly -- the leak is specific to whatever code path loads level textures and meshes, not a general "nothing gets released" bug.
Tip A leak that only shows up after 5 or 10 repeats, not after 1, is exactly why "it worked when I tested it" is not proof of anything — always retest a suspected leak with several repeats before trusting the result either way.

12. Putting a Memory Budget Together

Back to the budget table from section 1. Every technique in this chapter maps onto one row of it: textures get smaller through resolution limits and ASTC compression (section 2); meshes get smaller by stripping channels the shader never reads (section 3); audio picks streaming or decompression based on clip length (section 4); the code/objects row shrinks through pooling (section 5) and simply generating less garbage in the first place (sections 6-8); and none of it stays saved if Addressables content never gets released (section 9) or the heap fragments over a long session (section 10).

The workflow that ties it together is always the same one from the performance chapters: measure first, with the Memory Profiler and the numbers from this chapter, before you guess at what to optimize. A texture that is already ASTC 6x6 at a sensible resolution is not your problem; a 4096x4096 uncompressed splash-screen texture that got left at default import settings might be half your budget by itself. The budget table is not a one-time exercise — revisit it as content gets added, and let the numbers tell you which category to look at next instead of guessing.

13. Glossary

14. Exercises

Exercise 1 A 1024x1024 texture is imported two ways: (a) RGB24 uncompressed (3 bytes/pixel, no alpha), and (b) ASTC 6x6 (0.44 bytes/pixel effective). Compute the base size and the size with a full mip chain (x 4/3) for both, in MB. How many times smaller is (b) than (a), with mips included?
Show answer

Base pixel count: 1024 * 1024 = 1,048,576 pixels.

(a) RGB24: base = 1,048,576 * 3 = 3,145,728 bytes = 3.00 MB. With mips: 3.00 * 4/3 = 4.00 MB.

(b) ASTC 6x6: base = 1,048,576 * 0.44 ≈ 461,373 bytes ≈ 0.44 MB. With mips: 0.44 * 4/3 ≈ 0.59 MB.

Ratio with mips included: 4.00 / 0.59 ≈ 6.8x smaller. ASTC 6x6 uses a larger compression block than ASTC 4x4, trading some quality for an even bigger size reduction versus uncompressed.

Exercise 2 A teammate wrote this method, called once per frame from Update, to build a debug string listing all currently poisoned enemies:
void Update()
{
    string debugLine = "Poisoned: ";
    var poisoned = enemies.Where(e => e.IsPoisoned).ToList();
    foreach (var e in poisoned)
    {
        debugLine += e.Name + ", ";
    }
    debugText.text = debugLine;
}
Find every source of garbage in this method (there is more than one), and rewrite it so it allocates nothing unless the actual set of poisoned enemies has changed since the last frame.
Show answer

Three separate sources of garbage here: (1) enemies.Where(...) allocates an iterator every frame, (2) .ToList() allocates a brand-new list every frame, and (3) debugLine += ... inside the loop allocates a new string on every single concatenation, not just once per frame — with N poisoned enemies that is N extra allocations on top of the two above, every frame, whether or not anything changed.

private readonly StringBuilder debugLine = new StringBuilder(128);
private int lastPoisonedCount = -1;
private int lastPoisonedHash = 0;

void Update()
{
    int count = 0;
    int hash = 17;
    for (int i = 0; i < enemies.Count; i++)
    {
        if (enemies[i].IsPoisoned)
        {
            count++;
            hash = hash * 31 + enemies[i].GetInstanceID();
        }
    }

    if (count != lastPoisonedCount || hash != lastPoisonedHash)
    {
        debugLine.Clear();
        debugLine.Append("Poisoned: ");
        for (int i = 0; i < enemies.Count; i++)
        {
            if (enemies[i].IsPoisoned)
            {
                debugLine.Append(enemies[i].Name).Append(", ");
            }
        }
        debugText.text = debugLine.ToString();
        lastPoisonedCount = count;
        lastPoisonedHash = hash;
    }
}

The first loop is a cheap, allocation-free pass just to detect whether the poisoned set changed (using a count plus a simple combined hash of instance IDs). Only when something actually changed does the second, more expensive pass run, using StringBuilder instead of repeated +=. On a frame where poison status is unchanged — the overwhelming majority of frames — this allocates nothing at all.

Exercise 3 Extend the ObjectPool<T> from section 5 with a maximum size: add a maxSize parameter to the constructor, and change Return so that once the pool already holds maxSize inactive objects, any further returned object is destroyed instead of enqueued (so the pool cannot grow without bound if far more objects than usual are ever spawned at once). Write the modified class.
Show answer
using System.Collections.Generic;
using UnityEngine;

public class ObjectPool<T> where T : Component
{
    private readonly Queue<T> pool = new Queue<T>();
    private readonly T prefab;
    private readonly Transform parent;
    private readonly int maxSize;

    public ObjectPool(T prefab, Transform parent, int prewarmCount, int maxSize)
    {
        this.prefab = prefab;
        this.parent = parent;
        this.maxSize = maxSize;

        for (int i = 0; i < prewarmCount; i++)
        {
            T obj = Object.Instantiate(prefab, parent);
            obj.gameObject.SetActive(false);
            pool.Enqueue(obj);
        }
    }

    public T Get()
    {
        T obj = pool.Count > 0 ? pool.Dequeue() : Object.Instantiate(prefab, parent);
        obj.gameObject.SetActive(true);
        return obj;
    }

    public void Return(T obj)
    {
        if (pool.Count >= maxSize)
        {
            Object.Destroy(obj.gameObject); // overflow -- do not keep growing the pool
            return;
        }

        obj.gameObject.SetActive(false);
        pool.Enqueue(obj);
    }
}

This still avoids allocation for the normal case (up to maxSize objects in flight at once), which covers the overwhelming majority of frames. It only pays an Instantiate/Destroy cost in the rare case where usage genuinely spikes past what the pool was sized for — a reasonable trade instead of letting one unusual frame (a 200-enemy horde event, say) permanently balloon the pool's resting size for the rest of the level.

← Back to all chapters