8.3 พื้นฐาน Rigging และ Skinning

เฟส 8 · Technical Art · เวลาเรียน: 20–40 h

mesh ถูกผูกกับ skeleton ยังไงให้ตัวละคร deform ถูก — พอที่จะสร้าง debug และ optimize rig และ skinning

1. Rig คืออะไร

character mesh (โมเดล 3D — ผิวหนัง หรือกลุ่มของ polygon ที่เรามองเห็น) ไม่รู้จักวิธีขยับตัวเองเลย มันเป็นแค่รายการยาว ๆ ของ vertex (จุดในพื้นที่ 3 มิติ) ที่เชื่อมกันเป็นสามเหลี่ยม ถ้าอยากให้ตัวละครเดิน โบกมือ หรือชกหมัด ต้องมีอะไรสักอย่างคอยดันจุดพวกนี้ให้ขยับ เฟรมต่อเฟรม โดยที่ยังดูเหมือนร่างกายอยู่

เครื่องมือที่ใช้ทำแบบนั้นเรียกว่า rig (ย่อมาจาก "rigging") rig คือ skeleton (โครงกระดูก) ที่สร้างจาก bone (กระดูก เรียกอีกอย่างว่า joint หรือข้อต่อ) จัดเรียงกันเป็น hierarchy (ลำดับชั้น) — คือ tree แบบ parent/child ชนิดเดียวกับที่เราเคยใช้ตอนเรียนเรื่อง scene graph กับการทำ transform parenting นั่นแหละ แต่ละ bone ไม่มี geometry (รูปทรง) ของตัวเองเลย bone คือแค่ transform (ตำแหน่ง + การหมุน และบางทีก็มี scale ด้วย) ที่อยู่ข้างในตัวละคร จากนั้น mesh จะถูก bind (ผูก) เข้ากับ skeleton นี้ ทำให้เวลา bone ขยับ ผิวหนังบริเวณใกล้ ๆ ก็จะขยับตามไปด้วย

การตั้งค่าสองชั้นแบบนี้ — skeleton ที่มองไม่เห็นคอยขับเคลื่อนผิวหนังที่มองเห็น — ก็เหมือนกับแขนของเราจริง ๆ เลย กระดูกขยับ ผิวหนังกับกล้ามเนื้อก็ตามไป นี่คือเหตุผลตรง ๆ ที่เทคนิคนี้ถูกเรียกว่า skinning

Character mesh (skin) Skeleton (bones / joints) .-''''-. o Head / o o \ | | ^ | o Neck \ ___ / | '------' o Chest | | +-+ | | / \ Shoulders | | o o UpperArms / \ | | o o o o Forearms mesh ไม่รู้วิธีขยับตัวเอง เลยสักนิด skeleton ต่างหาก ที่เป็นตัวขับเคลื่อนมัน

Rigging คืองานสร้าง skeleton แล้วผูกมันเข้ากับ mesh ให้ถูกต้อง ส่วน skinning (อีกครึ่งหนึ่งของบทนี้) คือคณิตศาสตร์ที่ตัดสินว่า สำหรับแต่ละ vertex ควรถูก bone ไหนดันมากแค่ไหน rigging กับ skinning รวมกันคือสิ่งที่ทำให้ mesh เดียวโค้งงอได้ โดยที่ artist ไม่ต้องมานั่งปั้น mesh ใหม่ทุกเฟรมของทุกท่าแอนิเมชัน

2. Bone ก็คือ Transform ที่อยู่ใน Hierarchy

ตรงนี้ไม่มีอะไรใหม่เลย bone ใช้ concept เดียวกับ parent/child transform ที่เราเคยเจอมาแล้ว คือมันมี local transform (ตำแหน่งและการหมุนเทียบกับ parent ของมัน) และมันจะสืบทอดทุกอย่างที่ parent ทำด้วย ถ้าขยับไหล่ แขนบน แขนล่าง และมือก็จะขยับตามไปด้วยทั้งหมด ทั้งที่จริง ๆ แล้วมีแค่ transform ของไหล่ที่เปลี่ยน

แต่พอจะวาดหรือ deform อะไรจริง ๆ สุดท้ายเราต้องการ world transform ของแต่ละ bone (ตำแหน่งและการหมุนในฉากทั้งหมด ไม่ใช่เทียบกับใคร) วิธีหาคือไล่ขึ้นไปตาม parent chain แล้วรวม local transform ทุกอันระหว่างทางเข้าด้วยกัน — world transform ของ parent รวมกับ local transform ของตัวเอง

Hip (root bone) | +-- Spine local offset: (0, 1, 0) from Hip | +-- Chest local offset: (0, 1, 0) from Spine | +-- Shoulder | +-- UpperArm | +-- Forearm | +-- Hand World position of Chest = Hip's world position + Spine's local offset + Chest's local offset

ใน engine จริง การ "รวม" แบบนี้คือการคูณ matrix ขนาด 4x4 (ซึ่งรวมทั้ง rotation กับ scale ด้วย ไม่ใช่แค่ position) แต่แนวคิดก็เหมือนกับการบวกด้านบนเป๊ะ ๆ คือ world transform ของ bone หนึ่ง ๆ คือ world transform ของ parent มันรวมกับ local transform ของตัวมันเอง นี่คือเวอร์ชันย่อที่ใช้ตำแหน่งธรรมดา ๆ เพื่อให้เห็นการไล่ chain โดยยังไม่ต้องใช้คณิตศาสตร์ matrix:

using UnityEngine;

public class Bone {
    public string name;
    public Bone parent;
    public Vector3 localPosition; // offset from parent, set once in the bind pose

    public Bone(string name, Bone parent, Vector3 localPosition) {
        this.name = name;
        this.parent = parent;
        this.localPosition = localPosition;
    }

    // Walk up the parent chain and add up every local offset
    public Vector3 GetWorldPosition() {
        Vector3 worldPos = localPosition;
        Bone current = parent;
        while (current != null) {
            worldPos += current.localPosition;
            current = current.parent;
        }
        return worldPos;
    }
}

public class RigDemo : MonoBehaviour {
    void Start() {
        Bone hip   = new Bone("Hip",   null,  new Vector3(0, 0, 0));
        Bone spine = new Bone("Spine", hip,   new Vector3(0, 1, 0));
        Bone chest = new Bone("Chest", spine, new Vector3(0, 1, 0));

        Debug.Log(chest.name + " world position: " + chest.GetWorldPosition());
    }
}
Output: Chest world position: (0.0, 2.0, 0.0)

Hip อยู่ที่จุด origin Spine อยู่สูงกว่ามัน 1 หน่วย Chest อยู่สูงกว่า Spine อีก 1 หน่วย พอไล่ขึ้นไปตาม chain แล้วบวก offset เข้าด้วยกัน ก็จะได้ (0, 2, 0) skeleton จริง ๆ ใช้ matrix เต็มรูปแบบ เลยรวม rotation เข้าไปด้วย — ถ้า Spine หมุน Chest ก็จะแกว่งตามไปด้วย ไม่ใช่แค่เลื่อนขึ้น — แต่แนวคิดการไล่ขึ้น chain ยังเหมือนเดิม

Tip นี่คือแนวคิด parent/child อันเดียวกับ transform hierarchy ที่เคยเรียนมาก่อนเป๊ะ ๆ rig ไม่ใช่ระบบใหม่ มันคือ transform tree ที่ใส่ชุดคอสตูมเข้าไป แล้วมีงานเพิ่มมาอีกอย่างคือ deform mesh

3. Bind Pose

ก่อนตัวละครจะถูกแอนิเมท skeleton จะถูกวางเข้าไปใน mesh ในท่าที่เป็นกลางท่าหนึ่งโดยเฉพาะ — ปกติแขนกางออกด้านข้างหรือลดต่ำลงมานิดหน่อย ขาตรง เรียกว่า T-pose หรือ A-pose ช่วงเวลานี้เรียกว่า bind pose (เรียกอีกอย่างว่า rest pose): เป็นท่าที่ skeleton อยู่ตอนที่ skin ถูก bind (ผูก) เข้ากับมัน

bind pose สำคัญเพราะ world transform ของทุก bone ณ ช่วงเวลานั้นเป๊ะ ๆ จะถูกบันทึกและเก็บไว้ transform ที่บันทึกไว้นี้เรียกว่า bind matrix — matrix ขนาด 4x4 หนึ่งอันต่อหนึ่ง bone เก็บข้อมูลว่า "bone นี้อยู่ตรงไหนใน world space ตอนที่ mesh ถูก bind เข้ากับมัน" ค่านี้จะไม่เปลี่ยนอีกเลยหลังจากนั้น มันถูกฝังไว้ในไฟล์โมเดลถาวร

Bind pose (T-pose) Animated pose (later, at runtime) o o /|\ /| / | \ / | / | \ / |__ o o o o o arms straight out arm bent at elbow Bind matrices are recorded Bones now differ from the HERE. This is the reference bind pose. Skinning measures everything else is measured "how far have we moved against. from bind?"

ทำไมต้องบันทึกตรงนี้ด้วย เพราะ skinning ไม่ใช่ "ย้าย vertex ไปอยู่ตรงที่ bone อยู่ตอนนี้" แต่มันคือ "ย้าย vertex ตามระยะที่ bone ขยับไป นับตั้งแต่ bind pose" ถ้าไม่มี bind matrix เป็นจุดอ้างอิง engine จะไม่มีทางรู้เลยว่า "ยังไม่ขยับเลย" ของ vertex นั้นหน้าตาเป็นยังไง

4. Skinning: Vertex หนึ่งจุดตาม Bone ได้หลายอัน

Skinning คือกระบวนการขยับ vertex ของ mesh ตามการเคลื่อนไหวของ bone เวอร์ชันง่ายที่สุด: ให้แต่ละ vertex ถูกกำหนดให้อยู่กับ bone เดียวเป๊ะ ๆ แล้วขยับตาม bone นั้นทุกอย่าง วิธีนี้เรียกว่า rigid skinning ซึ่งใช้ได้ดีกับชิ้นส่วนหุ่นยนต์ที่แข็งทื่อ แต่แย่มากตรงข้อต่อ — mesh จะฉีกขาดเป็นชิ้น ๆ แยกจากกันตรงข้อศอกกับหัวเข่าทุกครั้ง เพราะ vertex ใกล้ข้อศอกที่ผูกกับ bone "แขนบน" จะค้างอยู่ที่เดิม ในขณะที่ vertex ที่ผูกกับ "แขนล่าง" ก็บินตามมันไป

ทางแก้คือ ให้ vertex หนึ่งจุดผูกกับ bone ได้มากกว่าหนึ่งอันพร้อมกัน แต่ละอันมี weight (ตัวเลข 0 ถึง 1 บอกว่า bone นั้นมีอิทธิพลมากแค่ไหน) แล้วเอาผลลัพธ์มา blend รวมกัน นี่แหละคือ skinning ในความหมายที่ใช้กันทั่วไป — พูดให้ชัดกว่านั้นคือ linear blend skinning (LBS) เทคนิคที่ใช้กันทั่วไปที่สุด และเป็นค่า default ของแทบทุก game engine

UpperArm bone Forearm bone o--------------------------o--------------------------o Shoulder Elbow Wrist * <- vertex V, on the skin | surface right at the elbow | V's bone weights: UpperArm : 0.5 Forearm : 0.5 V's final position = 0.5 * (where UpperArm thinks V should be) + 0.5 * (where Forearm thinks V should be)

bone แต่ละอันที่มีอิทธิพลต่อ vertex จะคำนวณความเห็นของตัวเองว่า vertex นั้นควรไปอยู่ตรงไหน (โดยดูจากว่า bone นั้นขยับไปเท่าไหร่นับตั้งแต่ bind pose) จากนั้นความเห็นทั้งหมดจะถูก blend เข้าด้วยกันโดยใช้ weight แล้วค่าเฉลี่ยถ่วงน้ำหนักนั้นคือตำแหน่งสุดท้ายของ vertex แค่นี้เลยคือแนวคิดทั้งหมด หัวข้อถัดไปจะไล่ดูว่า "bone คิดว่า vertex ควรอยู่ตรงไหน" คำนวณยังไง แล้วก็ blend มันด้วยตัวเลขจริง ๆ

5. Inverse Bind Matrix

world matrix ปัจจุบันของ bone หนึ่งบอกเราว่า bone นั้นอยู่ตรงไหน ตอนนี้ แต่ vertex ไม่ได้ถูกเก็บแบบอ้างอิงกับ bone มันถูกเก็บอยู่ใน space ของ mesh เอง ที่ตำแหน่งตอน bind pose ก่อนที่เราจะถามได้ว่า "bone นี้ขยับไปเท่าไหร่แล้ว" เราต้องรู้ตำแหน่งของ vertex เทียบกับ bone ตอน bind ก่อน นี่แหละคือหน้าที่ของ inverse bind matrix

inverse bind matrix ก็ตรงตามชื่อเลย คือ inverse ทางคณิตศาสตร์ของ bind matrix ของ bone นั้น (world matrix ที่บันทึกไว้ในหัวข้อ 3) การเอา vertex ที่ตำแหน่ง bind pose คูณกับ inverse bind matrix ของ bone จะแปลง vertex นั้นออกจาก world space ไปเป็น "local space ของ bone นี้ ตอน bind time" พอ vertex ถูกแปลงให้อยู่ในรูปเทียบกับ bone แล้ว การคูณด้วย world matrix ปัจจุบัน ของ bone ก็จะพา vertex นั้นไปด้วย ไม่ว่า bone จะขยับไปไหนนับจากตอนนั้น

bind-pose vertex inverse bind matrix current bone matrix (world space, --> (undo the bind pose, --> (re-apply wherever wherever it was express relative to the bone has moved sculpted) the bone instead) to now) v_bind * InverseBind_bone * CurrentWorld_bone = v_skinned

เมื่อรวมสอง matrix ของ bone หนึ่งอันเข้าด้วยกัน มันจะกลายเป็นสิ่งที่ engine เรียกว่า skin matrix (บางทีก็เรียก "skinning matrix" หรือเป็น slot หนึ่งใน "matrix palette" — array ที่มี skin matrix หนึ่งอันต่อ bone หนึ่งตัว):

// One skin matrix per bone, computed once per frame on the CPU
Matrix4x4 skinMatrix = currentWorldMatrix * inverseBindMatrix;

// inverseBindMatrix itself never changes after the model is loaded --
// it only depends on the bind pose, which is fixed
Matrix4x4 inverseBindMatrix = Matrix4x4.Inverse(boneBindWorldMatrix);

inverse bind matrix ถูกคำนวณแค่ ครั้งเดียว ตอนโหลดโมเดล (มันขึ้นกับ bind pose อย่างเดียว ซึ่งไม่เปลี่ยนแปลง) ส่วน skin matrix จะถูกคำนวณใหม่ทุกเฟรม ทุก bone เพราะ pose ปัจจุบันเปลี่ยนทุกเฟรมตอนแอนิเมท นี่ถูกกว่าการคำนวณอะไรก็ตามใหม่ต่อ vertex บน CPU มาก — ซึ่งเป็นเหตุผลตรง ๆ ที่ trick สองขั้นตอนนี้มีอยู่

Common mistake ลืมใส่ inverse bind matrix แล้ว skin ด้วย current bone matrix แบบดิบ ๆ เป็นบั๊กยอดฮิตของมือใหม่มาก mesh จะระเบิดกลายเป็นก้อนพัวพันตั้งแต่กดเล่นครั้งแรก เพราะทุก vertex จะกระโดดไปอยู่ "ตรงที่ bone อยู่" โดยไม่สนว่า vertex นั้นเคยอยู่ตรงไหนเทียบกับ bone ตั้งแต่แรก

6. Linear Blend Skinning: คำนวณด้วยมือ

ตอนนี้เอาหัวข้อ 4 กับ 5 มารวมกันเป็นสูตรจริง สำหรับ vertex ที่ถูกอิทธิพลจาก bone 1..n แต่ละอันมี weight w_i และ skin matrix M_i (current world matrix คูณด้วย inverse bind matrix จากหัวข้อ 5):

v_skinned = w_1 * (M_1 * v_bind) + w_2 * (M_2 * v_bind) + ... + w_n * (M_n * v_bind) where: v_bind = the vertex's fixed position in the bind pose M_i = skin matrix for bone i = CurrentWorld_i * InverseBind_i w_i = weight of bone i for this vertex (weights should add up to 1.0)

bone แต่ละอันจะคำนวณตำแหน่งที่เป็น candidate ของ vertex ตามความเห็นของตัวเอง (M_i * v_bind) แล้วตำแหน่งสุดท้ายคือค่าเฉลี่ยถ่วงน้ำหนักของ candidate ทั้งหมดนั้น นี่คือเหตุผลที่มันถูกเรียกว่า linear blend skinning: มันคือผลรวมถ่วงน้ำหนักธรรมดา ๆ ไม่มีอะไรซับซ้อนไปกว่านั้น

ตัวอย่างที่คำนวณจริง

ลอง vertex ตรงข้อศอก V จาก diagram ในหัวข้อ 4 ที่ถูก weight 0.5 กับ UpperArm และ 0.5 กับ Forearm ใน bind pose: UpperArm อยู่ที่ world position (0, 0, 0), Forearm (ลูกของมัน) อยู่ที่ (0, 2, 0) และ V อยู่ตรงข้อศอกพอดี ที่ bind position (0, 2, 0) เพื่อให้เลขคำนวณง่าย ตัวอย่างนี้ใช้ตำแหน่งธรรมดาไม่มีการหมุน — engine จริงใช้ matrix 4x4 เต็มรูปแบบ แต่ขั้นตอนการ blend ทำงานเหมือนกันเป๊ะ

ทีนี้ลองแอนิเมทการงอข้อศอก: ให้ UpperArm อยู่ตำแหน่งเดิมเป๊ะ ๆ (current position (0, 0, 0) ไม่เปลี่ยนจาก bind) แต่ให้ Forearm แกว่งไปด้านข้าง จน current position ของมันกลายเป็น (1, 2, 0)

// What UpperArm "thinks" V's position should be:
//   step 1: undo bind pose        -> V relative to UpperArm = (0,2,0) - (0,0,0) = (0,2,0)
//   step 2: re-apply current pose -> (0,0,0) + (0,2,0) = (0,2,0)
Vector3 upperArmOpinion = new Vector3(0, 2, 0);

// What Forearm "thinks" V's position should be:
//   step 1: undo bind pose        -> V relative to Forearm = (0,2,0) - (0,2,0) = (0,0,0)
//   step 2: re-apply current pose -> (1,2,0) + (0,0,0) = (1,2,0)
Vector3 forearmOpinion = new Vector3(1, 2, 0);

float wUpper   = 0.5f;
float wForearm = 0.5f;

Vector3 skinnedPosition = wUpper * upperArmOpinion + wForearm * forearmOpinion;
Debug.Log("Skinned vertex position: " + skinnedPosition);
Output: Skinned vertex position: (0.5, 2.0, 0.0)

UpperArm ไม่ขยับเลย มันเลยยืนยันว่า V ควรอยู่ที่ (0, 2, 0) ต่อไป Forearm แกว่งออกไปที่ (1, 2, 0) มันเลยยืนยันว่า V ควรตามมันไปที่นั่น ผลของการ blend คือหารครึ่งระหว่างสองความเห็น: V ไปจบที่ (0.5, 2, 0) — อยู่ตรงกลางระหว่างสองความเห็น แทนที่จะตาม Forearm ไปเต็ม ๆ แบบที่ผิวหนังจริงจะทำ จำตัวอย่างนี้ไว้ให้ดี เพราะหัวข้อ 8 จะอธิบายว่าทำไมผลลัพธ์ "อยู่ตรงกลาง" แบบนี้แหละคือต้นเหตุของปัญหาที่มองเห็นได้ตรงข้อต่อ

7. Skin Weight กับ Weight Painting

vertex ทุกจุดใน skinned mesh เก็บรายการสั้น ๆ ของคู่ (bone, weight) ไว้ — ปกติสูงสุด 4 bone ต่อ vertex เพราะ 4 ก็เพียงพอสำหรับแทบทุกข้อต่อแล้ว และยังทำให้ข้อมูลต่อ vertex มีขนาดเล็ก weight พวกนี้คือสิ่งที่สูตรในหัวข้อ 6 เรียกว่า w_i และมันควรจะรวมกันได้ 1.0 ต่อ vertex หนึ่งจุด (เรียกว่าเป็น normalized)

// One vertex's skin data, roughly how it is stored in a real mesh
struct VertexSkinData {
    int   boneIndex0, boneIndex1, boneIndex2, boneIndex3;
    float weight0,    weight1,    weight2,    weight3;
    // weight0 + weight1 + weight2 + weight3 should equal 1.0
};

weight พวกนี้มาจากไหน มีสองทาง:

Weight paint view (heat map, red = high weight, blue = low weight) UpperArm bone selected: Forearm bone selected: ((((RRRR)))) ((((BBBB)))) (((RRRRRR))) (((BBBBBB))) ((RRRROOOO)) vs. ((BBBBOOOO)) (OOOOoooo) (OOOOrrrr) (oooo..) (oooo..) red/orange near shoulder, blue near shoulder, fading out toward elbow growing toward elbow

การ weight painting ที่ดีเป็นหนึ่งในทักษะที่มีค่ามากแต่ไม่ค่อยมีใครพูดถึงในงาน character art ตัวละครที่โมเดลสวยแค่ไหน ถ้า weight ทำมาสะเพร่า พอขยับปุ๊บก็ดูพังทันที

8. เมื่อ Weight ผิดพลาด: ปัญหา Candy-Wrapper

ย้อนกลับไปดูตัวอย่างที่คำนวณในหัวข้อ 6 vertex ตรงข้อศอกที่ weight 50/50 ระหว่าง UpperArm กับ Forearm จบลงที่ ค่าเฉลี่ย ของความเห็นสอง bone แทนที่จะตามอันใดอันหนึ่งเต็ม ๆ ทำแบบนี้กับทุก vertex รอบข้อศอก แล้วหน้าตัดทั้งวงของแขนก็จะหดตัวเข้าหาแกนหมุนของข้อต่อตอนข้อศอกงอ — พื้นผิวจะบุ๋มเข้าไปเหมือนกระดาษห่อลูกอมที่ถูกบิด นี่คือที่มาของชื่อเล่นตรงตัวเลย: candy-wrapper problem (เรียกอีกอย่างว่า joint collapse หรือ "beach-ball elbow" เวลาที่มันแย่มากจนดูกลมเป็นลูกบอลแทนที่จะบุ๋ม)

BAD: 50/50 weights, elbow bent 90 degrees GOOD: better weights + more bones UpperArm Forearm UpperArm Forearm ======] ======] | | ) <- pinched, twisted (__) <- keeps roughly ( "candy wrapper" ( ) round volume | cross-section | [====== [====== every vertex near the joint extra weight spread across more is torn 50/50 between two bones/vertices near the joint bones with very different poses avoids any vertex being torn evenly

ต้นตอของปัญหาเหมือนกันเสมอ: มี vertex บางจุดที่ weight เท่ากันเกินไประหว่าง bone สองอันที่ pose ปัจจุบันของมันห่างกันมาก (ข้อต่อที่งอมาก ๆ ก็นับว่า "ห่างกันมาก" ได้เลย ถึงแม้ bone จะยังชนกันอยู่ใน mesh) ทางแก้ที่ rigger ใช้จริง ๆ มีดังนี้:

Tip ถ้าเห็นข้อศอกหรือเข่าของตัวละคร "บิด" หรือ "ยุบ" เฉพาะตอนงอสุดมุม แต่ปกติดีทุกที่ ให้สงสัย weight painting เป็นอันดับแรก มันคือสาเหตุที่พบบ่อยที่สุดอันดับหนึ่งเลย

9. GPU Skinning เกิดขึ้นตรงไหนกันแน่

การคำนวณตำแหน่งของทุก vertex ใหม่บน CPU ทุกเฟรม สำหรับ mesh ที่มี vertex หลายหมื่นจุด จะช้ามาก — และนี่คืองานแบบ "ทำคณิตศาสตร์จำนวนน้อย ๆ ซ้ำ ๆ หลายครั้งแบบอิสระต่อกัน" ที่ GPU ถูกออกแบบมาให้ทำพอดี ดังนั้นเกมจริง ๆ จะทำ skinning บน GPU ข้างใน vertex shader (โปรแกรมเล็ก ๆ ที่รันหนึ่งครั้งต่อหนึ่ง vertex ทุกเฟรม บนการ์ดจอ)

ฝั่ง CPU ทำครั้งเดียวต่อเฟรม

CPU ยังต้องคำนวณส่วนเล็ก ๆ อยู่ดี: ไล่ hierarchy ของ bone หา world matrix ปัจจุบันของแต่ละ bone คูณกับ inverse bind matrix ของมัน แล้วรวมผลลัพธ์เป็น array ของ skin matrix (มักเรียกว่า matrix palette) — หนึ่งช่องต่อหนึ่ง bone อาจจะ 50-100 matrix เล็ก ๆ สำหรับตัวละครทั่วไป array นั้นจะถูกอัปโหลดขึ้น GPU

ฝั่ง GPU ทำครั้งเดียวต่อ vertex แบบขนาน

// Simplified HLSL-style vertex shader skinning
struct VertexInput {
    float3 position : POSITION;
    float4 weights  : BLENDWEIGHT;   // up to 4 bone weights for this vertex
    uint4  boneIDs  : BLENDINDICES;  // which 4 bones from the palette to use
};

float4x4 boneMatrices[MAX_BONES]; // the matrix palette, uploaded once per frame

float3 SkinVertex(VertexInput IN) {
    float4x4 skinMatrix =
          IN.weights.x * boneMatrices[IN.boneIDs.x]
        + IN.weights.y * boneMatrices[IN.boneIDs.y]
        + IN.weights.z * boneMatrices[IN.boneIDs.z]
        + IN.weights.w * boneMatrices[IN.boneIDs.w];

    return mul(skinMatrix, float4(IN.position, 1.0)).xyz;
}

นี่คือสูตรเดียวกับหัวข้อ 6 เป๊ะ ๆ แค่เขียนในแบบที่ shader เขียนจริง ๆ: สร้าง skin matrix รวมหนึ่งอันต่อ vertex จาก bone matrix ที่ถ่วงน้ำหนักสูงสุด 4 อัน แล้วแปลงตำแหน่ง vertex ตอน bind pose ด้วยมัน vertex ทุกจุดบน mesh รันฟังก์ชันเล็ก ๆ อันเดียวกันนี้แบบอิสระต่อกันและพร้อมกันหมด นี่คือเหตุผลที่ vertex หลายพันจุดทำ skinning เสร็จในเวลาต่ำกว่าหนึ่งมิลลิวินาทีบน hardware สมัยใหม่

Common mistake vertex shader ไม่รู้จักและไม่เกี่ยวข้องกับ vertex อื่นเลย ถ้า vertex ที่อยู่ติดกันสองจุดมีสัดส่วน weight ต่างกันมาก (จุดหนึ่ง 90% UpperArm อีกจุดที่ห่างกันแค่เศษเซนติเมตร 90% Forearm) shader ก็จะ skin มันแบบต่างกันสุดขั้วโดยไม่สนใจอะไรเลย แล้วรอยต่อระหว่างสอง vertex นั้นก็จะฉีกหรือบุ๋ม — เป็นปัญหา candy-wrapper อีกแบบหนึ่ง คราวนี้เกิดจาก weight ที่เปลี่ยนกะทันหัน แทนที่จะเป็นการแบ่งเท่า ๆ กัน

10. Dual Quaternion Skinning: Blend ที่ดีกว่าแต่แพงกว่า

Linear blend skinning มีปัญหา candy-wrapper เพราะมัน blend position ของ bone เป็นเส้นตรง — การเฉลี่ยสอง pose ที่หมุนต่างกันใน space ธรรมดาจะตัดมุมเสมอ ซึ่งนั่นแหละคือที่มาของการเสียปริมาตร Dual quaternion skinning (DQS) แก้ปัญหานี้โดย blend การหมุน ของแต่ละ bone ด้วย dual quaternion (วัตถุทางคณิตศาสตร์ขนาดกะทัดรัดที่แทนทั้งการหมุนและการเลื่อนตำแหน่งไว้ด้วยกัน สร้างต่อยอดจาก quaternion ที่อาจจะเคยเจอมาแล้วตอนเรียนเรื่องการแทนค่าการหมุน) แทนที่จะ blend position ดิบ ๆ

ไม่จำเป็นต้องเข้าใจพีชคณิตของ quaternion ก็เข้าใจข้อแลกเปลี่ยนนี้ได้: DQS blend "bone นี้หมุนไปเท่าไหร่ และขยับไปอยู่ไหน" ในแบบที่กวาดไปตามเส้นโค้ง แทนที่จะตัดเป็นเส้นตรงระหว่างสอง pose มันเลยรักษาปริมาตรรอบข้อต่อได้ดีกว่ามาก — บ่อยครั้งแก้ปัญหา candy-wrapper ได้โดยไม่ต้องเพิ่ม bone หรือ corrective shape เลย ต้นทุนคือมันคำนวณต่อ vertex แพงกว่าผลรวมถ่วงน้ำหนักธรรมดาของ LBS พอสมควร และมันอาจสร้าง artifact แบบใหม่ของตัวเอง (ข้อต่อ "บวม" แทนที่จะบุ๋ม) ถ้าใช้แบบไม่ระวัง

Linear Blend Skinning (LBS) Dual Quaternion Skinning (DQS) blends POSITIONS in a straight line blends ROTATIONS along a curve A o A o | | | <- straight cut, ) <- follows the | loses volume ( actual bend B o B o

engine ส่วนใหญ่ให้เราเลือกได้เป็นราย character หรือราย mesh ว่าจะใช้ LBS หรือ DQS (บางทีก็เป็น shader variant ให้เลือก บางทีก็เป็น setting ตอน import) เพราะ DQS ไม่ได้ดีกว่าเสมอไปในทุกกรณี มันคือข้อแลกเปลี่ยนคนละแบบ สำหรับมือใหม่ สิ่งสำคัญที่ต้องจำคือ ถ้าข้อศอกหรือไหล่ยังยุบอยู่หลังจากแก้ weight painting แล้ว DQS คือเครื่องมือถัดไปที่ควรหยิบมาใช้ ไม่ใช่ปริศนาที่ต้องแก้ด้วยการเพิ่ม corrective bone ไปเรื่อย ๆ

11. ส่วนประกอบทั่วไปของ Rig: Controls กับ IK Handle

ทุกอย่างที่พูดมาจนถึงตอนนี้เป็นเรื่องของ deform bone — bone ที่ขับเคลื่อน mesh จริง ๆ ผ่าน skinning แต่ animator แทบไม่เคยจับ deform bone โดยตรงเลย rig จริง ๆ จะมีชั้นที่สองซ้อนอยู่ข้างบน สร้างขึ้นมาเพื่อให้งานของ animator ง่ายขึ้นล้วน ๆ

Controls

control คือรูปทรงง่าย ๆ (วงกลม ลูกศร กล่อง — ไม่ถูก render ในเกมจริงเลย) ที่วางไว้ในฉากให้ animator คลิกแล้วลากได้ การขยับ control ไม่ได้ deform mesh โดยตรง แต่มันขับเคลื่อน deform bone หนึ่งอันหรือมากกว่าที่ซ่อนอยู่ข้างล่าง มักผ่าน logic เพิ่มเติม (constraint) ที่ rigger ตั้งค่าไว้ control มีไว้เพื่อให้งานของ animator เป็น "จับวงกลมตรงมือแล้วลาก" ไม่ใช่ "ไล่หาชื่อ bone จากลิสต์ 80 ชื่อ"

Forward Kinematics (FK) กับ Inverse Kinematics (IK)

มีสองวิธีในการจัด pose ให้ chain ของ bone อย่างเช่นแขน:

Forward Kinematics (FK) Inverse Kinematics (IK) you rotate each joint yourself you place one target, rig solves the rest Shoulder -- rotate 30 deg Hand target o <- you place this | | Elbow -- rotate 45 deg (solver works backward from here) | | Hand ends up wherever Elbow angle -- solved automatically this chain puts it | Shoulder angle -- solved automatically

บทนี้จะไม่ลงลึกว่า IK solver คำนวณมุมพวกนั้นได้ยังไง (นั่นเป็นหัวข้อของมันเองต่างหาก) — เป้าหมายตรงนี้แค่ให้จำ concept ได้ว่า: IK handle กับ control เป็นชั้นความสะดวกสำหรับ animator ที่ซ้อนอยู่บน deform-bone skeleton ตัวเดียวกับที่บทนี้พูดถึงมาตลอด เบื้องหลังแล้ว พอ solver ตัดสินใจได้ว่ามุมหมุนสุดท้ายของ bone เป็นเท่าไหร่ ทุกอย่างตั้งแต่หัวข้อ 2 เป็นต้นไป — bind matrix, inverse bind matrix, skin matrix, linear blend skinning — ก็ทำงานเหมือนเดิมเป๊ะ ไม่ว่ามุมหมุนนั้นจะมาจาก FK, IK หรือ physics simulation ก็ตาม

12. Pipeline เต็มรูปแบบ ตั้งแต่ต้นจนจบ

นี่คือทุกอย่างจากบทนี้เรียงตามลำดับที่มันเกิดขึ้นจริง แบ่งเป็น "ตั้งค่าครั้งเดียว" กับ "ทำทุกเฟรม":

SET UP ONCE (when the character is made / imported) 1. Artist builds the skeleton (bone hierarchy) inside the mesh, in the bind pose 2. Each bone's bind-pose world matrix is recorded 3. Each bone's inverse bind matrix is computed (inverse of step 2) 4. Artist assigns skin weights per vertex (auto weights + hand weight painting) EVERY FRAME (at runtime, while the game is playing) 5. Animation system sets each bone's CURRENT local transform (from a keyframed clip, IK solve, ragdoll physics, etc.) 6. Engine walks the hierarchy: local transforms combine into current WORLD matrices, parent to child 7. CPU computes one skin matrix per bone: skinMatrix = currentWorldMatrix * inverseBindMatrix 8. Skin matrices upload to the GPU as a matrix palette 9. Vertex shader runs for every vertex, in parallel: blend up to 4 weighted skin matrices, transform the bind-pose vertex, output the final skinned position 10. GPU rasterizes the now-deformed mesh to the screen

สิบขั้นตอน แต่จริง ๆ มีงานแค่สองแบบ: การตั้งค่าครั้งเดียวที่ artist ทำด้วยมือ กับ loop ที่ทำทุกเฟรมที่ engine รันซ้ำประมาณ 60 ครั้งต่อวินาที แบบอัตโนมัติเต็มรูปแบบ สำหรับตัวละครทุกตัวบนหน้าจอ

Glossary

Exercise 1 skeleton หนึ่งมี 4 bone เรียงเป็น chain แต่ละอันเก็บแค่ local position offset จาก parent ของมัน (ไม่มีการหมุน เพื่อให้เรื่องง่าย):
Root      parent: none    localPosition: (0, 0, 0)
Thigh     parent: Root    localPosition: (0, -1, 0)
Shin      parent: Thigh   localPosition: (0, -1, 0)
Foot      parent: Shin    localPosition: (0, -0.5, 0)
ใช้วิธี "บวกไล่ตาม chain" แบบเดียวกับหัวข้อ 2 คำนวณ world position ของ Foot ด้วยมือ จากนั้นเขียน C# สั้น ๆ (จะใช้ class Bone จากหัวข้อ 2 ซ้ำก็ได้) เพื่อ print ค่าออกมา แล้วเช็คว่าคำตอบตรงกัน
Show answer

บวก local offset ทุกอันจาก Foot ขึ้นไปถึง Root: (0, -0.5, 0) + (0, -1, 0) + (0, -1, 0) + (0, 0, 0) = (0, -2.5, 0)

Bone root  = new Bone("Root",  null,  new Vector3(0, 0, 0));
Bone thigh = new Bone("Thigh", root,  new Vector3(0, -1, 0));
Bone shin  = new Bone("Shin",  thigh, new Vector3(0, -1, 0));
Bone foot  = new Bone("Foot",  shin,  new Vector3(0, -0.5f, 0));

Debug.Log(foot.name + " world position: " + foot.GetWorldPosition());
// Output: Foot world position: (0.0, -2.5, 0.0)

Foot จบลงที่ 2.5 หน่วยด้านล่างของ Root ซึ่งสมเหตุสมผล เพราะมันคือผลรวมความยาวขาทั้งหมด ตรงลงไปข้างล่าง เนื่องจากไม่มี bone ไหนถูกหมุนเลยในตัวอย่างที่ทำให้ง่ายนี้

Exercise 2 vertex ตรงหัวเข่า V ถูก weight 0.8 กับ bone Thigh และ 0.2 กับ bone Shin หลังจากงอเข่า: ความเห็นของ Thigh ว่า V ควรอยู่ตรงไหนคือ (0, -1.0, 0.1) และความเห็นของ Shin ว่า V ควรอยู่ตรงไหนคือ (0, -1.0, 0.6) ใช้สูตร linear blend skinning จากหัวข้อ 6 คำนวณตำแหน่งสุดท้ายของ V หลัง skinning จากนั้นอธิบายในหนึ่งประโยคว่า vertex นี้มีความเสี่ยงสูงหรือต่ำที่จะเกิด candy-wrapper artifact ที่มองเห็นได้ พร้อมเหตุผล
Show answer
Vector3 thighOpinion = new Vector3(0, -1.0f, 0.1f);
Vector3 shinOpinion  = new Vector3(0, -1.0f, 0.6f);
float wThigh = 0.8f;
float wShin  = 0.2f;

Vector3 skinnedPosition = wThigh * thighOpinion + wShin * shinOpinion;
Debug.Log("Skinned vertex position: " + skinnedPosition);
// Output: Skinned vertex position: (0.0, -1.0, 0.2)

ความเสี่ยงต่ำ สัดส่วน weight (0.8 / 0.2) ห่างจาก 50/50 มาก ทำให้ vertex เอียงไปทางความเห็นของ Thigh เป็นหลัก (0.2 อยู่ใกล้กับค่า 0.1 ของ Thigh ไม่ได้อยู่กึ่งกลางไปทาง 0.6 ของ Shin) vertex ที่ weight ใกล้ 50/50 อย่างตัวอย่างข้อศอกในหัวข้อ 6 ต่างหากที่จะถูกฉีกเท่า ๆ กันระหว่างสอง pose ที่ต่างกันมาก แล้วเกิดการบุ๋มให้เห็นชัด

Exercise 3 rigger ส่ง vertex skin data นี้มาให้ แล้วบอกว่าไหล่ในเกม "ดูละลาย" แต่ในโปรแกรมโมเดลปกติดี:
struct VertexSkinData {
    int   boneIndex0 = 3,    boneIndex1 = 7;
    float weight0    = 0.7f, weight1    = 0.5f;
};
หาบั๊กให้เจอ อธิบายว่าทำไมมันถึงทำให้ mesh บิดเบี้ยวให้เห็นได้ แล้วเขียนเวอร์ชันที่แก้ไขแล้ว
Show answer

weight รวมกันไม่ได้ 1.0 — 0.7 + 0.5 = 1.2 สูตรในหัวข้อ 6 สมมติว่า weight เป็นค่าเฉลี่ยถ่วงน้ำหนักที่ถูกต้อง ถ้ารวมกันได้มากกว่า 1 vertex จะถูกดันไปไกลเกินไปตามอิทธิพลของทุก bone (ผลของแต่ละ skin matrix ถูกนับซ้ำเกิน) ทำให้ mesh ยืดหรือ "ละลาย" ออกไปรอบ ๆ vertex นั้น นี่คือบั๊กคลาสสิกของ un-normalized weights ซึ่งมักเกิดจากการแก้ไข weight data ด้วยมือ หรือขั้นตอน export ที่พัง

struct VertexSkinData {
    int   boneIndex0 = 3,     boneIndex1 = 7;
    float weight0    = 0.58f, weight1    = 0.42f; // normalized: 0.7 / 1.2 and 0.5 / 1.2
};

การหารแต่ละ weight ด้วยผลรวมเดิม (1.2) จะปรับสัดส่วนให้กลับมารวมกันได้ 1.0 พอดีอีกครั้ง โดยยังคงสัดส่วนสัมพัทธ์ระหว่างสอง bone ไว้เหมือนเดิม (bone 3 ยังคงมีอิทธิพลมากกว่า bone 7 แค่ปรับสัดส่วนให้ถูกต้อง)

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