กล้อง (camera) ไม่ได้ขยับตัวละครของคุณ ไม่ได้สร้างความเสียหาย และไม่ได้รันตรรกะเกมเพลย์อะไรเลย มันแค่ตัดสินใจว่าผู้เล่นจะเห็นโลกเกมส่วนไหน และมุมมองนั้นเคลื่อนไหวยังไง ฟังดูเหมือนเรื่องเล็ก แต่จริง ๆ แล้วมันเป็นตัวแปรที่ทรงพลังที่สุดตัวหนึ่งที่กำหนดว่าเกม "รู้สึก" ยังไงตอนเล่นจริง บทนี้จะสร้างระบบกล้องทีละชิ้น: follow camera, การทำให้เคลื่อนไหวนุ่มนวล (smoothing), look-ahead, การจำกัดขอบเขต (bounds), กล้อง third-person orbit, การชนกำแพง (wall collision) และ screen shake ระหว่างทางเราจะเอาแนวคิดจากบทก่อน ๆ กลับมาใช้ — ลำดับเฟรม, linear interpolation (Lerp), ตรีโกณมิติ, และ quaternion
ลองนึกภาพเกม platformer สองเกมที่มีด่านเหมือนกันเป๊ะ ฟิสิกส์เหมือนกันเป๊ะ และตัวควบคุมผู้เล่น (player controller) เหมือนกันเป๊ะ เกมแรกกล้องจะสแนป (snap) ไปตำแหน่งผู้เล่นทันทีทุกเฟรม ส่วนเกมที่สองกล้องจะตามหลังอยู่นิดหน่อยแล้วค่อย ๆ เข้าที่ เกมที่สองแทบทุกครั้งจะให้ความรู้สึก "มีชีวิต" มากกว่าและดูไม่แข็งทื่อ ทั้งที่ตัวเกมเพลย์จริง ๆ ไม่ได้เปลี่ยนอะไรเลย
เรื่องพวกนี้ไม่ได้เกี่ยวกับการทำกล้องให้ "ถูกต้อง" — ปกติแล้วไม่มีกล้องแบบเดียวที่ถูกต้องอยู่แล้ว มันคือการปรับตัวเลขไม่กี่ตัว (ตามเร็วแค่ไหน มองล่วงหน้าไกลแค่ไหน สั่นแรงแค่ไหน) จนกว่าเกมจะรู้สึกใช่ บทนี้จะให้ building block ที่คุณเอาไปปรับจูนเองได้
กล้องทุกตัวใน 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 เท่าไหร่ ในทุก ๆ เฟรม?
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" หนึ่งเฟรมทุกครั้งที่จังหวะเวลาเปลี่ยน
Update() เพราะคิดว่า "โค้ดการเคลื่อนที่ต้องอยู่ตรงนี้" โค้ดกล้องเป็นกรณีพิเศษ — มันควรอ่านตำแหน่งหลังจากทุกอย่างขยับเสร็จแล้วในเฟรมนั้นแทบทุกครั้งจริง ๆ แล้ว Unity รัน pipeline ที่ตายตัวทุกเฟรม: ฟิสิกส์อัปเดตก่อน (FixedUpdate รันศูนย์ครั้งหรือมากกว่านั้น) จากนั้น Update() ของทุกสคริปต์จะรัน และหลังจากทั้งหมดนั้นรันเสร็จแล้วเท่านั้น LateUpdate() ของทุกสคริปต์ถึงจะรัน แล้วเฟรมนั้นก็จะถูก render
เพราะ 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()
Rigidbody ใน FixedUpdate อย่าลืมเช็กค่า Interpolate ใน Rigidbody component ด้วย ค่านั้นจะทำให้ตำแหน่งที่มองเห็นนุ่มนวลขึ้นระหว่าง physics step แต่ละครั้ง — มันทำงานร่วมกับ camera smoothing ไม่ใช่แทนที่กัน และเชื่อมโยงกับแนวคิดเรื่อง interpolation จากบท interpolation ก่อนหน้านี้กล้องที่สแนปไปตำแหน่ง target เป๊ะ ๆ ทุกเฟรมจะให้ความรู้สึกแข็งทื่อ เกมส่วนใหญ่มักปล่อยให้กล้องตามหลังนิดหน่อยแล้วค่อย ๆ เข้าที่ — เป็นแนวคิด linear interpolation (Lerp) ตัวเดียวกับที่เจอในบท interpolation แค่เอามาใช้กับกล้องทุก ๆ เฟรม
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 ล้วน ๆ
Vector3.Lerp(transform.position, desiredPosition, speed * Time.deltaTime) แล้วคิดว่ามันแก้ปัญหาเรื่อง framerate independence ได้แล้ว มันดีขึ้นจริง แต่เป็นแค่การประมาณเท่านั้น — ตอน frame rate ต่ำ ๆ speed * Time.deltaTime อาจเกิน 1 ได้ ซึ่งจะทำให้ overshoot เป้าหมาย หรือถึงขั้น oscillate เลยก็ได้ ให้ใช้สูตรที่แม่นยำด้านล่าง หรือ SmoothDamp แทน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 มีฟังก์ชันที่ทำแบบนี้ให้คุณอยู่แล้ว และมันทำงานเหมือนสปริงแบบ 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 ใช้กันจริง ๆ
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 เพิ่มนี้ ทุกครั้งที่เปลี่ยนทิศทางกล้องจะสะบัดทันที — ซึ่งเป็นความรู้สึกกระตุกแบบที่บททั้งบทนี้พยายามหลีกเลี่ยงเป๊ะ ๆ
ในด่านที่มีขอบชัดเจน (จุดเริ่ม/จบของเกม 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 ไปได้ — ผู้เล่นแค่เดินเข้าใกล้ขอบจอแทน แทนที่กล้องจะตามไปโชว์พื้นที่ว่างเปล่าต่อ
orthographicSize คูณ aspect ratio) เพื่อซ่อนขอบให้มิดจริง ๆ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
pitch ให้อยู่ในช่วงที่สมเหตุสมผล (ในที่นี้คือ -20 ถึง 60 องศา) ช่วยไม่ให้กล้องหมุนพลิกข้าม target จนกลับหัวได้ ซึ่งจะทำให้ผู้เล่นงงมาก นี่คือเหตุผลที่โค้ดเก็บ yaw กับ pitch เป็น float แยกกันสองตัว แทนที่จะสะสม rotation ตรง ๆ บน Transform — float แยกกัน clamp ง่าย แต่ quaternion ที่สะสมไปเรื่อย ๆ clamp ไม่ได้กล้อง 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 เล็กน้อย เพื่อให้กล้องอยู่ข้างหน้ากำแพงนิดหน่อยแทนที่จะไปแตะมันเลย
ลอง 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
Physics.Raycast (เส้นบางไม่มีความหนา) กับการชนของกล้อง ray บาง ๆ อาจลอดผ่านช่องว่างข้างมุมกำแพงบาง ๆ ไปได้ ทั้งที่เลนส์กล้องจริง ๆ (ซึ่งมีความกว้างอยู่) จะยังชนอยู่ Physics.SphereCast (ray ที่มีความหนา) มักจะเหมาะกับกล้องมากกว่า — แนวคิดเดียวกันเลย แค่เปลี่ยน Raycast เป็น SphereCast แล้วใส่ radius เล็ก ๆ เข้าไปscreen shake ช่วยสื่อถึงแรงกระแทก (impact) — ระเบิด การลงพื้นหนัก ๆ หรือโดนดาเมจ แนวคิดหลักง่ายมาก: ในช่วงเวลาสั้น ๆ บวก offset สุ่มเล็ก ๆ เข้าไปในตำแหน่งกล้องทุกเฟรม แล้วค่อย ๆ ลด offset นั้นลงตามเวลาจนเหลือศูนย์
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 ค่อย ๆ จางหายไปแบบนุ่มนวล แทนที่จะตัดจบทันที
Random ล้วน ๆ) ตามเวลาแทน ซึ่งจะได้การสั่นที่นุ่มนวลและดูเป็นธรรมชาติมากกว่า ตรรกะการ decay ยังเหมือนเดิมทุกอย่าง — เปลี่ยนแค่แหล่งที่มาของความสุ่มเท่านั้นถ้าคุณติดทั้งสคริปต์ follow และสคริปต์ ScreenShake ไว้บน GameObject เดียวกัน มันจะแย่งกันเอง: ทั้งคู่เขียนลง transform.position เดียวกันทุกเฟรม ใครรันทีหลังก็ชนะไป แล้วอีกฝั่งก็พังไปแบบเงียบ ๆ ทางแก้ที่สะอาดคือทำ hierarchy เล็ก ๆ ที่แยก "กล้องควรอยู่ตรงไหนตามหลักการ" ออกจาก "อาการสั่นเพิ่มเติมที่ซ้อนทับเข้าไป"
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) โดยไม่ต้องรู้อะไรเลยว่ากล้องตอนนี้กำลังขยับยังไงอยู่
ทุกอย่างในบทนี้เขียนด้วยมือทั้งหมด เพื่อให้คุณเข้าใจจริง ๆ ว่ามันเกิดอะไรขึ้นและทำไม ในงานโปรดักชันจริง ทีม Unity ส่วนใหญ่จะใช้ Cinemachine แทน ซึ่งเป็น package ฟรีอย่างเป็นทางการของ Unity ที่ทำแนวคิดเดียวกันนี้ (และมากกว่านั้นอีกเยอะ) โดยที่คุณไม่ต้องเขียนโค้ด follow/smoothing/collision เองเลย
Camera จริง ๆการรู้วิธีสร้างระบบพวกนี้ด้วยมือยังสำคัญอยู่ — Cinemachine ไม่สามารถครอบคลุมไอเดียกล้องแบบ custom ได้ทุกอย่าง และการเข้าใจว่าข้างในมันทำงานยังไงจะทำให้คุณปรับจูนมันได้มีประสิทธิภาพมากขึ้นเยอะ (หรือ debug มันได้ตอนมันทำอะไรที่คุณไม่ได้คาดไว้) แต่สำหรับงานโปรดักชันประจำวันส่วนใหญ่ ให้ลอง Cinemachine ก่อน แล้วค่อยลงไปเขียนโค้ดกล้องเองสำหรับพฤติกรรมเฉพาะที่มันไม่ครอบคลุม
ไม่ว่าคุณจะสร้างกล้องเองด้วยมือหรือพึ่ง 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 ไปแล้ว
Update() แทนที่จะเป็น LateUpdate() — ทำให้เกิดอาการกระตุกหนึ่งเฟรม (หัวข้อ 2-3)Lerp เป็นค่าคงที่ดิบ ๆ (หรือ speed * Time.deltaTime ดิบ ๆ) แทนที่จะเป็นสูตร exponential หรือ SmoothDamp — กล้องให้ความรู้สึกต่างกันในแต่ละ frame rate (หัวข้อ 4)LayerMask ผิด — อาจไปชน collider ของผู้เล่นเอง (ดึงกล้องเข้ามาโดยไม่มีเหตุผล) หรือไม่ก็มองข้ามกำแพงไปเลย (ลืมใส่ mask bit ของ geometry ในด่าน)transform.position เดียวกัน — ให้เอา shake ไปไว้บน child object แล้วเขียนลง localPosition แทน (หัวข้อ 9)OnEnable — พอเอา shake component กลับมาใช้ใหม่หลัง disable แล้ว re-enable มันอาจค้างอยู่ที่ offset เก่าได้ครบแล้วสำหรับ pipeline ทั้งหมด: อ่านตำแหน่ง target, smooth เข้าไปหามัน, นำหน้ามันนิดหน่อย, จำกัดให้อยู่ในขอบเขต, ให้ผู้เล่น orbit รอบมันได้, กันไม่ให้ทะลุกำแพง, และสั่นตอนโดนกระแทก ทุกอย่างที่ว่ามาคือแค่คณิตศาสตร์ไม่กี่บรรทัดที่รันครั้งเดียวต่อเฟรมใน LateUpdate() — "ความรู้สึก" ของกล้องมาจากการปรับจูนตัวเลข ไม่ใช่จากโค้ดที่ซับซ้อน
Update() ของทุกสคริปต์รันเสร็จแล้ว ใช้กับอะไรก็ตามที่ควรตอบสนองต่อตำแหน่งสุดท้ายของ object ในเฟรมนั้น เช่นกล้องUpdate() ใน pipeline ของแต่ละเฟรมt ระหว่าง 0 ถึง 1; Vector3.Lerp(a, b, t) คืนจุดที่อยู่ตามสัดส่วนนั้นจาก a ไป bsmoothTime วินาที ทำงานเหมือนสปริงแบบ critically damped และเป็น framerate-independent อยู่แล้วMathf.Clamp)Quaternion.Eulerusing 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);
}
}
บั๊กที่ 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 จะเท่าไหร่
FollowCameraSmoothed) เพิ่มสองฟีเจอร์เข้าไป: clamp ตำแหน่งสุดท้ายให้อยู่ใน minBounds/maxBounds (เหมือนหัวข้อ 6) และเพิ่ม look-ahead ง่าย ๆ ที่อิงจาก input คีย์บอร์ดแนวนอน — ตอนผู้เล่นกดขวาค้าง กล้องควรเอียงไปทางขวา ตอนกดซ้ายค้าง กล้องควรเอียงไปทางซ้าย รอบนี้คุณไม่มี Rigidbody ให้อ่านความเร็ว เลยต้องสร้าง look-ahead จาก Input.GetAxis("Horizontal") ตรง ๆ แทน เขียนสคริปต์เต็ม ๆ ออกมา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
ScreenShake จากหัวข้อ 9 สั่นด้วย magnitude คงที่ แล้วหยุดกะทันหันทันทีที่ shakeDuration ถึงศูนย์ ซึ่งดูดื้อ ๆ ไปหน่อย ให้แก้ไขมันเพื่อให้ magnitude ค่อย ๆ จางหายไปตลอดช่วงเวลา shake — แรงตอนเริ่ม แล้วค่อย ๆ จางจนหมดตอนจบ แทนที่จะตัดจบทันที ใช้ signature ของเมธอด public Shake(duration, magnitude) แบบเดิม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 แทน ก็จะให้ดีไซเนอร์ปรับรูปทรงของการจางหายได้โดยไม่ต้องแตะโค้ดเลย