Three.js from Zero to Senior · Part 17 — Rigging & Animation: AnimationMixer, Cross-Fades and Mixamo
Make a character move. Rigging and skinning explained, where clips come from (Blender, Mixamo), then drive a skinned glTF with AnimationMixer — cross-fade states, fire one-shot emotes, blend morph-target expressions — with a live demo.
Một model tĩnh là đồ đạc; một model chuyển động là nhân vật. Part 16 đã làm asset nhỏ lại — giờ ta làm chúng sống dậy. Demo nạp một glTF có xương đầy đủ và cho bạn cross-fade giữa các trạng thái di chuyển, bắn các emote một-lần, và điều khiển biểu cảm mặt — tất cả từ một AnimationMixer.
Từ vựng bạn thật sự cần
Animation xương có năm thành phần — học đúng từ thì API tự đọc được:
- Rig / Skeleton / Bones — một phân cấp
Bonevô hình, như bộ xương tạo dáng được. - Skinning / weights — mỗi đỉnh gắn vào một hay nhiều xương với trọng số, nên xoay xương sẽ biến dạng mesh. Đây là
SkinnedMesh. - AnimationClip — một animation có tên (“Walk”), gồm nhiều
KeyframeTrack. - AnimationMixer — bộ phát theo từng model; mỗi nhân vật tạo một cái.
- AnimationAction — một clip đang chạy trên mixer, có weight, time-scale, chế độ loop và fade.
Clip đến từ đâu?
Ba nguồn thực tế:
- Blender — rig bằng Armature, làm Action, xuất với Skinning + Animation bật. Dùng Always Sample Animations và NLA track để xuất nhiều clip có tên.
- Mixamo (miễn phí, Adobe) — tải mesh người lên, nhận auto-rig cùng kho clip mocap khổng lồ.
- Có sẵn — nhân vật mẫu như
RobotExpressivetrong demo.
Điểm vướng của Mixamo: nó xuất FBX, không phải glTF, thường ở tỉ lệ 100×. Vòng qua Blender: nhập FBX, sửa tỉ lệ, gộp các clip cần vào NLA track, rồi xuất một .glb tối ưu.
Nạp và bấm play
GLTFLoader dựng lại SkinnedMesh và skeleton cho bạn; clip nằm trong gltf.animations. Nối một mixer và một action cho mỗi clip:
let mixer; const actions = {};
const timer = new THREE.Timer();
timer.connect(document);
loader.load('/models/robot.glb', (gltf) => {
scene.add(gltf.scene);
mixer = new THREE.AnimationMixer(gltf.scene); // one independently controlled root
gltf.animations.forEach((clip) => {
actions[clip.name] = mixer.clipAction(clip); // name → action
});
actions['Idle'].play();
});
// The rule everyone forgets — without this, the model is frozen in T-pose:
renderer.setAnimationLoop((timestamp) => {
timer.update(timestamp);
if (mixer) mixer.update(timer.getDelta()); // advance by delta time
renderer.render(scene, camera);
});
Nếu nhân vật đứng chữ T và không nhúc nhích, gần như luôn là thiếu mixer.update(dt) hoặc một clip chưa .play().
Cross-fade: bí mật của sự “mượt”
Nhảy phắt từ Idle sang Run trông như robot. Cross-fade hạ weight của action này và nâng action kia, nên tay chân hoà giữa hai dáng:
let active;
function fadeToState(name, duration = 0.4) {
const next = actions[name];
if (next === active) return;
active?.fadeOut(duration); // ramp current weight → 0
next.reset()
.setEffectiveWeight(1)
.setLoop(THREE.LoopRepeat, Infinity)
.fadeIn(duration) // ramp new weight 0 → 1
.play();
active = next;
}
fadeOut/fadeIn là dạng tường minh; prev.crossFadeTo(next, duration) là cách viết gọn. Chỉnh thời lượng trực tiếp trong demo để cảm nhận 0.1s so với 0.6s thay đổi “độ nặng” của nhân vật ra sao.
Emote một-lần rồi quay về
Một cái vẫy tay hay nhảy nên chạy một lần, giữ khung cuối, rồi trả quyền về việc nhân vật đang làm. Dùng LoopOnce + clampWhenFinished, và lắng nghe sự kiện finished của mixer:
let emoteAction = null;
function playEmote(name) {
const a = actions[name];
a.reset();
a.setLoop(THREE.LoopOnce, 1);
a.clampWhenFinished = true; // freeze on the last frame, no snap-back
active?.fadeOut(0.2); a.fadeIn(0.2).play(); active = a;
emoteAction = a;
}
mixer.addEventListener('finished', (event) => {
if (event.action !== emoteAction) return;
emoteAction = null;
fadeToState(baseState, 0.3);
});
Sự kiện finished đó là cả mẹo tạo “cảm giác game”: làm một việc, rồi trở lại đứng yên.
Tốc độ, trọng số và lớp additive
Mỗi action có các điều khiển trực tiếp:
action.setEffectiveTimeScale(1.5); // play 50% faster (the demo's Speed slider)
action.setEffectiveWeight(0.5); // blend at half strength
Chạy hai action loop ở weight một phần thì được một blend — nền tảng của blend-tree di chuyển. Để xếp lớp (vẫy tay trong khi đi), makeClipAdditive(targetClip, referenceFrame, referenceClip, fps) trừ một reference pose khỏi clip trước khi cộng lên dáng nền. Reference frame hoặc bone mask sai sẽ làm nhân vật lệch dáng; helper này vẫn cần visual test.
Biểu cảm: morph target, bone hoặc hybrid
Facial rig có thể dùng xương, morph target (blend shape), hoặc kết hợp cả hai. Với morph target, mỗi biểu cảm là delta vị trí đỉnh có tên, chỉnh từ 0 tới 1. Tìm mesh mang chúng và đặt influence:
let face; model.traverse((o) => { if (o.morphTargetDictionary) face = o; });
const i = face.morphTargetDictionary['Surprised'];
face.morphTargetInfluences[i] = 1; // full Surprised; 0.5 = halfway
Các nút biểu cảm trong demo làm đúng việc đó. Morph target cũng có thể được animate bên trong clip, như xương.
Clone cùng rig khác retarget khác rig
Nếu cần nhiều instance của cùng rig, SkeletonUtils.clone() tạo bone hierarchy riêng nhưng dùng lại geometry/material theo reference:
import { clone } from 'three/addons/utils/SkeletonUtils.js';
const instance = clone(gltf.scene); // a skinned copy with its own skeleton
const mixer = new THREE.AnimationMixer(instance);
mixer.clipAction(sharedClip).play(); // same clip, different body
Đó không phải retarget. Khi source và target khác rest pose, tỉ lệ hoặc tên xương, hãy tạo clip mới với retargetClip và bone mapping:
import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
const retargeted = SkeletonUtils.retargetClip(
targetSkinnedMesh,
sourceSkeleton,
sourceClip,
{ hip: 'mixamorigHips', names: targetToSourceBoneNames }
);
new THREE.AnimationMixer(targetSkinnedMesh).clipAction(retargeted).play();
Tên xương chỉ là một phần; rest pose, bind matrix, hip scale và local offset cũng ảnh hưởng. Retarget phải có fixture cho vai, tay, chân và root motion, không chỉ kiểm tra clip “có chạy”.
Khắc phục: triệu chứng → nguyên nhân → cách sửa
| Symptom | Likely cause | Fix |
|---|---|---|
| Stuck in T-pose | no mixer.update(dt) or nothing played | update mixer each frame; action.play() |
| Exploded / giant mesh | Mixamo 100× scale, bad bind pose | fix scale in Blender; apply transforms |
| Animation on wrong joints | bone names don’t match | retarget; keep one rig naming |
| Snappy, robotic switches | no cross-fade | fadeOut/fadeIn or crossFadeTo |
| Emote snaps back to start | missing clampWhenFinished | set it + handle finished |
| Character slides (“moonwalk”) | root motion vs in-place mismatch | use in-place clips, move the node yourself |
| Face won’t emote | wrong mesh / wrong morph name | find mesh with morphTargetDictionary |
Lời cảnh báo của bậc thầy
- Một mixer cho mỗi root cần điều khiển độc lập, cập nhật mỗi frame với delta thật từ
THREE.Timer; dùng group/optional root chỉ khi contract yêu cầu. - Số xương tốn tài nguyên. Three.js có thể cấp bone matrix qua
DataTexture; giới hạn thực phụ thuộc vertex skinning cost, mesh, pass và GPU — profile model thật thay vì dùng một ngưỡng thần kỳ. - Mixamo là FBX. Luôn vòng qua Blender ra glTF; đừng ship FBX lên web.
- Đặt tên clip và xương nhất quán. Bản thân tương lai retarget thư viện clip sẽ cảm ơn bạn.
- Dispose mixer và skeleton khi xoá nhân vật, như mọi tài nguyên GPU.
Thực hành
- Trong demo, đặt cross-fade 0.05s rồi 0.8s và cảm nhận khác biệt giữa dứt khoát và bồng bềnh.
- Nối phím:
W→ Walking,Shift+W→ Running, thả → Idle, đều quafadeToState. - Lấy một clip Mixamo, đưa qua Blender ra glTF, và chạy nó trên skeleton này.
- Trộn hai clip loop bằng
setEffectiveWeightđể giả một bước đi nửa tốc độ.
Tiếp theo
Giờ bạn nạp, nén và animate được nhân vật. Cao trào tự nhiên là tương tác: click vào nhân vật, mặt đất click-để-đi, và một state machine nhỏ nối input với các cross-fade vừa dựng — biến con rối thành thứ biết phản hồi.