แม้ว่าความจุ RAM จริงของอุปกรณ์เคลื่อนที่สมัยใหม่จะเพิ่มขึ้นอย่างต่อเนื่อง แต่การใช้หน่วยความจำของเกมก็เพิ่มขึ้นเร็วกว่าการเติบโตนี้เนื่องจากชิ้นงานที่มีความละเอียดสูง และไปป์ไลน์การแสดงผลที่ซับซ้อน
ปัญหาหลักที่เกิดจากการใช้งานหน่วยความจำมากเกินไป
- การสิ้นสุดการทำงานเนื่องจากหน่วยความจำไม่เพียงพอ (LMK): ระบบปฏิบัติการจะปิดแอปที่ทำงานอยู่เบื้องหลัง หรือแม้แต่แอปที่ทำงานอยู่เบื้องหน้าโดยบังคับเพื่อแก้ไขปัญหาหน่วยความจำไม่เพียงพอทั่วทั้งระบบ
- เฟรมหลุดและภาพกระตุก: การเพิ่มขึ้นของการเก็บขยะ (GC) บ่อยครั้งใน Managed Heap หรือการสลับหน่วยความจำระดับ OS ทำให้เกิดคอขวดในการประมวลผล
- การควบคุมความร้อนและการสิ้นเปลืองแบตเตอรี่: การจัดสรรหน่วยความจำ การยกเลิกการจัดสรร และการคอมมิตหน้าหน่วยความจำอย่างต่อเนื่องจะทำให้ CPU ทำงานหนักมาก ส่งผลให้เกิดความร้อนเพิ่มขึ้นและแบตเตอรี่หมดเร็วขึ้น
การอัปเดตการจัดการหน่วยความจำของ Android
- Android 17: การเปิดตัว
MemoryLimiter: Android 17 เปิดตัวMemoryLimiterซึ่งจะตรวจสอบการใช้หน่วยความจำของแอปพลิเคชันอย่างต่อเนื่องเทียบกับเกณฑ์เฉพาะของอุปกรณ์ ระบบจะปิดแอปที่ใช้หน่วยความจำเกิน ขีดจำกัดทันที เนื่องจากกลไกนี้เข้มงวดกว่า LMK แบบดั้งเดิม การจัดการการใช้งานหน่วยความจำสูงสุดจึงมีความสำคัญมากกว่าที่เคย
ลดการใช้งานหน่วยความจำใน Unity
ใน Unity เมื่อเอนจินขยายพูลหน่วยความจำภายใน (Native Block
Allocators และ Managed Heap) เพื่อรองรับการโหลดสูงสุดที่สูง Unity จะเก็บหน้าหน่วยความจำเหล่านั้นไว้แทนที่จะส่งคืนไปยังระบบปฏิบัติการทันที
ด้วยเหตุนี้ แม้หลังจากเลิกโหลดเนื้อหาที่มีขนาดใหญ่แล้ว Resident
Memory พื้นฐานก็ยังคงสูงขึ้น ทำให้แอปพลิเคชันมีความเสี่ยงสูงต่อการสิ้นสุดกระบวนการของ Android
OS (เช่น LMK หรือ MemoryLimiter)
การเพิ่มประสิทธิภาพหน่วยความจำต้องดำเนินการผ่านเสาหลัก 3 ประการต่อไปนี้เพื่อป้องกันการบังคับใช้ระดับระบบปฏิบัติการ
- หมวดหมู่ ก: ลดการใช้งานหน่วยความจำสูงสุด
- หมวดหมู่ ข: กำจัดร่องรอยของเนื้อหาและระบบที่ไม่จำเป็น
- หมวดหมู่ ค. การจัดสรร GC ที่ไม่จำเป็น
หมวดหมู่ ก: ลดการใช้งานหน่วยความจำสูงสุด
ตัวจัดสรรของ Unity จะเก็บหน้าหน่วยความจำที่ว่างไว้เพื่อนำกลับมาใช้ใหม่แทนที่จะส่งคืนให้ระบบปฏิบัติการทันที ดังนั้นหน่วยความจำพื้นฐานจึงมักจะแสดงถึงจุดสูงสุดที่เคยมีมากกว่าการใช้งานในปัจจุบัน ดังนั้นการป้องกันไม่ให้เกิดการเพิ่มขึ้นตั้งแต่แรกจึงมีประสิทธิภาพมากกว่าการพึ่งพาการล้างข้อมูลหลังจากเกิดเหตุการณ์
1. หลีกเลี่ยง AssetBundle ที่มีขนาดใหญ่เกินไป
เนื่องจากกลไกการโหลด Bundle ของ Unity ทำให้ AssetBundle ขนาดใหญ่ส่งผลให้เกิด การใช้หน่วยความจำมากเกินไปและการขัดข้องเนื่องจากหน่วยความจำไม่เพียงพอ จัดกลุ่มให้เป็นโมดูลเพื่อให้มั่นใจว่ามีการจัดการทรัพยากรอย่างมีประสิทธิภาพ
ปัญหาสำคัญ
- ค่าใช้จ่ายด้านหน่วยความจำสูง: การขอชิ้นงานขนาดเล็กเพียงชิ้นเดียวจะบังคับให้ Unity โหลดทั้งไฟล์ Bundle (รวมถึงส่วนหัว ข้อมูลเมตา และบัฟเฟอร์การสตรีม) ลงใน RAM
- การดักการเลิกโหลด: หากมีการใช้งานชิ้นงานใดๆ ในแพ็กเกจอยู่ ระบบจะเลิกโหลดทั้งแพ็กเกจไม่ได้ ซึ่งจะทำให้ข้อมูลที่ไม่ได้ใช้ติดอยู่ใน RAM
แนวทางปฏิบัติแนะนำ
- จัดกลุ่มเนื้อหาเป็นโมดูล: จัดกลุ่มชิ้นงานตามฉากหรือวงจรที่สมเหตุสมผล
- เคล็ดลับสำหรับ Unity 6.6 ขึ้นไป: ใช้ไดเรกทอรีเนื้อหาเพื่อป้องกัน การขึ้นต่อกันข้าม Bundle โดยไม่ตั้งใจ
2. เพิ่มประสิทธิภาพการอ้างอิงเนื้อหาใน ScriptableObjects
ฟิลด์ที่ซีเรียลไลซ์โดยตรงของ UnityEngine.Object ใน ScriptableObject จะสร้างการอ้างอิงแบบฮาร์ดโดยตรง
ซึ่งบังคับให้โหลดชิ้นงานทั้งหมดที่อ้างอิงลงใน RAM ทันทีที่โหลดหรือสร้างอินสแตนซ์ของ ScriptableObject เอง
// BEFORE: Loading SceneRequiredAssets forces _worldAsset and _spawnSettings into RAM immediately
public class SceneRequiredAssets : ScriptableObject
{
public string sceneName;
public Object _worldAsset;
public Object _spawnSettings;
}
// AFTER: Use AssetReference to enable asynchronous, on-demand loading using Addressables
public class SceneRequiredAssets : ScriptableObject
{
public string sceneName;
public AssetReference _worldAsset;
public AssetReference _spawnSettings;
}
3. กำหนดค่าประเภทการโหลดคลิปเสียง
การโหลดคลิปเสียงทั้งหมดแบบไม่บีบอัดลงในหน่วยความจำโดยตรงจะทำให้เกิด การใช้งานหน่วยความจำสูงสุดอย่างถาวร ควรตั้งค่าการโหลดเสียงตามโปรไฟล์การใช้งาน
| หมวดหมู่เสียง | ประเภทการโหลด | เหตุผล |
|---|---|---|
| BGM (เพลงประกอบ) | สตรีมมิง | สตรีมเสียงจากดิสก์ในบัฟเฟอร์ขนาดเล็กเพื่อขจัดปัญหาหน่วยความจำเพิ่มขึ้นอย่างรวดเร็ว |
| SFX แบบยาว | บีบอัดในหน่วยความจำ | ช่วยให้ค่าใช้จ่ายของ RAM ต่ำและคลายการบีบอัดเสียงได้ทันทีระหว่างการเล่น |
| SFX สั้นๆ ที่ใช้บ่อย | แตกไฟล์เมื่อโหลด | คลายการบีบอัดเสียงลงใน RAM เมื่อโหลดเพื่อหลีกเลี่ยงค่าใช้จ่ายของ CPU ในรันไทม์ระหว่างการเล่น |
4. ใช้กลยุทธ์การจัดกลุ่มออบเจ็กต์และการเผยแพร่
อินสแตนซ์ที่ยังไม่ได้เผยแพร่ซึ่งยังคงอยู่ใน Object Pool เมื่อมีการเปลี่ยนฉากจะใช้หน่วยความจำที่สงวนไว้ไปเรื่อยๆ ทำให้หน่วยความจำที่ใช้พื้นฐานเพิ่มขึ้นโดยไม่จำเป็น
- การดำเนินการ: ล้างหรือตัดออบเจ็กต์ที่รวมกันซึ่งไม่ได้ใช้เป็นระยะๆ ในระหว่างฉาก เปลี่ยนหรือช่วงที่มีกิจกรรมต่ำ เพื่อให้ Unity สามารถคืนหรือนำพื้นที่ที่สงวนไว้ นั้นกลับมาใช้ซ้ำสำหรับการจัดสรรอื่นๆ
หมวดหมู่ ข: ลดการใช้งานหน่วยความจำที่ไม่จำเป็น
การกำจัดเนื้อหากราฟิกที่ซ้ำซ้อนและการแสดงผลบัฟเฟอร์เป้าหมายโดยตรง จะช่วยลดหน่วยความจำที่ใช้พื้นฐาน
1. เพิ่มประสิทธิภาพพื้นผิวการแสดงผลและความลึกของกล้อง
- นำบัฟเฟอร์ความลึก/ลายฉลุออก: ตั้งค่ารูปแบบลายฉลุความลึกเป็นไม่มี สำหรับ Render Texture ที่ต้องการเฉพาะข้อมูลสี
- ปิดใช้เท็กซ์เจอร์ความลึกของกล้อง UI: สำหรับกล้อง UI ที่ไม่จำเป็นต้องใช้ข้อมูลความลึก ให้ปิดใช้การสร้างเท็กซ์เจอร์ความลึกในการตั้งค่ากล้อง URP เพื่อกำจัด
CopyDepthPass และหน่วยความจำเท็กซ์เจอร์ GPU ที่เชื่อมโยง
2. เพิ่มประสิทธิภาพพื้นผิวและตาข่าย
| หมวดหมู่ | หลักเกณฑ์การเพิ่มประสิทธิภาพ |
|---|---|
| การบีบอัดพื้นผิว | ใช้รูปแบบการบีบอัดของแพลตฟอร์มเป้าหมายเสมอ (เช่น ASTC สำหรับ Android) |
| เปิดใช้การอ่าน/เขียน | ปิดใช้ไว้จนกว่าจะจำเป็น การเปิดใช้ตัวเลือกนี้จะทำซ้ำหน่วยความจำของพื้นผิวใน RAM ของ CPU และ GPU |
| Mipmap | ปิดใช้ Mipmap สำหรับพื้นผิว UI หรือออบเจ็กต์ที่แก้ไขในระยะกล้องคงที่ ซึ่งจะช่วยประหยัดหน่วยความจำพื้นผิวได้ประมาณ 33% |
| ความซับซ้อนของ Mesh | ลดจำนวนรูปหลายเหลี่ยมและสตรีมจุดยอดที่ไม่จำเป็นเพื่อลดหน่วยความจำที่ใช้ของ GPU และหน่วยความจำดั้งเดิม |
3. แยกตัวแปรของ Shader และเพิ่มประสิทธิภาพหน่วยความจำ
Uber-shader (เช่น URP Lit Shader) จะห่อหุ้มฟีเจอร์ต่างๆ มากมายโดยใช้คีย์เวิร์ด #multi_compile และ shader_feature หากไม่มีการเพิ่มประสิทธิภาพ
การระเบิดแบบคอมบิเนชันจะสร้างตัวแปรเชเดอร์ที่ไม่ซ้ำกันหลายหมื่นรายการ
ซึ่งส่งผลให้ขนาดบิลด์เพิ่มขึ้น การใช้หน่วยความจำดั้งเดิมจำนวนมาก
และการสะดุดในการคอมไพล์ไดรเวอร์ GPU ระหว่างการเล่นเกม
ก. กลไกของค่าใช้จ่ายเพิ่มเติมของหน่วยความจำตัวแปรของ Shader
- การขยายแบบรวม: รูปแบบที่เป็นไปได้ทั้งหมดจะเพิ่มขึ้นแบบทวีคูณเมื่อ เพิ่มกลุ่มคีย์เวิร์ดแต่ละกลุ่ม
- สถาปัตยกรรมการจัดสรร Chunk: Unity จะคอมไพล์ไบนารีที่แตกต่างกันของกลุ่ม เป็นบล็อกหน่วยความจำที่บีบอัดซึ่งเรียกว่า Chunk (ค่าเริ่มต้น: 4 MB)
- หน่วยความจำเนทีฟบวม: เมื่อโค้ดรันไทม์ขอแม้แต่ตัวแปรเดียว ภายในก้อน ระบบจะคลายการบีบอัดก้อนขนาด 4 MB ทั้งหมดลงใน RAM หากไม่ได้เพิ่มประสิทธิภาพ ตัวแปรที่ไม่ได้ใช้หลายพันรายการซึ่งรวมอยู่ในก้อนเหล่านั้นจะใช้หน่วยความจำดั้งเดิมอย่างถาวร
ข. ไปป์ไลน์การลบหลายระดับในตัวของ Unity: Unity จะลบ
ตัวแปรที่ไม่จำเป็นโดยอัตโนมัติในเวลาบิลด์โดยอิงตามการตั้งค่ากราฟิกของแพลตฟอร์มเป้าหมาย
และฟีเจอร์ของเอนจินที่ไม่ได้ใช้ (เช่น การตั้งค่าหมอก, ไลท์แมป และ XR) นอกจากนี้ ระบบจะกรองตัวแปร shader_feature ออกโดยอัตโนมัติ
หากไม่มีการใช้คีย์เวิร์ดของตัวแปรเหล่านั้นในเนื้อหาใดๆ ใน
โปรเจ็กต์ ในขณะที่ระบบจะรวมตัวแปร #multi_compile ไว้โดยบังคับไม่ว่าจะมีการใช้งานหรือไม่ก็ตาม
ค. สถาปัตยกรรมการลบอัตโนมัติที่กำหนดเอง
(IPreprocessShaders) เนื่องจาก Static Analysis ตรวจหาคีย์เวิร์ดที่แก้ไขแบบไดนามิกโดยใช้สคริปต์ C# รันไทม์ (Material.EnableKeyword) ไม่ได้ การลบมาตรฐานจึงมักไม่เพียงพอ หากต้องการ
ตรวจสอบว่ามีเฉพาะตัวแปรที่ใช้จริงเท่านั้น คุณสามารถรวบรวมตัวแปร
ระหว่างชุดทดสอบ QA โดยใช้ Player.log (เปิดใช้
บันทึกการคอมไพล์ Shader ในการตั้งค่า Editor) หรือ Profiler Traces
(เครื่องหมาย Shader.CreateGPUProgram) จากนั้นใช้
IPreprocessShaders.OnProcessShader ในสคริปต์ Editor เพื่อกรอง
ตัวแปรที่ไม่เคยดำเนินการในระหว่างรันไทม์ โดยเก็บเฉพาะตัวแปรที่จำเป็น
ไว้ในบิลด์
หมวดหมู่ ค. การกำจัดการจัดสรร GC ที่ไม่จำเป็น
การจัดสรรการเก็บขยะ (GC) ในฮีปที่มีการจัดการทำให้เกิดการ กระจายหน่วยความจำ การขยายฮีปที่ไม่เคยลดลง และเฟรมหลุดอย่างรุนแรงในระหว่าง การหยุดชั่วคราวของ GC
1. ป้องกันการจัดสรร Closure ของ Lambda
เมื่อนิพจน์ Lambda จับตัวแปรภายในภายนอก C# จะสร้าง
คลาสการแสดงผลโดยนัยในฮีป การดำเนินการนี้ภายใน Update จะจัดสรร
อินสแตนซ์ของ Closure ทุกเฟรม
// Bad: Capturing local variable 'targetId' allocates a new closure object on the Heap every frame
void Update()
{
int targetId = 100;
Monster target = monsterList.Find(m => m.Id == targetId);
}
// Good 1: Replace with a standard 'for' loop (Recommended: 0 B allocation)
void Update()
{
int targetId = 100;
Monster target = null;
for (int i = 0; i < monsterList.Count; i++)
{
if (monsterList[i].Id == targetId)
{
target = monsterList[i];
break;
}
}
}
// Good 2: Use a static lambda (C# 9.0+) if no outer variables are captured
Monster target = monsterList.Find(static m => m.Id == 100);
2. ใช้ stackalloc และ Span
หลีกเลี่ยงการจัดสรรฮีปสำหรับอาร์เรย์ชั่วคราวที่มีอายุสั้นโดยใช้หน่วยความจำสแต็ก
// Before: Allocates an array on the Heap every call (GC Target)
Vector2[] pos = new Vector2[4];
// After: Utilizes Stack memory using System.Span (0 B Heap Allocation)
System.Span<Vector2> pos = stackalloc Vector2[4];
3. เพิ่มประสิทธิภาพการทำซ้ำคอลเล็กชัน
หลีกเลี่ยงการจัดสรรฮีปของ Enumerator และการจัดสรรฮีปของ Boxing ที่เกิดจากส่วนขยาย LINQ หรือตัวเข้าถึง ReadOnlyCollection
// Before: LINQ Count() causes internal GetEnumerator() heap allocations
bool hasData = component != null && component.parameters.Count(parameter => parameter.overrideState) > 0;
// After: Replaced with indexer and direct loop iteration
bool hasData = HasDataOptimized(component);
private bool HasDataOptimized(TestComponent component)
{
if (component == null) return false;
var count = component.parameters.Count;
for (var i = 0; i < count; ++i)
{
if (component.parameters[i].overrideState)
return true;
}
return false;
}
4. กฎเพิ่มเติมในการป้องกันการจัดสรร GC
- หลีกเลี่ยงการใช้
Camera.allCamerasเนื่องจากจะสร้างอาร์เรย์Camera[]ใหม่ในฮีปทุกครั้งที่เรียกใช้ แต่ให้แคชอาร์เรย์กล้องแล้วส่งไปยังCamera.GetAllCameras(_allCameras)แทน - หลีกเลี่ยง
foreachในIReadOnlyList<T>: การวนซ้ำในอินเทอร์เฟซ จะทำให้เกิดการ Boxing ตัวแจงนับโครงสร้าง ซึ่งจะสร้างการจัดสรร GC โปรดใช้ลูปforมาตรฐานแทน - แคชออบเจ็กต์โครูทีน: แคชอินสแตนซ์
WaitForSecondsแทนการสร้างอินสแตนซ์yield return new WaitForSeconds(time);ซ้ำๆ - คีย์ Struct ที่กำหนดเองในพจนานุกรม: การใช้ Struct ที่กำหนดเองเป็นคีย์ Dictionary
จะเรียกใช้
Equalsเริ่มต้น ซึ่งจะทริกเกอร์การ Boxing ออบเจ็กต์ ใช้IEqualityComparer<T>แล้วส่งไปยังตัวสร้างพจนานุกรม
public struct TypeKey
{
public int v1;
public int v2;
public class TypeKeyComparer : IEqualityComparer<TypeKey>
{
public bool Equals(TypeKey x, TypeKey y) => x.v1 == y.v1 && x.v2 == y.v2;
public int GetHashCode(TypeKey obj) => obj.v1.GetHashCode() ^ obj.v2.GetHashCode();
}
}
// Pass custom comparer during Dictionary initialization to prevent boxing
public readonly Dictionary<TypeKey, int> _typeKeyDictionary = new(new TypeKey.TypeKeyComparer());
5. ลดการใช้งานทั่วไปและการสะท้อน
แม้ว่าวิธีการทั่วไปจะช่วยให้สามารถนำโค้ดกลับมาใช้ซ้ำและบำรุงรักษาได้อย่างยอดเยี่ยม แต่การใช้วิธีการเหล่านี้มากเกินไปอาจส่งผลเสียต่อโปรเจ็กต์ในบริบทของแบ็กเอนด์ IL2CPP (Intermediate Language to C++) ของ Unity
- โค้ด IL2CPP ที่มีขนาดใหญ่เกินไป: สำหรับชุดค่าผสมของประเภททั่วไปที่ไม่ซ้ำกันทุกชุด IL2CPP จะสร้างโค้ดเวอร์ชันเฉพาะ การใช้ Generics ที่ซับซ้อนมากเกินไปอาจทำให้โค้ด C++ ที่สร้างขึ้นเกิด "การระเบิดแบบคอมบิเนชัน" ซึ่งจะเพิ่มขนาดไบนารีของแอปพลิเคชันและร่องรอยหน่วยความจำแบบเนทีฟอย่างมาก
- ค่าใช้จ่ายในการสะท้อน: วิธีการที่ใช้การสะท้อน เช่น API ของ
System.Reflectionจะช้าโดยธรรมชาติและมักทำให้เกิดการจัดสรรฮีปในระหว่างรันไทม์ - แนวทางปฏิบัติแนะนำ: ใช้ Generics อย่างรอบคอบ โดยให้ความสำคัญกับความชัดเจนของสถาปัตยกรรมมากกว่าการใช้งานแบบกว้างๆ โดยไม่เลือก ในกรณีที่
ประสิทธิภาพมีความสําคัญ ให้เลือกใช้ประเภทที่เฉพาะเจาะจงหรือ
การพ้องรูปที่อิงตามอินเทอร์เฟซ สำหรับการรีเฟลกชัน ให้แคชผลลัพธ์ เช่น
MethodInfoหรือFieldInfoระหว่างการเริ่มต้นแทนที่จะค้นหาในลูปการอัปเดต
6. หลีกเลี่ยงการรั่วไหลของเชลล์ที่มีการจัดการ
ทุก UnityEngine.Object เช่น MonoBehaviour, Texture หรือ GameObject
มี Wrapper "Managed Shell" ของ C# ที่สื่อสารกับเครื่องมือ C++ ดั้งเดิม
- ปัญหา: หากมีการเก็บ Managed Shell ไว้ในหน่วยความจำโดยการอ้างอิงแบบคงที่ การสมัครใช้บริการเหตุการณ์แบบถาวร หรือ Closure ที่ไม่ได้ล้าง GC จะเรียกคืนหน่วยความจำไม่ได้ แม้ว่าออบเจ็กต์เนทีฟจะถูกทำลาย แต่ Wrapper ที่มีการจัดการ จะยังคงอยู่ ซึ่งทำให้เกิดการรั่วไหลของหน่วยความจำ "ผี" ที่ทำให้ Managed Heap บวม
- ความละเอียด: ใช้รูปแบบการล้างข้อมูลที่มีประสิทธิภาพเสมอ เมื่อทำลายออบเจ็กต์หรือเปลี่ยนฉาก ให้ยกเลิกการสมัครรับข้อมูลเหตุการณ์อย่างชัดเจนโดยใช้ตัวดำเนินการ
-=และทำให้การอ้างอิงแบบคงที่ไปยังประเภทUnityEngine.Objectเป็นค่าว่าง การล้างข้อมูลนี้ช่วยให้ GC รวบรวม Wrapper ได้สำเร็จเมื่อเครื่องมือดั้งเดิมปล่อยแฮนเดิลแล้ว