บทนี้พูดถึงเนื้อหาจริง ๆ ที่เราส่งเข้าไปให้ GPU (Graphics Processing Unit ชิปที่แปลงสามเหลี่ยมกับ texture ให้กลายเป็นพิกเซลบนหน้าจอ) ประมวลผล นั่นคือ mesh กับ texture บน PC หรือ console เฟรมที่ช้ามักเป็นความผิดของ CPU — gameplay logic เยอะเกินไป, physics object เยอะเกินไป แต่บนมือถือมักจะตรงข้ามกัน: art ต่างหากที่ทำให้เฟรมช้า GPU ของมือถือเล็กมากเมื่อเทียบกับการ์ดจอเดสก์ท็อป มันแชร์ memory กับ power ร่วมกับ CPU และมันจะลดความเร็วตัวเองลงโดยตั้งใจเมื่อร้อนเกินไป โมเดลตัวละครตัวเดียว ชุด texture ชุดเดียว กับเอฟเฟกต์โปร่งใสไม่กี่ตัว สามารถตัดสินได้เลยว่าเกมจะรักษา 60 เฟรมต่อวินาที (fps) ไว้ได้หรือจะตกลงไปเหลือ 20 บทนี้จะพูดถึงวิธีวัดต้นทุนตรงนี้ และเทคนิคเฉพาะเจาะจง — LOD, batching, atlas, compression, channel packing และ culling — ที่สตูดิโอทำเกมมือถืออย่าง HoYoverse ใช้เพื่อให้ art รันได้เร็ว
ทุกเฟรม ฮาร์ดแวร์สองส่วนจะผลัดกันทำงาน CPU รัน gameplay code ของเรา คิดว่าเฟรมนี้ต้องวาดอะไรบ้าง แล้วส่งรายการคำสั่ง "วาด mesh นี้ ด้วย material นี้" ที่เรียกว่า draw call ให้ GPU จากนั้น GPU ทำงานสองอย่างต่อกัน: แปลงมุมของทุกสามเหลี่ยม (vertex processing — vertex คือจุดมุมหนึ่งจุดของสามเหลี่ยม) แล้วค่อยลงสีทุกพิกเซลที่สามเหลี่ยมพวกนั้นครอบคลุม (fragment หรือ pixel shading) ถ้าฝั่งไหนใช้เวลาเกิน frame budget เฟรมนั้นก็มาช้า แล้ว fps ก็ตก
GPU บนมือถือถูกออกแบบให้เล็กโดยตั้งใจ — มันต้องใส่อยู่ในอุปกรณ์ที่ใช้แบตเตอรี่และพกใส่กระเป๋าได้ มีสามอย่างที่ทำให้มันไวต่อเนื้อหา art เป็นพิเศษ
เพราะแบบนี้ art asset ต้องมี safety margin ที่กว้างกว่าบนมือถือเมื่อเทียบกับ PC ที่เหลือของบทนี้จะพูดถึง knob ที่วัดผลได้จริง ๆ ที่ช่วยให้ mesh กับ texture อยู่ในขอบเขตนั้น: โมเดลมีกี่ triangle, มันเสีย draw call กี่ครั้ง, texture ใหญ่แค่ไหน และเอฟเฟกต์ของมันสร้าง overdraw เท่าไหร่
เราปรับปรุงสิ่งที่ยังไม่ได้วัดไม่ได้ Unity มีที่หลัก ๆ สามที่ให้ดู
Window > Analysis > Frame Debugger) ให้เราไล่ดูทีละ draw call ในเฟรมเดียว และเห็นว่าแต่ละคำสั่งวาดอะไรจริง ๆWindow > Analysis > Profiler) แสดงเวลา CPU กับ GPU ต่อเฟรม แยกตามระบบ และต่อกับอุปกรณ์จริงผ่านสายได้ — สำคัญมาก เพราะตัวเลขใน Editor เองไม่เคยตรงกับมือถือจริง ๆ เป๊ะเราอ่านตัวเลขชุดเดียวกันนี้จาก Editor script ก็ได้ สะดวกดีถ้าอยากได้หน้าจอสรุปเร็ว ๆ แทนที่จะไล่เปิดหลายหน้าต่าง
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
public class FrameStatsWindow : EditorWindow
{
[MenuItem("Tools/Frame Stats")]
static void Open()
{
GetWindow<FrameStatsWindow>("Frame Stats");
}
void OnGUI()
{
// UnityStats is the same internal data source that feeds the
// Game view's Stats overlay. It is undocumented but widely
// used for quick custom tooling like this.
GUILayout.Label("Draw calls: " + UnityStats.drawCalls);
GUILayout.Label("Batches: " + UnityStats.batches);
GUILayout.Label("Triangles: " + UnityStats.triangles);
GUILayout.Label("Vertices: " + UnityStats.vertices);
Repaint();
}
}
#endif
ผลลัพธ์ที่คาดหวัง: เปิด Tools > Frame Stats แล้วกด Play จะเห็นหน้าต่างเล็ก ๆ ที่มีตัวเลขสี่ตัว อัปเดตแบบ live พร้อมกับ Stats overlay ของ Game view เอง เดินกล้องเข้าไปใกล้ตัวละครที่มีรายละเอียดเยอะ ตัวเลข triangle จะขึ้น ส่วนถ้ามองไปที่กำแพงที่เต็มไปด้วย prop แยกกันเยอะ ๆ ตัวเลข draw call จะขึ้นแทน
ตัวเลขทั้งสี่ตัวชี้ไปที่ปัญหาคนละแบบ
Polygon budget (หรือ triangle budget) คือเป้าหมายจำนวน triangle สูงสุดสำหรับ asset หนึ่งชิ้น ที่ตกลงกันไว้ก่อนที่ artist จะเริ่มปั้นโมเดล เพื่อให้เกมทั้งเกมอยู่ใน frame budget รวมเมื่อทุกอย่างมารวมกัน ถ้าไม่มี budget asset แต่ละชิ้นมักจะโตขึ้นเรื่อย ๆ — "เพิ่มรายละเอียดอีกนิดเดียว" — จนฉากทั้งฉากเกิน budget โดยไม่มีใครชี้ได้เลยว่า asset ตัวไหนเป็นต้นเหตุ
ไม่มีตัวเลขที่ใช้ได้ทุกที่ budget ขึ้นกับอุปกรณ์เป้าหมาย มีกี่ object บนจออยู่พร้อมกัน และเหลือเฟรม budget เท่าไหร่หลังจากระบบอื่น (UI, effect, lighting) แบ่งไปใช้แล้ว จุดเริ่มต้นที่พอสมเหตุสมผลสำหรับเกมมือถือยุคใหม่ระดับเดียวกับเกมโลกเปิดของ HoYoverse จะประมาณนี้
ลองไล่คำนวณดู: สมมติฉากถนนมีตัวละครหลัก 1 ตัวอยู่บนจอที่ 28,000 triangle, NPC 6 ตัวเฉลี่ยตัวละ 8,000 triangle, และ prop พื้นหลัง 40 ชิ้นเฉลี่ยชิ้นละ 400 triangle ทั้งหมดอยู่ที่ LOD สูงสุดตอนนี้
92,000 triangle สำหรับถนนเส้นนี้อยู่ใน budget รวม 1-3 ล้านสบาย ๆ — แต่เลขนี้จะเป็นจริงได้ก็ต่อเมื่อ NPC และ prop ส่วนใหญ่บนจอไม่ได้อยู่ที่ LOD สูงสุดจริง ๆ พวกมันอยู่ไกลจากกล้องและสลับไปใช้ mesh ที่ถูกกว่าไปแล้ว นั่นคือสิ่งที่ Section 4 จะพูดถึง
Level of Detail (LOD) คือการเก็บ mesh เดียวกันไว้หลายเวอร์ชัน — เวอร์ชันรายละเอียดสูงหนึ่งอัน กับเวอร์ชันที่ลดรายละเอียดอีกหนึ่งหรือหลายอัน — แล้วสลับไปมาตามพื้นที่หน้าจอที่ object นั้นครอบครองอยู่จริง ตัวละครที่ยืนอยู่หน้ากล้องเลยต้องการรายละเอียดครบทุกอย่าง แต่ตัวละครตัวเดียวกันที่อยู่ห่างออกไป 50 เมตร ครอบคลุมแค่พิกเซลไม่กี่จุด การเสีย 28,000 triangle ไปกับสิ่งที่ผู้เล่นมองไม่เห็นรายละเอียดจึงเป็นการเสีย GPU time ไปเปล่า ๆ
component LODGroup ของ Unity จัดการการสลับนี้ให้ มันไม่ได้ใช้ระยะห่างกล้องดิบ ๆ โดยตรง — แต่ใช้ screen-relative transition height (สัดส่วนความสูงของหน้าจอที่ bounding box ของ object กำลังครอบครองอยู่ตอนนั้น) ซึ่งคำนึงถึง field of view และขนาดของ object เองไปในตัวโดยธรรมชาติ ต่างจากค่าตัดเป็นเมตรตายตัวที่ต้องมาปรับใหม่ทุกครั้งสำหรับแต่ละ asset
using UnityEngine;
public class SetupCharacterLOD : MonoBehaviour
{
public Mesh highDetailMesh; // ~18,000 triangles
public Mesh midDetailMesh; // ~6,000 triangles
public Mesh lowDetailMesh; // ~1,200 triangles
public Material material;
void Start()
{
LODGroup lodGroup = gameObject.AddComponent<LODGroup>();
LOD[] lods = new LOD[3];
lods[0] = MakeLOD(highDetailMesh, 0.5f); // switch below 50% of screen height
lods[1] = MakeLOD(midDetailMesh, 0.15f); // switch below 15%
lods[2] = MakeLOD(lowDetailMesh, 0.02f); // switch below 2%, culled after that
lodGroup.SetLODs(lods);
lodGroup.RecalculateBounds();
}
LOD MakeLOD(Mesh mesh, float screenRelativeHeight)
{
GameObject lodObject = new GameObject("LOD_" + mesh.name);
lodObject.transform.SetParent(transform, false);
MeshFilter meshFilter = lodObject.AddComponent<MeshFilter>();
MeshRenderer meshRenderer = lodObject.AddComponent<MeshRenderer>();
meshFilter.mesh = mesh;
meshRenderer.material = material;
return new LOD(screenRelativeHeight, new Renderer[] { meshRenderer });
}
}
ผลลัพธ์ที่คาดหวัง: ทันทีที่ Start() รันเสร็จ ตัวละครจะมี child object สามตัว แต่ละตัวถือ mesh ของ LOD หนึ่งระดับ พอเราเลื่อนกล้องใน Scene view ออกห่างจากตัวละคร Unity จะสลับ renderer ที่ active อยู่ให้เองอัตโนมัติ — เราดูมันเกิดขึ้นสด ๆ ได้จาก LOD visualization ของ Scene view (แถบสีที่มุมขวาบนของ Scene view สีเขียวคือ LOD0 ไล่ไปจนถึงสีแดงคือ LOD ต่ำสุด)
Draw call คือคำสั่งหนึ่งจาก CPU ที่บอก GPU ว่า "render mesh นี้ ด้วย material นี้" ก่อน draw call ส่วนใหญ่ GPU ยังต้องการ SetPass call (การเปลี่ยน state — สลับ shader, texture, หรือชุด render setting) ทั้งสองอย่างกิน CPU time ในการเตรียมและส่งต่อ และต้นทุนนี้มีอยู่ไม่ว่า mesh จะมี 10 triangle หรือ 10,000 triangle — draw call สำหรับก้อนหินเล็ก ๆ พื้นหลังกิน CPU overhead พอ ๆ กับ draw call ของตัวละครหลักเลย
บน PC desktop CPU กับ graphics driver สมัยใหม่รับ draw call หลายพันครั้งต่อเฟรมได้สบาย ๆ แต่บนมือถือ CPU core ที่อ่อนกว่า และ mobile graphics driver ที่บางและ optimize น้อยกว่า ทำให้แต่ละ draw call แพงขึ้นตามสัดส่วนมากกว่ามาก budget คร่าว ๆ ที่พอสมเหตุสมผลสำหรับมือถือระดับกลางที่จะรักษา 60 fps อยู่ประมาณ 100 ถึง 300 draw call ต่อเฟรมสำหรับทั้งฉาก — ไม่ใช่ต่อ object
นี่คือเหตุผลที่ "ฉากนี้เสีย draw call กี่ครั้ง" สำคัญพอ ๆ กับ "โมเดลนี้มี triangle กี่ตัว" — มันคือ budget คนละตัวกัน โมเดลอาจจะเบาเรื่อง triangle แต่ยังแพงอยู่ดีเพราะใช้ material ต่างกันถึงห้าตัว
ทุกเทคนิคใน section นี้ทำงานด้วยแนวคิดเดียวกัน: ทำให้ GPU วาด triangle ได้เยอะขึ้นต่อ draw call แทนที่จะเสีย draw call หนึ่งครั้งต่อหนึ่ง object
Static batching รวม mesh ของ object ที่ถูก mark เป็น "Static" ใน Editor ตอน build time ตราบใดที่พวกมันใช้ material เดียวกัน มันใช้ได้ดีกับ prop ที่ไม่เคยขยับ (อาคาร, หิน, level geometry) แต่กิน memory เพิ่ม เพราะแต่ละ instance ที่ถูกรวมจะ duplicate mesh data ของตัวเอง Dynamic batching รวม mesh เล็ก ๆ (ที่มี vertex count ต่ำกว่า limit ที่กำหนด) ที่ใช้ material เดียวกันโดยอัตโนมัติ ตอน runtime — มีประโยชน์ แต่มันเพิ่ม CPU cost ต่อเฟรมของตัวเอง และช่วยแค่ mesh เล็ก ๆ เท่านั้น จึงสำคัญน้อยลงกว่าเมื่อก่อน SRP Batcher (มีใน Universal และ High Definition Render Pipeline ของ Unity) ต่างออกไป: มันไม่ได้ลดจำนวน draw call โดยตรง แต่ลด CPU cost ในการเตรียมแต่ละ draw call ลงอย่างมาก โดยการเก็บข้อมูลต่อ material แคชไว้บน GPU ระหว่าง draw call บนโปรเจกต์มือถือที่ใช้ URP สมัยใหม่ การเช็คให้แน่ใจว่า SRP Batcher ทำงานอยู่จริง (ดูได้ใน Frame Debugger) มักจะได้ผลดีกว่าการไล่ทำ batching ด้วยมือ
Static batching, dynamic batching, และการ combine mesh ด้วยมือ ทั้งหมดมีข้อกำหนดร่วมกันหนึ่งอย่าง: object ที่จะ combine กันต้องใช้ material เดียวกัน (shader เดียวกัน, texture เดียวกัน) หิน 2 ก้อนที่มี texture คนละตัว combine กันไม่ได้ ไม่ว่า mesh จะเรียบง่ายแค่ไหน texture atlas (texture เล็ก ๆ หลายตัวถูกอัดรวมเข้าไปในภาพใหญ่ภาพเดียว โดย UV coordinate — พิกัดการแมป texture แบบ 2D — ของแต่ละ object ถูก remap ให้ชี้ไปที่ส่วนเล็ก ๆ ของภาพนั้น) เปลี่ยน object ที่หน้าตาต่างกันหลายตัวให้กลายเป็น object ที่ใช้ material เดียวกันหมด ซึ่งเป็นสิ่งที่ปลดล็อก batching ให้พวกมัน Section 7 จะพูดถึง atlas แบบเต็ม ๆ
สำหรับกลุ่ม prop แบบ static ที่ใช้ material เดียวกันอยู่แล้ว เรา merge mesh ของพวกมันตรง ๆ ตอน load ได้เลย ซึ่งการันตี draw call เดียวไม่ว่า batching setting ด้านบนจะตั้งไว้ยังไง
using UnityEngine;
public class CombinePropMeshes : MonoBehaviour
{
void Start()
{
MeshFilter[] meshFilters = GetComponentsInChildren<MeshFilter>();
CombineInstance[] combine = new CombineInstance[meshFilters.Length];
for (int i = 0; i < meshFilters.Length; i++)
{
combine[i].mesh = meshFilters[i].sharedMesh;
combine[i].transform = meshFilters[i].transform.localToWorldMatrix;
meshFilters[i].gameObject.SetActive(false); // hide the originals
}
Mesh combinedMesh = new Mesh();
combinedMesh.CombineMeshes(combine);
GetComponent<MeshFilter>().mesh = combinedMesh;
gameObject.SetActive(true);
}
}
ผลลัพธ์ที่คาดหวัง: กลุ่มก้อนหิน 40 ชิ้นที่แยกกันเป็น GameObject คนละตัว แต่ละตัวเสีย draw call ของตัวเอง จะกลายเป็น mesh รวมอันเดียวที่มี vertex count เป็น 40 เท่าของก้อนหินก้อนเดียว วาดด้วย draw call เดียวจริง ๆ ข้อแลกเปลี่ยน: หิน 40 ก้อนนั้นจะขยับ, ถูก cull, หรือถูกทำลายทีละก้อนไม่ได้อีกต่อไป เพราะตอนนี้มันกลายเป็น mesh เดียวแล้ว — เทคนิคนี้เหมาะกับกลุ่ม static ที่ไม่เปลี่ยนแปลงเลยเท่านั้น
GPU instancing จัดการกับกรณีตรงข้าม: mesh เดียวกันหลายชุด (หญ้า, หิน, ต้นไม้, ลูกธนู) อยู่คนละตำแหน่ง ที่เรา combine ล่วงหน้าไม่ได้เพราะพวกมันต้องขยับ, spawn, หรือ despawn แยกกันได้ แทนที่จะเสีย draw call หนึ่งครั้งต่อหนึ่งชุด CPU ส่ง mesh ไปครั้งเดียว บวกกับ list ของ transform matrix แล้ว GPU จะวาดทุกชุดใน draw call เดียวนั้นเลย
using UnityEngine;
public class InstancedRocks : MonoBehaviour
{
public Mesh rockMesh;
public Material instancedMaterial; // must have "Enable GPU Instancing" checked
public int rockCount = 500;
Matrix4x4[] matrices;
void Start()
{
matrices = new Matrix4x4[rockCount];
for (int i = 0; i < rockCount; i++)
{
Vector3 pos = Random.insideUnitSphere * 50f;
matrices[i] = Matrix4x4.TRS(pos, Quaternion.identity, Vector3.one);
}
}
void Update()
{
// One draw call submits all 500 rocks to the GPU at once.
Graphics.DrawMeshInstanced(rockMesh, 0, instancedMaterial, matrices, matrices.Length);
}
}
ผลลัพธ์ที่คาดหวัง: หิน 500 ก้อนปรากฏบนจอ แต่ Frame Debugger แสดงแค่ 1 draw call สำหรับทั้งหมด แทนที่จะเป็น 500 หินยังขยับตำแหน่งใหม่ได้ทุกเฟรมด้วยการเขียน array matrices ใหม่ ซึ่งเป็นสิ่งที่ batching ธรรมดาทำไม่ได้
Graphics.DrawMeshInstanced มี limit ตายตัวที่ 1023 instance ต่อการเรียกหนึ่งครั้ง (ขนาด shader constant-buffer ที่ตายตัว) ถ้าจำนวนเกินนั้น ให้แบ่ง array ของ matrix เป็นก้อนละไม่เกิน 1023 แล้วเรียกครั้งละหนึ่งก้อนrenderer.material (ไม่ใช่ sharedMaterial) ที่ไหนก็ตามในโค้ด แม้แค่จะเช็ค property เฉย ๆ การเข้าถึง .material จะสร้างสำเนา material ส่วนตัวขึ้นมาเงียบ ๆ ตั้งแต่ครั้งแรกที่ถูกแตะ ซึ่งทำให้ batching ของ object นั้นพังไปเลยตั้งแต่จุดนั้น เพราะมันไม่ได้ใช้ material ร่วมกับใครแล้ว ใช้ sharedMaterial เวลาแค่จะอ่านค่าอย่างเดียวTexture atlas คือการอัด texture เล็ก ๆ หลายตัวเข้าไปในภาพใหญ่ภาพเดียว แทนที่จะมี texture หัว, texture ตัว, texture อาวุธ, และ texture ผม เป็นไฟล์แยกกันสี่ไฟล์ (และ material แยกกันสี่ตัว) ทั้งสี่จะถูกอัดรวมเข้าไปในภาพเดียว แล้ว UV coordinate ของแต่ละ mesh ก็ถูกขยับให้ชี้ไปที่ส่วนของตัวเองในภาพที่ใช้ร่วมกันนั้น
Atlas ไม่ได้มาฟรี ๆ การ pack ทำให้เสียพื้นที่ไปบ้าง (ต้องมี padding ระหว่างแต่ละส่วน เพื่อไม่ให้ mip-mapping — ดู Section 9 — ผสมขอบของส่วนหนึ่งเข้ากับส่วนข้างเคียง ปรากฏการณ์นี้เรียกว่า "bleeding") atlas ยังมีเพดานขนาดด้วย (2048x2048 หรือ 4096x4096 เป็นค่าสูงสุดทั่วไปบนมือถือ) ดังนั้นจึงมีขีดจำกัดว่าอัดอะไรเข้าไปได้เท่าไหร่ก่อนจะต้องมี atlas ตัวที่สอง และเมื่อหลายส่วนใช้ atlas ร่วมกันแล้ว การสลับแค่ส่วนเดียวตอน runtime (เช่น สกินอาวุธที่ต่างออกไป) มักแปลว่าต้อง pack ใหม่ทั้งหมด หรือไม่ก็ยอมให้ส่วนนั้นหลุดออกจาก atlas ที่ใช้ร่วมกัน และกลับไปเสีย draw call ของตัวเองอีกครั้ง
ข้อแลกเปลี่ยนตรงนี้สำคัญมากสำหรับเกมที่มีชุดตัวละครหรือสกินที่สลับได้ แบบที่เกมมือถือหลายเกมขายกัน: ทางประนีประนอมที่ทำกันจริง ๆ คือมี atlas สองสามตัวต่อตัวละคร — ตัวหนึ่งสำหรับตัวและหน้าพื้นฐาน (ที่ไม่เปลี่ยนเลย) และอีกตัวแยกต่างหากต่อช่องที่สลับได้ (ชุด, อาวุธ) แทนที่จะมี atlas ยักษ์ตัวเดียวสำหรับทุกอย่าง แลก draw call เพิ่มขึ้นอีกไม่กี่ครั้งเพื่อความสามารถในการสลับแค่ส่วนเดียวโดยไม่ต้องแตะส่วนอื่น
texture แบบ RGBA 32-bit ที่ไม่บีบอัดเก็บ 8 bit ต่อ color channel รวมเป็น 32 bit (4 byte) ต่อพิกเซล Texture compression format เก็บภาพเดียวกันด้วยจำนวน bit ต่อพิกเซลที่น้อยกว่ามาก โดยการเข้ารหัสพิกเซลเป็นบล็อกเล็ก ๆ รวมกันแทนที่จะเก็บแต่ละพิกเซลแยกกัน — แลกกับคุณภาพภาพที่เสียไปบ้าง ซึ่งเลือกให้เสียในระดับที่สังเกตยากตอนดูในระยะปกติ
ASTC (Adaptive Scalable Texture Compression) คือมาตรฐานสมัยใหม่บนทั้ง Android และ iOS จุดเด่นของมันคือ block size ที่เลือกได้: ทุกบล็อกเข้ารหัสเป็น 128 bit เสมอ แต่เราเลือกได้ว่าแต่ละบล็อกครอบคลุมกี่พิกเซล ซึ่งเป็นตัวกำหนด bit ต่อพิกเซล (bpp) โดยตรง
ETC2 คือ format พื้นฐานเก่าของ Android (รับประกันว่ารองรับบนอุปกรณ์ OpenGL ES 3.0): ประมาณ 4 bpp สำหรับ RGB ประมาณ 8 bpp เมื่อเพิ่ม alpha เข้าไป PVRTC คือ format เก่าเฉพาะ iOS (โหมดตายตัว 4 bpp หรือ 2 bpp) ที่ถูกแทนที่ด้วย ASTC บนอุปกรณ์ยุคปัจจุบันไปเกือบหมดแล้ว ในโปรเจกต์สมัยใหม่ที่เล็งทั้งสองแพลตฟอร์ม ASTC อย่างเดียวมักครอบคลุมทุกอย่างได้ ETC2 เก็บไว้เป็น fallback สำหรับฮาร์ดแวร์ Android เก่ามาก ๆ ส่วนน้อยที่ไม่รองรับ ASTC เท่านั้น
เราตั้ง compression format ต่อแพลตฟอร์มด้วยโค้ดได้ ให้มันเซ็ตอัตโนมัติตอน import แทนที่จะไปตั้งค่าทุก texture ทีละตัวใน Inspector
using UnityEditor;
using UnityEngine;
public class SetMobileTextureFormat : AssetPostprocessor
{
void OnPreprocessTexture()
{
// In a real project, filter by folder so this only touches
// the textures you actually mean to, e.g.:
// if (!assetPath.Contains("Characters")) return;
TextureImporter importer = (TextureImporter)assetImporter;
TextureImporterPlatformSettings androidSettings =
importer.GetPlatformTextureSettings("Android");
androidSettings.overridden = true;
androidSettings.format = TextureImporterFormat.ASTC_6x6;
androidSettings.compressionQuality = 50;
importer.SetPlatformTextureSettings(androidSettings);
TextureImporterPlatformSettings iosSettings =
importer.GetPlatformTextureSettings("iPhone");
iosSettings.overridden = true;
iosSettings.format = TextureImporterFormat.ASTC_6x6;
importer.SetPlatformTextureSettings(iosSettings);
}
}
ผลลัพธ์ที่คาดหวัง: จากนี้ไป texture ทุกตัวที่ import หรือ reimport เข้าโปรเจกต์จะได้ Android override และ iOS override ตั้งเป็น ASTC 6x6 ให้อัตโนมัติ เห็นได้ใน Inspector ของ texture นั้นใต้แท็บ Android กับ iOS — ไม่ต้องไปคลิกตั้งค่า import ของทุก texture ด้วยมืออีกต่อไป
Mip-map (ย่อจากภาษาละติน "multum in parvo" แปลว่ามากในพื้นที่เล็ก) คือสำเนาของ texture ที่ย่อไว้ล่วงหน้า ความกว้างครึ่งหนึ่งและความสูงครึ่งหนึ่งของระดับก่อนหน้า ไล่ลงไปจนถึง 1x1 Unity สร้าง chain ทั้งหมดให้อัตโนมัติ เมื่อ object อยู่ไกลจากกล้องและครอบคลุมแค่ไม่กี่พิกเซล GPU จะ sample mip level เล็ก ๆ แทนที่จะใช้ texture ความละเอียดเต็ม
Mip-map ไม่ใช่แค่ feature ด้านคุณภาพ แต่เป็นเรื่อง performance ด้วย การ sample mip level เล็ก ๆ ที่แน่นเข้ากับ GPU's fast texture cache ได้ดีกว่ามากเมื่อเทียบกับการ sample พิกเซลกระจัดกระจายจาก base texture ขนาดใหญ่ เป็นแนวคิด cache-locality เดียวกับที่เราเรียนไปในบทก่อนหน้า เพียงแต่นำมาใช้กับ texture memory แทนที่จะเป็น array
แต่ mip chain เต็ม ๆ ก็กิน memory เพิ่มด้วย: ประมาณหนึ่งในสามมากกว่า base level เดี่ยว ๆ เพราะแต่ละ mip level มีพิกเซลแค่หนึ่งในสี่ของระดับก่อนหน้า และผลรวมของ series อนันต์ 1 + 1/4 + 1/16 + 1/64 + ... ลู่เข้าหา 4/3 ลองไล่คำนวณดูสำหรับ base texture 1024x1024
เอาตัวเลข bit ต่อพิกเซลจาก Section 8 มารวมกับกฎ mip-chain 4/3 จะได้ค่าประมาณ memory จริง ๆ ลองดู base-color texture 2048x2048 ที่ ASTC 6x6 (3.56 bpp)
เรื่องแนวทาง resolution บนโปรเจกต์มือถือ: base color texture ของตัวละครหลักมักเป็น 1024x1024 หรือ 2048x2048; prop ขนาดกลางมักเป็น 512x512 หรือ 1024x1024; prop พื้นหลังเล็ก ๆ หรือ texture พื้นดินที่ tile มักเป็น 512x512 หรือเล็กกว่า การใช้ขนาดใหญ่กว่าที่ object นั้นจะได้ปรากฏบนจอจริง ๆ เป็นหนึ่งในสาเหตุที่พบบ่อยที่สุดของ texture memory ที่เสียเปล่า
material แบบ physically based มักต้องการ grayscale map หลายตัวนอกเหนือจาก base color: metallic map (แต่ละพิกเซลมีความเป็นโลหะแค่ไหน), roughness map (การสะท้อนคมหรือเบลอแค่ไหน), และ ambient occlusion (AO) map (ซอกไหนถูกบังเงาโดยธรรมชาติจาก geometry ใกล้เคียงมากแค่ไหน) การเก็บแต่ละอันเป็น RGBA texture เต็มรูปแบบเสียพื้นที่ไปเปล่า ๆ เพราะค่า grayscale ต้องการแค่ channel เดียว ไม่ใช่สี่ channel Channel packing เก็บ grayscale map ที่ไม่เกี่ยวข้องกันสามตัว (หรือสี่ตัว) ไว้ใน channel แดง, เขียว, และน้ำเงินของ texture เดียว แทน
shader แกะ channel ออกมาด้วยการอ่านแต่ละ color component ตรง ๆ
// HLSL fragment shader snippet
fixed4 mask = tex2D(_MaskMap, uv);
float metallic = mask.r; // red channel = metallic
float roughness = mask.g; // green channel = roughness
float ao = mask.b; // blue channel = ambient occlusion
ผลลัพธ์ที่คาดหวัง: ตอนนี้ shader ต้องการ texture sample แค่ครั้งเดียวเพื่อได้ค่าทั้งสามตัว แทนที่จะ sample แยกกันสามครั้ง เรื่องนี้สำคัญบนมือถือเป็นพิเศษ เพราะ GPU มือถือมักจะ bandwidth-bound (ถูกจำกัดด้วยจำนวนไบต์ที่ขยับได้ต่อวินาที ไม่ใช่ด้วยปริมาณการคำนวณดิบที่ทำได้) การลด texture read จาก 3 เหลือ 1 ต่อพิกเซลเป็นกำไรที่วัดผลได้จริงโดยตรง แล้วยังหมายถึงการเก็บและ stream texture asset แค่ตัวเดียวแทนที่จะเป็นสามตัวด้วย
ไม่มีมาตรฐานเดียวในวงการที่บอกว่า map ไหนต้องอยู่ channel ไหน ("mask map" ตาม convention ของ engine หนึ่งอาจเป็น metallic/AO/detail-mask/smoothness ใน alpha channel; อีกทีมอาจใช้ลำดับที่ต่างไปเลยก็ได้) สิ่งที่สำคัญคือทีมของเราเลือก convention เดียว เขียนเอกสารไว้ครั้งเดียว แล้ว shader กับ artist ทุกคนทำตามอย่างสม่ำเสมอ
Overdraw คือการที่ GPU shade พิกเซลเดียวกันมากกว่าหนึ่งครั้งในหนึ่งเฟรม overdraw จำนวนหนึ่งเลี่ยงไม่ได้อยู่แล้ว — object ทึบที่วางซ้อนกันก็ยังเสีย overdraw ไปบ้างก่อนที่ GPU จะรู้ว่าอันไหนมองเห็นได้จริง ๆ ความเสียหายจริง ๆ มาจาก transparency: particle effect, hair card, ใบไม้, และ UI panel ที่ใช้ alpha blending
การ render แบบทึบ (opaque) ใช้ early depth testing ได้ (เรียกอีกอย่างว่า early-Z หรือ depth pre-pass): GPU เช็คว่าพิกเซลนี้ถูกบังโดยอะไรที่อยู่ใกล้กว่าหรือเปล่า ก่อน ที่จะรัน pixel shader เต็มรูปแบบบนมัน แล้วข้ามงาน shading ไปเลยถ้าใช่ transparency แบบ alpha-blended ใช้ทางลัดนี้แบบเดียวกันไม่ได้ — พิกเซลโปร่งใสต้อง blend กับสิ่งที่อยู่ข้างหลังมันอยู่แล้ว ดังนั้นมันต้องรัน shader จริง ๆ และผสมสีเข้าด้วยกัน แล้ว object โปร่งใสต้องถูกวาดจากไกลไปใกล้ (back-to-front) เพื่อให้การ blend นั้นออกมาถูกต้อง ในลำดับที่ depth buffer ข้ามผ่านไปเฉย ๆ ไม่ได้
ตัวอย่างการคำนวณต้นทุน: หน้าจอมือถือขนาด 1080x2400 มี 2,592,000 พิกเซล ที่ 60 fps และ overdraw factor พื้นฐาน 1 เท่า (shade แต่ละพิกเซลครั้งเดียว) GPU shade พิกเซลประมาณ 155.5 ล้านพิกเซลต่อวินาทีแค่สำหรับฉากทึบ ๆ effect particle ระเบิดหนึ่งชุดบวกกับ UI panel โปร่งใสที่ซ้อนกันสองสามอันดันให้ overdraw factor เฉลี่ยในบริเวณนั้นขึ้นไปถึง 3 เท่าหรือมากกว่าได้ง่าย ๆ — ทำให้งาน pixel-shading ในบริเวณนั้นเพิ่มเป็นสามเท่า โดยที่บนจอไม่มีอะไรเพิ่มขึ้นเลยเมื่อเทียบกับเวอร์ชันที่ optimize ดีของเอฟเฟกต์เดียวกัน
วิธีปฏิบัติจริงในการคุม overdraw: ทำ particle แต่ละตัวให้เล็กบนจอและมี lifetime สั้น หลีกเลี่ยงการซ้อน UI panel โปร่งใสเต็มจอหลาย ๆ อันพร้อมกัน เลือก alpha-cutout แทน alpha-blend เมื่อความต่างทางภาพน้อย และใช้โหมด Overdraw ของ Scene view เป็นประจำระหว่างพัฒนาเพื่อหาจุดที่มีปัญหา (บริเวณที่ render ออกมาเกือบขาวทึบจากชั้นที่ซ้อนกันเยอะ) ก่อนที่มันจะไปถึงมือถือของผู้เล่นจริง
เราน่าจะรู้จัก frustum culling อยู่แล้ว (การข้าม object ที่อยู่นอก view cone ของกล้อง เพื่อให้ GPU ไม่ต้องคิดถึงมันเลยด้วยซ้ำ) Occlusion culling ไปไกลกว่านั้นอีกขั้น: มันข้าม object ที่อยู่ใน view cone แต่ถูกบังจนมิดโดยสิ่งที่อยู่ใกล้กล้องกว่า เช่น รูปปั้นที่ยืนอยู่หลังกำแพงทึบ
ระบบ occlusion culling ในตัวของ Unity เป็นระบบแบบ bake: เราเปิด Window > Rendering > Occlusion Culling mark ว่า object ไหนบังการมองเห็นได้ (Occluder Static) และ object ไหนควรถูกพิจารณาเพื่อ cull (Occludee Static) แล้ว bake ข้อมูลครั้งเดียว ตอน runtime กล้องจะอ่านข้อมูลที่คำนวณไว้แล้วนั้นเพื่อตัดสินใจว่าจะข้ามอะไร ซึ่งถูกกว่าการทดสอบ geometry จริงกับกล้องทุกเฟรมมาก
using UnityEditor;
using UnityEngine;
public class MarkAsOccluder
{
[MenuItem("Tools/Mark Selection As Occluder And Occludee")]
static void Mark()
{
foreach (GameObject go in Selection.gameObjects)
{
StaticEditorFlags flags = GameObjectUtility.GetStaticEditorFlags(go);
flags |= StaticEditorFlags.OccluderStatic;
flags |= StaticEditorFlags.OccludeeStatic;
GameObjectUtility.SetStaticEditorFlags(go, flags);
}
}
}
ผลลัพธ์ที่คาดหวัง: เลือกกลุ่ม GameObject ของกำแพงกับอาคาร แล้วรัน menu item นี้ จะ flag พวกมันเป็นทั้ง occluder และ occludee ใน dropdown Static ของ Inspector เพื่อให้การ bake occlusion ครั้งถัดไปนำมันมาคิดด้วย หลังจาก bake แล้ว เดินกล้องไปตามถนนแล้วดู Frame Debugger จะเห็น draw call ของอาคารที่อยู่หลังมุมที่กล้องมองไม่เห็นจริง ๆ หายไป
Occlusion culling คุ้มค่าที่สุดในฉากที่แน่น, ในร่ม, หรือในเมือง ที่กำแพงกับอาคารบังสิ่งที่อยู่ใน view cone ทางเทคนิคเยอะมาก มันคุ้มค่าน้อยที่สุดในพื้นที่กลางแจ้งโล่ง ๆ ที่แทบไม่มีอะไรทึบพอจะบังอะไรได้เลย ทุ่งโล่งพึ่งพา LOD (Section 4) กับการตัด draw distance แบบตายตัวมากกว่า occlusion culling มาก
รวมทุกเทคนิคใน section นี้เป็น checklist เดียวสำหรับตัวละครหรือ prop หนึ่งชิ้นที่ต้องรักษา 60 fps บนมือถือ
renderer.material ในจุดที่ sharedMaterial ใช้ได้อยู่แล้ว เพื่อไม่ให้ batching พังไปเงียบ ๆ (Section 6)(a) ประมาณ 40 draw call — หนึ่งครั้งต่อหินหนึ่งก้อน เพราะแต่ละก้อนใช้ material คนละตัวและ batch หรือ combine กับก้อนอื่นไม่ได้ แม้ว่าหินแต่ละก้อนจะเรียบง่ายและ poly ต่ำ แต่ละก้อนก็ยังเสีย CPU overhead ของตัวเองในการส่งคำสั่งอยู่ดี
(b) เมื่อ texture ของหินทุกก้อนอยู่ใน atlas เดียวกันและหินทุกก้อนใช้ material เดียวกันแล้ว Mesh.CombineMeshes รวม mesh หินทั้ง 40 ก้อนเป็น mesh เดียวได้ ทำให้ต้นทุนลงมาเหลือแค่ 1 draw call สำหรับทั้งกลุ่มพอดี ข้อแลกเปลี่ยน: กลุ่มที่ถูก combine แล้วขยับ, cull, หรือทำลายทีละก้อนไม่ได้อีกต่อไป — มันทำตัวเป็น object เดียวตั้งแต่จุดนั้น ดังนั้นเทคนิคนี้เหมาะกับหินที่ตั้งใจให้เป็น level geometry แบบ static ถาวรเท่านั้น
SetupCharacterLOD (50%, 15%, 2%) เติมจำนวน triangle ที่สมเหตุสมผลให้ LOD1 กับ LOD2 แล้วเขียนบรรทัดตั้งค่า lods[] ที่จะ config มันขึ้นมาใช้กฎประมาณหนึ่งในสามของระดับก่อนหน้าในแต่ละขั้น: LOD0 = 24,000, LOD1 =~ 8,000, LOD2 =~ 2,600 (24,000 / 3 =~ 8,000; 8,000 / 3 =~ 2,600)
LOD[] lods = new LOD[3];
lods[0] = MakeLOD(highDetailMesh, 0.5f); // ~24,000 tris, switch below 50% of screen height
lods[1] = MakeLOD(midDetailMesh, 0.15f); // ~8,000 tris, switch below 15%
lods[2] = MakeLOD(lowDetailMesh, 0.02f); // ~2,600 tris, switch below 2%, culled after that
lodGroup.SetLODs(lods);
lodGroup.RecalculateBounds();
อัตราส่วนที่แน่นอน (หนึ่งในสาม) เป็นแค่กฎคร่าว ๆ ไม่ใช่กฎตายตัว artist จริง ๆ จะปั้น LOD1 กับ LOD2 ด้วยมือแล้วเช็คว่ามันยังดูถูกต้องบนจอที่ระยะที่ตั้งใจไว้หรือเปล่า แต่ตัวเลขนี้ให้เป้าหมายเริ่มต้นที่สมเหตุสมผลสำหรับส่งให้ modeler ไปทำต่อ
Base color, 2048x2048:
texels = 2048 x 2048 = 4,194,304
base bytes = 4,194,304 x 3.56 / 8 =~ 1,866,465 bytes =~ 1.78 MB
with mips = 1.78 MB x 4/3 =~ 2.37 MB
Mask map, 1024x1024:
texels = 1024 x 1024 = 1,048,576
base bytes = 1,048,576 x 3.56 / 8 =~ 466,616 bytes =~ 0.445 MB
with mips = 0.445 MB x 4/3 =~ 0.593 MB
Normal map, 1024x1024 (ขนาดและ format เดียวกับ mask map):
with mips =~ 0.593 MB
รวม: 2.37 + 0.593 + 0.593 =~ 3.56 MB อยู่ใน budget 6 MB สบาย ๆ เหลือพื้นที่อีกประมาณ 2.44 MB สำหรับอะไรก็ตามที่ตัวละครอาจต้องการเพิ่มเติม (emission mask, texture ของ material ที่สอง และอื่น ๆ)