Back in the memory chapter you learned that RAM is a limited, numbered space, and in the data structures chapter you learned that how you arrange data decides whether it fits and how fast you can reach it. This chapter applies the exact same thinking to a whole game's content instead of one array. A live-service mobile game like the ones HoYoverse ships can end up with hundreds of characters, thousands of textures, and gigabytes of audio and animation by its second year. None of that can sit in RAM at once, and none of it can require a brand new App Store submission every time a new character is added. This chapter is about the system Unity uses to solve both problems: the asset pipeline (how a file becomes something the engine can use) and Addressables (how you load only what you need, from wherever it currently lives, and update it without shipping a new app).
An asset is any source content file that lives in your project's Assets folder: a texture (.png), a 3D model (.fbx), an audio clip (.wav), a script (.cs), a scene, a prefab. The moment you drop a new file into Assets, or Unity notices an existing one changed, it does not just leave the raw file alone. It runs that file through an importer — a piece of Unity code specific to that file type (a TextureImporter for images, a ModelImporter for 3D models, an AudioImporter for sound). The importer reads the raw source and converts it into a processed form the engine's renderer, audio system, or animation system can use directly, using whatever import settings you've configured for that file (covered in the next few sections).
That Library folder is why a teammate can clone your project and, the first time they open it, watch Unity sit there "importing" for several minutes: it is rebuilding all of that processed data locally, because nobody commits Library to version control. It is a cache, not source content.
.gitignore should exclude Library/, Temp/, Obj/, Build/, and Logs/. It should not exclude anything under Assets/, including the files you'll meet in the next section.For every source file Unity manages, it creates a small companion file with the same name plus .meta on the end — Hero_Diluc.png gets Hero_Diluc.png.meta, sitting right next to it. A .meta file is plain text and holds two things: the import settings you picked for that asset in the Inspector, and a GUID (Globally Unique Identifier — a long, effectively-random hex string) that gets generated exactly once, the first time the asset is imported, and then never changes.
fileFormatVersion: 2
guid: 8f3a9c2e1b7d4f6a9c0e2b3d4f5a6b7c
TextureImporter:
maxTextureSize: 2048
textureCompression: 1
mipmaps:
enableMipMap: 1
spriteMode: 1
Here is the part that trips up almost every beginner. When you drag a texture onto a material, or a prefab into a scene, Unity does not remember "this material uses the file at Assets/Textures/Hero_Diluc.png". File paths change too easily — someone renames a folder, reorganizes the project. Instead, Unity stores the reference as a GUID plus a file ID (the file ID picks out which sub-object inside that file, since one .fbx can contain several meshes). As long as the GUID in the .meta file matches the GUID every other asset already has on file, the reference resolves correctly — even if you renamed the texture or moved it to a different folder, because the .meta file moved and was renamed right along with it.
Now see what happens if the .meta file never makes it into version control — say someone's .gitignore had *.meta in it by mistake, or it just never got git add-ed.
This is why the rule exists: always commit .meta files alongside their asset. The GUID is the actual identity of an asset as far as Unity's references are concerned; the file name and path are just a label for humans.
.meta file with no matching source, which is harmless but messy, and worse, doing the reverse — deleting a .meta file by hand while keeping the asset — silently reassigns a new GUID the next time Unity imports it, breaking every reference the way you just saw above.An artist might hand you a 4096x4096 painted texture. Whether that costs you 64 MB of GPU memory or 2 MB depends entirely on the import settings, not on anything you do in code. The three settings that matter most:
ASTC's block size is the setting that most directly trades quality for memory. A smaller block (like 4x4) packs less data per pixel block, so it looks closer to the original; a larger block (like 12x12) packs many more pixels' worth of color into the same stored data, so it is smaller but blurrier.
Read that table as: the same source painting can cost 16 MB uncompressed, 4 MB at ASTC 4x4, or 1 MB at ASTC 8x8. Multiply that difference by the hundreds of textures a live-service game ships, and the compression setting alone is the difference between a game that fits on a phone and one that does not.
width * height * bytesPerPixel, then multiply by about 1.33 if mipmaps are enabled (the extra half/quarter/eighth-size copies add roughly a third more on top of the base size). Use this to sanity-check a memory budget before you even open the Inspector.A 3D model (.fbx, typically) has its own importer with settings that matter just as much as a texture's:
mesh.vertices at runtime (needed for procedural deformation, runtime-generated terrain, and similar tricks) — but it doubles that mesh's memory footprint, since now both a GPU copy and a CPU copy exist. Leave it off unless you actually touch the mesh data in code.Audio has the same shape of trade-off, but the setting that matters most is Load Type, because it decides when a clip is decompressed, not just how it's stored:
The Compression Format (PCM = uncompressed, largest and highest fidelity; Vorbis = good general-purpose compression; ADPCM = fast, cheap-to-decode compression) works together with Load Type. A typical live-service mobile game streams its background music and long story voice-over (so a 3-minute track never costs 3 minutes' worth of decoded RAM at once), and decompresses-on-load its short UI and combat sounds (so they play instantly with no per-play decoding cost, since they'll be triggered constantly).
Building a Unity project (File > Build Settings) turns your Assets folder plus your compiled C# into a platform-specific package — an .apk/.aab for Android, an .ipa for iOS. Three things happen during a build that decide what actually ends up inside it:
Resources is always included in full, whether anything references it or not, because Resources.Load can ask for it by a string path at any time and Unity has no way to know ahead of time which strings your code might construct.That third rule is the seed of the next section's problem: a Resources folder is a trap door that bypasses "only ship what's used," and it also has a matching runtime trap door — it lets you eagerly load everything inside it into memory in one call.
Here's that trap door in code. Resources.LoadAll loads every asset in a Resources subfolder immediately, synchronously, all at once:
using UnityEngine;
public class BadCharacterLoader : MonoBehaviour
{
void Awake()
{
// Every prefab under Assets/Resources/Characters gets fully loaded
// into RAM right now -- meshes, textures, everything -- whether
// the player will ever see most of them this session or not.
GameObject[] all = Resources.LoadAll<GameObject>("Characters");
Debug.Log("Loaded " + all.Length + " character prefabs into RAM");
}
}
Console output, on a roster with 87 characters:
Loaded 87 character prefabs into RAM
Trace what that means. The player picked one team of 4 characters for this match. But this one Awake() call just fully loaded all 87 into memory — every mesh, every texture, every animation clip — and there is no built-in way to unload just the ones you don't need, short of the broad, expensive Resources.UnloadUnusedAssets() scan. A phone typically has a few gigabytes of RAM shared between the OS, background apps, and your game. Loading a whole growing roster at once, every session, blows through that ceiling fast — and worse, since Resources content always ships inside the app (section 6), the initial download keeps growing every single patch, forever, even for content most players will never touch.
What you actually want is: load only the 4 characters this match needs, right before you need them, and free them the moment the match ends. That requires loading assets by name/address at runtime, from a location you control, instead of stuffing everything into one eagerly-loaded folder. That's what the rest of this chapter builds toward.
Unity's original answer to this was the Asset Bundle: a single binary file that packages up a chosen set of assets, built separately from your main app, and loadable at runtime from a local path or downloaded from a server. You load the bundle, pull specific assets out of it by name, and unload the whole bundle when you're done with it.
using UnityEngine;
public class OldBundleLoader : MonoBehaviour
{
void Start()
{
// Load a bundle file sitting on disk (already downloaded earlier).
AssetBundle bundle = AssetBundle.LoadFromFile(
Application.persistentDataPath + "/characters_klee.bundle");
GameObject prefab = bundle.LoadAsset<GameObject>("Klee_Model");
Instantiate(prefab);
// false = unload the bundle's index, but keep objects already
// instantiated from it alive. true would destroy those too.
bundle.Unload(false);
Debug.Log("Spawned " + prefab.name + " from a manual asset bundle");
}
}
Console output:
Spawned Klee_Model from a manual asset bundle
This works, and it solves the "not everything in RAM at once" problem. But it pushes a lot of bookkeeping onto you by hand: you decide which assets go in which bundle file, you track which bundles depend on which other bundles (so a shared texture used by two characters doesn't accidentally get duplicated into both bundles, wasting download size), you track which bundles are already loaded so you don't load the same one twice, and you reference every asset by a raw string name with no built-in versioning or update system. On a project with a handful of downloadable packs this is manageable. On a live-service game adding content every few weeks, it becomes its own small content-management system that someone has to build and maintain. That gap is exactly what Addressables was built to close.
Addressables is Unity's newer content system, built on top of the same asset bundle format under the hood, but it removes almost all of that manual bookkeeping. Three ideas make it work:
"Characters/Klee_Model", for example. Your code asks for that address; it never needs to know which bundle file the asset physically lives in, or whether that bundle is even downloaded yet.The catalog is the key piece: it is just a downloadable file, separate from your app binary. Update the catalog and the bundles it points to, and the running app finds new or changed content the next time it checks — no app binary changed, no store re-submission needed. That single fact is what section 11 builds on.
Every Addressables load is asynchronous — it might have to download megabytes over the network, so it can never be an instant call like Resources.Load. It returns an AsyncOperationHandle<T>, a handle you either subscribe to with a Completed event, or await directly since it exposes a .Task.
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class CharacterLoader : MonoBehaviour
{
AsyncOperationHandle<GameObject> handle;
void OnEnable()
{
handle = Addressables.LoadAssetAsync<GameObject>("Characters/Klee_Model");
handle.Completed += OnCharacterLoaded;
}
void OnCharacterLoaded(AsyncOperationHandle<GameObject> h)
{
if (h.Status == AsyncOperationStatus.Succeeded)
{
GameObject go = Instantiate(h.Result);
Debug.Log("Loaded and spawned: " + go.name);
}
else
{
Debug.LogError("Failed to load Characters/Klee_Model");
}
}
void OnDisable()
{
// Drop our reference. Addressables keeps a reference COUNT per
// address; when it hits zero, the memory behind it is freed.
Addressables.Release(handle);
}
}
Console output when this component becomes active:
Loaded and spawned: Klee_Model(Clone)
The async/await version reads more linearly, and is usually easier to follow once you have more than one load in a row:
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class CharacterSpawner : MonoBehaviour
{
GameObject spawned;
async void Start()
{
// InstantiateAsync loads AND instantiates in one call, and ties the
// loaded asset's reference count directly to the instance it hands back.
AsyncOperationHandle<GameObject> handle =
Addressables.InstantiateAsync("Characters/Klee_Model", transform);
spawned = await handle.Task; // pauses here without blocking the game
Debug.Log("Spawned: " + spawned.name);
}
void OnDestroy()
{
if (spawned != null)
Addressables.ReleaseInstance(spawned); // unloads if this was the last user
}
}
Console output:
Spawned: Klee_Model(Clone)
Notice the pattern in both examples: every LoadAssetAsync / InstantiateAsync is matched with a Release / ReleaseInstance. Addressables keeps a reference count per address (the same idea as a shared pointer's use-count, if that rang a bell from earlier chapters) — loading the same address twice just bumps the count and hands back the already-loaded asset instead of loading it again, and the underlying bundle only actually unloads from memory once every matching Release has happened.
LoadAssetAsync and never storing or releasing the handle. The asset (and the whole bundle behind it) stays referenced forever, because Addressables has no way to know you're "done" with it unless you say so with Release. This is the single most common Addressables memory leak, and it will not show up as an error — just as RAM that quietly never comes back down.Now put sections 9 and 10 together and the live-service payoff becomes obvious. Because a Remote Addressable Group's bundle lives on a CDN, separate from the app binary, a studio can ship a brand new character, a limited-time event map, or new voice lines by: building new Addressables content, uploading the new bundles plus an updated catalog to their server, and letting the running app discover it.
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using System.Threading.Tasks;
public class PatchChecker : MonoBehaviour
{
async void Start()
{
// Ask the CDN: is there a newer catalog than the one we launched with?
AsyncOperationHandle<System.Collections.Generic.List<string>> check
= Addressables.CheckForCatalogUpdates();
var toUpdate = await check.Task;
Addressables.Release(check);
if (toUpdate.Count == 0)
{
Debug.Log("Content is already up to date");
return;
}
var updateHandle = Addressables.UpdateCatalogs(toUpdate);
await updateHandle.Task;
Addressables.Release(updateHandle);
Debug.Log("Catalog updated: " + toUpdate.Count + " location(s) changed");
// Fetch the actual bundle bytes for the new event now, while the
// player is still browsing the menu -- a "pre-download" before
// the content is even unlocked.
var downloadHandle = Addressables.DownloadDependenciesAsync("Event_Fireworks");
await downloadHandle.Task;
Addressables.Release(downloadHandle);
Debug.Log("New event content pre-downloaded and ready");
}
}
Worked trace, on a day a new event has just gone live on the server:
Catalog updated: 1 location(s) changed
New event content pre-downloaded and ready
This is exactly the "pre-download the next update" feature you'll see in live-service mobile games before a big patch: the app binary you already have installed doesn't change at all, but it quietly fetches new Addressables bundles in the background so the content is sitting on your device, ready, the moment the event unlocks. None of it needed a new App Store or Play Store submission — only actual code changes need that. Content, art, and even balance data that's driven by Addressable assets can ship on a much faster cadence than app review would ever allow, while the initial install stays small, because remote content only downloads for players who actually reach it.
Here is the whole pipeline, start to finish, in one picture:
Assets folder.TextureImporter, ModelImporter, AudioImporter)..meta file..Completed, .Result, .Status, and .Task for await.Release has happened..gitignore accidentally contained the line *.meta for a few weeks. During that time they added Hero_Diluc.png and used it on a material. After the .gitignore is fixed and they push, you pull the branch and open the project: the material that should show Diluc's face now shows a pink/checkerboard texture, even though Hero_Diluc.png is right there in your Assets folder under the same name. Explain, step by step, why this happens. Use the words GUID and reference in your answer.Because *.meta was ignored, Hero_Diluc.png.meta was never committed — only the raw Hero_Diluc.png was. On your teammate's machine, Unity imported the file and generated a GUID (say 8f3a...), and the material's serialized data stored a reference to the texture as {guid: 8f3a..., fileID: ...}, not as a file path. When you pull the branch, you receive Hero_Diluc.png but no matching .meta file. Unity treats this as a brand-new, never-before-seen asset, runs the importer, and mints a new, different GUID (say c1a0...) for it. The material still points at the old guid: 8f3a..., which no longer exists anywhere in your copy of the project. Unity can't resolve that reference, so it shows the texture slot as missing (pink/checkerboard) — even though a file with the right name exists, because Unity never looks the reference up by name, only by GUID.
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class IconRefresher : MonoBehaviour
{
public UnityEngine.UI.Image icon;
void ShowIcon(string address)
{
var handle = Addressables.LoadAssetAsync<Sprite>(address);
handle.Completed += h =>
{
icon.sprite = h.Result;
};
}
}
What is missing, and how would you fix it?The handle is a local variable that goes out of scope the moment ShowIcon returns. Nothing ever calls Addressables.Release on it. Every single call to ShowIcon loads a sprite, bumps that address's reference count by one, and then loses the only handle that could have released it — so the reference count for every weapon icon the player has ever viewed this session stays above zero forever, and none of those sprites (or the bundles backing them) can ever be freed.
Fix: keep track of the handle so it can be released, typically the previous one, right before loading the next icon (or when the inventory screen closes):
AsyncOperationHandle<Sprite> currentHandle;
bool hasHandle;
void ShowIcon(string address)
{
if (hasHandle)
Addressables.Release(currentHandle); // free the previous icon first
currentHandle = Addressables.LoadAssetAsync<Sprite>(address);
hasHandle = true;
currentHandle.Completed += h => { icon.sprite = h.Result; };
}
Now each new icon load releases the one before it, so at most one weapon icon's worth of memory is ever held at a time, instead of all 200.
(a) 1024 * 1024 = 1,048,576 pixels. At 4 bytes/pixel: 1,048,576 * 4 = 4,194,304 bytes = 4.0 MB.
(b) ASTC 8x8 is 2 bits per pixel, which is 2 / 8 = 0.25 bytes per pixel. 1,048,576 * 0.25 = 262,144 bytes = 0.25 MB (256 KB).
(c) 4.0 MB / 0.25 MB = 16. The compressed version is 16 times smaller than uncompressed RGBA32. This is why nobody ships uncompressed textures on mobile: for one portrait it's a rounding error, but for a roster of hundreds of characters times multiple textures each (albedo, normal map, mask), that 16x gap is the difference between an install that fits comfortably on a phone and one that doesn't.
You now know the full path a piece of content takes: a source file plus a .meta file with a stable GUID, imported using settings that trade quality for memory, packaged by a build pipeline that only ships what's reachable (or, dangerously, everything in a Resources folder), and — for anything too big or too changeable to bake into that build — addressed, bundled, and loaded on demand through Addressables, async, reference-counted, and releasable. That last piece is what lets a live-service game keep its install small while its content keeps growing for years, patched over a CDN instead of an app store queue.