10.2 Character Physics

เฟส 10 · ฟิสิกส์สำหรับเกม · เวลาเรียน: 20–35 h

ทำให้ตัวละครเคลื่อนที่และชนอย่างน่าเชื่อ — capsule controller, การตรวจพื้น, การก้าว และการจัดการทางลาด

บทที่แล้วเราสร้างสคริปต์เคลื่อนที่ผู้เล่น: อ่าน input แปลงเป็น velocity แล้วส่ง velocity นั้นเข้า CharacterController.Move() ปล่อยให้ field อย่าง isGrounded, slopeLimit, และ stepOffset จัดการส่วนที่เหลือให้เงียบ ๆ นั่นเพียงพอที่จะทำให้ controller รู้สึกดีแล้ว แต่ยังไม่พอที่จะเข้าใจว่าเกิดอะไรขึ้นจริง ๆ ในตอนที่แคปซูลนั้นแตะกำแพง ทางลาด บันได หรือแพลตฟอร์มที่กำลังเคลื่อนที่อยู่เอง บทนี้จะเปิดกล่องนั้นออกมาดู

ทุกอย่างในบทนี้คือชั้นฟิสิกส์ที่อยู่ใต้ character controller ทุกตัว ไม่ว่าจะเป็น CharacterController ในตัวของ Unity หรือตัวที่คุณเขียนเองแบบ custom ทั้งหมด เราจะค่อย ๆ สร้างแบบเดียวกับบทก่อน: ทำไม Rigidbody ธรรมดาถึงมักเป็นเครื่องมือที่ผิดสำหรับตัวละครผู้เล่น ทำไม mover เกือบทุกตัวถึงใช้รูปทรงแคปซูล อัลกอริทึมตัวเดียว — collide-and-slide — ที่ทำให้แคปซูลเคลื่อนที่ผ่านโลกที่เต็มไปด้วยกำแพงกับพื้นได้อย่างลื่นไหล ตามด้วยการตรวจจับพื้น ทางลาด ขั้นบันได แรงโน้มถ่วง แพลตฟอร์มที่เคลื่อนที่ได้ และปิดท้ายด้วยการเปรียบเทียบ kinematic กับ dynamic mover รวมถึง controller ในตัวของ Unity กับ controller แบบ custom

1. ทำไมไม่ใช้ Rigidbody ตรง ๆ ไปเลย?

บทที่ 6.2 พูดถึงไปแล้วว่าตัวละครที่ขับเคลื่อนด้วย Rigidbody จะให้ความรู้สึก "physics-y" — มีโมเมนตัม ลื่นไถล เด้ง สำหรับลัง ragdoll และยานพาหนะ นั่นคือสิ่งที่คุณต้องการพอดี แต่สำหรับตัวละครที่ผู้เล่นควบคุมโดยตรง มันมักจะเป็นปัญหา และคุ้มค่าที่จะเข้าใจว่าทำไมถึงเป็นแบบนั้นจริง ๆ ไม่ใช่แค่จำไว้เฉย ๆ ว่าเป็นกฎทั่วไป

physics engine อย่าง PhysX (ตัวที่ฝังอยู่ใน Unity) กำลังแก้ปัญหาที่ยากกว่า "ขยับแคปซูลตัวเดียวนี้" มาก ทุก physics step มันจะมองไปที่ rigid body ทุกตัว ในซีน หาทุกคู่ที่ซ้อนทับกัน แล้วคำนวณชุดของ impulse (การเปลี่ยน velocity แบบทันทีทันใด) ที่ดันให้พวกมันแยกออกจากกันพอดี ๆ ที่จะไม่ซ้อนทับกันอีก พร้อมกับพยายามรักษาความเสถียรทางตัวเลขของทั้งซีนไปด้วย นี่คือ solver (ตัวแก้ปัญหา) — อัลกอริทึมที่ค่อย ๆ วนเข้าใกล้คำตอบโดยประมาณ แทนที่จะคำนวณคำตอบที่แม่นยำในครั้งเดียว มันถูกสร้างมาเพื่อความสมจริงและความเสถียรของทั้งซีน ไม่ใช่เพื่อทำให้วัตถุตัวใดตัวหนึ่งรู้สึกคมชัดและคาดเดาได้ภายใต้การควบคุมโดยตรงของผู้เล่น

อาการที่เห็นได้ชัดสามอย่างเกิดขึ้นจากดีไซน์นี้โดยตรง:

two floor colliders meeting at a seam, seen from above collider A collider B +-----------+ | +-----------+ | | | | | | o----------> capsule crossing the seam | | | | | +-----------+ | +-----------+ ^ the seam: each collider can report a slightly different contact normal here, which the solver resolves as two separate tiny pushes -> jitter

ทางแก้ที่ character controller ที่ปล่อยจริงเกือบทุกตัวใช้ รวมถึงตัวในตัวของ Unity เอง คือหยุดขอให้ physics engine จำลองตัวละครทั้งหมด แล้วเข้าควบคุมตำแหน่งของตัวละครเองทุกเฟรมแบบ manual ใช้รูปทรงพิเศษ (แคปซูล) และอัลกอริทึมพิเศษ (collide-and-slide) ที่เขียนขึ้นมาเฉพาะสำหรับ "ขยับวัตถุตัวนี้ไปได้ไกลที่สุดเท่าที่มันจะไปได้ ไม่มากไม่น้อยกว่านั้น" — ไม่มี solver ไม่มี impulse ไม่มีการเด้ง เว้นแต่คุณจะเขียนโค้ดให้มันเด้งเอง นี่คือสิ่งที่ส่วนที่เหลือของบทนี้จะสร้างขึ้นมา

Tip ไม่ได้แปลว่า Rigidbody แย่ — มันแค่แก้ปัญหาคนละแบบ ลังที่ควรจะกลิ้งตกอย่างสมจริงเมื่อถูกเตะ หรือ ragdoll ต้องการงานของ solver จริง ๆ คือปฏิกิริยาที่สมจริงต่อแรงใด ๆ ก็ตาม ส่วนแคปซูลของผู้เล่นต้องการสิ่งตรงข้าม: input เดิม ๆ ต้องให้ผลลัพธ์การเคลื่อนที่แบบเดิม แม่นยำ ทำซ้ำได้ทุกครั้ง

2. แคปซูล: รูปทรงที่ character controller เกือบทุกตัวใช้

แคปซูล (capsule) คือทรงกระบอกที่มี hemisphere (ครึ่งทรงกลม) ปิดหัวท้ายทั้งสองด้าน คุณอธิบายมันได้ครบด้วยตัวเลขแค่สามตัว: จุดสองจุดที่มาร์คปลายของเส้นตรงที่ลากผ่านกลางลำตัว (เรียกว่า point0 กับ point1 รวมกันเรียกว่า axis ของแคปซูล) และ radius — รัศมีเดียวกันทั้งลำตัวทรงกระบอกและฝาโค้งทั้งสองด้าน

....... . . <- top hemisphere, radius r . . center: point1 . o . | | | | | | <- cylindrical body | | | (the straight part) . o . . . <- bottom hemisphere, radius r . . center: point0 ....... the dotted "o---o" down the middle is the capsule's AXIS radius r = distance from the axis out to the skin, measured the same way at every point along the whole shape

ทำไมต้องรูปทรงนี้โดยเฉพาะ แทนที่จะเป็นกล่อง (ซึ่งใกล้เคียงกับ silhouette ของคนตอนมองจากด้านบนมากกว่า) หรือทรงกระบอกธรรมดา?

การเช็ค "หาจุดที่ใกล้ที่สุดบน axis แล้วเทียบกับ radius" นี้คุ้มค่าที่จะดูเป็นตัวเลขจริง เพราะมันคือคณิตศาสตร์ตัวเดียวกับที่รันอยู่ข้างในทุก ๆ capsule collision check ที่ engine ทำให้คุณ สมมติ axis ของแคปซูลลากขึ้นตรง ๆ จาก point0 = (0, 1, 0) ไปยัง point1 = (0, 2, 0) ด้วย radius = 0.5 แล้วเราอยากรู้ว่าจุด (0.3, 1.5, 0.4) อยู่ข้างในหรือเปล่า:

using System;

class CapsuleCheck
{
    static void Main()
    {
        // capsule axis
        float ax = 0f, ay = 1f, az = 0f;   // point0
        float bx = 0f, by = 2f, bz = 0f;   // point1
        float radius = 0.5f;

        // test point
        float px = 0.3f, py = 1.5f, pz = 0.4f;

        // closest point on the segment [point0, point1] to the test point.
        // since this axis only varies in y, t is just how far along in y.
        float t = (py - ay) / (by - ay);
        t = Math.Max(0f, Math.Min(1f, t));   // clamp to the segment, not the infinite line

        float cx = ax, cy = ay + t * (by - ay), cz = az;

        float dx = px - cx, dy = py - cy, dz = pz - cz;
        float dist = (float)Math.Sqrt(dx * dx + dy * dy + dz * dz);

        Console.WriteLine($"t = {t}");
        Console.WriteLine($"closest point on axis = ({cx}, {cy}, {cz})");
        Console.WriteLine($"distance to axis = {dist}");
        Console.WriteLine($"inside capsule (radius {radius})? {dist <= radius}");
    }
}

ผลลัพธ์:

t = 0.5
closest point on axis = (0, 1.5, 0)
distance to axis = 0.5
inside capsule (radius 0.5)? True

ลองตรวจด้วยมือ: จุดทดสอบเบี่ยงจาก axis ไป 0.3 หน่วยในแกน x และ 0.4 หน่วยในแกน z และ 0.3-4-0.5 คือสามเหลี่ยมมุมฉาก 3-4-5 ที่ย่อส่วนด้วย 0.1 ดังนั้นระยะทางเส้นตรงคือ 0.5 พอดี — อยู่บนผิวแคปซูลพอดิบพอดี สังเกตว่าใช้แรงคำนวณน้อยแค่ไหน: ไม่มีการเช็คทีละหน้า ไม่มีมุม แค่การ project ลงบน line segment ครั้งเดียวกับ square root หนึ่งครั้ง นี่คือเคล็ดลับทั้งหมดที่ทำให้แคปซูลกลายเป็นรูปทรงมาตรฐานสำหรับ character physics ทั้งใน Unity และแทบทุก engine อื่น ๆ

Common mistake ลืม clamp t ให้อยู่ในช่วง [0, 1] ถ้าไม่ clamp สูตรจะปฏิบัติกับ axis เหมือนเป็นเส้นตรงยาวไม่จำกัดแทนที่จะเป็น segment ที่มีความยาวจำกัด ทำให้จุดที่อยู่ต่ำกว่าเท้าของแคปซูลมาก ๆ หรือสูงกว่าหัวมาก ๆ อาจถูกคำนวณผิดว่า "ใกล้" กับ axis และถูกนับว่าเกิด collision

3. แนวคิดหลัก: collide-and-slide

สมมติตัวละครอยากเคลื่อนที่ไปข้างหน้า 1 หน่วยในเฟรมนี้ และบังเอิญมีกำแพงอยู่ห่างออกไป 0.6 หน่วย วางเฉียงขวางเส้นทางของตัวละคร วิธีคิดง่าย ๆ สองแบบล้วนล้มเหลว:

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

top-down view: character trying to move from S toward the X, but a wall (the diagonal line of #) is in the way S - - - - - - - - X 1) full move blocked partway by the wall # # # # # # # (the wall runs diagonally across the path) S - - - -* 2) move up to just before the wall (*) . . . 3) redirect the LEFTOVER distance so it . . runs ALONG the wall instead of into it . . . * 4) final position after sliding

ขั้นตอน redirect — เปลี่ยนการเคลื่อนที่ที่เหลือให้กลายเป็นการเคลื่อนที่ "ไปตามพื้นผิว" — คือการ project แบบเดียวกับที่คุณใช้กับทางลาดในบทที่ 6.2: เอา leftover move vector มาแบนราบลงบนระนาบที่นิยามด้วย normal ของพื้นผิว (เวกเตอร์ที่ชี้ออกมาตรง ๆ จากมัน) ด้วย Vector3.ProjectOnPlane การกระทำเดียวนี้จะตัดส่วน "เข้าไปในกำแพง" ของการเคลื่อนที่ออก แล้วเก็บส่วน "ไปตามกำแพง" ไว้

เขียนเป็นอัลกอริทึม โดยไม่สนรายละเอียดเฉพาะภาษา:

function CollideAndSlide(position, moveVector, depth):
    if depth >= MAX_BOUNCES:
        return position                       // safety net, see below

    hit = CapsuleCast(position, moveVector.direction, moveVector.length)

    if hit is nothing:
        return position + moveVector          // path is clear, do the whole move

    // move up to just short of the hit, leaving a tiny gap (the "skin width")
    allowedDistance = hit.distance - SKIN_WIDTH
    newPosition = position + moveVector.direction * allowedDistance

    // whatever distance we did not get to use yet
    leftoverDistance = moveVector.length - allowedDistance
    leftoverVector = moveVector.direction * leftoverDistance

    // redirect it to run along the surface instead of into it
    slideVector = ProjectOnPlane(leftoverVector, hit.normal)

    return CollideAndSlide(newPosition, slideVector, depth + 1)

มีรายละเอียดสองอย่างที่สำคัญพอ ๆ กับแนวคิดหลัก:

Tip นี่คืออัลกอริทึมตัวเดียวกันเป๊ะ ๆ ที่รันอยู่ข้างใน CharacterController.Move() ในตัวของ Unity — แค่คุณมองไม่เห็นลูปเพราะมันเกิดขึ้นในโค้ด native ของ engine การเข้าใจมันคือสิ่งที่ทำให้คุณคาดเดาได้ว่าทำไม character controller ถึงมีพฤติกรรมแบบนั้นตรงมุมหรือตอนชนกำแพงแบบเฉียด ๆ แทนที่จะมองมันเป็นกล่องดำ

4. Collide-and-slide เป็นโค้ดจริง

นี่คืออัลกอริทึมจากหัวข้อ 3 เขียนเป็น Unity component จริง ๆ ใช้ Physics.CapsuleCast เพื่อทดสอบเส้นทางของแคปซูลก่อนที่จะขยับจริง นี่คือฐานที่ส่วนที่เหลือของบทนี้จะต่อยอดขึ้นไป

using UnityEngine;

[RequireComponent(typeof(CapsuleCollider))]
public class CapsuleMover : MonoBehaviour
{
    public float skinWidth = 0.02f;
    public int maxSlideIterations = 4;
    public LayerMask collisionMask;

    CapsuleCollider capsule;

    void Awake()
    {
        capsule = GetComponent<CapsuleCollider>();
    }

    // Moves the transform by moveVector this frame, sliding along
    // anything it touches along the way. Call this once per frame.
    public void Move(Vector3 moveVector)
    {
        Vector3 remaining = moveVector;

        for (int i = 0; i < maxSlideIterations && remaining.sqrMagnitude > 0.0001f; i++)
        {
            GetCapsulePoints(out Vector3 point0, out Vector3 point1);

            float distance = remaining.magnitude;
            Vector3 direction = remaining.normalized;

            bool didHit = Physics.CapsuleCast(point0, point1, capsule.radius,
                direction, out RaycastHit hit, distance, collisionMask);

            if (didHit)
            {
                float allowedDistance = Mathf.Max(0f, hit.distance - skinWidth);
                transform.position += direction * allowedDistance;

                float usedFraction = (distance > 0f) ? allowedDistance / distance : 0f;
                Vector3 leftover = remaining * (1f - usedFraction);

                remaining = Vector3.ProjectOnPlane(leftover, hit.normal);
            }
            else
            {
                transform.position += remaining;
                remaining = Vector3.zero;
            }
        }
    }

    void GetCapsulePoints(out Vector3 point0, out Vector3 point1)
    {
        float halfSegment = Mathf.Max(0f, capsule.height * 0.5f - capsule.radius);
        Vector3 center = transform.position + capsule.center;
        point0 = center + Vector3.up * halfSegment;
        point1 = center - Vector3.up * halfSegment;
    }
}

คณิตศาสตร์ส่วนเดียวที่คุ้มค่าจะตรวจด้วยมือคือขั้นตอน redirect: Vector3.ProjectOnPlane(leftover, hit.normal) สมมติว่าหลังจากลองครั้งแรกแล้วโดนบัง leftover vector คือ (0.42, 0, 0) — เหลือ 0.42 หน่วยที่ต้องเดินทาง ตรงไปตามแกน x — และกำแพงที่ชนนั้นเอียงจนมี normal ประมาณ (-0.7071, 0, 0.7071) (กำแพงมุม 45 องศา เพราะ 0.7071 ≈ 1/√2):

using System;

class SlideMath
{
    static void Main()
    {
        float vx = 0.42f, vy = 0f, vz = 0f;          // leftover move vector
        float nx = -0.7071f, ny = 0f, nz = 0.7071f;  // wall's surface normal

        float dot = vx * nx + vy * ny + vz * nz;

        float px = vx - dot * nx;
        float py = vy - dot * ny;
        float pz = vz - dot * nz;

        Console.WriteLine($"dot(v,n) = {dot:F4}");
        Console.WriteLine($"slide vector = ({px:F4}, {py:F4}, {pz:F4})");
        Console.WriteLine($"slide length = {Math.Sqrt(px * px + py * py + pz * pz):F4}");
    }
}

ผลลัพธ์:

dot(v,n) = -0.2970
slide vector = (0.2100, 0.0000, 0.2100)
slide length = 0.2970

การเคลื่อนที่ "ตรงเข้ากำแพง" เดิม 0.42 หน่วย กลายเป็นการเคลื่อนที่ 0.297 หน่วยที่แบ่งเท่า ๆ กันระหว่างแกน x กับ z — ไถลไปตามแนวกำแพงในแนวทแยงแทนที่จะดันเข้าไปในมัน นี่คืองานทั้งหมดของ collide-and-slide เขียนออกมาเป็น dot product หนึ่งครั้งกับการลบหนึ่งครั้ง ทำซ้ำได้สูงสุด maxSlideIterations ครั้งต่อเฟรม

5. การตรวจจับพื้นและการ snap ลงพื้น

บทที่ 6.2 พูดถึงการเช็คพื้นพื้นฐานด้วย raycast หรือ CheckSphere ไปแล้ว ส่วน capsule mover ที่สร้างบน collide-and-slide จะได้ประโยชน์จากอีกชิ้นหนึ่ง: spherecast — การกวาดทรงกลมไปตามทิศทางหนึ่งแล้วถามว่า "อะไรคือสิ่งแรกที่ลูกบอลเคลื่อนที่นี้จะแตะ?" — ซึ่งทำงานคล้าย overlap sphere จากบท 6.2 แต่ยังบอกระยะทางกับ surface normal ของสิ่งที่มันชนด้วย ซึ่งทั้งทางลาดและขั้นบันไดต้องใช้ทั้งคู่

public float groundCheckDistance = 0.3f;
public float groundSnapDistance = 0.3f;
public LayerMask groundMask;

bool isGrounded;
Vector3 groundNormal = Vector3.up;

bool CheckGround(out RaycastHit hit)
{
    GetCapsulePoints(out Vector3 point0, out Vector3 point1);
    // sweep the bottom sphere of the capsule straight down a short distance
    bool hitSomething = Physics.SphereCast(point1, capsule.radius, Vector3.down,
        out hit, groundCheckDistance, groundMask);
    return hitSomething;
}

ทีนี้มาดูปัญหาที่ละเอียดอ่อนกว่านั้น ลองนึกภาพตัวละครเดินลงบันไดด้วยความเร็วแนวราบคงที่ ทุกเฟรม collide-and-slide จะขยับมันไปข้างหน้า แรงโน้มถ่วงก็ดึงมันลงด้วย แต่ดึงลงทีละนิดในแต่ละเฟรม เพราะมันเพิ่งถูกรีเซ็ตเป็นค่าเล็ก ๆ ตอนที่แตะพื้นครั้งล่าสุด (บทที่ 6.2 หัวข้อ 3) ถ้าขั้นบันไดหล่นลงเร็วกว่าที่แรงโน้มถ่วงจะดึงตัวละครลงไปแตะได้ทันภายในเฟรมเดียว ตัวละครจะร่อนเลยขอบบันไดแต่ละขั้นไปสองสามเซนติเมตรก่อนที่แรงโน้มถ่วงจะตามทัน ตลอดทั้งบันได อาการนี้จะอ่านออกมาเป็นตัวละครกระเด้งลงบันไดทีละขั้นให้เห็นชัด ๆ แทนที่จะเดินลงอย่างลื่นไหล

without ground snapping with ground snapping (character briefly floats (character sticks to the above each stair edge) stairs, moves smoothly) .. .. o.. o_ o.. o_ o.. o_ o.. <- tiny hops o_ <- smooth o.. o_ o o =====stairs===== =====stairs=====

ทางแก้: หลัง collide-and-slide move ปกติของเฟรมเสร็จแล้ว ถ้าตัวละครแตะพื้นอยู่ในเฟรมก่อนหน้า ให้ยิง spherecast สั้น ๆ เพิ่มอีกครั้งตรงลงไปด้านล่าง — ไกลกว่าระยะที่แรงโน้มถ่วงต่อเฟรมเล็ก ๆ จะไปถึง แต่ยังสั้นพอที่จะจับได้แค่บันไดหรือทางลาดที่อยู่ใกล้ ๆ ไม่ใช่ขอบผาจริง ๆ ที่ตัวละครควรจะตกลงไป ถ้าเจอ ก็ snap ตำแหน่งลงไปบนพื้นผิวนั้นทันที แทนที่จะรอให้แรงโน้มถ่วงสะสมหลายเฟรมค่อย ๆ ปิดช่องว่าง

void SnapToGround()
{
    if (!wasGroundedLastFrame) return;   // only snap if we were already on ground

    GetCapsulePoints(out Vector3 point0, out Vector3 point1);
    bool hit = Physics.SphereCast(point1, capsule.radius, Vector3.down,
        out RaycastHit groundHit, groundSnapDistance, groundMask);

    if (hit)
    {
        transform.position += Vector3.down * groundHit.distance;
        isGrounded = true;
        groundNormal = groundHit.normal;
    }
}
Common mistake เช็คพื้นด้วยระยะสั้น ๆ เดียวกับที่ใช้สำหรับ flag "ตอนนี้แตะพื้นอยู่ไหม" แล้วมาสงสัยทีหลังว่าทำไมตัวละครถึงสะดุดตอนลงบันไดหรือทางลาด การเช็ค flag กับการเช็ค snap เป็นคนละเรื่องกัน: flag ใช้ระยะสั้นมาก ๆ ได้ (แค่อยากรู้ว่า "แตะอยู่หรือเปล่า") แต่ระยะ snap ต้องมีช่วงที่กว้างพอจะเชื่อมช่องว่างแนวดิ่งที่การเคลื่อนที่แนวราบหนึ่งเฟรมสร้างขึ้นได้ ที่ความเร็วสูงสุดของตัวละคร — บนบันไดชัน ตัวละครที่เร็ว หรือ frame rate ต่ำ ช่องว่างนั้นอาจใหญ่กว่าที่มือใหม่จะเดาไว้ตอนแรก

6. ทางลาด: ขีดจำกัดมุมที่เดินได้และการไถลบนทางชัน

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

ทางลาดที่อยู่ในขีดจำกัดคือทางที่เดินได้: ตัวละครควรเคลื่อนที่ไปตามพื้นผิวของมันอย่างลื่นไหล ไม่ใช่ยื่นออกมาในมุมแนวราบแล้วเสียบเข้าไปในมันหรือลอยเหนือมัน ตรงนี้ใช้ Vector3.ProjectOnPlane อีกครั้ง — แบนการเคลื่อนที่แนวราบที่ตั้งใจไว้ลงบนระนาบของทางลาดก่อนจะใช้งานจริง เหมือนกับ AdjustMoveForSlope ในบทที่ 6.2 เป๊ะ ๆ

ทางลาดที่เกินขีดจำกัดคือทางที่เดินไม่ได้ และต้องจัดการต่างออกไป: แทนที่จะให้ตัวละครปีนขึ้นไป ให้ปฏิบัติกับมันเหมือนที่ collide-and-slide ปฏิบัติกับกำแพง — เพียงแต่ "ทิศทางไถล" ตรงนี้ไม่ใช่ทิศทาง input ของผู้เล่น แต่เป็นตรงลงล่าง ที่ถูก redirect ไปตามผิวของทางลาด สิ่งนี้จะสร้างพฤติกรรม "ไถลตกจากพื้นผิวชัน" ที่ผู้เล่นคาดหวังพอดี

normal (n) ^ . . steep slope surface . / theta -> . / __________. /_______________ theta = angle between n and Vector3.up theta <= slopeLimit -> walkable: move projected flat onto the slope theta > slopeLimit -> too steep: character slides DOWN the slope, in the direction "down" projects onto it
public float slopeLimit = 45f;
public float slideSpeed = 8f;

Vector3 ComputeSlideVelocity(Vector3 normal)
{
    // "straight down," flattened onto the slope's own surface
    return Vector3.ProjectOnPlane(Vector3.down, normal).normalized * slideSpeed;
}

bool IsWalkable(Vector3 normal)
{
    return Vector3.Angle(normal, Vector3.up) <= slopeLimit;
}

ตัวอย่างที่คำนวณจริง: ทางลาดที่มี surface normal (0.8, 0.6, 0) (เป็น unit vector — ตรวจดู: 0.8² + 0.6² = 0.64 + 0.36 = 1.0 ถูกต้อง) มุมจากแนวตั้งคือ arccos(0.6) ≈ 53.13° ซึ่งชันกว่า slopeLimit ที่ 45° ดังนั้นนับว่าเดินไม่ได้ แล้วตัวละครจะไถลไปทิศทางไหน?

using System;

class SlopeSlide
{
    static void Main()
    {
        float nx = 0.8f, ny = 0.6f, nz = 0f;      // slope's surface normal
        float dx = 0f, dy = -1f, dz = 0f;         // straight down

        float dot = dx * nx + dy * ny + dz * nz;

        float sx = dx - dot * nx;
        float sy = dy - dot * ny;
        float sz = dz - dot * nz;

        float len = (float)Math.Sqrt(sx * sx + sy * sy + sz * sz);

        Console.WriteLine($"dot(down,n) = {dot}");
        Console.WriteLine($"slide direction (unnormalized) = ({sx}, {sy}, {sz})");
        Console.WriteLine($"length = {len}");
        Console.WriteLine($"normalized slide direction = ({sx / len}, {sy / len}, {sz / len})");
    }
}

ผลลัพธ์:

dot(down,n) = -0.6
slide direction (unnormalized) = (0.48, -0.64, 0)
length = 0.8
normalized slide direction = (0.6, -0.8, 0)

ตัวละครไถลลงเป็นส่วนใหญ่และเบี่ยงข้างเล็กน้อย ตรงกับทิศทางที่แรงโน้มถ่วงจะดึงวัตถุที่แนบสนิทกับผิวทางลาดนั้นพอดี — ไม่ใช่ตรงลงทะลุทางลาด และไม่ใช่เบี่ยงข้างหลุดออกไปจากมัน เอาทิศทางที่ normalize แล้วนี้คูณด้วย slideSpeed แล้วส่งเข้า Move() ตัวเดียวกันจากหัวข้อ 4 แคปซูลก็จะไถลลงพื้นผิวชันด้วยโค้ด collide-and-slide ตัวเดียวกับที่จัดการทุกอย่าง

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

7. ขั้นบันไดกับ step offset

step — ขอบถนน บันได รากไม้ — สั้นพอที่ไม่ควรทำให้ตัวละครช้าลงเลย แต่สูงพอที่ทางเรขาคณิตแล้ว collide-and-slide จะมองว่ามันเป็นกำแพง: hemisphere ด้านล่างของแคปซูลจะชนหน้าตั้งของ step แล้วไถลจนหยุดอยู่ตรงหน้ามัน เหมือนชนกำแพงทั่วไป

stepOffset คือความสูง (หน่วย) ที่ mover ควรปีนข้ามส่วนนูนโดยอัตโนมัติแทนที่จะมองมันเป็นกำแพง เคล็ดลับเบื้องหลังไม่ต้องใช้ฟิสิกส์ใหม่เลย มันใช้ Move() ตัวเดียวกันจากหัวข้อ 4 ซ้ำสามครั้งตามลำดับ:

1) try the normal horizontal move -> blocked by the step's vertical face ____ |step| o-->#| | |____| ===== ===================== 2) move UP by stepOffset first (a small vertical Move call) ____ |step| o #| | |____| ===== ===================== 3) try the horizontal move again from the raised position -> now clear, since the capsule's bottom is now above the step's top face ____ |step| | o--> |____| ===== ===================== 4) move back DOWN until the capsule settles back onto the ground (either the top of the step, or back to the original floor level)
public float stepOffset = 0.3f;

// Called when the normal horizontal move was blocked by something low
// enough that it might just be a step. Returns true if it climbed it.
bool TryStepUp(Vector3 horizontalMove)
{
    Vector3 startPosition = transform.position;

    // 1) lift up by stepOffset, stopping early if something blocks even that
    Move(Vector3.up * stepOffset);

    // 2) try the original horizontal move again from up here
    Move(horizontalMove);

    // 3) drop back down onto whatever is below, up to stepOffset + a margin
    GetCapsulePoints(out Vector3 point0, out Vector3 point1);
    bool grounded = Physics.SphereCast(point1, capsule.radius, Vector3.down,
        out RaycastHit hit, stepOffset + 0.1f, collisionMask);

    if (grounded)
    {
        transform.position += Vector3.down * hit.distance;
        return true;
    }

    // nothing solid found below - this wasn't a valid step, undo everything
    transform.position = startPosition;
    return false;
}

รายละเอียดสำคัญของดีไซน์นี้อยู่ที่ branch สุดท้าย: ถ้าการยกขึ้นแล้วขยับไปข้างหน้าไม่ได้ลงบนพื้นแข็งภายในระยะประมาณ stepOffset ความพยายามทั้งหมดจะถูกยกเลิกกลับไปที่เดิม นี่คือสิ่งที่ทำให้กำแพงสูงยังคงเป็นกำแพง — จากมุมมองของฟังก์ชันนี้ กำแพงก็แค่ "step ที่สูงกว่า stepOffset" และการเช็ค drop-back-down คือสิ่งที่แยกทั้งสองแบบออกจากกัน

Tip นี่คือสิ่งที่ CharacterController.stepOffset ในตัวของ Unity ทำให้คุณข้างในเป๊ะ ๆ แค่เป็นโค้ด native ของ engine แทนที่จะเป็นสคริปต์ การตั้งค่ามันคือ Inspector field สองตัว ส่วนการเขียนเองแบบข้างบนนี้คือประมาณ 15 บรรทัด — เป็นเหตุผลหนึ่งที่หลายทีมเลือกใช้ component ในตัวสำหรับจัดการ step แม้ว่าจะเขียน mover แบบ custom เองสำหรับส่วนอื่นทั้งหมดก็ตาม

8. แรงโน้มถ่วงและ terminal velocity

บทที่ 6.2 พูดถึงแรงโน้มถ่วงในฐานะค่าที่สะสมเข้า verticalVelocity ทุกเฟรมขณะลอยตัวอยู่กลางอากาศ มีอีกส่วนหนึ่งที่บทนั้นยังไม่ครอบคลุม: วัตถุที่ตกอย่างอิสระจริง ๆ ไม่ได้เร่งความเร็วไปเรื่อย ๆ ตลอดกาล — แรงต้านอากาศจะค่อย ๆ สมดุลกับแรงโน้มถ่วงในที่สุด แล้ววัตถุก็หยุดเร่งความเร็ว ความเร็วที่สมดุลนั้นเรียกว่า terminal velocity character mover ไม่ได้จำลองแรงต้านอากาศจริง ๆ แต่มันเลียนแบบผลลัพธ์ที่มองเห็นได้: clamp ความเร็วตกให้มีค่าสูงสุด แทนที่จะปล่อยให้มันโตไม่มีขีดจำกัด

มีเหตุผลที่ใช้งานได้จริงสองอย่างที่ทำให้เรื่องนี้สำคัญ นอกเหนือจากความสมจริง:

public float gravity = -36f;          // units/s^2
public float terminalVelocity = -24f; // most negative allowed fall speed, units/s

float verticalVelocity;

void ApplyGravity()
{
    verticalVelocity += gravity * Time.deltaTime;
    verticalVelocity = Mathf.Max(verticalVelocity, terminalVelocity);
}

ไล่ดูทีละเฟรมที่ 30 FPS คงที่ (Time.deltaTime = 1/30 วินาที พอดี) เริ่มจาก verticalVelocity = 0 ตอนเริ่มตก: แต่ละเฟรมลบไป 36 * (1/30) = 1.2 หน่วย/วินาที จนกระทั่ง clamp หยุดมันไว้ที่ -24

frame: 0 1 2 3 4 5 ... 20 21 velocity (u/s): 0.0 -1.2 -2.4 -3.6 -4.8 -6.0 ... -24.0 -24.0 ^ clamp reached here exactly without clamp, frame 21 would have been -25.2

เฟรมที่ 20 ลงเอยที่ -24.0 พอดี เพราะ 24 / 1.2 = 20 ลงตัวพอดี — เป็นความบังเอิญของตัวเลขชุดนี้ ที่เลือกมาให้ trace ลงเอยด้วยค่าที่ลงตัว แต่รูปร่างของกราฟ (ทางลาดตรงลง แล้วเป็นเส้นราบ) คือสิ่งที่เกิดขึ้นเสมอไม่ว่าจะใช้คู่ของ gravity กับ terminal velocity คู่ไหนก็ตาม

Common mistake ตั้งค่า terminalVelocity ให้สุดโต่งเกินไป (หรือลืมตั้งไปเลย) จนมันไม่เคยถูกใช้จริงในการตกปกติของเกม การ clamp จะช่วยได้ก็ต่อเมื่อการตกยาวพอที่จะไปถึงมันเท่านั้น — ถ้าการตกที่สูงที่สุดในเลเวลของคุณไปถึงแค่ -18 หน่วย/วินาที แต่ terminalVelocity ตั้งไว้ที่ -200 คุณก็ยังมีความเสี่ยง tunneling แบบเดิมเป๊ะที่พยายามจะแก้อยู่ดี ปรับมันให้เข้ากับ level geometry จริงของคุณ: ความเร็วตกสูงสุดที่คุณอยากอนุญาตก่อนที่ระยะทางการเคลื่อนที่ต่อเฟรมของตัวละครจะเสี่ยงข้ามพ้น collider พื้นที่บางที่สุดของคุณไปได้

9. การยืนอยู่บนแพลตฟอร์มที่เคลื่อนที่ได้

ทุกอย่างที่ผ่านมาสมมติว่าพื้นไม่เคลื่อนที่ แพลตฟอร์มลิฟต์ทำลายสมมติฐานนั้น และทำลายมันในแบบเฉพาะเจาะจง: Move() ของตัวละครเองจะคำนึงถึงแค่การเคลื่อนที่ที่ตัวละครเองตั้งใจ (input บวกแรงโน้มถ่วง) เท่านั้น ถ้าแพลตฟอร์มข้างใต้ไถลออกจากใต้ตัวละครระหว่างเฟรมหนึ่งไปอีกเฟรม ไม่มีอะไรในหัวข้อ 1–8 รู้เรื่องนี้เลย — จากมุมมองของ mover พื้นแค่เทเลพอร์ตไปนิดหน่อย และตัวละครถูกทิ้งไว้ข้างหลัง ยืนอยู่กลางอากาศตรงที่แพลตฟอร์มเคยอยู่ หรือไถลถอยหลังเมื่อเทียบกับมัน

frame N: platform at x=10.0, character standing on it at world x=10.0 frame N+1, WITHOUT the fix: platform moves to x=10.5 character's own Move() adds nothing extra, so character stays at x=10.0 platform: [==========] platform: [==========] ^ character here ^ character now behind it! x=10.0 x=10.0 x=10.5 frame N+1, WITH the fix: character's position also gets +0.5 added, the same delta the platform itself moved, so it rides along correctly

ทางแก้: ติดตามว่าแพลตฟอร์มเองเคลื่อนที่ไปเท่าไหร่ในเฟรมนี้ (delta ของมัน) แล้วถ้าตัวละครกำลังยืนอยู่บนแพลตฟอร์มตัวนั้นอยู่ตอนนี้ ก็บวก delta เดียวกันนั้นเข้ากับตำแหน่งของตัวละครก่อนที่จะเรียก Move() ของมันเองในเฟรมนั้น

using UnityEngine;

public class MovingPlatform : MonoBehaviour
{
    Vector3 lastPosition;
    public Vector3 DeltaPosition { get; private set; }

    void Awake()
    {
        lastPosition = transform.position;
    }

    void FixedUpdate()
    {
        DeltaPosition = transform.position - lastPosition;
        lastPosition = transform.position;
    }
}
// inside the character mover, once per frame, BEFORE its own Move() call:
void ApplyPlatformMotion(RaycastHit groundHit)
{
    MovingPlatform platform = groundHit.collider.GetComponent<MovingPlatform>();
    if (platform != null && isGrounded)
    {
        transform.position += platform.DeltaPosition;
    }
}

มีอีกรายละเอียดหนึ่งที่คนมักงงตอนเจอครั้งแรก: เวลาตัวละครกระโดดออกจากแพลตฟอร์มที่เคลื่อนที่อยู่ มันควรจะยังลอยไปด้านข้างต่อตามการเคลื่อนที่ของแพลตฟอร์ม หรือควรตกตรงลงเหมือนแพลตฟอร์มไม่เคยเคลื่อนที่เลย? สัญชาตญาณทางฟิสิกส์จริง ๆ และเกมแอ็กชันส่วนใหญ่ ต้องการแบบแรก — ตัวละครควรเก็บ horizontal velocity ของแพลตฟอร์ม ณ ขณะที่ออกจากมันพอดี เหมือนกับตอนที่คุณกระโดดออกจากสเก็ตบอร์ดที่กำลังเคลื่อนที่ ความเร็วด้านข้างก็ติดตัวไปด้วย นี่เรียกว่า velocity inheritance (การสืบทอด velocity): ณ ตอนที่ออกจากแพลตฟอร์ม (ไม่ว่าจะกระโดดหรือแค่เดินหลุดขอบไป) ให้บวก velocity ปัจจุบันของแพลตฟอร์ม (delta position หารด้วย delta time) เข้ากับ horizontal velocity ของตัวละครเองครั้งเดียว แทนที่จะยัง snap ตำแหน่งต่อไปทุกเฟรม

Tip CharacterController ในตัวของ Unity ไม่ได้ทำสิ่งเหล่านี้ให้อัตโนมัติเลย — มันรู้แค่เรื่อง static กับ kinematic collider ที่มันแตะโดยตรงระหว่างการเรียก Move() ครั้งล่าสุด การรองรับแพลตฟอร์มเคลื่อนที่ ทั้งใน component ในตัวและ mover แบบ custom เต็มรูปแบบ เป็นสิ่งที่คุณต้องเพิ่มเองเสมอ ด้วยแพทเทิร์นการติดตาม delta แบบข้างบนนี้เป๊ะ ๆ

10. Kinematic กับ dynamic controller: การชั่งน้ำหนักข้อดีข้อเสีย

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

DYNAMIC (Rigidbody, physics solver drives it) ----------------------------------------------- + gets pushed by explosions, other rigidbodies, for free + rests naturally on top of moving platforms via contact/friction + good fit for physics-heavy games (climbing, vehicles, ragdoll blends) - solver-driven motion fights precise, frame-exact control - jitter/bounce on seams and glancing hits, hard to fully eliminate - exact per-frame distance moved is not fully in your hands KINEMATIC (collide-and-slide, YOUR code drives it) ----------------------------------------------- + fully deterministic: same input always produces the same motion + no unwanted bounce, no seam jitter - you control every step + precise tuning (chapter 6.2's acceleration/coyote time/buffering all assume this level of control) - does not interact with other physics objects automatically - moving platforms, pushing crates, etc. must be hand-coded (sections 7 and 9 of this chapter) - more code to write and maintain than "just add a Rigidbody"

ตัวละครที่ผู้เล่นควบคุมในเกมแอ็กชัน 3 มิติ เกมแพลตฟอร์ม และเกมยิงที่ปล่อยจริงส่วนใหญ่ใช้แนวทาง kinematic เพราะเหตุผลตรง ๆ ว่าสิ่งที่ทำให้ controller รู้สึกถูกปรับแต่งมาอย่างตั้งใจ — jump arc ที่สม่ำเสมอ ไม่มีการเด้งที่ไม่คาดคิด ground snapping ที่ไม่เคยพลาด — ทั้งหมดนี้ขึ้นอยู่กับความแน่นอน (determinism) ที่ solver ไม่ให้คุณได้ อย่างไรก็ตาม เรามักจะเห็นแบบผสม: kinematic mover สำหรับผู้เล่น พร้อมกับโค้ดเสริมเล็กน้อย (callback สไตล์ OnControllerColliderHit ที่จะพูดถึงต่อไป) ที่ใส่แรงผลักให้กับ dynamic Rigidbody ใด ๆ ที่แคปซูลของผู้เล่นแตะ เพื่อให้ลังยังถูกผลักได้ ทั้งที่ตัวผู้เล่นเองไม่ได้ถูกจำลองด้วย solver

11. CharacterController ในตัวของ Unity เทียบกับการเขียนเอง

ทุกอย่างที่สร้างมาตลอดหัวข้อ 3–9 — capsule casting, collide-and-slide, ground snapping, การจัดการทางลาด, step offset, แรงโน้มถ่วง, แพลตฟอร์มเคลื่อนที่ — โดยแก่นแล้วก็คือการทำ CharacterController ในตัวของ Unity ขึ้นมาใหม่ตั้งแต่ศูนย์ ในสิ่งที่มันทำให้คุณอยู่แล้ว ด้วยโค้ด native ของ engine ที่เร็ว เปิดให้ใช้งานผ่านพื้นผิวที่เล็กกว่ามาก: ส่วนใหญ่ก็แค่ Move(), isGrounded, slopeLimit, และ stepOffset

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

Unity's built-in CharacterController Fully custom mover ------------------------------------- ---------------------------- fast, native, battle-tested you write and own every step small surface: Move(), isGrounded, every number (skin width, max slopeLimit, stepOffset bounces, snap distance) is great default for most projects yours to see and tune internal loop not tunable/visible needed for: wall-running, must hand-code: pushing rigidbodies, ledge grabs, deterministic moving platforms (either way) netcode, unusual abilities

กฎง่าย ๆ ที่ใช้งานได้จริง: เริ่มด้วย CharacterController ในตัวก่อน มันถูกต้อง เร็ว และครอบคลุมเกมส่วนใหญ่ รวมถึงสิ่งที่เกมแอ็กชันบุคคลที่สามสไตล์ HoYoverse ต้องการเป็นส่วนใหญ่ ค่อยเอื้อมไปหา mover แบบ custom เต็มรูปแบบ ที่สร้างตามวิธีที่หัวข้อ 3–9 ของบทนี้อธิบาย เมื่อคุณเจอข้อจำกัดที่เจาะจงและเป็นรูปธรรมของตัวในตัวจริง ๆ เท่านั้น — ความสามารถที่กล่องดำแสดงออกมาไม่ได้ หรือข้อกำหนดเรื่อง determinism ที่มันรับประกันไม่ได้ — แทนที่จะเขียนมันไว้ล่วงหน้า "เผื่อไว้ก่อน"

Tip ต่อให้คุณไม่เคยต้องเขียน mover แบบ custom สำหรับเกมที่ปล่อยจริงเลย การเข้าใจ collide-and-slide คณิตศาสตร์ของแคปซูล และ ground snapping อย่างละเอียด คือสิ่งที่ทำให้คุณวินิจฉัยพฤติกรรมแปลก ๆ ของ CharacterController ในตัวได้ถูกต้อง — การติดตรงชิ้นส่วน level geometry เฉพาะจุด ทางลาดที่ไม่ยอมปล่อย step ที่บางทีก็ปีนได้บางทีก็ไม่ได้ — แทนที่จะเดาค่าใน Inspector มั่ว ๆ

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

13. แบบฝึกหัด

Exercise 1 axis ของแคปซูลตัวหนึ่งลากจาก point0 = (2, 0, 0) ไป point1 = (2, 3, 0) ด้วย radius = 0.6 ใช้วิธีจากหัวข้อ 2 (clamp t ให้อยู่ใน [0, 1] หาจุดที่ใกล้ที่สุดบน axis แล้วเทียบระยะกับ radius) หาว่าจุด (2.45, 1.5, 0.6) อยู่ในแคปซูลหรือไม่ แสดง t จุดที่ใกล้ที่สุด และระยะทาง
Show answer

axis เปลี่ยนแค่ในแกน y ดังนั้น t = (py - 0) / (3 - 0) = 1.5 / 3 = 0.5 ซึ่งอยู่ใน [0, 1] อยู่แล้ว ไม่ต้อง clamp

จุดที่ใกล้ที่สุดบน axis: (2, 0 + 0.5 * 3, 0) = (2, 1.5, 0)

ระยะห่างจากจุดนั้นไปยังจุดทดสอบ: dx = 2.45 - 2 = 0.45, dz = 0.6 - 0 = 0.6, dy = 0

distance = sqrt(0.45^2 + 0.6^2) = sqrt(0.2025 + 0.36) = sqrt(0.5625) = 0.75

0.75 > radius (0.6) ดังนั้น จุดนี้อยู่นอกแคปซูล ห่างออกไป 0.75 - 0.6 = 0.15 หน่วย

Exercise 2 ระหว่างขั้นตอน collide-and-slide leftover move vector คือ v = (0, 0, 0.6) (เหลือ 0.6 หน่วยที่ต้องเดินทาง ตรงไปตามแกน z) และกำแพงที่ชนมี surface normal n = (0.6, 0, -0.8) (เป็น unit vector: ตรวจดู 0.6² + 0.8² = 1) ใช้ slide = v - (v · n) * n แบบในหัวข้อ 4 คำนวณ slide vector และความยาวของมัน
Show answer

Dot product: v · n = 0*0.6 + 0*0 + 0.6*(-0.8) = -0.48

slide.x = 0     - (-0.48)*0.6  = 0 + 0.288 = 0.288
slide.y = 0     - (-0.48)*0    = 0
slide.z = 0.6   - (-0.48)*(-0.8) = 0.6 - 0.384 = 0.216

length = sqrt(0.288^2 + 0.216^2) = sqrt(0.082944 + 0.046656) = sqrt(0.1296) = 0.36

slide vector คือ (0.288, 0, 0.216) ความยาว 0.36 สังเกตว่าความยาวหดจาก 0.6 เดิมเหลือ 0.36 — การ project ลงบนระนาบของกำแพงจะเหลือความยาวเท่าเดิมหรือน้อยกว่าเวกเตอร์เดิมเสมอ เพราะมันทิ้งส่วนที่ชี้ตรงเข้ากำแพงออกไป

Exercise 3 ตัวละครเริ่มตกด้วย verticalVelocity = 0 gravity = -45 หน่วย/วินาที² terminalVelocity = -27 หน่วย/วินาที และเกมรันที่ 20 FPS คงที่ (Time.deltaTime = 0.05 วินาที พอดี) ใช้แพทเทิร์น clamp จากหัวข้อ 8 verticalVelocity หลังผ่านไป 5 เฟรมมีค่าเท่าไหร่? มันถึง terminalVelocity พอดีครั้งแรกที่เฟรมไหน และค่าตรงนั้นคือเท่าไหร่?
Show answer

แต่ละเฟรมลบไป 45 * 0.05 = 2.25 หน่วย/วินาที จนกระทั่ง clamp ที่ -27 หยุดมันไว้

frame:     0     1      2     3      4     5
velocity:  0.0  -2.25  -4.5  -6.75  -9.0  -11.25

หลังผ่านไป 5 เฟรม verticalVelocity = -11.25 หน่วย/วินาที

มันถึง clamp เมื่อ 2.25 * frames = 27 นั่นคือ frames = 27 / 2.25 = 12 ที่เฟรม 12 verticalVelocity ถึง -27.0 พอดีเป็นครั้งแรก (terminal velocity) และคงอยู่ที่นั่นในทุกเฟรมถัดไปเพราะ clamp ยังคงถูกใช้งานซ้ำ ๆ

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