This programming guide is only available in Vietnamese. Switch the language toggle to Vietnamese to read the full article.
Cho đến nay, mọi vật thể chúng ta vẽ đều cứng nhắc (rigid): cả lưới đa giác cùng dịch chuyển, xoay hay co giãn như một khối thống nhất. Nhưng một nhân vật người, một con vật, hay một xúc tu mềm mại lại cần các bộ phận uốn cong độc lập mà bề mặt vẫn liền lạc, không rách. Kỹ thuật then chốt cho điều này là Skeletal Animation (hoạt họa dựa trên bộ xương) kết hợp với Skinning (phủ da) — nền tảng của mọi nhân vật trong game 3D, phim hoạt hình và glTF.
1. Từ keyframe đến skeletal animation
Cách hoạt họa sơ khai nhất là keyframe theo đỉnh (vertex keyframe / morph target): lưu toàn bộ vị trí mọi đỉnh ở từng khung hình rồi nội suy tuyến tính giữa chúng. Cách này đơn giản nhưng có ba nhược điểm nghiêm trọng. Thứ nhất, chi phí bộ nhớ khổng lồ — một nhân vật 20.000 đỉnh với 60 khung hình đòi hỏi lưu hàng triệu vector. Thứ hai, nội suy tuyến tính giữa hai tư thế cong sẽ làm cánh tay "co ngắn" lại vì đỉnh đi theo đường thẳng chứ không theo cung tròn. Thứ ba, mọi chuyển động phải được bake sẵn, không thể điều khiển động (procedural) như cho nhân vật nhìn theo con trỏ.
Skeletal animation giải quyết tất cả. Thay vì lưu vị trí đỉnh, ta dựng một bộ xương (skeleton) gồm các xương (bone) nối với nhau tại các khớp (joint) theo một cây phân cấp (hierarchy). Mỗi đỉnh của lưới da được "gắn" vào một hay nhiều xương với các trọng số (weights). Khi ta xoay một khớp vai, mọi xương con bên dưới (khuỷu tay, cổ tay, bàn tay) sẽ kế thừa phép biến đổi đó một cách tự nhiên — giống hệt cách xương thật trong cơ thể chuyển động.
Mô hình phân cấp này cực kỳ tiết kiệm: thay vì hàng triệu vector, ta chỉ cần hoạt họa vài chục ma trận khớp (joint matrix). Chuyển động cũng chính xác về mặt vật lý vì khi xoay khớp, các đỉnh quay theo cung tròn thực sự quanh khớp đó. Dưới đây là cấu trúc khái niệm của một đỉnh được "skinned" — nó mang theo chỉ số khớp và trọng số:
// Một đỉnh trong skeletal animation mang theo dữ liệu skinning:
const skinnedVertex = {
position: [0.5, 1.2, 0.0], // vị trí ở BIND POSE (tư thế gốc)
normal: [0.0, 1.0, 0.0],
// Mỗi đỉnh chịu ảnh hưởng của tối đa 4 xương:
jointIndices: [0, 1, 0, 0], // chỉ số xương trong mảng joint matrix
jointWeights: [0.7, 0.3, 0.0, 0.0], // trọng số — TỔNG phải = 1.0
};
// Đỉnh này bị xương 0 kéo 70% và xương 1 kéo 30%.
// Khi cả hai xương xoay, vị trí cuối là trung bình có trọng số.
2. Bind Pose, Joint Matrices & Inverse Bind Matrix
Để hiểu skinning, phải phân biệt rõ các không gian (space). Lưới da được mô hình hóa một lần ở bind pose — tư thế mặc định (thường là tư thế chữ T hoặc chữ A). Tọa độ đỉnh trong file lưới được biểu diễn trong không gian mô hình (model space). Mỗi xương lại có hệ tọa độ riêng — không gian xương (bone space / joint space) — với gốc đặt tại khớp của nó.
Vấn đề trung tâm: ta muốn biến đổi một đỉnh theo chuyển động của xương, nhưng đỉnh được lưu trong model space còn phép xoay xương lại định nghĩa trong bone space. Giải pháp gồm hai ma trận cho mỗi khớp:
-
Joint Matrix (ma trận khớp hiện tại): biến đổi từ bone space của khớp về model
space, ở tư thế đang hoạt họa. Nó được tính bằng cách nhân dồn các phép biến đổi cục bộ dọc
theo cây phân cấp:
world = parentWorld × localTransform. - Inverse Bind Matrix (ma trận nghịch đảo bind): đây là nghịch đảo của joint matrix tại bind pose. Nó đưa một đỉnh từ model space (bind pose) về bone space của khớp. Đây là bước "tháo" đỉnh khỏi tư thế gốc để có thể gắn lại theo tư thế mới.
Khi ghép hai ma trận này lại, ta được skin matrix của một khớp:
skinMat = jointMatrix × inverseBindMatrix. Trực giác: inverse bind đưa đỉnh từ model
space về bone space ở tư thế gốc, sau đó joint matrix đưa nó trở lại model space nhưng ở tư thế đã
hoạt họa. Nếu khớp không di chuyển khỏi bind pose, hai ma trận triệt tiêu nhau (skinMat = I) và đỉnh đứng yên — đúng như mong đợi.
vị trí mới = Σi weighti × skinMatrix(jointi) × vị tríbind
Cấu trúc dữ liệu của bộ xương là một mảng các khớp, mỗi khớp ghi nhớ chỉ số cha của nó để tạo thành cây:
// Bộ xương: mảng khớp, mỗi khớp trỏ tới CHA của nó (-1 = gốc).
const skeleton = [
{ name: 'root', parent: -1, localMatrix: mat4(), inverseBind: mat4() },
{ name: 'bone1', parent: 0, localMatrix: mat4(), inverseBind: mat4() },
{ name: 'bone2', parent: 1, localMatrix: mat4(), inverseBind: mat4() },
{ name: 'bone3', parent: 2, localMatrix: mat4(), inverseBind: mat4() },
];
// localMatrix = phép biến đổi của khớp SO VỚI cha của nó (đã hoạt họa).
// inverseBind = nghịch đảo world matrix của khớp tại BIND POSE,
// tính sẵn một lần lúc nạp mô hình.
Mỗi khung hình, ta duyệt cây từ gốc xuống lá, nhân dồn ma trận cục bộ với world matrix của cha để có world matrix (chính là joint matrix), rồi ghép với inverse bind để ra skin matrix gửi lên shader:
// Tính world matrix cho từng khớp bằng cách duyệt cây phân cấp.
// Giả định mảng đã sắp xếp sao cho cha luôn đứng TRƯỚC con.
function computeJointMatrices(skeleton) {
const world = []; // world (joint) matrix của từng khớp
const skinMatrices = []; // ma trận cuối cùng gửi lên shader
for (let i = 0; i < skeleton.length; i++) {
const joint = skeleton[i];
if (joint.parent === -1) {
world[i] = joint.localMatrix; // gốc: world = local
} else {
// world = worldCủaCha × local (kế thừa biến đổi của cha)
world[i] = multiply(world[joint.parent], joint.localMatrix);
}
// skinMatrix = jointMatrix × inverseBindMatrix
skinMatrices[i] = multiply(world[i], joint.inverseBind);
}
return skinMatrices; // mảng mat4 -> uniform u_jointMat[]
}
3. Linear Blend Skinning (LBS) trong Vertex Shader
Phần biến dạng thực sự diễn ra trên GPU, trong vertex shader, với chi phí gần như miễn phí vì GPU tính song song mọi đỉnh. Kỹ thuật phổ biến nhất là Linear Blend Skinning (LBS), còn gọi là Smooth Skinning hay Skeletal Subspace Deformation. Ý tưởng: với mỗi đỉnh, ta biến đổi nó bằng từng skin matrix của các xương ảnh hưởng, rồi trộn tuyến tính kết quả theo trọng số.
Mỗi đỉnh mang hai attribute mới: a_jointIndices (vec4 — chỉ số tối đa 4 xương) và
a_jointWeights (vec4 — trọng số tương ứng, tổng bằng 1.0). Mảng skin matrix được truyền
qua uniform uniform mat4 u_jointMat[N]. Công thức cốt lõi là tổng có trọng số:
gl_Position = MVP × skinMatrix × vec4(a_position, 1.0)
Lưu ý quan trọng về kỹ thuật: ta trộn các ma trận (rồi áp dụng một lần) thay vì áp dụng từng ma trận rồi trộn kết quả — về mặt toán học hai cách tương đương do phép nhân ma trận-vector có tính tuyến tính, nhưng trộn ma trận trước thường gọn hơn. Pháp tuyến cũng phải được biến đổi để chiếu sáng đúng; với phép biến đổi cứng (xoay + tịnh tiến) ta có thể dùng chính phần 3×3 của skin matrix. Đây là cài đặt GLSL ES 1.00 hoàn chỉnh:
attribute vec3 a_position; // vị trí ở bind pose
attribute vec3 a_normal;
attribute vec4 a_jointIndices; // chỉ số tối đa 4 xương
attribute vec4 a_jointWeights; // trọng số, tổng = 1.0
uniform mat4 u_MVP;
uniform mat4 u_jointMat[4]; // skinMatrix của từng khớp (số khớp cố định)
varying vec3 v_normal;
void main() {
// Linear Blend Skinning: trộn tuyến tính các skin matrix theo trọng số.
mat4 skinMatrix =
a_jointWeights.x * u_jointMat[int(a_jointIndices.x)] +
a_jointWeights.y * u_jointMat[int(a_jointIndices.y)] +
a_jointWeights.z * u_jointMat[int(a_jointIndices.z)] +
a_jointWeights.w * u_jointMat[int(a_jointIndices.w)];
vec4 skinnedPos = skinMatrix * vec4(a_position, 1.0);
v_normal = mat3(skinMatrix) * a_normal; // biến đổi pháp tuyến
gl_Position = u_MVP * skinnedPos;
}
4. Demo tương tác: Xúc tu phủ da điều khiển bằng xương
Demo dưới đây dựng một xúc tu (tentacle) dạng dải hộp được chia nhỏ dọc theo chiều
dài, phủ da lên một bộ xương 4 khớp. Mỗi đỉnh được gán trọng số dựa trên vị trí dọc theo xúc tu, nên
bề mặt uốn cong liền lạc khi các khớp xoay. Joint matrix được tính lại mỗi khung hình từ các
góc xương dao động theo sin(). Hãy kéo các thanh trượt để đổi tốc độ và biên độ uốn, bật
Hiện xương để thấy các khớp:
5. Vấn đề & cải tiến: candy-wrapper & Dual Quaternion Skinning
Linear Blend Skinning đơn giản và nhanh, nhưng có một khuyết tật cố hữu do bản chất trộn tuyến tính ma trận: trung bình của hai ma trận xoay không còn là một ma trận xoay hợp lệ. Khi một khớp xoay lớn — đặc biệt là vặn xoắn (twist) quanh trục xương, ví dụ cổ tay xoay 180° — thể tích quanh khớp bị co lại, tạo ra hiện tượng cổ điển gọi là candy-wrapper artifact (vỏ kẹo thắt eo). Khuỷu tay và vai gập mạnh cũng dễ bị xẹp, mất thể tích.
Giải pháp công nghiệp là Dual Quaternion Skinning (DQS). Thay vì trộn ma trận, DQS biểu diễn mỗi phép biến đổi cứng (xoay + tịnh tiến) bằng một quaternion kép (dual quaternion) rồi nội suy chúng. Phép nội suy này bảo toàn tính chất xoay nên loại bỏ candy-wrapper và giữ thể tích tốt hơn nhiều, đổi lại chi phí tính toán nhỉnh hơn. Hầu hết engine hiện đại cho phép chọn DQS cho các khớp dễ bị xoắn.
Một mảnh ghép quan trọng khác là animation blending: làm sao chuyển mượt từ clip "đi"
sang clip "chạy", hay pha trộn nhiều clip cùng lúc. Câu trả lời nằm ở cách nội suy keyframe của từng
kênh hoạt họa: vị trí và tỉ lệ (scale) nội suy tuyến tính bằng LERP, còn
phép xoay phải dùng SLERP (Spherical Linear Interpolation) trên
quaternion để đường xoay đi theo cung cầu ngắn nhất, không bị "giật" tốc độ. Dưới đây là phác thảo nội
suy một kênh hoạt họa:
// Lấy mẫu một kênh hoạt họa tại thời điểm t, nội suy giữa 2 keyframe.
function sampleChannel(channel, t) {
const { times, values, type } = channel;
// tìm cặp keyframe bao quanh t
let i = 0;
while (i < times.length - 1 && times[i + 1] < t) i++;
const t0 = times[i], t1 = times[i + 1];
const f = (t - t0) / (t1 - t0); // hệ số nội suy [0,1]
if (type === 'rotation') {
return slerp(values[i], values[i + 1], f); // quaternion -> SLERP
}
return lerp(values[i], values[i + 1], f); // vị trí / scale -> LERP
}
// Animation blending: pha trộn hai tư thế theo trọng số chuyển cảnh.
function blendPoses(poseA, poseB, blend) {
return poseA.map((qA, j) => slerp(qA, poseB[j], blend));
}
6. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 1: Inverse Bind Matrix
Vai trò chính của inverseBindMatrix trong công thức
skinMatrix = jointMatrix × inverseBindMatrix là gì?
Trắc nghiệm 2: Linear Blend Skinning
Trong vertex shader LBS, skinMatrix của một đỉnh được tính như thế nào?
Trắc nghiệm 3: Candy-wrapper & DQS
Hiện tượng candy-wrapper artifact trong LBS phát sinh từ đâu, và kỹ thuật nào khắc phục nó?
Tải mã nguồn thực hành
File chứa vertex shader Linear Blend Skinning hoàn chỉnh và demo xúc tu phủ da để bạn tự thử nghiệm:
Tải về mã nguồn Skinning mẫu
Comments
Bình luận