9.2 Blend Tree และ State Machine

เฟส 9 · แอนิเมชัน · เวลาเรียน: 20–35 h

การ blend ระหว่างแอนิเมชันอย่างนุ่ม (เดินเป็นวิ่ง) และขับ transition ด้วย animation state machine

คุณรู้วิธีทำให้ Animator เล่นคลิปเดียวแล้ว เช่น Idle หรือ Run วิธีนั้นใช้ได้ดีตราบใดที่ตัวละครไม่เปลี่ยนสิ่งที่กำลังทำ แต่พอผู้เล่นกดเดินหน้าตอนตัวละครยืนนิ่ง ๆ อยู่ คุณจะเจอปัญหาทันที: จะไปจากท่า Idle สู่ท่า Run ยังไงโดยไม่ให้มันดูเหมือนตัดต่อวิดีโอห่วย ๆ ที่กระโดดข้ามเฟรม? คำตอบที่เกมแทบทุกเกมที่คุณเคยเล่นใช้ก็คือ engine ไม่เคย "สลับ" ทันทีจริง ๆ แต่มันจะ blend (ผสมผสาน) กัน บทนี้จะสอนว่า Animator ของ Unity ทำการ blend นี้ยังไง และคุณจะสั่งงานมันจาก C# ยังไง

1. ทำไมแทบไม่มีใครเล่นคลิปเดียวโดด ๆ

ถ้าคุณสลับคลิปด้วย animator.Play("Run") Unity จะทิ้งท่าปัจจุบันที่กำลังแสดงอยู่ แล้วกระโดดตรงไปเฟรม 0 ของ Run ในเฟรมถัดไปทันที ไม่มีอะไรอยู่ตรงกลางเลย ขาของตัวละครจะเทเลพอร์ตจากท่าหนึ่งไปอีกท่าหนึ่งภายในเฟรมเดียว

Frame N (Idle, standing still): character upright, arms relaxed Frame N+1 (Animator.Play("Run") called): character SNAPS to Run frame 0, mid-stride, one leg forward No frames in between. The pose jumps instantly. This sudden jump is usually called "popping."

วิธีแก้ที่เกมแทบทุกเกมใช้คือ blending: แทนที่จะสลับทันที คุณผสมท่า (pose) สองท่าหรือมากกว่าเข้าด้วยกันโดยใช้ blend weight (น้ำหนักการผสม คือตัวเลข 0 ถึง 1 ที่บอกว่าท่าของคลิปนั้นมีผลต่อผลลัพธ์สุดท้ายมากแค่ไหน) weight เท่ากับ 1.0 แปลว่า "100% คลิปนี้" 0.0 แปลว่า "0% คลิปนี้ ไม่นับเลย" ส่วน 0.5 แปลว่า "เฉลี่ยท่าของคลิปนี้กับอีกคลิปหนึ่ง 50/50 ทีละกระดูก"

Animator ของ Unity ให้เครื่องมือมาสามอย่างซึ่งจริง ๆ แล้วเป็นไอเดียเดียวกันหมด — blend ด้วย weight — แค่เอาไปใช้ในสถานการณ์ต่างกัน:

บทนี้จะสร้างทั้งสามอย่าง แล้วค่อยพาไปดู state machine ที่ตัดสินใจว่า state ไหน (คลิปหรือ blend tree) กำลัง active อยู่ และจะสั่งงานทั้งหมดนี้จากสคริปต์ C# ของคุณเองยังไง

2. Cross-Fade ระหว่างสองคลิป

การ blend แบบง่ายที่สุดคือ cross-fade: บอก Animator ว่า "เริ่มเล่น state นี้ แล้ว blend เข้าไปหามันภายใน N วินาที" Unity จะค่อย ๆ ลด weight ของ state เก่าและเพิ่ม weight ของ state ใหม่ทุกเฟรม จนกว่า state ใหม่จะมี weight ถึง 1.0 และครองท่าทั้งหมด

using UnityEngine;

public class SimpleCrossFade : MonoBehaviour
{
    public Animator animator;

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.R))
        {
            // Blend from whatever is playing right now into "Run"
            // over 0.25 seconds.
            animator.CrossFade("Run", 0.25f);
        }
    }
}

โค้ดนี้ไม่มี console output — มันเป็น effect ทางภาพที่คุณจะเห็นใน Game view แต่เราสามารถไล่ดูได้ว่า Unity ใช้ weight เท่าไรในแต่ละเฟรมระหว่างช่วง blend 0.25 วินาทีนั้น:

Blend of Walk -> Run over 0.25 seconds (CrossFade) time (s) 0.00 0.05 0.10 0.15 0.20 0.25 Walk weight 1.00 0.80 0.60 0.40 0.20 0.00 Run weight 0.00 0.20 0.40 0.60 0.80 1.00 t=0.00 Walk [##########] 1.0 Run [..........] 0.0 t=0.10 Walk [######....] 0.6 Run [####......] 0.4 t=0.25 Walk [..........] 0.0 Run [##########] 1.0 final_pose = Walk_pose * Walk_weight + Run_pose * Run_weight

"Pose" ในที่นี้หมายถึงตำแหน่งและการหมุนของทุกกระดูกในโครง rig ของตัวละคร (จากบทเรื่อง skinned mesh) ทุกเฟรม Unity จะ sample ท่าจากทั้งสองคลิป ณ เวลาปัจจุบัน คูณ transform ของแต่ละกระดูกด้วย weight ของคลิปนั้น แล้วบวกผลลัพธ์ทั้งสองเข้าด้วยกัน นี่คือเหตุผลที่การ blend ครึ่ง-ครึ่งระหว่างท่ายืนกับท่าวิ่งจะดูเหมือนท่ากึ่งกลางที่เป็นไปได้จริงตามธรรมชาติ — ไม่ใช่ตัวละครสองตัวซ้อนทับกันเหมือนผี

CrossFade(stateName, duration) รับชื่อ state ที่จะ blend เข้าไปหา และจำนวนวินาทีที่การ blend จะใช้ มีเมธอดที่เกี่ยวข้องอีกตัวคือ CrossFadeInFixedTime ซึ่งทำแบบเดียวกัน แต่วัดระยะเวลา transition เป็นวินาทีจริงไม่ว่าคลิปเป้าหมายจะยาวแค่ไหน (มีประโยชน์เวลาต้องการ timing ที่แม่นยำ เช่น sync กับเสียง sound effect)

Tip คุณเรียก CrossFade ซ้ำได้ก่อนที่ blend แรกจะจบ Unity ไม่บังคับให้รอ — มันจะเริ่ม blend จากท่าผสมที่กำลังแสดงอยู่บนจอตอนนั้นไปหาเป้าหมายใหม่เลย นี่คือสิ่งที่ทำให้คอมโบและการเปลี่ยนทิศทางอย่างรวดเร็วรู้สึกลื่นไหลตอบสนองไว ไม่ใช่ช้าอืด

Cross-fade เหมาะกับการเปลี่ยน state แบบครั้งเดียวที่สั่งจากโค้ด แต่ locomotion (การเคลื่อนที่ของตัวละคร) — อย่างการเดินที่ค่อย ๆ เร่งเป็นวิ่ง — ต้องการอะไรที่ blend ต่อเนื่องตามค่าตัวเลขที่เปลี่ยนไปเรื่อย ๆ ไม่ใช่แค่ครั้งเดียวระหว่างคลิปสองคลิปที่ระบุชื่อไว้ นั่นคือหน้าที่ของ blend tree

3. 1D Blend Tree: จาก Idle ไป Walk ไป Run

Blend Tree คือ state ชนิดพิเศษใน Animator: แทนที่จะถือคลิปเดียว มันถือหลายคลิปและ blend ระหว่างคลิปเหล่านั้นตาม parameter หนึ่งตัวหรือมากกว่า (ค่าที่มีชื่อเก็บไว้บน Animator อธิบายละเอียดในหัวข้อ 5) 1D Blend Tree ใช้ float parameter แค่ตัวเดียว — ปกติชื่อ Speed — และรายการ motion แต่ละตัวจะมี threshold (ค่าขีดแบ่ง) กำกับไว้: ค่า parameter ที่ motion นั้นจะเล่นด้วย weight เต็ม 100%

คุณสร้าง 1D Blend Tree ใน Animator window (คลิกขวาที่ state, Create State, From New Blend Tree แล้วเปิดมันขึ้นมา) ไม่ใช่ในโค้ด ข้างในนั้นคุณจะตั้งค่า:

ตอน runtime Unity จะดูค่า Speed ปัจจุบัน หา motion สองตัวที่ threshold ครอบคลุมค่านั้นอยู่ แล้ว blend เฉพาะสองตัวนั้น ส่วนที่เหลือได้ weight เป็น 0

1D Blend Tree, parameter = Speed Speed: 0.0 2.0 6.0 | | | Motion: Idle (blend) Walk (blend) Run Example: Speed = 3.5, which sits between Walk(2.0) and Run(6.0) t = (3.5 - 2.0) / (6.0 - 2.0) = 0.375 Walk weight = 1 - t = 0.625 Run weight = t = 0.375 Idle weight = 0.000 (out of range for this segment, ignored)

คณิตศาสตร์ที่ใช้คือ linear interpolation (จากบทเรื่องคณิตศาสตร์/vector): หาว่า Speed อยู่ระหว่าง threshold สองตัวข้างเคียงที่สัดส่วนเท่าไรในรูปเศษส่วน 0 ถึง 1 แล้วใช้สัดส่วนนั้นเป็นตัวแบ่ง weight พอ Speed เพิ่มขึ้นเรื่อย ๆ เกิน 2.0 weight ของ Walk จะลดลงเรื่อย ๆ และของ Run จะเพิ่มขึ้นเรื่อย ๆ โดยไม่มีรอยต่อ — ไม่มีเฟรมไหนที่คลิปหนึ่งแทนที่อีกคลิปแบบทันทีทันใด

งานของคุณใน C# คือแค่คอยอัปเดตค่า Speed ให้ทันสมัย ปกติจะ smooth ไว้ด้วยเพื่อไม่ให้กระโดดทันทีเวลาผู้เล่นกดปุ่ม:

using UnityEngine;

public class Locomotion1D : MonoBehaviour
{
    public Animator animator;
    public float maxSpeed = 6f;
    public float acceleration = 10f;

    private float currentSpeed;

    void Update()
    {
        float input = Mathf.Clamp01(Mathf.Abs(Input.GetAxis("Vertical")));
        float targetSpeed = input * maxSpeed;

        currentSpeed = Mathf.MoveTowards(currentSpeed, targetSpeed, acceleration * Time.deltaTime);
        animator.SetFloat("Speed", currentSpeed);
    }
}

ไล่ trace ดู: สมมติผู้เล่นกด "เดินหน้า" เต็มที่ (Input.GetAxis("Vertical") == 1) ทำให้ targetSpeed = 6 เริ่มจาก currentSpeed = 0 ด้วย acceleration = 10 และเวลาต่อเฟรมประมาณ 0.016 วินาที (60 FPS) currentSpeed จะเพิ่มขึ้นราว 0.16 ต่อเฟรม: 0 -> 0.16 -> 0.32 -> ... จนกระทั่งถึง 6 ในเวลาประมาณ 0.6 วินาที ระหว่างนั้น blend tree จะค่อย ๆ เลื่อนจาก Idle ผ่าน Walk ไปสู่ Run ตามที่ diagram ข้างบนอธิบายไว้ทุกเฟรม

Common mistake ตั้งค่า Speed ตรง ๆ จาก raw input (animator.SetFloat("Speed", input * maxSpeed)) โดยไม่มีการ smooth เลย ขาของตัวละครจะกระตุกเปลี่ยนความเร็วทันทีที่กดหรือปล่อยปุ่ม เพราะ blend tree ได้รับค่าที่กระโดดทันทีแทนที่จะค่อย ๆ ไต่ขึ้น ต้อง smooth parameter ในโค้ดเสมอ (Mathf.MoveTowards, Mathf.SmoothDamp, หรือ overload dampTime ของ Animator.SetFloat เองจากหัวข้อ 9) ก่อนส่งค่าให้ Animator

4. 2D Blend Tree: Blend ตามทิศทางการเคลื่อนที่

2D Blend Tree ทำงานคล้ายกันแต่ใช้ float parameter สองตัวพร้อมกัน — ปกติคือ MoveX (strafe เดินด้านข้าง ซ้าย/ขวา) และ MoveZ (หน้า/หลัง) — ทำให้คุณ blend ระหว่างคลิปที่วางอยู่บนระนาบ 2D แทนที่จะเป็นเส้นเดียว นี่คือสิ่งที่ทำให้ตัวละครวิ่งทแยงมุมแบบ strafe ได้โดย blend ระหว่าง RunForward กับ RunRight แทนที่จะวิ่งตรงไปข้างหน้าได้อย่างเดียว

ใน Animator window คุณจะเลือกชนิดของ 2D blend แล้ววางแต่ละ motion ไว้ที่จุด 2D:

MoveZ (forward, +1) ^ | RunForward | MoveX -1 RunLeft ----- Idle(0,0) ----- RunRight MoveX +1 | RunBack | v MoveZ (backward, -1) If diagonal clips are authored too, e.g. RunForwardRight at (0.7, 0.7), Unity finds the motions closest to the current (MoveX, MoveZ) point and blends between them, weighting each by how close it is to that point.

Unity มีชนิดของ 2D blend หลายแบบให้เลือก และการเลือกให้ถูกก็สำคัญ:

สำหรับชุดการเคลื่อนที่ 8 ทิศทางปกติ (หน้า, หลัง, ซ้าย, ขวา, และทแยงมุมสี่ทิศ) Freeform Directional คือตัวเลือกที่ใช้บ่อยที่สุด การสั่งงานจาก C# คือคอยอัปเดต MoveX กับ MoveZ ให้ตรงกับทิศทางการเคลื่อนที่แบบ local ของตัวละคร:

using UnityEngine;

public class Locomotion2D : MonoBehaviour
{
    public Animator animator;
    public float smoothTime = 0.1f;

    private Vector2 currentBlend;
    private Vector2 blendVelocity;

    void Update()
    {
        float h = Input.GetAxisRaw("Horizontal"); // strafe: -1 left, +1 right
        float v = Input.GetAxisRaw("Vertical");   // -1 back, +1 forward

        Vector2 targetBlend = new Vector2(h, v);
        currentBlend = Vector2.SmoothDamp(currentBlend, targetBlend, ref blendVelocity, smoothTime);

        animator.SetFloat("MoveX", currentBlend.x);
        animator.SetFloat("MoveZ", currentBlend.y);
    }
}

ไล่ trace ดู: ผู้เล่นกดเดินหน้าพร้อมกับกดขวา ทำให้ h = 1, v = 1 และ targetBlend = (1, 1) Vector2.SmoothDamp จะค่อย ๆ เลื่อน currentBlend เข้าหาจุดนั้นภายในเวลาประมาณ smoothTime วินาที แทนที่จะกระโดดไปเลย พอ currentBlend เข้าใกล้ (1, 1) 2D blend tree จะหา motion ที่ใกล้ทิศทางนั้นที่สุด — น่าจะเป็น RunForward กับ RunRight หรือคลิป RunForwardRight เฉพาะทางถ้าคุณสร้างไว้ — แล้ว blend มันเข้าด้วยกัน ทำให้ตัวละครวิ่งทแยงมุมได้จริง เท้าตรงกับทิศทางที่กำลังเคลื่อนที่

Tip ให้ MoveX/MoveZ อ้างอิงตามทิศที่ตัวละครหันหน้า ไม่ใช่ world space ถ้า parameter เป็น world-space แต่คลิปใน blend tree ถูกสร้างมาแบบ "วิ่งไปข้างหน้าตามทิศตัวละคร" การหมุนตัวละครจะทำให้ blend เลือกคลิปผิดสำหรับทิศทางที่ดูเหมือนคุณกำลังเคลื่อนที่ไป

5. Animator State Machine: State, Transition และ Parameter

Blend tree ตัดสินใจว่าจะ blend ยังไง ภายใน node เดียว ส่วน state machine ตัดสินใจว่า node ไหนกำลัง active อยู่ — ตัวละครอยู่ใน Locomotion หรือ Jump หรือ Attack? คุณสร้างมันแบบ visual ใน Animator window เป็น graph ของกล่องกับลูกศร

State Machine (Animator window) [Entry] --> [Locomotion] (a 1D Blend Tree driven by "Speed") [Locomotion] --Jump (trigger)--> [Jump] [Jump] --Grounded == true, Exit Time 0.9--> [Locomotion] [Any State] --Health <= 0--> [Death]

Trigger มีจุดที่ต้องระวังเป็นพิเศษ: ต่างจาก Bool ที่ค่ายังคงอยู่แบบที่คุณตั้งไว้ครั้งล่าสุด Trigger จะถูก "arm" (ตั้งพร้อมยิง) โดย SetTrigger แล้วถูกล้างค่าอัตโนมัติทันทีที่ transition ที่ใช้มันยิงจริง มันทำงานเหมือน event ที่เกิดครั้งเดียว ซึ่งเหมาะกับ "กระโดดตอนนี้เลย" พอดี — คุณไม่อยากให้ตัวละครกระโดดซ้ำทุกเฟรมแค่เพราะ bool ค้างเป็น true

คุณตรวจสอบได้ว่า Animator มี parameter อะไรบ้างทั้งหมดจากโค้ด ซึ่งมีประโยชน์เวลา debug controller ที่ไม่ได้สร้างเอง:

using UnityEngine;

public class ListAnimatorParameters : MonoBehaviour
{
    public Animator animator;

    void Start()
    {
        foreach (AnimatorControllerParameter p in animator.parameters)
        {
            Debug.Log(p.name + " : " + p.type);
        }
    }
}

Console output สำหรับ locomotion controller ทั่วไป:

Speed : Float
Grounded : Bool
Jump : Trigger

สังเกตว่า ชื่อ parameter เป็น plain string ธรรมดา และ case-sensitive (แยกตัวพิมพ์ใหญ่เล็ก) ข้อเท็จจริงข้อเดียวนี้เป็นสาเหตุของบั๊กเงียบ ๆ มากกว่าอะไรอื่นในบทนี้ทั้งหมด จะพูดถึงเต็ม ๆ ในหัวข้อ 10

6. Transition Duration และ Interruption

ลูกศร transition ทุกอันใน graph มีการตั้งค่าการ blend เป็นของตัวเอง แยกจากคำสั่ง CrossFade ที่คุณเขียนในโค้ด สองการตั้งค่าที่สำคัญที่สุดสำหรับมือใหม่คือ:

สองการตั้งค่านี้ทำงานร่วมกันในแบบที่ทำให้เกือบทุกคนงงตอนเจอครั้งแรก:

State "Attack" is a looping animation, length normalized 0..1 Transition Attack -> Locomotion has Has Exit Time = true, Exit Time = 1.0 Player releases the attack button at normalizedTime = 0.1 the transition's condition becomes true right away, but Unity WAITS it only fires once normalizedTime reaches 1.0 -> almost a full extra loop feels like the input "did nothing" for up to 0.9 of the clip's length

สำหรับ action ที่ต้องเร็วและตอบสนองไว (กระโดด, หลบ, ยกเลิกการโจมตี) ปกติคุณจะอยากปิด Has Exit Time เพื่อให้ transition ยิงทันทีที่เงื่อนไขเป็นจริง ส่วน animation ที่ต้องเล่นจบทุกครั้งเมื่อเริ่มแล้ว (ท่าไม้ตายใหญ่ ๆ, animation ตาย) ให้เปิดค่านี้ไว้

การ Interrupt Transition กลางคัน

บางครั้ง transition กำลัง blend อยู่ พอมีเงื่อนไขใหม่ที่เร่งด่วนกว่าเป็นจริงขึ้นมา — ผู้เล่นกำลัง blend จาก Walk ไป Attack แล้วอยากจะ Jump แทน การตั้งค่า Interruption Source บน transition ควบคุมว่าอนุญาตให้ทำแบบนี้ไหม และถ้าอนุญาต transition ขาออกของ state ไหนที่ interrupt มันได้ (None, Current State, Next State หรือทั้งคู่)

Transition A -> B starts at t=0.00, duration 0.30s t=0.00 A weight=1.00 B weight=0.00 t=0.10 A weight=0.67 B weight=0.33 new condition true: target becomes C t=0.10 A weight=0.67 C weight=0.33 B is dropped, C continues from here t=0.30 A weight=0.00 C weight=1.00

เมื่ออนุญาตให้ interrupt การ blend จะไม่เริ่มใหม่ตั้งแต่ต้น — มันจะเก็บท่าผสมของ state เก่าที่กำลังแสดงอยู่ไว้ แล้วเปลี่ยนทิศทางครึ่งที่กำลังเข้ามาไปหาเป้าหมายใหม่แทน ถ้าไม่อนุญาต ตัวละครต้อง blend เข้าไปหา B ให้เสร็จเต็มที่ก่อน transition ที่ออกจาก B ถึงจะถูกพิจารณาได้ ซึ่งรู้สึกหน่วงมากสำหรับอะไรที่ต้องยกเลิกได้ไว

7. Layer และ Avatar Mask: เล็งปืนขณะวิ่ง

ทุกอย่างที่ผ่านมา blend ท่า ทั้งตัว แต่บ่อยครั้งคุณอยากให้สองอย่างเกิดขึ้นพร้อมกันในส่วนต่างของร่างกาย: ขาวิ่งตาม locomotion state machine ในขณะที่ครึ่งบนเล็งไปที่เป้าหมายอย่างเป็นอิสระ นั่นคือหน้าที่ของ Layer

Animator window มีแผง Layers แต่ละ layer เป็น state machine อิสระของตัวเอง และผลลัพธ์ของมันจะถูก blend ทับซ้อนบน layer ที่อยู่ข้างล่าง:

Layer 1: Base weight 1.0 mask: full body plays Locomotion state machine -> legs walk/run Layer 2: UpperBodyAim weight 0..1 mask: spine + arms + head only plays Aim clip -> only affects the masked bones Final pose = Base layer pose, then UpperBodyAim's pose overrides just the masked bones on top, scaled by the layer's weight

Avatar Mask คือ asset (คลิกขวา, Create, Avatar Mask) ที่มี checkbox ต่อกลุ่มกระดูก ปลดล็อกส่วนขาออกจาก mask ของ aim layer แล้ว layer นั้นจะแตะกระดูกขาไม่ได้เลยไม่ว่าจะเล่นคลิปอะไรก็ตาม — ขายังคงอยู่ภายใต้การควบคุมของ base layer เสมอ

using UnityEngine;

public class AimLayerController : MonoBehaviour
{
    public Animator animator;
    private int aimLayerIndex;

    void Start()
    {
        aimLayerIndex = animator.GetLayerIndex("UpperBodyAim");
    }

    void Update()
    {
        bool isAiming = Input.GetMouseButton(1);
        float targetWeight = isAiming ? 1f : 0f;

        float current = animator.GetLayerWeight(aimLayerIndex);
        animator.SetLayerWeight(aimLayerIndex, Mathf.MoveTowards(current, targetWeight, 5f * Time.deltaTime));
    }
}

ไล่ trace ดู: ระหว่างที่ผู้เล่นกดปุ่มเมาส์ขวาค้างไว้ targetWeight เป็น 1 และ SetLayerWeight จะไต่ weight ของ aim layer จาก 0 ขึ้นไปหา 1 ภายในเวลาประมาณ 0.2 วินาที (1 / 5) ระหว่างที่ไต่อยู่นั้น ขาของตัวละครยังคงวิ่งตามที่ base layer สั่งอยู่ปกติ ในขณะที่ครึ่งบนค่อย ๆ หมุนเข้าสู่ท่าเล็ง — ไม่ใช่การกระโดดทันที แต่เป็น cross-fade จริง ๆ เฉพาะกระดูกที่อยู่ใน mask

Common mistake ลืมผูก Avatar Mask ให้กับ layer แบบ additive/override เลย ถ้าไม่มี mask, blend mode แบบ Override ของ layer จะแทนที่ ทั้งตัว ไม่ใช่แค่ครึ่งบน — พอ weight ของมันขึ้นเหนือ 0 เท่านั้น ขาจะแข็งค้างในท่าของคลิป aim ทันทีและหยุดวิ่ง

8. Root Motion กับ In-Place Animation

มีวิธีสองแบบที่แตกต่างกันมากในการที่คลิป animation หนึ่งจะแทนการเคลื่อนที่ และการสับสนระหว่างสองแบบนี้เป็นสาเหตุคลาสสิกของบั๊ก "ตัวละครลอยไถล" หรือ "เท้าตัวละครไถลบนพื้น"

In-place clip (no root motion): the clip only moves bones relative to the character's origin. transform.position never changes from playing the clip alone. if your code doesn't move the object, the character runs in place and its feet visibly slide against the ground. Root motion clip: the clip's hip/root bone itself travels forward as part of the animation data. Unity exposes that per-frame travel as animator.deltaPosition / animator.deltaRotation, and (if enabled) applies it to transform.position/rotation automatically each frame.

สลับโหมดนี้ได้ด้วย checkbox Apply Root Motion บน component Animator หรือ flag เดียวกันในโค้ด animator.applyRootMotion เมื่อเปิดไว้ Unity จะเรียกเมธอดพิเศษ OnAnimatorMove หนึ่งครั้งต่อเฟรม ทันทีหลังจากคำนวณ animation เสร็จ และการย้ายตำแหน่ง object ที่จุดนั้นแทนที่จะทำใน Update จะทำให้การเคลื่อนที่ตรงกับเท้าเป๊ะ ๆ:

using UnityEngine;

[RequireComponent(typeof(Animator))]
public class RootMotionRelay : MonoBehaviour
{
    private Animator animator;

    void Awake()
    {
        animator = GetComponent<Animator>();
        animator.applyRootMotion = true;
    }

    // Unity calls this automatically, once per frame, only while
    // Apply Root Motion is enabled.
    void OnAnimatorMove()
    {
        transform.position += animator.deltaPosition;
        transform.rotation *= animator.deltaRotation;
    }
}

Root motion ดูดีมากสำหรับการเคลื่อนที่บนพื้นที่เท้าตรงกับ animation เป๊ะ ๆ แต่ควบคุมได้ยากกว่า (รัศมีการเลี้ยวและความเร็วถูก bake ไว้ในคลิปแล้ว ไม่ใช่ parameter ที่คุณปรับได้) และเข้ากันไม่ดีกับการเคลื่อนที่ที่ขับเคลื่อนด้วย physics เกม action ส่วนใหญ่ใช้คลิปแบบ in-place สำหรับ locomotion แล้วย้ายตัวละครด้วยโค้ด (CharacterController หรือ Rigidbody จากบทเรื่อง physics) ขับเคลื่อนด้วยค่า Speed/MoveX/MoveZ ชุดเดียวกับที่ป้อนให้ blend tree ทำให้ความเร็ว animation ที่เห็นกับความเร็วการเคลื่อนที่จริงตรงกันเสมอ Root motion มักถูกสงวนไว้สำหรับช่วงเวลาที่ animation ต้องแม่นยำมาก เช่นท่าพุ่งโจมตีที่ scripted ไว้หรือการปีน ที่ตำแหน่งเท้าแม่นยำสำคัญกว่าการควบคุมที่ผู้เล่นสั่งได้ตรง ๆ

9. สั่งงานและอ่านค่า Animator จาก C#

ทุกอย่างข้างบนเกิดขึ้นเพราะโค้ดตั้งค่า parameter แล้ว Animator ตอบสนอง หัวข้อนี้เป็นข้อมูลอ้างอิงของทั้งสองทิศทาง: เขียน parameter เข้าไป และอ่าน state กลับออกมา

การเขียน Parameter

using UnityEngine;

public class AnimatorParameterDemo : MonoBehaviour
{
    public Animator animator;
    public bool isGrounded;

    void Update()
    {
        // Float: drives 1D/2D blend trees directly
        float speed = new Vector3(Input.GetAxis("Horizontal"), 0f, Input.GetAxis("Vertical")).magnitude;
        animator.SetFloat("Speed", speed);

        // Float with built-in smoothing: eases toward the value over
        // dampTime seconds instead of snapping to it in one frame
        animator.SetFloat("Speed", speed, 0.1f, Time.deltaTime);

        // Bool: stays true or false until you change it again
        animator.SetBool("Grounded", isGrounded);

        // Int: useful for combo steps, weapon type, etc.
        animator.SetInteger("ComboIndex", 2);

        // Trigger: fires once; Unity clears it automatically once a
        // transition that consumes it actually fires
        if (Input.GetButtonDown("Jump"))
        {
            animator.SetTrigger("Jump");
        }

        // ResetTrigger: cancels a trigger that has not fired yet
        if (Input.GetKeyDown(KeyCode.Escape))
        {
            animator.ResetTrigger("Jump");
        }
    }
}

สังเกตว่า SetFloat มี overload สองแบบคือรับ 2 argument กับ 4 argument โดยตั้งใจ แบบธรรมดารับ 2 argument จะตั้งค่า parameter ให้ตรงตามที่ระบุทันที เหมาะถ้าคุณ smooth ค่าเองอยู่แล้ว (แบบที่ Locomotion1D ในหัวข้อ 3 ทำด้วย Mathf.MoveTowards) ส่วน overload ที่มี dampTime ให้ Animator ช่วย smooth ให้เอง สะดวกดีแต่คุณควบคุมรายละเอียดการ smooth ได้น้อยกว่า

การอ่าน State กลับมา

using UnityEngine;

public class AnimatorStateReader : MonoBehaviour
{
    public Animator animator;

    void Update()
    {
        AnimatorStateInfo state = animator.GetCurrentAnimatorStateInfo(0);

        if (state.IsName("Jump") && state.normalizedTime >= 1f)
        {
            Debug.Log("Jump clip finished playing");
        }

        if (animator.IsInTransition(0))
        {
            AnimatorTransitionInfo t = animator.GetAnimatorTransitionInfo(0);
            Debug.Log("Blending, progress = " + t.normalizedTime);
        }
    }
}

GetCurrentAnimatorStateInfo(0) ถามเกี่ยวกับ layer 0 (base layer ส่ง 1 สำหรับ layer ที่สอง ไปเรื่อย ๆ) IsName ตรวจสอบชื่อของ state (หรือ path เต็มอย่าง "Base Layer.Jump" ถ้าชื่อชนกันข้าม sub-state machine) normalizedTime คือค่า 0-ถึง-1-ต่อ-loop เดียวกับในหัวข้อ 6 เพียงแต่มันนับต่อเนื่องเกิน 1.0 สำหรับคลิปที่ loop (1.2 แปลว่า "20% เข้าไปใน loop ที่สอง") แทนที่จะวนกลับไป 0 นี่คือเหตุผลว่าทำไม >= 1f ถึงเป็นการเช็คที่ถูกต้องสำหรับ "คลิปที่ไม่ loop นี้เล่นจบหรือยัง" พอ state Jump เล่นคลิปจบเต็มแล้ว console จะพิมพ์ครั้งเดียว ส่วนตอนที่ transition กำลัง blend อยู่ มันจะพิมพ์ progress ทุกเฟรม

10. บั๊กยอดฮิต: Transition ที่ไม่ยอมยิง หรือกระโดดแทนที่จะ Blend

มือใหม่แทบทุกคนต้องเจอบั๊กพวกนี้สักอันหนึ่ง ควรจำไว้ให้ขึ้นใจเพราะ Animator ไม่ขึ้น error ให้เลยสักอัน — transition แค่เงียบ ๆ ไม่ทำตามที่คุณคาดหวัง

พิมพ์ชื่อ Parameter ผิด

// The Animator Controller has a Trigger parameter named exactly "Jump".
animator.SetTrigger("jump"); // wrong case -> silently does nothing

// Fix: match the exact, case-sensitive name shown in the Animator window
animator.SetTrigger("Jump");

ชื่อ parameter เป็น plain string ที่จับคู่กันแบบตรงตัวสะกดและตัวพิมพ์ใหญ่เล็กเป๊ะ ๆ "Jump" กับ "jump" คือ parameter คนละตัวกันในสายตาของ Animator Unity เวอร์ชันใหม่ ๆ จะขึ้น warning ใน console ถ้าชื่อ parameter ไม่มีอยู่จริง แต่เวอร์ชันเก่ากว่าจะไม่ทำอะไรเลย — trigger นั้นแค่หายไปเฉย ๆ วิธีแก้ที่นิยมคือเก็บชื่อไว้ครั้งเดียวเป็น const string หรือ cache ค่า hash ของมันด้วย Animator.StringToHash เพื่อให้การพิมพ์ผิดกลายเป็น compile error แทนที่จะเป็นบั๊กเงียบ ๆ

ลืมปิด Has Exit Time

พูดถึงไปแล้วในหัวข้อ 6 แต่ควรย้ำอีกครั้งเพราะเป็นรายงานแบบ "transition ของฉันไม่ยอมยิง (เร็ว ๆ)" ที่พบบ่อยที่สุด: state ที่ loop อยู่ พร้อมกับติ๊ก Has Exit Time ไว้และตั้ง Exit Time ใกล้ 1.0 จะทำให้ transition รอจนคลิปเล่น loop ปัจจุบันเกือบจบก่อนถึงจะได้เช็คเงื่อนไขด้วยซ้ำ ถ้า state ควร interrupt ได้ทันที (การเคลื่อนที่ หรือ action ส่วนใหญ่) ให้ปิด Has Exit Time

Transition Duration เป็นศูนย์

Transition Duration ที่เป็น 0 จะทำให้ "transition" กลายเป็นการกระโดดทันที ไม่ใช่การ blend — ปัญหาเดียวกับที่พูดถึงในหัวข้อ 1 แค่ซ่อนอยู่ใน state machine แทนที่จะเป็นการเรียก Play ตรง ๆ ถ้าการเปลี่ยนแปลงดูเหมือนการตัดต่อกระโดดข้ามเฟรม ทั้งที่คุณใช้ state machine ไม่ได้เรียก Play เอง ให้เช็ค duration บนลูกศร transition แทนที่จะเช็คโค้ดของคุณ

เปรียบเทียบ Float แบบเท่ากันเป๊ะ

เงื่อนไข transition แบบ Speed Equals 0 อาจไม่ยอมยิงเลย เพราะ float ที่ถูก smooth (จาก Mathf.MoveTowards, SmoothDamp, หรือ overload dampTime ของ SetFloat) แทบไม่มีทางลงเอยที่ 0.0 พอดีเป๊ะ — มันจะเข้าใกล้แล้วหยุดห่างนิดหน่อย ให้ใช้เงื่อนไข Less/Greater พร้อม threshold เล็ก ๆ (Speed Less 0.05) แทน Equals สำหรับ float ที่ถูก smooth

ตั้ง Trigger บน Object ที่ถูกปิดอยู่

การเรียก SetTrigger บน Animator ที่ GameObject ของมันถูกปิด (inactive) อยู่ตอนนั้นจะไม่ทำอะไรเลย — ไม่มี error การเรียกนั้นแค่ no-op เฉย ๆ ถ้าคุณกำลังจะคิว action ไว้ล่วงหน้า (เช่น "กระโดดทันทีที่แตะพื้น") บนตัวละครที่อาจถูกปิดไปแวบเดียว (object pooling, cutscene ที่สลับการมองเห็น) ให้เช็ค gameObject.activeInHierarchy ก่อนที่จะพึ่ง trigger ให้รอดจากช่วงนั้นไปได้

11. ประกอบร่างทั้งหมด: Locomotion Controller ฉบับเต็ม

นี่คือวิธีที่ชิ้นส่วนทั้งหมดในบทนี้มารวมกันในสคริปต์เดียวที่สั่งงาน Animator Controller เต็มรูปแบบ: 1D blend tree สำหรับ locomotion, trigger สำหรับกระโดด และ aim layer

ตัว Animator Controller เอง (สร้างใน Animator window ไม่ใช่ในโค้ด) จะมี:

using UnityEngine;

[RequireComponent(typeof(Animator))]
public class PlayerAnimatorDriver : MonoBehaviour
{
    private Animator animator;
    private int aimLayer;

    public float speed;
    public bool isGrounded = true;

    void Awake()
    {
        animator = GetComponent<Animator>();
        aimLayer = animator.GetLayerIndex("UpperBodyAim");
    }

    void Update()
    {
        // 1. Locomotion: drive the 1D blend tree
        float input = Input.GetAxis("Vertical");
        speed = Mathf.MoveTowards(speed, input * 6f, 10f * Time.deltaTime);
        animator.SetFloat("Speed", speed);

        // 2. Jump: fire a trigger, guarded by our own grounded check
        if (isGrounded && Input.GetButtonDown("Jump"))
        {
            animator.SetTrigger("Jump");
        }
        animator.SetBool("Grounded", isGrounded);

        // 3. Aim layer: fade in/out with the right mouse button
        bool aiming = Input.GetMouseButton(1);
        float targetWeight = aiming ? 1f : 0f;
        float current = animator.GetLayerWeight(aimLayer);
        animator.SetLayerWeight(aimLayer, Mathf.MoveTowards(current, targetWeight, 5f * Time.deltaTime));
    }
}

ไล่ trace ดูช่วงเวลาหนึ่ง: ผู้เล่นยืนนิ่งอยู่ (speed ใกล้ 0, Locomotion แสดงเป็น Idle เกือบทั้งหมด) กดปุ่มเล็งค้างไว้ (layer UpperBodyAim ไต่ขึ้นไปหา weight 1 ทำให้ครึ่งบนหันไปเล็งในขณะที่ขายังนิ่งอยู่) แล้วกดเดินหน้ากับกระโดดพร้อมกันเกือบพอดี speed เริ่มไต่ขึ้นไปหา 6 เลื่อน blend tree ของ Locomotion จาก Idle ไปหา Walk ส่วน SetTrigger("Jump") ยิง transition Locomotion -> Jump ทันที เพราะ transition นั้นปิด Has Exit Time ไว้ ทั้งสามระบบ — blend tree, การสลับ state ด้วย trigger, และ aim layer ที่เป็นอิสระ — ทำงานพร้อมกันโดยไม่เหยียบกัน เพราะแต่ละอันแตะแค่ parameter และกระดูกที่มันเป็นเจ้าของเท่านั้น

12. คำศัพท์

13. แบบฝึกหัด

Exercise 1 1D Blend Tree ใช้ parameter Speed กับ motion Idle ที่ threshold 0, Walk ที่ threshold 2, และ Run ที่ threshold 6 จงคำนวณ weight ของทุก motion เมื่อ Speed = 1 และอีกครั้งเมื่อ Speed = 5 แสดงเศษส่วน interpolation t ที่ใช้ในแต่ละกรณีด้วย
Show answer

Speed = 1 อยู่ระหว่าง Idle (0) กับ Walk (2): t = (1 - 0) / (2 - 0) = 0.5 weight ของ Idle = 1 - t = 0.5, weight ของ Walk = t = 0.5, weight ของ Run = 0 (นอกช่วง)

Speed = 5 อยู่ระหว่าง Walk (2) กับ Run (6): t = (5 - 2) / (6 - 2) = 0.75 weight ของ Walk = 1 - t = 0.25, weight ของ Run = t = 0.75, weight ของ Idle = 0 (นอกช่วง)

Exercise 2 Animator Controller มี parameter ชนิด Trigger ชื่อ Attack เป๊ะ ๆ ป้อนเข้า transition Locomotion -> AttackState โค้ดข้างล่างนี้ทำงานตอนผู้เล่นคลิก แต่ animation โจมตีไม่เล่นเลย และ console ก็ไม่ขึ้น error อะไรทั้งนั้น จงหาบั๊กและแก้ไข
void OnFire(InputValue value)
{
    animator.SetTrigger("attack");
}
Show answer

parameter ใน Animator Controller ชื่อ Attack (A ตัวใหญ่) แต่โค้ดเรียก SetTrigger("attack") ด้วย a ตัวเล็ก ชื่อ parameter ถูกจับคู่แบบตรงตัวสะกดและตัวพิมพ์ใหญ่เล็กเป๊ะ ๆ ดังนั้น Unity มองว่านี่คือ parameter คนละตัวกันโดยสิ้นเชิง (และในกรณีนี้ตัวหนึ่งไม่มีอยู่จริงด้วยซ้ำ) trigger Attack ตัวจริงเลยไม่เคยถูกตั้งค่า เงื่อนไขของ transition จึงไม่เคยเป็นจริง และไม่มีอะไรเล่นเลย — โดยไม่มี error เพราะการส่งชื่อที่ไม่รู้จักให้ SetTrigger ไม่ถูกนับเป็นความล้มเหลวร้ายแรง

void OnFire(InputValue value)
{
    animator.SetTrigger("Attack"); // matches the Animator's parameter name exactly
}

วิธีแก้ที่ปลอดภัยกว่าในระยะยาวคือเก็บชื่อไว้ครั้งเดียว เช่น private static readonly int AttackHash = Animator.StringToHash("Attack"); แล้วเรียก animator.SetTrigger(AttackHash) จากค่าคงที่ตัวเดียวนี้เสมอ เพื่อให้การพิมพ์ผิดในอนาคตกลายเป็นจุดเดียวที่ compiler เช็คให้ แทนที่จะเป็น string กระจัดกระจายที่พิมพ์ผิดได้ง่ายในหลาย ๆ ที่

Exercise 3 เขียน MonoBehaviour ที่หา layer index ของ layer ชื่อ "UpperBodyAim" ใน Start แล้วทุกเฟรมค่อย ๆ ไต่ weight ของ layer นั้นไปหา 1 ขณะที่ผู้เล่นกด KeyCode.LeftShift ค้างไว้ และไต่ไปหา 0 ในกรณีอื่น โดยให้การไต่เต็มจาก 0 ไป 1 ใช้เวลาประมาณ 0.2 วินาที (คำใบ้: นี่คือ pattern เดียวกับสคริปต์ aim layer ในหัวข้อ 7 แค่เปลี่ยน input กับเวลาไต่นิดหน่อย)
Show answer
using UnityEngine;

public class ShiftLayerFader : MonoBehaviour
{
    public Animator animator;
    private int layerIndex;

    void Start()
    {
        layerIndex = animator.GetLayerIndex("UpperBodyAim");
    }

    void Update()
    {
        bool held = Input.GetKey(KeyCode.LeftShift);
        float targetWeight = held ? 1f : 0f;

        // a full 0 -> 1 fade in 0.2s means a rate of 1 / 0.2 = 5 per second
        float current = animator.GetLayerWeight(layerIndex);
        animator.SetLayerWeight(layerIndex, Mathf.MoveTowards(current, targetWeight, 5f * Time.deltaTime));
    }
}

ไอเดียหลักคือ "ไต่เต็มภายใน X วินาที" แปลงเป็นอัตรา 1 / X หน่วยต่อวินาที สำหรับ argument max-delta ของ Mathf.MoveTowards คูณด้วย Time.deltaTime เพื่อให้ไม่ขึ้นกับ frame rate เพราะเป้าหมายคือ 0.2 วินาที อัตราจึงเป็น 1 / 0.2 = 5 ตรงกับตัวอย่างในหัวข้อ 7

นี่คือการ blend ทั้งหมดแบบครบวงจร: cross-fade สำหรับการเปลี่ยนครั้งเดียว, 1D กับ 2D blend tree สำหรับ locomotion ต่อเนื่อง, state machine ที่ตัดสินใจว่า state ไหน active อยู่และเมื่อไร, จังหวะเวลาของ transition กับการ interrupt, layer กับ mask สำหรับส่วนร่างกายที่เป็นอิสระจากกัน, root motion เทียบกับการเคลื่อนที่แบบ in-place, และคำสั่ง C# ที่สั่งงานและอ่านค่าทั้งหมดนี้ ครั้งหน้าที่ตัวละครในเกมของคุณต้องเปลี่ยนจากยืนนิ่งไปวิ่งเต็มสปีด หรือเล็งอาวุธขณะวิ่งอยู่ คุณจะรู้แล้วว่าเครื่องมือไหนของ Animator จัดการส่วนไหนของงานนี้

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