12.1 ระบบ UI (UGUI / UI Toolkit)

เฟส 12 · UI / UX Programming · เวลาเรียน: 30–50 h

สร้าง interface ใน Unity ด้วย UGUI และ UI Toolkit ตัวใหม่ — layout, data binding และการ scale ที่ตอบสนองข้ามขนาดจอ

ทุกเกมต้องมีวิธีแสดงตัวเลข, ปุ่ม, health bar, ตาราง inventory และเมนูให้ผู้เล่นเห็น วาดทับหรือแทนที่โลกเกม 3D หรือ 2D ชั้นนี้เรียกว่า UI (user interface) Unity ให้ระบบสองแบบสำหรับสร้างมัน: UGUI ระบบเก่าที่อิง GameObject ซึ่งคุณน่าจะเคยสัมผัสมาแล้วถ้าเคยลาก Button ลงไปในซีน และ UI Toolkit ระบบใหม่กว่าที่หน้าตาและวิธีทำงานคล้ายการสร้างเว็บเพจจาก markup กับ stylesheet บทนี้จะพูดถึงทั้งสองระบบ บวกกับไอเดียที่ใช้ได้ไม่ว่าคุณจะเลือกระบบไหน: UI ถูกสร้างเป็น tree ของ element ยังไง, tree นั้น scale ข้ามจากจอมือถือไปจอ 4K ได้สะอาดยังไง, จะอ่านค่า click กับ drag โดยไม่ต้อง poll ทุกเฟรมยังไง และจะกันไม่ให้โค้ด UI กลายเป็นกลุ่ม reference ตรง ๆ เข้าไปใน game state ได้ยังไง

เหมือนเดิม ทุกไอเดียข้างล่างนี้มาพร้อมโค้ด C# ที่รันได้จริง และในจุดที่ไม่มีอะไรให้ print ออก console ก็จะมี trace ให้ไล่ตามด้วยมือ (ด้วยดินสอ) ได้

1. UI Tree: Element, Panel และ RectTransform

UI ใน Unity ไม่ใช่ภาพวาดก้อนใหญ่ก้อนเดียว มันคือ tree (โครงสร้างแบบลำดับชั้นที่แต่ละชิ้นมี parent แค่หนึ่งเดียว แต่มี child ได้กี่ตัวก็ได้) ของชิ้นส่วนเล็ก ๆ ที่เรียกว่า UI element: panel (สี่เหลี่ยมพื้นหลังที่รวม element อื่นไว้ข้างใน), image (ไอคอน, sprite), text, button, slider และอื่น ๆ ทุก element คือ GameObject ตัวหนึ่งใน Hierarchy window เหมือนกับตัวละครและ prop ที่คุณสร้างมาแล้ว — ข้อแตกต่างจริง ๆ มีแค่ว่า UI element จะพก component RectTransform แทนที่ Transform ธรรมดาที่ GameObject ตัวอื่น ๆ ใช้กัน

Canvas (root of all UI -- has a RectTransform sized to the screen) | +-- Panel (a background Image, groups the elements below it) | | | +-- Image (an icon) | +-- Text (TMP) "Score: 100" | +-- Button | | | +-- Text (TMP) "Play" | +-- HealthBar (Slider) every box in this tree is a GameObject with a RectTransform component -- UI elements live in flat, 2D screen space, not the 3D world

RectTransform เก็บสี่เหลี่ยมแทนที่จะเป็นจุดจุดเดียว: มันอยู่ตรงไหน, ใหญ่แค่ไหน และ (ตามที่หัวข้อ 3 จะเจาะลึก) มันควรทำตัวยังไงตอน parent มันถูก resize คุณอ่านค่าพวกนี้จากสคริปต์ได้เหมือน component อื่น ๆ ทั่วไป:

using UnityEngine;

public class InspectRect : MonoBehaviour
{
    void Start()
    {
        RectTransform rt = GetComponent<RectTransform>();
        Debug.Log("width=" + rt.rect.width + " height=" + rt.rect.height);
        Debug.Log("anchoredPosition=" + rt.anchoredPosition);
    }
}

แนบสคริปต์นี้กับ Button ที่ designer วางไว้ห่างจาก anchor 160 pixel ทางขวาและ 40 pixel ลงล่าง ขนาด 200 คูณ 50 pixel แล้ว Console จะ print ตัวเลขพวกนั้นกลับมาเป๊ะ ๆ:

width=200 height=50
anchoredPosition=(160.0, -40.0)

ไม่มีอะไรวิเศษเกิดขึ้นเลย — สคริปต์แค่อ่านตัวเลขชุดเดียวกับที่คุณเห็นใน component Rect Transform ของ Inspector ประเด็นคือ UI element ก็คือข้อมูล Unity ธรรมดา ๆ ชิ้นหนึ่ง: GameObject หนึ่งตัว, component ที่คุณอ่าน-เขียนจาก C# ได้, และตำแหน่งหนึ่งใน tree ส่วน text element เกือบทั้งหมดจะใช้ component ชื่อ TextMeshProUGUI (จาก namespace TMPro คนมักเรียกสั้น ๆ ว่า "TMP") แทนที่ component Text รุ่นเก่าที่คุณภาพต่ำกว่า — ตัวอย่างในบทนี้ทั้งหมดสมมติว่าใช้ TMP เพราะนั่นคือค่า default ของโปรเจกต์ Unity สมัยใหม่

Tip เพราะ UI element ก็คือ GameObject ธรรมดา ๆ ทุกเทคนิคที่คุณรู้อยู่แล้วยังใช้ได้หมด: Instantiate เพื่อ spawn ตัวใหม่ตอน runtime, SetActive(false) เพื่อซ่อนมัน, transform.Find หรือ serialized field เพื่อขอ reference ไปหามัน คำศัพท์ใหม่มีแค่ RectTransform กับ component ต่าง ๆ (Image, Button, Slider, …) ที่เปลี่ยนสี่เหลี่ยมธรรมดาให้กลายเป็นสิ่งที่มองเห็นและกดได้

2. Canvas และ Render Mode

UI element ทุกตัวต้องอยู่ใต้ GameObject พิเศษตัวหนึ่งที่เรียกว่า Canvas — มันคือรากของ UI tree และเป็นตัวที่บอก Unity จริง ๆ ว่า "วาดทุกอย่างใต้ฉันเป็น UI 2D นะ ไม่ใช่ส่วนหนึ่งของซีน 3D" GameObject ที่เป็น Canvas มักจะพก component สามตัวไปด้วยกัน: Canvas เอง, CanvasScaler (หัวข้อ 5) และ GraphicRaycaster (หัวข้อ 7 สำหรับเรื่อง click) field renderMode ของ component Canvas เป็นตัวตัดสินว่า "วาดยังไง" ซึ่งมีตัวเลือกอยู่สามแบบ:

SCREEN SPACE - OVERLAY SCREEN SPACE - CAMERA WORLD SPACE (the default) (tied to one camera) (an object IN the scene) +------------------+ +------------------+ . 3D scene . | always on top, | | drawn as a flat | . [Canvas] | ignores cameras, | | plane in front of | . / . | resizes to match | | that camera, can be | . / health . | the screen exactly | | hidden behind closer | . / bar . +------------------+ | 3D objects, camera | . o (enemy) . | post-effects apply to it| . . +------------------+ . . . . . . . . used for: HUD, menus used for: HUD that should used for: floating health that must always show be affected by camera FX bars, in-world signs, VR UI

Screen Space - Overlay คือค่า default และเป็นตัวที่คุณจะใช้กับ HUD และเมนูส่วนใหญ่ของเกม: Canvas ถูกวาดทับบนสุดของทุกอย่าง เต็มจอเสมอ และไม่สนใจเลยว่ากล้องตัวไหนหันไปทางไหน Screen Space - Camera ผูก Canvas เข้ากับกล้องตัวใดตัวหนึ่งโดยเฉพาะ แล้ววางมันเป็นระนาบแบนอยู่ห่างจากเลนส์กล้องตัวนั้นตามระยะที่กำหนด — ปกติมันก็ยังเต็มจออยู่ดี แต่เพราะตอนนี้มันเป็นส่วนหนึ่งของการ render ของกล้องตัวนั้นแล้ว วัตถุ 3D ที่เข้ามาใกล้กล้องมากกว่าระยะของ Canvas จะบัง UI ได้ในทางภาพ และ effect เต็มจอของกล้อง (เช่นการปรับสีแบบ screen-space) ก็มีผลกับ UI ด้วยเช่นกัน World Space เป็นตัวที่แปลกแยกออกไป: Canvas ทำตัวเหมือนวัตถุอื่น ๆ ในซีน 3D มี position, rotation, scale ของตัวเอง แล้วถูกวาดโดยกล้องตัวไหนก็ตามที่มองเห็นมัน จากมุมไหนก็ได้ นี่คือวิธีที่ health bar ลอยอยู่เหนือหัวศัตรูแล้วหันหน้าเข้าหากล้องเสมอ หรือวิธีที่ UI panel กลายเป็นวัตถุจริง ๆ ในซีน VR

using UnityEngine;

public class SwitchRenderMode : MonoBehaviour
{
    public Canvas canvas;
    public Camera uiCamera;

    public void UseOverlay()
    {
        canvas.renderMode = RenderMode.ScreenSpaceOverlay;
    }

    public void UseCameraSpace()
    {
        canvas.renderMode = RenderMode.ScreenSpaceCamera;
        canvas.worldCamera = uiCamera;
        canvas.planeDistance = 10f;   // 10 units in front of the camera
    }

    public void UseWorldSpace()
    {
        canvas.renderMode = RenderMode.WorldSpace;
        canvas.transform.position = new Vector3(0f, 2f, 5f);
        canvas.transform.localScale = Vector3.one * 0.01f; // shrink to normal size
    }
}
Common mistake ลืมบรรทัดสุดท้ายของ UseWorldSpace ไป RectTransform ของ World Space Canvas วัดเป็นหน่วยโลก (world unit) ไม่ใช่ screen pixel ดังนั้น health bar panel ขนาด 400 คูณ 60 (ซึ่งดูสมเหตุสมผลบน Canvas แบบ screen-space) จะกลายเป็นกำแพงกว้าง 400 หน่วยในซีน 3D การย่อ Transform ของ Canvas ลง (มักจะประมาณ 0.01 คือ 1 world unit ต่อ 100 UI unit) นี่แหละที่ทำให้มันกลับมาดูเป็น UI element ขนาดปกติอีกครั้ง

3. Anchor และ Pivot: element ยึดขอบและ scale ยังไง

นี่คือไอเดียที่สำคัญที่สุดในเรื่อง layout ของ UGUI และเป็นจุดที่มือใหม่สะดุดบ่อยที่สุด RectTransform ไม่ได้เก็บ "x, y, width, height" แบบง่าย ๆ เหมือนสี่เหลี่ยม 2D ทั่วไป แต่มันเก็บสี่อย่างแยกกัน: anchorMin กับ anchorMax (จุดสองจุด แต่ละจุดมี x กับ y ระหว่าง 0 ถึง 1 บอกว่า element ตัวนี้ถูกยึดกับตรงไหนในสี่เหลี่ยมของ parent), pivot (จุดหนึ่ง ก็ 0 ถึง 1 เหมือนกัน บอกว่าส่วนไหนในสี่เหลี่ยมของ element เองที่ใช้เป็นจุดกำเนิด), anchoredPosition (ระยะห่างของ pivot จากจุด anchor เป็น pixel) และ sizeDelta (ขนาดของ element หรือ — อย่างที่คุณจะเห็น — บางทีก็เป็นระยะเว้นแทน)

เมื่อ anchorMin กับ anchorMax เป็นจุดเดียวกัน element จะทำตัวเหมือนกล่องขนาดตายตัวที่ยึดติดกับจุดนั้นจุดเดียวของ parent — ขนาดของมันมาจาก sizeDelta ตรง ๆ และมันจะไม่ยืด ไม่ว่า parent จะถูก resize ยังไงก็ตาม

using UnityEngine;

public class PinTopLeft : MonoBehaviour
{
    void Start()
    {
        RectTransform rt = GetComponent<RectTransform>();

        // pin this element to the parent's top-left corner
        rt.anchorMin = new Vector2(0f, 1f);
        rt.anchorMax = new Vector2(0f, 1f);
        rt.pivot     = new Vector2(0f, 1f);

        // anchoredPosition is now measured from that corner, in pixels
        rt.anchoredPosition = new Vector2(20f, -20f); // 20px right, 20px down
    }
}
parent rect, e.g. an 800x600 Canvas, anchorMin=anchorMax=(0,1) "top-left" +----------------------------------+ |X | X = the anchor point, tied to the | [HealthBar] | parent's top-left corner | | | | pivot=(0,1) -- the element's OWN | | top-left corner is its origin, so | | anchoredPosition=(20,-20) reads as +----------------------------------+ "20px right, 20px down from X" resize the parent to 1600x1200 -- HealthBar keeps the SAME 20px distance from the top-left corner. it does not move toward the middle and does not stretch, because its anchor is a single fixed point

เมื่อ anchorMin กับ anchorMaxต่างกันในแกนไหน element จะยืดในแกนนั้น: ขอบของมันในแกนนั้นจะแตะขอบของ parent ที่ตรงกันเสมอ ไม่ว่า parent จะถูก resize ยังไง และ sizeDelta ในแกนนั้นจะไม่มีความหมายว่า "width" อีกต่อไป — มันหมายถึง "เล็กกว่าการยืดเต็ม ๆ กี่ pixel" คือเป็นระยะเว้นขอบ (margin)

using UnityEngine;

public class StretchTopBar : MonoBehaviour
{
    void Start()
    {
        RectTransform rt = GetComponent<RectTransform>();

        rt.anchorMin = new Vector2(0f, 1f);  // left edge,  top edge
        rt.anchorMax = new Vector2(1f, 1f);  // right edge, top edge -- stretches across
        rt.pivot     = new Vector2(0.5f, 1f);

        rt.sizeDelta = new Vector2(0f, 60f); // x=0 means "no inset", height is fixed at 60px
        rt.anchoredPosition = Vector2.zero;
    }
}
anchorMin=(0,1), anchorMax=(1,1) -- both ends of the top edge are anchors parent = 800 wide parent resized to 1600 wide +----------------------------------+ +--------------------------------------------------+ |==================================| |====================================================| | (bar spans 0 to 800) | | (bar spans 0 to 1600, automatically) | +----------------------------------+ +--------------------------------------------------+ the bar's WIDTH now tracks the parent's width by itself -- height still fixed at 60px because the y-axis anchors are still a single point (1,1)

pivot เป็นคนละเรื่องกับ anchor: มันตัดสินว่าส่วนไหนของตัว element นี้เองที่ใช้เป็นจุดกำเนิดสำหรับ position — และที่สำคัญคือสำหรับ rotation กับ scale ด้วย:

pivot = (0.5, 0.5) pivot = (1, 0.5) (center -- the default) (middle of the RIGHT edge) +-------+ +-------+ | X | <- X = pivot | X | <- X = pivot +-------+ +-------+ scaling or rotating happens AROUND the pivot point. a health bar meant to "drain" from the right edge inward should use pivot=(1,0.5): shrinking its transform.localScale.x then keeps the right edge fixed and eats into the bar from the LEFT, instead of shrinking evenly from the center
Tip ไอคอนสี่เหลี่ยมเล็ก ๆ (anchor-preset) ใน Rect Transform ของ Inspector (ไอคอนที่หน้าตาเหมือนเป้ายิงธนู มีตัวเลือกมุมและแบบยืด) ไม่ใช่ feature แยกต่างหาก — มันเป็นแค่ shortcut ที่กรอกค่า anchorMin, anchorMax ให้ (และถ้ากด Alt ค้าง หรือ Alt+Shift สำหรับ pivot ด้วย ก็จะกรอก anchoredPosition กับ sizeDelta ให้ด้วย) ทุกอย่างที่มันทำ คุณเขียนโค้ดเองแบบข้างบนได้เหมือนกัน
Common mistake ปล่อย pivot ไว้ที่ค่า default (0.5, 0.5) แล้วก็งงว่าทำไม effect ที่อิง scale (เช่น health bar ที่หดตัว) ถึงหายไปเท่า ๆ กันจากทั้งสองข้าง แทนที่จะหายจากขอบที่ต้องการ pivot ต่างหากที่คุมว่าการ scale กับ rotation จะเกิดจากจุดไหน ไม่ใช่ anchor — เช็คตัวนี้ก่อนเลยเวลาที่ scale หรือ rotation ดูผิดปกติ

4. Auto-Layout Group: Horizontal, Vertical และ Grid Layout

Anchor กับ pivot เหมาะกับ element จำนวนไม่กี่ตัวที่คุณวางตำแหน่งเองด้วยมือ แต่แถบ inventory ที่มีจำนวนช่องไม่แน่นอน หรือรายการข้อความแชทที่โตขึ้นเรื่อย ๆ ต้องการอะไรที่จัดตำแหน่ง child ให้อัตโนมัติ component Layout Group ที่แนบไว้กับ parent ทำแบบนั้นเป๊ะ ๆ: มันดู child ของมันทุกครั้งที่มีอะไรเปลี่ยนแปลง แล้วจัดเรียงเป็นแถว, คอลัมน์ หรือตาราง ตาม padding, spacing และการจัด alignment ที่คุณตั้งค่าไว้ครั้งเดียว

Horizontal Layout Group Vertical Layout Group Grid Layout Group (arranges children in a row) (arranges children in a (arranges children in column) rows AND columns) [A][B][C][D] [A] [A][B][C] [B] [D][E][F] [C] [G][H][I] [D] add or remove a child at runtime and every OTHER child shifts automatically to make room -- you never compute anchoredPosition by hand for these
using UnityEngine;

public class BuildInventoryRow : MonoBehaviour
{
    public GameObject slotPrefab;
    public Transform rowParent;   // has a *LayoutGroup component attached

    void Start()
    {
        for (int i = 0; i < 5; i++)
        {
            Instantiate(slotPrefab, rowParent);
        }
    }
}

สังเกตว่าสคริปต์นี้ไม่แตะ anchoredPosition เลยแม้แต่นิดเดียว — มันแค่เอา slotPrefab ห้าชุดไปเป็นลูกของ rowParent ผลลัพธ์จะออกมาเป็นแถว, คอลัมน์ หรือตาราง ขึ้นอยู่กับว่า component layout ตัวไหน (HorizontalLayoutGroup, VerticalLayoutGroup หรือ GridLayoutGroup) ถูกแนบไว้กับ rowParent ใน Inspector — โค้ด spawn ชุดเดียวกันนี้ให้ผลลัพธ์การจัดวางที่ต่างกันสามแบบ แค่สลับ component ตัวเดียวนั้น โดยไม่ต้องแก้โค้ดเลยสักบรรทัด

ญาติใกล้ชิดของมันคือ ContentSizeFitter ซึ่งทำงานตรงข้ามกัน: แทนที่จะจัด child มันจะ resize ตัว parent เองให้พอดีกับเนื้อหา chat bubble panel ที่ควรจะสูงขึ้นตามความยาวของ text ข้างในตั้งค่า ContentSizeFitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize แล้วความสูงของ panel จะตามความสูงที่ text ของ TMP ข้างในต้องการจริง ๆ ทุกครั้งที่ text เปลี่ยน

Common mistake เพิ่ม HorizontalLayoutGroup หรือแบบอื่น ๆ เข้าไป แล้วก็ยังพยายามตั้ง anchoredPosition ของ child จากโค้ดเพื่อขยับมันนิดหน่อย Layout Group จะคำนวณและเขียนทับตำแหน่งของ child ทุกตัวตามรอบของมันเอง ดังนั้นการเปลี่ยนตำแหน่งด้วยมือจะถูกล้างเงียบ ๆ ในรอบ layout ถัดไป ถ้าคุณต้องการควบคุมตำแหน่งของ child ตัวใดตัวหนึ่งเองแบบเฉพาะกิจ child ตัวนั้นต้องอยู่นอก layout group หรือไม่ก็ต้อง disable group ไว้ชั่วคราว

5. Scale ข้ามความละเอียดจอ: Canvas Scaler กับ Reference Resolution

UI ที่ออกแบบด้วยสายตาบนจอมอนิเตอร์ 1920x1080 จะไม่ดูดีอัตโนมัติบนมือถือ 1080x2340 หรือแท็บเล็ต 2048x1536 — ถ้าไม่มีตัวช่วย ปุ่มที่ตั้งไว้ "กว้าง 200 pixel" จะเป็นขนาดที่เหมาะสมบนมอนิเตอร์ แต่กลายเป็นเป้าที่เล็กจนกดยากบนมือถือ เพราะ "pixel" มีขนาดจริงต่างกันมากในแต่ละอุปกรณ์ component CanvasScaler (ที่อยู่บน GameObject เดียวกับ Canvas) แก้ปัญหานี้ด้วย scale mode ที่ชื่อ Scale With Screen Size: คุณออกแบบ UI ครั้งเดียวที่ reference resolution ค่าหนึ่ง แล้ว Canvas Scaler จะคำนวณตัวเลขตัวเดียว — scale factor — มาคูณ UI element ทุกตัว เพื่อให้ทั้ง layout โตหรือหดไปด้วยกัน พอดีกับจอที่มันรันจริง ๆ

using UnityEngine;
using UnityEngine.UI;

public class ConfigureScaler : MonoBehaviour
{
    public CanvasScaler scaler;

    void Start()
    {
        scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
        scaler.referenceResolution = new Vector2(1920f, 1080f);
        scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
        scaler.matchWidthOrHeight = 1f;   // 0 = match width, 1 = match height
    }
}

slider matchWidthOrHeight ตัดสินว่า scale factor จะอิงมิติไหน ที่ 0 scale factor จะเป็นแค่ actual width / reference width; ที่ 1 จะเป็น actual height / reference height ลอง trace มือดูกับอุปกรณ์จริงสองตัว โดยตั้ง reference resolution ไว้ที่ 1920x1080:

reference resolution: 1920 x 1080 PHONE, 1080 x 2340, match = 1 (match HEIGHT) scaleFactor = actualHeight / refHeight = 2340 / 1080 = 2.1667 a UI element that is 100 reference-units wide is drawn 100 * 2.1667 = 216.67 real pixels wide on this phone TABLET, 2048 x 1536, match = 0 (match WIDTH) scaleFactor = actualWidth / refWidth = 2048 / 1920 = 1.0667 the same 100-unit-wide element is drawn 100 * 1.0667 = 106.67 real pixels wide on this tablet

พอเปิด ScaleWithScreenSize แล้ว คุณจะเลิกคิดเป็น pixel จริงของอุปกรณ์ไปเลย แล้วออกแบบขนาดและตำแหน่งของ RectTransform ทุกตัวเป็น "reference unit" — ตัวเลขที่สมเหตุสมผลที่ reference resolution — แล้วปล่อยให้ Canvas Scaler แปลงมันให้เข้ากับจอไหนก็ตามที่เกมรันอยู่จริง

Tip ค่า matchWidthOrHeight ระหว่าง 0 กับ 1 เช่นค่า default ที่พบบ่อยคือ 0.5 ไม่ได้แค่เอา scale factor สองตัวมาเฉลี่ยตรง ๆ — Unity ผสมมันด้วย curve แบบ logarithmic ดังนั้นจอที่กว้างเป็นสองเท่ากับจอที่สูงเป็นสองเท่าจะมีน้ำหนักเท่า ๆ กันในการผสม คุณไม่จำเป็นต้องรู้สูตรเป๊ะ ๆ ก็ใช้มันได้ดี: มองว่า 0 คือ "fit width เป๊ะเสมอ", 1 คือ "fit height เป๊ะเสมอ" และค่ากลาง ๆ คือ "ค่าประนีประนอมที่เอียงไปทางด้านที่ใกล้กว่า" แล้วปรับด้วยสายตากับอุปกรณ์เป้าหมายจริง ๆ ของคุณ

6. Safe Area: กัน UI ให้พ้นรอยบาก

Reference resolution กับ scale factor แก้เรื่องขนาดได้ แต่มือถือยุคใหม่มีอีกปัญหาหนึ่ง: หน้าจอจริง ๆ ไม่สามารถใช้ได้เต็มพื้นที่ทั้งหมด รอยบากกล้อง (notch) หรือมุมโค้งอาจกินพื้นที่ด้านบนของจอ และแถบท่าทาง home indicator ก็อาจซ้อนทับด้านล่าง — วาดปุ่มไว้ใต้จุดพวกนี้ ส่วนหนึ่งของมันจะมองไม่เห็นหรือกดยาก Unity เปิด property Screen.safeArea ให้ ซึ่งเป็น Rect เป็นหน่วย pixel จริงของจอ (วัดจากมุมล่างซ้าย) ที่ไม่รวมโซนอันตรายพวกนี้เลย

phone screen with a camera notch cut into the top: +---[camera notch]-------------------+ <- UNSAFE (notch, status bar) | | | SAFE AREA | <- Screen.safeArea: put every | (all UI content goes here) | piece of UI content inside | | this rectangle |______________________________[home]| <- UNSAFE (home indicator gesture zone)

วิธีแก้ทั่วไปคือสคริปต์เล็ก ๆ บน panel "SafeArea" หนึ่งตัวที่ UI ชิ้นอื่น ๆ ทั้งหมดเป็นลูกอยู่ข้างใต้ แปลง Rect ที่เป็น pixel จาก Screen.safeArea ให้กลายเป็นค่า anchorMin/anchorMax แบบ normalized (0 ถึง 1 ตามที่หัวข้อ 3 พูดถึงไปแล้ว):

using UnityEngine;

[RequireComponent(typeof(RectTransform))]
public class SafeAreaFitter : MonoBehaviour
{
    RectTransform rectTransform;
    Rect lastSafeArea;

    void Awake()
    {
        rectTransform = GetComponent<RectTransform>();
        Apply(Screen.safeArea);
    }

    void Update()
    {
        // cheap to compare every frame; actually re-applying is rare
        // (only happens on rotation or a foldable device unfolding)
        if (Screen.safeArea != lastSafeArea)
            Apply(Screen.safeArea);
    }

    void Apply(Rect safeArea)
    {
        lastSafeArea = safeArea;

        Vector2 anchorMin = safeArea.position;
        Vector2 anchorMax = safeArea.position + safeArea.size;

        anchorMin.x /= Screen.width;
        anchorMin.y /= Screen.height;
        anchorMax.x /= Screen.width;
        anchorMax.y /= Screen.height;

        rectTransform.anchorMin = anchorMin;
        rectTransform.anchorMax = anchorMax;
    }
}

ลอง trace ดูกับมือถือ 1080x2340 ที่ safe area เป็น Rect(0, 102, 1080, 2166) (เว้นด้านล่าง 102px ให้ home indicator และตัดด้านบน 72 pixel ให้รอยบาก เพราะ 102 + 2166 = 2268 เหลือ 2340 - 2268 = 72 pixel ที่ไม่ปลอดภัยด้านบน):

anchorMin.x = 0    / 1080 = 0.0
anchorMin.y = 102  / 2340 = 0.0436
anchorMax.x = 1080 / 1080 = 1.0
anchorMax.y = 2268 / 2340 = 0.9692

เอาไปใช้กับ RectTransform แบบยืด (stretched) ของ SafeAreaFitter ผลคือมันดึงขอบล่างขึ้นมา 4.36% ของความสูงจอ และดึงขอบบนลงมา 3.08% ของความสูงจอ — เป็นเปอร์เซ็นต์เล็ก ๆ แต่พอดีเป๊ะที่จะเลื่อน child element ทุกตัวออกมาจากใต้รอยบากและ home indicator ได้ บนอุปกรณ์ไหนก็ตาม โดยไม่ต้อง hardcode ตัวเลข pixel สักตัวเดียว

Tip ห่อเฉพาะเนื้อหาที่ต้องหลบรอยบากจริง ๆ เท่านั้น — ภาพพื้นหลังเต็มจอมักจะยังต้องคลุมเต็มขอบจอเหมือนเดิม ดังนั้นให้มันอยู่นอก SafeAreaFitter ส่วนปุ่มกับข้อความและอะไรก็ตามที่กดได้ให้อยู่ข้างในนั้น

7. จัดการ Click: Button, UnityEvent และ Event System

การกด UI button ไม่ใช่กรณีพิเศษของการอ่าน input ตรง ๆ แบบ Input.GetKeyDown — มันไหลผ่าน pipeline เล็ก ๆ ของตัวเอง EventSystem GameObject ตัวเดียว (ควรมีแค่ตัวเดียวต่อซีน) รับ input ของ pointer และการเลื่อนด้วยคีย์บอร์ดทั้งหมด, component GraphicRaycaster บน Canvas หาว่า UI element ตัวไหน (ถ้ามี) อยู่ใต้ pointer (คำนึงถึงว่าตัวไหนอยู่ทับตัวไหน — คล้ายกับวิธีที่ 3D raycast หาจุดชนที่ใกล้ที่สุด) และสุดท้าย component ของ element ที่อยู่บนสุด (ในกรณีนี้คือ Button) จะยิง event ของมันออกมา

mouse click | v EventSystem (one per scene -- routes all UI input) | v GraphicRaycaster (on the Canvas -- finds the TOPMOST UI | element under the pointer, if any) v Button.onClick.Invoke() (a UnityEvent -- every listener you registered now runs, in order)

Button.onClick คือ UnityEvent — type ในตัวของ Unity สำหรับ "รายชื่อฟังก์ชันที่จะเรียกเมื่อมีอะไรเกิดขึ้น" คล้ายกับ C# event ที่คุณใช้มาแล้วมาก เพียงแต่มันต่อสายผ่าน Inspector ด้วยสายตาได้ด้วย โดยไม่ต้องเขียนโค้ดเลยก็ได้ ถ้าต่อสายจากโค้ดจะหน้าตาแบบนี้:

using UnityEngine;
using UnityEngine.UI;

public class PlayButtonHandler : MonoBehaviour
{
    public Button playButton;

    void OnEnable()
    {
        playButton.onClick.AddListener(HandlePlayClicked);
    }

    void OnDisable()
    {
        playButton.onClick.RemoveListener(HandlePlayClicked);
    }

    void HandlePlayClicked()
    {
        Debug.Log("Play button clicked -- starting game");
    }
}

กด button ตอน Play Mode แล้ว Console จะ print:

Play button clicked -- starting game

การ AddListener ใน OnEnable คู่กับ RemoveListener ใน OnDisable คือวินัย subscribe/unsubscribe แบบเดียวกับที่คุณใช้กับ C# event อยู่แล้ว — การลืม unsubscribe อันตรายพอ ๆ กับที่อื่น เพราะ object ที่ถูก destroy หรือ disable ไปแล้วแต่ยัง subscribe อยู่ก็ยังถูกเรียกได้อยู่ หรือทำให้เกิด leak ได้

Common mistake button ที่มองเห็นอยู่ชัดเจนแต่ไม่ตอบสนอง click เลย มักมาจากสาเหตุใดสาเหตุหนึ่งในสองอย่าง: ในซีนไม่มี EventSystem เลย (Unity จะเสนอสร้างให้อัตโนมัติตอนที่คุณเพิ่ม UI element ตัวแรก แต่มันอาจถูกลบไปโดยไม่ตั้งใจ) หรือไม่ก็มี UI element ที่มองไม่เห็นตัวอื่น — บ่อยครั้งคือ panel โปร่งใสเต็มจอที่เพิ่มเข้ามาทีหลัง อย่างเช่น fade overlay — วางทับ button อยู่ใน hierarchy โดยเปิด Raycast Target ไว้บน Image ของมัน ดักจับ click ไปเงียบ ๆ ก่อนที่มันจะไปถึง button ข้างล่างเลย

8. จัดการ Drag: Pointer Interface

Button.onClick ครอบคลุมแค่การกดแล้วปล่อยบน element เดียวกันเท่านั้น ส่วนการ drag — กด, ขยับระหว่างที่ยังกดค้าง, ปล่อย — ต้องใช้กลไกอีกแบบ: interface เล็ก ๆ ชุดหนึ่งจาก UnityEngine.EventSystems ที่ Event System เรียกให้อัตโนมัติ ในจังหวะที่ถูกต้อง บน component ตัวไหนก็ตามที่ implement มันไว้ การ implement IBeginDragHandler, IDragHandler และ IEndDragHandler ก็เหมือนกับตอนที่คุณ implement IPlayerInput ในบทก่อนหน้าเป๊ะ ๆ: คุณสัญญาว่าจะมี method พวกนี้ให้ แล้วมีอะไรบางอย่างมาเรียกมันให้คุณเอง

using UnityEngine;
using UnityEngine.EventSystems;

public class DraggableIcon : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    RectTransform rectTransform;
    CanvasGroup canvasGroup;

    void Awake()
    {
        rectTransform = GetComponent<RectTransform>();
        canvasGroup = GetComponent<CanvasGroup>();
    }

    public void OnBeginDrag(PointerEventData eventData)
    {
        // let drop targets underneath "see" the pointer through this icon
        canvasGroup.blocksRaycasts = false;
    }

    public void OnDrag(PointerEventData eventData)
    {
        rectTransform.anchoredPosition += eventData.delta;
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        canvasGroup.blocksRaycasts = true;
    }
}

eventData.delta คือระยะที่ pointer ขยับนับจากเฟรมก่อนหน้าเป็น pixel — ไอเดีย "การเปลี่ยนแปลงนับจากครั้งที่แล้ว" แบบเดียวกับ Time.deltaTime เพียงแต่เป็นตำแหน่ง pointer แทนที่จะเป็นเวลา ลอง trace สี่เฟรมของการ drag ที่เริ่มจาก anchoredPosition = (100, 50):

start: anchoredPosition = (100, 50) frame 1: delta = (5, 0) -> position = (105, 50) frame 2: delta = (5, -2) -> position = (110, 48) frame 3: delta = (4, -3) -> position = (114, 45) frame 4: delta = (3, -1) -> position = (117, 44) total displacement over the drag: (17, -6) -- exactly the sum of every delta, one OnDrag call per frame

CanvasGroup เป็น component ที่ใช้ setting หนึ่งกับ subtree ของ UI element ทั้งชุดพร้อมกัน — ตรงนี้ blocksRaycasts = false ระหว่าง drag หมายความว่า GraphicRaycaster จะมองทะลุไอคอนตัวนี้ไปในระหว่างที่มันถูกถืออยู่ เพื่อให้ drop target ข้างใต้ (เช่นช่อง inventory) ตรวจจับได้ว่า pointer อยู่เหนือ มัน ไม่ใช่ไอคอนที่ลอยทับอยู่ CanvasGroup.alpha (fade ทั้ง subtree) กับ CanvasGroup.interactable (ปิดการใช้งาน button ทั้ง subtree พร้อมกัน เหมาะกับการทำ panel จาง ๆ แบบ disable) ก็คือไอเดียเดียวกันนี้เอาไปใช้กับ setting อื่น

9. UI Toolkit: UXML, USS และเลือกใช้ตัวไหนดี

ทุกอย่างที่ผ่านมาคือ UGUI ระบบ UI อีกตัวของ Unity คือ UI Toolkit สร้าง UI ด้วยวิธีที่ต่างออกไป: แทนที่จะเป็น GameObject หนึ่งตัวต่อหนึ่ง element มันใช้ tree เบา ๆ ของ object C# ธรรมดาที่เรียกว่า VisualElement อธิบายแบบ declarative ผ่านไฟล์ markup — คล้ายกับการสร้างเว็บเพจจาก HTML กับ CSS มาก

UXML: Markup สำหรับโครงสร้าง

UXML เป็น markup format แบบ XML ที่อธิบายว่ามี element อะไรบ้างและซ้อนกันยังไง โดยไม่ปนรายละเอียดเรื่อง style เข้ามาเลย:

<ui:UXML xmlns:ui="UnityEngine.UIElements">
    <ui:VisualElement name="root" class="panel">
        <ui:Label text="Score: 0" name="score-label" class="score-text" />
        <ui:Button text="Play" name="play-button" class="primary-button" />
    </ui:VisualElement>
</ui:UXML>

USS: Stylesheet สำหรับ Style

USS (Unity Style Sheets) เป็นภาษาคล้าย CSS ที่จัด style ให้ element ตาม name หรือตาม class เหมือน attribute class="panel" ข้างบนเป๊ะ ๆ:

.panel {
    flex-direction: column;
    align-items: center;
    padding: 20px;
}

.score-text {
    font-size: 32px;
    color: white;
}

.primary-button {
    width: 200px;
    height: 60px;
}

.primary-button:hover {
    background-color: rgb(80, 80, 80);
}

ต่อสายเข้า C#

component UIDocument ตัวเดียวบน GameObject หนึ่งตัวชี้ไปที่ไฟล์ UXML และเปิด tree ของมันออกมาเป็น rootVisualElement จากตรงนั้น Q<T>(name) ("query") หา element ตาม name ให้ คล้าย GameObject.Find มาก แต่ขอบเขตอยู่แค่ใน UI tree นี้:

using UnityEngine;
using UnityEngine.UIElements;

public class ScoreScreenController : MonoBehaviour
{
    [SerializeField] UIDocument uiDocument;

    Label scoreLabel;
    Button playButton;

    void OnEnable()
    {
        VisualElement root = uiDocument.rootVisualElement;

        scoreLabel = root.Q<Label>("score-label");
        playButton = root.Q<Button>("play-button");

        playButton.clicked += HandlePlayClicked;
    }

    void OnDisable()
    {
        playButton.clicked -= HandlePlayClicked;
    }

    void HandlePlayClicked()
    {
        Debug.Log("Play button clicked (UI Toolkit)");
    }

    public void SetScore(int score)
    {
        scoreLabel.text = "Score: " + score;
    }
}

สังเกตว่า Button.clicked ตรงนี้เป็น callback สไตล์ C# event ธรรมดา (+= / -=) ไม่ใช่ UnityEvent แบบ onClick ของ UGUI — เป็นหนึ่งในความต่างด้านชื่อเล็ก ๆ น้อย ๆ หลายจุดที่คุณจะเจอตอนย้ายไปมาระหว่างสองระบบนี้

Retained Mode พูดง่าย ๆ

UI Toolkit มักถูกอธิบายว่าเป็นระบบ UI แบบ retained-mode "Retained" ตรงนี้หมายความว่าระบบเก็บ ("retain") tree ของ element ไว้ข้ามเฟรม แล้วคำนวณและวาดใหม่เฉพาะส่วนที่เปลี่ยนจริง ๆ เท่านั้น — ตรงข้ามกันคือ immediate mode ที่คุณสร้าง UI ทั้งหมดใหม่จากศูนย์ จากโค้ด ทุก ๆ เฟรม (นี่คือวิธีทำงานของระบบ OnGUI/IMGUI เก่าของ Unity ที่ใช้แค่ใน editor เป๊ะ ๆ และเป็นเหตุผลว่าทำไมมันไม่เคยเหมาะกับ UI ในเกมเลย) ทั้ง UGUI และ UI Toolkit เป็น retained ในความหมายนี้เหมือนกัน — ความต่างจริง ๆ ที่ใช้งานได้ระหว่างสองระบบคือ น้ำหนักต่อ element: element ของ UGUI คือ GameObject เต็มรูปแบบ มี Transform, RectTransform และ component อย่างน้อยหนึ่งตัว ทั้งหมดเข้าร่วมใน scene graph ปกติของ Unity; element ของ UI Toolkit คือ object C# เล็ก ๆ เบา ๆ ที่ไม่มีภาระพวกนั้นเลย

UGUI UI Toolkit (one GameObject per element) (one VisualElement per element, NO GameObject at all) Canvas (GameObject) UIDocument (ONE GameObject) Panel (GameObject) rootVisualElement Text (GameObject) Label Button (GameObject) Button Text (GameObject) (its label is built in) every box = a Transform, a every box = a small C# object held RectTransform, and one or more in a tree -- no Transform, no UI components to track and GameObject, no per-object update each frame component overhead

แล้วควรใช้ตัวไหนดี?

10. อัปเดต UI ให้สะอาด: ใช้ Event แทน Polling พร้อมโน้ตเรื่อง MVC/MVP

ตอนนี้คุณสร้างและกด UI ได้แล้ว คำถามต่อไปคือมันควรรู้เรื่องการเปลี่ยนแปลงของ game state ยังไง — ค่า health ที่ลดลง, คะแนนที่เพิ่มขึ้น วิธีแรกที่ล่อใจคือเช็คค่าทุกเฟรมข้างใน Update:

using UnityEngine;
using UnityEngine.UI;

public class HealthBarPolling : MonoBehaviour
{
    public PlayerHealth playerHealth;
    public Slider slider;

    void Update()
    {
        // BAD: recomputes and reassigns every single frame, even when
        // health has not changed at all since the last frame
        slider.value = playerHealth.CurrentHealth / (float)playerHealth.MaxHealth;
    }
}

วิธีนี้ใช้ได้ แต่มันทำงานจริง ๆ — การหารและการเขียน property ซึ่งตัวมันเองก็อาจไป trigger การอัปเดต UI ภายในอีกที — ในทุก ๆ เฟรมจากอาจจะเป็นพัน ๆ เฟรมที่ไม่มีอะไรเปลี่ยนแปลงเลยแม้แต่นิดเดียว วิธีที่สะอาดกว่า เหมือนกับการเทียบ polling-กับ-event จากบท input เป๊ะ ๆ คือให้ระบบ health ยิง event ออกมาเฉพาะตอนที่มันเปลี่ยนจริง ๆ แล้วให้ UI แค่ฟังอย่างเดียว:

using System;
using UnityEngine;

public class PlayerHealth : MonoBehaviour
{
    public event Action<int, int> OnHealthChanged;   // (current, max)

    public int MaxHealth = 100;
    int currentHealth;

    void Awake()
    {
        currentHealth = MaxHealth;
    }

    public void TakeDamage(int amount)
    {
        currentHealth = Mathf.Max(0, currentHealth - amount);
        OnHealthChanged?.Invoke(currentHealth, MaxHealth);   // fires ONLY here
    }
}
using UnityEngine;
using UnityEngine.UI;

public class HealthBarView : MonoBehaviour
{
    public PlayerHealth playerHealth;
    public Slider slider;

    void OnEnable()
    {
        playerHealth.OnHealthChanged += HandleHealthChanged;
    }

    void OnDisable()
    {
        playerHealth.OnHealthChanged -= HandleHealthChanged;
    }

    void HandleHealthChanged(int current, int max)
    {
        slider.value = current / (float)max;
    }
}

ตลอดการต่อสู้ 10 วินาทีที่ 60 เฟรมต่อวินาที (600 เฟรม) ที่ผู้เล่นโดนตีพอดี 3 ครั้ง เวอร์ชัน polling จะรันการหารและ assign 600 ครั้ง; เวอร์ชัน event รันแค่ 3 ครั้ง และให้ผลลัพธ์ slider บนจอออกมาเหมือนกันเป๊ะตลอดทั้งหมด เพราะค่าของ slider ต้องเปลี่ยนจริง ๆ แค่ 3 ครั้งเท่านั้น

การแยกแบบนี้ — ตัวเก็บข้อมูลธรรมดาที่ไม่รู้จัก UI เลย กับสคริปต์ UI ที่ไม่รู้จักกฎของเกมเลย เชื่อมกันด้วย event — คือจุดเริ่มต้นของตระกูล pattern MVC (Model-View-Controller) หรือ MVP (Model-View-Presenter) Model คือข้อมูลธรรมดา (PlayerHealth ข้างบน) View คือตัว UI element เอง (Slider, Text) Presenter (หรือ Controller) คือสคริปต์เล็ก ๆ ที่อยู่ตรงกลาง: มันฟัง event ของ Model แล้วอัปเดต View และมันฟัง event ของ View (เช่นการกดปุ่ม) แล้วเรียก method บน Model ทั้ง Model และ View ไม่จำเป็นต้องมี reference ตรง ๆ ถึงกันเลย

MODEL PRESENTER VIEW (PlayerHealth -- (listens to Model, (Slider, Text -- plain data, no updates View; listens only ever set by UI references at all) to View, updates Model) the Presenter) TakeDamage(10) | v OnHealthChanged(90,100) --------------> slider.value = 0.9 player clicks "Heal" <---------------- button.onClick | v Heal(20) called on the Model the Model never imports UnityEngine.UI, and the View never touches currentHealth directly -- only the Presenter talks to both sides

คุณไม่จำเป็นต้องมี framework ที่มีชื่อตายตัวเพื่อให้ได้ประโยชน์นี้ — HealthBarView ข้างบนก็ทำหน้าที่เป็น Presenter เล็ก ๆ อยู่แล้ว นิสัยที่สำคัญมีแค่: คลาส game-state ยิง event เวลามันเปลี่ยน และมีแค่คลาส UI เท่านั้นที่แตะ UI component และทั้งสองฝั่งจะเจอกันผ่านการ subscribe event เท่านั้น ไม่ใช่สคริปต์ UI เอื้อมไปอ่าน (หรือสคริปต์ game-logic เอื้อมไปเขียน) อีกฝั่งตรง ๆ เลย

11. Performance ของ UI: Batching, Rebuild และทำไมการแยก Canvas ถึงสำคัญ

UGUI พยายามวาด UI element จำนวนมากด้วย GPU draw call (คำสั่งเดียวที่บอก GPU ว่า "วาด geometry ก้อนนี้เดี๋ยวนี้") ให้น้อยที่สุดเท่าที่ทำได้ ด้วยการ batch — รวม element ที่ใช้ material กับ texture เดียวกัน (ส่วนใหญ่คือ font atlas เดียวกัน หรือ sprite atlas ของ UI เดียวกัน) ให้กลายเป็น draw call เดียว แทนที่จะเป็นหนึ่ง call ต่อหนึ่ง element นี่คือเหตุผลที่ style guide ของ UI สนับสนุนให้อัด icon ไว้ใน sprite atlas ที่ใช้ร่วมกัน: element ที่วาดจาก atlas เดียวกัน batch รวมกันได้ ในขณะที่ element ที่ใช้ texture คนละตัวที่ไม่ได้แชร์กันจะบังคับให้เกิด batch break

Batching เกิดขึ้นบนต้นทุนพื้นฐานอีกอย่างหนึ่ง: UI element ทุกตัวใต้ Canvas จะมี geometry ของมัน (สามเหลี่ยมกับสี vertex จริง ๆ ที่ GPU วาด) ถูก Unity สร้างและ cache ไว้ ทันทีที่อะไรก็ตามเปลี่ยนแปลงเกี่ยวกับ element ตัวใดตัวหนึ่งใต้ Canvas นั้น — text ของมัน, ขนาดของมัน, ว่ามัน active อยู่หรือเปล่า แม้แต่แค่ขยับมัน — Unity จะทำเครื่องหมาย Canvas นั้นว่า dirty แล้วต้องสร้าง geometry ของทุกอย่างใน batch ของ Canvas นั้นใหม่ ก่อนที่เฟรมถัดไปจะ render สิ่งนี้เรียกว่า canvas rebuild และมันไม่ได้ฟรี: Canvas ที่มีปุ่มไม่กี่ปุ่ม rebuild ทุกเฟรมไม่ใช่เรื่องน่ากังวลอะไร แต่ Canvas ที่มีตาราง inventory ขนาดใหญ่ rebuild ทุกเฟรมแค่เพราะตัวเลข HUD เล็ก ๆ ข้าง ๆ กระดิกขึ้น คืองานที่เสียเปล่าซ้ำ ๆ หลายสิบครั้งต่อวินาที

ONE BIG CANVAS TWO SEPARATE CANVASES (200-icon inventory grid + HUD timer, inventory grid canvas HUD canvas all one Canvas component) (rarely changes) (timer text changes every frame) timer text changes this frame | | | | no change here v v v -> NOT dirty timer text WHOLE canvas marked dirty inventory grid changes | stays untouched | v v ALL 200 icons' geometry only the small gets regenerated too, HUD canvas's even though none of geometry is them actually changed regenerated

การแนบ component Canvas ตัวที่สองไว้กับ child GameObject จะสร้าง nested canvas — batch การ rebuild ของมันแยกเป็นอิสระจาก parent เอง การแยก subtree ที่เปลี่ยนบ่อย (นาฬิกาจับเวลา, ตัวเลข damage ที่ popup ขึ้นมา, log แชท) ออกเป็น nested Canvas ของตัวเอง หมายความว่าการเปลี่ยนแปลงถี่ ๆ ของมันจะไม่บังคับให้ subtree ที่ใหญ่กว่ามากและส่วนใหญ่นิ่ง ๆ (ตาราง inventory, panel settings) ต้อง rebuild ตามไปด้วย

// Hierarchy sketch, not a runnable script:
//
// MainCanvas          (Canvas + CanvasScaler + GraphicRaycaster)
//   InventoryPanel     -- 200 icons, rarely changes
//   HUDPanel           (its own Canvas component -- a nested canvas)
//     AmmoText          -- changes every shot
//     TimerText          -- changes every frame
//
// updating TimerText now only dirties HUDPanel's small rebuild batch,
// never InventoryPanel's much larger one

นิสัยเล็ก ๆ อีกไม่กี่อย่างช่วยเสริมการแยก canvas ได้: ใช้ text ของ TMP แทน component Text รุ่นเก่า (TMP สร้าง geometry ได้มีประสิทธิภาพกว่าและ batch ได้ดีกว่า); ปิด Raycast Target บน image ที่เป็นแค่ของตกแต่ง (raycast target ที่เปิดอยู่ทุกตัวคืออีกหนึ่งอย่างที่ GraphicRaycaster ต้องเช็คทุกครั้งที่มี click แม้ว่ามันจะกดไม่ได้อยู่แล้วก็ตาม); และหลีกเลี่ยงการ animate ค่าใด ๆ (fade, pulse, slide) บน element ที่อยู่ใน Canvas เดียวกับเนื้อหานิ่ง ๆ จำนวนมากที่ไม่เกี่ยวข้องกัน — ให้มันมี nested Canvas เล็ก ๆ ของตัวเองแทน

Tip เรื่องพวกนี้ไม่สำคัญเลยกับ prototype เล็ก ๆ ที่มี UI element แค่สิบกว่าตัว — วัดด้วย UI module ของ Unity Profiler ก่อนที่จะไป optimize การแยก Canvas คือทางแก้สำหรับ UI ที่โตขึ้นมากแล้วและกำลังกินเวลาเฟรมให้เห็นชัด ไม่ใช่กฎที่ต้องใช้กับทุกอย่างตั้งแต่ปุ่มแรก

12. Glossary

13. Exercises

Exercise 1 button รูปเฟือง settings ตัวหนึ่งมี RectTransform ที่ anchorMin = (1, 0), anchorMax = (1, 0), pivot = (1, 0), anchoredPosition = (-20, 20) และ sizeDelta = (160, 50) Canvas ที่เป็น parent ของมันมีขนาด 800 คูณ 600 โดย (0,0) อยู่ที่มุมล่างซ้ายและ (800,600) อยู่ที่มุมบนขวา หาพิกัด pixel ของขอบซ้าย, ขวา, ล่าง และบนของ button นี้ภายใน Canvas
Show answer
anchor point  = (800 * 1, 600 * 0) = (800, 0)           -- bottom-right corner
pivot position = anchor point + anchoredPosition
              = (800 - 20, 0 + 20) = (780, 20)

pivot=(1,0) means this point IS the button's own right edge and bottom edge:
  right edge x = 780
  left  edge x = 780 - width(160)  = 620
  bottom edge y = 20
  top    edge y = 20 + height(50) = 70

button spans x: 620 to 780, y: 20 to 70

เริ่มจากจุด anchor: anchorMin กับ anchorMax เป็น (1, 0) เหมือนกันทั้งคู่ ดังนั้นมันบอกจุด ๆ เดียวที่มุมล่างขวาของ Canvas คือ (800, 0) เป็น pixel anchoredPosition คือระยะห่างของ pivot จากจุด anchor นั้น ดังนั้น pivot จะอยู่ที่ (800 - 20, 0 + 20) = (780, 20) เพราะ pivot = (1, 0) เหมือนกัน จุดนั้นจึงเป็นทั้งขอบขวาของ button เอง (pivot.x = 1) และขอบล่างของมัน (pivot.y = 0) ในเวลาเดียวกัน ส่วนที่เหลือของสี่เหลี่ยมจึงยืดออกไปทางซ้ายและขึ้นบนจากจุดนั้นตาม sizeDelta: ขอบซ้ายอยู่ที่ 780 - 160 = 620, ขอบบนอยู่ที่ 20 + 50 = 70 ผลลัพธ์คือ button ขนาด 160x50 ที่อยู่ใกล้มุมล่างขวา เว้นระยะ 20 pixel จากแต่ละขอบ — เป็น pattern เดียวกับที่ไอคอน settings มุมจอมักใช้กัน และมันจะเว้นระยะ 20 pixel เท่าเดิมนี้เสมอ ไม่ว่า Canvas จะถูก resize ยังไง เพราะ anchor ของมันคือตัวมุมเอง

Exercise 2 Canvas Scaler ตัวหนึ่งใช้ ScaleWithScreenSize, reference resolution 1920x1080 และ matchWidthOrHeight = 1 (match height อย่างเดียว) เกมรันบนมือถือที่ 1170x2532 (ก) scale factor เท่าไหร่? (ข) button ที่ RectTransform width เป็น 300 reference unit จะกว้างกี่ pixel จริงบนมือถือนี้? (ค) ในหน่วย reference unit มือถือนี้กว้างเท่าไหร่จริง ๆ ที่มองเห็นได้? ตัวเลขนั้นบอกอะไรเกี่ยวกับการออกแบบที่ 1920 กว้าง ตอนที่ match = 1?
Show answer
(a) scaleFactor = actualHeight / refHeight = 2532 / 1080 = 2.3444

(b) realPixelWidth = 300 * 2.3444 = 703.3 px

(c) visible width in reference units = actualWidth / scaleFactor
                                      = 1170 / 2.3444 = 499.1 reference units

เพราะ matchWidthOrHeight = 1 scale factor จะดูแต่ height เท่านั้น — reference width ที่ 1920 ไม่มีบทบาทในการคำนวณมันเลย ได้ scale factor เท่ากับ 2.3444 ดังนั้น button กว้าง 300 unit จะถูกวาดกว้าง 703.3 pixel จริง ส่วนที่น่าสนใจคือข้อ (ค): มือถือเครื่องนี้มองเห็นได้กว้างแค่ 499.1 reference unit นิดหน่อยกว่าหนึ่งในสี่ของ canvas ที่ออกแบบไว้กว้าง 1920 unit อะไรก็ตามที่วางไว้ไกลจากขอบซ้ายเกินประมาณ 499 reference unit — toolbar แนวนอนที่กว้าง หรือ HUD element ที่ anchor ไว้ใกล้ "กลาง" ของ design ที่กว้าง 1920 — จะถูกดันหลุดออกนอกขอบจอที่มองเห็นได้ของมือถือเครื่องนี้ไปเลย นี่คือเหตุผลว่าทำไมการ match แค่ height อย่างเดียวถึงเสี่ยงกับมือถือแนวสูงที่แคบมาก ๆ: มันรับประกันว่าความสูงเต็มจะมองเห็นได้เสมอ แต่ไม่ได้บอกอะไรเลยว่าความกว้างจะเหลือแค่ไหน ซึ่งก็เป็นเหตุผลว่าทำไมทั้งเรื่อง safe-area (หัวข้อ 6) และการทดสอบกับอุปกรณ์ aspect ratio แคบจริง ๆ ถึงสำคัญ ไม่ใช่แค่เลือกค่า match แล้วเชื่อมันเฉย ๆ

Exercise 3 HUD นับกระสุนตัวหนึ่งอัปเดต text element ของ TMP เกมรัน 4 วินาทีที่ 60 FPS (รวม 240 เฟรม) ผู้เล่นยิง 6 นัด และค่าที่ text ของกระสุนแสดงจริง ๆ เปลี่ยนพอดี 6 ครั้งตลอด 4 วินาทีนั้น เวอร์ชัน A ตั้งค่า .text ทุกเฟรมของ Update() ไม่ว่ากระสุนจะเปลี่ยนหรือไม่ เวอร์ชัน B ตั้งค่า .text เฉพาะข้างใน handler ที่ subscribe กับ event OnAmmoChanged ซึ่งยิงเฉพาะตอนกระสุนลดลงจริง ๆ เท่านั้น (ก) แต่ละเวอร์ชันเขียนค่า .text กี่ครั้ง? (ข) ทุกครั้งที่เขียน .text จะทำเครื่องหมาย Canvas ของ element นั้นว่า dirty เพื่อ rebuild ก่อนเฟรมถัดไปจะ render — แต่ละเวอร์ชัน trigger canvas rebuild กี่ครั้งจาก text กระสุนตัวนี้อย่างเดียว? (ค) อธิบายในหนึ่งประโยคว่าทำไมข้อได้เปรียบของเวอร์ชัน B จะยิ่งมากขึ้น ถ้า Canvas ของ HUD นี้มีตาราง inventory 200 ไอคอนอยู่ด้วย แทนที่จะมีแค่ text กระสุนอย่างเดียว
Show answer
(a) Version A: 240 writes (once every frame, whether or not ammo changed)
    Version B: 6 writes   (only on the frames ammo actually changed)

(b) Version A: 240 canvas rebuild triggers
    Version B: 6 canvas rebuild triggers

ตัวเลขทั้งสองข้อมาจากหัวข้อ 11 ตรง ๆ: ทุกครั้งที่เขียนค่า field .text ของ component Text/TMP จะทำเครื่องหมาย Canvas ของมันว่า dirty ดังนั้นจำนวนครั้งที่เขียนกับจำนวนครั้งที่ trigger dirty จึงเป็นตัวเลขเดียวกันสำหรับแต่ละเวอร์ชัน สำหรับข้อ (ค): ถ้า text กระสุนอยู่ใน Canvas เดียวกับตาราง inventory 200 ไอคอน แทนที่จะมี nested Canvas ของตัวเอง ทุกครั้งจาก 240 ครั้งที่เวอร์ชัน A trigger dirty จะบังคับให้ Unity สร้าง geometry ของทั้ง batch ที่แชร์กันใหม่ทั้งหมด — icon ทั้ง 200 ตัว — แม้ว่าจะไม่มีตัวไหนเปลี่ยนเลยก็ตาม บ่อยเกินความจำเป็นถึง 234 เท่า; เวอร์ชัน B จะจ่ายต้นทุนการ rebuild ทั้ง batch นี้แค่ใน 6 เฟรมที่มีอะไรเปลี่ยนแปลงจริง ๆ เท่านั้น และ (ตามที่หัวข้อ 11 พูดถึงด้วย) การให้ text กระสุนมี nested Canvas เล็ก ๆ ของตัวเองจะลดงานเสียเปล่าของเวอร์ชัน A ลงเหลือแค่ rebuild subtree เล็ก ๆ นั้นเท่านั้น โดยไม่แตะตาราง icon เลย ไม่ว่าจะใช้เวอร์ชันไหนก็ตาม

บทนี้ครอบคลุมรูปร่างของ Unity UI จากทั้งสองทิศทาง: tree ของ element ใต้ Canvas ที่วางตำแหน่งด้วย anchor กับ pivot ที่รอดจากการเปลี่ยนขนาดจอทุกแบบ, จัดเรียงอัตโนมัติด้วย layout group, scale ได้สะอาดข้ามมือถือ, แท็บเล็ต และมอนิเตอร์ด้วย Canvas Scaler กับ safe-area fitter และอ่านผ่าน Event System ที่แปลง input ดิบของ pointer ให้กลายเป็น click กับ drag UGUI กับ UI Toolkit สร้าง tree นั้นด้วยสองวิธีที่ต่างกัน — แบบหนึ่งหนักไปทาง GameObject และคุ้นเคย อีกแบบเบาและขับเคลื่อนด้วย markup — แต่ทั้งสองแบบได้ประโยชน์จากนิสัยเดียวกันสองอย่างที่อยู่ข้างใต้: ให้ event ไม่ใช่การ poll ทุกเฟรม เป็นตัวพา state ที่เปลี่ยนเข้าไปหา UI และคอยจับตาดูว่าอะไรบังคับให้ Canvas ต้อง rebuild แล้วแยกมันออกจากกันเมื่อต้นทุนนั้นเริ่มแสดงผลจริง ๆ

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