ทุกเกมต้องมีวิธีแสดงตัวเลข, ปุ่ม, 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 ให้ไล่ตามด้วยมือ (ด้วยดินสอ) ได้
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 ตัวอื่น ๆ ใช้กัน
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 สมัยใหม่
GameObject ธรรมดา ๆ ทุกเทคนิคที่คุณรู้อยู่แล้วยังใช้ได้หมด: Instantiate เพื่อ spawn ตัวใหม่ตอน runtime, SetActive(false) เพื่อซ่อนมัน, transform.Find หรือ serialized field เพื่อขอ reference ไปหามัน คำศัพท์ใหม่มีแค่ RectTransform กับ component ต่าง ๆ (Image, Button, Slider, …) ที่เปลี่ยนสี่เหลี่ยมธรรมดาให้กลายเป็นสิ่งที่มองเห็นและกดได้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 คือค่า 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
}
}
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 ขนาดปกติอีกครั้งนี่คือไอเดียที่สำคัญที่สุดในเรื่อง 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
}
}
เมื่อ 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;
}
}
pivot เป็นคนละเรื่องกับ anchor: มันตัดสินว่าส่วนไหนของตัว element นี้เองที่ใช้เป็นจุดกำเนิดสำหรับ position — และที่สำคัญคือสำหรับ rotation กับ scale ด้วย:
anchorMin, anchorMax ให้ (และถ้ากด Alt ค้าง หรือ Alt+Shift สำหรับ pivot ด้วย ก็จะกรอก anchoredPosition กับ sizeDelta ให้ด้วย) ทุกอย่างที่มันทำ คุณเขียนโค้ดเองแบบข้างบนได้เหมือนกันpivot ไว้ที่ค่า default (0.5, 0.5) แล้วก็งงว่าทำไม effect ที่อิง scale (เช่น health bar ที่หดตัว) ถึงหายไปเท่า ๆ กันจากทั้งสองข้าง แทนที่จะหายจากขอบที่ต้องการ pivot ต่างหากที่คุมว่าการ scale กับ rotation จะเกิดจากจุดไหน ไม่ใช่ anchor — เช็คตัวนี้ก่อนเลยเวลาที่ scale หรือ rotation ดูผิดปกติAnchor กับ pivot เหมาะกับ element จำนวนไม่กี่ตัวที่คุณวางตำแหน่งเองด้วยมือ แต่แถบ inventory ที่มีจำนวนช่องไม่แน่นอน หรือรายการข้อความแชทที่โตขึ้นเรื่อย ๆ ต้องการอะไรที่จัดตำแหน่ง child ให้อัตโนมัติ component Layout Group ที่แนบไว้กับ parent ทำแบบนั้นเป๊ะ ๆ: มันดู child ของมันทุกครั้งที่มีอะไรเปลี่ยนแปลง แล้วจัดเรียงเป็นแถว, คอลัมน์ หรือตาราง ตาม padding, spacing และการจัด alignment ที่คุณตั้งค่าไว้ครั้งเดียว
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 เปลี่ยน
HorizontalLayoutGroup หรือแบบอื่น ๆ เข้าไป แล้วก็ยังพยายามตั้ง anchoredPosition ของ child จากโค้ดเพื่อขยับมันนิดหน่อย Layout Group จะคำนวณและเขียนทับตำแหน่งของ child ทุกตัวตามรอบของมันเอง ดังนั้นการเปลี่ยนตำแหน่งด้วยมือจะถูกล้างเงียบ ๆ ในรอบ layout ถัดไป ถ้าคุณต้องการควบคุมตำแหน่งของ child ตัวใดตัวหนึ่งเองแบบเฉพาะกิจ child ตัวนั้นต้องอยู่นอก layout group หรือไม่ก็ต้อง disable group ไว้ชั่วคราว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:
พอเปิด ScaleWithScreenSize แล้ว คุณจะเลิกคิดเป็น pixel จริงของอุปกรณ์ไปเลย แล้วออกแบบขนาดและตำแหน่งของ RectTransform ทุกตัวเป็น "reference unit" — ตัวเลขที่สมเหตุสมผลที่ reference resolution — แล้วปล่อยให้ Canvas Scaler แปลงมันให้เข้ากับจอไหนก็ตามที่เกมรันอยู่จริง
matchWidthOrHeight ระหว่าง 0 กับ 1 เช่นค่า default ที่พบบ่อยคือ 0.5 ไม่ได้แค่เอา scale factor สองตัวมาเฉลี่ยตรง ๆ — Unity ผสมมันด้วย curve แบบ logarithmic ดังนั้นจอที่กว้างเป็นสองเท่ากับจอที่สูงเป็นสองเท่าจะมีน้ำหนักเท่า ๆ กันในการผสม คุณไม่จำเป็นต้องรู้สูตรเป๊ะ ๆ ก็ใช้มันได้ดี: มองว่า 0 คือ "fit width เป๊ะเสมอ", 1 คือ "fit height เป๊ะเสมอ" และค่ากลาง ๆ คือ "ค่าประนีประนอมที่เอียงไปทางด้านที่ใกล้กว่า" แล้วปรับด้วยสายตากับอุปกรณ์เป้าหมายจริง ๆ ของคุณReference resolution กับ scale factor แก้เรื่องขนาดได้ แต่มือถือยุคใหม่มีอีกปัญหาหนึ่ง: หน้าจอจริง ๆ ไม่สามารถใช้ได้เต็มพื้นที่ทั้งหมด รอยบากกล้อง (notch) หรือมุมโค้งอาจกินพื้นที่ด้านบนของจอ และแถบท่าทาง home indicator ก็อาจซ้อนทับด้านล่าง — วาดปุ่มไว้ใต้จุดพวกนี้ ส่วนหนึ่งของมันจะมองไม่เห็นหรือกดยาก Unity เปิด property Screen.safeArea ให้ ซึ่งเป็น Rect เป็นหน่วย pixel จริงของจอ (วัดจากมุมล่างซ้าย) ที่ไม่รวมโซนอันตรายพวกนี้เลย
วิธีแก้ทั่วไปคือสคริปต์เล็ก ๆ บน 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 สักตัวเดียว
การกด UI button ไม่ใช่กรณีพิเศษของการอ่าน input ตรง ๆ แบบ Input.GetKeyDown — มันไหลผ่าน pipeline เล็ก ๆ ของตัวเอง EventSystem GameObject ตัวเดียว (ควรมีแค่ตัวเดียวต่อซีน) รับ input ของ pointer และการเลื่อนด้วยคีย์บอร์ดทั้งหมด, component GraphicRaycaster บน Canvas หาว่า UI element ตัวไหน (ถ้ามี) อยู่ใต้ pointer (คำนึงถึงว่าตัวไหนอยู่ทับตัวไหน — คล้ายกับวิธีที่ 3D raycast หาจุดชนที่ใกล้ที่สุด) และสุดท้าย component ของ element ที่อยู่บนสุด (ในกรณีนี้คือ Button) จะยิง event ของมันออกมา
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 ได้
EventSystem เลย (Unity จะเสนอสร้างให้อัตโนมัติตอนที่คุณเพิ่ม UI element ตัวแรก แต่มันอาจถูกลบไปโดยไม่ตั้งใจ) หรือไม่ก็มี UI element ที่มองไม่เห็นตัวอื่น — บ่อยครั้งคือ panel โปร่งใสเต็มจอที่เพิ่มเข้ามาทีหลัง อย่างเช่น fade overlay — วางทับ button อยู่ใน hierarchy โดยเปิด Raycast Target ไว้บน Image ของมัน ดักจับ click ไปเงียบ ๆ ก่อนที่มันจะไปถึง button ข้างล่างเลย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):
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 อื่น
ทุกอย่างที่ผ่านมาคือ UGUI ระบบ UI อีกตัวของ Unity คือ UI Toolkit สร้าง UI ด้วยวิธีที่ต่างออกไป: แทนที่จะเป็น GameObject หนึ่งตัวต่อหนึ่ง element มันใช้ tree เบา ๆ ของ object C# ธรรมดาที่เรียกว่า VisualElement อธิบายแบบ declarative ผ่านไฟล์ markup — คล้ายกับการสร้างเว็บเพจจาก HTML กับ CSS มาก
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 (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);
}
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 — เป็นหนึ่งในความต่างด้านชื่อเล็ก ๆ น้อย ๆ หลายจุดที่คุณจะเจอตอนย้ายไปมาระหว่างสองระบบนี้
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# เล็ก ๆ เบา ๆ ที่ไม่มีภาระพวกนั้นเลย
GameObject/Transform ที่คุณมีอยู่แล้วโดยตรง ค่อยไปเรียน UI Toolkit ตอนที่คล่องแล้ว โดยเฉพาะกับหน้าจอที่เริ่มสังเกตเห็นปัญหา performance ของ UI (หัวข้อ 11)ตอนนี้คุณสร้างและกด 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 ตรง ๆ ถึงกันเลย
คุณไม่จำเป็นต้องมี framework ที่มีชื่อตายตัวเพื่อให้ได้ประโยชน์นี้ — HealthBarView ข้างบนก็ทำหน้าที่เป็น Presenter เล็ก ๆ อยู่แล้ว นิสัยที่สำคัญมีแค่: คลาส game-state ยิง event เวลามันเปลี่ยน และมีแค่คลาส UI เท่านั้นที่แตะ UI component และทั้งสองฝั่งจะเจอกันผ่านการ subscribe event เท่านั้น ไม่ใช่สคริปต์ UI เอื้อมไปอ่าน (หรือสคริปต์ game-logic เอื้อมไปเขียน) อีกฝั่งตรง ๆ เลย
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 เล็ก ๆ ข้าง ๆ กระดิกขึ้น คืองานที่เสียเปล่าซ้ำ ๆ หลายสิบครั้งต่อวินาที
การแนบ 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 เล็ก ๆ ของตัวเองแทน
Transform บน UI element เก็บ anchor, pivot, anchored position และขนาดScreen.safeArea)Button.onClick)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 นี้ภายใน Canvasanchor 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 ของมันคือตัวมุมเอง
ScaleWithScreenSize, reference resolution 1920x1080 และ matchWidthOrHeight = 1 (match height อย่างเดียว) เกมรันบนมือถือที่ 1170x2532 (ก) scale factor เท่าไหร่? (ข) button ที่ RectTransform width เป็น 300 reference unit จะกว้างกี่ pixel จริงบนมือถือนี้? (ค) ในหน่วย reference unit มือถือนี้กว้างเท่าไหร่จริง ๆ ที่มองเห็นได้? ตัวเลขนั้นบอกอะไรเกี่ยวกับการออกแบบที่ 1920 กว้าง ตอนที่ match = 1?(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 แล้วเชื่อมันเฉย ๆ
.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 กระสุนอย่างเดียว(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 แล้วแยกมันออกจากกันเมื่อต้นทุนนั้นเริ่มแสดงผลจริง ๆ