6.2 Character Controller และการเคลื่อนที่

เฟส 6 · Gameplay Programming · เวลาเรียน: 30–50 h

สร้างการเคลื่อนที่ของผู้เล่นที่ตอบสนองดี — เช็คพื้น, กระโดด, ทางลาด, คุมกลางอากาศ — และทำไม 'game feel' ส่วนใหญ่อยู่ตรงนี้

ตอนนี้คุณรู้พื้นฐานของ Unity scene แล้ว: GameObject, Transform, MonoBehaviour, ลูป Update/FixedUpdate และฟิสิกส์เบื้องต้นด้วย Rigidbody กับ collider บทนี้จะเอาชิ้นส่วนพวกนั้นมาสร้างระบบที่ถูกแตะบ่อยที่สุดในเกมเกือบทุกเกมที่คุณจะปล่อยจริง นั่นคือระบบที่ทำให้ตัวละครผู้เล่นเคลื่อนที่ ฟังดูง่าย — อ่าน input แล้วขยับตัวละคร — แต่ความต่างระหว่าง controller ที่ให้ความรู้สึกเหมือนเกมแพลตฟอร์มของ HoYoverse หรือ Nintendo กับ controller ที่ให้ความรู้สึกเหมือนโปรเจกต์นักศึกษา อยู่ที่รายละเอียดที่เราจะคุยกันในบทนี้ทั้งหมด

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

1. "Character controller" คืออะไร?

character controller ไม่ใช่ class ตัวใดตัวหนึ่งโดยเฉพาะ — มันเป็นชื่อเรียกรวม ๆ ของ "โค้ดที่แปลง input ของผู้เล่นให้กลายเป็นตำแหน่งและการเคลื่อนที่ของตัวละครในโลกเกม" ทุกเกมที่มีตัวละครที่ผู้เล่นควบคุมจะมีสิ่งนี้อยู่ ไม่ว่าจะยาวสิบบรรทัดหรือสองพันบรรทัด งานหลัก ๆ ของมันมักจะคล้ายกันเสมอ:

ใน Unity มีวิธีสร้างระบบนี้สองแบบที่ต่างกันโดยพื้นฐาน ทั้งสองแบบถูกใช้อยู่ตลอดในเกมที่ปล่อยจริง และการเลือกแบบที่เหมาะกับเกมของคุณก็สำคัญ

Approach A: Rigidbody Approach B: CharacterController ----------------------- -------------------------------- Physics engine (PhysX) moves YOUR CODE moves you directly by you by applying forces/velocity calling Move() every frame Reacts to other physics objects Ignores physics forces from others Can be pushed, can push things Does not get pushed by explosions, Gravity is automatic other rigidbodies, etc. (unless Movement can feel "physics-y" you code that yourself) (momentum, sliding, bouncing) Gravity is NOT automatic - you add it Movement is precise and predictable Built-in slope + step handling

ไม่มีแบบไหนที่เป็น "คำตอบที่ถูกต้อง" โดยทั่วไป — มันเป็นเครื่องมือคนละแบบกัน เราจะสร้างเวอร์ชันเล็ก ๆ ที่ใช้งานได้ของทั้งสองแบบ แล้วใช้เวลาที่เหลือของบทนี้ไปกับการปรับแต่งที่ทำให้การเคลื่อนที่รู้สึกดี ซึ่งส่วนใหญ่ใช้ได้กับทั้งสองแนวทางเท่า ๆ กัน

2. แนวทาง A: การเคลื่อนที่แบบใช้ Rigidbody

Rigidbody คือการที่คุณยกตัวละครให้ physics engine ของ Unity ดูแลแทน คุณไม่ได้กำหนดตำแหน่งของตัวละครโดยตรง แต่จะตั้งค่า velocity ของมัน (หรือใส่แรงเข้าไป) แล้ว physics engine จะเป็นคนขยับมันเอง พร้อมเช็ค collision ไปด้วยระหว่างทาง นี่คือ physics engine ตัวเดียวกับที่ขับเคลื่อนลัง, ragdoll, และยานพาหนะ ดังนั้นตัวละครที่ควบคุมด้วย Rigidbody จึงมีปฏิสัมพันธ์กับวัตถุ physics อื่น ๆ ได้เป็นธรรมชาติ

นี่คือเวอร์ชันง่ายที่สุดที่ขยับได้: อ่าน input ใน Update แล้วเอาไปใส่ velocity ของ Rigidbody ใน FixedUpdate (physics จะรันด้วย fixed timestep ของตัวเอง แยกจาก frame rate ของการเรนเดอร์เสมอ — นั่นคือเหตุผลที่โค้ดเคลื่อนที่ที่แตะ physics ต้องอยู่ใน FixedUpdate ไม่ใช่ Update)

using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
public class RigidbodyMoverNaive : MonoBehaviour
{
    public float moveSpeed = 7f;

    Rigidbody rb;
    Vector3 inputDir;   // world-space desired direction, length 0..1

    void Awake()
    {
        rb = GetComponent<Rigidbody>();
        rb.freezeRotation = true;   // don't let physics tip our capsule over
    }

    void Update()
    {
        float x = Input.GetAxisRaw("Horizontal");
        float z = Input.GetAxisRaw("Vertical");
        inputDir = new Vector3(x, 0f, z).normalized;
    }

    void FixedUpdate()
    {
        Vector3 target = inputDir * moveSpeed;
        // NAIVE: snaps straight to the target speed, every physics tick.
        rb.velocity = new Vector3(target.x, rb.velocity.y, target.z);
    }
}

ไล่ดูทีละสเต็ป: สมมติว่า physics step คงที่ที่ 1/60 วินาที และผู้เล่นยืนนิ่งอยู่ แล้วกดปุ่ม "ขวา" จนสุดทันที

tick (FixedUpdate #) inputDir.x rb.velocity.x 0 (key not pressed) 0.0 0.0 1 (key just pressed) 1.0 7.0 <-- jumps straight to max speed 2 1.0 7.0 ... (key released) 0.0 0.0 <-- jumps straight to zero

นี่แหละคือปัญหา "ทันทีทันใดเกินไป ไม่ใช่ลอย ๆ" ที่หัวข้อนี้เตือนไว้ เอาแค่ครึ่ง "ทันทีทันใด" ก่อน: velocity กระโดดจาก 0 ไปเป็น 7 ภายใน physics tick เดียว แล้วก็กระโดดกลับไป 0 ทันทีที่ปล่อยปุ่ม มันให้ความรู้สึกแข็งทื่อเหมือนหุ่นยนต์ — ตัวละครจริง ๆ (รวมถึงฮีโร่เกมแพลตฟอร์มจริง ๆ) ใช้เวลาสองสามเฟรมกว่าจะเริ่มเคลื่อนที่ และอีกสองสามเฟรมกว่าจะหยุด เราจะแก้ปัญหานี้ในหัวข้อ 4 วิธีแก้จะเหมือนกันไม่ว่าคุณจะใช้ Rigidbody หรือ CharacterController

Tip Unity เวอร์ชันใหม่ ๆ (Unity 6 ขึ้นไป) เปลี่ยนชื่อ Rigidbody.velocity เป็น Rigidbody.linearVelocity เพื่อเปิดที่ว่างให้ชื่อ angular velocity แยกออกมา ทั้งสองชื่อยังใช้ได้อยู่ระยะหนึ่งเพื่อความเข้ากันได้ แต่ถ้าคุณใช้ Unity เวอร์ชันใหม่แล้วชื่อเก่าขึ้นสีเทาว่า obsolete ให้ใช้ linearVelocity แทน พฤติกรรมเหมือนกันทุกอย่าง
Common mistake เขียนโค้ดเคลื่อนที่ที่อ่านหรือเขียนค่าสถานะ physics (rb.velocity, rb.AddForce, การเช็ค collision) ไว้ใน Update แทนที่จะเป็น FixedUpdate Update รันครั้งเดียวต่อเฟรมที่เรนเดอร์ ซึ่งไม่ใช่ช่วงเวลาคงที่ และอาจรันเร็วหรือช้ากว่า step ของ physics engine เอง การอ่าน input ใน Update นั้นโอเคและปกติดี (input ควรตอบสนองทันที) แต่การเอา input นั้นไปใส่ physics ต้องอยู่ใน FixedUpdate

3. แนวทาง B: CharacterController แบบ kinematic

Unity มี component ในตัวชื่อตรงตัวว่า CharacterController มันเป็น collider รูปแคปซูลที่คุณขยับเองด้วยการเรียก controller.Move(motion) ทุกเฟรม คำว่า kinematic หมายความว่ามันไม่ถูกจำลองโดย physics engine เลย — ไม่มีอะไรมาดันมันได้ แรงโน้มถ่วงไม่ดึงมันลง rigidbody ตัวอื่นชนแล้วกระเด้งออกไป แต่ตัวมันเองไม่กระเด้งกลับ คุณเป็นคนคุมเต็ม 100% ว่ามันจะไปทางไหน หน้าที่เดียวของ component นี้คือหยุดไม่ให้คุณเดินทะลุกำแพงกับพื้น และให้ flag isGrounded ที่เชื่อถือได้

using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public class CharacterControllerMoverNaive : MonoBehaviour
{
    public float moveSpeed = 7f;
    public float gravity = -25f;   // CharacterController does NOT apply gravity for you

    CharacterController controller;
    float verticalVelocity;

    void Awake()
    {
        controller = GetComponent<CharacterController>();
    }

    void Update()
    {
        float x = Input.GetAxisRaw("Horizontal");
        float z = Input.GetAxisRaw("Vertical");
        Vector3 inputDir = new Vector3(x, 0f, z).normalized;

        if (controller.isGrounded && verticalVelocity < 0f)
            verticalVelocity = -2f;      // small downward push so we stay "grounded"
        verticalVelocity += gravity * Time.deltaTime;

        Vector3 motion = inputDir * moveSpeed;
        motion.y = verticalVelocity;

        controller.Move(motion * Time.deltaTime);   // Move() takes an actual distance
    }
}

มีสองเรื่องในโค้ดนี้ที่มักพลาดตอนเจอครั้งแรก:

การดันลงเล็กน้อย (verticalVelocity = -2f แทนที่จะเป็น 0f) ตอนอยู่บนพื้นดูแปลกแต่สำคัญมาก: ถ้าปล่อยให้ vertical velocity อยู่ที่ 0 เป๊ะ ๆ ขณะยืนบนพื้นราบ isGrounded อาจกระพริบเป็น false ไปหนึ่งเฟรมเพราะช่องว่างเล็ก ๆ ของ floating point และบนทางลาดลง คุณจะไถลหลุดออกจากพื้นแทนที่จะเกาะติดมันไว้ การดันลงคงที่เล็ก ๆ นี้จะกด controller ให้แนบกับพื้นแน่นทุกเฟรม

แนวทาง C: kinematic Rigidbody

ยังมีตัวเลือกที่สามที่อยู่ตรงกลางระหว่างสองแบบ: Rigidbody ที่ตั้ง isKinematic = true การเปิด flag นี้เป็นการบอก physics engine ว่า "หยุดจำลองวัตถุนี้ — อย่าใส่แรงโน้มถ่วงให้มัน อย่าให้แรงหรือ collision อื่นมาขยับมัน" แล้วคุณก็ขยับมันเองด้วย Rigidbody.MovePosition(targetPosition) ใน FixedUpdate มันทำตัวคล้าย CharacterController มาก (ไม่มีอะไรมาดันมัน คุณคุมเต็ม) โดยมีข้อต่างสำคัญข้อเดียว: มัน ไม่มี collision response ในตัวเลยสักนิด CharacterController.Move จะหยุดที่กำแพงแล้วไถลไปตามกำแพงให้อัตโนมัติ แต่ kinematic Rigidbody จะ MovePosition ทะลุกำแพงไปดื้อ ๆ เว้นแต่โค้ดของคุณเองจะ cast ไปข้างหน้าแล้วหยุดมัน

[RequireComponent(typeof(Rigidbody))]
public class KinematicMover : MonoBehaviour
{
    public float moveSpeed = 7f;
    Rigidbody rb;
    Vector3 inputDir;

    void Awake()
    {
        rb = GetComponent<Rigidbody>();
        rb.isKinematic = true;                 // physics will not move this body
        rb.interpolation = RigidbodyInterpolation.Interpolate; // smooth it (section 12)
    }

    void Update()   // read input where it is responsive
    {
        float x = Input.GetAxisRaw("Horizontal");
        float z = Input.GetAxisRaw("Vertical");
        inputDir = new Vector3(x, 0f, z).normalized;
    }

    void FixedUpdate()   // move on the physics clock
    {
        Vector3 delta = inputDir * moveSpeed * Time.fixedDeltaTime;
        rb.MovePosition(rb.position + delta);   // MovePosition takes a TARGET position
    }
}

แล้วทำไมต้องใช้แบบนี้แทนที่จะเขียน transform.position += delta ใน Update เฉย ๆ? เพราะ MovePosition ยังคงลงทะเบียนตัวละครไว้กับระบบ physics: trigger volume (OnTriggerEnter) ทำงานได้อย่างเชื่อถือได้ Rigidbody แบบ dynamic ตัวอื่นถูกดันได้ถูกต้อง และ interpolation (หัวข้อ 12) ช่วย smooth การเคลื่อนที่ระหว่าง physics tick ได้ ส่วนการ teleport Transform ตรง ๆ ไม่ทำอะไรแบบนั้นเลย และเป็นสาเหตุยอดฮิตของอาการ "ผู้เล่นเดินผ่าน trigger แล้วไม่มีอะไรเกิดขึ้น" kinematic Rigidbody คือรากฐานที่ custom controller เต็มรูปแบบส่วนใหญ่สร้างอยู่บนนั้น — คุณได้ การบันทึกบัญชี ของ physics ที่ถูกต้อง ในขณะที่ทำการเคลื่อนที่และ collision จริง ๆ เองทั้งหมด (ผ่าน cast, หัวข้อ 5 และ 10)

Rigidbody กับ CharacterController — เลือกยังไง

Dynamic Rigidbody Kinematic Rigidbody CharacterController (isKinematic = false) (isKinematic = true) (built-in capsule) ---------------------- ----------------------- ---------------------- physics moves you: YOU move it (MovePosition); YOU move it (Move); forces, gravity, physics does not. physics does not. momentum, bouncing no auto collision - you collide-and-slide, you CAN be pushed by cast ahead and stop isGrounded, slopeLimit, explosions and objects yourself stepOffset all built in best for: vehicles, nothing pushes it, but it nothing pushes it ragdolls, physics still fires triggers, best for: standard puzzles, momentum- pushes dynamics, and humanoid walk / run / driven characters interpolates jump with the least code best for: fully custom movers needing exact control + real events

เกมแพลตฟอร์ม 3 มิติ, action, และเกมยิงส่วนใหญ่ (แนวเกมที่ HoYoverse และสตูดิโอคล้าย ๆ กันปล่อยออกมา) ใช้ CharacterController หรือตัวเคลื่อนที่แบบ kinematic ที่เขียนเองทั้งหมด ก็เพราะเหตุผลนี้เป๊ะ ๆ: คุมเกม feel ได้เต็มที่ ต่อจากนี้ เทคนิคการปรับแต่ง (acceleration, ground check, จังหวะกระโดด) จะอธิบายด้วยโค้ดสไตล์ CharacterController เพราะมันเป็นเครื่องมือสอนที่เข้าใจง่ายกว่า แต่ทุกเทคนิคใช้ได้กับตัวเคลื่อนที่แบบ Rigidbody เหมือนกัน — คุณแค่เอา velocity ที่คำนวณได้ไปใส่ rb.velocity แทนที่จะส่งเข้า Move()

4. การเคลื่อนที่แนวราบ: acceleration กับ friction

หัวข้อ 2 แสดงปัญหาให้เห็นแล้ว: การกระโดดตรงไปความเร็วสูงสุดแล้วกระโดดกลับ 0 ทันทีให้ความรู้สึกแข็งทื่อ ความผิดพลาดตรงข้ามก็แย่พอกัน — smooth มากเกินไปจนตัวละครยังคงไถลต่อไปเรื่อย ๆ หลังจากปล่อยจอย เหมือนยืนอยู่บนน้ำแข็ง นี่คืออาการ "floaty" (ลอย ๆ ไม่หนักแน่น) วิธีแก้ทั้งสองปัญหาคือไอเดียเดียวกันที่เกมใช้กันมาตลอด: accelerate (เร่งความเร็ว) เข้าหาความเร็วเป้าหมายแทนที่จะ teleport ไปเลย และ decelerate (ลดความเร็ว โดยใช้ friction) กลับไปที่ 0 เมื่อไม่มี input ในอัตราที่คุณควบคุมได้

public float maxSpeed = 7f;         // top horizontal speed, units/second
public float acceleration = 60f;    // how fast we speed UP, units/second^2
public float friction = 45f;        // how fast we slow DOWN with no input

Vector3 horizontalVelocity;         // current x/z velocity, carried frame to frame

void UpdateHorizontalVelocity()
{
    float x = Input.GetAxisRaw("Horizontal");
    float z = Input.GetAxisRaw("Vertical");
    Vector3 wishDir = new Vector3(x, 0f, z).normalized;

    Vector3 targetVelocity = wishDir * maxSpeed;
    float rate = (wishDir.sqrMagnitude > 0.0001f) ? acceleration : friction;

    horizontalVelocity = Vector3.MoveTowards(horizontalVelocity, targetVelocity, rate * Time.deltaTime);
}

Vector3.MoveTowards(current, target, maxDelta) จะขยับ current เข้าหา target ทีละไม่เกิน maxDelta หน่วย และหยุดพอดีที่เป้าหมายโดยไม่แซงเลยไป — เหมาะกับงานนี้มาก เมื่อมี input เราจะไล่ตาม maxSpeed ด้วยอัตรา acceleration เมื่อไม่มี input เป้าหมายจะกลายเป็นศูนย์ แล้วเราไล่ตามมันด้วยอัตรา friction (ซึ่งมักจะเร็วกว่า) ทำให้การหยุดรู้สึกฉับไวกว่าการเริ่มเดิน ซึ่งเป็นสิ่งที่เกม action ส่วนใหญ่ต้องการ

ไล่ดูทีละสเต็ปที่ 60 FPS คงที่ (Time.deltaTime = 1/60 ≈ 0.0167s) เริ่มจากหยุดนิ่ง แล้วกดปุ่มเดินค้างไว้: แต่ละเฟรมความเร็วเปลี่ยนได้มากสุด acceleration * deltaTime = 60 * (1/60) = 1.0 หน่วย/วินาที

frame: 0 1 2 3 4 5 6 7 speed (u/s): 0.0 1.0 2.0 3.0 4.0 5.0 6.0 7.0 <- reaches maxSpeed here key released at frame 7, friction = 45 -> step = 45/60 = 0.75 u/s per frame frame: 7 8 9 10 11 ... 17 speed (u/s): 7.0 6.25 5.50 4.75 4.00 ... 0.25 -> 0 soon after

สังเกตว่าใช้เวลา 7 เฟรม (ประมาณ 0.12 วินาที) ถึงจะถึงความเร็วเต็มที่ และประมาณ 10 เฟรม (ประมาณ 0.16 วินาที) กว่าจะหยุดสนิท ทั้งสองอย่างเร็วพอที่จะรู้สึกตอบสนองไว แต่ไม่ใช่ทันทีทันใด การไล่ระดับแบบนุ่ม ๆ นี่แหละคือสิ่งที่แยก control แบบ "แน่น กระชับ" ออกจากแบบ "แข็งทื่อ" เป็นส่วนใหญ่

speed ^ 7 | instant (bad): step function, snaps up and down | ____ | | |________ | | tuned (good): short ramp up, short ramp down | ___________ | / \ | / \___ | | floaty (bad): slow mushy curve, keeps drifting after input stops | ....................... | .. ....... 0 +----------------------------------------------> time
Tip ให้ถือว่า acceleration, friction, และ maxSpeed เป็นสามปุ่มแรกที่คุณปรับให้ตัวละครไหนก็ตาม ก่อนจะไปแตะอย่างอื่น แค่เพิ่ม acceleration เป็นสองเท่าอย่างเดียว ก็ทำให้ตัวละครรู้สึกกระฉับกระเฉงขึ้นอย่างชัดเจนโดยไม่เปลี่ยนความเร็วสูงสุดเลย นี่คือการปรับแต่งที่ลงทุนน้อยแต่ได้ผลมากที่สุดอย่างหนึ่งในทั้งระบบ
Common mistake ใช้ Vector3.Lerp(current, target, t) โดยใส่ t คงที่ทุกเฟรมแทนที่จะใช้ MoveTowards การทำ Lerp ด้วย t คงที่จะเข้าใกล้เป้าหมายแบบ asymptote และในทางคณิตศาสตร์ไม่มีวันถึงเป้าหมายจริง ๆ สักที — มันคือกราฟแบบ "floaty ไม่เคยนิ่ง" ที่เห็นด้านบนเป๊ะ ๆ แถมความเร็วจริงของมันยังเปลี่ยนไปตาม frame rate ในแบบที่คาดเดายาก ส่วน MoveTowards ที่ใช้อัตราหน่วย/วินาที^2 ชัดเจน จะถึงเป้าหมายได้สะอาดและไม่ขึ้นกับ frame rate

การควบคุมบนพื้นกับกลางอากาศ: ปุ่มปรับสองชุด

การใช้ acceleration กับ friction ค่าเดียวทุกที่ซ่อนปัญหาที่คุณจะรู้สึกได้ทันทีที่กระโดด: friction ตัวเดียวกันที่ทำให้คุณหยุดกึกบนพื้น ก็จะฆ่าความเร็วแนวราบของคุณ กลางอากาศ ด้วย ดังนั้นการวิ่งแล้วกระโดดจะดับสนิททันทีที่คุณปล่อยจอยเหนือช่องว่าง controller จริง ๆ จะแยกค่าพวกนี้เป็นสองชุด — ตอนอยู่บนพื้นกับตอนลอยอยู่ — เพราะกลางอากาศมักต้องการ friction น้อยกว่ามาก (เพื่อให้การกระโดดยังคงโมเมนตัมไว้) และ acceleration น้อยลงนิดหน่อย (เพื่อให้คุณผูกมัดกับส่วนโค้งการกระโดด แต่ยังขยับปรับได้เล็กน้อย)

public float groundAccel    = 60f;
public float groundFriction = 45f;
public float airAccel    = 25f;   // weaker steering while airborne
public float airFriction = 6f;    // almost no drag: keep run momentum through a jump

void UpdateHorizontalVelocity()
{
    float x = Input.GetAxisRaw("Horizontal");
    float z = Input.GetAxisRaw("Vertical");
    Vector3 wishDir = new Vector3(x, 0f, z).normalized;

    Vector3 targetVelocity = wishDir * maxSpeed;
    bool moving = wishDir.sqrMagnitude > 0.0001f;

    float accel    = isGrounded ? groundAccel    : airAccel;
    float friction = isGrounded ? groundFriction : airFriction;
    float rate = moving ? accel : friction;

    horizontalVelocity = Vector3.MoveTowards(horizontalVelocity, targetVelocity, rate * Time.deltaTime);
}

สิ่งที่ผู้เล่นรู้สึก: เมื่อ airFriction เกือบเป็นศูนย์ การวิ่งหลุดขอบไปจะพาความเร็วเต็มของคุณข้ามช่องว่างไปได้แม้จะปล่อยจอยแล้ว — ความรู้สึกอันเป็นเอกลักษณ์ของเกมแพลตฟอร์มที่แม่นยำ เมื่อ airAccel ต่ำกว่า groundAccel คุณยังบังคับเลี้ยวได้นิดหน่อยกลางกระโดด แต่ไม่สามารถกลับทิศทันทีกลางอากาศได้ ซึ่งถ้าทำได้จะดูไร้น้ำหนักและผิดธรรมชาติ ค่าพวกนี้เป็นการเลือกดีไซน์ ไม่ใช่กฎตายตัว — เกม action-platformer ที่กระชับอย่าง Celeste จงใจใช้ air control สูง ประเด็นคือพื้นกับอากาศสมควรมีตัวเลขของตัวเอง เพื่อให้คุณปรับแต่งแต่ละอย่างแยกกันได้

5. Ground check: raycast กับ overlap sphere

เกือบทุกการตัดสินใจในบทนี้ — กระโดดได้ไหม แรงโน้มถ่วงควรมีผลไหม เกาะติดทางลาดไหม — ขึ้นอยู่กับคำถาม boolean ข้อเดียว: ตอนนี้ตัวละครแตะพื้นอยู่หรือเปล่า? ฟังดูเป็นเรื่องเล็กน้อย แต่จริง ๆ แล้วเป็นส่วนที่ยุ่งยากที่สุดส่วนหนึ่งของ character controller

CharacterController.isGrounded มีอยู่แล้วและเป็นจุดเริ่มต้นที่โอเค แต่มันมีนิสัยแปลก ๆ ที่รู้กันดี: มันจะอัปเดตก็ต่อเมื่อมีการเรียก Move() เท่านั้น และอาจรายงาน false ไปหนึ่งเฟรมทั้งที่ยืนอยู่บนพื้นราบ ๆ หรือรายงาน true สั้น ๆ หนึ่งเฟรมทันทีหลังจากเดินหลุดขอบไปแล้ว เพราะมันอ้างอิงจากว่า collision ของการเรียก Move() ครั้งล่าสุดแตะอะไรภายใน skin width เล็ก ๆ ของ controller หรือเปล่า (บัฟเฟอร์เล็ก ๆ ไม่กี่มิลลิเมตรที่ Unity เผื่อไว้รอบ collider เพื่อไม่ให้ติดค้าง) controller ที่ปล่อยจริงส่วนใหญ่ไม่เชื่อมันเพียงอย่างเดียว — พวกเขาจะเพิ่ม ground check ของตัวเองไว้ใต้เท้าตัวละครด้วย

เครื่องมือมาตรฐานสองตัวสำหรับเรื่องนี้คือ raycast (เส้นบาง ๆ ที่มองไม่เห็น ยิงออกจากจุดหนึ่งไปทิศทางหนึ่ง แล้วถามว่า "เส้นนี้ชนอะไรเป็นอันแรก?") และ overlap sphere (ลูกบอลเล็ก ๆ ที่มองไม่เห็น วางไว้ที่ตำแหน่งหนึ่ง แล้วถามว่า "ตอนนี้มีอะไรแตะลูกบอลนี้อยู่ไหม?") Unity มีให้ทั้งสองแบบ:

public Transform groundCheck;        // empty child GameObject, placed right at the feet
public float groundCheckRadius = 0.2f;
public LayerMask groundMask;         // set in the Inspector to ONLY the "Ground" layer

bool isGrounded;

void CheckGround()
{
    // Overlap sphere: "is anything on the Ground layer touching this little ball?"
    isGrounded = Physics.CheckSphere(groundCheck.position, groundCheckRadius, groundMask);
}

bool GroundedByRaycast(float castDistance)
{
    // Raycast: "what is directly below the character's center, within castDistance?"
    return Physics.Raycast(transform.position, Vector3.down, castDistance, groundMask);
}
character capsule +-----------------+ | | | o <- transform.position (capsule center) | | | | raycast, length = castDistance | v |_________________| o <- groundCheck transform, at the feet ( ) <- CheckSphere, radius = groundCheckRadius ========================================== ground collider, Layer "Ground"

ลูกบอลเล็ก ๆ ที่เท้ามักจะให้อภัยมากกว่า raycast เส้นเดียวที่บาง ๆ: ตอนยืนอยู่พอดีตรงขอบขั้นบันไดหรือขอบแพลตฟอร์ม ray เส้นเดียวที่ยิงตรงลงจากจุดกึ่งกลางอาจพลาดพื้นไปเลยทั้งที่เท้าของตัวละครยังแตะพื้นอยู่ชัด ๆ ทรงกลมที่มีรัศมีเล็ก ๆ จะครอบคลุมกรณีขอบ ๆ แบบนี้ได้ controller จริง ๆ หลายตัวรันทั้งสองอย่าง: เช็คด้วยทรงกลมสำหรับ flag "ฉันแตะพื้นไหม" ที่ใช้ประจำ บวกกับ raycast แยกต่างหากตอนที่ต้องการ surface normal ของพื้น (ทิศที่มันหันหน้าไป — ใช้กับทางลาดในหัวข้อ 10)

Common mistake ตั้งค่า layer mask ของ ground check เป็น "Everything" แทนที่จะเป็น layer "Ground" เฉพาะทาง ถ้า capsule collider ของผู้เล่นเอง, ไอเทมที่ถืออยู่, หรือตัวละครอื่นอยู่ใน mask ด้วย CheckSphere อาจคืนค่า true เพราะมันซ้อนทับกับ ตัวมันเอง หรือตัวละครใกล้ ๆ ไม่ใช่พื้น — ทำให้เกิดบั๊กคลาสสิกที่การกระโดดดูเหมือนจะทำงานมั่ว ๆ เวลายืนใกล้กำแพงหรือผู้เล่นอื่น ให้แยก layer เฉพาะสำหรับพื้นเสมอ แล้วตั้ง mask ให้ตรงกับ layer นั้นเท่านั้น
Tip วาด ground check ของคุณออกมาทุกเฟรมด้วย Debug.DrawRay หรือ Gizmos ระหว่างที่ปรับแต่งค่า (Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius) ไว้ใน OnDrawGizmosSelected) คุณปรับค่าที่มองไม่เห็นไม่ได้หรอก — เรื่องนี้จริงกับตัวเลขเกือบทุกตัวในบทนี้

6. คำนวณความสูงกระโดดจาก apex ที่ต้องการ

โค้ดกระโดดแรก ๆ ของมือใหม่มักจะหน้าตาเหมือน verticalVelocity = 8f; โดยเลข 8 ได้มาจากการลองผิดลองถูก แล้วต้องเดาใหม่ทุกครั้งที่แรงโน้มถ่วงเปลี่ยน มีวิธีที่ดีกว่านั้น: ตัดสินใจก่อนว่าการกระโดดควร รู้สึก ยังไง — ควรสูงแค่ไหน และควรใช้เวลานานแค่ไหนกว่าจะถึงจุดสูงสุด — แล้วค่อยคำนวณตัวเลขที่แม่นยำที่ให้ความรู้สึกแบบนั้นออกมา

เรียกความสูงสุดว่า h (ตัวละครลอยขึ้นกี่หน่วย) และเวลาที่ใช้ไปถึงจุดสูงสุดนั้นว่า t (apex — จุดบนสุดของส่วนโค้ง ที่ vertical velocity เป็นศูนย์ชั่วขณะหนึ่ง) การเคลื่อนที่แบบ acceleration คงที่พื้นฐานให้สูตรง่าย ๆ สองสูตรกับเรา:

initial upward velocity: v0 = 2h / t gravity (negative, pulls down): g = -2h / t^2

สูตรพวกนี้มาจากไหน? ที่ apex vertical velocity จะลดลงมาเหลือ 0 พอดี ดังนั้น v0 = -g * t เอาไปแทนในสูตรความสูงมาตรฐาน h = v0*t + 0.5*g*t^2 แล้วจัดรูปให้ง่ายขึ้น ก็จะได้สองสูตรด้านบนพอดี คุณไม่ต้องมานั่งพิสูจน์ใหม่ทุกครั้ง — จำแค่ว่า: เลือกความสูงกับเวลา แล้วแทนค่าเข้าไป

public float jumpHeight = 2f;     // desired peak height, in units (meters)
public float timeToApex = 0.4f;   // desired seconds to reach that peak

float gravity;
float jumpVelocity;

void Awake()
{
    gravity      = -2f * jumpHeight / (timeToApex * timeToApex);
    jumpVelocity =  2f * jumpHeight / timeToApex;

    Debug.Log($"gravity = {gravity}, jumpVelocity = {jumpVelocity}");
}

Output (หน้าต่าง Console):

gravity = -25, jumpVelocity = 10

ลองเช็คด้วยมือ: ลอยขึ้นเป็นเวลา 0.4 วินาทีด้วยความเร็วเริ่มต้น 10 หน่วย/วินาที ขณะที่ลดความเร็วด้วยอัตรา 25 หน่วย/วินาที^2 ตัวละครจะมี vertical speed เป็น 0 พอดีที่ t = 10 / 25 = 0.4s — ตรงกัน ความสูงที่ได้: 10*0.4 - 0.5*25*0.4^2 = 4 - 2 = 2 — ตรงกับ jumpHeight เป๊ะ ใส่ตัวเลขไปแค่สองตัว แล้วส่วนโค้งทั้งหมดก็ออกมาเป็นรูปทรงที่คุณต้องการพอดี

void Update()
{
    if (isGrounded && Input.GetButtonDown("Jump"))
        verticalVelocity = jumpVelocity;

    verticalVelocity += gravity * Time.deltaTime;
    // ... feed verticalVelocity into controller.Move() as in section 3
}
Tip ปรับด้วยความรู้สึก ไม่ใช่ด้วยสัญชาตญาณจากสูตร เกม action platformer ส่วนใหญ่ใช้ timeToApex ที่ค่อนข้างสั้น (0.25–0.45 วินาที) — การลอยขึ้นช้า ๆ นาน ๆ จะให้ความรู้สึก "floaty" แม้คณิตศาสตร์จะถูกต้องก็ตาม เพราะการกระโดดในโลกจริงให้ความรู้สึกฉับไว การปรับ timeToApex เพียงเล็กน้อยเป็นหนึ่งในการปรับตัวเลขเดี่ยว ๆ ที่ส่งผลมากที่สุดในบทนี้ทั้งหมด

7. Variable jump height: กระโดดสั้นกับกระโดดเต็ม

ถ้าใช้ gravity ค่าคงที่ค่าเดียว การกระโดดทุกครั้ง — ไม่ว่าจะแตะปุ่มแค่เฟรมเดียวหรือกดค้างไว้ตลอด — จะไปถึงความสูงเท่ากันเป๊ะ เทียบกับเกมอย่าง Mario หรือ Celeste ที่แตะปุ่มกระโดดสั้น ๆ จะได้กระโดดต่ำ ๆ และกดค้างจะได้กระโดดเต็มที่ ฟีเจอร์เดียวนี้ทำให้การเล่นแพลตฟอร์มควบคุมได้มากขึ้นเยอะ เพราะผู้เล่นสามารถตัดสินใจความสูงของการกระโดดกลางอากาศได้ด้วยการเลือกจังหวะปล่อยปุ่ม

เคล็ดลับคือ: ใส่แรงโน้มถ่วง เพิ่ม ในสองสถานการณ์เฉพาะ — ตอนกำลังตก (ใส่เสมอ เพื่อให้การตกฉับไว ไม่ลอย) และตอนยังลอยขึ้นอยู่ ถ้าปุ่มกระโดดถูกปล่อยไปแล้ว (นี่คือสิ่งที่ตัด "กระโดดสั้น" ให้จบเร็วขึ้น):

public float fallMultiplier = 2.5f;     // extra gravity while falling
public float lowJumpMultiplier = 2f;    // extra gravity while rising but button released

void ApplyGravity()
{
    bool jumpHeld = Input.GetButton("Jump");

    if (verticalVelocity < 0f)
    {
        // Falling: always fall faster than we rose. Feels weighty, not floaty.
        verticalVelocity += gravity * (fallMultiplier - 1f) * Time.deltaTime;
    }
    else if (verticalVelocity > 0f && !jumpHeld)
    {
        // Rising but the button let go early: cut the jump short.
        verticalVelocity += gravity * (lowJumpMultiplier - 1f) * Time.deltaTime;
    }

    verticalVelocity += gravity * Time.deltaTime;   // the normal gravity from section 6
}

ไล่ดูทีละสเต็ปโดยใช้ตัวเลขจากหัวข้อ 6 (gravity = -25, jumpVelocity = 10) เทียบระหว่างกระโดดแบบกดค้างเต็มที่กับแตะปุ่มแล้วปล่อยทันทีตอนออกตัว (lowJumpMultiplier = 2):

full jump (button held the whole way up): effective gravity while rising = -25 (normal) time to apex = 10 / 25 = 0.4s peak height = 10^2 / (2*25) = 2.0 units short hop (button released immediately at takeoff): effective gravity while rising = -25 * 2 = -50 (low jump multiplier applied) time to apex = 10 / 50 = 0.2s peak height = 10^2 / (2*50) = 1.0 units same initial launch speed (10 u/s), HALF the peak height, because the player let go of the button — the player controls height mid-air.

สังเกตว่าการกระโดดทั้งสองแบบเริ่มด้วยแรงดันออกจากพื้นเท่ากันเป๊ะ (jumpVelocity = 10) — สิ่งที่เปลี่ยนไปมีแค่ว่าแรงโน้มถ่วงดึงแรงแค่ไหนหลังจากนั้น และแรงดึงนั้นขึ้นอยู่กับ input ของผู้เล่น ระหว่าง การกระโดด ไม่ใช่แค่ตอนออกตัวเท่านั้น นี่คือเคล็ดลับทั้งหมด

Common mistake เช็ค Input.GetButtonUp("Jump") ครั้งเดียวแล้วเปลี่ยนไปใช้ค่า gravity ที่เล็กลงแบบถาวร วิธีนี้จะตัดกระโดดได้ก็ต่อเมื่อผู้เล่นปล่อยปุ่ม ก่อน apex เท่านั้น ถ้าปล่อยตอนกำลังตกลงมาแล้วมันจะไม่ทำอะไรเลย แถมค่าที่ถูกเปลี่ยนแบบถาวรยังอาจหลุดไปติดค้างในการกระโดดครั้งถัดไปด้วย ให้เช็ค Input.GetButton("Jump") (สถานะกดค้าง) สด ๆ ทุกเฟรมแทน เหมือนโค้ดด้านบน — มันจะทำสิ่งที่ถูกต้องเองไม่ว่าผู้เล่นจะปล่อยเร็ว ปล่อยช้า หรือไม่ปล่อยเลย

Apex hang: ช่วงลอยนิดหน่อยตอนอยู่บนสุด

ยังมีชิ้นส่วนของ jump feel อีกอย่างที่มืออาชีพปรับแต่งแต่มือใหม่มองข้าม: apex modifier หรือ "apex hang" พาราโบลาธรรมดาเคลื่อนที่ช้าที่สุดที่จุดสูงสุดอยู่แล้ว แต่เกมแพลตฟอร์มดี ๆ หลายเกมจงใจขยายจุดนั้นให้เกินจริง — ใกล้ ๆ ยอดของการกระโดด ที่ vertical velocity ใกล้ศูนย์ พวกเขาจะ ลด แรงโน้มถ่วงลงชั่วครู่ และมักเพิ่มความเร็วแนวราบเล็กน้อย ผลลัพธ์คือช่วงลอยค้างบาง ๆ ที่ยอด ซึ่งให้เวลาผู้เล่นเล็งจุดลงพื้นมากขึ้น และทำให้การกระโดดดูมีชีวิตชีวาแทนที่จะเป็นส่วนโค้งแข็ง ๆ

public float apexThreshold   = 3f;    // |verticalVelocity| under this = "near the apex"
public float apexGravityMult = 0.5f;  // soften gravity to half strength at the apex
public float apexSpeedBonus  = 1.15f; // small horizontal speed boost at the apex

bool NearApex()
{
    return !isGrounded && Mathf.Abs(verticalVelocity) < apexThreshold;
}

// scale the normal gravity term while hanging near the top:
float g = gravity;
if (NearApex()) g *= apexGravityMult;
verticalVelocity += g * Time.deltaTime;

// and let horizontal top speed rise a touch at the apex:
float speed = NearApex() ? maxSpeed * apexSpeedBonus : maxSpeed;

ทำไมต้อง Mathf.Abs? ช่วงลอยค้างควรครอบคลุมทั้งช่วงท้ายของการลอยขึ้น และ ช่วงต้นของการตก — ทั้งบริเวณที่เคลื่อนที่ช้ารอบ ๆ ยอด — ดังนั้นคุณจึงทดสอบขนาดของ vertical velocity ไม่ใช่เครื่องหมายของมัน ค่าควรอ่อนโยน: threshold แค่ไม่กี่หน่วยต่อวินาที กับแรงโน้มถ่วงครึ่งหนึ่งก็เหลือเฟือ ถ้าทำเกินไปตัวละครจะดูเหมือนไปติดขอบที่มองไม่เห็นตรงยอดทุกครั้งที่กระโดด ถ้าทำถูก ผู้เล่นจะไม่มีทางสังเกตกลไกนี้เลย — พวกเขาแค่รู้สึกว่าการกระโดด "ไปถึง"

8. Coyote time: ให้อภัยการกระโดดที่กดหลังหลุดขอบไปแล้วนิดหน่อย

ชื่อมาจากโคโยตี้ในการ์ตูนที่วิ่งหลุดหน้าผาไปแล้วไม่ตกจนกว่าจะก้มมองลงไป: coyote time คือหน้าต่างเวลาผ่อนผันสั้น ๆ (ปกติ 80–150 มิลลิวินาที) หลังจากตัวละครเดินหลุดขอบไปแล้ว ที่ input กระโดดยังคงทำงานได้อยู่ เหมือนกับว่ายังยืนอยู่บนพื้นแข็ง ๆ ถ้าไม่มีสิ่งนี้ ผู้เล่นที่กดกระโดดช้าไปแค่หนึ่งเฟรม — ซึ่งเป็นจังหวะที่มนุษย์กดพลาดได้ปกติมาก ไม่ใช่ความผิดพลาดอะไร — จะแค่ตกลงไปเฉย ๆ และมันจะรู้สึกไม่แฟร์ เพราะมองจากภาพแล้วตัวละครก็ยัง "อยู่บนแพลตฟอร์มแทบจะพอดี"

grounded ============================| | (character walks off the edge here, t = 0.00s) time -> 0.00s 0.05s 0.10s 0.20s |<--- coyote window (0.10s) -->| (jump still allowed in here, as if still grounded) jump pressed at 0.05s -> INSIDE the window -> jump succeeds (a "coyote save") jump pressed at 0.15s -> OUTSIDE the window -> too late, character just falls
public float coyoteTime = 0.1f;   // seconds of grace after leaving the ground
float coyoteTimeCounter;

void Update()
{
    if (isGrounded)
        coyoteTimeCounter = coyoteTime;      // reset the grace window while grounded
    else
        coyoteTimeCounter -= Time.deltaTime; // count down while airborne

    bool canJump = coyoteTimeCounter > 0f;

    if (canJump && Input.GetButtonDown("Jump"))
    {
        verticalVelocity = jumpVelocity;
        coyoteTimeCounter = 0f;              // used it up, no double-dipping
    }
}

ตัวนับจะรีเซ็ตกลับเป็น coyoteTime ทุกเฟรมที่ตัวละครแตะพื้นอยู่ และจะเริ่มนับถอยหลังก็ต่อเมื่อลอยขึ้นจากพื้นเท่านั้น — ดังนั้นไม่สำคัญว่า ทำไม ตัวละครถึงหลุดจากพื้น (เดินหลุดขอบ หรือพื้นเลื่อนหายไปจากใต้เท้า) หน้าต่างเวลานี้จะทำงานแบบเดียวกันเสมอ การตั้ง coyoteTimeCounter = 0f หลังกระโดดสำเร็จ จะป้องกันไม่ให้ผู้เล่นกระโดดซ้ำกลางอากาศได้อีกโดยบังเอิญ

Tip 100 มิลลิวินาที (0.1 วินาที) เป็นค่าเริ่มต้นที่นิยมใช้กัน — นานพอที่จะแก้ปัญหา "ไม่แฟร์" แบบเฉียดฉิว แต่สั้นพอที่ผู้เล่นจะไม่มีทางสังเกตว่ามีการกระโดดกลางอากาศจริง ๆ เกิดขึ้น ผู้เล่นส่วนใหญ่ที่ได้ประโยชน์จาก coyote time ไม่เคยรู้ตัวเลยว่ามันมีอยู่ พวกเขาแค่รู้สึกว่า control "แฟร์ดี"

9. Jump buffering: จำการกดกระโดดที่กดเร็วไปนิดหน่อยไว้

Jump buffering เป็นภาพสะท้อนของ coyote time แทนที่จะให้อภัยการกดกระโดด ช้า ไปนิดหน่อย (หลังหลุดจากพื้นแล้ว) มันจะให้อภัยการกดกระโดด เร็ว ไปนิดหน่อย (ก่อนแตะพื้น) ผู้เล่นที่กดกระโดดรัว ๆ ก่อนตัวละครจะลงพื้น — ก็เป็นจังหวะมนุษย์ปกติอีกเช่นกัน เวลาพยายามต่อกระโดดทันทีหลังลงพื้น — ไม่ควรถูกทิ้งการกดนั้นไปเงียบ ๆ แค่เพราะตัวละครยังลอยอยู่เหนือพื้นอีกหนึ่งหรือสองเฟรม

time -> ... 0.40s 0.50s (character lands) |<------- buffer window (0.15s) ------->| jump pressed here (still airborne) buffered jump fires HERE press at 0.40s, lands at 0.50s: gap = 0.10s < 0.15s buffer -> jump fires on landing press at 0.25s, lands at 0.50s: gap = 0.25s > 0.15s buffer -> buffer expired, dropped
public float jumpBufferTime = 0.15f;   // seconds a jump press is remembered
float jumpBufferCounter;

void Update()
{
    if (Input.GetButtonDown("Jump"))
        jumpBufferCounter = jumpBufferTime;   // remember this press
    else
        jumpBufferCounter -= Time.deltaTime;  // memory fades over time

    bool wantsToJump = jumpBufferCounter > 0f;

    if (isGrounded && wantsToJump)
    {
        verticalVelocity = jumpVelocity;
        jumpBufferCounter = 0f;               // consumed, don't fire twice
    }
}

รูปแบบนี้ตั้งใจให้สมมาตรกับ coyote time: coyote time ถามว่า "เมื่อกี้ฉันแตะพื้นมาไม่นานพอไหม?" ส่วน jump buffering ถามว่า "ฉันกดกระโดดมาไม่นานพอไหม?" ทั้งสองอย่างเป็นแค่ตัวนับถอยหลังที่รีเซ็ตเมื่อมีเหตุการณ์ที่กระตุ้นมัน แล้วเช็คว่ามากกว่าศูนย์หรือเปล่า ใน controller จริง ๆ คุณจะรวมทั้งสองเงื่อนไขเข้าด้วยกันเป็นเงื่อนไขกระโดดเดียว:

bool canJump = (isGrounded || coyoteTimeCounter > 0f);
bool wantsToJump = jumpBufferCounter > 0f;

if (canJump && wantsToJump)
{
    verticalVelocity = jumpVelocity;
    coyoteTimeCounter = 0f;
    jumpBufferCounter = 0f;
}
Tip coyote time กับ jump buffering แทบไม่มีต้นทุนในการทำเลย — float สองตัวกับ if สองอัน — และเป็นสองอย่างที่ให้อัตราส่วน "การปรับปรุง game feel" ต่อ "จำนวนบรรทัดโค้ด" สูงที่สุดในบทนี้ทั้งหมด เกมแพลตฟอร์มที่ได้รีวิวดี ๆ เกือบทุกเกมใช้ทั้งสองอย่างนี้ ถึงแม้ตัวเลขพวกนี้จะไม่เคยปรากฏในสื่อการตลาดเลยก็ตาม

10. ทางลาดกับขั้นบันได

พื้นราบเป็นกรณีที่ง่าย ด่านจริง ๆ มีทางลาดและขอบเล็ก ๆ อยู่ด้วย และ character controller ต้องมีกฎรองรับทั้งสองแบบ ไม่งั้นตัวละครจะไถลลงทางลาดเอียงเบา ๆ ที่ควรเดินขึ้นได้สบาย ๆ หรือติดค้างกระเด็นออกตอนอยู่บนสุดของทางลาด หรือหยุดสนิทตรงขอบเตี้ย ๆ แบบขอบถนนที่ไม่ควรทำให้ช้าลงด้วยซ้ำ

ทางลาด: normal vector ของพื้นบอกมุมให้เรา

ทุกพื้นผิวมี normal — เวกเตอร์ที่ชี้ออกมาตรง ๆ จากมัน ทำมุม 90 องศากับพื้นผิว พื้นราบจะมี normal ชี้ขึ้นตรง ๆ (Vector3.up) ส่วน normal ของทางลาดจะเอียง มุมระหว่าง normal กับแนวขึ้นตรง ๆ นั่นแหละคือความชันของทางลาด

normal vector (n) ^ \ \ ground surface \ / theta -> \ / ______angle__\/________________ theta = angle between n and Vector3.up theta <= slopeLimit -> walkable: character sticks to the surface theta > slopeLimit -> too steep: treated like a wall, character can't climb it

CharacterController มีสิ่งนี้ในตัวอยู่แล้ว: ตั้งค่า field slopeLimit (หน่วยองศา ค่าเริ่มต้น 45) ใน Inspector หรือในโค้ด แล้ว component จะให้ตัวละครเดินขึ้นอะไรที่ชันน้อยกว่านั้นได้เองโดยอัตโนมัติ และกันไม่ให้ขึ้นอะไรที่ชันกว่า พร้อมไถลตัวละครกลับลงมาถ้าทางลาดชันเกินไป ถ้าคุณเขียนตัวเคลื่อนที่แบบกำหนดเองทั้งหมด (ไม่ใช้ CharacterController ใช้ raycast ล้วน ๆ แล้วขยับตำแหน่งเอง) คุณต้องเช็คแบบเดียวกันนี้ด้วยตัวเอง:

public float maxSlopeAngle = 45f;

bool TryGetGroundNormal(out Vector3 normal)
{
    if (Physics.Raycast(transform.position, Vector3.down, out RaycastHit hit, 1.2f, groundMask))
    {
        normal = hit.normal;
        float angle = Vector3.Angle(normal, Vector3.up);
        return angle <= maxSlopeAngle;   // true only if it's walkable
    }
    normal = Vector3.up;
    return false;
}

Vector3 AdjustMoveForSlope(Vector3 moveDir, Vector3 groundNormal)
{
    // Re-aim the move direction to lie flat along the slope's surface,
    // instead of driving into it (or lifting off it) on an angle.
    return Vector3.ProjectOnPlane(moveDir, groundNormal).normalized;
}

Vector3.ProjectOnPlane(vector, planeNormal) จะเอาทิศทางหนึ่งมาแปะให้แบนราบไปตามพื้นผิวที่อธิบายด้วย normal ของพื้นผิวนั้น — นี่แหละคือความหมายทางคณิตศาสตร์ของ "เดินไปตามทางลาดนี้ ไม่ใช่เดินทะลุเข้าไปในมัน" ถ้าไม่มีขั้นตอนนี้ ตัวเคลื่อนที่แบบไร้เดียงสาจะพยายามเดินในแนวราบบนทางลาด ซึ่งจะดันตัวมันเข้าไปในทางลาดเล็กน้อย (แล้ว collider จะดันมันกลับออกมา ทำให้เกิดอาการสั่น jitter) หรือไม่ก็ทิ้งช่องว่างเล็ก ๆ ไว้

ขั้นบันได: การเปลี่ยนความสูงเล็ก ๆ ที่ควรแค่เดินข้ามไปได้เลย

step คือส่วนนูนแนวตั้งเล็ก ๆ — ขอบถนน, บันได, รากไม้ — เตี้ยพอที่คนจริง ๆ จะแทบไม่รู้สึกตัวว่าเดินข้ามมันไป แต่สูงพอที่ในทางเรขาคณิตแล้วจะถูกนับเป็นกำแพงสำหรับ capsule collider ที่ไถลไปตามพื้น CharacterController มีสิ่งนี้ในตัวอีกเช่นกัน: field stepOffset (หน่วยเดียวกับระยะทาง ปกติ 0.2–0.4) บอกความสูงสูงสุดที่มันควรปีนขึ้นเองโดยอัตโนมัติโดยไม่ต้องมี input กระโดดเลย

void Awake()
{
    controller = GetComponent<CharacterController>();
    controller.slopeLimit = 45f;
    controller.stepOffset = 0.3f;   // auto-climbs bumps up to 0.3 units tall
}

ถ้าคุณเขียนตัวเคลื่อนที่แบบกำหนดเองทั้งหมดโดยไม่ใช้ CharacterController เวอร์ชันที่ทำเองจะยิง ray ไปข้างหน้าสองเส้น: เส้นต่ำที่ระดับ "หน้าแข้ง" กับเส้นสูงกว่าที่ "ความสูงขั้นบันได" ถ้า ray เส้นต่ำชนอะไรบางอย่างแต่ ray เส้นสูงไม่ชน แสดงว่าสิ่งที่อยู่ข้างหน้าเตี้ยพอที่จะก้าวขึ้นไปได้เลย คุณก็แค่ขยับตัวละครขึ้นก่อนแล้วค่อยเดินไปข้างหน้า

Tip นี่คือเหตุผลหนักแน่นข้อหนึ่งที่หลายทีมเลือกใช้ CharacterController แทนตัวเคลื่อนที่แบบ Rigidbody ที่เขียนเองทั้งหมด: slope limit กับ step offset ที่ถ้าไม่มีสิ่งนี้จะต้องเขียนโค้ด raycasting เต็มหน้าเลย กลายเป็นแค่ field สอง field ใน Inspector

แพลตฟอร์มที่เคลื่อนที่: การยืนอยู่บนสิ่งที่ขยับได้

เอา CharacterController ไปวางบนแพลตฟอร์มที่เลื่อนไปด้านข้าง แล้วดูแพลตฟอร์มไถลออกไปจากใต้เท้ามัน — ตัวละครอยู่กับที่ในพิกัดโลกในขณะที่แพลตฟอร์มจากไป เหตุผลง่ายมาก: controller.Move ขยับตัวละครโดยอ้างอิงกับ โลก และไม่มีอะไรบอกมันว่า "พื้น" กำลังเคลื่อนที่อยู่ ตัวละครต้องถูกพาไปด้วยอย่างจงใจ วิธีที่แน่นหนาคือการบวกการเคลื่อนที่ต่อเฟรมของแพลตฟอร์มเองเข้าไปในการ move ของตัวละครทุกเฟรมที่มันยืนอยู่บนนั้น

public float castDistance = 0.4f;   // how far below the feet to look for a platform
Transform activePlatform;
Vector3 lastPlatformPos;
Vector3 platformVelocity;           // world units/second the platform is moving

void TrackPlatform()
{
    // Cast a small sphere down to find what we stand on (and its collider).
    if (Physics.SphereCast(transform.position, groundCheckRadius, Vector3.down,
                           out RaycastHit hit, castDistance, groundMask)
        && hit.transform.CompareTag("MovingPlatform"))
    {
        if (hit.transform == activePlatform)
            platformVelocity = (hit.transform.position - lastPlatformPos) / Time.deltaTime;
        else
            platformVelocity = Vector3.zero;   // just stepped on: no delta to measure yet

        activePlatform  = hit.transform;
        lastPlatformPos = hit.transform.position;
    }
    else
    {
        activePlatform  = null;
        platformVelocity = Vector3.zero;
    }
}

// fold it into the normal move so the character rides along:
Vector3 motion = horizontalVelocity + platformVelocity;
motion.y = verticalVelocity;
controller.Move(motion * Time.deltaTime);

เพราะ platformVelocity วัดจากการเคลื่อนที่จริงของแพลตฟอร์ม โค้ดชุดเดียวกันนี้จึงจัดการทั้งสายพานเลื่อนด้านข้าง, ลิฟต์ที่ลอยขึ้น, และแพลตฟอร์มที่หมุนเป็นวงกลมได้โดยไม่ต้องมีกรณีพิเศษ และมันยังตั้งค่า velocity inheritance ตอนกระโดด ได้อย่างสะอาดด้วย ขณะอยู่บนพื้นการพาไปจะอยู่แค่ตราบใดที่คุณยังแตะแพลตฟอร์มอยู่ ดังนั้นเฟรมที่คุณกดกระโดดคุณจึงรวมความเร็วนั้นเข้าไปในความเร็วของตัวเอง:

horizontalVelocity += new Vector3(platformVelocity.x, 0f, platformVelocity.z);

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

Common mistake การพาผู้เล่นไปด้วยการ parent (transform.SetParent(platform)) เข้ากับแพลตฟอร์มที่มี scale ไม่เท่ากันทุกแกนหรือ scale ที่ถูก animate อยู่ การ parent จะสืบทอด scale ของ parent มา ดังนั้นตัวละครอาจยืดหรือหดให้เห็นชัด ๆ ขณะโดยสารอยู่ การ parent ใช้ได้ดีกับแพลตฟอร์มที่ scale เท่ากันทุกแกน แต่วิธีบวก-เดลตาด้านบนเลี่ยงปัญหาเรื่อง scale และการหมุนได้ทั้งหมด

11. รวมทุกอย่างเข้าด้วยกัน และทำไม game feel ถึงอยู่ตรงนี้

นี่คือ controller แบบเต็มที่รวมทุกชิ้นส่วนจากบทนี้เข้าด้วยกัน: การเคลื่อนที่แบบ acceleration/friction (หัวข้อ 4), ground check เฉพาะทาง (หัวข้อ 5), ความสูงกระโดดจาก apex (หัวข้อ 6), variable jump height (หัวข้อ 7), coyote time (หัวข้อ 8), และ jump buffering (หัวข้อ 9) ส่วนทางลาดกับขั้นบันได (หัวข้อ 10) จัดการด้วย field สองตัวของ CharacterController ที่ตั้งค่าไว้ใน Awake

using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public class PlayerMovement : MonoBehaviour
{
    [Header("Horizontal movement")]
    public float maxSpeed = 7f;
    public float acceleration = 60f;
    public float friction = 45f;

    [Header("Jump shape")]
    public float jumpHeight = 2f;
    public float timeToApex = 0.4f;
    public float fallMultiplier = 2.5f;
    public float lowJumpMultiplier = 2f;

    [Header("Game feel windows")]
    public float coyoteTime = 0.1f;
    public float jumpBufferTime = 0.15f;

    [Header("Ground check")]
    public Transform groundCheck;
    public float groundCheckRadius = 0.2f;
    public LayerMask groundMask;

    CharacterController controller;
    Vector3 horizontalVelocity;
    float verticalVelocity;
    float gravity;
    float jumpVelocity;
    float coyoteTimeCounter;
    float jumpBufferCounter;
    bool isGrounded;

    void Awake()
    {
        controller = GetComponent<CharacterController>();
        controller.slopeLimit = 45f;
        controller.stepOffset = 0.3f;

        gravity      = -2f * jumpHeight / (timeToApex * timeToApex);
        jumpVelocity =  2f * jumpHeight / timeToApex;
    }

    void Update()
    {
        isGrounded = Physics.CheckSphere(groundCheck.position, groundCheckRadius, groundMask);

        UpdateHorizontalVelocity();
        UpdateJumpTimers();
        ApplyGravityAndJump();

        Vector3 motion = horizontalVelocity;
        motion.y = verticalVelocity;
        controller.Move(motion * Time.deltaTime);
    }

    void UpdateHorizontalVelocity()
    {
        float x = Input.GetAxisRaw("Horizontal");
        float z = Input.GetAxisRaw("Vertical");
        Vector3 wishDir = new Vector3(x, 0f, z).normalized;

        Vector3 targetVelocity = wishDir * maxSpeed;
        float rate = (wishDir.sqrMagnitude > 0.0001f) ? acceleration : friction;
        horizontalVelocity = Vector3.MoveTowards(horizontalVelocity, targetVelocity, rate * Time.deltaTime);
    }

    void UpdateJumpTimers()
    {
        coyoteTimeCounter = isGrounded ? coyoteTime : coyoteTimeCounter - Time.deltaTime;

        if (Input.GetButtonDown("Jump"))
            jumpBufferCounter = jumpBufferTime;
        else
            jumpBufferCounter -= Time.deltaTime;
    }

    void ApplyGravityAndJump()
    {
        bool jumpHeld = Input.GetButton("Jump");
        bool canJump = isGrounded || coyoteTimeCounter > 0f;
        bool wantsToJump = jumpBufferCounter > 0f;

        if (isGrounded && verticalVelocity < 0f)
            verticalVelocity = -2f;   // stay pressed to the ground / slopes

        if (canJump && wantsToJump)
        {
            verticalVelocity = jumpVelocity;
            coyoteTimeCounter = 0f;
            jumpBufferCounter = 0f;
        }
        else if (verticalVelocity < 0f)
        {
            verticalVelocity += gravity * (fallMultiplier - 1f) * Time.deltaTime;
        }
        else if (verticalVelocity > 0f && !jumpHeld)
        {
            verticalVelocity += gravity * (lowJumpMultiplier - 1f) * Time.deltaTime;
        }

        verticalVelocity += gravity * Time.deltaTime;
    }
}

นี่คือลูปหลักโดยตั้งใจ ไม่ใช่เพดานสูงสุด การปรับแต่งเสริมแต่ละอย่างจากบทนี้ต่อเข้ากับมันได้ตรง ๆ โดยไม่ต้องรื้อโครงสร้าง: การควบคุมบนพื้นกับกลางอากาศ (หัวข้อ 4) แค่สลับค่าคงที่สองตัวใน UpdateHorizontalVelocity; apex hang (หัวข้อ 7) ปรับสเกลเทอมแรงโน้มถ่วงเมื่อ Mathf.Abs(verticalVelocity) มีค่าน้อย; การพาผู้เล่นไปบนแพลตฟอร์มที่เคลื่อนที่ (หัวข้อ 10) บวกความเร็วต่อเฟรมของแพลตฟอร์มเข้าไปในการ Move ตัวสุดท้าย การที่โครงเดียวกันรองรับทั้งหมดได้โดยไม่ต้องเขียนใหม่นี่แหละคือเหตุผลที่แต่ละไอเดียอยู่ในเมธอดเล็ก ๆ ของตัวเอง

ไม่มีส่วนไหนในสคริปต์นี้ที่ซับซ้อนเมื่อแยกดูทีละส่วน — มันคือไอเดียเล็ก ๆ ที่มีชื่อเรียกเก้าอย่างเรียงซ้อนกันขึ้นมา แต่ละอย่างแก้ปัญหาแค่หนึ่งอย่างเป๊ะ ๆ การเรียงซ้อนแบบนี้คือบทเรียนที่แท้จริงของบทนี้: "การเคลื่อนที่" ในเกมที่ปล่อยจริงไม่เคยเป็นทริกฉลาด ๆ ทริกเดียว มันคือกองของการตัดสินใจเล็ก ๆ ที่ตั้งใจทำ และทุกอย่างในนั้นเป็นตัวเลขหรือกฎที่คุณสัมผัสได้ด้วยมือตัวเองด้วยการเปลี่ยนค่ามันแล้วลองเล่นดู

นี่ก็เป็นเหตุผลที่ "ความรู้สึก" ของเกมส่วนใหญ่อยู่ในระบบนี้โดยเฉพาะ มากกว่าที่ไหนอื่นในโค้ดเบสแทบทั้งหมด มือของผู้เล่นอยู่บนอุปกรณ์ input ทุกวินาทีที่เล่นเกม และ character controller คือโค้ดชิ้นเดียวที่แตะทุกวินาทีเหล่านั้น บั๊กในระบบ inventory อาจกวนใจผู้เล่นแค่ตอนเปิดเมนูครั้งเดียว แต่ส่วนโค้งกระโดดที่แข็งทื่อ หรือ coyote time ที่หายไป 50 มิลลิวินาที จะกวนใจผู้เล่น ทุกครั้งที่กระโดด ตลอดทั้งเกม การเปลี่ยนแปลงเล็ก ๆ ตรงนี้จึงถูกรู้สึกได้ไม่สมส่วนกับขนาดของมัน:

Tip เวลา controller รู้สึก "แปลก ๆ" แต่บอกไม่ได้ชัด ๆ ว่าทำไม ให้เปลี่ยนทีละตัวเลขแล้วลองกระโดดหรือวิ่งแบบเดิมซ้ำ ๆ ดู การปรับแต่งการเคลื่อนที่ไม่ใช่สิ่งที่จะทำถูกได้จากการอ่านสูตรแค่ครั้งเดียว — มันเป็นสิ่งที่คุณต้องค่อย ๆ สัมผัสหาทางเข้าไปเอง เหมือนกับที่ sound designer ปรับ mix เสียงด้วยหู

12. Fixed timestep, FixedUpdate, และ interpolation

หัวข้อ 2 บอกไว้ว่า physics รันด้วยนาฬิกาของตัวเอง และนั่นคือเหตุผลที่โค้ด physics ต้องอยู่ใน FixedUpdate คุ้มค่าที่จะเข้าใจนาฬิกาตัวนี้ให้ถ่องแท้ เพราะบั๊กการเคลื่อนที่จำนวนมากที่น่าแปลกใจแท้จริงแล้วเป็นบั๊กเรื่องจังหวะเวลาที่ปลอมตัวมา Unity มีลูปอัปเดตสองตัวรันพร้อมกัน: Update ทำงานหนึ่งครั้งต่อ เฟรมที่เรนเดอร์ (30, 60, 144 ครั้งต่อวินาที — เท่าที่เครื่องจะไหว และมันเปลี่ยนไปมาในแต่ละเฟรม) ส่วน FixedUpdate ทำงานด้วย fixed timestep ค่าเริ่มต้นคือทุก ๆ 0.02 วินาที (50 ครั้งต่อวินาที) อ่านได้จาก Time.fixedDeltaTime และตั้งค่าได้ที่ Project Settings > Time

นาฬิกาสองตัวนี้แทบไม่เคยตรงกัน Unity จะเก็บเวลาที่ผ่านไปไว้ในบัญชีสะสม แล้วจ่ายออกทีละก้อนขนาดคงที่:

fixedDeltaTime = 0.02s (50 Hz physics) Running at 144 fps -> each frame is ~0.0069s of real time: frame: 1 2 3 4 5 6 7 ... time banked: .007 .014 .021 .001 .008 .015 .022 ... physics runs: 0 0 1 0 0 0 1 ... (~50/sec) Running at 30 fps -> each frame is ~0.0333s of real time: frame: 1 2 3 ... time banked: .033 .026 .019 ... (0.02 spent, remainder carried over) physics runs: 1 1 1 ... occasionally 2 (still ~50/sec)

ดังนั้นในหนึ่งเฟรมที่เรนเดอร์ FixedUpdate อาจทำงาน ศูนย์ครั้ง (ตอนที่การเรนเดอร์เร็วกว่า physics), หนึ่งครั้ง, หรือ หลายครั้ง (หลังจากเกิดอาการกระตุก เพื่อไล่ให้ทัน) กฎสองข้อตกผลึกออกมาจากเรื่องนี้:

[RequireComponent(typeof(Rigidbody))]
public class TimestepAwareMover : MonoBehaviour
{
    public float jumpVelocity = 10f;
    Rigidbody rb;
    bool jumpQueued;

    void Awake()
    {
        rb = GetComponent<Rigidbody>();
        rb.interpolation = RigidbodyInterpolation.Interpolate;
    }

    void Update()   // variable frame clock: read input here
    {
        if (Input.GetButtonDown("Jump"))
            jumpQueued = true;
    }

    void FixedUpdate()   // fixed 50 Hz clock: change physics here
    {
        if (jumpQueued)
        {
            Vector3 v = rb.velocity;
            v.y = jumpVelocity;
            rb.velocity = v;
            jumpQueued = false;   // consume exactly one press
        }
    }
}

แต่ตอนนี้ปัญหาใหม่โผล่มาตอน refresh rate สูง ๆ: ถ้า physics ขยับวัตถุแค่ 50 ครั้งต่อวินาที ในขณะที่คุณเรนเดอร์ 144 เฟรมต่อวินาที วัตถุจะกระโดดเป็นขั้น ๆ ให้เห็น — ตรรกะ smooth แต่ภาพกระตุก วิธีแก้คือ interpolation: ตั้ง rb.interpolation = RigidbodyInterpolation.Interpolate แล้ว Unity จะเลื่อน Transform ที่เรนเดอร์ให้ smooth ระหว่างสองตำแหน่ง physics ล่าสุด ซ่อนขั้น 50 Hz ไว้จนหมด (Extrapolate จะเดาไปข้างหน้าโดยใช้ velocity ปัจจุบันแทน — smooth กว่าตอนเคลื่อนที่อิสระ แต่มันจะเลยเป้าแล้วเด้งกลับให้เห็นตอนวัตถุชนอะไร ดังนั้น Interpolate จึงเป็นค่าเริ่มต้นที่ปลอดภัยกว่า) เปิดมันเฉพาะกับวัตถุที่คุณจ้องดูใกล้ ๆ จริง ๆ อย่างตัวผู้เล่น เพราะมันมีต้นทุนต่อวัตถุอยู่เล็กน้อย

Common mistake "แก้" อาการ physics กระตุกด้วยการลาก Fixed Timestep ลงไปเป็นค่าเล็ก ๆ อย่าง 0.005s นั่นแค่ทำให้การจำลองทั้งหมดรัน 200 ครั้งต่อวินาที และเผา CPU ทิ้งโดยไม่ได้ภาพที่ดีขึ้นเลย — อาการกระตุกเป็นปัญหาความ smooth ของ การเรนเดอร์ และ interpolation แก้มันได้แทบไม่มีต้นทุน ปล่อย Fixed Timestep ไว้ที่ 0.02 เว้นแต่คุณมีเหตุผลเรื่องความแม่นยำของการจำลองที่เจาะจงจริง ๆ
Tip CharacterController เลี่ยงปัญหาทั้งหมดนี้ไปได้: คุณเรียก controller.Move จาก Update ดังนั้นมันขยับ Transform ทุกเฟรมที่เรนเดอร์อยู่แล้ว และไม่มีวันแสดงอาการกระตุก 50 Hz — เป็นอีกเหตุผลที่มันรู้สึก smooth ตั้งแต่แกะกล่องและไม่ต้องตั้งค่า interpolation สิ่งที่แลกมาคือคุณต้องดูแลแรงโน้มถ่วงและจังหวะเวลาเอง โดยคูณด้วย Time.deltaTime ที่ผันแปร เหมือนกับที่ตัวอย่าง CharacterController ทุกอันในบทนี้ทำ

13. บั๊กที่เจอบ่อยและวิธีแก้

ทุกข้อด้านล่างนี้เคยกัดโปรแกรมเมอร์ character controller ทุกคนมาแล้วอย่างน้อยหนึ่งครั้ง การฝึกให้จำ อาการ ได้แล้วกระโดดตรงไปที่ กลไก เลย คือหัวใจส่วนใหญ่ของการดีบั๊กการเคลื่อนที่ให้เร็วแทนที่จะทรมาน

ตัวละครสั่นหรือกระตุก

มักเป็นหนึ่งในสามอย่าง (1) Rigidbody ถูกขยับใน Update หรือไม่ได้เปิด interpolation — ขยับมันใน FixedUpdate แล้วตั้ง Interpolate (หัวข้อ 12) (2) มีสองระบบเขียนตำแหน่งในเฟรมเดียวกันแล้วแย่งกัน — เช่นโค้ด move ของคุณกับแพลตฟอร์มที่ parent ไว้ต่างก็ดัน Transform; ให้มีโค้ดชิ้นเดียวเป็นเจ้าของตำแหน่ง (3) บนทางลาด การเดินในแนวราบชนเข้าไปในพื้นผิว จน collider ดันคุณกลับออกมาทุกเฟรม — ให้ project การเคลื่อนที่ลงบนทางลาด (หัวข้อ 10) และคงแรงกดลงเล็ก ๆ ตอนอยู่บนพื้นไว้ (หัวข้อ 3) แทนที่จะปล่อยให้ vertical velocity อยู่ที่ศูนย์

Double jump ผีสิง (หรือกระโดดไม่รู้จบ)

เฟรมที่คุณกดกระโดด vertical velocity พุ่งขึ้นไป +10 แต่ตัวละครยังไม่ได้ลอยพ้นพื้นจริง ๆ ดังนั้นทรงกลม ground check จึงยังซ้อนทับพื้นอยู่หนึ่งหรือสองเฟรม ถ้าเงื่อนไขกระโดดของคุณเห็น isGrounded == true อีกครั้งในขณะที่การกดที่ buffer ไว้ยังมีชีวิตอยู่ มันจะยิงกระโดดครั้งที่สอง วิธีแก้สองอย่างใช้ร่วมกัน: ใช้ตัวนับทันทีที่กระโดด (coyoteTimeCounter = 0; jumpBufferCounter = 0; เหมือนในหัวข้อ 9) และอย่านับว่าอยู่บนพื้นขณะกำลังเคลื่อนที่ขึ้น — ให้เงื่อนไขเป็น verticalVelocity <= 0f หรือเพิ่มการล็อกสั้น ๆ หลังกระโดด (เพิกเฉย ground check ราว ๆ 0.05 วินาทีหลังออกตัว) เพื่อไม่ให้เฟรมแรก ๆ ที่ลอยอยู่มา re-trigger ได้

ติดกำแพงกลางอากาศ

สองสาเหตุที่แยกกันสวมอาการเดียวกัน ถ้าคุณอยู่บน Rigidbody friction ของ physics material ของกำแพงกำลังจับคุณไว้ — ใส่ PhysicsMaterial ที่มี friction เป็นศูนย์และตั้ง friction-combine เป็น Minimum ให้กำแพง เพื่อให้คุณไถลลงมันแทนที่จะค้าง ถ้าคุณอยู่บนตัวเคลื่อนที่แบบกำหนดเอง คุณกำลังดัน velocity เต็ม ๆ เข้าไปในกำแพงซึ่งไม่มีทางไป; เอาส่วนที่พุ่งเข้ากำแพงออกด้วยการ project ลงบนหน้ากำแพง: velocity = Vector3.ProjectOnPlane(velocity, wallNormal) ซึ่งทำให้คุณไถลไปตามกำแพงแทนที่จะติดหนึบ CharacterController ทำ collide-and-slide นี้ให้คุณอยู่แล้ว จึงเป็นเหตุผลที่บั๊กนี้ส่วนใหญ่เป็นปัญหาของตัวเคลื่อนที่แบบ Rigidbody

จมลงไปในพื้น หรือสั่นบนทางลาด

สัญญาณคือตัวละครที่ค่อย ๆ จมลงไปในพื้นหรือสั่นขณะยืนบนทางลาด สาเหตุคือแรงโน้มถ่วงสะสม: ถ้าคุณเอาแต่บวกแรงโน้มถ่วงทุกเฟรมขณะอยู่บนพื้น verticalVelocity จะโตเป็นเลขติดลบมาก ๆ และการ Move แต่ละครั้งจะพยายามดันแคปซูลลงลึกเข้าไปในพื้น; ระบบกู้คืนการทะลุ (penetration recovery) ของ controller จะดันมันกลับ และการแย่งกันนี้แสดงออกมาเป็นอาการสั่น ให้รีเซ็ต vertical velocity เป็นค่าติดลบเล็ก ๆ (-2f) ทันทีที่อยู่บนพื้น (หัวข้อ 3) แทนที่จะปล่อยให้มันโต project การเคลื่อนที่ไปตามทางลาดเพื่อให้ส่วนแนวราบไม่ขุดลงไป (หัวข้อ 10) และทำให้แน่ใจว่า ground mask ไม่รวม collider ของผู้เล่นเอง (หัวข้อ 5)

ตกทะลุพื้นบาง ๆ ตอนความเร็วสูง (tunneling)

วัตถุที่เร็วอาจเคลื่อนที่ในหนึ่ง physics step ไกลกว่าความหนาของตัวมันเอง การทดสอบ collision แบบไม่ต่อเนื่องจึงไม่เคยเจอการซ้อนทับ และวัตถุก็ทะลุพื้นไปเนียน ๆ วิธีแก้แรกไม่ใช่ step ที่เล็กลง — แต่คือ continuous collision detection: ตั้ง collisionDetectionMode ของ Rigidbody เป็น ContinuousDynamic (หรือ Continuous) เพื่อให้ solver กวาดไปตามเส้นทางของวัตถุแทนที่จะทดสอบแค่จุดปลายทั้งสอง การทำแพลตฟอร์มบางให้หนากว่าระยะเดินทางต่อ step ที่เร็วที่สุดคือเวอร์ชันเสริมความมั่นใจ เหตุผลเดียวกันนี้อธิบายว่าทำไม CharacterController ที่เคลื่อนที่เร็วมาก ๆ ถึงอาจข้าม trigger บางเฉียบไปได้

การเคลื่อนที่รู้สึกต่างกันที่ frame rate ต่างกัน

ถ้ากระโดดสูงกว่าบนเครื่อง 30 fps เทียบกับ 144 fps แสดงว่ามีบางอย่างที่ขึ้นกับเวลาไม่ได้ถูกสเกลอย่างถูกต้อง — ที่พบบ่อยคือการกระโดดที่สั่งจาก FixedUpdate ในขณะที่ input ก็ถูกอ่านใน FixedUpdate ด้วย (การกดถูกทิ้งหรือถูกอ่านซ้ำ หัวข้อ 12) หรือการเปลี่ยน velocity ต่อเฟรมที่ลืมคูณ Time.deltaTime ให้เก็บทุกค่า acceleration และแรงโน้มถ่วงในหน่วยต่อวินาที แล้วคูณด้วย delta ที่ตรงกัน (Time.deltaTime ใน Update, Time.fixedDeltaTime ใน FixedUpdate) buffer input ใน Update และเลือกใช้ MoveTowards แทน Lerp ที่ขึ้นกับจำนวนเฟรม (หัวข้อ 4)

14. ศัพท์สำคัญ

15. แบบฝึกหัด

Exercise 1 ตัวละครตัวหนึ่งมี acceleration = 40 หน่วย/วินาที^2, maxSpeed = 6 หน่วย/วินาที และเกมรันที่ 50 FPS คงที่ ดังนั้น Time.deltaTime = 0.02s พอดี ผู้เล่นกดปุ่มเดินค้างไว้จากตอนที่หยุดนิ่งสนิท โดยใช้ Vector3.MoveTowards แบบในหัวข้อ 4 จงเขียนความเร็วแนวราบหลังจากแต่ละเฟรมใน 6 เฟรมแรก แล้วบอกว่าเฟรมไหนที่ตัวละครถึง maxSpeed เป็นครั้งแรก
Show answer

แต่ละเฟรมความเร็วเปลี่ยนได้มากสุด acceleration * deltaTime = 40 * 0.02 = 0.8 หน่วย/วินาที

frame:  1    2    3    4    5    6
speed:  0.8  1.6  2.4  3.2  4.0  4.8

ไล่แบบเดิมต่อไป: เฟรม 7 จะได้ 5.6 และเฟรม 8 น่าจะได้ 6.4 แต่ MoveTowards จะไม่มีวันแซงเป้าหมายไปเลย มันจึงหยุดที่ 6.0 พอดี ความเร็วสูงสุดจะถึงเป็นครั้งแรกที่เฟรม 8 ประมาณ 0.16 วินาทีหลังจากกดปุ่ม

Exercise 2 คุณต้องการกระโดดที่สูงสุดพอดี 3.24 เมตร ในเวลา 0.6 วินาทีหลังจากลอยจากพื้น โดยใช้สูตรจากหัวข้อ 6 (v0 = 2h/t และ g = -2h/t^2) จงคำนวณค่า jumpVelocity กับ gravity ที่คุณจะใส่ใน Inspector แล้วแยกต่างหาก: ถ้าคุณใส่ fallMultiplier ที่มากกว่า 1 ให้กับการกระโดดนี้ ตัวละครจะใช้เวลาลอยขึ้นมากกว่าหรือตกลงมากกว่ากัน และทำไมนั่นถึงมักเป็นสิ่งที่คุณต้องการ?
Show answer

เมื่อ h = 3.24 และ t = 0.6:

jumpVelocity = 2 * 3.24 / 0.6 = 10.8
gravity      = -2 * 3.24 / (0.6 * 0.6) = -6.48 / 0.36 = -18.0

เช็ค: เวลาไปถึง apex = 10.8 / 18.0 = 0.6s (ตรงกัน) ความสูงที่ได้ = 10.8*0.6 - 0.5*18*0.6^2 = 6.48 - 3.24 = 3.24 (ตรงกัน)

fallMultiplier > 1 จะเพิ่มแรงโน้มถ่วงก็ต่อเมื่อ verticalVelocity < 0 (ครึ่งที่กำลังตกของส่วนโค้ง) เท่านั้น — มันไม่แตะครึ่งที่ลอยขึ้นเลย ดังนั้นตัวละครจะลอยขึ้นด้วยอัตราปกติ แต่ตกลงมาเร็วกว่าที่ลอยขึ้น หมายความว่า มันใช้เวลาลอยขึ้นมากกว่าเวลาตกลง นี่มักเป็นสิ่งที่คุณต้องการ: การออกตัวจากพื้นแบบฉับไวมีจังหวะ ตามด้วยการลงพื้นที่รวดเร็วและหนักแน่น แทนที่จะเป็นพาราโบลาแบบสมมาตรและลอย ๆ ที่ตัวละครดูเหมือนลอยค้างอยู่กลางอากาศตอนขาลง

Exercise 3 ตัวละครเดินหลุดขอบไป (เลิกแตะพื้น) ที่ t = 0.00s โดยมี coyoteTime = 0.10s และ jumpBufferTime = 0.15s ถ้าไม่มีอะไรมาแทรกแซงแรงโน้มถ่วง ตัวละครจะตกลงถึงพื้นที่ต่ำกว่าตามธรรมชาติที่ t = 0.50s ลองพิจารณาการเล่นซ้ำสามครั้งที่ แยกจากกัน ของการตกแบบเดียวกันนี้เป๊ะ ๆ ในแต่ละครั้ง ผู้เล่นกดปุ่มกระโดดแค่ครั้งเดียว ที่เวลาต่างกัน สำหรับแต่ละเวลาที่กดด้านล่างนี้ ให้บอกว่ากระโดดเกิดขึ้นไหม ถ้าเกิด เกิดที่เวลาไหนและเพราะกลไกไหน (coyote time หรือ jump buffering)
  • (a) กดกระโดดที่ t = 0.05s
  • (b) กดกระโดดที่ t = 0.25s
  • (c) กดกระโดดที่ t = 0.40s
Show answer

(a) t = 0.05s: กระโดดทันที ที่ t = 0.05s ผ่านทาง coyote time หน้าต่าง coyote จะอยู่จนถึง 0.00 + 0.10 = 0.10s การกดที่ 0.05s ตกอยู่ในหน้าต่างนั้นพอดี ดังนั้น canJump จะเป็น true แม้ตัวละครจะลอยอยู่กลางอากาศ และกระโดดจะเกิดขึ้นทันทีที่กดปุ่ม

(b) t = 0.25s: ไม่มีการกระโดดเกิดขึ้น มันช้าเกินไปสำหรับ coyote time มาก (หน้าต่างปิดไปแล้วที่ 0.10s) มันจะเริ่มนับถอยหลัง jump-buffer ที่อยู่จนถึง 0.25 + 0.15 = 0.40s แต่ตัวละครจะไม่ลงพื้นจนกว่าจะถึง t = 0.50s — ตอนนั้น buffer หมดอายุไปแล้ว ดังนั้น wantsToJump จะเป็น false ตอนที่ลงพื้นจริง การกดจะถูกทิ้งไปเงียบ ๆ

(c) t = 0.40s: กระโดดที่ t = 0.50s (จังหวะที่ลงพื้นพอดี) ผ่านทาง jump buffering buffer จะอยู่จนถึง 0.40 + 0.15 = 0.55s การลงพื้นเกิดขึ้นที่ 0.50s ซึ่งอยู่ในหน้าต่างนั้นพอดี ดังนั้นทันทีที่ isGrounded กลายเป็น true wantsToJump ก็ยังเป็น true อยู่ด้วย กระโดดจึงเกิดขึ้นพอดีตอนลงพื้น — ผู้เล่นจะรู้สึกว่านี่คือการกระโดดที่จังหวะเป๊ะมาก ทั้งที่จริง ๆ แล้วปุ่มถูกกดตอนยังลอยอยู่กลางอากาศ

Exercise 4 เกมเรนเดอร์ที่ 120 fps คงที่ และ Fixed Timestep ถูกปล่อยไว้ที่ค่าเริ่มต้น 0.02s (a) ในหนึ่งวินาทีจริง Update รันกี่ครั้งและ FixedUpdate รันกี่ครั้ง? (b) โดยเฉลี่ยแล้วมี FixedUpdate กี่ครั้งต่อเฟรมที่เรนเดอร์ และทำไมมันถึงไม่ใช่จำนวนเต็ม? (c) ทำไมการอ่าน Input.GetButtonDown("Jump") แค่ภายใน FixedUpdate ถึงเป็นบั๊กในกรณีนี้?
Show answer

(a) Update รันหนึ่งครั้งต่อเฟรมที่เรนเดอร์ จึงประมาณ 120 ครั้ง ส่วน FixedUpdate รันด้วยนาฬิกาคงที่ 1 / 0.02 = 50 ครั้ง

(b) 50 / 120 ≈ 0.42 ครั้งของ FixedUpdate ต่อเฟรมที่เรนเดอร์ — น้อยกว่าหนึ่ง มันไม่ใช่จำนวนเต็มเพราะนาฬิกาสองตัวเป็นอิสระต่อกัน: Unity เก็บเวลาจริงของแต่ละเฟรมไว้ในบัญชีแล้วจ่ายออกทีละก้อน 0.02s ดังนั้นเฟรมส่วนใหญ่จึงรัน physics ศูนย์ step และราว ๆ ทุกเฟรมที่สามจะรันหนึ่งครั้ง เมื่อรวมทั้งวินาทีก็ยังเฉลี่ยได้ 50

(c) GetButtonDown เป็น true แค่หนึ่งเฟรมที่เรนเดอร์พอดี ในหลาย ๆ เฟรมที่ FixedUpdate รันศูนย์ครั้ง การกดนั้นจะไม่ถูกเห็นภายใน FixedUpdate เลยและกระโดดจะถูกทิ้งเงียบ ๆ; ส่วนเฟรมที่ FixedUpdate รันสองครั้งก็อาจอ่านมันซ้ำได้ ให้อ่านมันใน Update ตั้ง flag แล้วไปใช้ flag นั้นใน FixedUpdate

Exercise 5 ผู้เล่นยืนนิ่ง (horizontalVelocity ของตัวเองเป็นศูนย์) อยู่บนแพลตฟอร์มที่เคลื่อนที่ในพิกัดโลกด้วยความเร็ว (4, 0, 0) หน่วย/วินาที โดยใช้โค้ดแพลตฟอร์มเคลื่อนที่จากหัวข้อ 10 (a) เฟรมนี้ controller.Move ได้รับ motion อะไร ก่อนคูณด้วย Time.deltaTime? (b) ตอนนี้ผู้เล่นกระโดด เมื่อรวมบรรทัด velocity-inheritance เข้าไปด้วย การกระโดดจะพาเขาไปทางไหนในแนวราบโดยประมาณ และนี่จำลองพฤติกรรมในโลกจริงแบบไหน?
Show answer

(a) motion = horizontalVelocity + platformVelocity = (0,0,0) + (4,0,0) = (4,0,0) จากนั้น motion.y ถูกตั้งเป็น verticalVelocity ดังนั้น Move ได้รับ (4, verticalVelocity, 0) * Time.deltaTime — ผู้เล่นถูกพาไปด้านข้างพร้อมกับแพลตฟอร์มแม้ความเร็วของตัวเองจะเป็นศูนย์

(b) การกระโดดบวกความเร็วของแพลตฟอร์มเข้าไปในของผู้เล่นเอง: horizontalVelocity += (4, 0, 0) ตอนนี้ลอยอยู่กลางอากาศโดยมี airFriction ใกล้ศูนย์ เขาจะคงความเร็วราว 4 หน่วย/วินาทีไปทางขวาตลอดการกระโดด และลงพื้นไปทางขวาไกลจากจุดที่ออกตัว นี่จำลองการสืบทอดโมเมนตัมจริง — การกระโดดออกจากรถไฟหรือแพลตฟอร์มที่กำลังเคลื่อนที่จะเหวี่ยงคุณไปในทิศที่มันกำลังไป

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