9.5 Procedural และ Physics-Based Animation

เฟส 9 · แอนิเมชัน · เวลาเรียน: 25–45 h

การเคลื่อนไหวที่สร้างด้วยโค้ดและฟิสิกส์ — ragdoll, การเล็งและมองแบบ procedural และ secondary motion สำหรับผ้าและผม

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 บทนี้เริ่มลืมๆ แล้ว ตอนนี้เป็นจังหวะที่ดีที่จะย้อนไปทบทวนอีกรอบ เพราะหลายหัวข้อข้างล่างนี้เอาคณิตศาสตร์ตัวเดียวกันมาใช้ซ้ำเกือบจะบรรทัดต่อบรรทัด

three sources of motion, and where this chapter sits AUTHORED CLIP -------------> PROCEDURAL (ON TOP) -------------> FULL PHYSICS an artist keys every code adjusts or adds to a physics owns the frame by hand clip every frame (this motion completely (chapters 9.1-9.4) chapter, most sections) (ragdolls, sections 7-8) walk cycle clip --> + look-at rotation on the head --> character gets shot, + hair / cloth jiggle physics takes over + feet nudged onto uneven stairs until it gets up again

1. Procedural look-at: การหันกระดูกไปหาเป้าหมายทุกเฟรม

สมมติว่าตัวละครบอสต้องหันหัวตามผู้เล่นตลอดเวลา 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 รูปร่างคน หมายความว่าหัวจะหมุนทะลุไหล่ไปเลย ไม่มีอะไรในสคริปต์นี้ที่ห้ามมันได้ หัวข้อถัดไปจะแก้ปัญหานี้

2. Clamping the look-at: ใส่ cone limit เพื่อไม่ให้คอบิดทะลุตัว

คอของคนจริงๆ ไม่ได้หมุนได้อิสระขนาดนั้น — มันมี range of motion จำกัดรอบๆ ทิศที่ spine หันอยู่ วิธีแก้คือใช้ cone limit: เลือก "rest direction" ขึ้นมาสักทิศ (ปกติใช้ forward ของ spine) วัดมุมระหว่างทิศนั้นกับเป้าหมาย แล้วถ้ามุมมันใหญ่เกินค่าสูงสุดที่กำหนดไว้ ก็ให้เล็งไปที่จุดที่ใกล้ที่สุดบนขอบของ cone แทนที่จะเล็งไปที่เป้าหมายจริงๆ

look-at cone limit, top-down view (spine.forward points up the page) spine.forward ^ | edge \ | / edge (-60deg) \|/ (+60deg) o <- neck / head pivot target A, 30 degrees off center --> INSIDE the cone --> head points exactly at target A target B, 100 degrees off center --> OUTSIDE the cone --> head clamps to the nearest edge (60 degrees)
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
เคล็ดลับ หลายเกมเลือก fade 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 ตัวอื่นแทน

3. Secondary motion: จำลองผม เสื้อผ้า และเครื่องประดับด้วย damped spring

ผม, หางม้า, เสื้อคลุม, ชายเสื้อโค้ท, เครื่องประดับ, หู, หาง — ตัวละครโดยเฉพาะตัวละครสไตล์อนิเมะเต็มไปด้วยของที่ไม่ควรขยับติดกับตัวแบบแข็งทื่อ พอหัวสะบัดเร็วๆ ผมควรจะตามหลังอยู่แป๊บหนึ่งแล้วแกว่งเลย 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-damper: the physics behind one spring bone rest position (moves with the parent) bone tip (mass m) * --spring k-- --damper c-- O <- current position | | |<-------- displacement x (current minus rest) -------->| force on the bone each frame: F = -k * x (spring: pulls back toward rest, proportional to how far it strayed) + (-c * v) (damper: resists CURRENT velocity, proportional to speed -- this is what drains energy and makes it settle, instead of swinging forever like chapter 2.5's frictionless spring)

ลองเทียบกับ 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 แล้ว

เคล็ดลับ ชื่อทางการของค่าที่บอกว่า damping เยอะแค่ไหนคือ damping ratio: 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 ด้วยตาไปเรื่อยๆ

4. สร้าง spring bone ด้วย C#

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 ทันทีที่มาถึงจะดูแข็งทื่อเหมือนหุ่นยนต์ การแกว่งเลยแล้วค่อยนิ่งนี่แหละที่ทำให้มันดูเป็นผมจริงๆ บนหน้าจอ ไม่ใช่แท่งแข็งๆ

5. ต่อ spring bone เป็นเชน: ผม, หาง, และเสื้อคลุม

spring bone ตัวเดียวทำให้ได้แค่ต่างหูที่กระเพื่อมๆ เส้นผม, หางม้า, หรือเสื้อคลุมต้องการกระดูกหลายตัวเรียงต่อกัน โดย rest position ของแต่ละตัวขึ้นอยู่กับว่ากระดูกตัวก่อนหน้าในเชนไปจบที่ตรงไหนจริงๆ — ไม่ใช่ตำแหน่งดั้งเดิมของ skeleton ที่ animate ไว้ แต่เป็นตำแหน่งที่ solve ด้วย spring ของเฟรมเดียวกันนี้

spring bone chain (e.g. a 4-link ponytail) [head, animated] | (rigid attachment) [bone 1] --spring-- rest = head.forward * length1 | [bone 2] --spring-- rest = bone1's SOLVED position this frame, offset by length2 | [bone 3] --spring-- rest = bone2's SOLVED position this frame, offset by length3 | [bone 4] --spring-- rest = bone3's SOLVED position this frame, offset by length4 head turns suddenly -> bone1's rest jumps immediately, but bone1 itself lags (it has its own spring) -> bone2's rest depends on bone1's LAGGED position, so bone2 lags even more on top of that -> the delay compounds down the chain, which is exactly the whip-like wave you see travel down a ponytail or a cape a beat after the body that is dragging it around
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 ของเฟรมนี้อัพเดตไปแล้ว ไม่ใช่ค่าตกค้างจากเฟรมที่แล้ว

ข้อผิดพลาดที่พบบ่อย ไปหา spring bone ทั้งหมดบนตัวละครด้วย GetComponentsInChildren<SpringBone>() แล้วอัพเดตตามลำดับที่การค้นหานั้นบังเอิญคืนค่ามาให้ ลำดับการค้นหาของ Unity ไม่ใช่อะไรที่ควรพึ่งพาเพื่อความถูกต้อง — ถ้าสคริปต์ของกระดูกลูกรันก่อนกระดูกแม่ของมันในเฟรมเดียวกัน มันจะอ่านตำแหน่ง parent ที่ค้างมาจากเฟรมก่อน แล้วทั้งเชนก็จะดูหลุดๆ หรือสั่นๆ แบบสังเกตได้ โดยเฉพาะตอนหันหัวเร็วๆ ให้กำหนดลำดับไว้ตรงๆ เลย จาก root ไป tip เป็น array ที่เรียงลำดับไว้ (แบบ SpringBoneChain ข้างบน) แทนที่จะเชื่อการค้นหาแบบอัตโนมัติ

6. Procedural foot IK: ให้เท้าเหยียบพื้นติดพื้นแม้พื้นจะไม่เรียบ

clip ของ walk cycle ถูกทำขึ้นมาโดยสมมติว่าพื้นเรียบ พอตัวละครเดินขึ้นขอบถนน, ทางลาด, หรือบันได เท้าจะจมลงไปในขั้นบันไดหรือไม่ก็ลอยอยู่เหนือมัน — เพราะ animation ไม่รู้เลยว่าความสูงของพื้นเปลี่ยนไป วิธีแก้ใช้รูปแบบเดียวกับ IK ในบท 9.3 แต่ทำแบบ procedural ทุกเฟรม: ยิง ray ตรงลงไปจากตำแหน่งที่ animate ไว้ของแต่ละเท้า แล้วย้าย IK target ของเท้านั้นไปยังจุดที่ ray ชนจริงๆ

foot IK on a step down (side view) animation assumes flat ground: actual ground has a step: hip o hip o | | L | R L | R | | | | ==+=====+== (clip's flat ground) ==+== . | . +== (ray hits lower ground under the right foot) each foot gets its own raycast straight down from where the animation put it; the hit point becomes that foot's IK target, and the hips ease toward the LOWER of the two hit offsets, so neither foot ends up looking like it is floating above the ground
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 ตรงข้ามกัน เพราะสองงานนี้ต้องการความรู้สึกที่ตรงข้ามกัน

เคล็ดลับ section นี้ปรับ walk cycle ที่ทำไว้แล้วให้เข้ากับพื้นที่ไม่เรียบด้วย IK เท่านั้น มันไม่ได้สร้างการเดินขึ้นมาใหม่ตั้งแต่ศูนย์ การทำ gait synthesis แบบ procedural เต็มรูปแบบ (เลือกเองว่าเท้าแต่ละข้างจะยกและวางตอนไหนโดยอิงจากความเร็วกับภูมิประเทศ โดยไม่มี clip ที่ทำไว้ล่วงหน้าเป็นฐานเลย) เป็นเทคนิคที่ใหญ่กว่านี้มาก มีเกมส่วนน้อยเท่านั้นที่ใช้ ก็เพราะเหตุผลนี้แหละ — การใช้ IK ซ้อนทับบน clip ที่ทำไว้แล้วให้ประโยชน์ 90% ด้วยความซับซ้อนแค่เศษเสี้ยวเดียว

7. Ragdoll: สลับตัวละครจาก animation ไปเป็น physics ตอนตาย

ไม่มี 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 ของการส่งไม้ต่อ — จะสลับสวิตช์ตอนไหนและยังไง

a ragdoll rig: every major bone gets its own Rigidbody + Collider, and every joint between two bones gets a ConfigurableJoint (or CharacterJoint) with angular limits [Head] | [Spine] / \ [L UpperArm] [R UpperArm] | | [L Forearm] [R Forearm] | [Hips] <-- usually the ROOT: no joint above it, / \ everything else hangs off this one [L Thigh] [R Thigh] | | [L Shin] [R Shin] (simplified -- a real rig also has feet, hands, and more spine segments; Unity's built-in Ragdoll Wizard generates a setup like this automatically)
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 อื่นๆ ด้วย) ทำให้กระดูกที่โดนยิงกระเด็นไปในทิศทางเดียวกับที่โดนยิง ตัวละครเลยไม่ได้แค่ทรุดอยู่กับที่เฉยๆ แต่ตอบสนองให้เห็นชัดว่าตายยังไง

เคล็ดลับ ให้ปิด collider ของ ragdoll ไว้ตลอดตอนที่ยัง animate อยู่ ไม่ใช่แค่ทำเป็น kinematic แล้วเปิด collider ทิ้งไว้ collider ที่เปิดอยู่แต่เป็น kinematic ยังคง generate collision event กับ collider ที่ไม่ใช่ kinematic (เช่นฉากในด่านหรือตัวละครอื่น) ซึ่งอาจไป trigger physics callback ที่ไม่ต้องการ หรือในบาง setup อาจไปดันวัตถุอื่นให้ขยับเห็นได้ชัด ทั้งที่ ragdoll เองไม่ได้ขยับเลย การปิด collider ไปเลยตอน animate จะเลี่ยงปัญหานี้ได้หมด

8. Blend กลับ: การลุกขึ้นจาก ragdoll

ตัวละครที่รอดจากการถูกล้มไม่ควรจะกระตุกจาก 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
เคล็ดลับ coroutine ตัวนี้ถูกทำให้ง่ายลงเพื่อให้เห็นแก่นของเทคนิคชัดๆ: record, snap, blend out ระบบ get-up ในโปรดักชันจริงมักจะขับ blend แบบเดียวกันนี้จาก LateUpdate แทนที่จะใช้ coroutine เปล่าๆ (เพื่อรับประกันว่ามันรันหลัง animation evaluation เสมอ กฎการเรียงลำดับเดียวกับ section 1) และมักจะ blend เป็น additive pose ก้อนเดียวที่กระชับแทนที่จะทำทีละกระดูก — แต่ไอเดียหลักๆ ก็เหมือนกับที่อยู่ตรงนี้ทุกอย่าง

9. ซ้อน procedural motion ทับบน base animation

ทุกเทคนิคในบทนี้เสียบเข้ากับจุดใดจุดหนึ่งที่เจาะจงใน pipeline ต่อเฟรมของ Unity การซ้อนมันให้ถูกลำดับนี่แหละที่ทำให้ตัวละครที่มีทั้ง walk cycle, การเล็ง, ผมที่กระเพื่อม, และ foot IK ทำงานร่วมกันได้ แทนที่จะแย่งกันควบคุม

one frame, in order -- this is where every system in this chapter plugs in Update() -- input, gameplay logic, deciding WHERE targets are | Animator evaluates the -- base layer (locomotion blend tree, 9.2) plus any base clip / blend tree authored ADDITIVE layers (e.g. a breathing loop) | OnAnimatorIK() -- section 6's foot IK, humanoid look-at IK if used | LateUpdate() -- section 1/2's look-at, section 4/5's spring bones; section 7's ragdoll switch REPLACES all of this | Render

คำว่า "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 นั้นคืออะไร

10. ตอนไหนที่ procedural motion ดีกว่า authored animation และมันแลกมาด้วยอะไร

จุดที่ procedural ชนะ

มันแลกมาด้วยอะไร

กฎที่ใช้ได้จริง: authored animation คือค่าเริ่มต้น หยิบ procedural motion มาใช้เฉพาะส่วนของตัวละครที่ต้องตอบสนองต่อสิ่งที่ animator ไม่มีทางรู้ล่วงหน้าได้จริงๆ เท่านั้น — แล้วพอหยิบมาใช้แล้ว ก็ต้อง budget ค่า CPU เหมือนกับที่ budget ระบบต่อตัวละครอื่นๆ เพราะไม่เหมือน clip ตรงที่มันไม่ได้ถูกลงตลอดแค่เพราะไม่มีใครมอง (นี่แหละคือสิ่งที่การ scale แบบ LOD ซึ่งเป็นหัวข้อของ Exercise 3 มีไว้ทำ)

11. Glossary (คำศัพท์)

12. แบบฝึกหัด (Exercises)

แบบฝึกหัด 1 spring bone ตัวหนึ่งมี stiffness 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 (ซึ่งตอนนี้กลับทิศแล้ว) จะเริ่มดึงมันกลับมา

แบบฝึกหัด 2 ทิศ spine forward ของตัวละครกับทิศไปหาเป้าหมายห่างกัน 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 อีกครั้ง

แบบฝึกหัด 3 ฉากฝูงชนมี background NPC 200 ตัว แต่ละตัวรัน spring-bone hair เต็มรูปแบบ (4 กระดูกต่อกันเป็นเชน) และ look-at เล็งไปที่ผู้เล่นเต็มรูปแบบทุกเฟรม frame rate ตกหนักทุกครั้งที่มี NPC พวกนี้เยอะๆ อยู่บนจอ ใช้ไอเดียจากบทนี้ ระบุการเปลี่ยนแปลงที่จับต้องได้สามอย่างที่จะช่วยได้ พร้อมอธิบายสั้นๆ ว่าทำไมแต่ละอย่างถึงได้ผล
ดูเฉลย

สามข้อไหนก็ได้จากนี้ (หรือแบบที่ปรับเปลี่ยนมาอย่างสมเหตุสมผล) ถือเป็นคำตอบที่ใช้ได้:

  • LOD ตามระยะสำหรับระบบ procedural รัน spring bone กับ look-at เฉพาะตัวละคร N ตัวที่ใกล้ที่สุด (หรือใครก็ตามที่มองเห็นจริงๆ ใน camera frustum) เท่านั้น ปิดทั้งสองอย่างไปเลยสำหรับ NPC ที่อยู่ไกลหรืออยู่นอกจอ แล้วปล่อยผม/หัวของพวกมันให้อยู่ใน pose ที่ animate เฉยๆ section 10 พูดถึง trade-off แบบนี้ไว้ตรงๆ เลย: procedural motion ซื้อการตอบสนองที่ไม่มีใครมองเห็นหรือรู้สึกได้จากตัวละคร background ที่อยู่ไกล 40 เมตร
  • อัพเดตให้ถี่น้อยลง step spring bone ทุกๆ เฟรมที่สองหรือสามแทนที่จะทำทุกเฟรม โดยค้างตำแหน่งไว้นิ่งๆ ในช่วงระหว่างนั้น (ปรับ dt ที่สะสมไว้ให้สอดคล้องกัน) ความล่าช้าเล็กน้อยที่เพิ่มขึ้นบนผม background มองไม่เห็นหรอก แต่ CPU ที่ประหยัดได้มองเห็นแน่นอน
  • ย่อเชนให้สั้นลงสำหรับตัวละคร background ใช้ spring bone แค่ 1-2 ตัวแทนที่จะเป็น 4 ตัวสำหรับใครก็ตามที่อยู่ไกล — เหมือนเป็น "hair LOD" แบบเดียวกับที่ mesh กับ texture มี LOD กระดูกน้อยลงหมายความว่าคำนวณ spring-damper น้อยลงต่อตัวละครต่อเฟรม
  • Cull look-at ตามความเกี่ยวข้อง ไม่ใช่แค่ระยะทาง NPC ที่ไม่มีเหตุผลเชิง gameplay ให้ต้อง track ผู้เล่น (มองผู้เล่นไม่เห็น, ไม่ได้อยู่ในการต่อสู้, แค่เป็นฉาก background เฉยๆ) ไม่จำเป็นต้องรัน look-at เลย — ตั้ง 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 แต่ละชิ้นต้องการเครื่องมือไหนจริงๆ — และแต่ละอย่างต้องแลกมาด้วยอะไรบ้าง เมื่อมีตัวละครสองร้อยตัวอยู่บนจอพร้อมกันแทนที่จะเป็นตัวเดียว

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