8.5 Optimize งานศิลป์

เฟส 8 · Technical Art · เวลาเรียน: 20–35 h

ทำให้งานศิลป์เร็ว: งบ polygon, texture atlas, LOD และการลด draw call — สำคัญมากบนฮาร์ดแวร์มือถือ

บทนี้พูดถึงเนื้อหาจริง ๆ ที่เราส่งเข้าไปให้ 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 รันได้เร็ว

1. ทำไม Art ถึงมักเป็น Bottleneck บนมือถือ

ทุกเฟรม ฮาร์ดแวร์สองส่วนจะผลัดกันทำงาน CPU รัน gameplay code ของเรา คิดว่าเฟรมนี้ต้องวาดอะไรบ้าง แล้วส่งรายการคำสั่ง "วาด mesh นี้ ด้วย material นี้" ที่เรียกว่า draw call ให้ GPU จากนั้น GPU ทำงานสองอย่างต่อกัน: แปลงมุมของทุกสามเหลี่ยม (vertex processing — vertex คือจุดมุมหนึ่งจุดของสามเหลี่ยม) แล้วค่อยลงสีทุกพิกเซลที่สามเหลี่ยมพวกนั้นครอบคลุม (fragment หรือ pixel shading) ถ้าฝั่งไหนใช้เวลาเกิน frame budget เฟรมนั้นก็มาช้า แล้ว fps ก็ตก

16.6 ms frame budget ที่ 60 fps (1000 ms / 60 = 16.6 ms ต่อเฟรม) CPU work: [ game logic ][ physics ][ prepare draw calls ] GPU work: [ vertex processing ][ pixel shading + overdraw ] บน desktop และ console ฝั่ง CPU มักเป็นจุดคอขวด บนมือถือ ฝั่ง GPU (ซึ่งขึ้นกับ art) มักเป็นจุดคอขวดแทน

GPU บนมือถือถูกออกแบบให้เล็กโดยตั้งใจ — มันต้องใส่อยู่ในอุปกรณ์ที่ใช้แบตเตอรี่และพกใส่กระเป๋าได้ มีสามอย่างที่ทำให้มันไวต่อเนื้อหา art เป็นพิเศษ

เพราะแบบนี้ art asset ต้องมี safety margin ที่กว้างกว่าบนมือถือเมื่อเทียบกับ PC ที่เหลือของบทนี้จะพูดถึง knob ที่วัดผลได้จริง ๆ ที่ช่วยให้ mesh กับ texture อยู่ในขอบเขตนั้น: โมเดลมีกี่ triangle, มันเสีย draw call กี่ครั้ง, texture ใหญ่แค่ไหน และเอฟเฟกต์ของมันสร้าง overdraw เท่าไหร่

Tip "รันลื่นบนมือถือของฉัน" ยังไม่ถือว่าจบงาน ทดสอบบนอุปกรณ์ระดับกลางหรือระดับล่างจริง ๆ ไม่ใช่แค่มือถือรุ่นใหม่ที่สุดของเราเอง และดู frame rate ตอนเล่นไปสักสองสามนาทีแล้ว ไม่ใช่แค่ตอนเปิดเกม เพื่อให้ thermal throttling โผล่มาให้เห็นตอนเทสต์ด้วย

2. วิธีวัด: Draw Calls, Triangles, Texture Memory, Overdraw

เราปรับปรุงสิ่งที่ยังไม่ได้วัดไม่ได้ Unity มีที่หลัก ๆ สามที่ให้ดู

เราอ่านตัวเลขชุดเดียวกันนี้จาก 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 จะขึ้นแทน

ตัวเลขทั้งสี่ตัวชี้ไปที่ปัญหาคนละแบบ

ข้อผิดพลาดที่พบบ่อย เทสต์ performance ใน Unity Editor อย่างเดียว Editor มี overhead ของตัวเอง และซ่อนพฤติกรรมจริงของอุปกรณ์ (thermal throttling, memory bandwidth จริง, driver overhead จริง) ตัวเลขจาก Editor เป็นแค่แนวทางคร่าว ๆ ตัวเลขจาก Profiler session ที่ต่อกับมือถือระดับกลางจริง ๆ ต่างหากที่สำคัญ

3. Polygon Budget

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 สูงสุดตอนนี้

1 ตัวละครหลัก x 28,000 tris = 28,000 NPC 6 ตัว x 8,000 tris = 48,000 prop 40 ชิ้น x 400 tris = 16,000 -------- รวม = 92,000 triangles

92,000 triangle สำหรับถนนเส้นนี้อยู่ใน budget รวม 1-3 ล้านสบาย ๆ — แต่เลขนี้จะเป็นจริงได้ก็ต่อเมื่อ NPC และ prop ส่วนใหญ่บนจอไม่ได้อยู่ที่ LOD สูงสุดจริง ๆ พวกมันอยู่ไกลจากกล้องและสลับไปใช้ mesh ที่ถูกกว่าไปแล้ว นั่นคือสิ่งที่ Section 4 จะพูดถึง

Tip จำนวน vertex มักสำคัญกับ GPU มากกว่าจำนวน triangle เพราะ vertex shader รันหนึ่งครั้งต่อหนึ่ง vertex และโมเดลที่มีขอบแข็ง (แผ่นเกราะ, มุมคม) ต้องการ vertex ซ้ำเพิ่มที่รอยต่อแต่ละจุด mesh สองอันที่มี triangle count เท่ากันอาจมี vertex count ต่างกันมาก แต่ triangle count ก็ยังเป็นตัวเลขที่ artist ใช้ติดตามกันในชีวิตประจำวัน เพราะมันคิดตามง่ายกว่าตอนปั้นโมเดล

4. Level of Detail (LOD)

Level of Detail (LOD) คือการเก็บ mesh เดียวกันไว้หลายเวอร์ชัน — เวอร์ชันรายละเอียดสูงหนึ่งอัน กับเวอร์ชันที่ลดรายละเอียดอีกหนึ่งหรือหลายอัน — แล้วสลับไปมาตามพื้นที่หน้าจอที่ object นั้นครอบครองอยู่จริง ตัวละครที่ยืนอยู่หน้ากล้องเลยต้องการรายละเอียดครบทุกอย่าง แต่ตัวละครตัวเดียวกันที่อยู่ห่างออกไป 50 เมตร ครอบคลุมแค่พิกเซลไม่กี่จุด การเสีย 28,000 triangle ไปกับสิ่งที่ผู้เล่นมองไม่เห็นรายละเอียดจึงเป็นการเสีย GPU time ไปเปล่า ๆ

พื้นที่หน้าจอที่ object ครอบครอง: มากกว่า 50% 15-50% 2-15% ต่ำกว่า 2% | | | | Mesh ที่ใช้: LOD0 LOD1 LOD2 culled จำนวน triangle (ตัวอย่างตัวละคร): 18,000 6,000 1,200 0 เมื่อกล้องเคลื่อนออกห่าง Unity จะสลับ renderer ไปใช้ mesh ที่ถูกกว่าโดยอัตโนมัติ -- ผู้เล่นแทบไม่มีทางอยู่ใกล้พอที่จะสังเกตเห็น

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 ต่ำสุด)

ข้อผิดพลาดที่พบบ่อย สร้าง LOD แค่สองระดับที่มีช่องว่างจำนวน triangle ห่างกันมาก (เช่น จาก 18,000 ดิ่งลงไปที่ 1,200 เลย) การสลับจะเห็นเป็น "pop" ที่ผู้เล่นสังเกตได้ทันที การมี LOD1 ตรงกลาง และเปิดใช้ตัวเลือก LOD cross-fade ของ Unity (ซึ่งจะ blend ระหว่าง mesh สองอันสั้น ๆ แทนที่จะสลับทันที) ทั้งสองอย่างช่วยให้การเปลี่ยนผ่านสังเกตเห็นได้ยากขึ้นมาก

5. Draw Calls และทำไมมันถึงแพง

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

prop พื้นหลังเดียวกัน 40 ชิ้น สองรูปแบบการตั้งค่า: Setup A: 40 material แยกกัน --> สูงสุด 40 draw call Setup B: 1 material ที่ใช้ร่วมกัน --> ต่ำสุดแค่ 1 draw call (ถ้า combine ด้วย) ผลลัพธ์บนจอเหมือนกันเป๊ะ แต่ต้นทุนที่ CPU ต่างกันมาก

นี่คือเหตุผลที่ "ฉากนี้เสีย draw call กี่ครั้ง" สำคัญพอ ๆ กับ "โมเดลนี้มี triangle กี่ตัว" — มันคือ budget คนละตัวกัน โมเดลอาจจะเบาเรื่อง triangle แต่ยังแพงอยู่ดีเพราะใช้ material ต่างกันถึงห้าตัว

6. ลด Draw Call: Batching, Atlas, Combining Meshes, และ GPU Instancing

ทุกเทคนิคใน section นี้ทำงานด้วยแนวคิดเดียวกัน: ทำให้ GPU วาด triangle ได้เยอะขึ้นต่อ draw call แทนที่จะเสีย draw call หนึ่งครั้งต่อหนึ่ง object

Static และ Dynamic Batching, และ SRP Batcher

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 ด้วยมือ

Texture Atlas ทำให้ 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 แบบเต็ม ๆ

Combine Mesh ด้วย Script

สำหรับกลุ่ม 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 สำหรับ Prop ที่ซ้ำกัน

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 ธรรมดาทำไม่ได้

Tip Graphics.DrawMeshInstanced มี limit ตายตัวที่ 1023 instance ต่อการเรียกหนึ่งครั้ง (ขนาด shader constant-buffer ที่ตายตัว) ถ้าจำนวนเกินนั้น ให้แบ่ง array ของ matrix เป็นก้อนละไม่เกิน 1023 แล้วเรียกครั้งละหนึ่งก้อน
ข้อผิดพลาดที่พบบ่อย อ่าน renderer.material (ไม่ใช่ sharedMaterial) ที่ไหนก็ตามในโค้ด แม้แค่จะเช็ค property เฉย ๆ การเข้าถึง .material จะสร้างสำเนา material ส่วนตัวขึ้นมาเงียบ ๆ ตั้งแต่ครั้งแรกที่ถูกแตะ ซึ่งทำให้ batching ของ object นั้นพังไปเลยตั้งแต่จุดนั้น เพราะมันไม่ได้ใช้ material ร่วมกับใครแล้ว ใช้ sharedMaterial เวลาแค่จะอ่านค่าอย่างเดียว

7. Texture Atlas

Texture atlas คือการอัด texture เล็ก ๆ หลายตัวเข้าไปในภาพใหญ่ภาพเดียว แทนที่จะมี texture หัว, texture ตัว, texture อาวุธ, และ texture ผม เป็นไฟล์แยกกันสี่ไฟล์ (และ material แยกกันสี่ตัว) ทั้งสี่จะถูกอัดรวมเข้าไปในภาพเดียว แล้ว UV coordinate ของแต่ละ mesh ก็ถูกขยับให้ชี้ไปที่ส่วนของตัวเองในภาพที่ใช้ร่วมกันนั้น

ก่อน: texture 4 ตัวแยกกัน, material 4 ตัว -> สูงสุด 4 draw call [ Head.png ] [ Body.png ] [ Weapon.png ] [ Hair.png ] หลัง: atlas texture 1 ตัว, material 1 ตัว -> 1 draw call +-------------------+-------------------+ | Head | Body | | UV (0, 0.5)-(0.5, 1) UV (0.5, 0.5)-(1, 1) | +-------------------+-------------------+ | Weapon | Hair | | UV (0, 0)-(0.5, 0.5) UV (0.5, 0)-(1, 0.5) | +-------------------+-------------------+

Atlas ไม่ได้มาฟรี ๆ การ pack ทำให้เสียพื้นที่ไปบ้าง (ต้องมี padding ระหว่างแต่ละส่วน เพื่อไม่ให้ mip-mapping — ดู Section 9 — ผสมขอบของส่วนหนึ่งเข้ากับส่วนข้างเคียง ปรากฏการณ์นี้เรียกว่า "bleeding") atlas ยังมีเพดานขนาดด้วย (2048x2048 หรือ 4096x4096 เป็นค่าสูงสุดทั่วไปบนมือถือ) ดังนั้นจึงมีขีดจำกัดว่าอัดอะไรเข้าไปได้เท่าไหร่ก่อนจะต้องมี atlas ตัวที่สอง และเมื่อหลายส่วนใช้ atlas ร่วมกันแล้ว การสลับแค่ส่วนเดียวตอน runtime (เช่น สกินอาวุธที่ต่างออกไป) มักแปลว่าต้อง pack ใหม่ทั้งหมด หรือไม่ก็ยอมให้ส่วนนั้นหลุดออกจาก atlas ที่ใช้ร่วมกัน และกลับไปเสีย draw call ของตัวเองอีกครั้ง

ข้อแลกเปลี่ยนตรงนี้สำคัญมากสำหรับเกมที่มีชุดตัวละครหรือสกินที่สลับได้ แบบที่เกมมือถือหลายเกมขายกัน: ทางประนีประนอมที่ทำกันจริง ๆ คือมี atlas สองสามตัวต่อตัวละคร — ตัวหนึ่งสำหรับตัวและหน้าพื้นฐาน (ที่ไม่เปลี่ยนเลย) และอีกตัวแยกต่างหากต่อช่องที่สลับได้ (ชุด, อาวุธ) แทนที่จะมี atlas ยักษ์ตัวเดียวสำหรับทุกอย่าง แลก draw call เพิ่มขึ้นอีกไม่กี่ครั้งเพื่อความสามารถในการสลับแค่ส่วนเดียวโดยไม่ต้องแตะส่วนอื่น

8. Texture Compression: ASTC และ ETC2

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 ด้วยมืออีกต่อไป

ข้อผิดพลาดที่พบบ่อย ใช้ block size บีบอัดแบบดุ ๆ เท่ากันหมดกับ texture ทุกประเภท normal map (ที่เข้ารหัสทิศทางพื้นผิว ไม่ใช่สี) จะเห็น banding artifact ชัดเจนที่ bit rate ต่ำ เพราะมันไวต่อการสูญเสียความละเอียดมากกว่า color texture วิธีที่ทำกันทั่วไปคือใช้ ASTC 4x4 หรือ 6x6 กับ normal map และ base color แล้วใช้ block size หยาบกว่าอย่าง 8x8 หรือ 10x10 กับข้อมูลที่ไม่ไวมากอย่าง ambient occlusion หรือ roughness mask

9. Resolution, Mip-map, และ Texture Memory

Mip-map (ย่อจากภาษาละติน "multum in parvo" แปลว่ามากในพื้นที่เล็ก) คือสำเนาของ texture ที่ย่อไว้ล่วงหน้า ความกว้างครึ่งหนึ่งและความสูงครึ่งหนึ่งของระดับก่อนหน้า ไล่ลงไปจนถึง 1x1 Unity สร้าง chain ทั้งหมดให้อัตโนมัติ เมื่อ object อยู่ไกลจากกล้องและครอบคลุมแค่ไม่กี่พิกเซล GPU จะ sample mip level เล็ก ๆ แทนที่จะใช้ texture ความละเอียดเต็ม

Base Mip 1 Mip 2 Mip 3 ...ไปจนถึง 1x1 1024x1024 -> 512x512 -> 256x256 -> 128x128 -> ... -> 1x1 object ที่อยู่ไกล sample mip เล็ก ๆ แทนที่จะใช้ base texture 1024x1024 -- อ่านไบต์น้อยลงต่อพิกเซล และไม่มีการ shimmer จาก 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

Base 1024x1024 = 1,048,576 texel Mip 1 512x512 = 262,144 texel (1/4 ของ base) Mip 2 256x256 = 65,536 texel Mip 3 128x128 = 16,384 texel Mip 4 64x64 = 4,096 texel ...ไล่ลงไปจนถึง 1x1... ---------------- รวมทุกระดับ ~ 1,398,101 texel =~ 1.334 เท่าของ base ตรงกับกฎ 4/3: (จำนวน texel ของ base) x 4 / 3

เอาตัวเลข bit ต่อพิกเซลจาก Section 8 มารวมกับกฎ mip-chain 4/3 จะได้ค่าประมาณ memory จริง ๆ ลองดู base-color texture 2048x2048 ที่ ASTC 6x6 (3.56 bpp)

texel = 2048 x 2048 = 4,194,304 base bit = 4,194,304 x 3.56 = 14,931,722 bit base byte = 14,931,722 / 8 =~ 1,866,465 byte =~ 1.78 MB รวม mip chain: 1.78 MB x 4/3 =~ 2.37 MB

เรื่องแนวทาง resolution บนโปรเจกต์มือถือ: base color texture ของตัวละครหลักมักเป็น 1024x1024 หรือ 2048x2048; prop ขนาดกลางมักเป็น 512x512 หรือ 1024x1024; prop พื้นหลังเล็ก ๆ หรือ texture พื้นดินที่ tile มักเป็น 512x512 หรือเล็กกว่า การใช้ขนาดใหญ่กว่าที่ object นั้นจะได้ปรากฏบนจอจริง ๆ เป็นหนึ่งในสาเหตุที่พบบ่อยที่สุดของ texture memory ที่เสียเปล่า

Tip ระบบ texture streaming ของ Unity (Mip Streaming เปิดได้ต่อ texture ใน Inspector) เก็บแค่ mip level ที่จำเป็นจริง ๆ ไว้ใน memory ตามระยะที่กล้องอยู่จริง แล้วโหลด mip ที่ละเอียดขึ้นเข้ามาเมื่อกล้องเข้าใกล้ เรื่องนี้สำคัญที่สุดกับเกมโลกเปิดที่มี texture data รวมทั้งหมดเยอะกว่าที่จะใส่ใน memory ที่ความละเอียดเต็มพร้อมกันได้ทั้งหมด

10. Channel Packing

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 เดียว แทน

grayscale map สามตัว: packed RGB texture ตัวเดียว: [ Metallic map, grayscale ] +---------------------------+ [ Roughness map, grayscale ] -> | channel แดง = Metallic | [ AO map, grayscale ] | channel เขียว = Roughness| | channel น้ำเงิน = AO | +---------------------------+ shader อ่าน 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 ทุกคนทำตามอย่างสม่ำเสมอ

11. Overdraw และ Transparency

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 ข้ามผ่านไปเฉย ๆ ไม่ได้

คอลัมน์พิกเซลหนึ่งคอลัมน์ มี particle โปร่งใส 5 ตัวซ้อนทับกัน ทั้งหมดครอบคลุมพิกเซลบนจอตัวเดียวกัน: Particle 5 ############ <- วาดตัวที่ 5 (ยังต้อง shade ทับ 4 ชั้นที่วาดไปแล้ว) Particle 4 ############ Particle 3 ############ Particle 2 ############ Particle 1 ############ พื้นหลัง ############ พิกเซลนั้นถูก shade รวม 6 ครั้งก่อนที่ผู้เล่นจะเห็นสีสุดท้ายที่ blend แล้ว -- overdraw 6 เท่าบนพิกเซลเดียว

ตัวอย่างการคำนวณต้นทุน: หน้าจอมือถือขนาด 1080x2400 มี 2,592,000 พิกเซล ที่ 60 fps และ overdraw factor พื้นฐาน 1 เท่า (shade แต่ละพิกเซลครั้งเดียว) GPU shade พิกเซลประมาณ 155.5 ล้านพิกเซลต่อวินาทีแค่สำหรับฉากทึบ ๆ effect particle ระเบิดหนึ่งชุดบวกกับ UI panel โปร่งใสที่ซ้อนกันสองสามอันดันให้ overdraw factor เฉลี่ยในบริเวณนั้นขึ้นไปถึง 3 เท่าหรือมากกว่าได้ง่าย ๆ — ทำให้งาน pixel-shading ในบริเวณนั้นเพิ่มเป็นสามเท่า โดยที่บนจอไม่มีอะไรเพิ่มขึ้นเลยเมื่อเทียบกับเวอร์ชันที่ optimize ดีของเอฟเฟกต์เดียวกัน

ข้อผิดพลาดที่พบบ่อย ใช้ transparency แบบ alpha-blended กับเอฟเฟกต์ที่จริง ๆ เป็น alpha-cutout ได้ (ทึบเต็มหรือมองไม่เห็นเต็มต่อพิกเซล ตัดสินด้วยการเช็ค threshold โดยไม่ต้อง blend) หรือเป็น opaque เต็มรูปแบบไปเลยก็ได้ — ใบไม้, รั้วลวดตาข่าย, และ particle sprite ธรรมดาเป็นกรณีทั่วไปที่ cutout ให้ผลลัพธ์แทบเหมือนกันแต่หลีกเลี่ยง overdraw และต้นทุนการ sort ของ blending จริง ๆ ไปได้เลย

วิธีปฏิบัติจริงในการคุม overdraw: ทำ particle แต่ละตัวให้เล็กบนจอและมี lifetime สั้น หลีกเลี่ยงการซ้อน UI panel โปร่งใสเต็มจอหลาย ๆ อันพร้อมกัน เลือก alpha-cutout แทน alpha-blend เมื่อความต่างทางภาพน้อย และใช้โหมด Overdraw ของ Scene view เป็นประจำระหว่างพัฒนาเพื่อหาจุดที่มีปัญหา (บริเวณที่ render ออกมาเกือบขาวทึบจากชั้นที่ซ้อนกันเยอะ) ก่อนที่มันจะไปถึงมือถือของผู้เล่นจริง

12. Occlusion Culling

เราน่าจะรู้จัก frustum culling อยู่แล้ว (การข้าม object ที่อยู่นอก view cone ของกล้อง เพื่อให้ GPU ไม่ต้องคิดถึงมันเลยด้วยซ้ำ) Occlusion culling ไปไกลกว่านั้นอีกขั้น: มันข้าม object ที่อยู่ใน view cone แต่ถูกบังจนมิดโดยสิ่งที่อยู่ใกล้กล้องกว่า เช่น รูปปั้นที่ยืนอยู่หลังกำแพงทึบ

กล้อง กำแพง (occluder) รูปปั้น (occludee) C --view cone--> [##########] ( X ) frustum culling อย่างเดียว: รูปปั้นอยู่ใน view cone ดังนั้นตามปกติ มันจะถูกส่งไปให้ GPU วาด occlusion culling: Unity คำนวณไว้ล่วงหน้าแล้วว่ากำแพงบัง มุมมองนี้จนมิด รูปปั้นเลยถูกข้ามไปทั้งหมด -- ไม่มี draw call, ไม่มี vertex processing, ไม่มี pixel shading ให้มันเลย

ระบบ 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 มาก

13. Checklist ปฏิบัติจริงสำหรับตัวละคร/Prop ที่ต้องรัน 60fps บนมือถือ

รวมทุกเทคนิคใน section นี้เป็น checklist เดียวสำหรับตัวละครหรือ prop หนึ่งชิ้นที่ต้องรักษา 60 fps บนมือถือ

Geometry

Texture

Material และ Draw Call

Transparency และ Culling

Testing

pipeline เต็ม ๆ ตั้งแต่ต้นจนจบ: โมเดล (Section 3) -> ระดับ LOD (Section 4) -> texture atlas + channel-packed mask (Section 7, 10) -> texture ที่ compress แล้วและมี mip-map (Section 8, 9) -> draw call ที่ batch / instance แล้ว (Section 6) -> transparency ที่ถูกคุมไว้ (Section 11) -> occlusion-culled เมื่ออยู่นอกจอหลังสิ่งกีดขวาง (Section 12) -> วัดผลบนมือถือจริง (Section 2)

14. Glossary

15. แบบฝึกหัด

แบบฝึกหัดที่ 1 — นับ Draw Call ฉากหนึ่งมี prop ก้อนหิน 40 ชิ้นกระจายอยู่รอบหน้าผา ตอนนี้แต่ละก้อนใช้ texture 512x512 ของตัวเองและ material ของตัวเอง (ไม่มี atlas, ไม่มี combine, ไม่มี instancing) ตอบ: (a) หิน 40 ก้อนนี้เสีย draw call ประมาณเท่าไหร่ในตอนนี้ และเพราะอะไร? (b) ถ้าหิน 40 ก้อนถูก pack ใหม่ให้ใช้ texture atlas เดียวกันและ material เดียวกัน แล้วถูก combine เข้าเป็น mesh เดียวตอน load โดยใช้เทคนิคจาก Section 6 พวกมันจะเสีย draw call เท่าไหร่ และข้อแลกเปลี่ยนคืออะไร?
Show answer

(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 ถาวรเท่านั้น

แบบฝึกหัดที่ 2 — ออกแบบ LOD Chain ศัตรูตัวใหม่มี mesh รายละเอียดสูงสุด (LOD0) ที่ 24,000 triangle ใช้กฎคร่าว ๆ จาก Section 4 ที่ว่าแต่ละระดับ LOD ลด triangle count ลงประมาณหนึ่งในสามของระดับก่อนหน้า และใช้ threshold ความสูงหน้าจอชุดเดียวกับตัวอย่าง SetupCharacterLOD (50%, 15%, 2%) เติมจำนวน triangle ที่สมเหตุสมผลให้ LOD1 กับ LOD2 แล้วเขียนบรรทัดตั้งค่า lods[] ที่จะ config มันขึ้นมา
Show answer

ใช้กฎประมาณหนึ่งในสามของระดับก่อนหน้าในแต่ละขั้น: 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 ไปทำต่อ

แบบฝึกหัดที่ 3 — Texture Memory Budget เกมมือถือเกมหนึ่งให้ budget texture ต่อตัวละคร 6 MB แบบบีบอัดแล้ว รวม mip-map ตัวละครหนึ่งใช้: base color texture 2048x2048, channel-packed mask texture 1024x1024 (metallic/roughness/AO), และ normal map 1024x1024 — ทั้งหมดบีบอัดที่ ASTC 6x6 (3.56 bpp) ใช้สูตรจาก Section 9 (texel x bpp / 8 สำหรับ base byte แล้ว x 4/3 สำหรับ mip chain เต็ม) ตัวละครนี้อยู่ใน budget 6 MB หรือไม่? แสดงการคำนวณของแต่ละ texture
Show answer

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 ที่สอง และอื่น ๆ)

← กลับไปหน้ารวมบท