animation clip ทุกตัวที่ artist ทำขึ้นมาจะถูก freeze ไว้ตั้งแต่ตอน export แล้ว: walk cycle จะย่ำเท้าแบบเดิมทุกครั้ง, ท่า hit reaction จะสะดุ้งด้วยระยะเท่าเดิมทุกครั้ง, ท่า idle จะหายใจด้วยจังหวะเท่าเดิมทุกครั้ง นั่นก็โอเคอยู่ จนกว่าเกมจะถามคำถามที่ clip ไม่เคยถูกสร้างมาให้ตอบได้ — พื้นตรงใต้เท้านี้อยู่ตรงไหนกันแน่ตอนนี้, ผู้เล่นยืนอยู่ตรงไหนกันแน่เพื่อให้บอสหันมามองตรงๆ ได้, ร่างกายตัวละครจะทำอะไรในจังหวะที่โดนจรวดอัดจนปลิวถอยหลัง บทนี้พูดถึงการสร้าง motion ด้วยโค้ดและฟิสิกส์แทน (หรือซ้อนทับ) clip ที่ทำไว้ล่วงหน้า เพื่อให้ตัวละครตอบสนองกับโลกเกมได้ถูกต้อง ในแบบที่ animator ไม่มีทางคาดเดาล่วงหน้าได้ครบทุกกรณี
ตอนนี้เรารู้เรื่อง skeletal animation กับการเล่น clip จากบท 9.1, การ blending กับ state machine จากบท 9.2, IK กับ root motion จากบท 9.3, และวิธีที่ Timeline เรียงลำดับโมเมนต์ที่ถูก script ไว้จากบท 9.4 กันมาแล้ว บทนี้เพิ่มแหล่งที่มาของ motion อีกแบบหนึ่ง: แทนที่จะ sample จาก clip เราจะคำนวณ rotation, ตำแหน่ง, หรือทั้ง physics simulation สดใหม่ทุกเฟรมเลย มันอิงตรงกับเรื่อง interpolation กับ easing จากบท 2.4 และคณิตศาสตร์ของ spring กับ numerical integration จากบท 2.5 — ถ้าเรื่องไหนใน 2 บทนี้เริ่มลืมๆ แล้ว ตอนนี้เป็นจังหวะที่ดีที่จะย้อนไปทบทวนอีกรอบ เพราะหลายหัวข้อข้างล่างนี้เอาคณิตศาสตร์ตัวเดียวกันมาใช้ซ้ำเกือบจะบรรทัดต่อบรรทัด
สมมติว่าตัวละครบอสต้องหันหัวตามผู้เล่นตลอดเวลา animator สามารถทำ clip "look left", "look right", "look up" ไว้สักหยิบมือแล้ว blend ระหว่างกัน (นี่คือพื้นที่ของบท 9.2) แต่วิธีนี้ทำได้แค่ประมาณทิศทางตายตัวไม่กี่ทิศเท่านั้น — ผู้เล่นยืนได้ทุกที่จริงๆ และหัวต้องชี้ไปหาผู้เล่น เป๊ะๆ ต่อเนื่องทุกเฟรม การทำ clip ไว้เป็นจำนวนอนันต์ไม่ใช่ทางเลือกที่ทำได้ แต่การคำนวณ rotation จากตำแหน่งสดของเป้าหมายทุกเฟรมคือทางที่ทำได้
using UnityEngine;
public class LookAtNaive : MonoBehaviour
{
public Transform head; // the bone to rotate (e.g. the head, or the topmost spine bone)
public Transform target; // what to look at, e.g. the player
public float turnSpeed = 200f; // degrees per second the head is allowed to turn
[Range(0f, 1f)] public float weight = 1f; // how much of the look-at to apply on top of the animation
void LateUpdate()
{
Vector3 toTarget = target.position - head.position;
Quaternion desiredRotation = Quaternion.LookRotation(toTarget, Vector3.up);
Quaternion animatedRotation = head.rotation; // whatever the Animator already set this frame
Quaternion turnedRotation = Quaternion.RotateTowards(animatedRotation, desiredRotation, turnSpeed * Time.deltaTime);
head.rotation = Quaternion.Slerp(animatedRotation, turnedRotation, weight);
}
}
โค้ดนี้อยู่ใน LateUpdate() ด้วยเหตุผลเดียวกับที่บทเรื่องกล้องเคยอธิบายไว้: Unity จะ evaluate Animator แล้วเขียน pose ที่ animate ของเฟรมนี้ลงไปในช่วงระหว่าง Update() กับ LateUpdate() ดังนั้นพอโค้ดนี้ทำงาน head.rotation จะมีค่า rotation ที่ animate เสร็จสมบูรณ์ของเฟรมนี้อยู่แล้ว การอ่านค่าตรงนี้แทนที่จะอ่านใน Update() รับประกันว่าสคริปต์นี้จะปรับ pose สุดท้าย จริงๆ ไม่ใช่ pose เก่าที่ค้างมาจากเฟรมที่แล้ว
Quaternion.LookRotation(toTarget, Vector3.up) จะสร้าง rotation ที่หันไปตามทิศ toTarget โดยใช้ world up เป็นตัวอ้างอิงของ "up" (อธิบายละเอียดไว้แล้วในบท rotation, 2.3) Quaternion.RotateTowards จะหมุนหัวด้วยความเร็วเชิงมุมคงที่ ถูก clamp ไว้ที่ turnSpeed * Time.deltaTime องศาต่อเฟรม — นี่คือวิธีแก้ปัญหาจากกับดักเรื่อง smoothing ในบท 2.4 ที่ตั้งใจเลือกใช้ตรงนี้แทนการใช้ Slerp(a, b, speed * Time.deltaTime) ตรงๆ เพราะ RotateTowards ไม่มีทาง overshoot เป้าหมายไปได้เลย ไม่ว่า Time.deltaTime จะโตแค่ไหนตอนเฟรมกระตุก ส่วน Slerp(animatedRotation, turnedRotation, weight) ตัวสุดท้ายเป็นงานคนละอย่างเลย: มันไม่ใช่การ smoothing ตามเวลา แต่เป็นการ blend ตามสัดส่วนคงที่ ทำให้ designer สามารถ fade การ look-at ทั้งหมดเข้า-ออกได้ (weight 0 = animation ล้วนๆ, weight 1 = look-at เต็มร้อย) โดยไม่ต้องไปแตะ turn speed เลย
ตรงนี้ไม่มี console output ให้ดู เพราะสคริปต์นี้ขับเคลื่อน Transform อยู่ตลอดเวลา พูดง่ายๆ คือถ้าเป้าหมายอยู่ๆ ก็โผล่มาทางขวาของตัวละคร 90 องศาตอนที่หัวหันไปข้างหน้าอยู่ จะเกิดอะไรขึ้นในไม่กี่เฟรม: เฟรม 1 หัวจะหมุนไปทาง turnSpeed * Time.deltaTime องศา, เฟรม 2 หมุนไปอีกเท่าเดิม แล้วก็ทำแบบนี้ต่อไปเรื่อยๆ จนกว่ามุมที่เหลือจะเล็กกว่าระยะที่เคลื่อนได้ต่อเฟรม ตอนนั้นหัวก็จะไปหยุดที่เป้าหมายพอดี
LookAtNaive ไปใช้จริงตามที่เขียนไว้ข้างบนตรงๆ เลย มันทำงานได้ดีมากตราบใดที่เป้าหมายยังอยู่แถวๆ ด้านหน้าตัวละคร แต่พอเป้าหมายเดินไปอยู่ด้านหลังตัวละครเมื่อไหร่ LookRotation ก็จะคำนวณ rotation ที่หมุนหัวไปเต็มๆ 150-180 องศาเพื่อหันไปหามันแบบไม่สนใจอะไร — ซึ่งบน rig รูปร่างคน หมายความว่าหัวจะหมุนทะลุไหล่ไปเลย ไม่มีอะไรในสคริปต์นี้ที่ห้ามมันได้ หัวข้อถัดไปจะแก้ปัญหานี้คอของคนจริงๆ ไม่ได้หมุนได้อิสระขนาดนั้น — มันมี range of motion จำกัดรอบๆ ทิศที่ spine หันอยู่ วิธีแก้คือใช้ cone limit: เลือก "rest direction" ขึ้นมาสักทิศ (ปกติใช้ forward ของ spine) วัดมุมระหว่างทิศนั้นกับเป้าหมาย แล้วถ้ามุมมันใหญ่เกินค่าสูงสุดที่กำหนดไว้ ก็ให้เล็งไปที่จุดที่ใกล้ที่สุดบนขอบของ cone แทนที่จะเล็งไปที่เป้าหมายจริงๆ
using UnityEngine;
public class LookAtClamped : MonoBehaviour
{
public Transform head;
public Transform spine; // defines the "forward" the cone is centered on
public Transform target;
public float turnSpeed = 200f;
public float maxConeAngle = 60f; // degrees the head may turn away from spine.forward
[Range(0f, 1f)] public float weight = 1f;
void LateUpdate()
{
Vector3 desiredDir = (target.position - head.position).normalized;
Vector3 restDir = spine.forward;
float angle = Vector3.Angle(restDir, desiredDir);
Vector3 clampedDir = angle > maxConeAngle
? Vector3.RotateTowards(restDir, desiredDir, maxConeAngle * Mathf.Deg2Rad, 0f)
: desiredDir;
Quaternion desiredRotation = Quaternion.LookRotation(clampedDir, Vector3.up);
Quaternion animatedRotation = head.rotation;
Quaternion turnedRotation = Quaternion.RotateTowards(animatedRotation, desiredRotation, turnSpeed * Time.deltaTime);
head.rotation = Quaternion.Slerp(animatedRotation, turnedRotation, weight);
}
}
Vector3.Angle(a, b) จะคืนค่ามุมแบบไม่มีเครื่องหมายระหว่างสองทิศ ตั้งแต่ 0 ถึง 180 องศา พอมุมนั้นเกิน maxConeAngle เมื่อไหร่ Vector3.RotateTowards(restDir, desiredDir, maxConeAngle * Mathf.Deg2Rad, 0f) จะหมุน restDir เข้าหา desiredDir ไปเท่ากับ maxConeAngle พอดี (ข้อสังเกต: overload ตัวนี้รับค่าเป็นเรเดียน เลยต้องแปลงด้วย Deg2Rad) และให้ magnitude เปลี่ยนแค่ 0 (ทั้งสอง vector เป็น unit length อยู่แล้ว เราต้องการแค่ทิศใหม่ ไม่ต้องการความยาวใหม่) ผลลัพธ์ที่ได้จะอยู่บนขอบของ cone พอดี ในฝั่งที่ใกล้เป้าหมายจริงที่สุด ส่วนที่เหลือหลังบรรทัดนี้เหมือนกับ section 1 ทุกอย่าง
นี่คือตัวอย่าง trace ของ target B จาก diagram — ห่างจาก spine forward 70 องศา, cone limit อยู่ที่ 45 องศา ดังนั้น clamp จะทำงานแน่นอน:
// target is 70 degrees off spine.forward, cone limit maxConeAngle = 45
// clampedAngle = min(70, 45) = 45 -- the clamp kicks in, head aims at the cone edge instead
// turnSpeed = 200 deg/s, dt = 0.1 (a bigger dt than a real frame, just to keep this table short)
//
// frame currentAngle (degrees from spine.forward)
// 0 0.00 -- this frame's animated pose has the head facing forward
// 1 20.00 -- moved the max allowed step: 200 * 0.1 = 20 degrees
// 2 40.00 -- moved another 20 degrees
// 3 45.00 -- only 5 degrees of room left before the clamp, so it stops there
// 4 45.00 -- held at the cone edge; it only moves again if the target does
weight ลงไปทาง 0 ตอนที่มุมของเป้าหมายเข้าใกล้หรือเลย cone limit ไปแล้ว แทนที่จะปล่อยให้หัวค้างอยู่ที่ขอบตลอดไป — ตัวละครที่จ้องหัวไหล่คุณค้างอยู่สิบวินาทีเพราะคุณแค่เดินไปอยู่ด้านหลังนิดเดียว มันดูเหมือนบั๊ก ไม่ใช่ดูตั้งใจ วิธีง่ายๆ คือ weight *= 1f - Mathf.InverseLerp(maxConeAngle * 0.5f, maxConeAngle, angle) เอา InverseLerp จากบท 2.4 มาใช้ซ้ำตรงๆ เลยVector3.up เข้าไปใน Quaternion.LookRotation (เช่นเป้าหมายอยู่ตรงหัวเป๊ะๆ หรืออยู่ใต้เท้าเป๊ะๆ) พอ forward กับ up กลายเป็นขนานกัน มันจะเป็น degenerate case ที่ไม่มี rotation ที่นิยามไว้ชัดเจน Unity จะ log warning ออกมาแล้วคืนค่าอะไรบางอย่างที่คุณคงไม่ได้อยากได้ ป้องกันไว้ด้วยการเช็คว่า ถ้า toTarget เกือบขนานกับ up vector ที่เลือกไว้ ให้ข้ามการ look-at ของเฟรมนั้นไปเลย หรือสลับไปใช้ up reference ตัวอื่นแทนผม, หางม้า, เสื้อคลุม, ชายเสื้อโค้ท, เครื่องประดับ, หู, หาง — ตัวละครโดยเฉพาะตัวละครสไตล์อนิเมะเต็มไปด้วยของที่ไม่ควรขยับติดกับตัวแบบแข็งทื่อ พอหัวสะบัดเร็วๆ ผมควรจะตามหลังอยู่แป๊บหนึ่งแล้วแกว่งเลย resting position ไปก่อนที่จะนิ่ง อาการตามหลังแล้ว overshoot แบบนี้เรียกว่า secondary motion — เหมือนกับ "follow-through" และ "overlapping action" ที่ animator สายดั้งเดิมวาดมือกันมาเป็นร้อยปี แต่ตรงนี้เราให้มันเกิดขึ้นเองอัตโนมัติแทน กระดูกที่ทำแบบนี้กับตัวเองมักถูกเรียกว่า spring bone หรือ dynamic bone (ชื่อเรียกจะต่างกันไปตามเอนจิ้นและ package ที่ทำให้มันดัง แต่ไอเดียเหมือนกันหมดทุกที่)
spring bone ทุกตัวจะติดตาม rest position อยู่ตลอด — คือตำแหน่งที่กระดูกควรจะอยู่ถ้ามันตาม parent แบบแข็งทื่อสมบูรณ์แบบ ซึ่งคำนวณใหม่จาก transform ปัจจุบันของ parent ทุกเฟรม ตำแหน่งจริงของกระดูกจะถูกดึงเข้าหา rest position นั้นด้วย spring และถูกต้านด้วย damper นี่คือ spring ตัวเดียวกับตัวอย่าง Hooke's-law ในบท 2.5 เป๊ะๆ แค่เพิ่มส่วนผสมใหม่เข้ามาหนึ่งอย่าง
ลองเทียบกับ spring ในบท 2.5: ตอนนั้น acceleration เป็นแค่ a = -k/m * x และ spring แบบไม่มีแรงเสียดทานก็แกว่งไปเรื่อยๆ ไม่มีวันหยุด เพราะไม่มีอะไรดึงพลังงานออกไปเลย พอเพิ่มเทอม damping -c * v เข้ามา (แรงที่ต้านทิศทางที่กระดูกกำลังเคลื่อนที่อยู่ตอนนั้นเสมอ คูณด้วยค่าคงที่ damping c) แรงรวมก็จะกลายเป็น F = -k*x - c*v ดังนั้น a = (-k*x - c*v) / m แล้วก็ step มันไปข้างหน้าด้วยสูตร semi-implicit Euler แบบเดียวกับบท 2.5 section 6 เป๊ะๆ — อัพเดต velocity ก่อน แล้วค่อยอัพเดตตำแหน่งด้วย velocity ใหม่ — แค่นี้ก็ได้ spring bone แล้ว
c / (2 * sqrt(k * m)) ถ้าต่ำกว่า 1 เรียกว่า underdamped — spring จะ overshoot แล้วแกว่งไปมาสักพักก่อนจะนิ่ง ซึ่งนี่แหละคือ jiggle ที่เราอยากได้สำหรับผม ถ้าเท่ากับ 1 พอดีเรียกว่า critically damped — มันจะกลับสู่ rest ได้เร็วที่สุดโดยไม่มี overshoot เลย (นี่คือสิ่งที่ SmoothDamp ของ Unity ใช้ข้างในตัวมันเอง ซึ่งจะมาเจอกันอีกทีใน section 6) ถ้ามากกว่า 1 เรียกว่า overdamped — ช้ากว่า critical และก็ยังไม่มี overshoot เหมือนกัน ถ้า damping น้อยไปผมจะดูเหมือนทำจากวุ้น ถ้ามากไปก็จะดูแข็งทื่อไม่มีชีวิตชีวา ค่าที่พอดีมักจะอยู่แถวๆ underdamped แล้วก็ค่อยๆ tune ด้วยตาไปเรื่อยๆusing UnityEngine;
public class SpringBone : MonoBehaviour
{
public Transform bone; // the moving tip (e.g. the very end of a strand of hair)
public Transform boneRest; // a child of the PARENT marking where the bone sits if perfectly rigid
public float stiffness = 20f; // how hard the spring pulls back toward rest -- higher snaps back faster
public float damping = 3f; // how much the motion is resisted -- higher settles faster, less jiggle
public float mass = 1f;
public float boneLength = 0.3f; // distance constraint to the parent, stops the bone from stretching
Vector3 velocity;
Vector3 currentPos;
void Start()
{
currentPos = bone.position;
}
void LateUpdate()
{
Vector3 restPos = boneRest.position;
Vector3 displacement = currentPos - restPos;
Vector3 accel = (-stiffness * displacement - damping * velocity) / mass;
velocity += accel * Time.deltaTime;
currentPos += velocity * Time.deltaTime;
// distance constraint: keep the bone exactly boneLength away from its parent,
// the same idea as satisfyDistance() from chapter 2.5's Verlet cloth section
Vector3 fromParent = currentPos - bone.parent.position;
currentPos = bone.parent.position + fromParent.normalized * boneLength;
bone.position = currentPos;
}
}
บล็อกแรกคือคณิตศาสตร์ spring-damper จาก section ก่อนหน้า แปลงตรงๆ ให้เป็นโค้ด: คำนวณ displacement, คำนวณ acceleration จากแรง spring บวกแรง damping, step velocity, step ตำแหน่ง — รูปร่างเหมือนตัวอย่าง spring ในบท 2.5 เป๊ะๆ แค่เพิ่มเทอม -damping * velocity เข้ามา สามบรรทัดสุดท้ายเป็นของใหม่: มันจะดัน currentPos กลับไปอยู่บนทรงกลมรัศมี boneLength รอบตำแหน่งจริงของ parent เหมือนกับฟังก์ชัน satisfyDistance จาก section เรื่อง cloth ในบท 2.5 เป๊ะๆ แค่เขียนสำหรับ link แบบจุดเดียวไปหา parent แทนที่จะเป็นจุดอิสระสองจุด ถ้าไม่มีขั้นตอนนี้ spring ที่แข็งพอหรือ Time.deltaTime ที่ใหญ่พอ อาจจะยืดกระดูกออกจาก parent ไปได้ไม่จำกัด แต่พอมีขั้นตอนนี้ กระดูกจะอยู่ห่างเท่ากับความยาวคงที่เสมอ ไม่ว่าจะโดน displace ไปมากแค่ไหนก็ตาม
ถ้าอยากเห็น spring นิ่งลงแบบแยกดูเดี่ยวๆ ให้ freeze parent ไว้สักครู่ (เพื่อให้ restPos หยุดขยับ) แล้วดูแค่ displacement x กับ velocity v โดยใช้ k = 20, c = 3, m = 1, dt = 0.1 (ใช้ dt ใหญ่กว่าเฟรมจริงอีกเช่นเคย เพื่อให้ตารางสั้นๆ) เริ่มที่ x = 1 หลังจาก rest position อยู่ๆ ก็กระโดดห่างออกไปหนึ่งหน่วย:
// step x v
// 0 1.0000 0.0000
// 1 0.8000 -2.0000
// 2 0.5000 -3.0000
// 3 0.1900 -3.1000
// 4 -0.0650 -2.5500
// 5 -0.2305 -1.6550
// 6 -0.3003 -0.6975
ลองดู x: มันข้ามศูนย์ไปแถวๆ step 4 แล้วพอถึง step 6 มัน overshoot ไปประมาณ -0.30 — เลย rest position ไปแล้ว — ก่อนที่แรง spring (ซึ่งตอนนี้ชี้กลับทิศแล้ว) จะเริ่มดึงมันกลับมา overshoot ตรงนี้ไม่ใช่บั๊ก มันคือประเด็นหลักของเรื่องนี้เลย กระดูกที่หยุดนิ่งที่ x = 0 ทันทีที่มาถึงจะดูแข็งทื่อเหมือนหุ่นยนต์ การแกว่งเลยแล้วค่อยนิ่งนี่แหละที่ทำให้มันดูเป็นผมจริงๆ บนหน้าจอ ไม่ใช่แท่งแข็งๆ
spring bone ตัวเดียวทำให้ได้แค่ต่างหูที่กระเพื่อมๆ เส้นผม, หางม้า, หรือเสื้อคลุมต้องการกระดูกหลายตัวเรียงต่อกัน โดย rest position ของแต่ละตัวขึ้นอยู่กับว่ากระดูกตัวก่อนหน้าในเชนไปจบที่ตรงไหนจริงๆ — ไม่ใช่ตำแหน่งดั้งเดิมของ skeleton ที่ animate ไว้ แต่เป็นตำแหน่งที่ solve ด้วย spring ของเฟรมเดียวกันนี้
using UnityEngine;
[System.Serializable]
public class SpringBoneLink
{
public Transform bone; // the tip this link controls
public Transform restPose; // marks the bone's rigidly-animated position each frame
public float boneLength = 0.2f;
}
public class SpringBoneChain : MonoBehaviour
{
public SpringBoneLink[] links; // MUST be ordered ROOT FIRST, tip last
public float stiffness = 200f;
public float damping = 12f;
public float mass = 1f;
Vector3[] velocities;
Vector3[] positions;
void Start()
{
velocities = new Vector3[links.Length];
positions = new Vector3[links.Length];
for (int i = 0; i < links.Length; i++)
positions[i] = links[i].bone.position;
}
void LateUpdate()
{
// this loop MUST run root-to-tip, in array order, so link i+1 reads link i's
// freshly-solved position from THIS frame, not its stale position from last frame
for (int i = 0; i < links.Length; i++)
{
Vector3 restPos = links[i].restPose.position;
Vector3 displacement = positions[i] - restPos;
Vector3 accel = (-stiffness * displacement - damping * velocities[i]) / mass;
velocities[i] += accel * Time.deltaTime;
positions[i] += velocities[i] * Time.deltaTime;
Transform parentBone = (i == 0) ? transform : links[i - 1].bone;
Vector3 fromParent = positions[i] - parentBone.position;
positions[i] = parentBone.position + fromParent.normalized * links[i].boneLength;
links[i].bone.position = positions[i];
}
}
}
แต่ละ link ก็คือสำเนาของ spring bone ตัวเดียวจาก section 4 เอามารันในลูป รายละเอียดที่สำคัญจริงๆ อยู่ในคอมเมนต์: ลูป for นี้ต้องประมวลผล link ที่เป็น root ก่อนแล้วค่อยไปที่ tip ทีหลัง ในเฟรมเดียวกัน เพื่อให้พอ link ที่ 2 อ่านค่า links[1].bone (กระดูกที่เป็น parent ของมัน) มันจะอ่านตำแหน่งที่ physics step ของเฟรมนี้อัพเดตไปแล้ว ไม่ใช่ค่าตกค้างจากเฟรมที่แล้ว
GetComponentsInChildren<SpringBone>() แล้วอัพเดตตามลำดับที่การค้นหานั้นบังเอิญคืนค่ามาให้ ลำดับการค้นหาของ Unity ไม่ใช่อะไรที่ควรพึ่งพาเพื่อความถูกต้อง — ถ้าสคริปต์ของกระดูกลูกรันก่อนกระดูกแม่ของมันในเฟรมเดียวกัน มันจะอ่านตำแหน่ง parent ที่ค้างมาจากเฟรมก่อน แล้วทั้งเชนก็จะดูหลุดๆ หรือสั่นๆ แบบสังเกตได้ โดยเฉพาะตอนหันหัวเร็วๆ ให้กำหนดลำดับไว้ตรงๆ เลย จาก root ไป tip เป็น array ที่เรียงลำดับไว้ (แบบ SpringBoneChain ข้างบน) แทนที่จะเชื่อการค้นหาแบบอัตโนมัติclip ของ walk cycle ถูกทำขึ้นมาโดยสมมติว่าพื้นเรียบ พอตัวละครเดินขึ้นขอบถนน, ทางลาด, หรือบันได เท้าจะจมลงไปในขั้นบันไดหรือไม่ก็ลอยอยู่เหนือมัน — เพราะ animation ไม่รู้เลยว่าความสูงของพื้นเปลี่ยนไป วิธีแก้ใช้รูปแบบเดียวกับ IK ในบท 9.3 แต่ทำแบบ procedural ทุกเฟรม: ยิง ray ตรงลงไปจากตำแหน่งที่ animate ไว้ของแต่ละเท้า แล้วย้าย IK target ของเท้านั้นไปยังจุดที่ ray ชนจริงๆ
using UnityEngine;
public class FootIK : MonoBehaviour
{
public Animator animator;
public LayerMask groundMask;
public float raycastHeight = 0.3f; // start the ray this far above the animated foot position
public float maxStepDown = 0.5f; // how far below the animated position still counts as "ground"
public float footRadius = 0.05f; // lifts the foot slightly off the exact hit point
void OnAnimatorIK(int layerIndex)
{
AdjustFoot(AvatarIKGoal.LeftFoot);
AdjustFoot(AvatarIKGoal.RightFoot);
}
void AdjustFoot(AvatarIKGoal foot)
{
Vector3 animatedPos = animator.GetIKPosition(foot);
Ray ray = new Ray(animatedPos + Vector3.up * raycastHeight, Vector3.down);
if (Physics.Raycast(ray, out RaycastHit hit, raycastHeight + maxStepDown, groundMask))
{
Vector3 targetPos = hit.point + Vector3.up * footRadius;
animator.SetIKPositionWeight(foot, 1f);
animator.SetIKPosition(foot, targetPos);
Quaternion groundRotation = Quaternion.FromToRotation(Vector3.up, hit.normal);
animator.SetIKRotationWeight(foot, 1f);
animator.SetIKRotation(foot, groundRotation * animator.GetIKRotation(foot));
}
}
}
OnAnimatorIK เป็น callback ที่ Unity รันให้อัตโนมัติทันทีหลังจาก evaluate base animation เสร็จ แต่ก่อน LateUpdate — เป็นช่องของ pipeline ที่สร้างมาสำหรับปรับ IK ของ humanoid โดยเฉพาะ animator.GetIKPosition(foot) จะอ่านว่าตอนนี้ animation วางเท้านั้นไว้ตรงไหน; raycast จะเริ่มจากจุดที่สูงกว่านั้นนิดหน่อยแล้วยิงตรงลงไป พอชน SetIKPosition จะย้ายเท้าไปที่จุดที่ชน (ยกขึ้นนิดหน่อยด้วย footRadius เพื่อไม่ให้ทะลุพื้น) และ SetIKRotation จะเอียงเท้าให้ตรงกับ ground normal (ทิศที่ชี้ตั้งฉากออกจากพื้นผิว) โดยใช้ Quaternion.FromToRotation สร้าง rotation จาก "แนวราบ" ไปเป็น "เข้ากับทางลาดนี้" แล้วคูณเข้ากับ rotation เดิมของเท้า เพื่อให้มันยังหันหน้าถูกทิศอยู่ระหว่างที่เอียง
OnAnimatorIK ถูกต้องทุกอย่างแล้วแต่งงว่าทำไมมันไม่ทำงานสักที มันจะรันก็ต่อเมื่อ base layer ของ Animator Controller เปิด checkbox IK Pass ไว้เท่านั้น (อยู่ใน Layers panel ของหน้าต่าง Animator) — ค่าเริ่มต้นมันปิดอยู่ และไม่มี error หรือ warning ใดๆ ถ้าคุณลืมเปิดมัน method นี้จะแค่ไม่ถูกเรียกแบบเงียบๆ ไปเลยสะโพกก็ต้องตอบสนองด้วย ไม่งั้นการก้าวขึ้นบันไดด้วยเท้าข้างเดียวจะดูเหมือนขาถูกยืดออก ให้ track hip offset ที่ทำ smooth ไว้ โดยขับเคลื่อนด้วยเท้าข้างที่ต่ำกว่า:
using UnityEngine;
public class HipOffset : MonoBehaviour
{
public Transform hips;
public float smoothTime = 0.08f;
float currentOffset;
float velocity;
// call this once you know each foot's own ground offset (e.g. from FootIK above)
public void ApplyHipOffset(float leftFootOffset, float rightFootOffset)
{
float targetOffset = Mathf.Min(leftFootOffset, rightFootOffset);
currentOffset = Mathf.SmoothDamp(currentOffset, targetOffset, ref velocity, smoothTime);
hips.position += Vector3.up * currentOffset;
}
}
// left foot ray hits a curb: +0.15 above the animated ground height
// right foot ray hits a dip: -0.05 below the animated ground height
// targetOffset = the LOWER of the two, so the higher foot never looks like it is floating
// targetOffset = min(+0.15, -0.05) = -0.05
//
// SmoothDamp is a critically damped spring under the hood -- the exact formula from
// section 3, just with damping set to critical: c = 2 * sqrt(k * m). Rolling that by
// hand (k = 100, m = 1, c = 20, dt = 0.05) on currentOffset gives:
//
// frame currentOffset
// 0 0.0000
// 1 -0.0125
// 2 -0.0219
// 3 -0.0289
// 4 -0.0342
// 5 -0.0381 -- closing in on -0.05, smoothly, with NO overshoot past it
สังเกตความต่างกับ spring ของผมใน section 4 ที่ตั้งใจทำเป็น underdamped เพื่อให้มัน overshoot แล้วกระเพื่อม แต่ตรงนี้ ถ้า overshoot หมายความว่าสะโพกจะกระเด้งขึ้นเลยความสูงเป้าหมายไปก่อนที่จะนิ่ง — ซึ่งผิด สำหรับสิ่งที่ควรจะดู "หนักแน่นติดพื้น" ไม่ใช่ "เด้งๆ" สูตรเดียวกัน แต่ damping ratio ตรงข้ามกัน เพราะสองงานนี้ต้องการความรู้สึกที่ตรงข้ามกัน
ไม่มี animator คนไหนคาดเดาทิศทาง, แรง, และท่าทางร่างกายที่แน่นอนของการตายทุกแบบที่เป็นไปได้ล่วงหน้าได้ วิธีแก้มาตรฐานคือ ragdoll: ให้กระดูกหลักทุกชิ้นมี Rigidbody, Collider, และ joint ที่เชื่อมกับกระดูก parent ของมันพร้อม angular limit — ไอเดีย clamp เดียวกับ cone ของ look-at ใน section 2 เลย แค่เปลี่ยนมาให้ physics engine เป็นคนบังคับแทนสคริปต์ กลไกเชิงลึกของ rigid body, collider, และ joint เป็นเรื่องของบทฟิสิกส์ (10.1 เรื่อง Rigid Bodies & Collision และ 10.3 เรื่อง Ragdolls, Cloth & Soft Bodies) ส่วนนี้จะพูดถึงฝั่ง animation ของการส่งไม้ต่อ — จะสลับสวิตช์ตอนไหนและยังไง
using UnityEngine;
public class RagdollToggle : MonoBehaviour
{
Animator animator;
Rigidbody[] ragdollBodies;
Collider[] ragdollColliders;
void Awake()
{
animator = GetComponent<Animator>();
ragdollBodies = GetComponentsInChildren<Rigidbody>();
ragdollColliders = GetComponentsInChildren<Collider>();
SetRagdollActive(false); // start animated: physics off, ragdoll colliders off
}
public void Die(Vector3 hitForce, Vector3 hitPoint, Rigidbody hitBone)
{
SetRagdollActive(true);
hitBone.AddForceAtPosition(hitForce, hitPoint, ForceMode.Impulse);
}
public void SetRagdollActive(bool active)
{
animator.enabled = !active; // Animator OFF once physics takes over
foreach (Rigidbody rb in ragdollBodies)
rb.isKinematic = !active; // isKinematic true = "the Animator moves this, not physics"
foreach (Collider col in ragdollColliders)
col.enabled = active;
}
}
ตอนที่ตัวละครยังถูก animate อยู่ Rigidbody ของ ragdoll ทุกตัวจะเป็น kinematic (isKinematic = true — flag ที่หมายความว่าแรง physics ไม่มีผลอะไรกับ object นี้เลย มีอย่างอื่น ในที่นี้คือ Animator เป็นคนคุมตำแหน่งมันเต็มๆ) และ Collider ของ ragdoll ทุกตัวจะถูกปิดไว้ ทำให้แขนขาไม่โดน physics ดันไปมาและไม่ชนกับอะไรเลยตอนที่ animation ยังคุมอยู่เต็มที่ Die() จะพลิกทั้งสองอย่าง: Animator ปิดตัวลง, Rigidbody ทุกตัวกลายเป็น non-kinematic เพื่อให้ gravity กับการชนขยับมันได้, collider เปิดขึ้นมา แล้วก็เรียก AddForceAtPosition พร้อม ForceMode.Impulse หนึ่งครั้ง (การเปลี่ยนความเร็วแบบทันที คูณด้วยมวล — บท 10.1 พูดถึง force mode อื่นๆ ด้วย) ทำให้กระดูกที่โดนยิงกระเด็นไปในทิศทางเดียวกับที่โดนยิง ตัวละครเลยไม่ได้แค่ทรุดอยู่กับที่เฉยๆ แต่ตอบสนองให้เห็นชัดว่าตายยังไง
ตัวละครที่รอดจากการถูกล้มไม่ควรจะกระตุกจาก pose physics ที่อ่อนปวกเปียกกลับไปเป็น animation ยืนแบบทันทีทันใด — อาการ pop แบบนั้นมันสะดุดตามาก วิธีแก้ทั่วไปคือ: freeze ตำแหน่งที่ ragdoll ไปจบอยู่ตรงนั้นแหละ, เปิด Animator กลับมาแล้วกระโดดเข้า clip get-up ทันที จากนั้น blend ทุกกระดูกจาก pose ragdoll ที่ freeze ไว้ไปหา animation ที่กำลังเล่นอยู่ ในช่วงเวลาสั้นๆ ค่อยๆ ลดอิทธิพลของ ragdoll ลงจนเหลือศูนย์
using UnityEngine;
using System.Collections;
public class RagdollGetUp : MonoBehaviour
{
public Animator animator;
public Transform hips;
public string getUpFrontClip = "GetUpFront";
public string getUpBackClip = "GetUpBack";
public float blendTime = 0.25f;
Transform[] allBones;
void Awake()
{
allBones = GetComponentsInChildren<Transform>();
}
public void StandUp()
{
bool faceDown = Vector3.Dot(hips.up, Vector3.up) < 0f;
StartCoroutine(BlendToAnimation(faceDown ? getUpFrontClip : getUpBackClip));
}
IEnumerator BlendToAnimation(string clipName)
{
// 1. remember exactly where physics left every bone
Vector3[] ragdollPos = new Vector3[allBones.Length];
Quaternion[] ragdollRot = new Quaternion[allBones.Length];
for (int i = 0; i < allBones.Length; i++)
{
ragdollPos[i] = allBones[i].position;
ragdollRot[i] = allBones[i].rotation;
}
GetComponent<RagdollToggle>().SetRagdollActive(false); // physics off, Animator back on
animator.Play(clipName, 0, 0f);
animator.Update(0f); // force the Animator to pose the skeleton RIGHT NOW, this frame
float t = 0f;
while (t < blendTime)
{
t += Time.deltaTime;
float lerp = t / blendTime;
for (int i = 0; i < allBones.Length; i++)
{
allBones[i].position = Vector3.Lerp(ragdollPos[i], allBones[i].position, lerp);
allBones[i].rotation = Quaternion.Slerp(ragdollRot[i], allBones[i].rotation, lerp);
}
yield return null;
}
}
}
StandUp() จะเลือกระหว่าง get-up clip สองแบบโดยใช้ Vector3.Dot(hips.up, Vector3.up) — dot product (บท 2.1) ระหว่างทิศ up ปัจจุบันของสะโพกกับ world up; ค่าติดลบหมายความว่าสะโพกชี้ไปทางพื้นมากกว่าฟ้า คือตัวละครลงไปคว่ำหน้า BlendToAnimation จะบันทึกตำแหน่งและ rotation ของทุกกระดูกในโลกก่อน ตามที่ physics ทิ้งไว้เป๊ะๆ แล้วปิด ragdoll แล้วบังคับให้ Animator evaluate เฟรมแรกของ get-up clip ทันทีด้วย animator.Update(0f) (ปกติแล้วเราจะรอเฟรมถัดไปเฉยๆ แต่ถ้ารอ จะมีอยู่หนึ่งเฟรมที่ skeleton อยู่ในสถานะกึ่งกลางที่ไม่ได้นิยามไว้) จากนั้นลูปจะ blend ทุกกระดูกจาก ragdollPos/ragdollRot ที่บันทึกไว้ไปหาตำแหน่งปัจจุบันที่ Animator วางไว้ โดย lerp ไต่จาก 0 ไป 1 — ที่ lerp = 0 pose ยังเป็น ragdoll ที่ freeze ไว้ พอถึง lerp = 1 ก็จะเป็น animation ที่กำลังเล่นอยู่เต็มๆ
ตัวอย่าง trace ตามความสูงของสะโพก (y) เฉยๆ ระหว่างที่ get-up clip ยกตัวละครขึ้น โดยใช้ blendTime = 0.25 และ step ละ dt = 0.05:
// ragdollPos.y = 0.20 (lying on the ground)
// say the get-up clip, sampled fresh each frame, moves hips.y like this:
// frame 1 (t=0.05): animatedY = 0.24 lerp = 0.05/0.25 = 0.20 -> blended.y = 0.20+(0.24-0.20)*0.20 = 0.208
// frame 2 (t=0.10): animatedY = 0.31 lerp = 0.40 -> blended.y = 0.20+(0.31-0.20)*0.40 = 0.244
// frame 3 (t=0.15): animatedY = 0.40 lerp = 0.60 -> blended.y = 0.20+(0.40-0.20)*0.60 = 0.320
// frame 4 (t=0.20): animatedY = 0.55 lerp = 0.80 -> blended.y = 0.20+(0.55-0.20)*0.80 = 0.480
// frame 5 (t=0.25): animatedY = 0.70 lerp = 1.00 -> blended.y = 0.70 -- fully on the animation
// the hips rise smoothly, even though the RAW animated pose alone would have popped
// straight from 0.20 to 0.24 the instant the clip started
LateUpdate แทนที่จะใช้ coroutine เปล่าๆ (เพื่อรับประกันว่ามันรันหลัง animation evaluation เสมอ กฎการเรียงลำดับเดียวกับ section 1) และมักจะ blend เป็น additive pose ก้อนเดียวที่กระชับแทนที่จะทำทีละกระดูก — แต่ไอเดียหลักๆ ก็เหมือนกับที่อยู่ตรงนี้ทุกอย่างทุกเทคนิคในบทนี้เสียบเข้ากับจุดใดจุดหนึ่งที่เจาะจงใน pipeline ต่อเฟรมของ Unity การซ้อนมันให้ถูกลำดับนี่แหละที่ทำให้ตัวละครที่มีทั้ง walk cycle, การเล็ง, ผมที่กระเพื่อม, และ foot IK ทำงานร่วมกันได้ แทนที่จะแย่งกันควบคุม
คำว่า "additive" ทำหน้าที่สองอย่างที่ต่างกันตลอดทั้งบทนี้ คุ้มค่าที่จะแยกให้ชัด Animator Controller ของ Unity รองรับ layer ที่ตั้ง Blend Mode เป็น Additive: animator จะทำ clip เป็นผลต่างจาก reference pose (บ่อยครั้งคือเฟรมแรกของ clip เอง) แล้ว Unity ก็เอาผลต่างนั้นไปบวกทับบนสิ่งที่ base layer สร้างไว้ — เหมาะกับของอย่างเช่นลูป breathing หรือ offset การเล็งอาวุธ ที่ animator ทำไว้ครั้งเดียวแล้วให้มันไป apply ทับบน base movement อะไรก็ได้ ส่วนที่เหลือทั้งหมดในบทนี้คือการบวกแบบ procedural ที่ขับด้วยสคริปต์: แทนที่จะเป็น delta clip ที่ทำไว้ล่วงหน้า โค้ดจะคำนวณ delta ทุกเฟรมจากข้อมูลสด (ตำแหน่งของเป้าหมาย, จุดที่ raycast ชน, displacement ปัจจุบันของ spring) ที่ไม่มี animator คนไหน bake ไว้ล่วงหน้าได้ ทั้งสองแบบก็คือ "บวก delta ทับบน base pose" เหมือนกัน — แค่ต่างกันตรงที่ว่าคนหรือสูตรเป็นคนตัดสินใจว่า delta นั้นคืออะไร
กฎที่ใช้ได้จริง: authored animation คือค่าเริ่มต้น หยิบ procedural motion มาใช้เฉพาะส่วนของตัวละครที่ต้องตอบสนองต่อสิ่งที่ animator ไม่มีทางรู้ล่วงหน้าได้จริงๆ เท่านั้น — แล้วพอหยิบมาใช้แล้ว ก็ต้อง budget ค่า CPU เหมือนกับที่ budget ระบบต่อตัวละครอื่นๆ เพราะไม่เหมือน clip ตรงที่มันไม่ได้ถูกลงตลอดแค่เพราะไม่มีใครมอง (นี่แหละคือสิ่งที่การ scale แบบ LOD ซึ่งเป็นหัวข้อของ Exercise 3 มีไว้ทำ)
c / (2 * sqrt(k * m)); ต่ำกว่า 1 คือ underdamped (overshoot แล้วแกว่ง), เท่ากับ 1 พอดีคือ critically damped (เข้าใกล้ rest เร็วที่สุด ไม่มี overshoot), มากกว่า 1 คือ overdampedLateUpdate หรือ OnAnimatorIKLateUpdate ใช้สำหรับปรับ IK ของ humanoid เช่นการวางเท้า ต้องเปิด checkbox "IK Pass" บน Animator layer ก่อนถึงจะใช้ได้k = 8, damping c = 2, mass m = 1, และ dt = 0.25 rest position ของมันเพิ่งกระโดด ทำให้มันมี displacement x = 2 โดยมี v = 0 ใช้ semi-implicit Euler (สูตรจาก section 4: v += a*dt แล้วค่อย x += v*dt โดย a = (-k*x - c*v) / m) คำนวณ x กับ v ด้วยมือที่ step 0, 1, 2, และ 3 กระดูกข้ามผ่าน rest position ครั้งแรกที่ step ไหน?step 0: x=2.0000 v=0.0000
step 1: x=1.0000 v=-4.0000
step 2: x=0.0000 v=-4.0000
step 3: x=-0.5000 v=-2.0000
Step 0 ไป 1: a = -8*2 - 2*0 = -16 ดังนั้น v = 0 + (-16*0.25) = -4 และ x = 2 + (-4*0.25) = 1 Step 1 ไป 2: a = -8*1 - 2*(-4) = -8 + 8 = 0 (แรงดึงของ spring กับ damping หักล้างกันพอดีในชั่วขณะนั้น) ดังนั้น v ยังคงเป็น -4 เท่าเดิม และ x = 1 + (-4*0.25) = 0 Step 2 ไป 3: a = -8*0 - 2*(-4) = 8 ดังนั้น v = -4 + (8*0.25) = -2 และ x = 0 + (-2*0.25) = -0.5
กระดูกไปตกที่ rest position (x = 0) พอดีที่ step 2 แต่ยังเคลื่อนที่เร็วอยู่ (v = -4) มันเลยไม่หยุดตรงนั้น — พอถึง step 3 มันแกว่งเลยไปที่ x = -0.5 overshoot ตรงนี้คือ jiggle นั่นเอง: ถ้า damping ไม่พอที่จะหักล้าง velocity ได้ทันเวลา spring จะไหลเลยจุดกึ่งกลางไปนิดหน่อยเสมอ ก่อนที่แรง spring (ซึ่งตอนนี้กลับทิศแล้ว) จะเริ่มดึงมันกลับมา
100 องศา look-at cone limit อยู่ที่ maxConeAngle = 60 องศา หัวตอนนี้หันตรงไปตาม spine forward พอดี (0 องศาจากจุดกึ่งกลาง) และหมุนด้วย turnSpeed = 300 องศาต่อวินาที ใช้ dt = 0.1 (ตามธรรมเนียมของ section 2) trace มุมของหัวตั้งแต่เฟรม 0 ถึง 3 หัวไปถึง clamp หลังจากกี่เฟรม และหยุดที่มุมเท่าไหร่?clampedAngle = min(100, 60) = 60 -- target is well outside the cone, clamp applies
maxStep = 300 * 0.1 = 30 degrees per frame
frame 0: currentAngle = 0 (diff to clamp = 60)
frame 1: currentAngle = 30 (moved the full 30-degree step)
frame 2: currentAngle = 60 (moved the full 30-degree step, exactly reaches the clamp)
frame 3: currentAngle = 60 (diff = 0, nothing left to move)
หัวไปถึง clamp ที่เฟรม 2 แล้วหยุดที่ 60 องศาจาก spine forward พอดี — ขอบของ cone — ไม่ใช่ที่ 100 องศาจริงของเป้าหมาย มันจะค้างอยู่ที่ 60 องศาแบบนี้ต่อไป จนกว่าเป้าหมายจะขยับเข้าใกล้ cone มากขึ้น หรือตัวตัวละครหมุนจนพาเป้าหมายกลับเข้ามาอยู่ในขอบ cone อีกครั้ง
สามข้อไหนก็ได้จากนี้ (หรือแบบที่ปรับเปลี่ยนมาอย่างสมเหตุสมผล) ถือเป็นคำตอบที่ใช้ได้:
dt ที่สะสมไว้ให้สอดคล้องกัน) ความล่าช้าเล็กน้อยที่เพิ่มขึ้นบนผม background มองไม่เห็นหรอก แต่ CPU ที่ประหยัดได้มองเห็นแน่นอนweight เป็น 0 แล้วข้ามงาน RotateTowards/Slerp ไปเลยสำหรับตัวละครนั้นจุดร่วมของทั้งหมดนี้คือ: จุดขายของ procedural motion คือการตอบสนองอย่างถูกต้องต่อสิ่งที่ animator คาดเดาไม่ได้ แต่ background NPC ที่ผู้เล่นจะเหลือบมองแค่ครึ่งวินาทีไม่ต้องการความถูกต้องระดับนั้นหรอก — เอา CPU budget ไปใช้กับตัวละครที่ผู้เล่นกำลังมองอยู่จริงๆ ดีกว่า
Authored clip, โค้ด procedural, และ physics เต็มรูปแบบ ไม่ใช่ตัวเลือกสามอย่างที่แข่งกันเอง — มันคือเครื่องมือสามชิ้นที่อยู่บนตัวละครตัวเดียวกันพร้อมๆ กัน base walk cycle จากบท 9.1 กับ 9.2 แบกรับ motion ในชีวิตประจำวัน; look-at ที่ clamp ไว้กับเชนของ spring bone ขี่ทับอยู่บนนั้นทุกเฟรม คอยตอบสนองต่อสิ่งที่ไม่มี clip ไหนคาดเดาได้; แล้วพอตัวละครตาย physics ก็เข้ามาคุมเต็มตัวจนกว่า get-up ที่ blend ไว้จะส่งการควบคุมกลับไปให้ animation สิ่งที่บทนี้ตั้งใจสอนส่วนใหญ่คือการรู้ว่า motion แต่ละชิ้นต้องการเครื่องมือไหนจริงๆ — และแต่ละอย่างต้องแลกมาด้วยอะไรบ้าง เมื่อมีตัวละครสองร้อยตัวอยู่บนจอพร้อมกันแทนที่จะเป็นตัวเดียว