ก่อนที่ player จะได้สู้บอส เปิด loot box หรือจ่ายเงินสักบาทในเกมเรา พวกเขาต้องผ่านด่านแรกก่อน คือการดาวน์โหลด ทุกอย่างที่เราสร้างมาไม่มีความหมายเลยถ้า player ดาวน์โหลดไม่จบ หัวข้อนี้พูดถึงด่านแรกนี้ และทำไมมันถึงสำคัญพอ ๆ กับเรื่องอื่นที่จะเจอในบทต่อ ๆ ไป
มีสองแรงที่ต่อต้านไฟล์ใหญ่ คือกฎของ app store และความอดทนของคนธรรมดา
มือถือส่วนใหญ่ไม่ได้ต่อ Wi-Fi (การเชื่อมต่ออินเทอร์เน็ตแบบไร้สายผ่าน router ต่างจาก mobile data ของมือถือที่มักมี cap รายเดือนและช้ากว่า) ตลอดเวลา app store เลยต้องป้องกันไม่ให้ player เผลอใช้ mobile data จนหมด cap โดยจำกัดว่าไฟล์ที่โหลดผ่าน cellular จะใหญ่ได้แค่ไหนก่อนจะเตือนหรือบล็อกเลย
ถ้าไฟล์เกินเส้นนี้ player ส่วนหนึ่งจะไม่ติดตั้งจนจบเลย พวกเขาเห็นคำเตือน คิดว่า "เดี๋ยวไปโหลดตอนมี Wi-Fi" แล้วปิดหน้า store ไป ส่วนใหญ่ก็ไม่กลับมาโหลดอีก
ต่อให้ไม่โดนบล็อกตรง ๆ ทุก 100 MB ที่เพิ่มเข้าไปก็เสีย player ไปเรื่อย ๆ บางคนเห็นขนาดไฟล์ในหน้า store แล้วถอยก่อนกดติดตั้ง บางคนเริ่มโหลดแล้วเบื่อหรือพื้นที่เก็บข้อมูลไม่พอ เลยยกเลิกกลางทาง ตัวเลขจริงจะต่างกันไปตาม store, genre, และประเทศ แต่รูปแบบเหมือนกันเสมอคือ ไฟล์ยิ่งใหญ่ ยิ่งติดตั้งสำเร็จน้อยลง นี่คือตัวอย่างรูปแบบ (เป็นตัวอย่างประกอบเท่านั้น ไม่ใช่ตัวเลขจริง)
พูดเป็นตัวเลขง่าย ๆ ถ้าหน้า store ของเรามีคนเข้ามาดู 100,000 ครั้งต่อเดือน แล้วไฟล์ขนาด 300 MB แปลงเป็นการติดตั้งสำเร็จ 80% ในขณะที่ไฟล์ 600 MB แปลงได้แค่ 60% นั่นแปลว่าเราได้ install เพิ่มอีก 20,000 ครั้งต่อเดือน จากงบ marketing เท่าเดิมเป๊ะ ๆ แค่จากการลดขนาดไฟล์
สำหรับ live-service game (เกมที่ยังทำงานต่อและเปลี่ยนแปลงไปเรื่อย ๆ หลังเปิดตัว มี content update สม่ำเสมอแทนที่จะปล่อยครั้งเดียวจบ) เรื่องนี้ไม่ใช่ปัญหาแค่ครั้งเดียว ทุก patch คือโอกาสที่ไฟล์จะบวมขึ้นอีก และทุก patch คือโอกาสที่จะเสีย player ที่กำลังจะลองอัปเดตตอนอยู่บนรถไฟ บนรถเมล์ หรืออยู่บนแพ็กเกจเน็ตที่มี cap ขนาดไฟล์เลยเป็นต้นทุนที่เกิดซ้ำตลอด ไม่ใช่แค่เช็คลิสต์วันเปิดเกม
เราลดขนาดสิ่งที่มองไม่เห็นไม่ได้ ก่อนจะไปแก้อะไร ต้องรู้ก่อนว่าข้างในไฟล์ build มีอะไรอยู่จริง ๆ Unity (และ engine ส่วนใหญ่) สามารถสร้าง build report ได้ คือรายงานสรุปว่า build สุดท้ายประกอบด้วยอะไรบ้าง แต่ละหมวด content กิน byte ไปเท่าไหร่หลังบีบอัดแล้ว
ใน Unity หลัง build เสร็จ Editor จะแสดง (และหน้าต่าง Build Profile / Build Report จะโชว์) อะไรประมาณนี้
Build Report - MyGame v1.4.2 (Android, arm64)
------------------------------------------------
Textures 412.6 MB (61%)
Audio 168.3 MB (25%)
Meshes 41.0 MB ( 6%)
Animation Clips 22.4 MB ( 3%)
Scripts (IL2CPP code) 9.8 MB ( 1%)
Fonts & UI Sprites 8.1 MB ( 1%)
Shaders 6.2 MB ( 1%)
Misc / Settings 3.1 MB ( 0%)
------------------------------------------------
Total (compressed) 671.5 MB
อ่านแบบเดิมทุกครั้ง คือเรียงจากใหญ่ไปเล็ก ดูสองสามหมวดบนสุดก่อน ที่เหลือปล่อยไว้ก่อนจนกว่าหมวดบนจะจัดการเรียบร้อย ในรายงานนี้ Textures กับ Audio รวมกันเป็น 86% ของ build ส่วน Scripts ซึ่งคือโค้ดเกมที่ compile แล้วจริง ๆ มีแค่ 9.8 MB ต่ำกว่า 2% ซึ่งเป็นเรื่องปกติ โค้ดที่ compile แล้วมีความหนาแน่นสูง (พฤติกรรมเยอะแยะอัดอยู่ใน byte น้อย ๆ) ในขณะที่ art กับเสียงไม่เป็นแบบนั้น ถ้า build report ไหนโค้ดกลายเป็นบรรทัดที่ใหญ่ที่สุด แทบจะแปลว่ามีอะไรผิดพลาดที่อื่น (เช่น library debug ทั้งก้อน หรือ database ฝังอยู่ในโค้ดโดยไม่ตั้งใจ)
Addressables (ระบบของ Unity ที่จะพูดถึงในหัวข้อ 7) มี report ของตัวเองเรียกว่า Build Layout Report ซึ่งแบ่งขนาดตาม content bundle แทนที่จะแบ่งตาม build ทั้งก้อน ทำให้เห็นได้ว่า downloadable content ก้อนไหนที่บวมขึ้นมา
build report บอกเราว่าอะไรใหญ่ แต่ไม่บอกว่าทำไมมันถึงหลุดเข้า build ตั้งแต่แรก คำถามที่สองนี้สำคัญเพราะ engine จะรวม asset เข้ามาตามการอ้างอิง (reference) ไม่ใช่ตามเจตนา ถ้ามีอะไรใน build ไม่ว่าจะเป็น scene, prefab, หรือ field ของ script ที่ชี้ไปหา asset นั้น asset นั้นจะถูกอัดเข้า build ไม่ว่า player จะได้เห็นมันจริงหรือเปล่าก็ตาม
สิ่งที่เกิดขึ้นบ่อย ๆ คือมีคนลาก reference art ก้อนใหญ่เข้าไปตอนประชุม design review แล้วลืมเอาออก
public class PlayerHud : MonoBehaviour
{
// Someone dragged a 4096x4096 concept-art screenshot in here
// during a design review, then forgot to remove it.
public Texture2D debugReferenceArt;
public Sprite healthIcon;
public Sprite staminaIcon;
}
ไม่มีโค้ดไหนในเกมอ่าน debugReferenceArt เลย มันไม่เคยขึ้นบนหน้าจอด้วยซ้ำ แต่เพราะมันเป็น public field ที่มีค่าถูก assign ไว้ใน Inspector ระบบ build เห็น reference ที่ยังมีชีวิตอยู่ เลยอัดภาพ 4096x4096 ทั้งก้อนเข้า build ไปด้วย ถ้าลบ reference นี้ทิ้งแล้ว build ใหม่ report อาจจะเล็กลงแบบนี้
Before: Textures 412.6 MB (61%)
After: Textures 380.1 MB (58%)
(one accidental 32 MB reference, gone)
กับดักแบบที่สองซึ่งใหญ่กว่าคือโฟลเดอร์พิเศษของ Unity ชื่อ Resources อะไรก็ตามที่วางในโฟลเดอร์ที่ชื่อ Resources เป๊ะ ๆ จะถูกอัดเข้า build ทั้งหมด ไม่ว่าจะมีการโหลดตอน runtime จริงหรือเปล่า เพราะ engine ไม่มีทางรู้ล่วงหน้าว่าโค้ดตรงไหนจะส่ง string อะไรเข้า Resources.Load ตอน runtime มันเลยเลือกทางปลอดภัยคือเก็บทุกอย่างไว้
Resources เป็นลิ้นชักเก็บของที่ "เผื่อจะได้ใช้" ในเกม live-service ที่ออก content ใหม่ทุกไม่กี่สัปดาห์เป็นเวลาปีขึ้นไป โฟลเดอร์ Resources จะโตขึ้นเรื่อย ๆ แบบเงียบ ๆ จนกว่าจะมีใครไปเห็น build report แล้วพบว่ามันมี art อยู่ 200 MB ที่ไม่มีใครอ้างอิงมาหกเดือนแล้ว ควรใช้ reference ตรง ๆ หรือ Addressables (หัวข้อ 7) แทน เพื่อให้ content ที่ไม่ได้ใช้จริง ๆ ถูกตรวจเจอและตัดทิ้งได้วิธีจับก่อนที่จะ ship คือค้นหาว่าอะไรอ้างอิง asset ก้อนใหญ่ผิดปกตินั้นจริง ๆ (engine ส่วนใหญ่มีเครื่องมือ "Find References In Scene/Project") แล้วรัน unused-asset analysis ก่อน build ใหญ่ทุกครั้ง จับ reference 32 MB ที่หลุดมาโดยไม่ตั้งใจได้หนึ่งครั้ง คุ้มกว่านั่งปรับ texture compression ทั้งบ่ายเสียอีก
Texture คือหมวดที่ใหญ่ที่สุดใน build report ตัวอย่างข้างบน และแทบทุกเกมจริง ๆ ก็เป็นแบบนี้ มีสองอย่างที่คุมขนาดของมันคือ compression format กับ max size
Texture compression format คือวิธี encode ข้อมูลภาพให้กินพื้นที่น้อยลง โดยใช้คณิตศาสตร์ที่ GPU (graphics processing unit, ชิปที่วาด pixel) แกะออกมาได้เร็วมาก ๆ ตรง ๆ ในระดับ hardware เลย ซึ่งต่างจากอะไรอย่าง JPG ที่ต้อง decompress เป็นภาพเต็มในหน่วยความจำก่อนถึงจะวาดได้ format ที่พบบ่อยได้แก่
ค่า import setting "max size" จำกัดว่า texture ใหญ่ที่สุดได้แค่ไหนใน build สุดท้าย ไม่ว่าไฟล์ source จะใหญ่แค่ไหนก็ตาม texture ที่วาดเป็น icon 64x64 บนจอ ไม่ได้ประโยชน์อะไรเลยจากการเก็บเป็นไฟล์ source 4096x4096 มีแต่จะกิน byte และกิน GPU memory เปล่า ๆ
// Editor script: force textures down to 1024 max, ASTC 6x6 on mobile.
using UnityEditor;
using UnityEngine;
public class TextureShrinker : AssetPostprocessor
{
void OnPreprocessTexture()
{
TextureImporter importer = (TextureImporter)assetImporter;
TextureImporterPlatformSettings mobile =
importer.GetPlatformTextureSettings("Android");
mobile.overridden = true;
mobile.maxTextureSize = 1024;
mobile.format = TextureImporterFormat.ASTC_6x6;
mobile.compressionQuality = 50;
importer.SetPlatformTextureSettings(mobile);
}
}
ตัวอย่างจริง ui_background.png เริ่มจากไฟล์ source PNG 32-bit ขนาด 4096x4096 ประมาณ 6.4 MB บนดิสก์ก่อน import หลังตั้งค่าตามข้างบน (จำกัดที่ 1024, ASTC 6x6) มันจะเหลือใน build ประมาณ 220 KB เล็กลงประมาณ 29 เท่า โดยคุณภาพแทบไม่ต่างเลยสำหรับ background ที่ไม่เคยเต็มเกินหนึ่งในสี่ของจอมือถือ
Audio คือหมวดที่ใหญ่เป็นอันดับสองใน build report ตัวอย่าง มีสองอย่างที่ช่วยลดขนาดได้มากที่สุดคือ compression format กับการเปลี่ยนเสียง sound effect เป็น mono
เสียงแบบไม่บีบอัด (PCM WAV) มีขนาดใหญ่ ประมาณ 10 MB ต่อนาทีสำหรับเสียง stereo คุณภาพระดับ CD format ที่บีบอัดอย่าง Vorbis จะ encode เสียงชุดเดิมใหม่โดยใช้ byte น้อยลงมาก ที่ระดับคุณภาพที่เราเลือกเอง โดยแลกกับคุณภาพที่ลดลงเล็กน้อยซึ่งปกติหูแทบไม่ได้ยินความต่าง สำหรับเสียงในเกมเกือบทั้งหมด ไม่ว่าจะ sound effect, ambience, หรือเพลงส่วนใหญ่ การประหยัดขนาดคุ้มค่ามาก
เสียง stereo เก็บสองช่อง (ซ้ายกับขวา) ส่วน mono เก็บช่องเดียว ขนาดไฟล์เหลือประมาณครึ่งเดียว sound effect ส่วนใหญ่ เช่น เสียงฝีเท้า, เสียงคลิก UI, เสียงกระทบอาวุธ ไม่จำเป็นต้องมีความกว้างแบบ stereo เลย เพราะ 3D positional audio (เสียงที่ดูเหมือนมาจากจุดใดจุดหนึ่งในโลกเกม) ก็ render มาจาก source แบบ mono แล้ว pan ซ้าย/ขวาโดย engine ตอน runtime อยู่แล้ว การบังคับให้ sound effect เป็น mono เกือบจะเป็นการลดขนาดแบบฟรี ๆ
using UnityEditor;
using UnityEngine;
public class AudioShrinker : AssetPostprocessor
{
void OnPreprocessAudio()
{
AudioImporter importer = (AudioImporter)assetImporter;
if (assetPath.Contains("/SFX/"))
{
importer.forceToMono = true;
}
AudioImporterSampleSettings settings = importer.defaultSampleSettings;
settings.compressionFormat = AudioCompressionFormat.Vorbis;
settings.quality = 0.5f; // 0 = smallest file, 1 = best quality
settings.loadType = AudioClipLoadType.CompressedInMemory;
importer.defaultSampleSettings = settings;
}
}
ตัวอย่างจริง footstep_grass.wav เริ่มจาก stereo, 16-bit, 44.1 kHz ยาว 2.1 วินาที ขนาดประมาณ 370 KB แบบไม่บีบอัด หลังบังคับเป็น mono แล้วบีบด้วย Vorbis ที่ quality 0.5 จะเหลือประมาณ 41 KB เล็กลงประมาณ 9 เท่า โดยไม่มีความต่างที่ได้ยินเลยสำหรับ sound effect แบบเล่นครั้งเดียว
โค้ดกินพื้นที่แค่ประมาณ 2% ใน build report ตัวอย่าง แต่มันก็ไม่ได้ฟรี และการเก็บกวาดมันช่วยมากกว่าแค่ขนาดดาวน์โหลด มันช่วยเวลาเปิดเกม (startup time) และขนาด patch ด้วย
เวลา Unity build ด้วย IL2CPP (ขั้นตอนที่แปลง C# เป็น C++ แล้วแปลงต่อเป็น native machine code) มี setting ชื่อ Managed Stripping Level ที่คุมว่าจะตัด method และ class ของ C# ที่ static analysis พิสูจน์ได้ว่าไม่เคยถูกเรียกใช้เลยแบบเข้มงวดแค่ไหน ยังมีแนวคิดที่คล้ายกันในระดับ engine ด้วย คือ Unity มาเป็น module (Physics2D, Terrain, Video, XR, และอื่น ๆ) module ที่ไม่ได้ใช้สามารถตัดออกจาก native binary ได้ผ่าน Package Manager และ setting engine code stripping ทำให้เกม 2D อย่างเดียวบนมือถือไม่ต้องแบก engine code ของ 3D terrain ที่ไม่มีวันถูกเรียกใช้เลย
asset ตัวเดียวกันอาจจะหลุดเข้า build ซ้ำมากกว่าหนึ่งครั้งโดยไม่มีใครตั้งใจ กรณีคลาสสิกคือ ทีม feature สองทีมต่างคน import icon ตัวเดียวกันเข้าโฟลเดอร์ของตัวเอง แทนที่จะใช้ไฟล์เดียวกันร่วมกัน
เรื่องนี้เกิดในระดับ content bundle (หัวข้อ 7) ได้เหมือนกัน ถ้า bundle สองก้อนที่แยกกันต่างก็พึ่งพา texture เดียวกัน แล้วไม่มีการตั้ง shared dependency bundle ไว้ engine ก็ไม่มีทางเลือกอื่นนอกจากอัด texture นั้นเข้าไปทั้งสอง bundle player ที่โหลดทั้งสอง bundle ก็ต้องจ่ายซ้ำสองครั้ง Addressables มี analysis rule "Check duplicate bundle dependencies" มาให้ในตัวสำหรับเรื่องนี้โดยเฉพาะ ควรรันก่อนออก content ทุกครั้ง ไม่ใช่แค่รันครั้งเดียวตอนตั้ง project
ทุกอย่างที่ผ่านมาคือลดขนาดของสิ่งที่เรามีอยู่แล้ว หัวข้อนี้พูดถึงการไม่ ship บางส่วนของมันเลยตั้งแต่แรก อย่างน้อยก็ไม่ ship ตั้งแต่ต้น
แทนที่จะทำ build เดียวที่มีทุก level, ทุก event, ทุก costume ที่เกมจะมีตลอดอายุของมัน เกม live-service ส่วนใหญ่จะ ship ไฟล์เริ่มต้น (initial download) เล็ก ๆ ที่พาผู้เล่นเข้า main menu ได้เร็ว แล้วค่อยไปดึงส่วนที่เหลือ ทั้ง level ใหม่, content ของ event ตามฤดูกาล, voice pack ของภาษาที่ player ไม่ได้เลือก ฯลฯ มาเป็น downloadable content (มักเรียกย่อว่า DLC) เฉพาะตอนที่ต้องใช้จริง ๆ เท่านั้น
มีระบบจริง ๆ ที่ทำแนวคิดนี้ได้อยู่ไม่กี่ตัว
สังเกตว่า player เห็น main menu ที่เล่นได้แล้ว ก่อนที่ chapter 300 MB จะเริ่มดาวน์โหลดด้วยซ้ำ ช่องว่างระหว่าง "ติดตั้งเสร็จ" กับ "ได้เล่นจริง" นี่แหละคือช่องว่างที่ตัวเลขการเลิกติดตั้งในหัวข้อ 1 ลงโทษเราอยู่ การแยก content แบบนี้ทำให้ช่องว่างนั้นแคบลงมาก ถึงแม้ว่าปริมาณข้อมูลรวมที่ดาวน์โหลดตลอดอายุเกมอาจจะพอ ๆ กันหรือมากกว่าเดิมด้วยซ้ำก็ตาม
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class ChapterLoader : MonoBehaviour
{
AsyncOperationHandle<GameObject> handle;
public async void LoadChapterBoss(string address)
{
// "address" is a string key, e.g. "Chapter1/BossPrefab".
// It can point at a local file or a remote bundle -
// the caller does not need to know which.
handle = Addressables.LoadAssetAsync<GameObject>(address);
GameObject bossPrefab = await handle.Task;
Instantiate(bossPrefab);
Debug.Log($"Loaded {bossPrefab.name}, from {address}");
}
public void UnloadChapterBoss()
{
// Release tells Addressables this handle is no longer needed.
// Once nothing else references the underlying bundle,
// Addressables is free to unload it from memory, and (for
// remote bundles) eventually evict it from local disk cache.
if (handle.IsValid())
{
Addressables.Release(handle);
}
}
}
Loaded Boss_Chapter1, from Chapter1/BossPrefab
ตอน LoadChapterBoss ทำงาน Addressables จะแปลง string "Chapter1/BossPrefab" ไปเป็นตำแหน่งจริงที่ content นั้นอยู่ ดาวน์โหลดถ้าจำเป็น (หรืออ่านจาก local cache ถ้าเคยดึงมาแล้ว) แล้วส่ง prefab ที่ใช้งานได้กลับมาเมื่อพร้อม คำว่า await หมายความว่าทั้งหมดนี้เกิดขึ้นโดยไม่ทำให้เกม freeze ระหว่างที่ดาวน์โหลดหรืออ่านดิสก์อยู่
LoadAssetAsync ไว้เสมอ แล้วเรียก Release เมื่อใช้เสร็จแล้ว Addressables ใช้ reference-counting กับ content ที่โหลดมา การลืม release handle เป็นสาเหตุยอดฮิตอย่างหนึ่งที่ทำให้การใช้ memory ของเกม Unity ค่อย ๆ พุ่งขึ้นเรื่อย ๆ ระหว่างเล่นยาว ๆDownloadable content (หัวข้อ 7) ตัดสินว่าข้อมูลอยู่ในเครื่องหรือไม่ ส่วน streaming ตัดสินว่าข้อมูลนั้นอยู่ใน memory (RAM) ตอนนี้หรือเปล่า ถึงแม้จะเป็น content ที่ดาวน์โหลดมาครบแล้วและอยู่บนดิสก์ก็ตาม open world สามารถใหญ่กว่าที่จะใส่ใน memory ได้พร้อมกันทั้งหมดได้ง่าย ๆ เกมเลยเก็บแค่หน้าต่างหนึ่งของมันไว้รอบตัว player แล้วสลับชิ้นเข้าออกตามที่ player เดินไป
มีสองกฎที่ทำให้มันรู้สึกลื่นไหลแทนที่จะกระตุก
พื้นที่ที่เก็บใน memory มีขอบเขตจำกัดเสมอ ไม่ว่าโลกทั้งหมดจะใหญ่แค่ไหน ถ้าแต่ละ chunk ขนาด 100m x 100m และ load radius เท่ากับ 3 chunk เกมจะเก็บพื้นที่ 700m x 700m ไว้ใน memory รอบตัว player เท่านั้น ไม่ว่าโลกทั้งหมดจะกว้าง 2 km หรือ 200 km ก็ตาม
ปกติใน Unity scene จะแทนที่ scene ที่โหลดอยู่ก่อนหน้าเสมอ นั่นคือ LoadSceneMode.Single ค่า default ส่วนการโหลดแบบ additive คือ LoadSceneMode.Additive จะเพิ่มเนื้อหาของ scene เข้าไปทับสิ่งที่โหลดอยู่แล้ว โดยไม่ไปปล่อยอะไรทิ้ง นี่คือวิธีปกติที่ chunk streaming (หัวข้อ 8) ถูก implement ใน Unity คือแต่ละ chunk เป็น scene เล็ก ๆ ของตัวเอง โหลดและปล่อยแยกกันอิสระตามที่ player เคลื่อนที่
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
public class ChunkStreamer : MonoBehaviour
{
public Transform player;
public float chunkSize = 100f;
public int loadRadius = 1; // chunks kept loaded around the player
readonly HashSet<Vector2Int> loadedChunks = new HashSet<Vector2Int>();
void Update()
{
Vector2Int current = WorldToChunk(player.position);
// Load every chunk within loadRadius of the player.
for (int dx = -loadRadius; dx <= loadRadius; dx++)
{
for (int dz = -loadRadius; dz <= loadRadius; dz++)
{
Vector2Int coord = new Vector2Int(current.x + dx, current.y + dz);
if (!loadedChunks.Contains(coord))
{
LoadChunk(coord);
}
}
}
// Unload anything that fell outside the radius.
var toUnload = new List<Vector2Int>();
foreach (Vector2Int coord in loadedChunks)
{
int distance = Mathf.Max(Mathf.Abs(coord.x - current.x),
Mathf.Abs(coord.y - current.y));
if (distance > loadRadius)
{
toUnload.Add(coord);
}
}
foreach (Vector2Int coord in toUnload)
{
UnloadChunk(coord);
}
}
Vector2Int WorldToChunk(Vector3 pos)
{
return new Vector2Int(
Mathf.FloorToInt(pos.x / chunkSize),
Mathf.FloorToInt(pos.z / chunkSize));
}
void LoadChunk(Vector2Int coord)
{
string sceneName = $"Chunk_{coord.x}_{coord.y}";
loadedChunks.Add(coord);
SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
Debug.Log($"Loading {sceneName}");
}
void UnloadChunk(Vector2Int coord)
{
string sceneName = $"Chunk_{coord.x}_{coord.y}";
loadedChunks.Remove(coord);
SceneManager.UnloadSceneAsync(sceneName);
Debug.Log($"Unloading {sceneName}");
}
}
สมมติ player ยืนนิ่งอยู่ใกล้ chunk (1, 0) ด้วย loadRadius = 1 แล้วเดินเร็ว ๆ ไปทางขวา ข้ามเข้า chunk (2, 0) ไม่กี่ frame ถัดมาจะ log แบบนี้
Loading Chunk_2_0
Loading Chunk_2_1
Loading Chunk_2_-1
Unloading Chunk_-1_0
Unloading Chunk_-1_1
Unloading Chunk_-1_-1
chunk 0 กับ 1 (ตรงกลาง) ยังอยู่ในรัศมีทั้งสองฝั่งของการข้าม เลยไม่โผล่ใน log ทั้งสองแบบเลย เพราะ loadedChunks.Contains(coord) เป็น true อยู่แล้วสำหรับมัน ทำให้ LoadChunk ถูกข้าม และมันยังอยู่ใน loadRadius ของตำแหน่งใหม่ ทำให้ UnloadChunk ถูกข้ามเหมือนกัน มีแค่ chunk ที่เข้าหรือออกจากรัศมีจริง ๆ เท่านั้นที่ทำงาน SceneManager.LoadSceneAsync กับ UnloadSceneAsync ทั้งคู่กระจายงานออกไปหลาย frame แทนที่จะ freeze เกมนาน ๆ ใน frame เดียว นี่แหละคือสิ่งที่ทำให้ streaming รู้สึกลื่นแทนที่จะกระตุกทุกครั้งที่ player ข้ามเส้นแบ่ง chunk
distance > loadRadius เป็นจริงทุก frame เลย เกิดการโหลด/ปล่อย chunk เดิมซ้ำ ๆ ตลอด วิธีแก้ทั่วไปคือมี buffer เล็ก ๆ คือปล่อยก็ต่อเมื่อ player ห่างออกไป loadRadius + 1 chunk ไม่ใช่แค่ loadRadius พอดี เพื่อไม่ให้การเดินไปมาใกล้เส้นแบ่งเกิด thrashingไม่ว่าเราจะบีบอัดและ stream content ได้ดีแค่ไหน การโหลดของใหม่ก็ยังใช้เวลามากกว่าศูนย์เสมอ เป้าหมายไม่ใช่การทำให้เวลานั้นหายไป แต่เป็นการทำให้แน่ใจว่า player ไม่ต้องนั่งจ้องหน้าจอที่ค้างรอมันเลย
ChunkStreamer จากหัวข้อ 9 ได้ประโยชน์นี้เกือบจะฟรีอยู่แล้ว LoadSceneAsync เป็น asynchronous โดยธรรมชาติ ตราบใดที่ load radius กว้างพอเมื่อเทียบกับความเร็วของ player chunk ที่ player กำลังเดินเข้าไปจะโหลดเสร็จก่อนที่จะไปถึงจริง ซ่อนอยู่หลัง gameplay ปกติทั้งหมด
เกม live-service ออก patch ทุกไม่กี่สัปดาห์ ถ้าทุก patch ต้องส่ง build ทั้งก้อนใหม่ทั้งหมดแค่เพื่อแก้ค่า damage ของอาวุธหนึ่งชิ้น player ที่เน็ตช้าจะยิ่งตามหลังมากขึ้นทุกครั้งที่มี update และค่าใช้จ่ายของ server กับ CDN (หัวข้อ 12) จะบวมขึ้นโดยไม่มีเหตุผล
Delta update (หรือ diff patch) คือการเทียบไฟล์เวอร์ชันเก่ากับเวอร์ชันใหม่ แล้วส่งเฉพาะ byte ที่เปลี่ยนไปจริง ๆ แทนที่จะส่งไฟล์ทั้งไฟล์ใหม่อีกครั้ง client มีไฟล์เก่าอยู่แล้ว ก็แค่ apply ส่วนต่างเล็ก ๆ นั้นเข้ากับสิ่งที่มีอยู่ แทนที่จะดาวน์โหลดทุกอย่างใหม่ตั้งแต่ต้น ในระดับ downloadable content bundle (หัวข้อ 7) โดยปกติแปลว่า มีแค่ bundle ที่เปลี่ยนจริง ๆ เท่านั้นที่ถูกดาวน์โหลดใหม่ bundle ที่ไม่ได้แตะต้องเลยจะถูกปล่อยไว้เฉย ๆ ทั้งหมด
เพื่อจะรู้ว่า bundle ไหนเปลี่ยน แต่ละ bundle จะถูกติด tag ด้วยเลข version และมักจะมี content hash ด้วย (ลายนิ้วมือสั้น ๆ ของเนื้อหาไฟล์เป๊ะ ๆ ถ้าแม้แต่ byte เดียวเปลี่ยน hash ก็จะเปลี่ยนตาม) client กับ server เทียบ manifest เล็ก ๆ ที่ลิสต์ hash ปัจจุบันของทุก bundle แล้วดึงมาเฉพาะ bundle ที่ hash ไม่ตรงกับที่ client มีอยู่แล้วเท่านั้น
{
"Chunk_2_0": { "version": 7, "hash": "a91f3d", "size_kb": 820 },
"Chunk_2_1": { "version": 3, "hash": "77c210", "size_kb": 640 },
"WeaponBalanceTable": { "version": 41, "hash": "0e5b9c", "size_kb": 4 }
}
ลอง trace ดู client ตอนนี้มี WeaponBalanceTable ที่ hash 5f21aa และ chunk bundle สองก้อนข้างบนตรงกับที่ server ลิสต์ไว้อยู่แล้ว manifest ล่าสุดของ server บอกว่า WeaponBalanceTable ตอนนี้อยู่ที่ hash 0e5b9c client เทียบ hash ทั้งสามตัว เจอว่ามีแค่ WeaponBalanceTable เท่านั้นที่ต่างกัน แล้วดาวน์โหลดแค่ไฟล์ 4 KB ก้อนนั้นก้อนเดียว ข้อมูล chunk 1.4 MB ที่ไม่ได้เปลี่ยนจะไม่ถูกส่งซ้ำเลย
เรื่องนี้สำคัญมากสำหรับเกม live-service เพราะ patch ส่วนใหญ่เป็นการปรับ balance กับ data ไม่ใช่ art ใหม่ ซึ่งเป็นการเปลี่ยนแปลงแบบที่ควรมีต้นทุนแค่หลัก kilobyte ไม่ใช่หลัก gigabyte ถ้าระบบ patch ถูกตั้งไว้ให้สังเกตได้ว่าที่เหลือไม่มีอะไรขยับเลย
เมื่อ content ถูกแยกเป็นชิ้นเล็ก ๆ ที่มี version แยกกันแล้ว ต้องมีอะไรสักอย่างมา serve ชิ้นเหล่านั้นให้ player หลายล้านคนทั่วโลก ได้เร็ว ในเวลาเดียวกับที่ patch เพิ่งปล่อยออกไปพอดี นั่นคืองานของ CDN (Content Delivery Network) คือชุดของ server ที่เรียกว่า edge node กระจายอยู่หลายภูมิภาค แต่ละที่เก็บสำเนาไฟล์ content ของเราไว้
แทนที่การดาวน์โหลดของ player ทุกคนจะต้องเดินทางไปที่ server กลางแห่งเดียวอีกฟากหนึ่งของโลก คำขอของแต่ละ player จะถูก serve จาก edge node ที่อยู่ใกล้ตัวแทน วิธีนี้ให้ประโยชน์สามอย่างพร้อมกัน คือ latency ต่ำลง (เวลาก่อนที่ข้อมูลจะเริ่มมาถึง), bandwidth รวมสูงขึ้น (player หลายพันคนดาวน์โหลด patch พร้อมกันถูกกระจายไปหลายเครื่องแทนที่จะทับเครื่องเดียว), และความทนทาน (ถ้า edge node หนึ่งมีปัญหา คำขอสามารถถูกส่งไปที่อื่นแทนได้)
setup ทั่วไปคือ build pipeline อัปโหลด content bundle ใหม่ไปที่ origin storage แล้ว CDN คัดลอกออกไปที่ edge node ต่าง ๆ และ remote catalog ของเกม (manifest จากหัวข้อ 11) ก็แค่ชี้ไปที่ URL ของ CDN เพราะ client สนใจแค่ URL กับ hash เท่านั้น การเปลี่ยนไปใช้ CDN provider เจ้าอื่น หรือเพิ่ม edge region ก็ไม่จำเป็นต้อง ship เกมเวอร์ชันใหม่เลย
Wi-Fi ที่ออฟฟิศหรือที่บ้านเร็วและเสถียรกว่าที่ player จริง ๆ หลายคนมีมาก player บางคนอยู่ในอุโมงค์รถไฟที่สัญญาณหลุดกลางดาวน์โหลด บางคนใช้ Wi-Fi สาธารณะที่แชร์กับมือถืออีกสองร้อยเครื่อง หรือใช้ cellular ที่หลุดไปสองสามวินาทีเป็นพัก ๆ flow การดาวน์โหลดที่ทำงานสมบูรณ์แบบบนการเชื่อมต่อที่เร็วและเสถียร อาจล่มไม่เป็นท่าบนการเชื่อมต่อจริง ทั้งค้างตลอดกาล, retry วนลูปไม่เลิก, หรือทิ้งไฟล์ที่เขียนไม่ครบไว้ครึ่ง ๆ กลาง ๆ และบั๊กแบบนี้จะไม่โผล่มาให้เห็นในการทดสอบปกติเลย นอกจากจะมีใครตั้งใจไปหามันจริง ๆ
วิธีทดสอบเรื่องนี้จริง ๆ แทนที่จะหวังให้มันดีเอง
using System;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
public static class DownloadRetry
{
public static async Task DownloadWithRetry(string address, int maxAttempts = 3)
{
int attempt = 0;
while (true)
{
attempt++;
var handle = Addressables.DownloadDependenciesAsync(address);
await handle.Task;
if (handle.Status == UnityEngine.ResourceManagement
.AsyncOperations.AsyncOperationStatus.Succeeded)
{
Debug.Log($"Downloaded {address} successfully");
Addressables.Release(handle);
return;
}
Addressables.Release(handle);
Debug.Log($"Download failed (attempt {attempt}/{maxAttempts})");
if (attempt >= maxAttempts)
{
throw new Exception($"Could not download {address} after {maxAttempts} attempts");
}
await Task.Delay(1000 * attempt); // simple backoff: wait longer each retry
}
}
}
Download failed (attempt 1/3)
Download failed (attempt 2/3)
Downloaded Chapter1/BossPrefab successfully
การเชื่อมต่อหลุดสองครั้ง แล้ว loop รอนานขึ้นอีกนิดหลังจากล้มเหลวแต่ละครั้ง (1 วินาที แล้วก็ 2 วินาที) ก่อนจะลองใหม่ แทนที่จะยิงซ้ำ ๆ ใส่การเชื่อมต่อที่แย่อยู่แล้วเต็มความเร็ว หรือยอมแพ้ทันที ครั้งที่สามมันสำเร็จแล้ว return ตามปกติ ถ้าไม่มี retry logic แบบนี้ แค่สะดุดครั้งแรกบนการเชื่อมต่อที่แย่ก็จะทำให้การดาวน์โหลด crash ไปเลย หรือไม่ก็ทำให้ player ค้างอยู่กับ progress bar ที่ไม่ขยับตลอดไป
Textures 210.0 MB (40%)
Audio 120.0 MB (23%)
Fonts & UI Sprites 95.0 MB (18%)
Meshes 60.0 MB (11%)
Scripts (IL2CPP code) 8.0 MB ( 2%)
Misc 35.0 MB ( 6%)
------------------------------------------------
Total (compressed) 528.0 MB
ทุก build ก่อนหน้านี้ของ project นี้ Fonts & UI Sprites อยู่ต่ำกว่า 10 MB มาตลอด ไม่มีใครจำได้ว่าเพิ่ม UI art ใหม่ไป 85 MB เมื่อไหร่ อะไรน่าจะเป็นสาเหตุที่เป็นไปได้มากที่สุด และเราจะทำอะไรตามลำดับจริง ๆ เพื่อหาและแก้มัน95 MB สำหรับ fonts และ UI sprites ที่กระโดดขึ้นมาจากปกติที่ <10 MB แทบจะแน่นอนว่าไม่ใช่งาน UI จริง ๆ มันมีรูปแบบของ asset ที่หลุดเข้ามาโดยไม่ตั้งใจ (หัวข้อ 3) ไม่ใช่การเติบโตที่ตั้งใจ สาเหตุที่น่าจะเป็นไปได้มากที่สุดคือ texture ขนาดใหญ่ที่ไม่ใช่ UI (debug overlay, ภาพ screenshot อ้างอิงเต็มจอ, mockup design ที่หลงเหลืออยู่) ที่ถูก import มาแล้วถูกจัดหมวดหรืออัดรวมอยู่กับ UI sprite atlas หรือมี public field ที่ไหนสักแห่งยังอ้างอิงถึงของแบบนั้นอยู่
ขั้นตอนที่จะหามันจริง ๆ
debugReferenceArt ในหัวข้อ 3) แล้ว build ใหม่เพื่อยืนยันว่าหมวดนั้นกลับลงมาใกล้ขนาดปกติแล้วResources ให้มองเป็นปัญหาที่ลึกกว่านั้น เพราะอะไรก็ตามใน Resources จะ ship ไม่ว่าจะถูกอ้างอิงหรือไม่ก็ตาม ทางแก้จริง ๆ คือย้ายมันออกจาก Resources ไปเลย ไม่ใช่แค่ลบ reference เดียวChunkStreamer จากหัวข้อ 9 ใช้ค่า loadRadius เดียวกันทั้งสำหรับโหลดและปล่อย ซึ่งกล่อง warning ของหัวข้อ 9 ชี้ไว้แล้วว่าทำให้เกิดการโหลด/ปล่อยซ้ำ ๆ (thrashing) เมื่อ player ยืนใกล้เส้นแบ่ง chunk ให้แก้ class นี้ให้ใช้รัศมีที่สองแยกออกมาสำหรับการปล่อย ซึ่งใหญ่กว่ารัศมีโหลด เพื่อให้ chunk ถูกปล่อยก็ต่อเมื่อ player อยู่นอกพื้นที่ที่โหลดไว้ชัดเจนแล้วเท่านั้น ไม่ใช่ทันทีที่ข้ามเส้นทางแก้คือเพิ่ม field รัศมีตัวที่สอง ที่ใหญ่กว่า loadRadius อย่างชัดเจน แล้วใช้มันเฉพาะในการเช็คตอนปล่อยเท่านั้น ส่วน loop โหลดยังใช้ loadRadius เหมือนเดิมไม่เปลี่ยน
public class ChunkStreamer : MonoBehaviour
{
public Transform player;
public float chunkSize = 100f;
public int loadRadius = 1;
public int unloadRadius = 2; // must be > loadRadius to add a buffer
readonly HashSet<Vector2Int> loadedChunks = new HashSet<Vector2Int>();
void Update()
{
Vector2Int current = WorldToChunk(player.position);
for (int dx = -loadRadius; dx <= loadRadius; dx++)
{
for (int dz = -loadRadius; dz <= loadRadius; dz++)
{
Vector2Int coord = new Vector2Int(current.x + dx, current.y + dz);
if (!loadedChunks.Contains(coord))
{
LoadChunk(coord);
}
}
}
var toUnload = new List<Vector2Int>();
foreach (Vector2Int coord in loadedChunks)
{
int distance = Mathf.Max(Mathf.Abs(coord.x - current.x),
Mathf.Abs(coord.y - current.y));
if (distance > unloadRadius) // was loadRadius - this was the bug
{
toUnload.Add(coord);
}
}
foreach (Vector2Int coord in toUnload)
{
UnloadChunk(coord);
}
}
Vector2Int WorldToChunk(Vector3 pos)
{
return new Vector2Int(
Mathf.FloorToInt(pos.x / chunkSize),
Mathf.FloorToInt(pos.z / chunkSize));
}
void LoadChunk(Vector2Int coord)
{
string sceneName = $"Chunk_{coord.x}_{coord.y}";
loadedChunks.Add(coord);
SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
}
void UnloadChunk(Vector2Int coord)
{
string sceneName = $"Chunk_{coord.x}_{coord.y}";
loadedChunks.Remove(coord);
SceneManager.UnloadSceneAsync(sceneName);
}
}
ด้วย loadRadius = 1 และ unloadRadius = 2 chunk ที่อยู่ระยะ 2 จะยังคงโหลดอยู่ต่อไป ถึงแม้จะอยู่นอก load radius แล้วก็ตาม มันแค่จะไม่ถูกสั่งโหลดซ้ำ เพราะมันอยู่ใน loadedChunks อยู่แล้ว ต้องรอจน player เดินห่างออกไปมากพอจน chunk อยู่ที่ระยะ 3 ถึงจะถูกปล่อยจริง ๆ player ที่ขยับไปมาข้ามเส้นแบ่งอยู่ที่ระยะ 1-ถึง-2 จะไม่ทำให้เกิดการโหลดหรือปล่อยอะไรเลยอีกต่อไป ซึ่งนี่คือจุดประสงค์ทั้งหมดของ buffer นี้
WeaponBalanceTable ซึ่งมีขนาด 4 KB และไม่มีอะไรอื่นเปลี่ยนเลย โดยใช้แนวคิด version manifest จากหัวข้อ 11 ให้ (a) อธิบายเป็นขั้นตอนว่า client รู้ได้ยังไงว่ามีแค่ bundle เดียวนี้เท่านั้นที่ต้องดาวน์โหลดใหม่ และ (b) ประมาณว่าข้อมูลถูกส่งเท่าไหร่ใน patch นี้ ถ้าใช้ delta/versioned patching เทียบกับถ้าไม่ใช้(a) client รู้ได้ยังไงว่าอะไรเปลี่ยน
WeaponBalanceTable จะถูกจัดคิวให้ดาวน์โหลด เพราะ hash ที่เปลี่ยนแปลว่าเนื้อหาข้างในเปลี่ยน(b) ข้อมูลที่ถูกส่ง