ตอนนี้เรามีเครื่องมือ 3 ใน 4 อย่างที่บทนี้ต้องใช้แล้ว บท transform สอนไปแล้วว่า world matrix ของ parent กับ local matrix ของ child รวมกันยังไงทีละ mul() เพื่อวางลำดับชั้น (hierarchy) ของวัตถุลงในโลก พร้อมกฎ "model = T * R * S" สเกลก่อน หมุนทีหลัง แล้วค่อยย้ายตำแหน่งเป็นขั้นตอนสุดท้าย บท quaternion ให้วิธีเก็บ orientation แบบสะอาด ๆ เป็น (w, x, y, z) รวมการหมุนด้วย quatMultiply และ blend ระหว่าง orientation สองอันแบบนุ่มนวลด้วย quatSlerp บท rigging แนะนำแนวคิดของ skeleton ที่ผูกกับ mesh และ bone weight ที่บอกว่าแต่ละ bone ควรลาก vertex ไปด้วยมากแค่ไหน บทนี้คือจุดที่ทุกอย่างรวมกันกลายเป็นตัวละครที่ขยับได้จริงและมี skin จริง พอจบบทนี้เราจะรู้ทุกขั้นตอน ทีละ matrix ว่าเกิดอะไรขึ้นระหว่าง "นี่คือ animation เดินหนึ่งชุด" กับ "นี่คือ mesh ที่กำลังงอเข่าอยู่บนหน้าจอ"
ลองนึกภาพวิธี animate ตัวละครแบบตรงไปตรงมาที่สุด: ทุกเฟรมของท่าเดิน เก็บตำแหน่งของ vertex ทุกตัวใน mesh เอาไว้ "keyframe" หนึ่งอันก็คือภาพรวมทั้ง mesh ณ ขณะนั้น วิธีนี้เรียกว่า vertex animation (หรือ morph-target animation ถ้า blend ระหว่าง snapshot เต็ม ๆ แบบนี้จำนวนไม่กี่อัน) และมันเข้าใจง่ายจริง ๆ
แต่พอ scale ขึ้นมันพังทันที mesh ตัวละครที่มี 10,000 vertex ถ้า animate ที่ 30 keyframe ต่อวินาที สำหรับท่าเดินยาว 2 วินาที จะต้องใช้ 10,000 * 60 * 3 float แค่สำหรับ clip เดียว — ตัวเลขกว่าล้านตัว สำหรับ animation เดียว ตัวละครเดียว อยากได้ท่าวิ่งด้วยไหม? ก็ต้องเก็บสำเนาเต็ม ๆ อีกชุด อยากให้ท่าเดินกับท่าวิ่ง blend เข้าหากันแบบนุ่มนวลตอนผู้เล่นเริ่มวิ่งสปรินต์ไหม? ไม่มีวิธีที่สะอาดในการ blend snapshot เต็มสอง snapshot ที่ต่างคนต่างสร้างขึ้นมาแยกกัน — เราจะกลายเป็นการ interpolate ตัวเลขดิบ ๆ ที่ไม่เชื่อมโยงกัน โดยไม่รู้เลยว่าตัวเลขไหนคือ vertex ไหนบน mesh ขณะที่มันเปลี่ยนรูปไป
Skeletal animation แก้ปัญหาทั้งสองข้อนี้ด้วยการ animate สิ่งที่เล็กกว่า mesh มาก ๆ นั่นคือ skeleton ชุด bone (เรียกอีกชื่อว่า joint — สองคำนี้ความหมายเดียวกันในบริบทนี้) ที่ตัดทอนมาแล้วเหลือประมาณ 30 ถึง 150 ชิ้น มีแค่ bone เท่านั้นที่ขยับ ส่วน vertex จริง ๆ ของ mesh จะอยู่นิ่งสัมพัทธ์กับ bone ที่มันผูกอยู่ และตามไปเองโดยอัตโนมัติ skeleton หนึ่งชุด animation clip เล็ก ๆ ไม่กี่อัน และ mesh หนึ่งชิ้นที่ผูกไว้ครั้งเดียว — ชุดนี้แทนที่การเก็บ snapshot เต็ม mesh แยกสำหรับทุกท่าที่เราต้องการได้ทั้งหมด
ที่เหลือของบทนี้จะตอบคำถามเดียวเป็นขั้น ๆ: เมื่อมี skeleton กับ animation clip แล้ว เราจะแปลงตัวเลข bone เล็ก ๆ ไม่กี่ตัวให้กลายเป็นตำแหน่งสุดท้ายของ vertex ทุกตัวบนหน้าจอ 60 ครั้งต่อวินาทีได้ยังไง?
เรารู้อยู่แล้วว่า Transform เก็บ position, rotation, scale และ world matrix ของ child มาจากการเอา world matrix ของ parent คูณกับ local matrix ของตัวเอง Skeleton ก็คือแนวคิดเดียวกันนี้เป๊ะ ๆ แค่เอาไปใช้กับ bone แทนที่จะเป็น scene object: มันคือ tree ของ transform ที่ตำแหน่งและการหมุนของแต่ละ bone ถูกเก็บ สัมพัทธ์กับ bone parent ของมัน ไม่ใช่สัมพัทธ์กับโลก
นี่ไม่ใช่เรื่องบังเอิญ และไม่ใช่แนวคิดใหม่ — มันคือ transform hierarchy ที่เรารู้จักแล้ว แค่ใส่หมวกคนละใบ หมุน bone ไหล่ แล้ว bone ทุกอันที่อยู่ใต้มันใน tree — ต้นแขน ปลายแขน มือ นิ้ว — จะกวาดตามไปด้วยอัตโนมัติ ด้วยเหตุผลเดียวกับที่ขยับ GameObject พ่อแม่แล้ว child ทุกตัวลากตามไปใน Unity นี่คือจุดประสงค์ทั้งหมดของการใช้ hierarchy แทนที่จะเป็น list แบน ๆ ของ bone อิสระ: เราแทบไม่อยากขยับมือโดยไม่ให้แขนตามไปด้วยเลย
ในโค้ด แต่ละ bone เก็บ parent ของมันด้วย index เข้าไปใน array ไม่ใช่ pointer — เหตุผลเดียวกับที่เราเคยใช้กับ data structure แบบ array มาแล้วในบท C: index copy ง่ายมาก บันทึกลงไฟล์ได้ตรง ๆ และไม่ต้องปวดหัวเรื่อง pointer-ownership root bone (ปกติคือสะโพกหรือ pelvis) ไม่มี parent จะถูก mark ด้วย -1
struct Bone {
std::string name;
int parentIndex; // -1 for the root bone, otherwise an index into Skeleton::bones
Mat4 inverseBindMatrix; // explained in Section 6 -- ignore it for now
};
struct Skeleton {
std::vector<Bone> bones; // parents ALWAYS appear before their children in this array
};
bones[i].parentIndex < i เสมอสำหรับทุก bone — world matrix ของ parent ต้องถูกคำนวณเสร็จก่อนที่เราจะเอาไปใช้คำนวณ world matrix ของ child ตัว exporter animation จริง ๆ ทุกตัว (FBX, glTF, ตัว import rig ของ Unity เอง) รับประกัน order นี้ไว้ด้วยเหตุผลนี้เป๊ะ ๆ ถ้าวันไหนต้องสร้าง skeleton เองด้วยมือ ให้เรียง order แบบนี้ก่อนเลยskeleton เฉย ๆ ก็แค่รูปทรงพัก — มันไม่ขยับเองหรอก animation clip ("Walk", "Idle", "Jump") คือข้อมูลที่ทำให้มันขยับ: ความยาวคงที่เป็นวินาที และสำหรับทุก bone ใน skeleton มี track ของ keyframe — snapshot ที่ประทับเวลาของ position กับ rotation ของ bone นั้นตัวเดียว
คำว่า "key" มีความหมายจริง ๆ ตรงนี้ ศิลปินไม่ได้ใส่ค่าให้ทุก bone ในทุกเฟรมด้วยมือ — นั่นจะสิ้นเปลืองพอ ๆ กับ vertex animation จาก Section 1 เลย แต่เขาจะตั้งท่า key ไม่กี่ท่าตรงจังหวะที่สำคัญ (เท้าแตะพื้น มือเหยียดสุด) แล้วปล่อยให้คอมพิวเตอร์เติมทุกอย่างระหว่าง key สอง key ด้วยการ interpolation ซึ่งเป็นหน้าที่ของ Section 4 พอดี bone ที่แทบไม่ขยับเลยตลอดทั้งวินาทีของ clip อาจต้องการแค่ 2 key ที่หัวและท้ายของช่วงนั้นก็พอ
struct PositionKey { float time; Vec3 value; };
struct RotationKey { float time; Quat value; };
struct BoneTrack {
std::vector<PositionKey> positions;
std::vector<RotationKey> rotations;
};
struct AnimationClip {
float duration; // total length in seconds
std::vector<BoneTrack> tracks; // tracks[i] drives skeleton.bones[i]
};
ตัวอย่างเล็ก ๆ ให้เห็นภาพ นี่คือ rotation track ที่เป็นไปได้ของ bone "RightElbow" ตอนงอศอกชกหมัดหนึ่งครั้ง โดยใช้ quatFromAxisAngle ตามที่นิยามไว้ในบท quaternion เป๊ะ ๆ:
BoneTrack elbowTrack;
elbowTrack.rotations = {
{ 0.0f, quatFromAxisAngle(Vec3{0,0,1}, 0.0f) }, // 0.0s: straight arm
{ 0.2f, quatFromAxisAngle(Vec3{0,0,1}, 100.0f * DEG2RAD) }, // 0.2s: elbow bent back
{ 0.4f, quatFromAxisAngle(Vec3{0,0,1}, 10.0f * DEG2RAD) }, // 0.4s: arm snaps forward
};
(ยังไม่มี output ให้รันตรงนี้ — นี่ยังเป็นแค่รูปร่างข้อมูล คู่ (time, Quat) สามคู่นอนอยู่ใน std::vector Section ถัดไปคือจุดที่ clip เริ่มผลิตตัวเลขออกมาจริง ๆ)
คำถามจริงที่เกมถามอยู่ 60 ครั้งต่อวินาที ไม่ใช่ "keyframe มีอะไรบ้าง" แต่คือ: ณ เวลา t ที่กำหนดมา เช่น 0.27 วินาทีเข้าไปใน punch ที่ยาว 0.4 วินาที bone ตัวนี้ position กับ rotation "ตอนนี้" คืออะไร? นี่เรียกว่าการ sample clip สูตรมันเหมือนเดิมเสมอสองขั้นตอน: หา keyframe สองอันที่ล้อมรอบ t อยู่ แล้ว interpolate ระหว่างมัน
Position key interpolate ด้วย lerp ธรรมดา เหมือนที่เรารู้จักอยู่แล้ว ส่วน rotation key interpolate ด้วย quatSlerp ตามที่นิยามไว้ในบท quaternion — ห้าม lerp แบบ component-wise ตรง ๆ กับตัวเลขดิบทั้ง 4 ตัวเด็ดขาด ด้วยเหตุผลเดียวกับที่ Section 10 ของบทนั้นเคยบอกไว้: ตัวเลข 4 ตัวที่ blend แยกกันจะไม่คงความเป็น rotation ที่ valid และ unit-length นี่คือจุดที่งานจากบท quaternion จ่ายผลตอบแทนกลับมาตรง ๆ เลย
Vec3 lerp(Vec3 a, Vec3 b, float t) {
return Vec3{ lerp(a.x, b.x, t), lerp(a.y, b.y, t), lerp(a.z, b.z, t) }; // reuses float lerp()
}
Vec3 sampleTrackPosition(const std::vector<PositionKey>& keys, float t) {
if (keys.size() == 1) return keys[0].value;
for (size_t i = 0; i + 1 < keys.size(); i++) {
if (t >= keys[i].time && t <= keys[i + 1].time) {
float span = keys[i + 1].time - keys[i].time;
float localT = (span > 0.0f) ? (t - keys[i].time) / span : 0.0f;
return lerp(keys[i].value, keys[i + 1].value, localT);
}
}
return keys.back().value; // t is past the last key: hold the final pose
}
Quat sampleTrackRotation(const std::vector<RotationKey>& keys, float t) {
if (keys.size() == 1) return keys[0].value;
for (size_t i = 0; i + 1 < keys.size(); i++) {
if (t >= keys[i].time && t <= keys[i + 1].time) {
float span = keys[i + 1].time - keys[i].time;
float localT = (span > 0.0f) ? (t - keys[i].time) / span : 0.0f;
return quatSlerp(keys[i].value, keys[i + 1].value, localT); // from the quaternion chapter
}
}
return keys.back().value;
}
ลองรันจริงกับ elbow track จาก Section 3 แล้วเช็คว่าข้อเท็จจริงเรื่องความเร็วคงที่ (constant-speed) จากบท quaternion ยังคงอยู่ในเมื่อ track มี key มากกว่าสองอัน:
int main() {
std::vector<float> times = {0.05f, 0.13f, 0.20f, 0.30f};
for (float t : times) {
Quat q = sampleTrackRotation(elbowTrack.rotations, t);
float angle = 2.0f * acosf(q.w) * 180.0f / PI;
printf("t=%.2f -> angle=%.1f deg\n", t, angle);
}
return 0;
}
ระหว่าง key0 (0 deg ที่ t=0.0) กับ key1 (100 deg ที่ t=0.2) มุมไต่ขึ้นเป็นสัดส่วนตรงกับว่า t ผ่าน segment นั้นไปแค่ไหน — t=0.05 คือหนึ่งในสี่ของ segment แล้วได้ 25 deg, t=0.13 คือ 65% ของ segment แล้วได้ 65 deg ตรงกับคุณสมบัติความเร็วเชิงมุมคงที่ที่ SLERP พิสูจน์ไว้แล้วในบท quaternion เป๊ะ ๆ ที่ t=0.30 เราข้ามเข้าไปใน segment ถัดไป แล้ว (key1 ถึง key2, 100 deg ลงไปที่ 10 deg) มุมเลยเริ่มลดกลับลงมาแล้ว — การ sample จะหา key สองอันที่ครอบ t อยู่ ณ ขณะนั้นใหม่เสมอ
การ sample track ของทุก bone ที่เวลา t จะได้ local pose ออกมา: position กับ rotation หนึ่งชุดต่อ bone แต่ละอันยังคงสัมพัทธ์กับ parent ของ bone นั้นอยู่ เหมือน local Transform เป๊ะ เพื่อจะวาง vertex ลงในโลกจริง เราต้องเดินไล่ hierarchy จาก Section 2 แล้วแปลง local pose ทุกอันให้เป็น world matrix ทีละ bone parent ก่อน child — กฎการสะสมค่าเดียวกันเป๊ะจากบท transform แค่เอามาใช้ทีละ bone แทนที่จะเป็นทีละ object
ก่อนอื่น เครื่องมือ Mat3 จากบท vector-และ-matrix ต้องขยายขึ้นอีกมิติหนึ่ง Mat4 เก็บ rotation, scale, translation รวมกันด้วย homogeneous-coordinate trick แบบเดียวกัน ("1 พิเศษ" จากบทนั้น) และรวมกันด้วย convention mul(A, B) แบบเดียวกันเป๊ะ: B เกิดก่อน A เกิดทีหลัง
struct Mat4 { float m[4][4]; };
Mat4 mat4Identity() {
Mat4 R{};
R.m[0][0] = R.m[1][1] = R.m[2][2] = R.m[3][3] = 1.0f;
return R;
}
Mat4 mul(const Mat4& A, const Mat4& B) { // same convention as Mat3: B first, then A
Mat4 R{};
for (int row = 0; row < 4; row++)
for (int col = 0; col < 4; col++) {
float sum = 0.0f;
for (int k = 0; k < 4; k++)
sum += A.m[row][k] * B.m[k][col];
R.m[row][col] = sum;
}
return R;
}
Vec3 transformPoint(const Mat4& M, Vec3 p) { // treats p as (x,y,z,1) -- a point, not a direction
return Vec3{
M.m[0][0]*p.x + M.m[0][1]*p.y + M.m[0][2]*p.z + M.m[0][3],
M.m[1][0]*p.x + M.m[1][1]*p.y + M.m[1][2]*p.z + M.m[1][3],
M.m[2][0]*p.x + M.m[2][1]*p.y + M.m[2][2]*p.z + M.m[2][3]
};
}
การสร้าง local matrix จาก position กับ rotation ที่ sample มาของ bone หนึ่งตัว ก็คือ model = T * R * S จากบท transform เป๊ะ ๆ โดยให้ scale ของ bone คงที่ที่ 1 ตรงนี้เพื่อให้ตัวเลขไม่ซับซ้อน (บาง format ก็ animate scale ต่อ bone จริง ๆ นะ สำหรับเอฟเฟกต์ squash-and-stretch — คณิตศาสตร์เหมือนกันเป๊ะ แค่คูณ diagonal matrix เพิ่มอีกตัวเดียว) ส่วน rotation block สร้างตรงจาก quaternion ด้วยสูตร quaternion-to-matrix มาตรฐาน — ไม่ต้อง derive ใหม่ แค่เชื่อมันเหมือนที่เราเชื่อ quatMultiply อยู่แล้ว:
Mat4 mat4RotationFromQuat(const Quat& q) {
float x = q.x, y = q.y, z = q.z, w = q.w;
Mat4 R = mat4Identity();
R.m[0][0] = 1 - 2*(y*y + z*z); R.m[0][1] = 2*(x*y - w*z); R.m[0][2] = 2*(x*z + w*y);
R.m[1][0] = 2*(x*y + w*z); R.m[1][1] = 1 - 2*(x*x + z*z); R.m[1][2] = 2*(y*z - w*x);
R.m[2][0] = 2*(x*z - w*y); R.m[2][1] = 2*(y*z + w*x); R.m[2][2] = 1 - 2*(x*x + y*y);
return R;
}
Mat4 mat4Translation(Vec3 t) {
Mat4 R = mat4Identity();
R.m[0][3] = t.x; R.m[1][3] = t.y; R.m[2][3] = t.z;
return R;
}
Mat4 mat4FromTRS(Vec3 position, Quat rotation) { // bone scale = 1 here, see note above
return mul(mat4Translation(position), mat4RotationFromQuat(rotation));
}
ทีนี้เดินไล่ hierarchy: world matrix ของแต่ละ bone คือ world matrix ของ parent ที่ คำนวณเสร็จแล้ว คูณกับ local matrix ของตัวมันเอง
std::vector<Mat4> computeWorldMatrices(const Skeleton& skeleton,
const std::vector<Vec3>& localPos,
const std::vector<Quat>& localRot) {
std::vector<Mat4> world(skeleton.bones.size());
for (size_t i = 0; i < skeleton.bones.size(); i++) {
Mat4 local = mat4FromTRS(localPos[i], localRot[i]);
int p = skeleton.bones[i].parentIndex;
world[i] = (p == -1) ? local : mul(world[p], local); // parent's world was already computed
}
return world;
}
ลอง trace สอง bone ด้วยมือ: Shoulder (root ของ chain เล็ก ๆ นี้ อยู่ที่ local position (0,0,0)) กับ child ชื่อ Elbow ที่ offset (1,0,0) จาก shoulder ใน bind pose — คิดว่า offset นี้คือความยาวของ bone ต้นแขน animate shoulder ด้วยการหมุน 90 องศารอบ Z ("ยกแขนขึ้น") แล้วไม่แตะ local pose ของ elbow เลย:
int main() {
Quat shoulderRot = quatFromAxisAngle(Vec3{0,0,1}, 90.0f * DEG2RAD);
Quat elbowRot = quatFromAxisAngle(Vec3{0,0,1}, 0.0f); // identity: no local bend yet
Mat4 shoulderWorld = mat4FromTRS(Vec3{0,0,0}, shoulderRot);
Mat4 elbowLocal = mat4FromTRS(Vec3{1,0,0}, elbowRot);
Mat4 elbowWorld = mul(shoulderWorld, elbowLocal);
Vec3 elbowWorldPos = transformPoint(elbowWorld, Vec3{0,0,0}); // the elbow joint itself
printf("elbow world position = (%.3f, %.3f, %.3f)\n", elbowWorldPos.x, elbowWorldPos.y, elbowWorldPos.z);
return 0;
}
นี่คือเทคนิค "หมุน (1,0) ไป 90 องศา แล้วลงเอยที่ (0,1)" จากตอน matrix ในบท transform เป๊ะ ๆ แค่ลากผ่าน mul เพิ่มอีกทีเดียว elbow เริ่มต้นอยู่ห่างจาก local +X ของ shoulder 1 หน่วย พอหมุน shoulder 90 องศารอบ Z offset ทั้งก้อนนั้นก็แกว่งขึ้นไปที่ world +Y ลาก elbow ไปด้วย — และ vertex ทุกตัวที่ skin ไว้กับ bone ใต้ elbow ก็จะแกว่งตามไปด้วยเหมือนกัน ซึ่งเป็นหน้าที่ของ Section 8
vertex ของ mesh ทุกตัวถูกสร้างขึ้นครั้งเดียวใน model space อยู่ในท่าอ้างอิงคงที่ที่ skeleton เคยอยู่ตอนที่ศิลปินวาด skin weight — ปกติคือ T-pose หรือ A-pose ท่าอ้างอิงนี้เรียกว่า bind pose ("bind" คือช่วงเวลาที่ mesh ถูกผูกเข้ากับ skeleton) คำนวณ world matrix ของ bind pose เองด้วย computeWorldMatrices ตัวเดียวกันจาก Section 5 เป๊ะ แค่ป้อน local position/rotation ของ bind pose เข้าไปแทนที่จะเป็นของ clip ที่ animate อยู่
ปัญหาที่ world matrix ของ bind pose สร้างขึ้นคือแบบนี้: ตำแหน่ง model-space ที่ vertex เก็บไว้ ได้ฝัง offset ที่ mesh เคยอยู่สัมพัทธ์กับ bone ตอน bind time เอาไว้แล้ว ถ้าเรา skin vertex ด้วยการเอามันไปคูณกับ world matrix ปัจจุบันที่ animate อยู่ ของ bone ตรง ๆ เราจะเอา offset ตอน bind time มาใส่ซ้ำอีกรอบทับบน offset ที่ animate ไปแล้ว — mesh จะลาก shape ของ bind pose มาเป็นสัมภาระส่วนเกินที่ไม่ต้องการ
วิธีแก้คือ inverse bind matrix: สำหรับทุก bone ให้ invert world matrix ของ bind pose มันเสียครั้งเดียว offline ตอน mesh ถูกโหลด (pipeline จริง ๆ ส่วนใหญ่ export ค่านี้มาจากไฟล์ FBX หรือ glTF ตรง ๆ คำนวณมาให้เรียบร้อยแล้ว) การเอา vertex ไปคูณกับ inverseBindMatrix ก่อน จะดึง transform ตอน bind-time ออกไป ทำให้ vertex ลงเอยอยู่ใน local space ของ bone นั้นเอง — จากตรงนั้น การคูณด้วย world matrix ปัจจุบันที่ animate อยู่ของ bone จะวางมันได้ถูกต้อง ไม่มีสัมภาระตกค้าง
การคำนวณ matrix inverse เต็มรูปแบบเป็นพีชคณิตที่มากเกินความจำเป็นของบทนี้จะไป derive — ให้มองว่า mat4Inverse เป็น black box เหมือนที่เราเชื่อ sqrtf อยู่แล้ว: math library ของเรา หรือ importer ที่โหลด mesh เข้ามา มีตัวที่ทดสอบมาแล้วให้ใช้อยู่แล้ว
Mat4 mat4Inverse(const Mat4& m); // black box -- provided by your math library
void computeInverseBindMatrices(Skeleton& skeleton,
const std::vector<Vec3>& bindLocalPos,
const std::vector<Quat>& bindLocalRot) {
std::vector<Mat4> bindWorld = computeWorldMatrices(skeleton, bindLocalPos, bindLocalRot);
for (size_t i = 0; i < skeleton.bones.size(); i++) {
skeleton.bones[i].inverseBindMatrix = mat4Inverse(bindWorld[i]); // done ONCE, at load time
}
}
สำหรับ chain Shoulder/Elbow จาก Section 5 bind pose นั้นเรียบง่าย — ทั้งสอง bone อยู่ที่ local pose พัก ไม่มีการหมุน: shoulder อยู่ที่ (0,0,0), elbow offset (1,0,0) นั่นทำให้ shoulderBindWorld เป็น identity matrix และ elbowBindWorld เป็น translation ล้วน ๆ ด้วย (1,0,0) การ invert translation ล้วน ๆ ก็แค่ negate มัน — ไม่มี rotation ให้ยกเลิก — ดังนั้น:
skinningMatrix = mul(boneWorld, inverseBindMatrix) = mul(bindWorld, mat4Inverse(bindWorld)) — ซึ่งก็คือ identity matrix ตามนิยามของ inverse เป๊ะ ๆ debugging tip ของ Section 12 อาศัยข้อเท็จจริงนี้ตรง ๆ เลยเอา Section 5 กับ 6 มารวมกัน จะได้ matrix ตัวเดียวที่ vertex ต้องการจริง ๆ: skinning matrix
skinningMatrix = mul(boneWorldMatrix, inverseBindMatrix)
อ่านจากขวาไปซ้าย ตามที่บท transform สอนให้อ่าน mul() ทุกครั้งเป๊ะ: ก่อนอื่นดึง bind pose ออก (inverseBindMatrix) แล้วค่อยใส่ world transform ปัจจุบันที่ animate อยู่ของ bone (boneWorldMatrix) skinning matrix หนึ่งตัวต่อ bone หนึ่งตัว คำนวณใหม่ทุกเฟรมที่ skeleton ขยับ:
std::vector<Mat4> computeSkinningMatrices(const Skeleton& skeleton, const std::vector<Mat4>& boneWorld) {
std::vector<Mat4> skin(skeleton.bones.size());
for (size_t i = 0; i < skeleton.bones.size(); i++) {
skin[i] = mul(boneWorld[i], skeleton.bones[i].inverseBindMatrix);
}
return skin;
}
มาเช็คตัวเลขจาก Section 5 กับ 6 กันจริง ๆ ว่าตรงกันไหม เอา vertex ที่อยู่ตรงตำแหน่ง elbow joint เป๊ะ ๆ ใน bind pose — ตำแหน่ง bind ใน model space ของมันคือ (1, 0, 0) offset เดียวกับที่เราให้ elbow bone ไว้ skin มันด้วย skinningMatrix[Elbow] จาก animated pose ใน Section 5 (shoulder หมุน 90 องศา elbow ไม่เปลี่ยน):
int main() {
Mat4 shoulderWorld = mat4FromTRS(Vec3{0,0,0}, quatFromAxisAngle(Vec3{0,0,1}, 90.0f * DEG2RAD));
Mat4 elbowWorld = mul(shoulderWorld, mat4FromTRS(Vec3{1,0,0}, quatFromAxisAngle(Vec3{0,0,1}, 0.0f)));
Mat4 inverseBindElbow = mat4Translation(Vec3{-1,0,0}); // from Section 6
Mat4 skinElbow = mul(elbowWorld, inverseBindElbow);
Vec3 skinned = transformPoint(skinElbow, Vec3{1,0,0}); // vertex's bind-pose position
printf("skinned vertex = (%.3f, %.3f, %.3f)\n", skinned.x, skinned.y, skinned.z);
return 0;
}
vertex ลงเอยตรง (0, 1, 0) เป๊ะ — world position เดียวกับที่เราคำนวณให้ elbow joint เองไปแล้วใน Section 5 มันต้องเป็นแบบนี้เท่านั้น: vertex ที่อยู่ตรง joint เป๊ะ ๆ และ weight เต็ม 100% กับ bone นั้น ต้องขยับไปตรงที่ joint ขยับไปเสมอ เป็นผลลัพธ์เล็ก ๆ แต่เป็นจุดเช็คที่มีประโยชน์ที่สุดในการจับ skinning pipeline ที่พังอยู่ และ Section 12 จะกลับมาใช้จุดนี้ตรง ๆ
skinning matrix ขยับก้อนพื้นที่แข็ง (rigid) ทั้งก้อน แต่พื้นผิว mesh จริง ๆ ไม่ได้แบ่งขาดตรง joint — skin ใกล้ ๆ ข้อศอกต้องยืดและงอนุ่มนวลข้ามทั้งต้นแขนและปลายแขนพร้อมกัน เทคนิคมาตรฐานคือ linear blend skinning (LBS หรือเรียกอีกชื่อว่า skeletal subspace deformation): vertex ทุกตัวเก็บ bone influence ได้สูงสุด 4 อัน แต่ละอันเป็นคู่ (index, weight) โดย weight รวมกันได้ 1 ตำแหน่งสุดท้ายของ vertex คือ blend แบบถ่วงน้ำหนักของสิ่งที่ skinning matrix ของ แต่ละ bone ที่มี influence จะทำกับมัน ถ้าคิดแยกทีละตัว
skinnedVertex = SUM over k of ( weight[k] * transformPoint(skinningMatrix[boneIndex[k]], bindVertex) )
struct SkinnedVertex {
Vec3 bindPosition; // position in model space, in the bind pose
int boneIndices[4]; // up to four influencing bones
float boneWeights[4]; // weights, sum to 1.0
};
Vec3 skinVertex(const SkinnedVertex& v, const std::vector<Mat4>& skin) {
Vec3 result{0, 0, 0};
for (int k = 0; k < 4; k++) {
if (v.boneWeights[k] <= 0.0f) continue;
Vec3 contribution = transformPoint(skin[v.boneIndices[k]], v.bindPosition);
result.x += v.boneWeights[k] * contribution.x;
result.y += v.boneWeights[k] * contribution.y;
result.z += v.boneWeights[k] * contribution.z;
}
return result;
}
ลองใส่ตัวเลขจริงกับ rig เดิม คราวนี้ให้ elbow เองงอ 30 องศาเพิ่มเข้าไปบน local ของมัน บวกกับที่ shoulder ยกขึ้น 90 องศา และ vertex อยู่ถัดจาก joint ไปนิดหน่อยที่ bind position (1.2, 0, 0) weight 50% Shoulder / 50% Elbow:
int main() {
Mat4 shoulderWorld = mat4FromTRS(Vec3{0,0,0}, quatFromAxisAngle(Vec3{0,0,1}, 90.0f * DEG2RAD));
Mat4 elbowWorld = mul(shoulderWorld,
mat4FromTRS(Vec3{1,0,0}, quatFromAxisAngle(Vec3{0,0,1}, 30.0f * DEG2RAD)));
Mat4 skinShoulder = mul(shoulderWorld, mat4Identity()); // inverse bind = identity
Mat4 skinElbow = mul(elbowWorld, mat4Translation(Vec3{-1,0,0})); // inverse bind from Section 6
Vec3 bindPos = Vec3{1.2f, 0.0f, 0.0f};
Vec3 fromShoulder = transformPoint(skinShoulder, bindPos);
Vec3 fromElbow = transformPoint(skinElbow, bindPos);
Vec3 blended;
blended.x = 0.5f * fromShoulder.x + 0.5f * fromElbow.x;
blended.y = 0.5f * fromShoulder.y + 0.5f * fromElbow.y;
blended.z = 0.5f * fromShoulder.z + 0.5f * fromElbow.z;
printf("from Shoulder alone = (%.3f, %.3f, %.3f)\n", fromShoulder.x, fromShoulder.y, fromShoulder.z);
printf("from Elbow alone = (%.3f, %.3f, %.3f)\n", fromElbow.x, fromElbow.y, fromElbow.z);
printf("blended (50/50) = (%.3f, %.3f, %.3f)\n", blended.x, blended.y, blended.z);
return 0;
}
vertex ที่ blend แล้วไม่ได้ลงเอยตรงที่ "ผูกกับ shoulder อย่างเดียวแบบแข็ง ๆ" จะพาไป และไม่ได้ลงเอยตรงที่ "ผูกกับ elbow อย่างเดียวแบบแข็ง ๆ" จะพาไปด้วย — มันอยู่ตรงกลางระหว่างสองอันแบบนุ่มนวล ซึ่งเป็นสิ่งที่ skin ของข้อศอกจริง ๆ ควรทำเวลามันงอพอดี นี่คือแนวคิดทั้งหมดของ LBS: ดึงความเห็นของทุก bone ที่มี influence เข้าหาคำตอบเฉลี่ยเดียว โดยถ่วงน้ำหนักตามว่า bone แต่ละตัวควรมีผลแค่ไหนตรง vertex นั้น ๆ
skinVertex จาก Section 8 รันถูกต้องบน CPU ได้ แต่ไม่มีเกมโปรดักชันตัวไหน skin ตัวละครที่กำลังวาดอยู่บน CPU หรอก — mesh ที่มี vertex เป็นหมื่นตัว ทำซ้ำทุกเฟรมสำหรับทุกตัวละครที่มองเห็น จะกิน CPU time ที่ส่วนอื่นของเกมต้องการไปหมด แทนที่จะทำแบบนั้น engine ทำแค่ 2 อย่างบน CPU ต่อเฟรม (Section 5 ถึง 7: sample clip, เดินไล่ hierarchy, คำนวณ skinning matrix) แล้วส่ง array เล็ก ๆ ของ skinning matrix — หนึ่งตัวต่อ bone อาจจะ 60 ถึง 150 matrix 4x4 เล็ก ๆ — ไปให้ GPU vertex ทุกตัวมี boneIndices กับ boneWeights ฝังอยู่ในตัวมันเองอยู่แล้วจาก Section 8 ตั้งไว้ตอน import mesh ครั้งเดียว vertex shader ของ GPU (โปรแกรมเล็ก ๆ ที่รันหนึ่งครั้งต่อ vertex แบบขนานกันทุก vertex ที่อยู่บนหน้าจอ) ทำสูตร blend ของ Section 8 เป๊ะ ๆ ด้วยตัวมันเอง สำหรับทุก vertex ทุกเฟรม:
// Vertex shader, HLSL-style pseudocode
struct VSInput {
float3 position : POSITION; // bind-pose position, same as SkinnedVertex::bindPosition
int4 boneIndices : BLENDINDICES; // up to 4 influencing bones
float4 boneWeights : BLENDWEIGHT; // weights, sum to 1.0
};
cbuffer SkinningData {
float4x4 skinMatrices[MAX_BONES]; // uploaded once per frame from the CPU-side array in Section 7
};
float4 main(VSInput input) : SV_Position {
float4 bindPos = float4(input.position, 1.0);
float4 skinnedPos = 0;
skinnedPos += input.boneWeights.x * mul(skinMatrices[input.boneIndices.x], bindPos);
skinnedPos += input.boneWeights.y * mul(skinMatrices[input.boneIndices.y], bindPos);
skinnedPos += input.boneWeights.z * mul(skinMatrices[input.boneIndices.z], bindPos);
skinnedPos += input.boneWeights.w * mul(skinMatrices[input.boneIndices.w], bindPos);
return mul(viewProjectionMatrix, skinnedPos);
}
ข้อจำกัด "influence สูงสุด 4 อัน" ไม่ใช่กฎฟิสิกส์อะไร — มันมาจากงบประมาณของ vertex format จริง ๆ (4 index กับ 4 weight พอดีเป๊ะกับ GPU register กว้าง 4 สองตัว) loop ฝั่ง GPU ทั้งหมดนี้คือสิ่งที่ SkinnedMeshRenderer ของ Unity และ Skeletal Mesh Component ของ Unreal ทำอยู่ข้างใต้ ทุกเฟรม สำหรับทุกตัวละครที่เราเคยเห็นขยับในทั้งสอง engine: CPU จัดการคณิตศาสตร์ skeleton ของ Section 5-7 ครั้งเดียว ส่วน GPU ทำ blend ของ Section 8 ซ้ำหนึ่งครั้งต่อ vertex แบบขนานกัน ข้าม vertex เป็นพัน ๆ ตัวพร้อมกัน
linear blend skinning มีจุดอ่อนทางภาพที่รู้จักกันดีอยู่หนึ่งอย่าง และมันมาจากสาเหตุรากเดียวกับที่บท quaternion เคยเตือนไว้แล้วเป๊ะ: การ blend rotation matrix สองตัวด้วยการถ่วงน้ำหนักเฉลี่ยตรง ๆ ไม่ได้ผลลัพธ์เป็น rotation matrix ที่ valid มันจะหดตัว ที่ joint ที่บิดเยอะ ๆ — ข้อมือหมุนไปไกล ไหล่แกว่งกว้าง — skin ที่ blend ด้วย LBS จะหดยุบเข้าหา joint เห็นได้ชัดด้วยตา เป็น artifact ที่รู้จักกันในชื่อ candy-wrapper effect (mesh บีบตัวตรง joint เหมือนกระดาษห่อลูกอมที่บิดจนบางตรงกลาง)
ทางแก้เดินตามบทเรียนเดียวกับที่บท quaternion เคยสอนไว้แล้ว: rotation จะ blend ได้อย่างปลอดภัยเมื่อเรา interpolate ตัว representation ของ rotation เอง (quaternion ด้วย SLERP หรือ NLERP) ไม่ใช่ matrix ที่สร้างมาจากมัน Dual quaternion skinning (DQS) แทน rigid transform ของแต่ละ bone — rotation กับ translation รวมกัน — ด้วย dual quaternion (quaternion ธรรมดาสองตัวจับคู่กัน: ตัวหนึ่งเก็บ rotation อีกตัวเข้ารหัส translation) แทนที่จะเป็น matrix 4x4 แล้ว blend dual quaternion ต่อ vertex แทนการ blend matrix การ blend unit dual quaternion แล้ว renormalize จะใกล้เคียง rigid motion จริงมากกว่าการ blend matrix มาก ซึ่งเป็นเหตุผลเป๊ะ ๆ ว่าทำไมอาการยุบตัวถึงหายไปเกือบหมด
struct DualQuat {
Quat real; // encodes rotation, same Quat type from the quaternion chapter
Quat dual; // encodes translation, combined algebraically with "real"
};
// briefly, conceptually -- full derivation is out of scope here:
DualQuat dqFromBone(Quat rotation, Vec3 translation);
DualQuat dqBlend(const DualQuat& a, float weightA, const DualQuat& b, float weightB); // blend, then normalize
Vec3 dqTransformPoint(const DualQuat& dq, Vec3 p);
DQS กินค่าใช้จ่ายต่อ vertex มากกว่า LBS นิดหน่อย เกมส่วนใหญ่จึงใช้ LBS เป็น default ทั่วทั้งตัวละคร แล้วเลือกใช้ DQS เฉพาะ joint ไม่กี่จุดที่บิดเยอะพอจะเห็นอาการยุบตัวจริง ๆ — ข้อมือกับไหล่คือจุดที่มักโดนบ่อยที่สุด — หรือไม่ก็เปิด DQS ทั้งตัวเลยถ้า hardware เป้าหมายรับค่าใช้จ่ายเพิ่มไหว
Section 3 ใบ้ไว้แล้วนิดหน่อย: การเก็บ key ทุกเฟรมเดียว สำหรับทุก bone ในทุก clip นั้นสะสมเร็วมาก ลองคิด skeleton 60 bone ธรรมดา ๆ clip ยาว 5 วินาที sample ที่ 30 key ต่อวินาที: นั่นคือ 60 * 150 = 9,000 bone-sample และแต่ละ sample ต้องการ Vec3 position บวก Quat rotation — 7 float, 28 byte รวมเป็น 9,000 * 28 = 252,000 byte ประมาณ 246 KB สำหรับ clip เดียว ตัวละครที่มี clip จริง ๆ 80 อัน (idle, walk, run, jump, ท่าโจมตีหลายท่า, emote บ้าง) จะต้องการพื้นที่ animation data ของตัวมันเองประมาณ 19 MB — ยังไม่นับตัวละครอื่นในเกมเลยด้วยซ้ำ
Animation compression คือชุดเทคนิคที่ย่อขนาดนี้ลงโดยผู้เล่นไม่รู้สึกอะไร ปกติทำหลังจาก clip ถูกสร้างเสร็จแล้ว เป็นขั้นตอน export แบบ offline:
w*w + x*x + y*y + z*z = 1 ข้อเท็จจริงความยาว-1 เดียวกับในบท quaternion) เราเลยเก็บแค่ 3 ใน 4 ตัวเลข บวกกับว่าตัวไหนที่เราตัดทิ้งไป แล้ว reconstruct ตัวสุดท้ายด้วย square root ตอนโหลดใช้รวมกันทั้งหมดนี้ ปกติจะย่อ clip เหลือแค่หนึ่งในห้าหรือหนึ่งในสิบของขนาดดิบ — clip 246 KB นั้นเหลือประมาณ 25-50 KB คือผลลัพธ์ปกติ ไม่ใช่กรณีพิเศษ นี่คือการแลกเปลี่ยนแบบเดียวกันเป๊ะที่บทนี้ทำมาตลอดตั้งแต่ Section 3: เก็บไว้แค่ข้อมูลที่การ interpolation สร้างขึ้นใหม่เองไม่ได้ง่าย ๆ แล้วปล่อยให้คณิตศาสตร์การ sample จาก Section 4 ทำงานที่เหลือให้ฟรี ๆ
โปรแกรมเมอร์กราฟิกแทบทุกคนจะเจอ build ที่ mesh ของตัวละคร พอเปิด animation ปุ๊บ กระจายเป็นกลุ่มสามเหลี่ยมกระจัดกระจายทันที — มักเรียกกันแบบไม่ค่อยเอ็นดูว่า "ตัวละครระเบิด" มันดูหายนะสุด ๆ แต่สาเหตุแทบทุกครั้งแคบมาก: inverse bind matrix ผิดหรือหายไป
// BAD: forgot the inverse bind matrix entirely
for (size_t i = 0; i < skeleton.bones.size(); i++) {
skinningMatrix[i] = boneWorld[i]; // still carries the FULL bind-pose offset baked in
}
// GOOD: strip the bind pose out first, exactly as Section 7 defined it
for (size_t i = 0; i < skeleton.bones.size(); i++) {
skinningMatrix[i] = mul(boneWorld[i], skeleton.bones[i].inverseBindMatrix);
}
ในเวอร์ชัน BAD ทุก vertex จะได้รับ offset เต็ม ๆ ของ world-space bind ของ bone ใส่ซ้ำอีกรอบ ทับบนตำแหน่ง bind-pose ของตัวมันเองที่ใส่ไว้แล้ว — และเพราะ bone แต่ละตัวอยู่ห่างจาก origin คนละระยะ คนละทิศทาง ตอน bind time vertex แต่ละตัวเลยถูกกระชากไปคนละปริมาณ ไม่สอดคล้องกันเลย ในเฟรมเดียวกัน mesh เลยไม่ได้เปลี่ยนรูป มันแตกกระจาย บั๊กตระกูลเดียวกันนี้ยังโผล่มาในรูปแบบ: inverse ที่คำนวณด้วยมือแล้ว negate แค่ translation ทิ้ง rotation ไป หรือ mesh ที่ vertex weight index bone คนละ order กับที่ skeleton array เก็บจริง (bone 5 ในไฟล์ mesh ไม่ใช่ bone 5 ใน skeleton ที่โหลดมา) — ทั้งสองแบบส่ง transform ผิด bone ให้ vertex ตรง ๆ เลย
skinningMatrix[i] ทุกตัว ทุกตัวต้องออกมาเป็น identity matrix เป๊ะ ตัวไหนที่ไม่ใช่ คือจุดที่บั๊กอยู่พอดี — เราเปลี่ยนจากการหา mesh ที่ระเบิดทั้งก้อน มาเป็นการไล่ list bone สั้น ๆ ที่เรียงลำดับไว้แทนตัวละครแทบไม่เคยกระโดดจาก "Idle" ไป "Walk" ตรง ๆ — การกระตุกที่เห็นได้ชัดจะดูเหมือนพัง วิธีแก้คือ crossfade สั้น ๆ: blend local pose ที่ sample มาจากสอง clip เป็นเวลาเสี้ยววินาที ไล่ blend weight จาก 0 ไป 1 แล้วรัน pipeline ปกติของ Section 5-9 กับ pose ที่ blend แล้วเพียงชุดเดียวที่ออกมา pipeline ที่อยู่ถัดจากตรงนี้ไม่ต้องรู้เลยด้วยซ้ำว่ามีการ blend เกิดขึ้น
struct LocalPose { std::vector<Vec3> positions; std::vector<Quat> rotations; };
LocalPose blendPoses(const LocalPose& a, const LocalPose& b, float blendFactor) {
LocalPose result;
result.positions.resize(a.positions.size());
result.rotations.resize(a.rotations.size());
for (size_t i = 0; i < a.positions.size(); i++) {
result.positions[i] = lerp(a.positions[i], b.positions[i], blendFactor);
result.rotations[i] = quatSlerp(a.rotations[i], b.rotations[i], blendFactor); // from the quaternion chapter
}
return result;
}
ทุกเฟรม สำหรับทุกตัวละครที่ animate อยู่ ทั้งบทนี้จะรันตาม order นี้:
t คือการหา key ที่ล้อมรอบแล้ว interpolate — lerp สำหรับ position, quatSlerp สำหรับ rotationboneWorld * inverseBind) คือตัวที่ขยับ vertex จริง ๆparentIndex == -1) ปกติคือสะโพกt ใด ๆ ด้วยการ interpolate keyframe สองอันที่ล้อมรอบมันอยู่mul(boneWorldMatrix, inverseBindMatrix) matrix ตัวเดียวที่ deform vertex ได้ถูกต้องจริง ๆ0 deg ที่ t=0.0, 45 deg ที่ t=0.5, และ 90 deg ที่ t=1.0 ใช้คุณสมบัติความเร็วเชิงมุมคงที่ของ SLERP (จากบท quaternion และยืนยันอีกครั้งใน Section 4) ทำนายด้วยมือว่ามุมที่ sampleTrackRotation ควรคืนค่าที่ t=0.25 และ t=0.75 คืออะไร แล้วเขียน track กับโค้ดเพื่อยืนยัน โดย print มุมด้วยเทคนิค 2*acosf(w)*180/PIt=0.25 ตกอยู่ใน segment แรก (t=0.0 ถึง t=0.5) พอดีครึ่งทาง ดังนั้นคำตอบควรอยู่ครึ่งทางระหว่าง 0 กับ 45 องศา: 22.5 deg t=0.75 ตกอยู่ใน segment ที่สอง (t=0.5 ถึง t=1.0) ก็พอดีครึ่งทางเหมือนกัน ดังนั้นคำตอบควรอยู่ครึ่งทางระหว่าง 45 กับ 90 องศา: 67.5 deg
int main() {
std::vector<RotationKey> keys = {
{ 0.0f, quatFromAxisAngle(Vec3{0,1,0}, 0.0f) },
{ 0.5f, quatFromAxisAngle(Vec3{0,1,0}, 45.0f * DEG2RAD) },
{ 1.0f, quatFromAxisAngle(Vec3{0,1,0}, 90.0f * DEG2RAD) },
};
for (float t : {0.25f, 0.75f}) {
Quat q = sampleTrackRotation(keys, t);
float angle = 2.0f * acosf(q.w) * 180.0f / PI;
printf("t=%.2f -> angle=%.1f deg\n", t, angle);
}
return 0;
}
ทั้งสองค่าตรงกับที่ทำนายด้วยมือเป๊ะ เพราะแต่ละ query ตกอยู่ครึ่งทางของ segment ของตัวเองพอดี และ SLERP เคลื่อนที่ด้วยอัตราเชิงมุมคงที่ตลอด segment เดียว ๆ เสมอ — ข้อเท็จจริงเดียวกับที่บท quaternion เคยวัดตรง ๆ ด้วยตารางมุมของมันเอง
(0,0,0), elbow offset (1,0,0), ทั้งสองมี identity rotation ใน bind pose) คำนวณ skinning matrix ของทั้งสอง bone ตอนที่ pose ปัจจุบัน ถูกตั้งให้เท่ากับ bind pose เป๊ะ ๆ (ไม่มีการ animate อะไรเลย) print translation column ของ skinning matrix แต่ละตัว แล้วยืนยันว่ามันออกมาเป็น (0,0,0) สำหรับทั้งสอง bone และอธิบายสั้น ๆ ด้วยคำพูดว่าทำไมมันถึงต้องเป็นแบบนี้เสมอ โดยใช้ tip จาก Section 6int main() {
// "current" pose == bind pose: shoulder identity, elbow at (1,0,0), no rotation
Mat4 shoulderWorld = mat4FromTRS(Vec3{0,0,0}, quatFromAxisAngle(Vec3{0,0,1}, 0.0f));
Mat4 elbowWorld = mul(shoulderWorld, mat4FromTRS(Vec3{1,0,0}, quatFromAxisAngle(Vec3{0,0,1}, 0.0f)));
Mat4 inverseBindShoulder = mat4Identity(); // from Section 6
Mat4 inverseBindElbow = mat4Translation(Vec3{-1,0,0}); // from Section 6
Mat4 skinShoulder = mul(shoulderWorld, inverseBindShoulder);
Mat4 skinElbow = mul(elbowWorld, inverseBindElbow);
printf("skinShoulder translation = (%.3f, %.3f, %.3f)\n", skinShoulder.m[0][3], skinShoulder.m[1][3], skinShoulder.m[2][3]);
printf("skinElbow translation = (%.3f, %.3f, %.3f)\n", skinElbow.m[0][3], skinElbow.m[1][3], skinElbow.m[2][3]);
return 0;
}
ทั้งสอง column ออกมาเป็นศูนย์เป๊ะ และ matrix เต็ม ๆ ทั้งสองก็เป็น identity matrix เป๊ะ ไม่ใช่แค่ส่วน translation เท่านั้น มันต้องเป็นแบบนี้เพราะ pose ปัจจุบันกับ bind pose คือ pose เดียวกันตรงนี้: boneWorld เท่ากับ bindWorld สำหรับทุก bone ดังนั้น skinningMatrix = mul(bindWorld, mat4Inverse(bindWorld)) ซึ่งคือ identity matrix ตามนิยาม — matrix คูณกับ inverse ของตัวเองจะหักล้างกันจนเหลือ "ไม่ทำอะไรเลย" เสมอ นี่คือจุดเช็คเดียวกับที่ Section 12 แนะนำให้รันเป็นอย่างแรกทุกครั้งที่ skinned mesh ดูพัง
std::vector<Mat4> computeSkinningMatrices(const Skeleton& skeleton, const std::vector<Mat4>& boneWorld) {
std::vector<Mat4> skin(skeleton.bones.size());
for (size_t i = 0; i < skeleton.bones.size(); i++) {
skin[i] = mul(skeleton.bones[i].inverseBindMatrix, boneWorld[i]); // <-- bug is on this line
}
return skin;
}
argument สองตัวของ mul สลับกันอยู่ จำกฎจากบท transform ไว้ — mul(A, B) ใช้ B ก่อน แล้วค่อยใช้ A — บรรทัดที่มีบั๊กนี้ใช้ world matrix ที่ animate ใหม่เอี่ยมของ bone ก่อน ใส่ตรงลงไปบน vertex ที่ยังอยู่ใน bind-pose model space อยู่เลย แล้วค่อยพยายามดึง offset ของ bind-pose ออกด้วย inverseBindMatrix ทีหลัง นี่กลับด้านกัน: vertex ต้องถูกดึงออกจาก bind pose ก่อน (เข้าไปใน local space ของ bone เอง) แล้วค่อยถูกดันเข้าไปใน pose ปัจจุบันที่ animate อยู่ทีหลัง การใส่ order ผิดแบบนี้ไม่ได้คำนวณอะไรที่ใกล้เคียง rigid motion เลย และ bone แต่ละตัวก็จะลาก vertex ของมันไปคนละปริมาณที่ไม่สอดคล้องกันอย่างสุดขั้ว — mesh เลยระเบิด
std::vector<Mat4> computeSkinningMatrices(const Skeleton& skeleton, const std::vector<Mat4>& boneWorld) {
std::vector<Mat4> skin(skeleton.bones.size());
for (size_t i = 0; i < skeleton.bones.size(); i++) {
skin[i] = mul(boneWorld[i], skeleton.bones[i].inverseBindMatrix); // inverseBind FIRST, boneWorld SECOND
}
return skin;
}
นี่คือนิยามดั้งเดิมของ Section 7 เป๊ะ ๆ และเป็นบทเรียนเรื่อง "order สำคัญ" แบบเดียวกับที่บท transform เคยสอนไว้ครั้งแรกด้วย matrix แบบ scale-แล้ว-translate ธรรมดา ๆ — บั๊กเดิม แค่ลึกลงไปอีกชั้น hierarchy หนึ่ง