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
Rigging คืองานสร้าง skeleton แล้วผูกมันเข้ากับ mesh ให้ถูกต้อง ส่วน skinning (อีกครึ่งหนึ่งของบทนี้) คือคณิตศาสตร์ที่ตัดสินว่า สำหรับแต่ละ vertex ควรถูก bone ไหนดันมากแค่ไหน rigging กับ skinning รวมกันคือสิ่งที่ทำให้ mesh เดียวโค้งงอได้ โดยที่ artist ไม่ต้องมานั่งปั้น mesh ใหม่ทุกเฟรมของทุกท่าแอนิเมชัน
ตรงนี้ไม่มีอะไรใหม่เลย bone ใช้ concept เดียวกับ parent/child transform ที่เราเคยเจอมาแล้ว คือมันมี local transform (ตำแหน่งและการหมุนเทียบกับ parent ของมัน) และมันจะสืบทอดทุกอย่างที่ parent ทำด้วย ถ้าขยับไหล่ แขนบน แขนล่าง และมือก็จะขยับตามไปด้วยทั้งหมด ทั้งที่จริง ๆ แล้วมีแค่ transform ของไหล่ที่เปลี่ยน
แต่พอจะวาดหรือ deform อะไรจริง ๆ สุดท้ายเราต้องการ world transform ของแต่ละ bone (ตำแหน่งและการหมุนในฉากทั้งหมด ไม่ใช่เทียบกับใคร) วิธีหาคือไล่ขึ้นไปตาม parent chain แล้วรวม local transform ทุกอันระหว่างทางเข้าด้วยกัน — world transform ของ parent รวมกับ local transform ของตัวเอง
ใน 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());
}
}
Hip อยู่ที่จุด origin Spine อยู่สูงกว่ามัน 1 หน่วย Chest อยู่สูงกว่า Spine อีก 1 หน่วย พอไล่ขึ้นไปตาม chain แล้วบวก offset เข้าด้วยกัน ก็จะได้ (0, 2, 0) skeleton จริง ๆ ใช้ matrix เต็มรูปแบบ เลยรวม rotation เข้าไปด้วย — ถ้า Spine หมุน Chest ก็จะแกว่งตามไปด้วย ไม่ใช่แค่เลื่อนขึ้น — แต่แนวคิดการไล่ขึ้น chain ยังเหมือนเดิม
ก่อนตัวละครจะถูกแอนิเมท 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 เข้ากับมัน" ค่านี้จะไม่เปลี่ยนอีกเลยหลังจากนั้น มันถูกฝังไว้ในไฟล์โมเดลถาวร
ทำไมต้องบันทึกตรงนี้ด้วย เพราะ skinning ไม่ใช่ "ย้าย vertex ไปอยู่ตรงที่ bone อยู่ตอนนี้" แต่มันคือ "ย้าย vertex ตามระยะที่ bone ขยับไป นับตั้งแต่ bind pose" ถ้าไม่มี bind matrix เป็นจุดอ้างอิง engine จะไม่มีทางรู้เลยว่า "ยังไม่ขยับเลย" ของ vertex นั้นหน้าตาเป็นยังไง
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
bone แต่ละอันที่มีอิทธิพลต่อ vertex จะคำนวณความเห็นของตัวเองว่า vertex นั้นควรไปอยู่ตรงไหน (โดยดูจากว่า bone นั้นขยับไปเท่าไหร่นับตั้งแต่ bind pose) จากนั้นความเห็นทั้งหมดจะถูก blend เข้าด้วยกันโดยใช้ weight แล้วค่าเฉลี่ยถ่วงน้ำหนักนั้นคือตำแหน่งสุดท้ายของ vertex แค่นี้เลยคือแนวคิดทั้งหมด หัวข้อถัดไปจะไล่ดูว่า "bone คิดว่า vertex ควรอยู่ตรงไหน" คำนวณยังไง แล้วก็ blend มันด้วยตัวเลขจริง ๆ
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 จะขยับไปไหนนับจากตอนนั้น
เมื่อรวมสอง 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 สองขั้นตอนนี้มีอยู่
ตอนนี้เอาหัวข้อ 4 กับ 5 มารวมกันเป็นสูตรจริง สำหรับ vertex ที่ถูกอิทธิพลจาก bone 1..n แต่ละอันมี weight w_i และ skin matrix M_i (current world matrix คูณด้วย inverse bind matrix จากหัวข้อ 5):
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);
UpperArm ไม่ขยับเลย มันเลยยืนยันว่า V ควรอยู่ที่ (0, 2, 0) ต่อไป Forearm แกว่งออกไปที่ (1, 2, 0) มันเลยยืนยันว่า V ควรตามมันไปที่นั่น ผลของการ blend คือหารครึ่งระหว่างสองความเห็น: V ไปจบที่ (0.5, 2, 0) — อยู่ตรงกลางระหว่างสองความเห็น แทนที่จะตาม Forearm ไปเต็ม ๆ แบบที่ผิวหนังจริงจะทำ จำตัวอย่างนี้ไว้ให้ดี เพราะหัวข้อ 8 จะอธิบายว่าทำไมผลลัพธ์ "อยู่ตรงกลาง" แบบนี้แหละคือต้นเหตุของปัญหาที่มองเห็นได้ตรงข้อต่อ
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 painting ที่ดีเป็นหนึ่งในทักษะที่มีค่ามากแต่ไม่ค่อยมีใครพูดถึงในงาน character art ตัวละครที่โมเดลสวยแค่ไหน ถ้า weight ทำมาสะเพร่า พอขยับปุ๊บก็ดูพังทันที
ย้อนกลับไปดูตัวอย่างที่คำนวณในหัวข้อ 6 vertex ตรงข้อศอกที่ weight 50/50 ระหว่าง UpperArm กับ Forearm จบลงที่ ค่าเฉลี่ย ของความเห็นสอง bone แทนที่จะตามอันใดอันหนึ่งเต็ม ๆ ทำแบบนี้กับทุก vertex รอบข้อศอก แล้วหน้าตัดทั้งวงของแขนก็จะหดตัวเข้าหาแกนหมุนของข้อต่อตอนข้อศอกงอ — พื้นผิวจะบุ๋มเข้าไปเหมือนกระดาษห่อลูกอมที่ถูกบิด นี่คือที่มาของชื่อเล่นตรงตัวเลย: candy-wrapper problem (เรียกอีกอย่างว่า joint collapse หรือ "beach-ball elbow" เวลาที่มันแย่มากจนดูกลมเป็นลูกบอลแทนที่จะบุ๋ม)
ต้นตอของปัญหาเหมือนกันเสมอ: มี vertex บางจุดที่ weight เท่ากันเกินไประหว่าง bone สองอันที่ pose ปัจจุบันของมันห่างกันมาก (ข้อต่อที่งอมาก ๆ ก็นับว่า "ห่างกันมาก" ได้เลย ถึงแม้ bone จะยังชนกันอยู่ใน mesh) ทางแก้ที่ rigger ใช้จริง ๆ มีดังนี้:
การคำนวณตำแหน่งของทุก vertex ใหม่บน CPU ทุกเฟรม สำหรับ mesh ที่มี vertex หลายหมื่นจุด จะช้ามาก — และนี่คืองานแบบ "ทำคณิตศาสตร์จำนวนน้อย ๆ ซ้ำ ๆ หลายครั้งแบบอิสระต่อกัน" ที่ GPU ถูกออกแบบมาให้ทำพอดี ดังนั้นเกมจริง ๆ จะทำ skinning บน GPU ข้างใน vertex shader (โปรแกรมเล็ก ๆ ที่รันหนึ่งครั้งต่อหนึ่ง vertex ทุกเฟรม บนการ์ดจอ)
CPU ยังต้องคำนวณส่วนเล็ก ๆ อยู่ดี: ไล่ hierarchy ของ bone หา world matrix ปัจจุบันของแต่ละ bone คูณกับ inverse bind matrix ของมัน แล้วรวมผลลัพธ์เป็น array ของ skin matrix (มักเรียกว่า matrix palette) — หนึ่งช่องต่อหนึ่ง bone อาจจะ 50-100 matrix เล็ก ๆ สำหรับตัวละครทั่วไป array นั้นจะถูกอัปโหลดขึ้น GPU
// 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 สมัยใหม่
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 แบบใหม่ของตัวเอง (ข้อต่อ "บวม" แทนที่จะบุ๋ม) ถ้าใช้แบบไม่ระวัง
engine ส่วนใหญ่ให้เราเลือกได้เป็นราย character หรือราย mesh ว่าจะใช้ LBS หรือ DQS (บางทีก็เป็น shader variant ให้เลือก บางทีก็เป็น setting ตอน import) เพราะ DQS ไม่ได้ดีกว่าเสมอไปในทุกกรณี มันคือข้อแลกเปลี่ยนคนละแบบ สำหรับมือใหม่ สิ่งสำคัญที่ต้องจำคือ ถ้าข้อศอกหรือไหล่ยังยุบอยู่หลังจากแก้ weight painting แล้ว DQS คือเครื่องมือถัดไปที่ควรหยิบมาใช้ ไม่ใช่ปริศนาที่ต้องแก้ด้วยการเพิ่ม corrective bone ไปเรื่อย ๆ
ทุกอย่างที่พูดมาจนถึงตอนนี้เป็นเรื่องของ deform bone — bone ที่ขับเคลื่อน mesh จริง ๆ ผ่าน skinning แต่ animator แทบไม่เคยจับ deform bone โดยตรงเลย rig จริง ๆ จะมีชั้นที่สองซ้อนอยู่ข้างบน สร้างขึ้นมาเพื่อให้งานของ animator ง่ายขึ้นล้วน ๆ
control คือรูปทรงง่าย ๆ (วงกลม ลูกศร กล่อง — ไม่ถูก render ในเกมจริงเลย) ที่วางไว้ในฉากให้ animator คลิกแล้วลากได้ การขยับ control ไม่ได้ deform mesh โดยตรง แต่มันขับเคลื่อน deform bone หนึ่งอันหรือมากกว่าที่ซ่อนอยู่ข้างล่าง มักผ่าน logic เพิ่มเติม (constraint) ที่ rigger ตั้งค่าไว้ control มีไว้เพื่อให้งานของ animator เป็น "จับวงกลมตรงมือแล้วลาก" ไม่ใช่ "ไล่หาชื่อ bone จากลิสต์ 80 ชื่อ"
มีสองวิธีในการจัด pose ให้ chain ของ bone อย่างเช่นแขน:
บทนี้จะไม่ลงลึกว่า 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 ก็ตาม
นี่คือทุกอย่างจากบทนี้เรียงตามลำดับที่มันเกิดขึ้นจริง แบ่งเป็น "ตั้งค่าครั้งเดียว" กับ "ทำทุกเฟรม":
สิบขั้นตอน แต่จริง ๆ มีงานแค่สองแบบ: การตั้งค่าครั้งเดียวที่ artist ทำด้วยมือ กับ loop ที่ทำทุกเฟรมที่ engine รันซ้ำประมาณ 60 ครั้งต่อวินาที แบบอัตโนมัติเต็มรูปแบบ สำหรับตัวละครทุกตัวบนหน้าจอ
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 ค่าออกมา แล้วเช็คว่าคำตอบตรงกัน
บวก 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 ไหนถูกหมุนเลยในตัวอย่างที่ทำให้ง่ายนี้
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 ที่ต่างกันมาก แล้วเกิดการบุ๋มให้เห็นชัด
struct VertexSkinData {
int boneIndex0 = 3, boneIndex1 = 7;
float weight0 = 0.7f, weight1 = 0.5f;
};
หาบั๊กให้เจอ อธิบายว่าทำไมมันถึงทำให้ mesh บิดเบี้ยวให้เห็นได้ แล้วเขียนเวอร์ชันที่แก้ไขแล้ว
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 แค่ปรับสัดส่วนให้ถูกต้อง)