ในบท memory คุณได้เรียนไปแล้วว่า RAM คือพื้นที่จำกัดที่มีเลข address และในบท data structure คุณได้เรียนว่าวิธีจัดเรียงข้อมูลคือตัวตัดสินว่าข้อมูลจะพอดีหรือไม่ และเข้าถึงได้เร็วแค่ไหน บทนี้เอาแนวคิดเดียวกันเป๊ะ ๆ มาใช้กับ "เนื้อหาทั้งเกม" แทนที่จะเป็น array เดียว เกม live-service บนมือถือแบบที่ HoYoverse ทำ อาจจบลงด้วยตัวละครหลายร้อยตัว texture หลายพันไฟล์ และ audio+animation รวมกันหลาย gigabyte ภายในปีที่สอง สิ่งเหล่านี้ไม่มีทางอยู่ใน RAM พร้อมกันได้ทั้งหมด และก็ไม่มีทางที่จะต้องส่งเกมขึ้น App Store ใหม่ทุกครั้งที่มีตัวละครใหม่เพิ่มเข้ามา บทนี้ว่าด้วยระบบที่ Unity ใช้แก้ปัญหาทั้งสองข้อนี้ คือ asset pipeline (ไฟล์กลายเป็นสิ่งที่ engine ใช้งานได้ยังไง) และ Addressables (โหลดเฉพาะสิ่งที่ต้องใช้ จากที่ไหนก็ได้ที่มันอยู่ตอนนี้ แล้วอัปเดตมันได้โดยไม่ต้องส่งแอปใหม่)
asset คือไฟล์เนื้อหาต้นฉบับใด ๆ ที่อยู่ในโฟลเดอร์ Assets ของโปรเจกต์: texture (.png), โมเดล 3D (.fbx), audio clip (.wav), script (.cs), scene, prefab ทันทีที่คุณลากไฟล์ใหม่ลงไปใน Assets หรือ Unity สังเกตเห็นว่าไฟล์เดิมมีการเปลี่ยนแปลง มันจะไม่ปล่อยไฟล์ดิบทิ้งไว้เฉย ๆ แต่จะเอาไฟล์นั้นผ่าน importer — โค้ดของ Unity ที่เฉพาะเจาะจงกับชนิดไฟล์นั้น (TextureImporter สำหรับรูป, ModelImporter สำหรับโมเดล 3D, AudioImporter สำหรับเสียง) importer จะอ่านไฟล์ต้นฉบับแล้วแปลงมันเป็นข้อมูลรูปแบบที่ renderer, ระบบเสียง, หรือระบบ animation ของ engine ใช้งานได้โดยตรง โดยใช้ import settings ที่คุณตั้งไว้สำหรับไฟล์นั้น (จะพูดถึงในหัวข้อถัด ๆ ไป)
โฟลเดอร์ Library นี่แหละคือเหตุผลที่เพื่อนร่วมทีม clone โปรเจกต์ของคุณแล้ว เปิดครั้งแรกจะเห็น Unity นั่ง "importing" อยู่หลายนาที: มันกำลังสร้างข้อมูลที่ผ่านการประมวลผลแล้วนั้นขึ้นใหม่บนเครื่องตัวเอง เพราะไม่มีใคร commit Library ขึ้น version control มันคือ cache ไม่ใช่เนื้อหาต้นฉบับ
.gitignore ของคุณควร exclude Library/, Temp/, Obj/, Build/, และ Logs/ แต่ ไม่ควร exclude อะไรที่อยู่ใต้ Assets/ เลย รวมถึงไฟล์ที่คุณจะเจอในหัวข้อถัดไปด้วยสำหรับทุกไฟล์ต้นฉบับที่ Unity จัดการ มันจะสร้างไฟล์คู่กันเล็ก ๆ ที่ชื่อเดียวกันแต่เติม .meta ต่อท้าย — Hero_Diluc.png จะมี Hero_Diluc.png.meta วางอยู่ข้าง ๆ กัน ไฟล์ .meta เป็น plain text และเก็บสองอย่าง: import settings ที่คุณเลือกไว้ให้ asset นั้นใน Inspector และ GUID (Globally Unique Identifier — สตริง hex ยาว ๆ ที่แทบจะสุ่ม) ที่ถูก generate ขึ้นครั้งเดียวตอน import asset ครั้งแรก แล้วจะไม่เปลี่ยนอีกเลย
fileFormatVersion: 2
guid: 8f3a9c2e1b7d4f6a9c0e2b3d4f5a6b7c
TextureImporter:
maxTextureSize: 2048
textureCompression: 1
mipmaps:
enableMipMap: 1
spriteMode: 1
ตรงนี้แหละที่มือใหม่เกือบทุกคนสะดุด เวลาคุณลาก texture ไปวางบน material หรือลาก prefab เข้า scene Unity ไม่ได้จำว่า "material นี้ใช้ไฟล์ที่ Assets/Textures/Hero_Diluc.png" เพราะ path ของไฟล์เปลี่ยนง่ายเกินไป — ใครสักคนอาจ rename โฟลเดอร์ หรือจัดโครงสร้างโปรเจกต์ใหม่ แทนที่จะทำแบบนั้น Unity เก็บ reference เป็น GUID บวก file ID (file ID ใช้เลือกว่าเป็น sub-object ตัวไหนในไฟล์นั้น เพราะไฟล์ .fbx ไฟล์เดียวอาจมีหลาย mesh อยู่ข้างใน) ตราบใดที่ GUID ใน .meta file ตรงกับ GUID ที่ asset อื่น ๆ มีอยู่ในไฟล์อยู่แล้ว reference ก็จะ resolve ได้ถูกต้อง — แม้คุณจะ rename texture หรือย้ายมันไปโฟลเดอร์อื่นก็ตาม เพราะไฟล์ .meta ก็ย้ายและถูก rename ตามไปด้วย
ทีนี้มาดูว่าจะเกิดอะไรขึ้นถ้าไฟล์ .meta ไม่เคยเข้าไปอยู่ใน version control เลย — สมมติว่า .gitignore ของใครบางคนมี *.meta อยู่โดยพลาด หรือแค่ลืม git add
นี่คือเหตุผลที่มีกฎข้อนี้: ต้อง commit ไฟล์ .meta คู่กับ asset ของมันเสมอ GUID คือตัวตนที่แท้จริงของ asset ในสายตาของ reference ระบบ Unity ส่วนชื่อไฟล์กับ path เป็นแค่ label ให้คนอ่านเข้าใจเท่านั้น
.meta ที่ไม่มีต้นฉบับคู่ ซึ่งไม่ทำอันตรายแต่รกรุงรัง และที่แย่กว่าคือทำกลับด้าน — ลบไฟล์ .meta ด้วยมือแต่เก็บ asset ไว้ — จะทำให้ Unity assign GUID ใหม่ให้แบบเงียบ ๆ ตอนที่มัน import อีกครั้ง ทำให้ reference ทุกอันพังแบบที่เห็นด้านบนartist อาจส่ง texture ที่วาดมาขนาด 4096x4096 ให้คุณ มันจะกิน GPU memory 64 MB หรือแค่ 2 MB ขึ้นอยู่กับ import settings ล้วน ๆ ไม่เกี่ยวกับอะไรที่คุณทำใน code เลย สามอย่างที่สำคัญที่สุด:
ขนาด block ของ ASTC คือ setting ที่แลกคุณภาพกับ memory ตรง ๆ ที่สุด block เล็ก (เช่น 4x4) เก็บข้อมูลน้อยกว่าต่อ pixel block เลยดูใกล้เคียงต้นฉบับมากกว่า ส่วน block ใหญ่ (เช่น 12x12) อัดสีของหลาย pixel ลงในข้อมูลที่เก็บเท่ากัน เลยเล็กกว่าแต่เบลอกว่า
อ่านตารางนี้แบบนี้: ภาพวาดต้นฉบับเดียวกัน อาจกิน 16 MB ถ้าไม่ compress เลย, 4 MB ที่ ASTC 4x4, หรือ 1 MB ที่ ASTC 8x8 คูณความต่างนี้ด้วย texture หลายร้อยไฟล์ที่เกม live-service หนึ่งเกมต้องมี แค่ setting การ compress อย่างเดียวก็เป็นตัวตัดสินแล้วว่าเกมจะลงในมือถือได้พอดีหรือไม่พอดี
width * height * bytesPerPixel แล้วคูณด้วยประมาณ 1.33 ถ้าเปิด mipmaps (สำเนาขนาดครึ่ง/หนึ่งในสี่/หนึ่งในแปดที่เพิ่มเข้ามาคิดเป็นราว ๆ หนึ่งในสามเพิ่มเติมจากขนาดฐาน) ใช้สูตรนี้เช็ค memory budget คร่าว ๆ ได้ก่อนจะเปิด Inspector ด้วยซ้ำโมเดล 3D (ปกติเป็น .fbx) มี importer ของตัวเองพร้อม setting ที่สำคัญไม่แพ้ texture:
mesh.vertices ตอน runtime ได้ (จำเป็นสำหรับ procedural deformation, terrain ที่ generate ตอน runtime และเทคนิคคล้าย ๆ กัน) — แต่มันจะ เพิ่มเป็นสองเท่า memory footprint ของ mesh นั้น เพราะตอนนี้มีทั้งสำเนาบน GPU และสำเนาบน CPU ปล่อยมันปิดไว้ถ้าคุณไม่ได้แตะข้อมูล mesh ใน code จริง ๆaudio มี trade-off รูปแบบเดียวกัน แต่ setting ที่สำคัญที่สุดคือ Load Type เพราะมันตัดสิน เมื่อไหร่ clip จะถูก decompress ไม่ใช่แค่ว่าเก็บยังไง:
Compression Format (PCM = ไม่ compress, ใหญ่สุดแต่คุณภาพสูงสุด; Vorbis = compression ทั่วไปที่คุณภาพดี; ADPCM = compression ที่ decode เร็วและถูก) ทำงานร่วมกับ Load Type เกม live-service บนมือถือทั่วไปมักจะ stream เพลงประกอบและเสียงพากย์เนื้อเรื่องยาว ๆ (เพลง 3 นาทีจะได้ไม่กิน RAM เท่ากับ decode ทั้ง 3 นาทีพร้อมกัน) และใช้ decompress-on-load กับเสียง UI และเสียงต่อสู้สั้น ๆ (จะได้เล่นได้ทันทีโดยไม่มีต้นทุน decode ต่อครั้ง เพราะมันจะถูก trigger บ่อยมาก)
การ build โปรเจกต์ Unity (File > Build Settings) จะแปลงโฟลเดอร์ Assets ของคุณบวก C# ที่ compile แล้ว ให้กลายเป็น package เฉพาะ platform นั้น ๆ — .apk/.aab สำหรับ Android, .ipa สำหรับ iOS มีสามอย่างที่เกิดขึ้นระหว่าง build ที่เป็นตัวตัดสินว่าอะไรจะอยู่ในนั้นจริง ๆ:
Resources ตรงตัว จะถูกรวมเข้า build เสมอ ทั้งหมด ไม่ว่าจะมีอะไรอ้างถึงมันหรือไม่ก็ตาม เพราะ Resources.Load สามารถขอมันด้วย string path เมื่อไหร่ก็ได้ และ Unity ไม่มีทางรู้ล่วงหน้าว่า code ของคุณจะสร้าง string แบบไหนขึ้นมากฎข้อที่สามนั่นแหละคือจุดเริ่มต้นของปัญหาในหัวข้อถัดไป: โฟลเดอร์ Resources เป็นเหมือนประตูลับที่ข้ามกฎ "ส่งเฉพาะที่ใช้จริง" ไป และมันยังมีประตูลับคู่กันตอน runtime อีกด้วย — มันให้คุณโหลดทุกอย่างข้างในนั้นเข้า memory พร้อมกันในการเรียกครั้งเดียวได้
นี่คือประตูลับนั้นในรูปแบบ code Resources.LoadAll โหลด asset ทุกตัวในโฟลเดอร์ย่อยของ Resources ทันที แบบ synchronous พร้อมกันทั้งหมด:
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 บน roster ที่มี 87 ตัวละคร:
Loaded 87 character prefabs into RAM
ลองตามดูว่ามันหมายความว่ายังไง ผู้เล่นเลือกทีม 4 ตัวละครสำหรับแมตช์นี้ แต่การเรียก Awake() ครั้งเดียวนี้เพิ่งโหลดทั้ง 87 ตัวเข้า memory เต็ม ๆ — ทุก mesh, ทุก texture, ทุก animation clip — และไม่มีวิธี built-in ที่จะ unload เฉพาะตัวที่ไม่ต้องใช้เลย นอกจากการสแกนแบบกว้าง ๆ และแพงอย่าง Resources.UnloadUnusedAssets() มือถือทั่วไปมี RAM แค่ไม่กี่ gigabyte ที่ต้องแบ่งกันระหว่าง OS, แอปที่รันเบื้องหลัง, และเกมของคุณ การโหลด roster ทั้งหมดที่โตขึ้นเรื่อย ๆ พร้อมกันทุก session จะชนเพดานนั้นเร็วมาก และที่แย่กว่านั้น เพราะเนื้อหาใน Resources ต้องอยู่ในแอปเสมอ (หัวข้อที่ 6) ขนาด download เริ่มต้นก็จะโตขึ้นทุก patch ตลอดไป แม้แต่สำหรับเนื้อหาที่ผู้เล่นส่วนใหญ่ไม่มีวันแตะเลยก็ตาม
สิ่งที่คุณต้องการจริง ๆ คือ: โหลดแค่ 4 ตัวละครที่แมตช์นี้ต้องใช้ ก่อนจะต้องใช้ไม่นาน แล้วปล่อยมันทิ้งทันทีที่แมตช์จบ ซึ่งต้องอาศัยการโหลด asset ด้วยชื่อ/address ตอน runtime จากตำแหน่งที่คุณควบคุมได้ แทนที่จะยัดทุกอย่างลงในโฟลเดอร์เดียวที่โหลดแบบ eager ทั้งหมด นี่คือสิ่งที่เนื้อหาที่เหลือของบทนี้กำลังจะพาไปถึง
คำตอบดั้งเดิมของ Unity สำหรับเรื่องนี้คือ Asset Bundle: ไฟล์ binary ไฟล์เดียวที่รวม asset ชุดหนึ่งเข้าด้วยกัน build แยกจากแอปหลักของคุณ และโหลดได้ตอน runtime จาก path ในเครื่องหรือ download มาจาก server คุณโหลด bundle, ดึง asset เฉพาะตัวออกมาด้วยชื่อ, แล้ว unload bundle ทั้งก้อนเมื่อใช้เสร็จ
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
วิธีนี้ใช้ได้ผล และแก้ปัญหา "ไม่ต้องอยู่ใน RAM พร้อมกันทั้งหมด" ได้ แต่มันผลักงานจัดการหลายอย่างมาให้คุณทำเอง: คุณต้องตัดสินใจว่า asset ไหนอยู่ใน bundle ไฟล์ไหน, ต้อง track ว่า bundle ไหนพึ่งพา bundle ไหนบ้าง (ไม่ให้ texture ที่ตัวละครสองตัวใช้ร่วมกันดันไปซ้ำอยู่ทั้งสอง bundle โดยไม่ตั้งใจ ทำให้ขนาด download เปลืองเปล่า ๆ), ต้อง track ว่า bundle ไหน load ไปแล้วเพื่อไม่ให้ load ซ้ำ, และต้องอ้างถึง asset ทุกตัวด้วยชื่อ string ดิบ ๆ โดยไม่มีระบบ version หรือ update built-in มาให้ ในโปรเจกต์ที่มี downloadable pack แค่ไม่กี่ตัวเรื่องนี้ยังจัดการไหว แต่ในเกม live-service ที่เพิ่มเนื้อหาทุกไม่กี่สัปดาห์ มันจะกลายเป็นระบบจัดการเนื้อหาเล็ก ๆ ของตัวเองที่ต้องมีคนสร้างและดูแลต่อไป ช่องว่างตรงนี้แหละคือสิ่งที่ Addressables ถูกสร้างมาเพื่ออุดให้
Addressables คือระบบเนื้อหารุ่นใหม่ของ Unity ที่สร้างอยู่บน asset bundle format แบบเดิมข้างใต้ แต่ตัดงาน bookkeeping ที่ต้องทำเองแทบทั้งหมดออกไป มีสามแนวคิดที่ทำให้มันทำงานได้:
"Characters/Klee_Model" code ของคุณแค่ขอ address นั้น ไม่ต้องรู้เลยว่า asset นั้นอยู่ใน bundle ไฟล์ไหนจริง ๆ หรือ bundle นั้น download มาหรือยังcatalog คือส่วนสำคัญที่สุด: มันเป็นแค่ไฟล์ที่ download ได้ แยกออกจาก app binary ของคุณ อัปเดต catalog และ bundle ที่มันชี้ไป แล้วแอปที่กำลังรันอยู่ก็จะเจอเนื้อหาใหม่หรือที่เปลี่ยนแปลงในครั้งถัดไปที่มันเช็ค — ไม่ต้องเปลี่ยน app binary เลย ไม่ต้อง submit ขึ้น store ใหม่ ข้อเท็จจริงข้อเดียวนี้แหละคือสิ่งที่หัวข้อที่ 11 จะต่อยอด
ทุกการโหลดของ Addressables เป็น asynchronous (ไม่บล็อกการทำงาน) — เพราะมันอาจต้อง download ข้อมูลหลาย megabyte ผ่านเครือข่าย มันเลยไม่มีทางเป็นการเรียกที่ได้ผลทันทีแบบ Resources.Load ได้ มันคืนค่าเป็น AsyncOperationHandle<T> ซึ่งเป็น handle ที่คุณจะ subscribe ด้วย Completed event หรือจะ await ตรง ๆ ก็ได้เพราะมันมี .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 ตอน component นี้ถูก enable:
Loaded and spawned: Klee_Model(Clone)
เวอร์ชัน async/await อ่านเป็นเส้นตรงกว่า และมักจะตามง่ายกว่าเมื่อมีการโหลดมากกว่าหนึ่งครั้งต่อกัน:
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)
สังเกต pattern ในทั้งสองตัวอย่าง: ทุก LoadAssetAsync / InstantiateAsync จะจับคู่กับ Release / ReleaseInstance เสมอ Addressables เก็บ reference count ต่อ address (แนวคิดเดียวกับ use-count ของ shared pointer ถ้าคุณเคยเจอมาจากบทก่อน ๆ) — การโหลด address เดิมซ้ำสองครั้งแค่เพิ่ม count ขึ้นหนึ่ง แล้วคืน asset ที่โหลดไว้แล้วกลับมาแทนที่จะโหลดใหม่ และ bundle ที่อยู่ข้างใต้จะ unload ออกจาก memory จริง ๆ ก็ต่อเมื่อทุก Release ที่จับคู่กันเกิดขึ้นครบแล้วเท่านั้น
LoadAssetAsync แล้วไม่เคยเก็บหรือ release handle เลย asset นั้น (และ bundle ทั้งก้อนข้างหลังมัน) จะถูกอ้างถึงตลอดไป เพราะ Addressables ไม่มีทางรู้ว่าคุณ "ใช้เสร็จแล้ว" นอกจากคุณจะบอกมันด้วย Release นี่คือ memory leak ที่พบบ่อยที่สุดของ Addressables และมันจะไม่ขึ้น error ให้เห็นเลย — มีแต่ RAM ที่เงียบ ๆ ไม่ยอมลดลงมาอีกทีนี้เอาหัวข้อ 9 กับ 10 มารวมกัน แล้วผลตอบแทนสำหรับ live-service ก็จะเห็นชัดเจน เพราะ bundle ของ Addressable Group แบบ Remote อยู่บน CDN แยกจาก app binary สตูดิโอสามารถส่งตัวละครใหม่เอี่ยม, แผนที่ event แบบจำกัดเวลา, หรือเสียงพากย์ใหม่ ได้โดย: build เนื้อหา Addressables ใหม่, upload bundle ใหม่พร้อม catalog ที่อัปเดตแล้วขึ้น server, แล้วปล่อยให้แอปที่กำลังรันอยู่ค้นพบมันเอง
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 ในวันที่ event ใหม่เพิ่ง live บน server:
Catalog updated: 1 location(s) changed
New event content pre-downloaded and ready
นี่คือ feature "pre-download อัปเดตถัดไป" แบบที่คุณเห็นในเกม live-service บนมือถือก่อน patch ใหญ่จะมาถึง: app binary ที่คุณติดตั้งอยู่แล้วไม่เปลี่ยนแปลงอะไรเลย แต่มันเงียบ ๆ ดึง Addressables bundle ใหม่มาไว้ในเบื้องหลัง ทำให้เนื้อหาอยู่บนเครื่องคุณพร้อมใช้งานทันทีที่ event ปลดล็อก ไม่มีขั้นตอนไหนต้อง submit ขึ้น App Store หรือ Play Store ใหม่เลย — มีแค่การเปลี่ยนแปลง code จริง ๆ เท่านั้นที่ต้องทำแบบนั้น เนื้อหา, งานอาร์ต, แม้แต่ balance data ที่ขับเคลื่อนด้วย Addressable asset สามารถส่งได้ในจังหวะที่เร็วกว่าที่การ review ของ app store จะยอมให้มาก ในขณะที่การติดตั้งครั้งแรกยังคงเล็ก เพราะเนื้อหา remote จะ download ให้เฉพาะผู้เล่นที่ไปถึงมันจริง ๆ เท่านั้น
นี่คือ pipeline ทั้งหมดตั้งแต่ต้นจนจบ ในภาพเดียว:
AssetsTextureImporter, ModelImporter, AudioImporter).meta ที่หายไป.Completed, .Result, .Status, และ .Task สำหรับ awaitRelease ที่จับคู่กันเกิดขึ้นครบแล้วเท่านั้น.gitignore ของเพื่อนร่วมทีมมีบรรทัด *.meta อยู่โดยไม่ตั้งใจอยู่หลายสัปดาห์ ในช่วงนั้นเขาเพิ่ม Hero_Diluc.png เข้ามาและใช้มันบน material ตัวหนึ่ง หลังจากแก้ .gitignore แล้ว push ขึ้นมา คุณ pull branch แล้วเปิดโปรเจกต์: material ที่ควรจะแสดงหน้า Diluc กลับแสดงเป็น texture สีชมพู/checkerboard ทั้งที่ Hero_Diluc.png ก็อยู่ในโฟลเดอร์ Assets ของคุณตรงนั้นด้วยชื่อเดียวกันเป๊ะ อธิบายทีละขั้นตอนว่าทำไมถึงเกิดเหตุการณ์นี้ ใช้คำว่า GUID และ reference ในคำตอบด้วยเพราะ *.meta ถูก ignore ไว้ Hero_Diluc.png.meta จึงไม่เคยถูก commit เลย มีแค่ Hero_Diluc.png ดิบ ๆ เท่านั้นที่ถูก commit บนเครื่องของเพื่อนร่วมทีม Unity import ไฟล์นั้นแล้ว generate GUID (สมมติว่า 8f3a...) และข้อมูล serialized ของ material ก็เก็บ reference ไปยัง texture เป็น {guid: 8f3a..., fileID: ...} ไม่ใช่ file path เมื่อคุณ pull branch คุณจะได้ Hero_Diluc.png แต่ไม่มีไฟล์ .meta ที่ตรงกันมาด้วย Unity จะมองว่านี่เป็น asset ใหม่เอี่ยมที่ไม่เคยเห็นมาก่อน แล้วรัน importer แล้ว mint GUID ใหม่ ที่ไม่เหมือนเดิม (สมมติว่า c1a0...) ให้มัน material ยังคงชี้ไปที่ guid: 8f3a... เดิม ซึ่งไม่มีอยู่จริงในโปรเจกต์ของคุณอีกต่อไป Unity resolve reference นั้นไม่ได้ มันเลยแสดงช่อง texture เป็น missing (สีชมพู/checkerboard) — แม้จะมีไฟล์ชื่อถูกต้องอยู่จริงก็ตาม เพราะ Unity ไม่เคยค้นหา reference ด้วยชื่อเลย ค้นด้วย 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;
};
}
}
ขาดอะไรไป และคุณจะแก้ยังไง?handle เป็น local variable ที่หลุด scope ทันทีที่ ShowIcon return ไม่มีที่ไหนเรียก Addressables.Release กับมันเลย ทุกครั้งที่เรียก ShowIcon จะโหลด sprite เพิ่ม reference count ของ address นั้นขึ้นหนึ่ง แล้วก็ทำ handle เดียวที่จะ release มันได้หายไป — reference count ของ icon อาวุธทุกชิ้นที่ผู้เล่นเคยดูใน session นี้เลยค้างอยู่เหนือศูนย์ตลอดไป และไม่มี sprite ตัวไหน (หรือ bundle ที่อยู่ข้างหลังมัน) ถูก free ได้เลย
วิธีแก้: เก็บ handle ไว้เพื่อจะ release มันได้ ปกติจะ release handle ก่อนหน้า ก่อนโหลด icon ตัวถัดไป (หรือตอนปิดหน้า inventory):
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; };
}
ตอนนี้ทุกครั้งที่โหลด icon ใหม่ จะ release ตัวก่อนหน้าไปด้วย ทำให้มี memory ของ icon อาวุธค้างอยู่ในเวลาเดียวได้อย่างมากแค่ตัวเดียว แทนที่จะเป็นทั้ง 200 ตัว
(a) 1024 * 1024 = 1,048,576 pixel ที่ 4 byte/pixel: 1,048,576 * 4 = 4,194,304 bytes = 4.0 MB
(b) ASTC 8x8 คือ 2 bit ต่อ pixel ซึ่งเท่ากับ 2 / 8 = 0.25 byte ต่อ pixel 1,048,576 * 0.25 = 262,144 bytes = 0.25 MB (256 KB)
(c) 4.0 MB / 0.25 MB = 16 เวอร์ชัน compress เล็กกว่าเวอร์ชันไม่ compress แบบ RGBA32 ถึง 16 เท่า นี่คือเหตุผลที่ไม่มีใครส่ง texture แบบไม่ compress บนมือถือ: สำหรับรูปโปรไฟล์รูปเดียวมันแค่ error เล็ก ๆ แต่สำหรับ roster ที่มีตัวละครหลายร้อยตัว คูณด้วย texture หลายไฟล์ต่อตัว (albedo, normal map, mask) ช่องว่าง 16 เท่านั้นแหละคือความต่างระหว่างการติดตั้งที่ลงในมือถือได้สบาย ๆ กับที่ลงไม่ได้
ตอนนี้คุณรู้แล้วว่าเนื้อหาชิ้นหนึ่งเดินทางยังไงตั้งแต่ต้นจนจบ: source file บวกไฟล์ .meta ที่มี GUID คงที่, import ด้วย setting ที่แลกคุณภาพกับ memory, packaged โดย build pipeline ที่ส่งเฉพาะที่ reachable เท่านั้น (หรือถ้าไม่ระวัง ก็ส่งทุกอย่างในโฟลเดอร์ Resources ไปด้วย), และ — สำหรับอะไรก็ตามที่ใหญ่เกินไปหรือเปลี่ยนบ่อยเกินกว่าจะ bake ลง build นั้น — ถูก address, bundle, และโหลดตามต้องการผ่าน Addressables แบบ async, นับ reference, และ release ได้ ชิ้นสุดท้ายนี่แหละที่ทำให้เกม live-service เก็บขนาดติดตั้งให้เล็กได้ ในขณะที่เนื้อหาโตขึ้นเรื่อย ๆ ไปอีกหลายปี patch ผ่าน CDN แทนที่จะเข้าคิว app store