10.3 Ragdoll, Cloth และ Soft Body

เฟส 10 · ฟิสิกส์สำหรับเกม · เวลาเรียน: 25–40 h

ตัวละครและวัสดุที่ขับด้วยฟิสิกส์ — ragdoll, cloth และ soft-body สำหรับผม เคป และเสื้อผ้า

กล่องตกอย่างเดียวมันง่าย: rigid body หนึ่งตัว collider หนึ่งตัว ที่เหลือแรงโน้มถ่วงจัดการให้เอง แต่ของที่น่าสนใจในเกมส่วนใหญ่ไม่ใช่กล่องเดี่ยว ๆ แบบนั้น โครงกระดูกของตัวละครคือกระดูกสิบกว่าชิ้นขึ้นไปที่ต้องเคลื่อนที่ไปด้วยกันและติดกันอยู่ที่ข้อต่อตลอดเวลา เสื้อคลุมหรือหางม้าก็คือจุดหลายสิบจุดที่ต้องอยู่ห่างกันประมาณเท่าเดิมในขณะที่ยังแกว่งได้อย่างอิสระ บทนี้พูดถึงการจำลองชิ้นส่วนที่เชื่อมกันหลายชิ้นพร้อมกัน แทนที่จะเป็นวัตถุเดี่ยว ๆ ตัวเดียว: ragdoll (โครงกระดูกที่แปลงเป็น rigid body ขับเคลื่อนด้วยฟิสิกส์) cloth (ผ้า — กริดของจุดที่เชื่อมกันด้วย distance constraint) และแนะนำสั้น ๆ เรื่อง soft body (ก้อนนุ่ม ๆ เด้ง ๆ แบบเยลลี่)

สองครึ่งของบทนี้ใช้เครื่องมือคนละแบบเพื่อแก้ปัญหาแบบเดียวกัน: ชิ้นส่วนหลายชิ้นที่เชื่อมกันแล้วต้องเคลื่อนที่เป็นระบบเดียว Ragdoll ใช้ rigid body เต็มรูปแบบกับ joint ซึ่งเป็นชิ้นส่วนพื้นฐานตัวเดิมจากบทฟิสิกส์ก่อนหน้า แค่เอามาต่อกันเป็นโซ่ ส่วน cloth กับ soft body ใช้อะไรที่เบากว่านั้น: จุดธรรมดา ๆ ที่ไม่มีการหมุน เดินหน้าไปทีละสเต็ปด้วย Verlet integration ที่เคยเจอมาแล้วในบทแคลคูลัส (บทที่ 2.5 หัวข้อ 7) แล้วยึดเข้าด้วยกันด้วย distance constraint ธรรมดา ๆ แทนที่จะใช้ physics joint เต็มรูปแบบ พอจบบทนี้คุณจะตั้งค่า ragdoll ที่ตายแล้วลุกขึ้นมาได้เอง และเขียน cloth หรือ rope simulation ของตัวเองตั้งแต่ต้นได้

1. Ragdoll คืออะไร?

ปกติแล้วโครงกระดูกของตัวละครถูกขับเคลื่อนด้วย animation: Animator เล่นท่าเดินหรือท่าชกที่บันทึกไว้ ตั้งค่าการหมุนของกระดูกทุกชิ้นจาก keyframe ที่ artist สร้างไว้ล่วงหน้า วิธีนี้ดูดีมากสำหรับทุกอย่างที่ animator วางแผนไว้ล่วงหน้า แต่มันตอบสนองไม่ได้ถ้าโดนระเบิดกระแทก สะดุดบันได หรือตกลงบนทางลาด — เพราะ animation ถูก bake ไว้ล่วงหน้าแล้วก็เล่นซ้ำแบบเดิมไม่ว่าอะไรจะเกิดขึ้นจริง ๆ รอบตัวละครก็ตาม Ragdoll เข้ามาแทนที่ animation ที่ bake ไว้ด้วยฟิสิกส์จริง: กระดูกทุกชิ้นกลายเป็น rigid body เล็ก ๆ ของตัวเอง (วัตถุแข็งที่ฟิสิกส์ผลัก ดึง และหมุนได้) ห่อด้วย collider เพื่อให้ชนพื้นหรือวัตถุอื่นได้ แล้วเชื่อมกับกระดูกข้างเคียงด้วย joint (constraint ทางฟิสิกส์ที่ยึด rigid body สองตัวไว้ที่จุดหนึ่ง ปกติจะจำกัดว่าหมุนสัมพัทธ์กันได้แค่ไหน) แรงโน้มถ่วง การชน และแรงกระแทกจะขยับทั้งโซ่ แล้วมันก็จะร่วงลงมาแบบเดียวกับร่างที่สลบจริง ๆ

one half of a ragdoll's bone chain (the other side mirrors it) [head] | o neck -- CONE joint (ball-and-socket, limited swing + twist) | [chest] | o shoulder -- CONE joint (wide swing, like a real shoulder) | [upper arm] | o elbow -- HINGE joint (bends one way only, like a real elbow) | [forearm] [chest] | o spine -- CONE joint (small swing, torso barely twists) | [pelvis] | o hip -- CONE joint (wide swing, like a real hip) | [upper leg] | o knee -- HINGE joint (bends one way only, like a real knee) | [lower leg] each "o" is a Joint component connecting the bone above to the bone below; each [bone] box carries its own Rigidbody + Collider

มีสองอย่างที่ทำให้ diagram นี้ต่างจากโครงกระดูก animation ธรรมดา อย่างแรก กล่องทุกกล่องคือวัตถุฟิสิกส์จริงที่มี mass กับ collider ไม่ใช่แค่ transform ที่ animator ขยับ อย่างที่สอง ตัวเชื่อมทุกตัวคือ joint และ joint ก็ไม่ได้มีแบบเดียว — ข้อศอกงอได้แค่ระนาบเดียว เลยใช้ joint แบบ hinge ส่วนไหล่แกว่งได้อิสระกว่ามาก เลยใช้ joint แบบ cone หัวข้อ 2 กับ 3 จะสร้างโครงสร้างแบบนี้เป็นโค้ดจริง ๆ ส่วนหัวข้อ 4 พูดถึงการสลับตัวละครระหว่างโหมด animated กับโหมด ragdoll

2. ตั้งค่า ragdoll: collider กับ joint ต่อกระดูกแต่ละชิ้น

การสร้าง ragdoll เองด้วยมือคือการไล่ไปทีละกระดูกในโครงกระดูกทั้งหมด แล้วใส่สามอย่างให้แต่ละชิ้น: collider ที่มีรูปทรงใกล้เคียงกับส่วนร่างกายนั้น (ปกติใช้ capsule เพราะแขนขามีรูปทรงคล้าย capsule) Rigidbody เพื่อให้ฟิสิกส์ขยับมันได้ และ — สำหรับกระดูกทุกชิ้นยกเว้น root (ปกติคือ pelvis หรือสะโพก) — joint ที่เชื่อมมันเข้ากับ Rigidbody ของกระดูกแม่ (parent bone) Unity มี "Ragdoll Wizard" ที่ทำแบบนี้ให้อัตโนมัติสำหรับ humanoid แต่การเห็นว่าจริง ๆ แล้วมันทำอะไรอยู่ก็มีประโยชน์ เพราะคุณจะต้องทำขั้นตอนเดียวกันเองสำหรับสัตว์ประหลาดที่ไม่ใช่ humanoid หรือต้องปรับผลลัพธ์เองด้วยมือ:

using UnityEngine;

// One entry per bone we want to turn into a physics body.
public class RagdollBone
{
    public Transform bone;
    public Transform parentBone;   // null for the root bone (e.g. the pelvis)
    public float radius;
    public bool isLimb;            // true = hinge (elbow, knee), false = cone (shoulder, hip, spine)
}

public class RagdollBuilder : MonoBehaviour
{
    public RagdollBone[] bones;

    void Start()
    {
        foreach (RagdollBone rb in bones)
        {
            CapsuleCollider col = rb.bone.gameObject.AddComponent<CapsuleCollider>();
            col.radius = rb.radius;

            Rigidbody body = rb.bone.gameObject.AddComponent<Rigidbody>();
            body.mass = 1f;

            if (rb.parentBone == null) continue;   // root bone has no joint to a parent

            Rigidbody parentBody = rb.parentBone.GetComponent<Rigidbody>();

            if (rb.isLimb)
            {
                HingeJoint hinge = rb.bone.gameObject.AddComponent<HingeJoint>();
                hinge.connectedBody = parentBody;
                hinge.useLimits = true;
                JointLimits lim = hinge.limits;
                lim.min = 0f;
                lim.max = 140f;
                hinge.limits = lim;
            }
            else
            {
                CharacterJoint cone = rb.bone.gameObject.AddComponent<CharacterJoint>();
                cone.connectedBody = parentBody;
                cone.swing1Limit = new SoftJointLimit { limit = 70f };
                cone.swing2Limit = new SoftJointLimit { limit = 50f };
            }
        }
    }
}

ไล่ทีละสเต็ป: สมมติตั้งค่า bones ไว้สำหรับ biped ธรรมดา (pelvis เป็น root, spine, chest, ต้นแขนสองข้าง, ปลายแขนสองข้าง, ต้นขาสองข้าง, ปลายขาสองข้าง) loop นี้รันหนึ่งรอบต่อกระดูกหนึ่งชิ้น pelvis จะได้ collider กับ Rigidbody แล้ว continue ทันที เพราะมันไม่มี parent กระดูกชิ้นอื่นทุกชิ้นจะได้ collider, Rigidbody และ HingeJoint (ถ้า isLimb เป็น true สำหรับข้อศอกกับเข่า) หรือ CharacterJoint (สำหรับทุกอย่างที่เหลือ — ไหล่ สะโพก spine คอ) เชื่อมกลับไปหา Rigidbody ของ parent มันเอง พอ Start() ทำงานเสร็จ โครงกระดูกทั้งหมดก็กลายเป็นโซ่ของ rigid body ที่ยึดกันไว้ด้วย joint พร้อมตอบสนองต่อแรงโน้มถ่วงและการชนทันทีที่มีอะไรมาเปิดฟิสิกส์ให้มัน — ซึ่งก็คือสิ่งที่หัวข้อ 4 ทำเลย

Tip ให้ Rigidbody ของกระดูกแต่ละชิ้นมี mass ที่เล็กและสมเหตุสมผล (ปลายแขนไม่ควรหนักกว่าลำตัว) แล้วให้ขนาด collider ใกล้เคียงกับความหนาจริงของแขนขานั้น ragdoll ที่ mass ไม่สมดุลกันหรือ collider ใหญ่เกินไปจะดูสั่น ๆ กระตุก ๆ และมักไปติดตัวเองอยู่บ่อย ๆ — บั๊กประเภท "ragdoll ของฉันบ้าไปแล้ว" ส่วนใหญ่ย้อนกลับไปเจอต้นตอที่ collider ขนาดผิด ๆ ทับซ้อนกับกระดูกข้างเคียงตอนอยู่นิ่ง ๆ

3. Hinge กับ cone limit: ให้ joint มีช่วงการเคลื่อนไหวที่สมจริง

joint ที่ไม่มี limit เลยจะปล่อยให้กระดูกหมุนอิสระไปทางไหนก็ได้ ทำให้ ragdoll ดูเหมือนกองยางไม่ใช่ร่างกาย joint จริง ๆ ในร่างกายมนุษย์เคลื่อนไหวได้แค่บางแบบเท่านั้น และ joint สำหรับ ragdoll ของ Unity จำลองรูปแบบที่พบบ่อยที่สุดสองแบบไว้ให้

HINGE joint -- 1 axis, like an elbow or knee min angle ----- straight ----- max angle (the limb can only swing back and forth, all in ONE plane) CONE + TWIST joint -- like a shoulder or hip swing1Limit / swing2Limit = how wide a cone the limb can swing inside twist limit = how far it can spin around its own long axis . . . <- edge of the cone the limb is allowed to swing inside . | . . | . <- resting direction (straight down from the socket) . | . . . .

Hinge joint (HingeJoint ของ Unity) ยอมให้หมุนรอบแกนเดียวเท่านั้น ระหว่างมุม min กับ max — ตรงกับสิ่งที่ข้อศอกหรือเข่าทำเป๊ะ ๆ มันงอด้านข้างหรือบิดไม่ได้ ส่วน cone joint (หรือ "ball-and-socket") ยอมให้หมุนอยู่ในรูปทรงกรวยรอบทิศทางพัก บวกกับ twist limit แยกต่างหากรอบแกนยาวของกระดูกเอง — ตรงกับสิ่งที่ไหล่หรือสะโพกทำ และ CharacterJoint ของ Unity ถูกสร้างมาเพื่อสิ่งนี้โดยเฉพาะ โดยมี swing1Limit กับ swing2Limit คุมความกว้างของกรวยในสองทิศทาง และ lowTwistLimit / highTwistLimit คุมการบิด:

using UnityEngine;

public class ShoulderExample : MonoBehaviour
{
    public Transform shoulderBone;
    public Transform torsoBone;

    void Start()
    {
        CharacterJoint shoulder = shoulderBone.gameObject.AddComponent<CharacterJoint>();
        shoulder.connectedBody = torsoBone.GetComponent<Rigidbody>();

        shoulder.swing1Limit = new SoftJointLimit { limit = 80f };   // forward / back swing
        shoulder.swing2Limit = new SoftJointLimit { limit = 60f };   // side swing

        shoulder.lowTwistLimit  = new SoftJointLimit { limit = -20f };
        shoulder.highTwistLimit = new SoftJointLimit { limit = 90f };
    }
}

ในทางกลับกัน ข้อศอกต้องการแค่ช่วง min/max เดียวรอบแกนเดียวเท่านั้น:

using UnityEngine;

public class ElbowExample : MonoBehaviour
{
    public Transform elbowBone;
    public Transform upperArmBone;

    void Start()
    {
        HingeJoint elbow = elbowBone.gameObject.AddComponent<HingeJoint>();
        elbow.connectedBody = upperArmBone.GetComponent<Rigidbody>();
        elbow.axis = new Vector3(0f, 1f, 0f);   // the one axis the elbow bends around

        elbow.useLimits = true;
        JointLimits limits = elbow.limits;
        limits.min = 0f;      // fully straight
        limits.max = 150f;    // fully bent
        elbow.limits = limits;
    }
}

ถ้าไม่มี limit พวกนี้ ข้อศอกของ ragdoll ที่กำลังตกอาจงอไปข้างหลังเป็นทรงที่เป็นไปไม่ได้ หรือไหล่อาจหมุนแขนวนรอบตัวเหมือนใบพัด แต่ถ้ามี limit ragdoll จะตกลงไปในท่าที่แย่ที่สุดก็แค่ดูเหมือนร่างที่สลบ ไม่ใช่ตุ๊กตาที่พัง การหาตัวเลข limit ที่ถูกต้องส่วนใหญ่ต้องลองผิดลองถูก: เริ่มจาก limit ที่กว้าง ๆ ก่อน ปล่อย ragdoll ทดสอบตกจากที่สูง แล้วขันให้แคบลงตรง joint ไหนก็ตามที่งอไปในทิศทางที่ของจริงงอไม่ได้

Common mistake ตั้ง limit แน่นเกินไปจนกระทั่ง ragdoll ต่อสู้กับตัวเอง ถ้า cone limit ของกระดูกอกกับ cone limit ของไหล่ต่างก็พยายามยึดท่าพักที่เกือบจะตรงเป๊ะพร้อมกัน joint solver ก็ต้องสู้กับ error ทางตัวเลขเล็ก ๆ ทุกเฟรมแค่เพื่อทำให้ทั้งสองอย่างพอใจ ซึ่งจะออกมาเป็นอาการสั่นละเอียด ๆ ต่อเนื่อง การเผื่อ slack เล็กน้อยในทุก limit (บวกไปอีกสักไม่กี่องศาจากที่ดู "ถูกต้อง") มักจะแก้ปัญหานี้ได้เสมอ

4. สลับจาก animation ไปเป็น ragdoll ตอนตาย

ปกติกระดูกของตัวละครถูกขับเคลื่อนเต็มที่โดย Animator โดยที่ Rigidbody.isKinematic ของทุกกระดูกตั้งเป็น true Rigidbody แบบ kinematic จะเมิน gravity กับแรงต่าง ๆ ไปเลย — มันจะขยับก็ต่อเมื่อมีอะไรบางอย่าง (ในที่นี้คือ Animator) ตั้งค่า transform ให้มันโดยตรงทุกเฟรม ตราบใดที่ body ยังเป็น kinematic อยู่ joint ที่ติดอยู่กับมันก็ไม่มีอะไรให้ constrain จริง ๆ — joint ยังอยู่ใน scene แต่ไม่ได้ทำอะไรเลย เพราะ Animator เป็นคนตัดสินใจแทนแล้วว่ากระดูกจะไปอยู่ตรงไหน การเปลี่ยนตัวละครให้เป็น ragdoll คือการพลิกสวิตช์นั้น: ปิด Animator แล้วตั้งค่า Rigidbody ของทุกกระดูกกลับเป็น non-kinematic เพื่อให้ gravity การชน และ limit ของ joint จากหัวข้อ 2 กับ 3 เข้ามาควบคุมทั้งหมด

WHILE ANIMATED AFTER Die() IS CALLED Animator writes bone transforms Animator component disabled every frame (isKinematic = true) every bone Rigidbody.isKinematic = false Rigidbodies ignore gravity/forces gravity + collisions + joint limits joints have nothing to constrain now drive every bone
using UnityEngine;

public class RagdollController : MonoBehaviour
{
    public Animator animator;
    public Rigidbody[] ragdollBodies;   // every bone's Rigidbody, collected once in Start()

    void Start()
    {
        SetRagdoll(false);   // start fully animated
    }

    public void SetRagdoll(bool enabled)
    {
        animator.enabled = !enabled;
        foreach (Rigidbody body in ragdollBodies)
            body.isKinematic = !enabled;
    }

    public void Die(Vector3 hitPoint, Vector3 hitForce)
    {
        SetRagdoll(true);
        Rigidbody nearest = FindNearestBody(hitPoint);
        nearest.AddForceAtPosition(hitForce, hitPoint, ForceMode.Impulse);
    }

    Rigidbody FindNearestBody(Vector3 point)
    {
        Rigidbody best = ragdollBodies[0];
        float bestDist = Vector3.Distance(point, best.position);
        foreach (Rigidbody body in ragdollBodies)
        {
            float d = Vector3.Distance(point, body.position);
            if (d < bestDist) { bestDist = d; best = body; }
        }
        return best;
    }
}

ไล่ทีละสเต็ป: ก่อนที่ Die() จะถูกเรียกครั้งแรก SetRagdoll(false) ทำงานไปแล้ว ดังนั้น animator.enabled เป็น true และทุกกระดูกเป็น kinematic — ตัวละครเดิน โจมตี ยืนเฉย ๆ แบบปกติทุกอย่าง แล้ว joint ทุกตัวก็ไม่ได้ทำอะไรเลย พอมีอะไรบางอย่างเรียก Die(hitPoint, hitForce) — สมมติว่าจรวดระเบิดใส่หน้าอกตัวละคร — SetRagdoll(true) จะปิด Animator แล้วพลิกทุกกระดูกเป็น non-kinematic ในรอบเดียว ทำให้โครงกระดูกทั้งหมดเริ่มร่วงลงตามแรงโน้มถ่วงทันที FindNearestBody จะเลือกกระดูกที่อยู่ใกล้จุดระเบิดที่สุด (น่าจะเป็นหน้าอกหรือแขนที่อยู่ใกล้ ๆ) แล้วใส่แรงระเบิดลงไปที่กระดูกชิ้นนั้นชิ้นเดียวโดยตรงในรูป impulse เพราะตอนนี้กระดูกทุกชิ้นเชื่อมกันผ่าน joint แรงผลักครั้งเดียวนั้นก็จะวิ่งลงไปตามโซ่ — อกกระชากแขนกับหัวที่เชื่อมอยู่ ซึ่งก็ไปกระชาก child ของมันเองต่อ — แล้วทั้งร่างก็ร่วงหลุดออกจากจุดระเบิดอย่างสมจริง โดยเป็นไปตาม hinge กับ cone limit ทุกตัวที่ตั้งไว้ในหัวข้อ 2 กับ 3

5. Blend กลับเข้าสู่ get-up animation

เปลี่ยนตัวละครให้เป็น ragdoll นั้นง่าย แต่การพาให้มันลุกขึ้นยืนได้อย่างนุ่มนวลคือครึ่งที่ยากกว่า ragdoll จะหยุดนิ่งอยู่ในท่าไหนก็ได้แทบทั้งนั้น — คว่ำหน้า หงายหลัง บิดตะแคง — และเกมส่วนใหญ่อยากเล่น animation "ลุกขึ้น" ที่เข้ากับท่านั้น แล้วให้กระดูกของตัวละครไหลจากตรงที่ฟิสิกส์ทิ้งไว้เข้าไปสู่ animation นั้น แทนที่จะกระตุกไปทันที (ซึ่งดูเหมือนบั๊ก) เทคนิคมาตรฐานคือ snapshot ตำแหน่งกับการหมุนของทุกกระดูกในจังหวะที่ ragdoll หยุดถูกจำลอง เปิด Animator กลับมาให้เริ่มขับกระดูกไปหา get-up clip อีกครั้ง แล้ว blend — คือการ interpolate — จาก snapshot ไปหาสิ่งที่ Animator กำลังสร้างอยู่ตอนนี้ ในช่วงเวลาสั้น ๆ

[ANIMATED] --hit/death--> [RAGDOLL] --get-up starts--> [BLEND] --blendTime elapses--> [ANIMATED] Animator drives bones physics drives bones ragdoll pose is Lerped Animator fully isKinematic = true isKinematic = false into the new animation drives bones again pose over blendTime
using System.Collections;
using UnityEngine;

public class GetUpBlend : MonoBehaviour
{
    public Animator animator;
    public Transform[] bones;     // every bone the ragdoll used
    public float blendTime = 0.4f;

    Vector3[] ragdollPos;
    Quaternion[] ragdollRot;

    public void StartGetUp()
    {
        // 1. snapshot exactly where physics left every bone
        ragdollPos = new Vector3[bones.Length];
        ragdollRot = new Quaternion[bones.Length];
        for (int i = 0; i < bones.Length; i++)
        {
            ragdollPos[i] = bones[i].position;
            ragdollRot[i] = bones[i].rotation;
        }

        animator.enabled = true;                 // animator starts driving bones again
        animator.Play(ChooseGetUpClip());         // e.g. "GetUpFront" or "GetUpBack"
        StartCoroutine(BlendToAnimation());
    }

    string ChooseGetUpClip()
    {
        // lying face-down if the chest's local up axis points toward the ground
        return (transform.up.y < 0f) ? "GetUpFront" : "GetUpBack";
    }

    IEnumerator BlendToAnimation()
    {
        float t = 0f;
        while (t < blendTime)
        {
            t += Time.deltaTime;
            float alpha = t / blendTime;
            for (int i = 0; i < bones.Length; i++)
            {
                // blend FROM the frozen ragdoll snapshot TOWARD wherever the
                // Animator just wrote this bone this frame
                bones[i].position = Vector3.Lerp(ragdollPos[i], bones[i].position, alpha);
                bones[i].rotation = Quaternion.Slerp(ragdollRot[i], bones[i].rotation, alpha);
            }
            yield return null;
        }
    }
}

ไล่ทีละสเต็ป: ทันทีที่ StartGetUp() ทำงาน ตำแหน่งกับการหมุนใน world space ปัจจุบันของทุกกระดูก (ไม่ว่า ragdoll จะร่วงไปตกอยู่ตรงไหน) จะถูกก็อปปี้ลงไปใน ragdollPos / ragdollRot Animator ถูกเปิดกลับมาแล้วสั่งให้เล่น get-up clip ที่เลือกจากการเช็คง่าย ๆ อันหนึ่ง — ถ้าทิศทาง "ขึ้น" ของตัวละครชี้ลงล่าง แปลว่ามันต้องนอนคว่ำหน้าอยู่ ก็เลยเล่น clip get-up แบบคว่ำหน้า และกลับกัน ในช่วง blendTime วินาทีถัดไป แต่ละเฟรม Animator จะเขียนท่า animated ของมันเองลงในทุกกระดูกก่อน (นั่นคือสิ่งที่ bones[i].position มีอยู่แล้วตอนเข้าลูป) แล้ว coroutine ก็เขียนทับด้วย Lerp/Slerp ระหว่าง snapshot ของ ragdoll ที่แช่แข็งไว้กับท่าที่เพิ่ง animate สด ๆ นั้น ตอน alpha = 0 กระดูกยังอยู่ตรงจุดที่ ragdoll ทิ้งไว้เป๊ะ ตอน alpha = 1 มันอยู่ตรงจุดที่ animation ต้องการเป๊ะ ระหว่างนั้นมันจะไหลลื่น ๆ จากจุดหนึ่งไปอีกจุดหนึ่ง พอ t เกิน blendTime coroutine ก็หยุดแตะกระดูกเลย แล้ว Animator ก็ขับมันเองต่อไปตามลำพังจากจุดนั้น

Tip เกมที่ปล่อยจริง ๆ มักไปไกลกว่านี้อีก — ใช้ package Animation Rigging ของ Unity, IK หรือ asset "ragdoll profile" เฉพาะทาง เพื่อแก้ตำแหน่งเท้าและกันไม่ให้ทะลุพื้นระหว่าง blend ด้วย เวอร์ชัน Lerp/Slerp ข้างบนคือแนวคิดหลักที่อยู่เบื้องหลังทั้งหมดนั้น: แช่แข็งท่าฟิสิกส์ไว้ก่อน แล้วค่อย interpolate ไปหาท่า animated ในช่วงเวลาสั้น ๆ

6. Cloth ในฐานะ particle ที่เชื่อมกัน: mass-spring model กับ position-based dynamics

Ragdoll คือ rigid body จำนวนไม่กี่ตัว (สิบกว่าถึงยี่สิบกว่าชิ้นกระดูก) ส่วน cloth คือขั้วตรงข้ามเลย: จุดเล็ก ๆ เป็นร้อย ๆ จุด แต่ละจุดเบาเกินไปและเยอะเกินไปที่จะให้ collider กับ Rigidbody เป็นของตัวเอง วิธีคิดแบบคลาสสิกเกี่ยวกับ cloth คือ mass-spring model: จินตนาการว่า cloth คือกริดของ point mass ที่แต่ละจุดเชื่อมกับเพื่อนบ้านด้วยสปริงที่ดึงมันกลับไปหาระยะพักทุกครั้งที่มันยืดหรือหดตัว

คุณจะจำลองแบบตรง ๆ ด้วยกฎของ Hooke จากบทแคลคูลัส (F = -k * stretch) แล้วเดินหน้าด้วย integrator ก็ได้ แต่ในทางปฏิบัติวิธีนี้ทำงานได้แย่มาก: สปริงของ cloth ต้องแข็งมาก ๆ (k สูง) ถึงจะรู้สึกเหมือนผ้าจริงแทนที่จะเป็นเยลลี่ และหัวข้อ 5 ของบทที่ 2.5 ก็แสดงให้เห็นไปแล้วว่าแรงที่แข็งและเปลี่ยนเร็ว ๆ จะดัน integrator ที่เสถียรแค่ไหนก็ตามไปสู่ความไม่เสถียร นอกจากจะทำให้ dt เล็กมาก ๆ — เล็กจนถึงขนาดที่ cloth แบบสปริงตรง ๆ ช้าเกินกว่าจะรันได้ทุกเฟรม วิธีแก้ที่เกมจริง ๆ ใช้กันคือ position-based dynamics (PBD): แทนที่จะคำนวณแรงสปริงแล้ว integrate มัน ให้มองแต่ละสปริงเป็น distance constraint แบบแข็ง ๆ ("สองจุดนี้ต้องห่างกัน restLength เป๊ะ ๆ") แล้วดันสองจุดนั้นให้ตรงตาม constraint โดยตรง — เป็นแนวคิด satisfyDistance ตัวเดียวกับที่เคยเห็นตอนท้ายหัวข้อ 7 ของบทที่ 2.5 เลย เอามารวมกับ Verlet integration (ที่ต้องการแค่ตำแหน่งปัจจุบันกับตำแหน่งก่อนหน้า ไม่ต้องมี velocity แยกให้คอย sync) แล้วคุณจะได้ cloth simulation ที่ทั้งถูกและเสถียรทางตัวเลข

MASS-SPRING CLOTH GRID (structural constraints only, drawn as lines) o---o---o---o---o each o = one particle (a point mass, no rotation) | | | | | each - or | = one distance constraint ("spring") o---o---o---o---o | | | | | o---o---o---o---o | | | | | o---o---o---o---o PIN row (never moved by integration or constraints): o o o o o <- e.g. a cape's collar, tied to the character's neck | | | | | o---o---o---o---o

โค้ด cloth จริง ๆ มักเพิ่ม constraint อีกสองแบบนอกจากแนวนอน/แนวตั้งที่วาดไว้ข้างบน: constraint แบบ shear (แนวทแยง เชื่อม particle แต่ละตัวเข้ากับเพื่อนบ้านที่เยื้องไปหนึ่งช่องในทั้งสองแกน ช่วยไม่ให้กริดยุบตัวเป็นเส้นบาง ๆ) และ constraint แบบ bend (เชื่อม particle เข้ากับเพื่อนบ้านที่ห่างไปสองช่องในแนวเส้นตรง ต้านการพับหักคมและกันไม่ให้ cloth ยับง่ายเกินไป) ทั้งสามแบบแก้ด้วยโค้ดสไตล์ satisfyDistance ตัวเดียวกันเป๊ะ ๆ — เปลี่ยนแค่คู่ particle กับ rest length เท่านั้น — เพราะฉะนั้นค่อยเพิ่มทีหลังก็ทำได้ถูก ๆ ทันทีที่ solver พื้นฐานจากสองหัวข้อถัดไปทำงานได้แล้ว

7. Verlet rope เป็นโค้ดจริง

วัตถุแบบ cloth ที่ง่ายที่สุดเท่าที่จะเป็นไปได้คือเชือก (rope): โซ่ของ particle ตัวเดียว ที่แต่ละตัวเชื่อมกับตัวถัดไปด้วย distance constraint โดย particle ตัวแรกถูก pin ไว้กับที่ สร้างอันนี้ก่อนที่จะกระโดดไปทำกริด 2D เต็มรูปแบบ ช่วยให้เห็นชิ้นส่วนที่กำลังขยับได้ง่ายขึ้น

PIN o <- particle 0 (pinned, e.g. tied to a hook) | o <- particle 1 | o <- particle 2 | o <- particle 3 | o <- particle 4 (free end, swings under gravity)
#include <cstdio>
#include <cmath>

const int N = 6;                 // number of particles in the rope
const float REST = 0.5f;         // rest length of each segment
const float GRAVITY = -9.8f;
const float DT = 1.0f / 60.0f;

struct Particle {
    float x, y;
    float px, py;      // previous position (this is what makes it Verlet)
    bool pinned;
};

Particle rope[N];

void initRope() {
    for (int i = 0; i < N; i++) {
        rope[i].x = i * REST;
        rope[i].y = 0.0f;
        rope[i].px = rope[i].x;   // starts at rest, implied velocity 0
        rope[i].py = rope[i].y;
        rope[i].pinned = (i == 0);   // pin only the first particle
    }
}

void integrate() {
    for (int i = 0; i < N; i++) {
        Particle& p = rope[i];
        if (p.pinned) continue;
        float newX = 2*p.x - p.px;                    // no horizontal force
        float newY = 2*p.y - p.py + GRAVITY * DT*DT;   // Verlet step from ch 2.5
        p.px = p.x; p.py = p.y;
        p.x = newX; p.y = newY;
    }
}

void satisfyConstraint(Particle& a, Particle& b, float rest) {
    float dx = b.x - a.x, dy = b.y - a.y;
    float dist = std::sqrt(dx*dx + dy*dy);
    if (dist < 1e-6f) return;
    float diff = (dist - rest) / dist;
    float moveA = a.pinned ? 0.0f : (b.pinned ? 1.0f : 0.5f);
    float moveB = b.pinned ? 0.0f : (a.pinned ? 1.0f : 0.5f);
    a.x += dx * moveA * diff;
    a.y += dy * moveA * diff;
    b.x -= dx * moveB * diff;
    b.y -= dy * moveB * diff;
}

void relaxConstraints(int iterations) {
    for (int it = 0; it < iterations; it++) {
        for (int i = 0; i < N - 1; i++) {
            satisfyConstraint(rope[i], rope[i+1], REST);
        }
    }
}

int main() {
    initRope();
    for (int frame = 0; frame < 3; frame++) {
        integrate();
        relaxConstraints(5);   // a handful of passes to settle every segment
        printf("frame %d: tip = (%.4f, %.4f)\n", frame, rope[N-1].x, rope[N-1].y);
    }
}

Output:

frame 0: tip = (2.5000, -0.0027)
frame 1: tip = (2.5000, -0.0082)
frame 2: tip = (2.4999, -0.0163)

ปลายเชือกแทบไม่ตกเลยในสามเฟรมแรกนี้ — เป็นเรื่องปกติ เพราะแรงโน้มถ่วงมีเวลาแค่ 3 * (1/60) วินาทีในการออกแรง แล้วช่วงเชือกก็แข็งพอที่ความหย่อนส่วนใหญ่ยังอยู่ในช่วงกำลังสะสมตัว สังเกตรูปร่างของลูป: integrate() รันหนึ่งครั้งต่อเฟรม ขยับ particle ที่เป็นอิสระทุกตัวด้วย Verlet step ธรรมดา เหมือนกับบทที่ 2.5 หัวข้อ 7 เป๊ะ ๆ จากนั้น relaxConstraints(5) จะรัน satisfyConstraint แบบคู่ ๆ ห้ารอบ ติดกันทั่วทั้งโซ่ ไม่ใช่แค่รอบเดียว การทำซ้ำแบบนี้สำคัญมาก — การแก้ระยะระหว่าง particle 0 กับ 1 อาจยืดระยะระหว่าง particle 1 กับ 2 ที่เพิ่งแก้ไปหมาด ๆ ออกอีกนิด ดังนั้นการไล่ผ่านโซ่แค่รอบเดียวจะไม่มีทางทำให้ทุกช่วงอยู่ที่ rest length พอดีเป๊ะได้เลย การรันหลายรอบ (นี่คือ "constraint relaxation" ตามชื่อหัวข้อ) จะปล่อยให้ error เล็ก ๆ ที่เหลืออยู่ค่อย ๆ นิ่งลงในแต่ละรอบ เหมือนกับที่แต่ละสี่เหลี่ยมใน Riemann sum เป็นแค่การประมาณพื้นที่จริง แต่ยิ่งเพิ่มจำนวนสี่เหลี่ยมก็ยิ่งเข้าใกล้ความจริงมากขึ้น

Tip ยิ่งรัน relaxation iteration มากขึ้น เชือกหรือ cloth ก็ยิ่งรู้สึกแข็งขึ้นและแม่นยำขึ้น แลกกับต้นทุนเวลา CPU ที่เพิ่มแบบเป็นเส้นตรงตรง ๆ (10 iteration กินเวลาประมาณสองเท่าของ 5 iteration) เกมส่วนใหญ่ลงเอยที่ประมาณ 4 ถึง 15 iteration ต่อเฟรม — พอที่จะทำให้ cloth ไม่ดูยืดจนเห็นได้ชัด แต่ก็ไม่มากจนกิน frame budget ไปหมด หัวข้อ 10 จะย้อนกลับมาพูดถึง trade-off นี้อีกครั้ง

8. จาก rope สู่ cloth: กริด 2D พร้อมจุด pin

Cloth คือแนวคิดเดียวกับ rope ในหัวข้อ 7 แค่ขยายไปเป็นสองมิติ: กริดของ particle แทนที่จะเป็นโซ่เส้นเดียว มี constraint แนวนอนไปยัง particle ทางขวา กับ constraint แนวตั้งไปยัง particle ด้านล่าง สำหรับ particle ทุกตัวที่มีตัวข้าง ๆ อยู่ โค้ดข้างล่างนี้จัดการแค่ simulation step เท่านั้น — การเอาตำแหน่งผลลัพธ์ไปใส่ vertex ของ Mesh เพื่อวาด cloth จริง ๆ เป็นโค้ด rendering ทั่วไปของ Unity เลยตัดออกไปตรงนี้ เพื่อให้เห็นฟิสิกส์ชัด ๆ

using System.Collections.Generic;
using UnityEngine;

public class VerletCloth : MonoBehaviour
{
    public int cols = 12;
    public int rows = 10;
    public float spacing = 0.2f;
    public int solverIterations = 8;
    public Vector3 gravity = new Vector3(0f, -9.8f, 0f);

    struct Constraint { public int a, b; public float restLength; }

    Vector3[] pos;
    Vector3[] prevPos;
    bool[] pinned;
    Constraint[] constraints;

    void Start()
    {
        int count = cols * rows;
        pos = new Vector3[count];
        prevPos = new Vector3[count];
        pinned = new bool[count];

        for (int y = 0; y < rows; y++)
        {
            for (int x = 0; x < cols; x++)
            {
                int i = y * cols + x;
                pos[i] = transform.position + new Vector3(x * spacing, -y * spacing, 0f);
                prevPos[i] = pos[i];              // starts at rest, implied velocity 0
                pinned[i] = (y == 0);             // pin the whole top row -- e.g. a cape's collar
            }
        }
        BuildConstraints();
    }

    void BuildConstraints()
    {
        var list = new List<Constraint>();
        for (int y = 0; y < rows; y++)
        {
            for (int x = 0; x < cols; x++)
            {
                int i = y * cols + x;
                if (x < cols - 1) list.Add(new Constraint { a = i, b = i + 1, restLength = spacing });     // horizontal
                if (y < rows - 1) list.Add(new Constraint { a = i, b = i + cols, restLength = spacing });  // vertical
            }
        }
        constraints = list.ToArray();
    }

    void Update()
    {
        float dt = Mathf.Min(Time.deltaTime, 1f / 30f);   // clamp -- same idea as ch 2.5's accumulator clamp
        Integrate(dt);
        for (int it = 0; it < solverIterations; it++)
            SatisfyConstraints();
    }

    void Integrate(float dt)
    {
        for (int i = 0; i < pos.Length; i++)
        {
            if (pinned[i]) continue;
            Vector3 current = pos[i];
            Vector3 velocity = current - prevPos[i];   // implied velocity, Verlet-style
            Vector3 next = current + velocity + gravity * dt * dt;
            prevPos[i] = current;
            pos[i] = next;
        }
    }

    void SatisfyConstraints()
    {
        foreach (Constraint c in constraints)
        {
            Vector3 delta = pos[c.b] - pos[c.a];
            float dist = delta.magnitude;
            if (dist < 1e-6f) continue;
            float diff = (dist - c.restLength) / dist;
            float moveA = pinned[c.a] ? 0f : (pinned[c.b] ? 1f : 0.5f);
            float moveB = pinned[c.b] ? 0f : (pinned[c.a] ? 1f : 0.5f);
            pos[c.a] += delta * moveA * diff;
            pos[c.b] -= delta * moveB * diff;
        }
    }
}

ไล่ทีละสเต็ป: Start() จัดวาง particle cols * rows ตัวเป็นกริดแบนใต้ transform.position แล้ว pin particle ทุกตัวในแถวที่ 0 — ขอบบนทั้งแถวห้อยอยู่ลอย ๆ ไม่มีอะไรยึดและไม่ขยับเลย เหมือนกับ particle ตัวแรกที่ถูก pin ในเชือกเป๊ะ ๆ BuildConstraints() ไล่กริดเดียวกัน แล้วบันทึก constraint แนวนอนหนึ่งอันไปยัง particle ทางขวา กับ constraint แนวตั้งหนึ่งอันไปยัง particle ด้านล่าง สำหรับ particle ทุกตัวที่มีตัวข้าง ๆ อยู่ รวมแล้วได้ constraint ประมาณ 2 * cols * rows อัน ทุกครั้งที่ Update() ทำงาน particle ที่ไม่ได้ถูก pin แต่ละตัวจะขยับหนึ่ง Verlet step ด้วยแรงโน้มถ่วง แล้ว SatisfyConstraints() ก็รันวนทุก constraint solverIterations รอบ — เทียบเท่ากับ relaxConstraints จากหัวข้อ 7 แต่เป็นเวอร์ชัน cloth เพราะแถว 0 ถูก pin ไว้ ส่วนที่เหลือของกริดก็เลยหย่อนลงมาตามน้ำหนักโดยนัยของตัวเอง แล้วนิ่งตัวเป็นผืนผ้าที่ห้อยอยู่ ยึดไว้ตามขอบบน

การ pin แถวคงที่แบบนี้ใช้ได้ดีกับม่าน แต่เสื้อคลุมหรือกระโปรงส่วนใหญ่จะถูก pin ไว้กับกระดูกที่ขยับได้เอง (กระดูกคอ กระดูกเอว) เพื่อให้ particle ที่ถูก pin เดินตามกระดูกที่กำลังขยับแทนที่จะแช่แข็งอยู่กับ world space ต้องตั้งค่าทั้ง pos[i] และ prevPos[i] ให้เท่ากับตำแหน่งปัจจุบันของกระดูกทุกเฟรม ก่อนที่ Integrate() จะรัน — การตั้งทั้งสองตัว ไม่ใช่แค่ pos[i] อย่างเดียว คือสิ่งที่กัน implied velocity ที่จุดนั้นไม่ให้พุ่งขึ้นเป็นค่าที่ใหญ่และผิดเพี้ยนในจังหวะที่กระดูกขยับ

9. Soft body แบบสั้น ๆ: ก้อนปริมาตรแบบเยลลี่

Soft body เอาแนวคิด particle-กับ-constraint แบบเดิมมาขยายจากผืนแบน (cloth) ให้กลายเป็นก้อนปริมาตรตันที่นุ่มยวบ — นึกถึงก้อนเยลลี่ ผลไม้นุ่ม ๆ หรือมอนสเตอร์ศัตรูที่เด้งได้ แทนที่จะเป็น particle แค่ชั้นเดียว ให้จินตนาการเป็น lattice 3D เต็มรูปทรงทั้งก้อน เชื่อมกันด้วย distance constraint ในทุกทิศทาง ไม่ใช่แค่ข้ามพื้นผิวอย่างเดียว

SOFT BODY (concept): a solid lattice of particles + distance constraints in every direction (not just a flat sheet like cloth), plus a pull back toward a resting shape so it does not collapse. rest shape squashed (a force pushes down) recovering o o o o o o o o o --> o o o o o o --> o o o o o o (flattened, wider) o o o internal constraints resist the squash and pull the shape back afterward

แค่ distance constraint ธรรมดา ๆ อย่างเดียวยังไม่พอที่จะทำให้ soft body ดูสมจริง — lattice ที่ยึดกันด้วย distance constraint ระหว่างเพื่อนบ้านอย่างเดียวยังสามารถแบนลงเรื่อย ๆ ได้ถ้าโดนแรงกดพอ เพราะการทำให้ constraint แต่ละอันในเชิง local พอใจ ไม่ได้การันตีว่ารูปทรงหรือปริมาตรโดยรวมจะคงอยู่ เกมกับเครื่องมือ simulation จึงเพิ่มแนวคิดอีกอันมาทับไว้: shape matching ที่ particle แต่ละตัวจะจำ offset จากรูปทรงพักของวัตถุไว้ด้วย แล้วในแต่ละเฟรมจะถูกดึงเข้าใกล้ตำแหน่งที่ offset นั้นบอกว่ามัน "ควร" อยู่ทีละนิด เทียบกับตำแหน่งเฉลี่ยและ orientation ปัจจุบันของก้อนนั้น:

// shape matching, concept-level pseudocode -- not a full implementation
for each particle i:
    goalPos[i] = currentCenterOfMass
               + currentOrientation * restOffset[i]   // where i "should" be right now
    pos[i] = lerp(pos[i], goalPos[i], stiffness)        // nudge it partway there

การหา currentOrientation — การหมุนที่ fit เข้ากับก้อนทั้งหมดได้ดีที่สุดเทียบกับท่าพักของมัน — คือส่วนที่ยากจริง ๆ ของ shape matching และมันอยู่นอกขอบเขตของบทนี้ ให้มองหัวข้อนี้เป็นแค่แผนที่ระดับแนวคิด ไม่ใช่สูตรให้ไปสร้างเองได้เลย สิ่งสำคัญที่ควรจำไว้คือ cloth, rope และ soft body ล้วนอยู่ในตระกูลเทคนิคเดียวกัน — particle, Verlet integration และ constraint — โดย soft body แค่เพิ่มแรงดึงที่รักษาปริมาตรเข้าไปทับบนกลไก distance-constraint ตัวเดิมจากหัวข้อ 6 ถึง 8

10. Cloth บนตัวละคร: เสื้อคลุม กระโปรง ผม — และต้นทุนด้าน performance

Cloth บนตัวละครโผล่มาบ่อยมากในเกมสไตล์อนิเมะ — เสื้อคลุมที่พลิ้ว กระโปรงพลีท ผมแฝดหรือหางม้าที่แกว่งตามการเคลื่อนไหว ทั้งสามอย่างใช้ชิ้นส่วนพื้นฐาน rope/grid ตัวเดียวกันจากหัวข้อ 7 กับ 8:

ต้นทุนของทั้งหมดนี้เป็นเรื่องจริงและบวกเพิ่มขึ้นเร็วมาก มีสามอย่างที่ผลักดันมัน: จำนวน particle (กริดใหญ่ขึ้นแปลว่างาน integration และ constraint มากขึ้น) จำนวน solver iteration (trade-off จากหัวข้อ 7 — ยิ่งรันหลายรอบยิ่งดูดีแต่ก็ยิ่งแพงตามสัดส่วน) และ collision — การเช็ค particle ของ cloth กับตัวร่างกายของตัวละครเอง เพื่อไม่ให้กระโปรงทะลุขาไปตรง ๆ self-collision แบบเต็มรูปแบบต่อ particle (เช็ค particle ทุกตัวกับ particle ทุกตัวและกับ body mesh ทั้งก้อน) แพงมากพอที่เกมส่วนใหญ่จะข้ามมันไปเลยสำหรับผม แล้วใช้แค่ capsule collider ราคาถูกไม่กี่อันตามแนวร่างกายสำหรับ cloth แทน ยอมรับการทะลุเล็ก ๆ น้อย ๆ เป็นบางครั้งแทน ต้นทุนทั้งหมดนี้ต้องจ่ายหนึ่งรอบต่อตัวละครที่ใส่ cloth หนึ่งตัว ต่อหนึ่งเฟรม — ตัวละครฮีโร่ตัวเดียวที่ใส่เสื้อคลุมหรูหรานั้นถูก แต่ฉากฝูงชนที่มีตัวละครพื้นหลังห้าสิบตัวใส่ชุดคล้าย ๆ กันนั้นไม่ถูกเลย เว้นแต่จะมีอะไรมาลดการ simulation ลงสำหรับตัวที่ผู้เล่นไม่ได้มองใกล้ ๆ

void Update()
{
    float dist = Vector3.Distance(Camera.main.transform.position, transform.position);
    int iterations = dist > 15f ? 2 : solverIterations;   // fewer relax passes far away
    bool freeze = dist > 40f;                              // stop simulating entirely when far off

    if (freeze) return;

    float dt = Mathf.Min(Time.deltaTime, 1f / 30f);
    Integrate(dt);
    for (int it = 0; it < iterations; it++)
        SatisfyConstraints();
}

นี่คือกลยุทธ์ level of detail (LOD) แบบง่าย ๆ: ตัวละครที่อยู่ไกลจากกล้องจะได้ solver iteration น้อยลงมาก (cloth จะดูยืดขึ้นนิดหน่อย แต่ไม่มีใครอยู่ใกล้พอที่จะสังเกตเห็น) และตัวละครที่อยู่ไกลมากพอจะหยุดจำลอง cloth ไปเลย แช่มันไว้ที่ท่าสุดท้าย แบบฝึกหัดที่สามในหัวข้อ 13 จะไล่ตัวเลขจริง ๆ ที่ trade-off นี้ประหยัดได้

11. component Cloth กับ Joint ของ Unity เทียบกับ custom Verlet solver

ทุกอย่างในหัวข้อ 6 ถึง 10 เขียนเองได้ทั้งหมดตามที่โชว์ไว้ หรือจะหยิบเครื่องมือในตัวของ Unity มาใช้ก็ได้ ทั้งสองทางเป็นตัวเลือกที่ใช้ได้จริงทั้งคู่ และการรู้ว่าจะเลือกอันไหนตอนไหนสำคัญกว่าการรู้ฟีเจอร์ครบทุกอย่างของแต่ละตัวเลือก

กฎง่าย ๆ ที่ใช้ได้จริง: หยิบ joint มาใช้เมื่อคุณมีชิ้นส่วนที่แข็งจริง ๆ เชื่อมกันจำนวนน้อย (ragdoll, ข้อโซ่, ป้ายที่แกว่ง) หยิบ component Cloth ในตัวของ Unity มาใช้กับชุดของตัวละครฮีโร่ตัวเดียวเมื่อลุคและความรู้สึกแบบ default ก็เพียงพอแล้วและอยากได้อะไรที่ทำงานได้เร็ว ๆ เขียน custom Verlet solver เองเมื่อต้องการวัตถุ cloth หลายตัวพร้อมกัน (ฉากฝูงชน) ต้องการท่วงท่าที่สไตลไลซ์เฉพาะตัวซึ่ง component ในตัวทำได้ไม่ง่าย หรือต้องการควบคุม performance trade-off เต็มที่แบบ LOD จากหัวข้อ 10

Tip เครื่องมือทั้งสามนี้ไม่ได้แยกขาดจากกันภายในเกมเดียว มันปกติมากที่จะใช้ joint ของ PhysX สำหรับ ragdoll, component Cloth ของ Unity สำหรับเสื้อคลุมฮีโร่ของตัวละครผู้เล่นตัวเดียว และ custom Verlet solver แบบเบา ๆ สำหรับฝูง NPC พื้นหลังที่ใส่ชุดเรียบง่ายกว่า — เลือกเครื่องมือให้เหมาะกับสถานการณ์ ไม่ใช่ใช้เครื่องมือเดียวทั้งเกม

12. อภิธานศัพท์ (Glossary)

13. แบบฝึกหัด

Exercise 1 โซ่ Verlet 3 particle มี rest length 1.0 ระหว่างแต่ละคู่ Particle A ถูก pin ไว้ที่ (0, 0) Particle B กับ C เริ่มต้นอยู่นิ่งที่ (1, 0) กับ (2, 0) (คือตำแหน่งก่อนหน้าเท่ากับตำแหน่งปัจจุบัน — implied velocity เป็นศูนย์) ใส่ Verlet integration step หนึ่งครั้งด้วยความเร่ง a = (0, -1) และ dt = 1 (เลือกให้ตัวเลขลงตัว) ให้ B กับ C จากนั้นรัน constraint satisfaction หนึ่งรอบ โดยทำคู่ A-B ก่อน แล้วค่อยทำคู่ B-C ตามกฎการแบ่งครึ่งจากหัวข้อ 7 (particle ที่ถูก pin ไม่ขยับ ระหว่าง particle อิสระสองตัว แต่ละตัวขยับครึ่งหนึ่งของค่าที่ต้องแก้) หาตำแหน่งของ B กับ C ที่ได้ และระยะห่างระหว่าง A กับ B ที่ได้หลังจากรอบเดียวนี้ ระยะ A-B สุดท้ายนั้นบอกอะไรคุณเกี่ยวกับสาเหตุที่หัวข้อ 7 รัน relaxation ห้ารอบแทนที่จะเป็นรอบเดียว?
Show answer

ขั้นตอน integration เพราะ prevPos = pos สำหรับทั้ง B และ C สูตร Verlet x_next = 2x - x_prev + a*dt*dt จะย่อเหลือเป็น x_next = x + a (เพราะ 2x - x = x และ dt*dt = 1) ดังนั้น B ขยับจาก (1, 0) ไปเป็น (1, -1) และ C ขยับจาก (2, 0) ไปเป็น (2, -1) ส่วน A ยังคงถูก pin อยู่ที่ (0, 0)

Constraint A-B dx = 1, dy = -1 ดังนั้น dist = sqrt(2) ~ 1.4142 diff = (1.4142 - 1) / 1.4142 ~ 0.2929 A ถูก pin ไว้ การแก้ทั้งหมดเลยตกไปที่ B: B = (1 - 1*0.2929, -1 - (-1*0.2929)) = (0.7071, -0.7071) เช็คดู: ระยะจาก A ไป B ตัวใหม่นี้ได้ 1.0000 พอดี ตามที่คาดไว้

Constraint B-C ใช้ B = (0.7071, -0.7071) ที่เพิ่งอัปเดตไปกับ C = (2, -1): dx = 1.2929, dy = -0.2929, dist ~ 1.3257, diff ~ 0.2457 ทั้งคู่เป็นอิสระ แต่ละตัวเลยขยับครึ่งหนึ่ง: B = (0.8659, -0.7431), C = (1.8412, -0.9640) เช็คดู: ระยะจาก B ตัวใหม่นี้ไป C ได้ 1.0000 พอดี

ระยะ A-B หลังรอบนี้: ระยะจาก A (0,0) ไป B ตัวสุดท้าย (0.8659, -0.7431) อยู่ที่ประมาณ 1.1410 — มากกว่า rest length 1.0 อย่างเห็นได้ชัดอีกครั้ง ทั้ง ๆ ที่คู่ A-B เพิ่งถูกทำให้ถูกต้องเป๊ะ ๆ ไปเมื่อสเต็ปก่อนหน้านี้เอง การแก้ B-C ทำให้ B ขยับ ซึ่งไปยืดระยะ A-B ออกอีก นี่แหละคือเหตุผลที่หัวข้อ 7 วน relaxConstraints ห้ารอบแทนที่จะเป็นรอบเดียว: แต่ละรอบเพิ่มเติมจะลด error ที่เหลืออยู่ลงไปอีก เหมือนกับที่สี่เหลี่ยม Riemann-sum เพิ่มขึ้นแต่ละอันในบทที่ 2.5 ทำให้การประมาณเข้าใกล้ค่าอินทิกรัลจริงมากขึ้นทีละนิด โดยไม่ต้องแก้ทุกอย่างให้ถูกต้องเป๊ะในรอบเดียว

Exercise 2 Ragdoll ของตัวละครบอสตัวหนึ่งต้องการ joint ตรง คอ กับ เข่า สำหรับแต่ละจุด: (a) บอกว่าคุณจะใช้ HingeJoint หรือ CharacterJoint และ (b) อธิบายในแง่ที่ว่าส่วนร่างกายจริงเคลื่อนไหวยังไง ว่าทำไมถึงเลือกแบบนั้นถึงถูกต้อง แล้วอธิบายว่าถ้าสลับตัวเลือกทั้งสองจะเกิดอะไรผิดปกติที่มองเห็นได้
Show answer

คอ: CharacterJoint (cone/twist) คอจริง ๆ เอียงไปข้างหน้า ข้างหลัง และด้านข้างได้ แล้วยังบิดได้ด้วย (หันหัวมองรอบ ๆ) — นั่นคือ swing สองทิศทางบวกกับ twist แยกต่างหาก ตรงกับสิ่งที่ swing1Limit, swing2Limit และ twist limit จำลองไว้เป๊ะ ๆ hinge แกนเดียวเป็นตัวแทน "เอียงข้างแล้วก็หันมองซ้ายไปด้วย" พร้อมกันไม่ได้ เพราะ hinge มีแกนหมุนแค่แกนเดียว

เข่า: HingeJoint เข่าจริง ๆ งอไปข้างหน้าได้แค่ในระนาบเดียว คล้าย ๆ บานพับประตู — มันไม่เอียงด้านข้างหรือบิดในการเคลื่อนไหวปกติ มุม min/max เดียวรอบแกนเดียวครอบคลุมสิ่งนี้ได้ครบถ้วน แล้วมันก็ถูกกว่าสำหรับ physics solver ที่จะทำงานกับ constraint แกนเดียว แทนที่จะเป็น cone ที่ไม่จำเป็น

ถ้าสลับกัน: การให้เข่าใช้ CharacterJoint จะทำให้มันงอด้านข้างหรือบิดได้เล็กน้อยเวลาโดนแรงกระแทก ซึ่งดูผิดชัดเจนมาก — เข่าจริง ๆ ไม่โก่งออกด้านข้าง การให้คอใช้ HingeJoint จะล็อกให้หัวงอได้แค่ในระนาบเดียว ดังนั้น ragdoll ที่ล้มตะแคงจะโชว์หัวกระตุกตรงแทนที่จะห้อยเอียงไปด้านข้างอย่างเป็นธรรมชาติ แล้วก็ไม่มีทางโชว์หัวที่บิดได้เลย เพราะ hinge ไม่มีแกน twist เลย

Exercise 3 เสื้อคลุมตัวหนึ่งใช้กริดจากหัวข้อ 8 ด้วย cols = 10, rows = 14 และ solverIterations = 6 รันที่ 60 FPS (a) กริดนี้มี structural constraint รวมทั้งหมดกี่อัน (นับคู่เพื่อนบ้านแนวนอนทุกคู่บวกกับคู่เพื่อนบ้านแนวตั้งทุกคู่) (b) มี constraint-satisfaction call เกิดขึ้นกี่ครั้งต่อวินาที สำหรับตัวละครหนึ่งตัวที่ใส่เสื้อคลุมนี้? (c) ถ้ามีตัวละคร 8 ตัวบนหน้าจอที่ใส่เสื้อคลุมนี้ทั้งหมดที่ full detail จะมี constraint-satisfaction call เกิดขึ้นกี่ครั้งต่อวินาทีรวมทั้งหมด? (d) ใช้แนวคิด LOD จากหัวข้อ 10 สมมติว่า 2 ใน 8 ตัวละครยังอยู่ที่ full detail (6 iteration) ส่วนอีก 6 ตัวอยู่ไกลพอที่จะลดเหลือ 2 iteration ยอดรวม call ต่อวินาทีใหม่คือเท่าไหร่ และคิดเป็นสัดส่วนประมาณเท่าไหร่ของยอดรวมเดิมใน (c)?
Show answer

(a) Constraint แนวนอน: แต่ละแถวใน 14 แถวมี 10 - 1 = 9 คู่เพื่อนบ้านแนวนอน รวมเป็น 14 * 9 = 126 Constraint แนวตั้ง: แต่ละคอลัมน์ใน 10 คอลัมน์มี 14 - 1 = 13 คู่เพื่อนบ้านแนวตั้ง รวมเป็น 10 * 13 = 130 รวมทั้งหมด: 126 + 130 = 256 constraint

(b) ต่อเฟรม: 256 constraints * 6 iterations = 1536 constraint-satisfaction call ต่อวินาทีที่ 60 FPS: 1536 * 60 = 92,160 call ต่อวินาที สำหรับตัวละครหนึ่งตัว

(c) เมื่อทั้ง 8 ตัวละครอยู่ที่ full detail: 92,160 * 8 = 737,280 call ต่อวินาที

(d) ตัวละคร full-detail 2 ตัวยังกินต้นทุนตัวละ 92,160 เท่าเดิม: 2 * 92,160 = 184,320 ส่วนตัวละครที่ลดคุณภาพทั้ง 6 ตัวตอนนี้รันตัวละ 256 constraints * 2 iterations * 60 = 30,720 call ต่อวินาที: 6 * 30,720 = 184,320 ยอดรวมใหม่: 184,320 + 184,320 = 368,640 call ต่อวินาที — เกือบครึ่งหนึ่งพอดีของยอดเดิม 737,280 สำหรับการเปลี่ยนแปลงที่ทำให้ cloth ดูนุ่มลงแบบเห็นได้เฉพาะบนตัวละครที่ผู้เล่นไม่ได้มองใกล้ ๆ เท่านั้น

นั่นคือกล่องเครื่องมือสำหรับจำลองสิ่งที่ไม่ใช่วัตถุแข็งชิ้นเดียว: ragdoll ต่อ rigid body เข้าด้วยกันเป็นโซ่ด้วย hinge กับ cone joint แล้วต้องส่งต่ออย่างระมัดระวังระหว่าง animation กับ physics ตอนตายและตอนฟื้น; cloth, rope และผมใช้ Verlet integration จากบทแคลคูลัสซ้ำ ยึดเข้าด้วยกันด้วย distance constraint ที่แก้ด้วยการ relaxation ซ้ำ ๆ แทนที่จะเป็นแรงสปริงตรง ๆ; soft body ขยายแนวคิดเดียวกันนี้ไปเป็นปริมาตร พร้อมแรงดึงเสริมกลับไปหารูปทรงพัก ไม่มีอันไหนต้องสร้างใหม่ตั้งแต่ต้นทุกครั้งเลย — joint กับ component Cloth ของ Unity ครอบคลุมกรณีใช้งานทั่วไปส่วนใหญ่แล้ว — แต่การรู้ว่า custom Verlet solver จริง ๆ แล้วทำอะไรอยู่ข้างใต้ต่างหากที่ทำให้คุณดันเกินขีดจำกัด default ได้ ตอนที่เกมต้องการ cloth ที่ฉีกขาดได้ ผมที่ทำงานแปลกไปจากปกติ หรืองบ performance ที่เครื่องมือในตัวเอื้อมไปไม่ถึงด้วยตัวเอง

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