4.2 เขียน C# ใน Unity

เฟส 4 · Unity (เอนจินหลัก) · เวลาเรียน: 60–100 h

วงจร MonoBehaviour (Awake, Start, Update, FixedUpdate), coroutine, event และ ScriptableObject — เขียนพฤติกรรมจริงของเกม

ตอนนี้คุณรู้จัก C# แบบเดี่ยว ๆ แล้ว ทั้ง class, object, generic, collection แต่ Unity ไม่ได้รัน class C# ธรรมดาเป็น script — มันรัน class ที่ inherit มาจาก base class พิเศษชื่อ MonoBehaviour บทนี้จะพาไปดูว่า MonoBehaviour ให้อะไรเราบ้าง: method ที่ Unity เรียกให้อัตโนมัติในจังหวะที่แน่นอน (เรียกว่า "lifecycle") field ที่กลายเป็นช่องแก้ไขได้ใน Editor และเครื่องมือพื้นฐานที่ script เกือบทุกตัวใน Unity ต้องใช้ — การ spawn object การอ่าน input และการรันโค้ดที่กินเวลาหลายวินาทีแทนที่จะจบในบรรทัดเดียว

1. MonoBehaviour: Base Class ที่ Script ทุกตัวใช้

script ทุกตัวที่แปะไว้บน GameObject (object ที่วางอยู่ใน scene ของ Unity) คือ class C# ธรรมดา ๆ ยกเว้นอย่างเดียวคือมัน inherit มาจาก MonoBehaviour ซึ่งเป็น class ที่ Unity เตรียมไว้ให้ การ inherit จาก MonoBehaviour ทำให้เกิดสองอย่าง: หนึ่งคือลาก script ไปแปะบน GameObject ใน Editor ได้เลย สองคือ class ของเราจะได้ method พิเศษชุดหนึ่งที่ Unity เรียกให้อัตโนมัติ โดยที่เราไม่ต้องเรียกเองเลย

using UnityEngine;

public class Player : MonoBehaviour
{
    // This class does nothing extra yet, but because it inherits
    // from MonoBehaviour, Unity already treats it as a "live" script:
    // you can drag this file onto any GameObject in the Hierarchy.
}

เกิดอะไรขึ้น: ตอนนี้ยังไม่มีอะไร print ออก Console เพราะ body ของ class ว่างเปล่า แต่ใน Editor script ตัวนี้จะโผล่เป็น component ที่แปะได้แล้ว: เลือก GameObject แล้วลาก Player.cs ไปวางบน Inspector panel ของมัน Unity จะสร้าง instance ของ class นี้ขึ้นมาหนึ่งตัวให้ object นั้น จากจุดนี้ไป Unity เป็นคนคุมทั้งหมด ทั้งสร้าง รัน และสุดท้ายทำลาย instance นั้น เราไม่ต้องเขียน new Player() เองเลย

เทียบกับ class C# ธรรมดาที่เคยเขียนมาก่อน เช่น helper คำนวณเลขเล็ก ๆ หรือ node ของ linked-list พวกนั้นเราสร้างเองด้วย new และไม่มีใครเรียก method ของมันถ้าเราไม่เรียกเอง แต่ MonoBehaviour ต่างออกไป พอมันถูกแปะอยู่บน GameObject ที่อยู่ใน scene ที่กำลังรัน engine loop ของ Unity จะเรียก method บางตัวของมันอัตโนมัติ ในจังหวะที่กำหนดไว้แน่นอนทุก ๆ เฟรม method พวกนั้นคือเนื้อหาหลักของบทนี้

เคล็ดลับ GameObject คือ container เฉย ๆ มีชื่อ ตำแหน่ง/การหมุน/ขนาด (เรียกว่า Transform) และ list ของ component ที่แปะอยู่ script ที่เป็น MonoBehaviour ก็คือ component ชนิดหนึ่ง GameObject ตัวเดียวมี component ได้หลายตัวพร้อมกัน เช่น Rigidbody สำหรับ physics, Collider สำหรับการชน, script ของเราเอง และอื่น ๆ

2. Lifecycle ของ Script: ภาพรวม

เพราะ Unity เป็นคนเรียก method ให้เรา เราจึงต้องรู้ให้ชัดว่ามี method อะไรบ้าง และแต่ละตัวรันตอนไหน รวม ๆ กันเรียกว่า "lifecycle" ของ script คือลำดับเหตุการณ์ตั้งแต่ object ถูกสร้างจนถูกทำลาย ถ้าจำลำดับผิด คุณจะเจอบั๊กที่โผล่มาเป็นบางครั้งเท่านั้น เช่น โค้ดที่อ่านค่าก่อนที่ script อีกตัวจะทันตั้งค่าให้

ภาพรวมทั้งหมด

Object created / scene loads | v Awake() (เรียกครั้งเดียว - ตั้งค่าภายใน, cache reference) | v OnEnable() (เรียกตอนนี้หนึ่งครั้ง แล้วเรียกอีกทุกครั้งที่ enable ใหม่) | v Start() (เรียกครั้งเดียว - ก่อนเฟรมแรกของ object นี้) | v ================================ PER-FRAME LOOP (วนซ้ำทุกเฟรม) ================================ | v FixedUpdate() / Update() / LateUpdate() -- ดู diagram ถัดไป | v (วนกลับไปบนสุดของ PER-FRAME LOOP ในเฟรมถัดไป) | v OnDisable() (object ถูกปิด หรือก่อนจะถูกทำลาย) | v OnDestroy() (object ถูกทำลาย - เก็บกวาดครั้งสุดท้าย)

ทุกตัวข้างบนคือแค่ชื่อ method ที่คุณเขียนไว้ใน class ของตัวเอง ไม่จำเป็นต้องมีครบทุกตัว — Unity เรียกเฉพาะตัวที่คุณเขียนไว้จริง ๆ script ที่ไม่มี method Update ก็ไม่มีใครมาเรียก Update ให้ ไม่มีต้นทุนอะไรถ้าจะไม่เขียนมัน

ซูมเข้าไปดูหนึ่งเฟรม

หนึ่งเฟรมที่ render ในมุมมองของ Unity: FixedUpdate() -- รัน 0, 1 หรือหลายครั้งในเฟรมนี้ (นาฬิกาคงที่: ทุก 0.02 วินาทีของเวลาเกม ตามค่า default คือ 50 ครั้งต่อวินาที) | v Update() -- รันครั้งเดียวพอดีต่อเฟรม (อ่าน input, logic ของเกม, ตัวจับเวลา, การเคลื่อนที่ที่ไม่ใช่ physics เอาไว้ตรงนี้) | v LateUpdate() -- รันครั้งเดียวพอดีต่อเฟรม หลังจาก Update() | ของทุก object รันจบไปแล้ว v Unity render เฟรมออกจอ

หัวข้อถัด ๆ ไปจะพา method แต่ละตัวมาดูทีละตัว พร้อมโค้ดที่รันได้จริง

3. Awake, OnEnable, และ Start: ลำดับการตั้งค่า

สาม method นี้ล้วนรันครั้งเดียวช่วงต้นชีวิตของ object แต่รันคนละจังหวะกัน และการสับสนลำดับของมันคือบั๊กที่มือใหม่เจอบ่อยมาก

Awake()

Awake รันครั้งเดียว เร็วที่สุดเท่าที่จะเป็นไปได้ — ตอน object ถูกสร้าง (ตอน scene โหลด หรือตอนมีใครเรียก Instantiate ระหว่างเกมรัน) มันรันแม้ checkbox enabled ของ script เองจะไม่ได้ติ๊กไว้ใน Inspector ก็ตาม ใช้มันสำหรับการตั้งค่าที่ขึ้นกับตัว object เองเท่านั้น เช่น การ cache component reference (หัวข้อ 9 พูดถึงเรื่องนี้) การตั้งค่าเริ่มต้นภายใน

OnEnable()

OnEnable รันครั้งเดียวทันทีหลัง Awake แล้วก็รันอีกทุกครั้งที่ object กลับมา active หลังจากเคยถูกปิด — เช่น script อื่นเรียก gameObject.SetActive(true) หลังจากที่มันเคยถูกตั้งเป็น false ใช้มันสำหรับอะไรก็ตามที่อยากให้ reset ทุกครั้งที่ object กลับมาเปิดใหม่ เช่นการ subscribe event

Start()

Start รันครั้งเดียว ก่อน Update ครั้งแรกของ object นี้ ความต่างสำคัญจาก Awake คือ Unity จะเรียก Awake ของ object ทุกตัวใน scene ให้ครบก่อน แล้วค่อยเริ่มเรียก Start ของตัวไหนก็ตาม นั่นแปลว่าใน Start คุณมั่นใจได้เลยว่า Awake ของ object อื่นทุกตัวรันไปแล้ว — มีประโยชน์เวลา script หนึ่งต้องอ่านค่าที่ script อีกตัวเพิ่งตั้งไว้ใน Awake ของมันเอง

using UnityEngine;

public class LifecycleLogger : MonoBehaviour
{
    void Awake()
    {
        Debug.Log("Awake");
    }

    void OnEnable()
    {
        Debug.Log("OnEnable");
    }

    void Start()
    {
        Debug.Log("Start");
    }
}

เกิดอะไรขึ้น: แปะ script นี้ลงบน GameObject ตัวไหนก็ได้แล้วกด Play Console จะพิมพ์ตามลำดับนี้เป๊ะ ๆ:

Awake
OnEnable
Start

ลำดับนี้ — Awake แล้ว OnEnable แล้ว Start — ไม่เปลี่ยนแปลงเลยสำหรับ object ปกติที่เริ่ม active อยู่ใน scene ตั้งแต่ต้น ควรจำไว้ให้ขึ้นใจ

ข้อผิดพลาดที่เจอบ่อย ถ้าตัว GameObject เองถูกปิดอยู่ใน Hierarchy (checkbox ข้างชื่อไม่ได้ติ๊กไว้) ตอน scene โหลด ทั้งสาม method นี้จะยังไม่รันเลย — แม้แต่ Awake ก็ไม่รัน — เพราะ GameObject ที่ปิดอยู่คือหลับสนิท ทั้งสามจะรันพร้อมกันทันทีที่มีใครเรียก SetActive(true) ให้มัน ตรงนี้ต่างจาก checkbox enabled ของ component เอง: กรณีนั้น Awake ยังรันทันที แต่ OnEnable กับ Start จะรอจนกว่า component จะถูก enable

4. Update: รันทุกเฟรม

Update คือ method ที่คุณจะใช้บ่อยที่สุด Unity เรียกมันครั้งเดียวต่อเฟรมที่ render — และแต่ละเฟรมใช้เวลาไม่เท่ากัน เครื่อง PC เกมมิ่งแรง ๆ อาจ render ได้ 300 เฟรมต่อวินาที ส่วนมือถือตอนโหลดหนัก ๆ อาจ render ได้แค่ 20 เฟรม Update รันครั้งเดียวทุกครั้ง ไม่ว่าเฟรมนั้นจะใช้เวลานานแค่ไหน

using UnityEngine;

public class Spinner : MonoBehaviour
{
    void Update()
    {
        transform.Rotate(0f, 2f, 0f); // turn 2 degrees every call to Update
    }
}

เกิดอะไรขึ้น: แปะ script นี้ลงบนลูกบาศก์แล้วกด Play — มันจะหมุน แต่ลองสังเกตดี ๆ: บนเครื่องที่ render ได้ 60 เฟรมต่อวินาที Update ถูกเรียก 60 ครั้งต่อวินาที ลูกบาศก์เลยหมุน 60 x 2 = 120 องศาต่อวินาที บนเครื่องที่ช้ากว่าซึ่ง render ได้แค่ 30 เฟรมต่อวินาที โค้ดชุดเดียวกันเป๊ะกลับหมุนแค่ 30 x 2 = 60 องศาต่อวินาที script เดียวกันวิ่งด้วยความเร็วที่ตาเห็นต่างกัน ขึ้นอยู่กับว่าเครื่องแรงแค่ไหน นั่นคือบั๊ก — หัวข้อ 7 จะแก้ให้

ตอนนี้จำไว้ก่อนว่าอะไรควรอยู่ใน Update: การอ่าน input, logic ของเกม, ตัวจับเวลา, อะไรก็ตามที่ไม่ใช่ physics ส่วน physics ไปอยู่ใน method ถัดไป

5. FixedUpdate: Physics บนนาฬิกาคงที่

FixedUpdate ต่างจาก Update ตรงจุดสำคัญจุดหนึ่ง: มันไม่ได้รันครั้งเดียวต่อเฟรมที่ render มันรันบนนาฬิกาของตัวเองที่คงที่ ตาม default คือทุก 0.02 วินาทีของเวลาเกม — นั่นคือ 50 ครั้งต่อวินาทีพอดี — ไม่ว่าเครื่องจะ render เร็วหรือช้าแค่ไหนก็ตาม physics engine ของ Unity (Rigidbody, collider, แรงต่าง ๆ) ต้องการ timestep ที่นิ่งและคาดเดาได้แบบนี้เพื่อให้แม่นยำและสม่ำเสมอ ดังนั้นอะไรก็ตามที่เกี่ยวกับ physics ควรอยู่ตรงนี้ ไม่ใช่ใน Update

using UnityEngine;

public class ConstantThrust : MonoBehaviour
{
    public float thrust = 10f;
    Rigidbody rb;

    void Awake()
    {
        rb = GetComponent<Rigidbody>();
    }

    void FixedUpdate()
    {
        rb.AddForce(Vector3.forward * thrust);
    }
}

เกิดอะไรขึ้น: แปะ script นี้ลงบน object ที่มี component Rigidbody แล้วกด Play — มันจะเร่งความเร็วไปข้างหน้าอย่างนุ่มนวล เพราะ FixedUpdate รันบนนาฬิกาของตัวเอง มันจึงไม่ได้ตรงกับเฟรมที่ render แบบหนึ่งต่อหนึ่งเสมอไป: ที่ 50 FPS คงที่ มันตรงกันเกือบพอดีหนึ่งครั้งต่อเฟรม ที่ 100 FPS ประมาณครึ่งหนึ่งของเฟรมจะไม่มี FixedUpdate เรียกเลย ที่ 20 FPS Unity จะเรียก FixedUpdate สองหรือสามครั้งติดกันเพื่อ "ตามให้ทัน" ก่อนจะวาดเฟรมถัดไป นี่คือ "0, 1 หรือหลายครั้ง" จาก diagram ในหัวข้อ 2 นั่นเอง

เคล็ดลับ คุณเปลี่ยนความถี่ของ FixedUpdate ได้จาก Project Settings หมวด Time ที่ช่อง Fixed Timestep ค่า default 0.02 วินาทีคือจุดสมดุลที่ดีระหว่างความแม่นยำของ physics กับต้นทุน CPU สำหรับเกมส่วนใหญ่

6. LateUpdate: รอบเก็บกวาด

LateUpdate ก็รันครั้งเดียวต่อเฟรมเหมือนกัน แต่รันหลัง Update ของ object ทุกตัวจบไปแล้วเสมอสำหรับเฟรมนั้น Unity ไม่การันตีว่า Update ของ script คนละตัวจะรันตามลำดับไหน — script เคลื่อนที่ผู้เล่นกับ script กล้องของคุณอาจรันก่อนหลังสลับกันไปในแต่ละเฟรมก็ได้ LateUpdate มีไว้แก้ปัญหานี้พอดี สำหรับกรณีอย่างกล้องที่ต้องตอบสนองตามตำแหน่งสุดท้ายของผู้เล่นเสมอ ไม่ใช่ตำแหน่งเมื่อครู่

using UnityEngine;

public class CameraFollow : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -10f);

    void LateUpdate()
    {
        transform.position = target.position + offset;
    }
}

เกิดอะไรขึ้น: แปะ script นี้ลงบนกล้องหลัก (Main Camera) ลาก Transform ของผู้เล่นไปใส่ target แล้วกด Play — กล้องจะอยู่ด้านหลังและด้านบนผู้เล่นอย่างนุ่มนวล ถ้าโค้ดนี้รันใน Update แทน จะมีบางเฟรมที่กล้องบังเอิญอัปเดตก่อนผู้เล่นขยับ ทำให้เห็นตำแหน่งเก่าของผู้เล่นอยู่หนึ่งเฟรม — อาการกระตุกเล็ก ๆ ที่เห็นได้ชัดโดยเฉพาะตอนเฟรมเรตต่ำ การรันใน LateUpdate การันตีว่าผู้เล่นขยับเสร็จแล้วในเฟรมนี้ก่อนที่กล้องจะก็อปตำแหน่งไป

7. Time.deltaTime: การเคลื่อนที่ที่ไม่ขึ้นกับเฟรมเรต

หัวข้อ 4 โชว์บั๊กของลูกบาศก์หมุนไปแล้ว: โค้ดชุดเดียวกันเป๊ะทำให้ object หมุนด้วยความเร็วจริงต่างกันตามเฟรมเรต ทางแก้คือ Time.deltaTime — ตัวเลขที่ Unity ส่งให้ทุกเฟรม เท่ากับจำนวนวินาทีจริงที่ผ่านไปตั้งแต่เฟรมก่อนหน้า (เป็นตัวเลขเล็ก ๆ ประมาณ 0.0166 ที่ 60 FPS ประมาณ 0.0333 ที่ 30 FPS)

using UnityEngine;

public class Spinner : MonoBehaviour
{
    public float degreesPerSecond = 120f;

    void Update()
    {
        transform.Rotate(0f, degreesPerSecond * Time.deltaTime, 0f);
    }
}

เกิดอะไรขึ้น: ตอนนี้ลูกบาศก์จะหมุน 120 องศาทุกวินาทีจริงเสมอ ไม่ว่าเฟรมเรตจะเป็นเท่าไหร่

ไม่ใช้ Time.deltaTime (หมุนคงที่ 2 องศาทุกครั้งที่ Update ถูกเรียก): 60 FPS: 60 ครั้ง/วินาที x 2 องศา = 120 องศาต่อวินาที 30 FPS: 30 ครั้ง/วินาที x 2 องศา = 60 องศาต่อวินาที (ช้าลง!) ใช้ Time.deltaTime (หมุน speed * deltaTime ทุกครั้งที่ Update ถูกเรียก): 60 FPS: deltaTime ~ 0.0166 วิ, 60 ครั้ง/วิ x (120 x 0.0166) ~ 120 องศา/วิ 30 FPS: deltaTime ~ 0.0333 วิ, 30 ครั้ง/วิ x (120 x 0.0333) ~ 120 องศา/วิ (ความเร็วเท่ากันทั้งคู่!)

เคล็ดลับคือ: "จำนวนคงที่ต่อครั้งที่เรียก" จะกลายเป็น "จำนวนคงที่ต่อวินาที" ทันทีที่คุณคูณมันด้วยจำนวนวินาทีจริงที่การเรียกครั้งนั้นครอบคลุม เฟรมที่ใช้เวลานานเป็นสองเท่าจะได้ deltaTime ที่ใหญ่เป็นสองเท่า มันเลยขยับ object ไปไกลเป็นสองเท่าเพื่อชดเชย — ระยะทางรวมที่เคลื่อนที่ต่อวินาทีจริงจึงเท่าเดิมเสมอ

นี่คือแนวคิดเดียวกันเป๊ะกับที่คุณเคยใช้ตอน interpolate ระหว่างค่าสองค่า คือผสมจากค่าเริ่มต้นไปค่าปลายทางด้วยเศษส่วน t ระหว่าง 0 กับ 1 ตรงนั้น t ต้องโตขึ้นด้วยอัตราคงที่ตามเวลาจริง ไม่ใช่ตามจำนวนเฟรม ไม่งั้นการผสมค่าจะเร็วขึ้นช้าลงตามเฟรมเรตไปด้วย Time.deltaTime คือวิธีสร้างการโตขึ้นแบบคงที่นั้น: ทุกเฟรมคุณบวกเศษเวลาจริงเล็ก ๆ เข้าไป t += Time.deltaTime / duration แทนที่จะบวกเศษคงที่ต่อการเรียกหนึ่งครั้ง การเคลื่อนที่ที่ไม่ขึ้นกับเฟรมเรตและการ interpolate ที่ไม่ขึ้นกับเฟรมเรตแก้ด้วยกฎเดียวกัน: อย่าสมมติเองว่าหนึ่ง "ก้าว" กินเวลาเท่าไหร่ — ถาม Time.deltaTime เสมอว่าเวลาผ่านไปจริงเท่าไหร่

เคล็ดลับ FixedUpdate ไม่ต้องใช้เทคนิคนี้กับแรง physics เพราะ timestep ของมันคงที่และคาดเดาได้อยู่แล้ว — นั่นคือเหตุผลทั้งหมดที่มันมีอยู่ คุณจะยังเห็น Time.fixedDeltaTime ถูกใช้ใน FixedUpdate บ้างในบางกรณี แต่สำหรับการเรียก AddForce ธรรมดาแบบในหัวข้อ 5 physics engine ของ Unity จัดการเรื่อง fixed timestep ให้คุณอยู่แล้ว

8. เปิด Field ให้ Inspector เห็น: public กับ [SerializeField]

Unity แปลง field บางตัวของ MonoBehaviour ให้กลายเป็นช่องแก้ไขได้ใน Inspector panel โดยอัตโนมัติ — ไม่ต้องเขียนโค้ด UI เพิ่มเลย มีสองวิธีทำแบบนี้:

using UnityEngine;

public class Health : MonoBehaviour
{
    public int maxHealth = 100;

    [SerializeField]
    private int currentHealth = 100;
}

เกิดอะไรขึ้น: เลือก GameObject ที่แปะ script นี้ Inspector จะโชว์สองช่องที่แก้ไขได้คือ "Max Health" กับ "Current Health" (Unity แปลงชื่อ field แบบ camelCase ให้เป็น label อ่านง่ายให้เองอัตโนมัติ) เริ่มต้นทั้งคู่ที่ 100 แก้ไขได้ด้วยมือหรือโดยดีไซเนอร์โดยไม่ต้องแตะโค้ดเลย

ความต่างระหว่างสองแบบนี้โผล่ออกมาข้างนอก Inspector ใน script อื่น:

เคล็ดลับ ให้ตั้งเป็น [SerializeField] private ไว้ก่อนเป็นค่าเริ่มต้น จะทำ field ให้เป็น public ก็ต่อเมื่อ script อื่นต้องอ่านหรือเขียนมันตรง ๆ จริง ๆ เท่านั้น — และถึงตอนนั้น public method (เช่น TakeDamage(int amount)) ก็มักจะปลอดภัยกว่า public field ตรง ๆ เพราะมันให้คุณควบคุมได้ว่าค่าจะเปลี่ยนได้แบบไหนบ้าง

9. GetComponent และการ Cache Reference

GetComponent<T>() ค้นหา component ชนิด T บน GameObject แล้วคืนค่ามันกลับมา (หรือ null ถ้าไม่มี) มันคือวิธีที่ script หนึ่งเข้าถึง component อีกตัวที่อยู่บน object เดียวกัน — เช่น script เคลื่อนที่ที่ต้องเข้าถึง Rigidbody ของ object นั้น

ปัญหา

using UnityEngine;

public class BadMover : MonoBehaviour
{
    void Update()
    {
        // Do not do this: GetComponent searches all over again,
        // every single frame, for a component that never changes.
        GetComponent<Rigidbody>().AddForce(Vector3.up);
    }
}

เกิดอะไรขึ้น: โค้ดนี้ทำงานถูกต้อง แต่ GetComponent ไม่ใช่ของฟรีที่เร็วทันที มันไล่ดู list ของ component ที่แปะอยู่บน GameObject เทียบ type ทีละตัวจนกว่าจะเจอที่ตรงกัน การทำแบบนั้น 60 ครั้งขึ้นไปต่อวินาที เพื่อผลลัพธ์ที่เหมือนเดิมเป๊ะทุกครั้ง คือการเสียเวลา CPU ที่ควรเอาไปใช้กับอะไรที่เปลี่ยนแปลงจริง ๆ ในแต่ละเฟรม

ทางแก้: cache ไว้ใน Awake

using UnityEngine;

public class GoodMover : MonoBehaviour
{
    Rigidbody rb; // cached reference, found once

    void Awake()
    {
        rb = GetComponent<Rigidbody>(); // search happens ONCE, here
    }

    void Update()
    {
        rb.AddForce(Vector3.up); // reuse the stored reference, no searching
    }
}

เกิดอะไรขึ้น: พฤติกรรมในเกมเหมือนเดิมทุกอย่าง แต่การค้นหาจะรันแค่ครั้งเดียวใน Awake (ซึ่งหัวข้อ 3 บอกไว้แล้วว่ารันก่อน Update เสมอ) ทุกเฟรมถัดไปแค่ใช้ field rb ที่เก็บไว้ซ้ำ ซึ่งแทบไม่มีต้นทุนอะไรเลย

ข้อผิดพลาดที่เจอบ่อย การเรียก GetComponent ใน Update คือความผิดพลาดด้านประสิทธิภาพที่มือใหม่เจอบ่อยที่สุดอย่างหนึ่ง มันไม่มีผลอะไรใน scene ทดสอบเล็ก ๆ ที่มี object เดียว แต่ต้นทุนจะบวกเพิ่มขึ้นเร็วมากทันทีที่ scene มี object หลายร้อยตัวที่ทำแบบนี้ทุกเฟรม cache ไว้ใน Awake (หรือ Start) แล้วใช้ field ที่ cache ไว้ทุกที่หลังจากนั้น

10. Instantiate และ Destroy: การ Spawn และลบ Object

เกมส่วนใหญ่สร้างและลบ object ตลอดเวลาระหว่างที่รัน — กระสุน ศัตรู ไอเทมเก็บได้ เอฟเฟกต์ระเบิด Instantiate สร้างสำเนาใหม่ของ object ที่มีอยู่แล้วขึ้นมาระหว่างเกมรัน Destroy ลบ object ออก ทั้งคู่ทำงานบน template ที่เรียกว่า prefab: GameObject ที่คุณตั้งค่าไว้ครั้งเดียวใน Editor (โมเดล, script, component ครบทุกอย่าง) แล้วเซฟเป็น asset ที่ใช้ซ้ำได้ใน Project window เพื่อให้คุณปั๊มสำเนาออกมาได้เท่าที่ต้องการระหว่างเกมรัน

using UnityEngine;

public class Gun : MonoBehaviour
{
    public GameObject bulletPrefab; // drag the Bullet prefab here in the Inspector
    public Transform muzzle;        // an empty child Transform at the gun's tip

    void Update()
    {
        if (Input.GetButtonDown("Fire1"))
        {
            GameObject bullet = Instantiate(bulletPrefab, muzzle.position, muzzle.rotation);
            Destroy(bullet, 3f); // remove this exact bullet after 3 seconds
        }
    }
}

เกิดอะไรขึ้น: ทุกครั้งที่ปุ่ม Fire1 (ปุ่มซ้ายของเมาส์ตาม default) ถูกกด Instantiate จะสร้างสำเนาใหม่ของ prefab กระสุนขึ้นมาหนึ่งตัวใน scene วางตำแหน่งและหมุนตรงตาม muzzle เป๊ะ กระสุนตัวใหม่นั้นเป็น GameObject ที่เป็นอิสระจากกันโดยสิ้นเชิงตั้งแต่จุดนั้นไป — ขยับมันไม่ทำให้ prefab asset ขยับตาม และยิงอีกครั้งก็สร้างสำเนาแยกอีกตัวขึ้นมา สามวินาทีต่อมา Destroy(bullet, 3f) จะลบกระสุนตัวนั้นออกจาก scene ให้อัตโนมัติ

Destroy(bullet) เฉย ๆ โดยไม่มี delay ไม่ได้ลบ object ทันทีตรงบรรทัดนั้นเลย — มันแค่ทำเครื่องหมายไว้ว่าจะลบตอนจบเฟรมปัจจุบัน ความล่าช้าเล็กน้อยนี้ตั้งใจให้เป็นแบบนั้น: โค้ดอื่นที่เผลอถือ reference ของ bullet ไว้ก่อนหน้านี้ในเฟรมเดียวกันจะไม่จู่ ๆ เจอ object ที่ถูกลบไปครึ่งหนึ่ง

ข้อผิดพลาดที่เจอบ่อย ลืม destroy object ที่ spawn ไว้คือสาเหตุคลาสสิกที่ทำให้เกมค่อย ๆ อืดลงเรื่อย ๆ: กระสุน เอฟเฟกต์ หรือศัตรูกองพะเนินอยู่ตลอดไปเพราะไม่มีใครเรียก Destroy ให้มันเลย ทุกครั้งที่เขียนคำสั่ง Instantiate ให้ถามตัวเองทันทีว่า object นี้จะถูก destroy เมื่อไหร่และยังไง

11. การอ่าน Input จากผู้เล่น

class Input แบบดั้งเดิมของ Unity คือวิธีง่ายที่สุดในการอ่านคีย์บอร์ด เมาส์ และเกมแพด สาม method นี้ครอบคลุมการใช้งานส่วนใหญ่:

using UnityEngine;

public class TopDownMover : MonoBehaviour
{
    public float speed = 5f;

    void Update()
    {
        float h = Input.GetAxis("Horizontal"); // -1 (A / Left) to +1 (D / Right)
        float v = Input.GetAxis("Vertical");   // -1 (S / Down) to +1 (W / Up)

        Vector3 move = new Vector3(h, 0f, v) * speed * Time.deltaTime;
        transform.Translate(move);
    }
}

เกิดอะไรขึ้น: กดค้าง D ทำให้ object ขยับไปทาง X บวก กดค้าง W ทำให้ขยับไปทาง Z บวก กดพร้อมกันสองปุ่มก็ขยับในแนวทแยง — ทั้งหมดที่ความเร็วคงที่ speed หน่วยต่อวินาที ด้วยเทคนิค Time.deltaTime เดียวกันกับหัวข้อ 7

Unity ยังมี "Input System" package รุ่นใหม่ที่ยืดหยุ่นกว่า เหมาะกับการรีแมปปุ่มและผู้เล่นหลายคน แต่ class Input แบบดั้งเดิมที่โชว์ตรงนี้ไม่ต้องตั้งค่าเพิ่มอะไรเลย และเพียงพอสำหรับสร้างและเข้าใจทุกอย่างในหนังสือเล่มนี้

12. Coroutine: การทำอะไรบางอย่างที่กินเวลา

ทุกอย่างที่ผ่านมารันบนลงล่างภายในเฟรมเดียว บางครั้งคุณอยากให้โค้ดกินเวลาหลายวินาที — เอฟเฟกต์กะพริบ ดีเลย์ก่อนศัตรูโจมตี ตัวนับถอยหลัง — โดยไม่หยุดเกมทั้งเกมระหว่างที่รอ นั่นคือหน้าที่ของ coroutine: method ที่หยุดกลางคันได้แล้วกลับมาทำต่อทีหลังด้วยตัวเอง ในขณะที่ทุกอย่างอื่นยังรันตามปกติ

using UnityEngine;
using System.Collections;

public class Blinker : MonoBehaviour
{
    public Renderer targetRenderer;

    void Start()
    {
        StartCoroutine(Blink());
    }

    IEnumerator Blink()
    {
        while (true)
        {
            targetRenderer.enabled = false;
            yield return new WaitForSeconds(0.2f);
            targetRenderer.enabled = true;
            yield return new WaitForSeconds(0.2f);
        }
    }
}

เกิดอะไรขึ้น: Start เรียก StartCoroutine(Blink()) ครั้งเดียว ซึ่งเริ่มรัน Blink renderer จะปิดตัวลง แล้ว yield return new WaitForSeconds(0.2f) หยุด เฉพาะ method นี้เท่านั้น ไว้ 0.2 วินาทีจริง — ส่วนที่เหลือของเกม รวมถึง Update ของ object ตัวนี้เองถ้ามี ยังรันตามปกติตลอดเวลานั้น พอครบ 0.2 วินาที การทำงานจะกลับมาต่อที่บรรทัดถัดไปทันที เปิด renderer กลับมา รออีกครั้ง แล้ววนไปเรื่อย ๆ — กะพริบสม่ำเสมอ โดยไม่มีตัวแปรจับเวลาที่ต้องจัดการเองเลยสักตัว

method ที่คืนค่าเป็น IEnumerator คือสิ่งที่ทำให้ coroutine เป็นไปได้: แทนที่จะรันจบตั้งแต่ต้นจนจบในทีเดียวแบบ method ธรรมดา มันถูกสร้างมาให้ Unity หยุดมันได้ที่ทุก yield return แล้วกลับมาทำต่อตรงจุดนั้นทีหลัง คุณไม่เรียก Blink() ตรง ๆ แบบ method ธรรมดาเลย — ต้องเรียกผ่าน StartCoroutine เสมอ ซึ่งส่งมอบการควบคุมเรื่องหยุดและทำต่อให้ Unity จัดการแทน

สิ่งที่มักใช้กับ yield return บ่อย ๆ:

เคล็ดลับ coroutine ที่เริ่มบน GameObject จะหยุดอัตโนมัติถ้า GameObject นั้นถูก destroy หรือถ้า component ที่รันมันถูก disable คุณสั่งหยุดมันเองได้ด้วย StopCoroutine

13. ScriptableObject: Asset ข้อมูลที่ใช้ร่วมกันได้

ScriptableObject คือ base class อีกตัวที่ Unity เตรียมไว้ให้ แนวคิดคล้าย MonoBehaviour แต่สร้างมาสำหรับข้อมูล ไม่ใช่พฤติกรรม ScriptableObject ไม่ได้แปะอยู่บน GameObject ใน scene — มันอยู่เป็น asset file ของตัวเองใน Project window เหมือนกับ texture หรือ audio clip

using UnityEngine;

[CreateAssetMenu(fileName = "NewWeaponData", menuName = "Data/Weapon Data")]
public class WeaponData : ScriptableObject
{
    public string weaponName = "Sword";
    public int damage = 10;
    public float attacksPerSecond = 1.5f;
}

เกิดอะไรขึ้น: [CreateAssetMenu(...)] เพิ่มรายการเข้าไปในเมนู "Create" ตอนคลิกขวาใน Project window คลิกมันแล้วจะได้ไฟล์ .asset จริง ๆ ขึ้นมา สมมติชื่อ Sword.asset ที่ดีไซเนอร์เปิดแก้ไขใน Inspector ได้เหมือน object ทั่วไปเลย กรอก weaponName, damage, และ attacksPerSecond ได้โดยไม่ต้องแตะโค้ดเลย

using UnityEngine;

public class Weapon : MonoBehaviour
{
    [SerializeField] private WeaponData data;

    public void Attack()
    {
        Debug.Log(data.weaponName + " hits for " + data.damage);
    }
}

เกิดอะไรขึ้น: เพราะ data เป็น field แบบ [SerializeField] Inspector จะโชว์ช่องให้ลาก Sword.asset ที่สร้างไว้ข้างบนเข้ามาใส่ พอเรียก Attack() จะพิมพ์ "Sword hits for 10" โดยใช้ค่าที่เก็บไว้บน asset นั้นตอนนั้น

ผลตอบแทนจะเห็นชัดตอนที่มี object หลายตัวควรใช้ข้อมูลชุดเดียวกัน ลองนึกภาพศัตรูถือดาบห้าสิบตัวใน scene ถ้าใส่ damage กับ weaponName เป็น field ตรง ๆ บน MonoBehaviour ของศัตรูแต่ละตัว จะกลายเป็นตัวเลขชุดเดียวกันที่ก็อปไว้ห้าสิบชุดแยกกัน — เปลี่ยนค่า damage ของดาบ คุณต้องจำไปอัปเดตทั้งห้าสิบตัวด้วยมือ ไม่งั้นค่าจะเพี้ยนไม่ตรงกันแบบเงียบ ๆ ถ้าให้ศัตรูทั้งห้าสิบตัวชี้ field data ไปที่ Sword.asset ตัวเดียวกันแทน จะมีตัวเลขอยู่แค่ชุดเดียว: เปลี่ยนครั้งเดียว ศัตรูทุกตัวที่อ้างอิงถึงมันจะได้ค่าใหม่ทันที

อภิธานศัพท์ (Glossary)

แบบฝึกหัด (Exercises)

แบบฝึกหัด 1 เขียน script ชื่อ OrderLogger ที่ log คำว่า "Awake", "OnEnable", และ "Start" (แต่ละคำครั้งเดียว) ด้วย Debug.Log แล้ว log คำว่า "First Update" ตอน Update รันครั้งแรกเท่านั้น ไม่ log ซ้ำอีกเลยหลังจากนั้น แปะมันไว้บน GameObject ที่เริ่ม active อยู่ใน scene ก่อนรัน ให้เขียนลำดับที่คุณคาดว่าจะเห็นสี่บรรทัดนี้ปรากฏใน Console
เฉลย
using UnityEngine;

public class OrderLogger : MonoBehaviour
{
    private bool hasLoggedFirstUpdate = false;

    void Awake()
    {
        Debug.Log("Awake");
    }

    void OnEnable()
    {
        Debug.Log("OnEnable");
    }

    void Start()
    {
        Debug.Log("Start");
    }

    void Update()
    {
        if (!hasLoggedFirstUpdate)
        {
            Debug.Log("First Update");
            hasLoggedFirstUpdate = true;
        }
    }
}

Console จะพิมพ์ตามลำดับนี้:

Awake
OnEnable
Start
First Update

Awake กับ OnEnable ทำงานก่อนเฟรมแรกจะเริ่มด้วยซ้ำ Start ก็ทำงานก่อน Update ของเฟรมแรกเช่นกัน เพราะ Unity การันตีว่า Start รันก่อน Update ตัวไหนก็ตามของ object เดียวกันเสมอ per-frame loop เพิ่งเริ่มหลังจากนั้น "First Update" จึงมาเป็นลำดับสุดท้ายเสมอ

แบบฝึกหัด 2 script ข้างล่างนี้เคลื่อนที่ object ไปข้างหน้าและมีบั๊กสองอย่าง: มันเรียก GetComponent ทุกเฟรมแทนที่จะ cache ไว้ และมันขยับ object เป็นระยะทางคงที่เท่าเดิมทุกเฟรมแทนที่จะคูณด้วย Time.deltaTime ทำให้มันวิ่งเร็วไม่เท่ากันบนเครื่องต่างกัน ให้เขียนใหม่แก้ทั้งสองปัญหา
using UnityEngine;

public class BuggyMover : MonoBehaviour
{
    public float speed = 5f;

    void Update()
    {
        GetComponent<Rigidbody>().MovePosition(
            GetComponent<Rigidbody>().position + Vector3.forward * speed);
    }
}
เฉลย
using UnityEngine;

public class FixedMover : MonoBehaviour
{
    public float speed = 5f;
    private Rigidbody rb; // cached once, instead of searched every frame

    void Awake()
    {
        rb = GetComponent<Rigidbody>();
    }

    void Update()
    {
        rb.MovePosition(rb.position + Vector3.forward * speed * Time.deltaTime);
    }
}

สองจุดที่แก้แยกกัน: cache rb ไว้ใน Awake ทำให้ GetComponent รันแค่ครั้งเดียวแทนที่จะรันสองครั้งทุกเฟรม การคูณด้วย Time.deltaTime เปลี่ยน speed จาก "หน่วยที่ขยับต่อการเรียกหนึ่งครั้ง" ให้เป็น "หน่วยที่ขยับต่อวินาที" ทำให้ object วิ่งได้ระยะทางจริงต่อวินาทีเท่าเดิมไม่ว่าเฟรมเรตจะเป็นเท่าไหร่ — เอาทางแก้จากหัวข้อ 7 มาใช้กับตัวอย่างใหม่

แบบฝึกหัด 3 เขียน coroutine ชื่อ SpawnLoop ที่ตราบใด field bool แบบ public ชื่อ isSpawning ยังเป็น true ให้ instantiate enemyPrefab แบบ public ที่ตำแหน่งของ Transform แบบ public ชื่อ spawnPoint ทุก 2 วินาที เริ่ม coroutine ครั้งเดียวใน Start loop ควรหยุด spawn โดยไม่โยน error เมื่อไหร่ก็ตามที่ isSpawning กลายเป็น false และห้ามมี coroutine ตัวที่สองรันซ้อนพร้อมกันเด็ดขาด
เฉลย
using UnityEngine;
using System.Collections;

public class EnemySpawner : MonoBehaviour
{
    public GameObject enemyPrefab;
    public Transform spawnPoint;
    public bool isSpawning = true;

    void Start()
    {
        StartCoroutine(SpawnLoop());
    }

    IEnumerator SpawnLoop()
    {
        while (isSpawning)
        {
            Instantiate(enemyPrefab, spawnPoint.position, spawnPoint.rotation);
            yield return new WaitForSeconds(2f);
        }
    }
}

เงื่อนไข while (isSpawning) ถูกตรวจสอบทุกครั้งที่ coroutine ตื่นกลับมาหลัง WaitForSeconds ไม่ใช่แค่ครั้งเดียวตอนเริ่ม — ดังนั้นการตั้ง isSpawning = false จากที่ไหนก็ตามในโค้ด (script อื่น, ปุ่ม UI) จะเงียบ ๆ จบ loop นี้ในครั้งถัดไปที่มันตื่นมา โดยไม่ต้องเขียนโค้ดเพิ่มเพื่อ "ยกเลิก" อะไรเลย มี coroutine รันอยู่แค่ตัวเดียวเสมอ เพราะ StartCoroutine(SpawnLoop()) ถูกเรียกแค่ครั้งเดียวใน Start

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