6.5 ระบบกล้อง

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

กล้องตาม, การเล็ง, การจัดเฟรมที่รู้การชน และการ smooth — ระบบเล็กที่ส่งผลมหาศาลต่อความรู้สึกของเกม

กล้อง (camera) ไม่ได้ขยับตัวละครของคุณ ไม่ได้สร้างความเสียหาย และไม่ได้รันตรรกะเกมเพลย์อะไรเลย มันแค่ตัดสินใจว่าผู้เล่นจะเห็นโลกเกมส่วนไหน และมุมมองนั้นเคลื่อนไหวยังไง ฟังดูเหมือนเรื่องเล็ก แต่จริง ๆ แล้วมันเป็นตัวแปรที่ทรงพลังที่สุดตัวหนึ่งที่กำหนดว่าเกม "รู้สึก" ยังไงตอนเล่นจริง บทนี้จะสร้างระบบกล้องทีละชิ้น: follow camera, การทำให้เคลื่อนไหวนุ่มนวล (smoothing), look-ahead, การจำกัดขอบเขต (bounds), กล้อง third-person orbit, การชนกำแพง (wall collision) และ screen shake ระหว่างทางเราจะเอาแนวคิดจากบทก่อน ๆ กลับมาใช้ — ลำดับเฟรม, linear interpolation (Lerp), ตรีโกณมิติ, และ quaternion

1. ทำไมกล้องถึงมีผลต่อความรู้สึกของเกมมากขนาดนี้

ลองนึกภาพเกม platformer สองเกมที่มีด่านเหมือนกันเป๊ะ ฟิสิกส์เหมือนกันเป๊ะ และตัวควบคุมผู้เล่น (player controller) เหมือนกันเป๊ะ เกมแรกกล้องจะสแนป (snap) ไปตำแหน่งผู้เล่นทันทีทุกเฟรม ส่วนเกมที่สองกล้องจะตามหลังอยู่นิดหน่อยแล้วค่อย ๆ เข้าที่ เกมที่สองแทบทุกครั้งจะให้ความรู้สึก "มีชีวิต" มากกว่าและดูไม่แข็งทื่อ ทั้งที่ตัวเกมเพลย์จริง ๆ ไม่ได้เปลี่ยนอะไรเลย

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

Tip เวลาคุณไม่แน่ใจว่าทำไมเกมที่ชอบถึง "รู้สึกดี" ลองอัดหน้าจอแล้วดูแบบ slow motion พฤติกรรมของกล้องที่มองไม่เห็นตอนความเร็วปกติ (smoothing, look-ahead, shake) จะเห็นชัดมากเวลาดูทีละเฟรม

สองส่วนของกล้องใน Unity

กล้องทุกตัวใน Unity คือ GameObject ที่มีสองอย่างติดอยู่ ซึ่งสำคัญกับบทนี้: Transform (position, rotation, scale — Transform ตัวเดียวกับที่ GameObject ทุกตัวมี และเป็นสิ่งที่สคริปต์ทุกตัวในบทนี้จะเปลี่ยนค่าทุกเฟรม) และ Camera component (ตั้งค่าอย่าง field of view, จะใช้ projection แบบ perspective หรือ orthographic, และ near/far clip plane — ระยะใกล้สุด/ไกลสุดที่กล้องจะ render)

using UnityEngine;

public class CameraInfo : MonoBehaviour
{
    void Start()
    {
        Camera cam = GetComponent<Camera>();
        Debug.Log("Field of view: " + cam.fieldOfView);
        Debug.Log("Orthographic: " + cam.orthographic);
        Debug.Log("Near clip: " + cam.nearClipPlane);
        Debug.Log("Far clip: " + cam.farClipPlane);
    }
}

ติดสคริปต์นี้ไว้กับ Main Camera แบบตั้งค่าเริ่มต้น จะพิมพ์ออกมาว่า:

Field of view: 60
Orthographic: False
Near clip: 0.3
Far clip: 1000

fieldOfView (FOV) คือมุมของกรวยมุมมอง (view cone) หน่วยเป็นองศา FOV เล็กจะให้ความรู้สึกเหมือนเลนส์ telephoto ซูมเข้า ส่วน FOV ใหญ่จะให้ความรู้สึกมุมกว้าง (wide-angle) และช่วยสื่อความเร็วได้ orthographic เป็น false แปลว่านี่คือกล้องแบบ perspective (ของที่อยู่ไกลจะดูเล็กลง) ซึ่งเป็นตัวเลือกปกติของเกม 3D กล้องแบบ orthographic (ไม่มีการย่อตามระยะ perspective) พบบ่อยในเกม 2D เกมวางแผน และเกม isometric nearClipPlane กับ farClipPlane กำหนดช่วงที่ Unity render จริง ๆ — อะไรที่ใกล้กว่า 0.3 หน่วย หรือไกลกว่า 1000 หน่วยจากกล้อง จะไม่ถูก render เลย

ตั้งแต่ตรงนี้ไปทุกอย่างในบทนี้จะวนอยู่กับคำถามเดียว: Transform ตัวนี้ควรมี position และ rotation เท่าไหร่ ในทุก ๆ เฟรม?

2. Follow Camera แบบพื้นฐาน (และทำไมมันถึงกระตุก)

follow camera แบบง่ายที่สุดคือก็อปตำแหน่งของ target ทุกเฟรม แล้วบวก offset คงที่เข้าไป:

using UnityEngine;

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

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

โค้ดนี้ทำงานได้ ในแง่ที่กล้องตาม target จริง ๆ แต่มันเขียนอยู่ใน Update() ซึ่งทำให้เกิดบั๊กที่สังเกตยาก Unity ไม่การันตีลำดับที่ Update() ของสคริปต์แต่ละตัวจะรัน ภายในเฟรมเดียวกัน (เว้นแต่คุณจะตั้งค่า script execution order เองใน Project Settings) ดังนั้นในเฟรมไหนก็ได้ Update() ของกล้องอาจรันก่อน Update() ของผู้เล่น — หมายความว่ากล้องอ่านตำแหน่งผู้เล่นจากก่อนที่มันจะขยับในเฟรมนี้

// One possible per-frame call order Unity might pick for Update():
Frame 42:
  Camera.Update()  -> reads target.position -> (0.9, 0, 0)   // still last frame's value
  Player.Update()  -> moves the player -> target.position becomes (1.0, 0, 0)
  // The camera already used the OLD position this frame, so it is one step behind.

ถ้า frame rate คงที่และความเร็วคงที่ ความหน่วงนี้จะเป็น offset เล็ก ๆ คงที่ที่แทบมองไม่เห็น ปัญหาจริง ๆ จะโผล่มาตอนคุณเพิ่ม smoothing เข้าไป (หัวข้อ 4) หรือตอนผู้เล่นขยับผ่านฟิสิกส์ (Rigidbody ที่อัปเดตใน FixedUpdate) — กล้องกับ target อาจจบลงด้วยการอ่านตำแหน่งของกันและกันในจังหวะที่ไม่สอดคล้องกันในเฟรม ซึ่งจะออกมาเป็นอาการกระตุก (jitter) ที่มองเห็นได้ หรือ "pop" หนึ่งเฟรมทุกครั้งที่จังหวะเวลาเปลี่ยน

Common mistake การเขียนโค้ด follow camera ไว้ใน Update() เพราะคิดว่า "โค้ดการเคลื่อนที่ต้องอยู่ตรงนี้" โค้ดกล้องเป็นกรณีพิเศษ — มันควรอ่านตำแหน่งหลังจากทุกอย่างขยับเสร็จแล้วในเฟรมนั้นแทบทุกครั้ง

3. LateUpdate: ตามหลังหลังจากทุกอย่างขยับเสร็จแล้ว

จริง ๆ แล้ว Unity รัน pipeline ที่ตายตัวทุกเฟรม: ฟิสิกส์อัปเดตก่อน (FixedUpdate รันศูนย์ครั้งหรือมากกว่านั้น) จากนั้น Update() ของทุกสคริปต์จะรัน และหลังจากทั้งหมดนั้นรันเสร็จแล้วเท่านั้น LateUpdate() ของทุกสคริปต์ถึงจะรัน แล้วเฟรมนั้นก็จะถูก render

Unity's per-frame pipeline (simplified): FixedUpdate() --> Update() for EVERY script --> LateUpdate() for EVERY script --> Render Bad camera: lives in Update() --> might run before the player's Update() moves it Good camera: lives in LateUpdate() --> guaranteed to run after ALL Update() calls this frame

เพราะ LateUpdate() รันหลังจาก Update() ของทุก object รันเสร็จแล้วเท่านั้น กล้องที่เขียนใน LateUpdate() จะเห็นตำแหน่งสุดท้ายของ target ที่ขยับเสร็จเรียบร้อยแล้วในเฟรมนั้นเสมอ ไม่ต้องพึ่งดวงเรื่องลำดับเลย แก้สคริปต์ก่อนหน้าได้ด้วยการเปลี่ยนแค่คำเดียว:

using UnityEngine;

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

    void LateUpdate()
    {
        transform.position = target.position + offset;
    }
}
Frame 42:
  Update() phase     -> Player.Update() moves the player -> target.position becomes (1.0, 0, 0)
                         (every other script's Update() also runs somewhere in this phase)
  LateUpdate() phase -> Camera.LateUpdate() reads target.position -> (1.0, 0, 0)   // fresh and correct

นี่คือกฎที่สำคัญที่สุดในบทนี้: อะไรก็ตามที่ follow, orbit หรือตอบสนองต่อตำแหน่งของ object อื่น ควรอยู่ใน LateUpdate() ไม่ใช่ Update() สคริปต์ทุกตัวที่เหลือในบทนี้จะใช้ LateUpdate()

Tip ถ้า target ของคุณขยับด้วย Rigidbody ใน FixedUpdate อย่าลืมเช็กค่า Interpolate ใน Rigidbody component ด้วย ค่านั้นจะทำให้ตำแหน่งที่มองเห็นนุ่มนวลขึ้นระหว่าง physics step แต่ละครั้ง — มันทำงานร่วมกับ camera smoothing ไม่ใช่แทนที่กัน และเชื่อมโยงกับแนวคิดเรื่อง interpolation จากบท interpolation ก่อนหน้านี้

4. ทำให้ Follow นุ่มนวล: Lerp, ความเป็นอิสระจาก Framerate, และ SmoothDamp

กล้องที่สแนปไปตำแหน่ง target เป๊ะ ๆ ทุกเฟรมจะให้ความรู้สึกแข็งทื่อ เกมส่วนใหญ่มักปล่อยให้กล้องตามหลังนิดหน่อยแล้วค่อย ๆ เข้าที่ — เป็นแนวคิด linear interpolation (Lerp) ตัวเดียวกับที่เจอในบท interpolation แค่เอามาใช้กับกล้องทุก ๆ เฟรม

เวอร์ชันพื้นฐาน — และบั๊กเรื่อง Framerate

using UnityEngine;

public class FollowCameraNaiveLerp : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothFactor = 0.1f; // fraction of the remaining distance closed EACH CALL

    void LateUpdate()
    {
        Vector3 desiredPosition = target.position + offset;
        transform.position = Vector3.Lerp(transform.position, desiredPosition, smoothFactor);
    }
}

โค้ดนี้จะปิดระยะที่เหลือ 10% ทุกครั้งที่ LateUpdate() รัน บั๊กอยู่ตรงนี้: LateUpdate() รันหนึ่งครั้งต่อเฟรมที่ render และจำนวนเฟรมต่อวินาทีขึ้นอยู่กับฮาร์ดแวร์ของผู้เล่น ค่า factor คงที่ต่อการเรียกหนึ่งครั้ง หมายความว่ากล้องจะปิดระยะห่างด้วยความเร็วจริงที่ต่างกันมาก ขึ้นอยู่กับ frame rate:

// remaining fraction of the distance left after 1 real second, factor = 0.1:
// 30 FPS  (30 calls/second):  0.9^30  => about 4.2% of the distance still remains
// 60 FPS  (60 calls/second):  0.9^60  => about 0.18% of the distance still remains
// 144 FPS (144 calls/second): 0.9^144 => about 0.00009% -- visually already snapped

ผู้เล่นที่ใช้จอ 144Hz จะได้กล้องที่แทบจะสแนปทันที ส่วนผู้เล่นที่เล่นบนคอนโซล 30 FPS จะได้กล้องที่หน่วงเห็นได้ชัด โค้ดเดียวกันเป๊ะ แต่ให้ความรู้สึกต่างกันลิบลับ — เพราะแค่ frame rate ล้วน ๆ

Common mistake การเขียน Vector3.Lerp(transform.position, desiredPosition, speed * Time.deltaTime) แล้วคิดว่ามันแก้ปัญหาเรื่อง framerate independence ได้แล้ว มันดีขึ้นจริง แต่เป็นแค่การประมาณเท่านั้น — ตอน frame rate ต่ำ ๆ speed * Time.deltaTime อาจเกิน 1 ได้ ซึ่งจะทำให้ overshoot เป้าหมาย หรือถึงขั้น oscillate เลยก็ได้ ให้ใช้สูตรที่แม่นยำด้านล่าง หรือ SmoothDamp แทน

ทางแก้: Exponential Smoothing

using UnityEngine;

public class FollowCameraSmoothed : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothRate = 8f; // higher = snappier, in "closures per second"

    void LateUpdate()
    {
        Vector3 desiredPosition = target.position + offset;
        float t = 1f - Mathf.Exp(-smoothRate * Time.deltaTime);
        transform.position = Vector3.Lerp(transform.position, desiredPosition, t);
    }
}

โค้ดนี้ใช้ Mathf.Exp (ฟังก์ชัน exponential) แปลง smoothRate กับเวลาที่ผ่านไปจริง (Time.deltaTime) ให้กลายเป็นค่า factor t ของ Lerp เพราะมันอิงกับเวลาจริงที่ผ่านไป ไม่ใช่ "รันไปกี่ครั้งแล้ว" ระยะทางรวมที่ปิดได้หลังจากเวลาจริงจำนวนหนึ่งจะเกือบเท่ากันเป๊ะ ไม่ว่า frame rate จะเป็นเท่าไหร่:

// remaining fraction after 0.5 real seconds, smoothRate = 8:
// computed in 15 steps at 30 FPS, or 30 steps at 60 FPS, or 72 steps at 144 FPS --
// all three land within a fraction of a percent of exp(-8 * 0.5) => about 1.8% remaining

ความรู้สึกเดียวกัน ไม่ว่าฮาร์ดแวร์ไหน นี่คือแพทเทิร์นทั่วไป: อย่าฝัง "จำนวนต่อเฟรม" ดิบ ๆ ลงไปใน Lerp factor เด็ดขาด — แปลง "จำนวนต่อวินาที" ที่คุณต้องการให้เป็นค่า factor ต่อเฟรมโดยใช้ Time.deltaTime ทางที่ดีที่สุดคือผ่าน Mathf.Exp แบบด้านบน

เวอร์ชันที่ Unity มีให้ในตัว: SmoothDamp

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

using UnityEngine;

public class FollowCameraSmoothDamp : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothTime = 0.25f; // roughly, seconds to close most of the distance
    Vector3 velocity; // SmoothDamp needs a place to remember velocity between calls

    void LateUpdate()
    {
        Vector3 desiredPosition = target.position + offset;
        transform.position = Vector3.SmoothDamp(transform.position, desiredPosition, ref velocity, smoothTime);
    }
}

Vector3.SmoothDamp เป็น framerate-independent อยู่แล้วในตัว (มันอ่าน Time.deltaTime เองข้างใน) พารามิเตอร์ ref velocity คือตัวแปรที่ SmoothDamp อ่านและเขียนทุกครั้งที่เรียก เพื่อติดตามว่ากล้องกำลังเคลื่อนที่เร็วแค่ไหนอยู่ — คุณแค่ประกาศมันเป็น field ครั้งเดียว แล้วส่งมันเข้าไปเรื่อย ๆ คุณไม่ต้องไปกำหนดค่ามันเองเลย ในทางปฏิบัติ SmoothDamp คือสิ่งที่ follow camera ที่เขียนมือส่วนใหญ่ใน Unity ใช้กันจริง ๆ

Target moves right. The camera trails and gradually catches up (C = camera, T = target): frame 0: C-------------------T frame 1: C---------------T frame 2: C-----------T frame 3: C-------T frame 4: C---T frame 5: CT (camera has essentially caught up) That trailing gap is not a bug -- it is exactly what makes the motion read as smooth instead of the camera instantly teleporting onto the target every single frame.

5. Look-Ahead: โชว์สิ่งที่กำลังจะมาถึง

follow camera แบบล้วน ๆ จะเอา target ไว้ตรงกลางจอเสมอ ตอนความเร็วต่ำมันโอเค แต่ตอนความเร็วสูงผู้เล่นมักอยากเห็นสิ่งที่อยู่ข้างหน้ามากกว่าข้างหลัง — กล้องเลยควรเอียงไปทางทิศทางที่กำลังเคลื่อนที่นิดหน่อย

using UnityEngine;

public class FollowCameraLookAhead : MonoBehaviour
{
    public Transform target;
    public Rigidbody targetBody;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothRate = 8f;
    public float lookAheadDistance = 3f;
    public float lookAheadSmoothRate = 4f;

    Vector3 currentLookAhead;

    void LateUpdate()
    {
        Vector3 desiredLookAhead = Vector3.zero;
        if (targetBody.velocity.sqrMagnitude > 0.01f)
        {
            desiredLookAhead = targetBody.velocity.normalized * lookAheadDistance;
        }

        // smooth the look-ahead offset itself, separately from the camera position
        float lookT = 1f - Mathf.Exp(-lookAheadSmoothRate * Time.deltaTime);
        currentLookAhead = Vector3.Lerp(currentLookAhead, desiredLookAhead, lookT);

        Vector3 desiredPosition = target.position + offset + currentLookAhead;
        float t = 1f - Mathf.Exp(-smoothRate * Time.deltaTime);
        transform.position = Vector3.Lerp(transform.position, desiredPosition, t);
    }
}

สังเกตว่า currentLookAhead ถูก smooth แยกต่างหากของมันเอง แยกจาก position smoothing ของกล้องเอง นี่คือเหตุผลว่าทำไมมันสำคัญ — ลอง trace ดูตอนผู้เล่นกลับทิศทางกะทันหัน:

// player was running right, instantly stops and runs left. lookAheadDistance = 3
// WITHOUT its own smoothing (using desiredLookAhead directly every frame):
frame 0: desiredLookAhead.x = +3.0   (running right)
frame 1: desiredLookAhead.x = -3.0   (running left) -- camera would JUMP 6 units in one frame

// WITH its own smoothing (currentLookAhead easing toward desiredLookAhead):
frame 0: currentLookAhead.x = 2.6
frame 1: currentLookAhead.x = 1.4   -- easing back through zero
frame 2: currentLookAhead.x = 0.1
frame 3: currentLookAhead.x = -1.0  -- easing out to the other side, no sudden jump

ถ้าไม่มีขั้นตอน smoothing เพิ่มนี้ ทุกครั้งที่เปลี่ยนทิศทางกล้องจะสะบัดทันที — ซึ่งเป็นความรู้สึกกระตุกแบบที่บททั้งบทนี้พยายามหลีกเลี่ยงเป๊ะ ๆ

Standing still, camera centers the target: Running right, camera leans ahead: [Cam] [Cam] | | v v (target) (target) ---look-ahead---> (more open space here)

6. จำกัดขอบเขตกล้องให้อยู่ในด่าน

ในด่านที่มีขอบชัดเจน (จุดเริ่ม/จบของเกม side-scroller หรือกำแพงของสนามแบบ top-down) ปกติคุณจะไม่อยากให้กล้องโชว์พื้นที่ว่างเปล่าเลยขอบนั้นออกไป ทางแก้ง่าย ๆ คือ clamp ตำแหน่งกล้องที่ต้องการให้อยู่ในกล่อง (box) ก่อนที่จะ smooth เข้าไปหามัน

using UnityEngine;

public class FollowCameraClamped : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothRate = 8f;
    public Vector2 minBounds = new Vector2(-20f, -5f);
    public Vector2 maxBounds = new Vector2(20f, 15f);

    void LateUpdate()
    {
        Vector3 desiredPosition = target.position + offset;

        desiredPosition.x = Mathf.Clamp(desiredPosition.x, minBounds.x, maxBounds.x);
        desiredPosition.y = Mathf.Clamp(desiredPosition.y, minBounds.y, maxBounds.y);

        float t = 1f - Mathf.Exp(-smoothRate * Time.deltaTime);
        transform.position = Vector3.Lerp(transform.position, desiredPosition, t);
    }
}

ลอง trace ดู โดยที่ maxBounds.x = 20 ขณะ target เดินเข้าใกล้ขอบขวาของด่าน:

// target.x = 18, offset.x = 0  -> desiredPosition.x = 18   (18 <= 20, unclamped)
// target.x = 22, offset.x = 0  -> desiredPosition.x = 22   (22 > 20)
//                                  Mathf.Clamp(22, -20, 20) => 20   -- held at the edge
// target.x = 30, offset.x = 0  -> desiredPosition.x = 30   (30 > 20)
//                                  Mathf.Clamp(30, -20, 20) => 20   -- still held at the edge

แม้ target จะเดินเลย x = 20 ไปเรื่อย ๆ ตำแหน่งที่กล้องต้องการจะไม่มีวันเลยจุด clamp ไปได้ — ผู้เล่นแค่เดินเข้าใกล้ขอบจอแทน แทนที่กล้องจะตามไปโชว์พื้นที่ว่างเปล่าต่อ

Common mistake clamp แค่จุดกึ่งกลางของกล้องอย่างเดียว แล้วลืมไปว่าจริง ๆ กล้องโชว์สี่เหลี่ยมทั้งผืน (หรือ frustum) รอบจุดนั้น ถ้า bounds ของคุณตรงกับขอบด่านเป๊ะ กล้องก็ยังโชว์พื้นที่ว่างเลยขอบไปได้อยู่ดี ให้ย่อกล่อง clamp เข้ามาด้านในประมาณครึ่งหนึ่งของความกว้าง/สูงที่กล้องมองเห็น (สำหรับกล้อง orthographic คือครึ่งหนึ่งของ orthographicSize คูณ aspect ratio) เพื่อซ่อนขอบให้มิดจริง ๆ

7. กล้อง Third-Person Orbit: Yaw, Pitch, และ Quaternion

follow camera จะรักษา offset คงที่ไว้ตลอด ส่วนกล้อง third-person orbit จะให้ผู้เล่นหมุนรอบ target ด้วยเมาส์แทน — ลองนึกถึงเกม action หรือ RPG สมัยใหม่แทบทุกเกม ตรงนี้แหละที่ตรีโกณมิติและ quaternion จากบทก่อน ๆ จะกลับมาใช้ตรง ๆ

แนวคิดคือ: เก็บมุมสองตัว yaw (การหมุนรอบแกน up ของโลก — หันซ้าย/ขวา) และ pitch (การหมุนรอบแกนด้านข้างแบบ local — ก้ม/เงย) รวมสองมุมนี้เข้าเป็น rotation เดียว แล้ววางกล้องไว้ข้างหลัง target ตามทิศทาง forward ของ rotation นั้น

using UnityEngine;

public class OrbitCamera : MonoBehaviour
{
    public Transform target;
    public float distance = 5f;
    public float yawSpeed = 120f;
    public float pitchSpeed = 80f;
    public float minPitch = -20f;
    public float maxPitch = 60f;

    float yaw;
    float pitch = 15f;

    void LateUpdate()
    {
        yaw += Input.GetAxis("Mouse X") * yawSpeed * Time.deltaTime;
        pitch -= Input.GetAxis("Mouse Y") * pitchSpeed * Time.deltaTime;
        pitch = Mathf.Clamp(pitch, minPitch, maxPitch);

        Quaternion rotation = Quaternion.Euler(pitch, yaw, 0f);
        Vector3 desiredPosition = target.position - (rotation * Vector3.forward * distance);

        transform.position = desiredPosition;
        transform.rotation = rotation;
    }
}

Quaternion.Euler(pitch, yaw, 0f) สร้าง rotation เดียวจากสองมุมนี้ (นี่คือการแปลง Euler angle เป็น quaternion แบบเป๊ะ ๆ ที่เจอในบท quaternion) rotation * Vector3.forward เอาทิศทาง "forward" ของโลกมาหมุนด้วย quaternion นั้น — ผลลัพธ์คือ unit vector ที่ชี้ไปทางที่กล้องกำลังมองอยู่ตอนนั้น เอามันไปคูณกับ distance แล้วลบออกจากตำแหน่งของ target จะวางกล้องไว้ห่างจากทิศทางที่กล้องหันหน้าไปพอดี distance หน่วย

// yaw = 0, pitch = 0: rotation * Vector3.forward = (0, 0, 1)
//   desiredPosition = target.position - (0, 0, 1) * 5 = target.position + (0, 0, -5)
//   -- camera sits directly behind the target on the -Z side, facing +Z (toward the target)

// yaw = 90, pitch = 0: rotation * Vector3.forward = (1, 0, 0)   (rotated 90 deg around Y)
//   desiredPosition = target.position - (1, 0, 0) * 5 = target.position + (-5, 0, 0)
//   -- camera has swung around to the target's left side
Top-down view (yaw = rotation around the target's up axis): yaw=90 | yaw=180 --(target)-- yaw=0 | yaw=270 Side view (pitch = rotation around the local sideways axis, clamped so it cannot flip over): camera (pitch = maxPitch, looking down) / / ------(target)------ / / camera (pitch = minPitch, looking up)
Tip การ clamp pitch ให้อยู่ในช่วงที่สมเหตุสมผล (ในที่นี้คือ -20 ถึง 60 องศา) ช่วยไม่ให้กล้องหมุนพลิกข้าม target จนกลับหัวได้ ซึ่งจะทำให้ผู้เล่นงงมาก นี่คือเหตุผลที่โค้ดเก็บ yaw กับ pitch เป็น float แยกกันสองตัว แทนที่จะสะสม rotation ตรง ๆ บน Transform — float แยกกัน clamp ง่าย แต่ quaternion ที่สะสมไปเรื่อย ๆ clamp ไม่ได้

8. กล้องที่รู้จักการชน (Collision-Aware): อย่าให้ทะลุกำแพง

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

using UnityEngine;

public class OrbitCameraWithCollision : MonoBehaviour
{
    public Transform target;
    public float distance = 5f;
    public float yawSpeed = 120f;
    public float pitchSpeed = 80f;
    public float minPitch = -20f;
    public float maxPitch = 60f;
    public float collisionBuffer = 0.3f;
    public LayerMask collisionMask;

    float yaw;
    float pitch = 15f;

    void LateUpdate()
    {
        yaw += Input.GetAxis("Mouse X") * yawSpeed * Time.deltaTime;
        pitch -= Input.GetAxis("Mouse Y") * pitchSpeed * Time.deltaTime;
        pitch = Mathf.Clamp(pitch, minPitch, maxPitch);

        Quaternion rotation = Quaternion.Euler(pitch, yaw, 0f);
        Vector3 camDirection = rotation * Vector3.forward * -1f; // direction FROM target TO camera
        float desiredDistance = distance;

        RaycastHit hit;
        if (Physics.Raycast(target.position, camDirection, out hit, distance, collisionMask))
        {
            desiredDistance = Mathf.Max(hit.distance - collisionBuffer, 0.2f);
        }

        transform.position = target.position + camDirection * desiredDistance;
        transform.rotation = rotation;
    }
}

camDirection ชี้จาก target ไปทางที่กล้องอยากอยู่ Physics.Raycast จะยิง ray ความยาว distance ไปทางนั้น แล้วรายงานสิ่งที่ใกล้ที่สุดที่มันชน ผ่านพารามิเตอร์ output ชื่อ hit ถ้าไม่ชนอะไรเลย กล้องก็ใช้ distance เต็ม ถ้าชนอะไรเข้า กล้องจะถูกวางไว้ที่ hit.distance (ระยะตามแนว ray ที่ชน) ลบด้วย collisionBuffer เล็กน้อย เพื่อให้กล้องอยู่ข้างหน้ากำแพงนิดหน่อยแทนที่จะไปแตะมันเลย

Clear line of sight -- camera sits at the full desired distance: (target) ---------------------------> [camera] Wall in the way -- the ray from target toward the desired spot hits it first: (target) --------|WALL| [camera desired position, but that spot is blocked] ray hits the wall here Camera gets pulled in to just before the hit point: (target) --------[camera]|WALL|

ลอง trace ดู distance = 5, collisionBuffer = 0.3:

// no wall within 5 units: hit is not found -> desiredDistance stays 5.0
// wall found at hit.distance = 2.0:
//   desiredDistance = Mathf.Max(2.0 - 0.3, 0.2) = 1.7   -- camera sits 1.7 units from the target
// wall found extremely close, hit.distance = 0.1:
//   desiredDistance = Mathf.Max(0.1 - 0.3, 0.2) = Mathf.Max(-0.2, 0.2) = 0.2  -- floor prevents going negative
Common mistake ใช้ Physics.Raycast (เส้นบางไม่มีความหนา) กับการชนของกล้อง ray บาง ๆ อาจลอดผ่านช่องว่างข้างมุมกำแพงบาง ๆ ไปได้ ทั้งที่เลนส์กล้องจริง ๆ (ซึ่งมีความกว้างอยู่) จะยังชนอยู่ Physics.SphereCast (ray ที่มีความหนา) มักจะเหมาะกับกล้องมากกว่า — แนวคิดเดียวกันเลย แค่เปลี่ยน Raycast เป็น SphereCast แล้วใส่ radius เล็ก ๆ เข้าไป

9. Screen Shake และการรวมระบบเข้าด้วยกัน

screen shake ช่วยสื่อถึงแรงกระแทก (impact) — ระเบิด การลงพื้นหนัก ๆ หรือโดนดาเมจ แนวคิดหลักง่ายมาก: ในช่วงเวลาสั้น ๆ บวก offset สุ่มเล็ก ๆ เข้าไปในตำแหน่งกล้องทุกเฟรม แล้วค่อย ๆ ลด offset นั้นลงตามเวลาจนเหลือศูนย์

Offset สุ่มที่ค่อย ๆ ลดลง (Decaying)

using UnityEngine;

public class ScreenShake : MonoBehaviour
{
    float shakeDuration = 0f;
    float shakeMagnitude = 0f;
    Vector3 initialPosition;

    void OnEnable()
    {
        initialPosition = transform.localPosition;
    }

    public void Shake(float duration, float magnitude)
    {
        shakeDuration = duration;
        shakeMagnitude = magnitude;
    }

    void Update()
    {
        if (shakeDuration > 0f)
        {
            transform.localPosition = initialPosition + Random.insideUnitSphere * shakeMagnitude;
            shakeDuration -= Time.deltaTime;
        }
        else
        {
            transform.localPosition = initialPosition;
        }
    }
}

Random.insideUnitSphere คืนจุดสุ่มภายในทรงกลมรัศมี 1 — เอามันไปคูณกับ shakeMagnitude เพื่อขยายหรือย่อ offset สุ่มนั้น shakeDuration จะนับถอยหลังทุกเฟรมด้วย Time.deltaTime (framerate-independent เหมือนหัวข้อก่อนหน้า) และพอมันถึงศูนย์ กล้องก็จะสแนปกลับไปที่ initialPosition พอดีเป๊ะ

// Shake(duration: 0.3, magnitude: 0.5) called on impact, at 60 FPS (dt about 0.0167):
// frame 0: shakeDuration = 0.300 -> still shaking, offset randomly within 0.5 units
// frame 6: shakeDuration = 0.200 -> still shaking
// frame 12: shakeDuration = 0.100 -> still shaking
// frame 18: shakeDuration = 0.001 -> still shaking (barely)
// frame 19: shakeDuration = -0.015 -> else branch runs, position snaps back to initialPosition

เวอร์ชันนี้จะสั่นด้วย magnitude คงที่ ไปเรื่อย ๆ จนกว่าจะหยุดกะทันหัน — พอใช้ได้สำหรับเอฟเฟกต์สั้น ๆ แต่ดูดื้อ ๆ ไปหน่อย Exercise 3 ท้ายบทนี้จะให้คุณลองทำให้ magnitude ค่อย ๆ จางหายไปแบบนุ่มนวล แทนที่จะตัดจบทันที

Tip การกระโดดสุ่มแบบหนัก ๆ ทีละเฟรม (แบบด้านบน) อาจดูเหมือน "buzzy" หรือกระด้างไปหน่อย implementation ระดับมืออาชีพมักจะ sample Perlin noise (ฟังก์ชันสุ่มแบบนุ่มนวล pseudo-random ที่ต่างจาก Random ล้วน ๆ) ตามเวลาแทน ซึ่งจะได้การสั่นที่นุ่มนวลและดูเป็นธรรมชาติมากกว่า ตรรกะการ decay ยังเหมือนเดิมทุกอย่าง — เปลี่ยนแค่แหล่งที่มาของความสุ่มเท่านั้น

รวมทุกอย่างเข้าด้วยกัน: Camera Rig ที่มี Shake เป็นลูก

ถ้าคุณติดทั้งสคริปต์ follow และสคริปต์ ScreenShake ไว้บน GameObject เดียวกัน มันจะแย่งกันเอง: ทั้งคู่เขียนลง transform.position เดียวกันทุกเฟรม ใครรันทีหลังก็ชนะไป แล้วอีกฝั่งก็พังไปแบบเงียบ ๆ ทางแก้ที่สะอาดคือทำ hierarchy เล็ก ๆ ที่แยก "กล้องควรอยู่ตรงไหนตามหลักการ" ออกจาก "อาการสั่นเพิ่มเติมที่ซ้อนทับเข้าไป"

CameraRig (empty GameObject) <-- FollowCamera / OrbitCamera script moves THIS transform (world position) | +-- Camera (GameObject with the Camera component) <-- ScreenShake script moves only THIS localPosition

ScreenShake เขียนลง transform.localPosition ไม่ใช่ transform.position อยู่แล้ว — ตั้งใจให้เป็นแบบนั้น Local position คือตำแหน่งเทียบกับ parent ดังนั้นตราบใดที่สคริปต์ shake อยู่บนลูกของ rig อาการสั่นสุ่มเล็ก ๆ ของมันจะถูกบวกเพิ่มเข้าไปจากตำแหน่งปัจจุบันของ rig พ่อ แทนที่จะเขียนทับมัน rig จะขยับยังไงก็ได้ตามใจ (follow, orbit, มี bounds และ collision) ส่วนลูกก็แค่สั่นนิดหน่อยรอบ ๆ ตำแหน่งที่ rig วางไว้

การสั่ง shake จากโค้ดเกมเพลย์ก็จะหน้าตาแบบนี้:

public class Explosion : MonoBehaviour
{
    public ScreenShake cameraShake;

    void OnExplode()
    {
        cameraShake.Shake(0.3f, 0.5f);
    }
}

cameraShake ตรงนี้คือ reference ไปที่ component ScreenShake บน Camera object ลูก ซึ่งลากใส่ผ่าน Inspector (หรือหาครั้งเดียวด้วย GetComponentInChildren) ระบบเกมเพลย์ไหนก็ตาม — โดนดาเมจ ลงพื้นหนัก ยิงอาวุธใหญ่ — แค่เรียก Shake(duration, magnitude) โดยไม่ต้องรู้อะไรเลยว่ากล้องตอนนี้กำลังขยับยังไงอยู่

10. Cinemachine และการ Debug

Cinemachine: เครื่องมือกล้องที่ Unity มีให้ในตัว

ทุกอย่างในบทนี้เขียนด้วยมือทั้งหมด เพื่อให้คุณเข้าใจจริง ๆ ว่ามันเกิดอะไรขึ้นและทำไม ในงานโปรดักชันจริง ทีม Unity ส่วนใหญ่จะใช้ Cinemachine แทน ซึ่งเป็น package ฟรีอย่างเป็นทางการของ Unity ที่ทำแนวคิดเดียวกันนี้ (และมากกว่านั้นอีกเยอะ) โดยที่คุณไม่ต้องเขียนโค้ด follow/smoothing/collision เองเลย

การรู้วิธีสร้างระบบพวกนี้ด้วยมือยังสำคัญอยู่ — Cinemachine ไม่สามารถครอบคลุมไอเดียกล้องแบบ custom ได้ทุกอย่าง และการเข้าใจว่าข้างในมันทำงานยังไงจะทำให้คุณปรับจูนมันได้มีประสิทธิภาพมากขึ้นเยอะ (หรือ debug มันได้ตอนมันทำอะไรที่คุณไม่ได้คาดไว้) แต่สำหรับงานโปรดักชันประจำวันส่วนใหญ่ ให้ลอง Cinemachine ก่อน แล้วค่อยลงไปเขียนโค้ดกล้องเองสำหรับพฤติกรรมเฉพาะที่มันไม่ครอบคลุม

การ Debug และข้อผิดพลาดที่พบบ่อย

ไม่ว่าคุณจะสร้างกล้องเองด้วยมือหรือพึ่ง Cinemachine สุดท้ายคุณก็ต้อง debug กล้องที่ทำงานแปลก ๆ อยู่ดี วิธีง่าย ๆ ที่จะเห็นว่ามันกำลังทำอะไรอยู่จริง ๆ:

void OnDrawGizmos()
{
    if (target == null) return;
    Gizmos.color = Color.yellow;
    Gizmos.DrawLine(target.position, transform.position);
    Gizmos.DrawWireSphere(transform.position, 0.3f);
}

OnDrawGizmos รันเฉพาะใน Scene view ของ editor เท่านั้น ไม่รันใน build เลย และไม่พิมพ์อะไรออก console เลยด้วย — แต่พอติดสคริปต์นี้ Scene view จะโชว์เส้นสีเหลืองจาก target ไปกล้องตรง ๆ ทุกเฟรม บวกกับทรงกลมโครงลวด (wireframe sphere) เล็ก ๆ ที่ตำแหน่งกล้องพอดี พอดูเส้นกับทรงกลมนี้ระหว่างเล่น จะเห็นชัดทันทีว่ากล้องกำลังตามช้าอยู่ ถูก clamp ไว้ที่ไหนแบบไม่คาดคิด หรือหลุดออกจาก rig ไปแล้ว

ครบแล้วสำหรับ pipeline ทั้งหมด: อ่านตำแหน่ง target, smooth เข้าไปหามัน, นำหน้ามันนิดหน่อย, จำกัดให้อยู่ในขอบเขต, ให้ผู้เล่น orbit รอบมันได้, กันไม่ให้ทะลุกำแพง, และสั่นตอนโดนกระแทก ทุกอย่างที่ว่ามาคือแค่คณิตศาสตร์ไม่กี่บรรทัดที่รันครั้งเดียวต่อเฟรมใน LateUpdate() — "ความรู้สึก" ของกล้องมาจากการปรับจูนตัวเลข ไม่ใช่จากโค้ดที่ซับซ้อน

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

12. แบบฝึกหัด

Exercise 1 สคริปต์ด้านล่างตั้งใจให้เป็น follow camera แบบนุ่มนวล แต่ผู้เล่นบอกว่ามันกระตุกหนักมาก และให้ความรู้สึกต่างกันในคอมพิวเตอร์แต่ละเครื่อง หาบั๊กให้เจอ (มีสองจุด) แล้วเขียนใหม่ให้ถูกต้อง
using UnityEngine;

public class BuggyFollowCamera : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothFactor = 0.1f;

    void Update()
    {
        Vector3 desiredPosition = target.position + offset;
        transform.position = Vector3.Lerp(transform.position, desiredPosition, smoothFactor);
    }
}
Show answer

บั๊กที่ 1: โค้ดนี้รันอยู่ใน Update() ดังนั้นมันอาจอ่านตำแหน่งของ target ก่อนที่ Update() ของ target เองจะขยับมันในเฟรมนี้ ทำให้เกิดอาการกระตุก ทางแก้: ย้าย logic ไปที่ LateUpdate()

บั๊กที่ 2: smoothFactor ถูกใช้เป็นสัดส่วนของ Lerp ตรง ๆ ทุกครั้งที่เรียก โดยไม่เกี่ยวข้องกับเวลาที่ผ่านไปเลย — กล้องเลยปิดระยะห่างด้วยอัตราที่ขึ้นอยู่กับ frame rate (เร็วบนเครื่องที่ FPS สูง ช้าบนเครื่องที่ FPS ต่ำ) ทางแก้: แปลงมันให้เป็นค่า factor แบบ framerate-independent โดยใช้ Time.deltaTime กับ Mathf.Exp

using UnityEngine;

public class FixedFollowCamera : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothRate = 8f;

    void LateUpdate()
    {
        Vector3 desiredPosition = target.position + offset;
        float t = 1f - Mathf.Exp(-smoothRate * Time.deltaTime);
        transform.position = Vector3.Lerp(transform.position, desiredPosition, t);
    }
}

ตอนนี้กล้องจะอ่านตำแหน่งสุดท้ายของ target ในเฟรมนั้นเสมอ และปิดระยะห่างเป็นสัดส่วนเท่ากันต่อวินาทีจริง ไม่ว่า frame rate จะเท่าไหร่

Exercise 2 เริ่มจาก follow camera แบบ framerate-independent จากหัวข้อ 4 (FollowCameraSmoothed) เพิ่มสองฟีเจอร์เข้าไป: clamp ตำแหน่งสุดท้ายให้อยู่ใน minBounds/maxBounds (เหมือนหัวข้อ 6) และเพิ่ม look-ahead ง่าย ๆ ที่อิงจาก input คีย์บอร์ดแนวนอน — ตอนผู้เล่นกดขวาค้าง กล้องควรเอียงไปทางขวา ตอนกดซ้ายค้าง กล้องควรเอียงไปทางซ้าย รอบนี้คุณไม่มี Rigidbody ให้อ่านความเร็ว เลยต้องสร้าง look-ahead จาก Input.GetAxis("Horizontal") ตรง ๆ แทน เขียนสคริปต์เต็ม ๆ ออกมา
Show answer
using UnityEngine;

public class FollowCameraClampedLookAhead : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothRate = 8f;
    public Vector2 minBounds = new Vector2(-20f, -5f);
    public Vector2 maxBounds = new Vector2(20f, 15f);
    public float lookAheadDistance = 3f;
    public float lookAheadSmoothRate = 4f;

    Vector3 currentLookAhead;

    void LateUpdate()
    {
        float horizontal = Input.GetAxis("Horizontal");
        Vector3 desiredLookAhead = new Vector3(horizontal, 0f, 0f) * lookAheadDistance;

        float lookT = 1f - Mathf.Exp(-lookAheadSmoothRate * Time.deltaTime);
        currentLookAhead = Vector3.Lerp(currentLookAhead, desiredLookAhead, lookT);

        Vector3 desiredPosition = target.position + offset + currentLookAhead;
        desiredPosition.x = Mathf.Clamp(desiredPosition.x, minBounds.x, maxBounds.x);
        desiredPosition.y = Mathf.Clamp(desiredPosition.y, minBounds.y, maxBounds.y);

        float t = 1f - Mathf.Exp(-smoothRate * Time.deltaTime);
        transform.position = Vector3.Lerp(transform.position, desiredPosition, t);
    }
}

สังเกตลำดับ: look-ahead ถูกบวกเข้ากับตำแหน่งที่ต้องการก่อนที่จะ clamp เพื่อให้ clamp เป็นตัวตัดสินสุดท้าย — กล้องจะไม่มีวันโชว์เลยขอบด่านได้เลย แม้ตอนกำลังเอียงไปข้างหน้าใกล้กำแพง การ clamp เกิดขึ้นก่อนขั้นตอน smoothing สุดท้าย เหมือนกับหัวข้อ 6

Exercise 3 สคริปต์ ScreenShake จากหัวข้อ 9 สั่นด้วย magnitude คงที่ แล้วหยุดกะทันหันทันทีที่ shakeDuration ถึงศูนย์ ซึ่งดูดื้อ ๆ ไปหน่อย ให้แก้ไขมันเพื่อให้ magnitude ค่อย ๆ จางหายไปตลอดช่วงเวลา shake — แรงตอนเริ่ม แล้วค่อย ๆ จางจนหมดตอนจบ แทนที่จะตัดจบทันที ใช้ signature ของเมธอด public Shake(duration, magnitude) แบบเดิม
Show answer
using UnityEngine;

public class ScreenShakeFading : MonoBehaviour
{
    float shakeTimer = 0f;
    float shakeDuration = 0f;
    float startMagnitude = 0f;
    Vector3 initialPosition;

    void OnEnable()
    {
        initialPosition = transform.localPosition;
    }

    public void Shake(float duration, float magnitude)
    {
        shakeDuration = duration;
        shakeTimer = duration;
        startMagnitude = magnitude;
    }

    void Update()
    {
        if (shakeTimer > 0f)
        {
            float t = shakeTimer / shakeDuration;          // 1 at the start, 0 at the end
            float currentMagnitude = startMagnitude * t;    // fades out linearly

            transform.localPosition = initialPosition + Random.insideUnitSphere * currentMagnitude;
            shakeTimer -= Time.deltaTime;
        }
        else
        {
            transform.localPosition = initialPosition;
        }
    }
}

จุดที่เปลี่ยนสำคัญ: shakeTimer แยกต่างหากจะนับถอยหลังจาก shakeDuration เดิม และอัตราส่วน t ของมันจะไล่จาก 1.0 ลงมาถึง 0.0 ตลอดช่วง shake การเอา startMagnitude ไปคูณกับอัตราส่วนนั้นทุกเฟรม ทำให้ offset สุ่มค่อย ๆ เล็กลงแบบนุ่มนวล แทนที่จะคงที่แล้วตัดจบทันที ถ้าเปลี่ยนจาก t เชิงเส้นเป็น AnimationCurve ที่ประเมินค่าที่ t แทน ก็จะให้ดีไซเนอร์ปรับรูปทรงของการจางหายได้โดยไม่ต้องแตะโค้ดเลย

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