บทที่แล้วเราสร้างสคริปต์เคลื่อนที่ผู้เล่น: อ่าน 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
บทที่ 6.2 พูดถึงไปแล้วว่าตัวละครที่ขับเคลื่อนด้วย Rigidbody จะให้ความรู้สึก "physics-y" — มีโมเมนตัม ลื่นไถล เด้ง สำหรับลัง ragdoll และยานพาหนะ นั่นคือสิ่งที่คุณต้องการพอดี แต่สำหรับตัวละครที่ผู้เล่นควบคุมโดยตรง มันมักจะเป็นปัญหา และคุ้มค่าที่จะเข้าใจว่าทำไมถึงเป็นแบบนั้นจริง ๆ ไม่ใช่แค่จำไว้เฉย ๆ ว่าเป็นกฎทั่วไป
physics engine อย่าง PhysX (ตัวที่ฝังอยู่ใน Unity) กำลังแก้ปัญหาที่ยากกว่า "ขยับแคปซูลตัวเดียวนี้" มาก ทุก physics step มันจะมองไปที่ rigid body ทุกตัว ในซีน หาทุกคู่ที่ซ้อนทับกัน แล้วคำนวณชุดของ impulse (การเปลี่ยน velocity แบบทันทีทันใด) ที่ดันให้พวกมันแยกออกจากกันพอดี ๆ ที่จะไม่ซ้อนทับกันอีก พร้อมกับพยายามรักษาความเสถียรทางตัวเลขของทั้งซีนไปด้วย นี่คือ solver (ตัวแก้ปัญหา) — อัลกอริทึมที่ค่อย ๆ วนเข้าใกล้คำตอบโดยประมาณ แทนที่จะคำนวณคำตอบที่แม่นยำในครั้งเดียว มันถูกสร้างมาเพื่อความสมจริงและความเสถียรของทั้งซีน ไม่ใช่เพื่อทำให้วัตถุตัวใดตัวหนึ่งรู้สึกคมชัดและคาดเดาได้ภายใต้การควบคุมโดยตรงของผู้เล่น
อาการที่เห็นได้ชัดสามอย่างเกิดขึ้นจากดีไซน์นี้โดยตรง:
rb.freezeRotation = true เพื่อไม่ให้แคปซูลเอียงล้ม การสัมผัสขอบในมุมตื้น ๆ ก็ยังทำให้เกิดการเด้งเล็ก ๆ ได้ เพราะ solver กำลังแก้ collision แบบทั่วไป ไม่ได้รู้ว่า "นี่คือผู้เล่น ห้ามเด้งเด็ดขาด"ทางแก้ที่ character controller ที่ปล่อยจริงเกือบทุกตัวใช้ รวมถึงตัวในตัวของ Unity เอง คือหยุดขอให้ physics engine จำลองตัวละครทั้งหมด แล้วเข้าควบคุมตำแหน่งของตัวละครเองทุกเฟรมแบบ manual ใช้รูปทรงพิเศษ (แคปซูล) และอัลกอริทึมพิเศษ (collide-and-slide) ที่เขียนขึ้นมาเฉพาะสำหรับ "ขยับวัตถุตัวนี้ไปได้ไกลที่สุดเท่าที่มันจะไปได้ ไม่มากไม่น้อยกว่านั้น" — ไม่มี solver ไม่มี impulse ไม่มีการเด้ง เว้นแต่คุณจะเขียนโค้ดให้มันเด้งเอง นี่คือสิ่งที่ส่วนที่เหลือของบทนี้จะสร้างขึ้นมา
แคปซูล (capsule) คือทรงกระบอกที่มี hemisphere (ครึ่งทรงกลม) ปิดหัวท้ายทั้งสองด้าน คุณอธิบายมันได้ครบด้วยตัวเลขแค่สามตัว: จุดสองจุดที่มาร์คปลายของเส้นตรงที่ลากผ่านกลางลำตัว (เรียกว่า point0 กับ point1 รวมกันเรียกว่า axis ของแคปซูล) และ radius — รัศมีเดียวกันทั้งลำตัวทรงกระบอกและฝาโค้งทั้งสองด้าน
ทำไมต้องรูปทรงนี้โดยเฉพาะ แทนที่จะเป็นกล่อง (ซึ่งใกล้เคียงกับ silhouette ของคนตอนมองจากด้านบนมากกว่า) หรือทรงกระบอกธรรมดา?
Physics.CapsuleCast, Physics.CheckCapsule) เร็วพอที่จะรันได้ทุกเฟรมการเช็ค "หาจุดที่ใกล้ที่สุดบน 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 อื่น ๆ
t ให้อยู่ในช่วง [0, 1] ถ้าไม่ clamp สูตรจะปฏิบัติกับ axis เหมือนเป็นเส้นตรงยาวไม่จำกัดแทนที่จะเป็น segment ที่มีความยาวจำกัด ทำให้จุดที่อยู่ต่ำกว่าเท้าของแคปซูลมาก ๆ หรือสูงกว่าหัวมาก ๆ อาจถูกคำนวณผิดว่า "ใกล้" กับ axis และถูกนับว่าเกิด collisionสมมติตัวละครอยากเคลื่อนที่ไปข้างหน้า 1 หน่วยในเฟรมนี้ และบังเอิญมีกำแพงอยู่ห่างออกไป 0.6 หน่วย วางเฉียงขวางเส้นทางของตัวละคร วิธีคิดง่าย ๆ สองแบบล้วนล้มเหลว:
Collide-and-slide คืออัลกอริทึมที่ทำสิ่งที่ถูกต้องในทั้งสองกรณี พูดง่าย ๆ คือ: พยายามเคลื่อนที่เต็มระยะก่อน ถ้ามีอะไรมาบังส่วนหนึ่งของมัน ก็เคลื่อนที่เท่าที่ว่างจริง ๆ แล้วเอาระยะที่เหลือมา redirect ให้วิ่งไปตามพื้นผิวที่ชน แทนที่จะวิ่งเข้าไปในมัน แล้วทำซ้ำด้วยระยะที่ redirect แล้วนั้น เผื่อมันจะไปชนอะไรอีกทันที
ขั้นตอน 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)
มีรายละเอียดสองอย่างที่สำคัญพอ ๆ กับแนวคิดหลัก:
CharacterController.Move() ในตัวของ Unity — แค่คุณมองไม่เห็นลูปเพราะมันเกิดขึ้นในโค้ด native ของ engine การเข้าใจมันคือสิ่งที่ทำให้คุณคาดเดาได้ว่าทำไม character controller ถึงมีพฤติกรรมแบบนั้นตรงมุมหรือตอนชนกำแพงแบบเฉียด ๆ แทนที่จะมองมันเป็นกล่องดำนี่คืออัลกอริทึมจากหัวข้อ 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 ครั้งต่อเฟรม
บทที่ 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) ถ้าขั้นบันไดหล่นลงเร็วกว่าที่แรงโน้มถ่วงจะดึงตัวละครลงไปแตะได้ทันภายในเฟรมเดียว ตัวละครจะร่อนเลยขอบบันไดแต่ละขั้นไปสองสามเซนติเมตรก่อนที่แรงโน้มถ่วงจะตามทัน ตลอดทั้งบันได อาการนี้จะอ่านออกมาเป็นตัวละครกระเด้งลงบันไดทีละขั้นให้เห็นชัด ๆ แทนที่จะเดินลงอย่างลื่นไหล
ทางแก้: หลัง 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;
}
}
ทุกพื้นผิวมี normal: เวกเตอร์ที่ชี้ออกมาตรง ๆ จากมัน ทำมุม 90 องศากับพื้นผิว พื้นราบมี normal เท่ากับ Vector3.up พอดี normal ของทางลาดจะเอียงออกจากแนวตั้งตามมุมของทางลาด และมุมนั้นแหละที่ค่า slopeLimit เอาไปเทียบ ด้วย Vector3.Angle(normal, Vector3.up)
ทางลาดที่อยู่ในขีดจำกัดคือทางที่เดินได้: ตัวละครควรเคลื่อนที่ไปตามพื้นผิวของมันอย่างลื่นไหล ไม่ใช่ยื่นออกมาในมุมแนวราบแล้วเสียบเข้าไปในมันหรือลอยเหนือมัน ตรงนี้ใช้ Vector3.ProjectOnPlane อีกครั้ง — แบนการเคลื่อนที่แนวราบที่ตั้งใจไว้ลงบนระนาบของทางลาดก่อนจะใช้งานจริง เหมือนกับ AdjustMoveForSlope ในบทที่ 6.2 เป๊ะ ๆ
ทางลาดที่เกินขีดจำกัดคือทางที่เดินไม่ได้ และต้องจัดการต่างออกไป: แทนที่จะให้ตัวละครปีนขึ้นไป ให้ปฏิบัติกับมันเหมือนที่ collide-and-slide ปฏิบัติกับกำแพง — เพียงแต่ "ทิศทางไถล" ตรงนี้ไม่ใช่ทิศทาง input ของผู้เล่น แต่เป็นตรงลงล่าง ที่ถูก redirect ไปตามผิวของทางลาด สิ่งนี้จะสร้างพฤติกรรม "ไถลตกจากพื้นผิวชัน" ที่ผู้เล่นคาดหวังพอดี
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 ตัวเดียวกับที่จัดการทุกอย่าง
step — ขอบถนน บันได รากไม้ — สั้นพอที่ไม่ควรทำให้ตัวละครช้าลงเลย แต่สูงพอที่ทางเรขาคณิตแล้ว collide-and-slide จะมองว่ามันเป็นกำแพง: hemisphere ด้านล่างของแคปซูลจะชนหน้าตั้งของ step แล้วไถลจนหยุดอยู่ตรงหน้ามัน เหมือนชนกำแพงทั่วไป
stepOffset คือความสูง (หน่วย) ที่ mover ควรปีนข้ามส่วนนูนโดยอัตโนมัติแทนที่จะมองมันเป็นกำแพง เคล็ดลับเบื้องหลังไม่ต้องใช้ฟิสิกส์ใหม่เลย มันใช้ Move() ตัวเดียวกันจากหัวข้อ 4 ซ้ำสามครั้งตามลำดับ:
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 คือสิ่งที่แยกทั้งสองแบบออกจากกัน
CharacterController.stepOffset ในตัวของ Unity ทำให้คุณข้างในเป๊ะ ๆ แค่เป็นโค้ด native ของ engine แทนที่จะเป็นสคริปต์ การตั้งค่ามันคือ Inspector field สองตัว ส่วนการเขียนเองแบบข้างบนนี้คือประมาณ 15 บรรทัด — เป็นเหตุผลหนึ่งที่หลายทีมเลือกใช้ component ในตัวสำหรับจัดการ step แม้ว่าจะเขียน mover แบบ custom เองสำหรับส่วนอื่นทั้งหมดก็ตามบทที่ 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
เฟรมที่ 20 ลงเอยที่ -24.0 พอดี เพราะ 24 / 1.2 = 20 ลงตัวพอดี — เป็นความบังเอิญของตัวเลขชุดนี้ ที่เลือกมาให้ trace ลงเอยด้วยค่าที่ลงตัว แต่รูปร่างของกราฟ (ทางลาดตรงลง แล้วเป็นเส้นราบ) คือสิ่งที่เกิดขึ้นเสมอไม่ว่าจะใช้คู่ของ gravity กับ terminal velocity คู่ไหนก็ตาม
terminalVelocity ให้สุดโต่งเกินไป (หรือลืมตั้งไปเลย) จนมันไม่เคยถูกใช้จริงในการตกปกติของเกม การ clamp จะช่วยได้ก็ต่อเมื่อการตกยาวพอที่จะไปถึงมันเท่านั้น — ถ้าการตกที่สูงที่สุดในเลเวลของคุณไปถึงแค่ -18 หน่วย/วินาที แต่ terminalVelocity ตั้งไว้ที่ -200 คุณก็ยังมีความเสี่ยง tunneling แบบเดิมเป๊ะที่พยายามจะแก้อยู่ดี ปรับมันให้เข้ากับ level geometry จริงของคุณ: ความเร็วตกสูงสุดที่คุณอยากอนุญาตก่อนที่ระยะทางการเคลื่อนที่ต่อเฟรมของตัวละครจะเสี่ยงข้ามพ้น collider พื้นที่บางที่สุดของคุณไปได้ทุกอย่างที่ผ่านมาสมมติว่าพื้นไม่เคลื่อนที่ แพลตฟอร์มลิฟต์ทำลายสมมติฐานนั้น และทำลายมันในแบบเฉพาะเจาะจง: Move() ของตัวละครเองจะคำนึงถึงแค่การเคลื่อนที่ที่ตัวละครเองตั้งใจ (input บวกแรงโน้มถ่วง) เท่านั้น ถ้าแพลตฟอร์มข้างใต้ไถลออกจากใต้ตัวละครระหว่างเฟรมหนึ่งไปอีกเฟรม ไม่มีอะไรในหัวข้อ 1–8 รู้เรื่องนี้เลย — จากมุมมองของ mover พื้นแค่เทเลพอร์ตไปนิดหน่อย และตัวละครถูกทิ้งไว้ข้างหลัง ยืนอยู่กลางอากาศตรงที่แพลตฟอร์มเคยอยู่ หรือไถลถอยหลังเมื่อเทียบกับมัน
ทางแก้: ติดตามว่าแพลตฟอร์มเองเคลื่อนที่ไปเท่าไหร่ในเฟรมนี้ (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 ตำแหน่งต่อไปทุกเฟรม
CharacterController ในตัวของ Unity ไม่ได้ทำสิ่งเหล่านี้ให้อัตโนมัติเลย — มันรู้แค่เรื่อง static กับ kinematic collider ที่มันแตะโดยตรงระหว่างการเรียก Move() ครั้งล่าสุด การรองรับแพลตฟอร์มเคลื่อนที่ ทั้งใน component ในตัวและ mover แบบ custom เต็มรูปแบบ เป็นสิ่งที่คุณต้องเพิ่มเองเสมอ ด้วยแพทเทิร์นการติดตาม delta แบบข้างบนนี้เป๊ะ ๆหัวข้อ 1 อธิบายไปแล้วว่าทำไม Rigidbody ธรรมดาถึงมักรู้สึกผิดที่ผิดทางสำหรับตัวละครผู้เล่น ตอนนี้คุณเห็นแล้วว่าทางเลือกแบบ kinematic ต้องแลกด้วยโค้ดเท่าไหร่ ก็คุ้มค่าที่จะวางข้อดีข้อเสียทั้งหมดให้ชัดเจน เพื่อให้การเลือกเป็นการตัดสินใจทางวิศวกรรมจริง ๆ ไม่ใช่แค่ "หลีกเลี่ยง Rigidbody ตลอดเวลา"
ตัวละครที่ผู้เล่นควบคุมในเกมแอ็กชัน 3 มิติ เกมแพลตฟอร์ม และเกมยิงที่ปล่อยจริงส่วนใหญ่ใช้แนวทาง kinematic เพราะเหตุผลตรง ๆ ว่าสิ่งที่ทำให้ controller รู้สึกถูกปรับแต่งมาอย่างตั้งใจ — jump arc ที่สม่ำเสมอ ไม่มีการเด้งที่ไม่คาดคิด ground snapping ที่ไม่เคยพลาด — ทั้งหมดนี้ขึ้นอยู่กับความแน่นอน (determinism) ที่ solver ไม่ให้คุณได้ อย่างไรก็ตาม เรามักจะเห็นแบบผสม: kinematic mover สำหรับผู้เล่น พร้อมกับโค้ดเสริมเล็กน้อย (callback สไตล์ OnControllerColliderHit ที่จะพูดถึงต่อไป) ที่ใส่แรงผลักให้กับ dynamic Rigidbody ใด ๆ ที่แคปซูลของผู้เล่นแตะ เพื่อให้ลังยังถูกผลักได้ ทั้งที่ตัวผู้เล่นเองไม่ได้ถูกจำลองด้วย solver
ทุกอย่างที่สร้างมาตลอดหัวข้อ 3–9 — capsule casting, collide-and-slide, ground snapping, การจัดการทางลาด, step offset, แรงโน้มถ่วง, แพลตฟอร์มเคลื่อนที่ — โดยแก่นแล้วก็คือการทำ CharacterController ในตัวของ Unity ขึ้นมาใหม่ตั้งแต่ศูนย์ ในสิ่งที่มันทำให้คุณอยู่แล้ว ด้วยโค้ด native ของ engine ที่เร็ว เปิดให้ใช้งานผ่านพื้นผิวที่เล็กกว่ามาก: ส่วนใหญ่ก็แค่ Move(), isGrounded, slopeLimit, และ stepOffset
แล้วทำไมถึงมีคนเขียนแบบ custom เอง? เพราะพื้นผิวเล็ก ๆ ของ component ในตัวนั้นก็คือข้อจำกัดของมันเช่นกัน — หลายอย่างถูกฝังไว้ตายตัวและปรับจากสคริปต์ไม่ได้:
maxSlideIterations, skin width ของมัน และลำดับที่มันแก้ปัญหาทางลาดกับ step กับกำแพงล้วนถูกกำหนดตายตัวไว้ข้างใน engine ถ้าเกมของคุณต้องการการเคลื่อนที่แปลก ๆ — wall-running, ledge grab, ท่าไถลที่จงใจไม่สนใจ slope limit เป็นเวลาสองวินาที — คุณกำลังสู้กับกล่องดำแทนที่จะปรับตัวเลขได้ตรง ๆOnControllerColliderHit(ControllerColliderHit hit) ซึ่งจะยิงหนึ่งครั้งต่อพื้นผิวหนึ่งที่มันแตะในการเรียก Move() ครั้งนั้น) แต่ไม่ได้ใส่แรงอะไรให้กับสิ่งที่มันชนเลย — คุณต้องเรียกอะไรทำนอง hit.rigidbody.AddForce(...) เอง เหมือนกับแนวทางแบบผสมที่พูดถึงในหัวข้อ 10Physics.CapsuleCast กับคณิตศาสตร์เวกเตอร์ที่เห็นในบทนี้ตรง ๆ คือโค้ดที่คุณเป็นเจ้าของเต็มที่และทำให้ deterministic ได้ตามใจกฎง่าย ๆ ที่ใช้งานได้จริง: เริ่มด้วย CharacterController ในตัวก่อน มันถูกต้อง เร็ว และครอบคลุมเกมส่วนใหญ่ รวมถึงสิ่งที่เกมแอ็กชันบุคคลที่สามสไตล์ HoYoverse ต้องการเป็นส่วนใหญ่ ค่อยเอื้อมไปหา mover แบบ custom เต็มรูปแบบ ที่สร้างตามวิธีที่หัวข้อ 3–9 ของบทนี้อธิบาย เมื่อคุณเจอข้อจำกัดที่เจาะจงและเป็นรูปธรรมของตัวในตัวจริง ๆ เท่านั้น — ความสามารถที่กล่องดำแสดงออกมาไม่ได้ หรือข้อกำหนดเรื่อง determinism ที่มันรับประกันไม่ได้ — แทนที่จะเขียนมันไว้ล่วงหน้า "เผื่อไว้ก่อน"
CharacterController ในตัวได้ถูกต้อง — การติดตรงชิ้นส่วน level geometry เฉพาะจุด ทางลาดที่ไม่ยอมปล่อย step ที่บางทีก็ปีนได้บางทีก็ไม่ได้ — แทนที่จะเดาค่าใน Inspector มั่ว ๆpoint0 ไป point1point0 = (2, 0, 0) ไป point1 = (2, 3, 0) ด้วย radius = 0.6 ใช้วิธีจากหัวข้อ 2 (clamp t ให้อยู่ใน [0, 1] หาจุดที่ใกล้ที่สุดบน axis แล้วเทียบระยะกับ radius) หาว่าจุด (2.45, 1.5, 0.6) อยู่ในแคปซูลหรือไม่ แสดง t จุดที่ใกล้ที่สุด และระยะทาง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 หน่วย
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 และความยาวของมัน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 ลงบนระนาบของกำแพงจะเหลือความยาวเท่าเดิมหรือน้อยกว่าเวกเตอร์เดิมเสมอ เพราะมันทิ้งส่วนที่ชี้ตรงเข้ากำแพงออกไป
verticalVelocity = 0 gravity = -45 หน่วย/วินาที² terminalVelocity = -27 หน่วย/วินาที และเกมรันที่ 20 FPS คงที่ (Time.deltaTime = 0.05 วินาที พอดี) ใช้แพทเทิร์น clamp จากหัวข้อ 8 verticalVelocity หลังผ่านไป 5 เฟรมมีค่าเท่าไหร่? มันถึง terminalVelocity พอดีครั้งแรกที่เฟรมไหน และค่าตรงนั้นคือเท่าไหร่?แต่ละเฟรมลบไป 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 ยังคงถูกใช้งานซ้ำ ๆ