เรารู้อยู่แล้วว่า rig ทำงานยังไง: skeleton คือ tree ของ bone ที่แต่ละอันเป็นลูกของอันก่อนหน้า (มือห้อยจาก forearm ซึ่งห้อยจาก upper arm ซึ่งห้อยจากไหล่) และ animation clip ก็คือชุดของ rotation ที่บันทึกไว้ sample หลายครั้งต่อวินาที สำหรับทุก bone ใน tree นั้น พอเล่น clip กลับ ทั้ง skeleton ก็จะขยับตามที่ animator pose ไว้เป๊ะๆ ทีละเฟรม แค่นี้ก็พอสำหรับ animation ส่วนใหญ่แล้ว - เดิน, ยืนเฉยๆ, แกว่งดาบ - แต่มันจะพังทันทีที่โลกเกิดอะไรบางอย่างที่ animator เดาล่วงหน้าไม่ได้: เท้าต้องลงตรงก้อนหินที่ไม่ได้อยู่ใน level ต้นฉบับ, มือต้องจับลูกบิดประตูที่ความสูงขึ้นอยู่กับตำแหน่งจริงของผู้เล่น หรือตัวละครต้องมองตาม target ที่กำลังเคลื่อนที่อยู่ บทนี้จะพูดถึงสองเทคนิคที่แก้ปัญหานี้: inverse kinematics (IK) ซึ่งคำนวณมุมของ joint จาก target แทนที่จะทำกลับด้าน และ root motion ซึ่งปล่อยให้ animation เองเป็นคนตัดสินใจว่าตัวละครควรขยับไปไกลแค่ไหนในโลกจริงๆ
เราจะเริ่มจากความต่างระหว่าง forward และ inverse kinematics, สร้าง two-bone IK solver จริงๆ ตั้งแต่ต้นด้วยคณิตศาสตร์แบบ exact (ไอเดีย law of cosines เดียวกับที่เจอใน trigonometry พื้นฐาน แค่เอามาชี้ที่ไหล่กับข้อศอกแทนที่จะเป็นสามเหลี่ยม abstract) แล้วค่อยดูสองวิธีมาตรฐานสำหรับแก้ IK ของ chain ที่ยาวกว่านั้น หลังจากนั้นเราจะคุยเรื่องปัญหา IK สามแบบที่เกมมุมมองบุคคลที่สาม (third-person) แทบทุกเกมต้องแก้จริงในทางปฏิบัติ: เท้าบนพื้นที่ไม่เรียบ, การเล็งและมองไปที่ target, และการเอื้อมไปยังจุดคงที่อย่างกำแพง ครึ่งหลังของบทนี้เป็นเรื่อง root motion: clip เก็บข้อมูลการเคลื่อนที่ของตัวเองได้ยังไง, เมื่อไหร่ที่มันดีกว่าการขยับตัวละครด้วยโค้ด, และจะรวมสองอย่างนี้เข้าด้วยกันยังไงให้ root-motion clip ยังเคารพ collision อยู่
ทุกอย่างที่คุณทำกับ animation มาจนถึงตอนนี้ ถึงจะไม่เคยเรียกชื่อมันตรงๆ ก็คือ forward kinematics (FK) ทั้งนั้น: คุณ หรือ animator หรือ component Animator ที่ blend clip กันอยู่ จะ set rotation ของแต่ละ joint ตรงๆ แล้วตำแหน่งของทุก bone ที่อยู่ล่างลงไปใน hierarchy ก็จะออกมาเองโดยอัตโนมัติ เพราะตำแหน่งโลก (world position) ของ bone ลูกก็คือตำแหน่งโลกของ parent บวกกับ offset ที่ถูกหมุนแล้ว set rotation ของไหล่ก่อน แล้วก็ข้อศอก แล้วก็ข้อมือ มือก็จะไปจบอยู่ตรงไหนก็ตามที่ chain ของ rotation พวกนี้พาไป คุณไม่เคยเลือกตำแหน่งของมือตรงๆ เลย - มันเป็น ผลลัพธ์ ไม่ใช่ input
วิธีนี้ง่าย เร็ว และเป็นสิ่งที่ keyframed animation ให้เราฟรีๆ อยู่แล้ว: animator pose มุมไหล่กับข้อศอกทีละมุม แล้วมือก็จะไปอยู่ตรงจุดที่มือจริงๆ ที่ติดกับ bone พวกนั้นควรจะอยู่โดยธรรมชาติ ปัญหาจะโผล่มาทันทีที่คุณต้องการสิ่งที่กลับกัน: "ให้มืออยู่บนลูกบิดประตูตรงนั้นพอดี" หรือ "ให้เท้าอยู่บนก้อนหินตรงนั้นพอดี" ตำแหน่งของลูกบิดประตูไม่สนใจหรอกว่าคุณเลือกมุมอะไรมา - คุณต้องคิดย้อนกลับจาก "มือต้องอยู่ตรงไหน" ไปหา "มุมอะไรที่จะพามันไปอยู่ตรงนั้น" ปัญหาแบบย้อนกลับนี้แหละคือ inverse kinematics และต่างจาก FK ตรงที่มันไม่มีคำตอบที่ชัดเจนแค่คำตอบเดียว
Inverse kinematics (IK) กลับปัญหาด้านตรงข้าม: คุณเลือก target - ตำแหน่ง (บางทีก็มี direction ด้วย) ที่อยากให้ปลายสุดของ bone chain หรือที่เรียกว่า end effector ไปถึง - แล้ว solver จะคิดย้อนกลับเพื่อคำนวณมุมของ joint ที่จะพามันไปถึงจุดนั้น
ทำไมมันยากกว่า FK? เพราะโดยทั่วไปแล้วมันไม่ได้มีคำตอบเดียว แต่มีคำตอบเป็นจำนวนอนันต์ ลองยืนหน้าลูกบิดประตูแล้วเอื้อมมือไปจับดู: ไหล่ ข้อศอก และข้อมือของคุณสามารถลงตัวได้หลายชุดมุมที่ทำให้มือยังอยู่บนลูกบิดเหมือนเดิม - ยกข้อศอกขึ้นนิดหน่อย ไหล่ก็ชดเชยให้ มือก็ยังอยู่ที่เดิมไม่ขยับเลย พูดในเชิงคณิตศาสตร์ chain ที่มีหลาย joint จะมี degrees of freedom (จำนวนวิธีที่มันขยับได้แบบอิสระต่อกัน) มากกว่าตัวเลข 3 ตัว (x, y, z) ที่ต้องใช้อธิบายตำแหน่ง target ทำให้ระบบเป็น underdetermined - มี unknown มากกว่าสมการ rig ตัวละครแบบเต็มตัวอาจมี joint ที่หมุนได้เป็นสิบๆ ตัว ซึ่งทุกตัวพยายามตอบสนอง target แค่ไม่กี่จุด (เท้าสองข้าง มือสองข้าง ทิศทางการมอง) นี่แหละคือเหตุผลที่ระบบ IK ระดับ production มักจะซ้อน solver เฉพาะทางหลายตัวเข้าด้วยกัน แทนที่จะแก้ "ทั้ง skeleton" เป็นสมการยักษ์สมการเดียว
ข่าวดีคือ: กรณี IK ที่เจอบ่อยที่สุดในเกม - แขนขาที่มีสองท่อน อย่างแขน (ไหล่-ข้อศอก-มือ) หรือขา (สะโพก-เข่า-เท้า) ที่เอื้อมไปหา target เดียว - มีคำตอบแบบ closed-form (หมายถึงสูตรที่คำนวณได้ตรงๆ ไม่ใช่การประมาณทีละขั้น) แค่คำตอบเดียวเป๊ะๆ ทันทีที่คุณเพิ่มข้อมูลอีกชิ้นหนึ่งเข้าไปว่ามันควรจะงอไปทางไหน นั่นก็คือ two-bone IK และมันคุ้มค่าที่จะทำความเข้าใจให้ลึกซึ้งเพราะคณิตศาสตร์สามเหลี่ยมแบบเดียวกันนี้จะโผล่มาอีก (แค่แฝงตัวมาในรูปอื่น) แทบทุกเทคนิค IK อื่นในบทนี้
TwoBoneIKConstraint (from the Animation Rigging package) implements internally.ลองนึกภาพแขน: ไหล่ที่อยู่นิ่ง ข้อศอกที่งอได้ และมือที่คุณอยากวางไว้ที่จุด target บางจุด ไหล่จะไม่ขยับเลย (มันคือ root ของ chain เล็กๆ นี้) ความยาวของ bone ทั้งสอง - ไหล่ถึงข้อศอก และข้อศอกถึงมือ - ก็ไม่เปลี่ยนเช่นกัน เพราะ bone ไม่ยืดหด สิ่งเดียวที่เปลี่ยนทุกเฟรมคือตำแหน่ง target ซึ่งปกติแล้วก็คือจุดที่ผู้เล่นเล็งอยู่, raycast สำหรับวางเท้า, หรือวัตถุที่กำลังถูกจับอยู่ ณ ตอนนั้น
ข้อมูลคงที่สามชิ้นนี้ (ความยาว bone สองอัน บวกกับตำแหน่ง target ปัจจุบัน) จะอธิบายเป็นรูปสามเหลี่ยมได้เสมอ: ด้านหนึ่งคือ bone ท่อนบน ด้านหนึ่งคือ bone ท่อนล่าง และด้านที่สามคือระยะเส้นตรงจากไหล่ไปยัง target โดยตรง - ระยะที่ไม่มี bone จริงๆ พาดผ่านเลย แต่เป็นเส้นสมมติที่มีประโยชน์สำหรับการคำนวณ
พอรู้ความยาวทั้งสามด้านของสามเหลี่ยมแล้ว trigonometry ธรรมดาก็จะให้มุมทุกมุมข้างในมาเลย - ไม่ต้องเดา ไม่ต้อง iterate ไม่ต้องค้นหา นี่แหละคือสิ่งที่ทำให้ two-bone IK เป็น closed-form solution แทนที่จะเป็น iterative solver แบบใน section 7 กับ 8: มันคำนวณคำตอบที่แม่นยำตรงๆ จากสูตรทุกเฟรม ไม่มี loop และไม่มีการ "ค่อยๆ ใกล้ขึ้นทีละหลายรอบ"
D (ระยะจากไหล่ถึง target) เปลี่ยนทุกเฟรม แต่ L1 กับ L2 (ความยาว bone จริงๆ) ไม่ควรเปลี่ยน ให้คำนวณ L1 กับ L2 แค่ครั้งเดียวใน Awake() หรือ Start() จาก rest pose ของตัวละคร แล้ว cache เอาไว้ - ถ้าคำนวณใหม่ทุกเฟรมจาก pose ปัจจุบันที่งออยู่แล้ว solver จะได้ข้อมูลขยะทันทีที่แขนไม่ได้เหยียดตรงสนิทคุณเจอ law of cosines มาแล้วในบท trigonometry ในฐานะ Pythagorean theorem เวอร์ชันทั่วไปสำหรับสามเหลี่ยมที่ไม่ใช่สามเหลี่ยมมุมฉาก สำหรับสามเหลี่ยมที่มีด้าน a, b, c โดย C คือมุมภายในที่อยู่ตรงข้ามด้าน c:
เอามาใช้กับสามเหลี่ยมไหล่-ข้อศอก-target จาก section 3 โดยใช้ด้านทั้งสามที่รู้ค่าอยู่แล้วคือ L1, L2, D เราต้องการมุมสองมุม: elbow angle (มุมภายในที่ E ระหว่าง bone ท่อนบนกับท่อนล่าง - บอกเราว่าแขนงอมากแค่ไหน) และ shoulder angle (มุมภายในที่ S ระหว่าง bone ท่อนบนกับเส้นตรงไปยัง target - บอกเราว่าแขนท่อนบนแกว่งออกจากการชี้ตรงไปที่ target มากแค่ไหน)
แก้ law of cosines สำหรับแต่ละมุมโดยเลือกว่าด้านไหนจะเล่นบทบาทของ "c" (ด้านที่อยู่ตรงข้ามมุมที่เราต้องการ):
จากนั้น elbowAngle = acos(...) และ shoulderAngle = acos(...) (acos หรือ "arc-cosine" คือฟังก์ชันผกผันของ cosine - มันแปลงค่า cosine กลับมาเป็นมุม แนวคิดเดียวกับ atan2 ที่เจอในบท trigonometry แค่สร้างมาสำหรับ ratio คนละแบบ) มีสองเรื่องที่ควรสังเกตก่อนเริ่มเขียนโค้ด:
D เท่ากับ L1 + L2 พอดี (target อยู่ไกลเท่ากับระยะที่แขนเหยียดสุดจะเอื้อมถึง) elbow angle จะออกมาเป็น 180 องศา - แขนตรงสนิท ตรงกับสัญชาตญาณ: การเอื้อมไปหาอะไรบางอย่างที่อยู่พอดีกับระยะสุดปลายนิ้ว หมายความว่าไม่ต้องงอเลยD มากกว่า L1 + L2 แปลว่า target นั้น เอื้อมไม่ถึง - ไม่มีสามเหลี่ยมที่มีความยาวด้านแบบนั้นอยู่จริง และสูตรข้างบนจะไปเอา arc-cosine ของค่าที่อยู่นอกช่วง -1 ถึง 1 ซึ่งเป็นค่า undefined โค้ดจริงๆ จะต้อง clamp D ให้อยู่ในช่วงที่เอื้อมถึงได้ก่อนรันสูตร (แสดงใน section 5) เพื่อไม่ให้เกิดเหตุการณ์นั้นตัวอย่างการคำนวณจริง: แขนท่อนบนยาว L1 = 0.5, forearm ยาว L2 = 0.4, และ target อยู่ห่างจากไหล่พอดี D = 0.7 หน่วย
elbow angle ประมาณ 101.5 องศานั้นคือมุมภายในของข้อศอก ไม่ใช่ "งอไปมากแค่ไหนจากท่าตรง" - แขนที่ตรงสนิทคือ 180 องศา ดังนั้นแขนนี้จึงงออยู่ประมาณ 78.5 องศาจากท่าตรง จำความต่างตรงนี้ไว้ให้ดี เพราะมันมักจะทำให้คนสับสนตอนต่อ IK เข้ากับ rig ครั้งแรก เพราะ rig animation ส่วนใหญ่คิดในแง่ "งอไปเท่าไหร่จาก rest pose" ไม่ใช่ "มุมภายในของสามเหลี่ยม"
แทนที่จะทำงานกับมุมดิบๆ และแกนของ bone ที่แตกต่างกันไปในแต่ละ rig (ซึ่งเปลี่ยนไปตาม rig แต่ละตัวและชวนงงเร็วมาก) วิธีที่สะอาดที่สุดในการ implement คือคำนวณ ตำแหน่ง ตรงๆ เลย: ข้อศอกควรอยู่ตรงไหน และตำแหน่งมือที่ถูก clamp ตาม target ควรอยู่ตรงไหน จากนั้นก็สั่งให้ rig ไหนก็ได้ชี้ bone ของมันไปที่ตำแหน่งพวกนั้น ตาม convention ของ rig นั้นๆ
solver ยังต้องการข้อมูลอีกชิ้นหนึ่งที่คณิตศาสตร์สามเหลี่ยมล้วนๆ ให้มาไม่ได้: ข้อศอกควรงอไป ทิศทาง ไหน ด้านคงที่ทั้งสามด้านกำหนดรูปทรงของสามเหลี่ยมได้ก็จริง แต่สามเหลี่ยมนั้นยังหมุนได้อย่างอิสระรอบเส้นจากไหล่ไปยัง target เหมือนบานพับที่ไม่มีตัวหยุด - ลองนึกภาพข้อศอกของคุณแกว่งเป็นวงกลมเต็มรอบเส้นจากไหล่ไปมือ ในขณะที่มือยังอยู่นิ่งสนิท ข้อศอกและเข่าจริงๆ ไม่ทำแบบนั้น มันงอไปทางเดียวกันเสมอ ทิศทางนั้นถูกกำหนดโดย pole vector ซึ่งจะพูดถึงเต็มๆ ใน section 6 - ตอนนี้ให้มองมันเป็นตำแหน่ง hint ที่บอกว่า "งอมาทางนี้"
using UnityEngine;
public static class TwoBoneIK
{
// root: shoulder / hip position (fixed, chain does not move this joint)
// target: where the tip should end up
// pole: a hint position that decides which way the joint bends
// lenUpper/lenLower: the two REST-POSE bone lengths, cached once and never
// recomputed from the current (already-bent) pose
// mid/tip: OUTPUT - the solved elbow/knee and hand/foot positions
public static void Solve(
Vector3 root, Vector3 target, Vector3 pole,
float lenUpper, float lenLower,
out Vector3 mid, out Vector3 tip)
{
Vector3 toTarget = target - root;
float rawDist = toTarget.magnitude;
// clamp to the reachable range so acos() never receives a value
// outside -1..1 (see section 4's "unreachable target" note)
float maxReach = lenUpper + lenLower;
float minReach = Mathf.Abs(lenUpper - lenLower);
float dist = Mathf.Clamp(rawDist, minReach + 0.0001f, maxReach - 0.0001f);
Vector3 dirToTarget = toTarget.normalized;
// law of cosines: angle at the ROOT, between the upper bone and
// the straight line to the (clamped) target
float cosRootAngle = (lenUpper * lenUpper + dist * dist - lenLower * lenLower)
/ (2f * lenUpper * dist);
float rootAngle = Mathf.Acos(Mathf.Clamp(cosRootAngle, -1f, 1f));
// axis to bend around: perpendicular to the plane made by the
// target direction and the pole direction (see section 6)
Vector3 dirToPole = (pole - root).normalized;
Vector3 bendAxis = Vector3.Cross(dirToTarget, dirToPole);
if (bendAxis.sqrMagnitude < 0.000001f)
bendAxis = Vector3.Cross(dirToTarget, Vector3.up); // fallback, rare
bendAxis.Normalize();
// rotate the "straight at target" direction by rootAngle around
// bendAxis to get the direction from root to the elbow/knee
Quaternion rot = Quaternion.AngleAxis(rootAngle * Mathf.Rad2Deg, bendAxis);
Vector3 dirToMid = rot * dirToTarget;
mid = root + dirToMid * lenUpper;
tip = root + dirToTarget * dist; // exactly lenLower away from 'mid' by construction
}
}
บรรทัดสุดท้ายคือผลตอบแทนของการทำพีชคณิตใน section 4 ก่อน: เพราะ dist, lenUpper, และ lenLower สอดคล้องกับ law of cosines อยู่แล้วเป๊ะๆ จุด root + dirToTarget * dist จึงรับประกันได้ว่าห่างจาก mid เท่ากับ lenLower พอดี - สามเหลี่ยมปิดสนิททุกเฟรม ไม่มี drift และไม่ต้องมีขั้นตอนแก้ไขเพิ่มเติม
rootAngle ก่อนสร้าง rotation หรือสลับ argument สองตัวใน Vector3.Cross เครื่องหมายไหนถูกต้องขึ้นอยู่กับ convention ของแกนใน rig ของคุณเอง และการได้เครื่องหมายผิดตอนต่อ rig ใหม่ครั้งแรกเป็นเรื่องที่เจอกันบ่อยมาก - แก้แค่บรรทัดเดียวพอเจอแล้ว ไม่ได้แปลว่าคณิตศาสตร์ของคุณผิดcomponent แบบเรียบง่ายที่สุดที่ใช้สิ่งนี้ขับ transform จริงสองอัน:
using UnityEngine;
public class TwoBoneIKLimb : MonoBehaviour
{
public Transform root; // shoulder / hip - not moved by this script
public Transform mid; // elbow / knee
public Transform tip; // hand / foot
public Transform target;
public Transform pole;
float lenUpper, lenLower;
void Awake()
{
// cache the REST-POSE lengths once, before anything bends
lenUpper = Vector3.Distance(root.position, mid.position);
lenLower = Vector3.Distance(mid.position, tip.position);
}
void LateUpdate()
{
TwoBoneIK.Solve(root.position, target.position, pole.position,
lenUpper, lenLower, out Vector3 midPos, out Vector3 tipPos);
mid.position = midPos;
tip.position = tipPos;
// point each bone's local forward axis at its child - adjust
// to match whichever axis your rig treats as "along the bone"
root.rotation = Quaternion.LookRotation(midPos - root.position, pole.position - root.position);
mid.rotation = Quaternion.LookRotation(tipPos - midPos, pole.position - mid.position);
}
}
การเรียก LookRotation คู่สุดท้ายนั้นทำงานจริงๆ อยู่: การ set position ของ bone ไม่ได้ทำให้มัน orient ถูกต้องโดยอัตโนมัติ - bone ก็คือแท่งที่มีความยาวและแกน หลังจากย้าย mid กับ tip ไปตำแหน่งใหม่แล้ว root กับ mid ก็ต้องอัปเดต rotation ของมันด้วย เพื่อให้ mesh ที่มองเห็นได้ ซึ่ง skin เข้ากับ bone พวกนี้ ยืดตามจริงๆ แทนที่ตำแหน่ง bone จะขยับไปแต่ mesh ยังบิดอยู่แบบเดิม สังเกตว่าโค้ดนี้รันใน LateUpdate ไม่ใช่ Update: IK ต้องรันหลังจากที่ Animator posed skeleton เสร็จสำหรับเฟรมนั้นเสมอ ไม่งั้นผลลัพธ์ของ IK จะถูกเขียนทับทันทีโดย animation pose รอบถัดไป นี่คือเหตุผลเดียวกับที่ follow-camera รันใน LateUpdate ถ้าคุณจำได้จากบท camera
Update() แทนที่จะเป็น LateUpdate() Animator ของ Unity จะอัปเดต transform ตาม timing ภายในของมันเอง ซึ่งไม่รับประกันว่าจะรันก่อน Update() ของ script อื่นที่เขียนขึ้นมาเอง LateUpdate() รับประกันว่าจะรันหลัง Update() ทุกตัว และหลังจาก Animator apply pose ของเฟรมปัจจุบันเสร็จแล้ว - ลำดับแบบนี้แหละที่ IK ต้องการ: pose ก่อน แล้วค่อยงอทับลงไปsection 5 พูดถึงเรื่องนี้ไปแล้วคร่าวๆ: ความยาวด้านคงที่สามด้านกำหนดแค่ รูปทรง ของสามเหลี่ยม ไม่ได้กำหนด การหมุน รอบเส้นจาก root ไปยัง target pole vector (เรียกอีกอย่างว่า pole target หรือ elbow/knee hint) ก็คือตำแหน่งที่บอก solver ว่า joint ควรงอไปด้านไหนของเส้นนั้น solver จะเลือกทิศทางการงอที่ทำให้ข้อศอกหรือเข่าใกล้กับตำแหน่ง pole มากที่สุด
ในทางปฏิบัติ ตำแหน่ง pole แทบทุกครั้งจะเป็น offset คงที่ ห่างออกไปนิดหน่อยด้านหน้า (สำหรับเข่า) หรือด้านหลัง (สำหรับข้อศอก) ของตัวละคร ไม่ใช่อะไรที่ขยับทุกเฟรมเหมือน target หลัก การตั้งค่าทั่วไปคือ: parent empty GameObject เข้ากับสะโพกหรือ spine ของตัวละคร แล้ว offset มันไปด้านหน้าประมาณความกว้างของเข่า แล้วใช้เป็น pole สำหรับ IK ของขา - มันจะอยู่ประมาณตำแหน่งที่เข่าจริงๆ จะชี้ไปตามธรรมชาติ ไม่ว่าขาจะกำลังเอื้อมไปทิศทางไหนอยู่ก็ตาม
Two-bone IK มีสูตรที่แม่นยำเพราะมันมี unknown แค่สองตัว (สองมุม) และสามเหลี่ยมเดียว spine ที่มี vertebrae หกชิ้น หางที่มีสิบท่อน หรือ rig หนวดปลาหมึก จะมี joint มากกว่านั้นเยอะ และไม่มีสูตร closed-form เดียวสำหรับ chain ที่ยาวแบบไม่จำกัด แทนที่จะทำแบบนั้น โค้ดระดับ production จะใช้ iterative solver - solver ที่ไม่คำนวณคำตอบที่แม่นยำในขั้นตอนเดียว แต่จะขยับทีละนิดซ้ำๆ ให้ใกล้ target ขึ้นเรื่อยๆ แล้วหยุดเมื่อใกล้พอ (หรือหลังจากลองครบจำนวนรอบที่กำหนดไว้)
CCD (Cyclic Coordinate Descent) คือ iterative IK solver ที่ง่ายที่สุด และเป็นตัวที่ควรทำความเข้าใจก่อนเพราะไอเดียมันสรุปได้ในประโยคเดียว: เริ่มจาก joint ที่ใกล้ปลาย target ที่สุด แล้วไล่ย้อนกลับไปทาง root หมุนแต่ละ joint ด้วยขนาดที่ทำให้ปลาย chain แกว่งเข้าใกล้ target มากขึ้น แล้วทำซ้ำทั้ง pass นี้อีกสองสามรอบ
using UnityEngine;
public class CCDSolver : MonoBehaviour
{
public Transform[] joints; // joints[0] = root ... joints[last] = tip
public int iterations = 10;
public float tolerance = 0.01f;
public void Solve(Vector3 target)
{
Transform tip = joints[joints.Length - 1];
for (int pass = 0; pass < iterations; pass++)
{
if (Vector3.Distance(tip.position, target) < tolerance)
return; // close enough, stop early - no need to burn more passes
for (int i = joints.Length - 2; i >= 0; i--)
{
Transform joint = joints[i];
Vector3 toTip = tip.position - joint.position;
Vector3 toTarget = target - joint.position;
// the smallest rotation that swings 'toTip' onto 'toTarget'
Quaternion delta = Quaternion.FromToRotation(toTip, toTarget);
joint.rotation = delta * joint.rotation;
}
}
}
}
Quaternion.FromToRotation(a, b) คืนค่า rotation ที่เอาทิศทาง a ไปชี้ที่ทิศทาง b พอดี - การ apply rotation นี้กับ joint ปัจจุบันจะทำให้ chain ที่เหลือทั้งหมด (ทุกอย่างที่อยู่ข้างล่างมัน รวมถึง tip) แกว่งเข้าหา target ทันที CCD ราคาถูก implement ง่าย และรองรับ chain ความยาวเท่าไหร่ก็ได้หรือ joint-angle limit ที่คุณติดเพิ่มเข้าไป แต่มันมีจุดแปลกที่รู้กันอยู่: เพราะ joint ที่อยู่ใกล้ tip จะถูกปรับทุกๆ pass ในขณะที่ joint ที่อยู่ใกล้ root มีสิทธิ์มีเสียงแค่นิดเดียว chain ของ CCD จึงมองเห็นได้ว่า "ม้วนงอ" อยู่ใกล้ end effector แทนที่จะงอเรียบตลอดความยาวทั้งหมด โดยเฉพาะเมื่อจำนวน iteration น้อย
FABRIK (Forward And Backward Reaching Inverse Kinematics) แก้ปัญหาการม้วนงอของ CCD ด้วยไอเดียที่ต่างออกไปเลย: แทนที่จะหมุน joint มันขยับ ตำแหน่ง ตรงๆ ไปมาตาม chain แล้วค่อยคำนวณ rotation กลับจากตำแหน่งสุดท้ายทีเดียวตอนจบ (เหมือนกับที่ two-bone solver ใน section 5 ทำเป๊ะ)
แต่ละ pass มีสองครึ่ง backward pass เริ่มด้วยการ snap tip ไปที่ target ตรงๆ เลย - โกงหน่อยๆ เพราะแบบนี้ทำให้ความยาว bone ทุกอันใน chain พังทันที - แล้วเดินย้อนกลับไปทาง root แก้ความยาว bone ทีละอัน โดยรักษาให้แต่ละ joint อยู่ห่างจาก joint ถัดไปในระยะที่ถูกต้องเสมอ จากนั้น forward pass ก็ทำแบบเดียวกันในทิศทางกลับกัน: snap root กลับไปตำแหน่งจริงที่มันคงที่ แล้วเดินไปทาง tip แก้ความยาว bone อีกครั้ง เพราะทั้งสอง pass ย้ายจุดไปอยู่บนเส้นตรงไปยังเพื่อนบ้านของมันเสมอ ที่ระยะห่างซึ่งถูกต้องอยู่แล้ว ความยาว bone จึงไม่มีทางผิดเพี้ยนเลยเมื่อ pass หนึ่งจบลง
using UnityEngine;
public class FabrikSolver : MonoBehaviour
{
public Vector3[] points; // points[0] = root ... points[last] = tip
public float[] boneLengths; // boneLengths[i] = distance(points[i], points[i+1])
public int iterations = 10;
public float tolerance = 0.01f;
public void Solve(Vector3 target)
{
Vector3 rootPos = points[0];
float totalLength = 0f;
for (int i = 0; i < boneLengths.Length; i++) totalLength += boneLengths[i];
// target out of reach: just stretch the whole chain straight at it
if (Vector3.Distance(rootPos, target) > totalLength)
{
for (int i = 0; i < points.Length - 1; i++)
{
float r = Vector3.Distance(target, points[i]);
float lambda = boneLengths[i] / r;
points[i + 1] = Vector3.Lerp(points[i], target, lambda);
}
return;
}
for (int pass = 0; pass < iterations; pass++)
{
if (Vector3.Distance(points[points.Length - 1], target) < tolerance)
break;
// backward pass: target -> root
points[points.Length - 1] = target;
for (int i = points.Length - 2; i >= 0; i--)
{
float r = Vector3.Distance(points[i + 1], points[i]);
float lambda = boneLengths[i] / r;
points[i] = Vector3.Lerp(points[i + 1], points[i], lambda);
}
// forward pass: root -> tip
points[0] = rootPos;
for (int i = 0; i < points.Length - 1; i++)
{
float r = Vector3.Distance(points[i + 1], points[i]);
float lambda = boneLengths[i] / r;
points[i + 1] = Vector3.Lerp(points[i], points[i + 1], lambda);
}
}
}
}
Vector3.Lerp(a, b, lambda) ตรงนี้ทำหน้าที่ "ขยับจาก a ไป b บางส่วน" แบบเดียวกับที่คุณใช้มันมาตลอด แค่เลือก lambda ให้จุดกึ่งกลางนั้นลงเอยที่ระยะห่าง boneLengths[i] จากเพื่อนบ้านพอดี แทนที่จะเป็นสัดส่วน blend แบบสุ่มๆ FABRIK มักจะ converge ด้วยจำนวน pass ที่น้อยกว่า CCD อย่างเห็นได้ชัด และงอเรียบตลอด chain แทนที่จะม้วนงออยู่ใกล้ tip นี่คือเหตุผลที่ engine สมัยใหม่ส่วนใหญ่ รวมถึง Animation Rigging package ของ Unity เองสำหรับ constraint "Chain IK" แบบหลาย bone ก็ใช้ solver สไตล์ FABRIK สำหรับ chain ที่ยาวกว่าสอง bone
นี่คือปัญหาในภาพเดียว: walk animation ถูกสร้างมาโดยสมมติว่าพื้นเรียบ ดังนั้นทุกเฟรมมันจะวางแต่ละเท้าไว้ที่ความสูงหนึ่งเทียบกับสะโพก พอเอาตัวละครตัวเดียวกันไปวางบนทางลาดหรือบันได animation ก็ไม่รู้เลยว่าพื้นเปลี่ยนไป - เท้าจะลอยอยู่เหนือพื้นผิวหรือจมลงไปในพื้น ขึ้นอยู่กับว่าพื้นจริงเบี่ยงจากความเรียบไปทางไหน
วิธีแก้ตามมาจาก diagram ตรงๆ เลย: สำหรับแต่ละเท้า ยิง raycast ตรงลงมาจากจุดที่อยู่สูงกว่าตำแหน่งที่ animation คิดว่าข้อเท้าควรอยู่นิดหน่อย หาว่ามันชนพื้นจริงๆ ตรงไหน แล้วเอาจุดที่ชนนั้นไปเป็น IK target ของเท้า แทนที่จะเชื่อความสูงของ animation เอง ขาท่อนบนกับท่อนล่างก็จะงอตามที่จำเป็นพอดี เพื่อเอื้อมไปถึงจุดที่แก้ไขแล้วนั้น - ซึ่งก็คือสิ่งที่ two-bone solver จาก section 5 คำนวณให้เป๊ะๆ
Humanoid rig ของ Unity มีระบบ two-bone IK ในตัวสำหรับเรื่องนี้โดยเฉพาะ ขับด้วย Animator.SetIKPosition และเพื่อนๆ ของมัน ภายใน callback พิเศษที่ชื่อ OnAnimatorIK ซึ่งจะทำงานก็ต่อเมื่อ layer ของ Animator Controller เปิด checkbox "IK Pass" ไว้เท่านั้น:
using UnityEngine;
public class FootIK : MonoBehaviour
{
public Animator animator;
public LayerMask groundMask;
public float raycastHeight = 0.5f; // start the ray this far above the foot
public float raycastDistance = 1.5f;
public float footSoleOffset = 0.05f; // keeps the sole from clipping into the ground
void OnAnimatorIK(int layerIndex)
{
PlaceFoot(AvatarIKGoal.LeftFoot);
PlaceFoot(AvatarIKGoal.RightFoot);
}
void PlaceFoot(AvatarIKGoal foot)
{
Vector3 animatedPos = animator.GetIKPosition(foot);
Vector3 rayOrigin = animatedPos + Vector3.up * raycastHeight;
if (Physics.Raycast(rayOrigin, Vector3.down, out RaycastHit hit, raycastDistance, groundMask))
{
Vector3 targetPos = hit.point + Vector3.up * footSoleOffset;
Quaternion tiltToSurface = Quaternion.FromToRotation(Vector3.up, hit.normal);
animator.SetIKPositionWeight(foot, 1f);
animator.SetIKRotationWeight(foot, 1f);
animator.SetIKPosition(foot, targetPos);
animator.SetIKRotation(foot, tiltToSurface * animator.GetIKRotation(foot));
}
else
{
// no ground found under this foot (e.g. stepping off a ledge):
// fade IK out and let the plain animation play
animator.SetIKPositionWeight(foot, 0f);
animator.SetIKRotationWeight(foot, 0f);
}
}
}
มีสองส่วนเฉพาะของ Unity ที่ทำงานจริงตรงนี้ animator.GetIKPosition(foot) ถามว่า "clip ที่กำลังเล่นอยู่ตอนนี้อยากให้เท้าอยู่ตรงไหน ก่อนที่ IK จะแตะต้องมันเลย?" - นั่นคือจุดเริ่มต้น raycast ตามธรรมชาติ เพราะมันคำนึงถึง pose ปัจจุบันอยู่แล้ว (กลางก้าว, ถ่ายน้ำหนัก, อะไรก็ตาม) ที่ clip กำลังอยู่ ณ ตอนนั้น SetIKPositionWeight กับ SetIKRotationWeight (0 ถึง 1) blend ระหว่าง "ไม่สน IK เลย เชื่อ animation" (0) กับ "เชื่อตำแหน่ง IK เต็มที่" (1) - การค่อยๆ fade แบบนี้แทนที่จะกระโดดจาก 0 ไป 1 ทันที คือสิ่งที่หยุดไม่ให้เท้ากระตุกให้เห็นชัดๆ ทันทีที่มันก้าวออกนอกขอบพื้นที่ตรวจเจอ
มีอีกรายละเอียดหนึ่งที่สำคัญสำหรับอะไรก็ตามที่ชันกว่าทางลาดเบาๆ: ถ้าเท้าข้างหนึ่งต้องเอื้อมลงไปไกลกว่าที่ animation คาดไว้อย่างเห็นได้ชัด (เช่น ก้าวลงบันไดสูง) ขาอาจหมดความยาวก่อนที่เท้าจะไปถึง raycast target - two-bone solver ใน section 5 จะ clamp ให้อยู่ในช่วงที่เอื้อมถึงได้อยู่แล้ว ดังนั้นขาก็แค่เหยียดตรงแล้วหยุด และเท้าก็จะลอยอยู่เหนือ target แทนที่จะเอื้อมถึง วิธีแก้มาตรฐานคือลดสะโพกทั้งหมดลงประมาณค่าความขาดแคลนที่มากที่สุดระหว่างความสูงเท้าที่ animate ไว้กับ IK target จริง เพื่อให้ทุกขามีระยะเอื้อมเพิ่มพอ:
float ComputeHipDrop(Animator animator, float leftTargetY, float rightTargetY)
{
float leftDrop = Mathf.Max(0f, animator.GetIKPosition(AvatarIKGoal.LeftFoot).y - leftTargetY);
float rightDrop = Mathf.Max(0f, animator.GetIKPosition(AvatarIKGoal.RightFoot).y - rightTargetY);
return Mathf.Max(leftDrop, rightDrop);
}
// inside OnAnimatorIK, after computing both raycast targets' Y heights:
float hipDrop = ComputeHipDrop(animator, leftTargetY, rightTargetY);
animator.bodyPosition -= Vector3.up * hipDrop;
นี่คือเหตุผลแบบเดียวกับ leg-reach clamp จาก section 5 เป๊ะ แค่เอามาใช้สูงขึ้นอีกระดับหนึ่ง: แทนที่จะปล่อยให้ขาที่เอื้อมเกินไปเหยียดตรงสนิทแล้วเห็นชัดว่า "หมดแรงเอื้อม" คุณขยับทั้งร่างกายลงมานิดหน่อย เพื่อไม่ให้ขาข้างไหนต้องเอื้อมเกินความยาวธรรมชาติของมันตั้งแต่แรก
เทคนิคเดียวกัน - เอา pose ที่ animate ไว้มาแล้วงอส่วนเล็กๆ ของมันเข้าหา target ด้วย IK - ใช้ได้ดีพอๆ กันกับการมองอะไรบางอย่าง เหมือนกับการวางเท้า ตัวละครที่ตาต้องมองตามไอเทมใกล้ๆ หรือครึ่งบนของร่างกายต้องเล็งอาวุธไปที่ crosshair ในขณะที่ขายังวิ่ง locomotion animation ปกติอยู่ข้างล่าง ก็คือกำลังแก้ปัญหาประเภทเดียวกันเป๊ะ
Humanoid rig ของ Unity มาพร้อม look-at IK สำเร็จรูปสำหรับเคส "มองตามจุดด้วยหัว/ตา/spine" โดยเฉพาะ:
using UnityEngine;
public class LookAtIK : MonoBehaviour
{
public Animator animator;
public Transform lookTarget;
[Range(0f, 1f)] public float lookWeight = 0.7f;
void OnAnimatorIK(int layerIndex)
{
if (lookTarget == null)
{
animator.SetLookAtWeight(0f);
return;
}
animator.SetLookAtWeight(lookWeight);
animator.SetLookAtPosition(lookTarget.position);
}
}
ภายในระบบนี้จะกระจาย look rotation ไปยังหลาย bone พร้อมกัน (ตา, หัว, และ spine นิดหน่อย) แทนที่จะ snap แค่หัวให้หันไปทาง target ตรงๆ นี่คือเหตุผลที่ lookWeight ต่ำกว่า 1 ก็ยังดูเป็นธรรมชาติ - คนจริงๆ ที่เหลือบมองอะไรบางอย่างแทบไม่หันแค่หัวไปสุดมุมเลย พวกเขาหมุนครึ่งบนของร่างกายไปด้วยนิดหน่อย
การเล็งอาวุธต้องการไอเดียเดียวกันแต่ปกติไม่มี helper สำเร็จรูปให้ เพราะสัดส่วนของการเล็งที่ควรมาจาก spine เทียบกับแขนนั้นขึ้นอยู่กับแต่ละเกมมากๆ วิธีที่ใช้กันทั่วไปคือ: คำนวณ rotation ที่จะเล็งตรง target แบบสมบูรณ์ แล้ว Slerp ไปแค่บางส่วน แล้ว apply กับ bone ของ spine หรือหน้าอก ซ้อนทับบนสิ่งที่ locomotion animation กำลังทำกับ bone นั้นอยู่แล้ว:
public Transform spineBone;
public Transform aimTarget;
[Range(0f, 1f)] public float aimWeight = 0.6f;
void LateUpdate()
{
Vector3 aimDir = (aimTarget.position - spineBone.position).normalized;
Quaternion fullAim = Quaternion.LookRotation(aimDir, Vector3.up);
// blend between "whatever locomotion already posed this bone to"
// and "fully aimed", instead of overwriting it outright
spineBone.rotation = Quaternion.Slerp(spineBone.rotation, fullAim, aimWeight);
}
คุณเจอ Slerp มาแล้วในบท rotation ในฐานะวิธีที่ถูกต้องในการ blend ระหว่างสอง orientation ตาม arc ที่สั้นที่สุด ตรงนี้มันทำหน้าที่สองอย่างพร้อมกันคือเป็น weight-blend ระหว่าง "animation ล้วนๆ" กับ "aim ล้วนๆ" งานเดียวกับที่ lookWeight และ IK position weight ทำไปก่อนหน้านี้ในบทนี้ การใช้งาน IK จริงแทบทุกแบบใน section นี้สรุปลงมาเป็น pattern เดียวกัน: คำนวณผล IK ที่แก้สมบูรณ์แล้ว จากนั้น blend กับ animation ที่มีอยู่เดิมด้วย weight 0-ถึง-1 บางค่า แทนที่จะแทนที่มันไปเลย
เคสสุดท้ายที่เจอบ่อยคือมือของตัวละครต้องไปลงที่จุดคงที่จุดหนึ่ง - กดปุ่ม, วางมือแนบกำแพงตอนเดินผ่าน, จับขั้นบันไดลิง นี่ไม่ใช่เทคนิคใหม่เลย มันคือ two-bone solver จาก section 5 ตรงๆ โดยตั้ง target เป็นจุดคงที่นั้น (หาได้ตามที่เหมาะกับสถานการณ์ - marker ที่ designer วางไว้ หรือ raycast สั้นๆ จากมือไปข้างหน้าเข้าหากำแพง) และ fade weight ของมันเข้ามาเมื่อตัวละครเข้าใกล้พอที่การเอื้อมจะดูเป็นธรรมชาติ แล้ว fade ออกอีกครั้งเมื่อเดินจากไป
ทุกอย่างที่ผ่านมาในบทนี้คืองอ animation ที่กำลังเล่นอยู่ให้ไปถึง target root motion เป็นเรื่องคนละแบบไปเลย: มันเกี่ยวกับตัวละครทั้งตัวเคลื่อนที่ไปในโลกยังไง ไม่ใช่แขนขาส่วนหนึ่งงออยู่ใน pose นั้นยังไง
ตอนที่ animator สร้าง run cycle สะโพก (ซึ่งคือ root bone ของ animation ปกติจะตั้งชื่อตรงๆ ว่า "Hips" หรือ "Pelvis") ไม่ได้แค่โยกขึ้นลงอยู่กับที่ - มันยังเคลื่อนที่ไปข้างหน้าด้วย ตรงตามระยะที่สะโพกของนักวิ่งจริงๆ เคลื่อนที่ระหว่างก้าวนั้น การเคลื่อนที่ไปข้างหน้านั้นแหละคือ root motion: ข้อมูลการเคลื่อนที่ที่ bake เข้าไปใน animation clip เองตรงๆ ทีละเฟรม ควบคู่ไปกับ joint rotation ปกติทั้งหมด
Unity จะอ่านค่า delta นั้นให้อัตโนมัติทุกเฟรม ผ่าน property สองตัวของ Animator คือ deltaPosition และ deltaRotation - "root เคลื่อนที่ไปไกลแค่ไหน และหมุนไปเท่าไหร่ นับจากเฟรมที่แล้ว ตามที่ clip บอก" ส่วนว่า delta นั้นจะถูก apply กับ transform จริงๆ ของ GameObject หรือไม่ ถูกควบคุมด้วย flag ตัวเดียว:
using UnityEngine;
[RequireComponent(typeof(Animator))]
public class RootMotionMover : MonoBehaviour
{
Animator animator;
void Awake()
{
animator = GetComponent<Animator>();
animator.applyRootMotion = true;
}
// Unity calls this automatically, once per frame, right after the
// Animator finishes evaluating the current pose - but ONLY while
// applyRootMotion is true. This is where root motion actually applies.
void OnAnimatorMove()
{
// this is literally what Unity already does for you by default
// when applyRootMotion = true and you do NOT override OnAnimatorMove:
transform.position += animator.deltaPosition;
transform.rotation *= animator.deltaRotation;
}
}
เมื่อ applyRootMotion = true และไม่มี OnAnimatorMove ที่เขียนเอง Unity จะทำ assignment แบบนั้นให้อัตโนมัติเป๊ะๆ ทันทีที่คุณเขียน OnAnimatorMove ของตัวเอง คุณจะเข้ามาควบคุมแทน - Unity ยังคงคำนวณ deltaPosition/deltaRotation จาก clip ให้ทุกเฟรมเหมือนเดิม แต่ตอนนี้ขึ้นอยู่กับโค้ดของคุณทั้งหมดแล้วว่าจะทำอะไรกับมันบ้าง (ถ้าจะทำ) hook ตัวนี้แหละที่ทำให้ root motion ทำงานร่วมกับ collision ได้ ซึ่งจะพูดถึงใน section 12
deltaPosition แทน ซึ่งคือ root motion จริงๆ) clip ที่สร้างมาสำหรับ root motion ใน Maya หรือ Blender ต้องตั้งค่านี้ให้ตรงกันตอน import ไม่งั้นตัวละครจะไม่ขยับเลยแม้จะมี applyRootMotion = true ก็ตามอีกทางเลือกหนึ่ง ซึ่งคุณใช้มาตลอดในบท character controller โดยไม่ต้องพึ่งเรื่องพวกนี้เลย: ให้ animation clip อยู่ in place (ไม่มี net translation ถูก bake อยู่ใน hip curve - การตั้งค่า import จาก tip ข้างบนตั้งเป็น "Bake Into Pose") แล้วขับตัวละครด้วยโค้ดล้วนๆ โดยใช้ค่า speed ที่เลือกเอง ป้อนเข้า controller.Move() หรือ rb.velocity เหมือนที่คุณรู้วิธีทำมาแล้ว
ไม่มีคอลัมน์ไหน "ถูกต้อง" โดยทั่วไปหรอก เหมือนกับที่ Rigidbody กับ CharacterController ไม่ใช่ทางเลือกแบบถูก-ผิดในบท character controller เกม third-person action งบสูง (Souls-like, action RPG สไตล์ HoYoverse ส่วนใหญ่) จะพึ่ง root motion หนักมาก เพราะมันทำให้ทุกการโจมตี, การหลบ, และการหมุนตัวหนักๆ รู้สึกมีน้ำหนักตรงตามที่ animator ตั้งใจไว้ ทุกเฟรม ส่วนแนวเกมที่เร็วและ responsive - platformer, arena shooter, อะไรก็ตามที่ผู้เล่นต้องรู้สึกถึง input latency ในระดับเฟรมเดียว - มักจะเอียงไปทางขับด้วยโค้ด เพราะคุณสมบัติ "รอ clip" ของ root motion นั้นขัดกับการควบคุมแบบเสี้ยววินาที
เวอร์ชันใน section 11 มีปัญหาจริงๆ อยู่: transform.position += animator.deltaPosition ขยับตัวละครเป็นเส้นตรงโดยไม่สนใจกำแพง พื้น หรือทางลาดเลย เพราะมันไม่เคยผ่านระบบ collision ใดๆ เลย วิธีแก้ก็คือ hook OnAnimatorMove ที่มีไว้เพื่อสิ่งนี้โดยเฉพาะ: แทนที่จะแตะ transform ตรงๆ ให้ส่ง delta ของ clip เองไปให้ CharacterController ซึ่งเป็น component ตัวเดียวกับในบท character controller เพื่อให้มันได้ collision handling แบบเดียวกับการขยับด้วยโค้ดทั่วไป:
using UnityEngine;
[RequireComponent(typeof(Animator))]
[RequireComponent(typeof(CharacterController))]
public class RootMotionController : MonoBehaviour
{
Animator animator;
CharacterController controller;
float verticalVelocity;
void Awake()
{
animator = GetComponent<Animator>();
controller = GetComponent<CharacterController>();
animator.applyRootMotion = true;
}
void OnAnimatorMove()
{
Vector3 motion = animator.deltaPosition;
// gravity is still ours to add - root motion clips normally only
// carry horizontal locomotion, not falling
if (controller.isGrounded && verticalVelocity < 0f)
verticalVelocity = -2f;
verticalVelocity += Physics.gravity.y * Time.deltaTime;
motion.y = verticalVelocity * Time.deltaTime;
controller.Move(motion); // now respects walls, slopes, steps
transform.rotation *= animator.deltaRotation;
}
}
สังเกตว่านี่ไม่ใช่ตัวเลือกแบบ either/or กับทุกอย่างที่พูดไปก่อนหน้านี้ในบท: root motion ตัดสินใจว่าตัวละครจะเคลื่อนที่ ไปไกลแค่ไหน ในแต่ละเฟรม ส่วนเทคนิค IK จาก section 5 ถึง 10 ก็ยังรันต่อทีหลัง (ใน LateUpdate เหมือนเดิม) เพื่อแก้เท้าและ aim ทับบนตำแหน่งที่ root motion เพิ่งพาตัวละครไป walk cycle แบบ root-motion ผสมกับ foot IK เป็นเรื่องที่เจอบ่อยมาก - root motion จัดการจังหวะและน้ำหนักโดยรวมให้ถูกต้อง แล้ว foot IK ก็ค่อยขยับแต่ละเท้าไม่กี่เซนติเมตรสุดท้ายให้ไปอยู่บนพื้นจริงๆ ตรงจุดนั้นพอดี ซึ่งเป็นสิ่งที่ root motion ที่สร้างไว้ล่วงหน้าไม่ว่าจะมากแค่ไหนก็ทำนายไม่ได้
animator.deltaPosition สำหรับการเคลื่อนที่ไปข้างหน้า แต่ก็ยังขับ speed parameter ของ Animator ด้วยค่าที่ตั้งเองแยกต่างหาก ซึ่งใช้แค่เลือก weight ของ blend tree) ถ้า playback speed ที่ blend tree เลือกไม่ตรงกับระยะที่ root curve ของ clip เองเดินทางจริงต่อวินาที ขาของตัวละครกับความเร็วการเคลื่อนที่จริงจะเพี้ยนออกจากกัน แล้วคุณก็จะได้เท้าไถลให้เห็นอีกครั้ง - ปัญหาเดียวกับที่ root motion มีไว้เพื่อป้องกันเลย ให้เก็บค่าเดียวเป็น single source of truth (ปกติคือ speed จาก input ผู้เล่นที่ป้อนเข้า blend tree) แล้วปล่อยให้ระยะที่ bake ไว้ของ root motion เองตามสิ่งที่ blend clip ที่ได้ผลิตออกมา แทนที่จะไปสู้กับมันด้วยตัวเลข speed อีกตัวที่แยกกันอิสระAnimator.deltaPosition/deltaRotationCharacterController) แทนที่จะปล่อยให้มันขยับ transform ตรงๆL1 = 0.6, lower bone ยาว L2 = 0.5, และ foot IK target ของเฟรมนี้อยู่ห่างจากสะโพกพอดี D = 0.9 หน่วย ใช้สูตร law-of-cosines สองสูตรจาก section 4 คำนวณ cos(elbowAngle) และ elbowAngle เป็นองศา จากนั้นคำนวณ cos(rootAngle) (มุมฝั่งสะโพก) และ rootAngle เป็นองศาcos(elbowAngle) = (L1^2 + L2^2 - D^2) / (2*L1*L2)
= (0.36 + 0.25 - 0.81) / (2*0.6*0.5)
= -0.20 / 0.6 = -0.3333
elbowAngle = acos(-0.3333) = 109.5 degrees
cos(rootAngle) = (L1^2 + D^2 - L2^2) / (2*L1*D)
= (0.36 + 0.81 - 0.25) / (2*0.6*0.9)
= 0.92 / 1.08 = 0.8519
rootAngle = acos(0.8519) = 31.6 degrees
ตรวจสอบความสมเหตุสมผล: มุมภายในทั้งสามของสามเหลี่ยมใดๆ รวมกันได้ 180 องศา มุมที่สาม (ที่จุด target) คือ 180 - 109.5 - 31.6 = 38.9 องศา - เป็นมุมที่ใช้ได้และไม่ติดลบ ดังนั้นตัวเลขพวกนี้จึงอธิบาย pose ที่เอื้อมถึงได้จริง
L1 = 0.6, L2 = 0.5) แต่คราวนี้ระยะ target ดิบๆ คือ D = 1.4 หน่วยจากสะโพก target นี้เอื้อมถึงไหม? ถ้าไม่ถึง solver จาก section 5 จะใช้ระยะที่ clamp แล้วเท่าไหร่ และ elbowAngle กับ rootAngle จะออกมาเท่าไหร่ที่ระยะ clamp นั้น? อธิบายเป็นประโยคเดียวว่าขาจะมองเห็นเป็นยังไงในกรณีนี้ระยะเอื้อมสูงสุดคือ L1 + L2 = 1.1 ระยะ target ดิบๆ ที่ 1.4 เกินกว่านั้นไปเยอะ ดังนั้น target จึงเอื้อมไม่ถึง - solver จะ clamp D ลงมาเหลือ (โดยพื้นฐานแล้ว) maxReach = 1.1 (โค้ดใน section 5 จะลบ epsilon เล็กๆ ออกเพื่อไม่ให้ acos รับค่า -1 หรือ 1 ตรงเป๊ะ แต่การใช้ 1.1 ตรงๆ ก็ทำให้คำนวณด้วยมือได้สะอาดกว่า):
cos(elbowAngle) = (0.36 + 0.25 - 1.21) / 0.6 = -0.6/0.6 = -1.0
elbowAngle = acos(-1.0) = 180 degrees
cos(rootAngle) = (0.36 + 1.21 - 0.25) / (2*0.6*1.1) = 1.32/1.32 = 1.0
rootAngle = acos(1.0) = 0 degrees
elbow angle 180 องศาหมายความว่าขาตรงสนิท และ root angle 0 องศาหมายความว่าสะโพกชี้ตรงไปที่ target (ที่ clamp แล้ว) โดยไม่มีการแกว่งเลย มองเห็นเป็น: ขาเหยียดสุดและชี้ตรงไปที่ target แต่เท้าจะหยุดก่อนที่จะไปถึงจริงๆ เพราะ target จริงอยู่ห่างออกไป 1.4 หน่วย แต่ขาสามารถเอื้อมได้ทางกายภาพแค่ 1.1
z) ในแต่ละช่วง 0.2 วินาทีของ loop ที่ยาว 1.2 วินาที ดังนี้: 0.16, 0.28, 0.32, 0.32, 0.24, 0.12 เมตร (a) ระยะทางรวมที่เดินทางตลอดหนึ่ง loop เต็มคือเท่าไหร่? (b) ตัวละครเวอร์ชันขับด้วยโค้ดแบบ in-place ต้องใช้ speed คงที่เท่าไหร่ เพื่อให้ครอบคลุมระยะทางรวมเท่ากันภายใน loop 1.2 วินาทีเดียวกัน? (c) แม้จะจับคู่ speed นั้นได้ตรงเป๊ะ ทำไมผู้เล่นที่สังเกตดีๆ ถึงยังรู้สึกถึงความต่างระหว่างเวอร์ชัน root-motion กับเวอร์ชันขับด้วยโค้ดที่ speed คงที่ได้อยู่?(a) ระยะทางรวมคือผลรวมของทั้งหกช่วง: 0.16 + 0.28 + 0.32 + 0.32 + 0.24 + 0.12 = 1.44 เมตรต่อ loop
(b) Speed คงที่ = ระยะทางรวม / เวลารวม = 1.44 / 1.2 = 1.2 เมตรต่อวินาที
(c) clip แบบ root-motion ไม่ได้เคลื่อนที่ด้วยความเร็วคงที่ 1.2 m/s ตลอด loop - มันเร่งความเร็วขึ้นตรงกลางของก้าว (สูงสุด 0.32m ต่อช่วง 0.2s หรือ 1.6 m/s) และช้าลงใกล้ๆ contact pose ตอนต้นกับตอนท้าย (0.16m กับ 0.12m ต่อช่วง หรือ 0.8 กับ 0.6 m/s) ตัวขับแบบ in-place ที่ speed คงที่จะให้ระยะทางรวมต่อ loop ที่ถูกต้อง แต่จะสูญเสียความแปรผันของ speed ภายในก้าวนั้นไปทั้งหมด ทำให้ขาของตัวละครมองเห็นได้ว่าดันตัวออกและลงพื้นในจังหวะที่ไม่ตรงกับความเร็วจริงที่ร่างกายกำลังเดินทางอยู่ ณ ตอนนั้นอีกต่อไป - เป็นเวอร์ชันที่ละเอียดอ่อนของปัญหาเท้าไถลแบบเดียวกับที่ root motion มีไว้แก้ไข แค่ถูกเฉลี่ยออกไปตลอดทั้งก้าว แทนที่จะผิดให้เห็นชัดๆ ทุกเฟรม