Three.js from Zero to Senior · Bonus 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.
A static model is furniture; a model that moves is a character. {Một model tĩnh là đồ đạc; một model chuyển động là nhân vật.} Part 16 got your assets small — now we make them come alive. {Part 16 đã làm asset nhỏ lại — giờ ta làm chúng sống dậy.} The demo loads a fully rigged glTF and lets you cross-fade between locomotion states, fire one-shot emotes, and drive face expressions — all from a single AnimationMixer. {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.}
Open the full demo {Mở demo đầy đủ}: /tools/threejs-animation-demo/.
The vocabulary you actually need {Từ vựng bạn thật sự cần}
Skeletal animation has five moving parts — learn the words and the API reads itself: {Animation xương có năm thành phần — học đúng từ thì API tự đọc được:}
- Rig / Skeleton / Bones — an invisible hierarchy of
Boneobjects, like a posable armature. {Rig / Skeleton / Bones — một phân cấpBonevô hình, như bộ xương tạo dáng được.} - Skinning / weights — each vertex is bound to one or more bones with weights, so moving a bone deforms the mesh. This is a
SkinnedMesh. {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 — one named animation (“Walk”), a bundle of
KeyframeTracks (bone X rotation over time, etc.). {AnimationClip — một animation có tên (“Walk”), gồm nhiềuKeyframeTrack.} - AnimationMixer — the per-model playback engine; you create one per character. {AnimationMixer — bộ phát theo từng model; mỗi nhân vật tạo một cái.}
- AnimationAction — a clip playing on a mixer, with weight, time-scale, loop mode and fades. {AnimationAction — một clip đang chạy trên mixer, có weight, time-scale, chế độ loop và fade.}
Where do the clips come from? {Clip đến từ đâu?}
Three practical sources: {Ba nguồn thực tế:}
- Blender — rig with an Armature, animate Actions, export with Skinning + Animation on (Part 14). Use Always Sample Animations and NLA tracks to ship multiple named clips. {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 (free, Adobe) — upload a humanoid mesh, get auto-rigging plus a huge library of mocap clips. {Mixamo (miễn phí, Adobe) — tải mesh người lên, nhận auto-rig cùng kho clip mocap khổng lồ.}
- Ready-made — sample characters like the
RobotExpressivein this demo. {Có sẵn — nhân vật mẫu nhưRobotExpressivetrong demo.}
The Mixamo catch: it exports FBX, not glTF, often at 100× scale. {Điểm vướng của Mixamo: nó xuất FBX, không phải glTF, thường ở tỉ lệ 100×.} Round-trip through Blender: import the FBX, fix scale, combine the clips you want into NLA tracks, then export one optimized .glb. {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.}
Load it and press play {Nạp và bấm play}
GLTFLoader rebuilds the SkinnedMesh and skeleton for you; the clips land in gltf.animations. {GLTFLoader dựng lại SkinnedMesh và skeleton cho bạn; clip nằm trong gltf.animations.} Wire one mixer and an action per clip: {Nối một mixer và một action cho mỗi clip:}
let mixer; const actions = {}; const clock = new THREE.Clock();
loader.load('/models/robot.glb', (gltf) => {
scene.add(gltf.scene);
mixer = new THREE.AnimationMixer(gltf.scene); // ONE per character
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(() => {
if (mixer) mixer.update(clock.getDelta()); // advance by delta time
renderer.render(scene, camera);
});
If your character stands in a T-pose and never moves, it’s almost always a missing mixer.update(dt) or a clip you never .play()-ed. {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-fading: the secret to “smooth” {Cross-fade: bí mật của sự “mượt”}
Snapping from Idle to Run looks robotic. {Nhảy phắt từ Idle sang Run trông như robot.} A cross-fade ramps one action’s weight down while another ramps up, so limbs blend between poses: {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 is the explicit form; prev.crossFadeTo(next, duration) is the shorthand. {fadeOut/fadeIn là dạng tường minh; prev.crossFadeTo(next, duration) là cách viết gọn.} Tune the duration live in the demo to feel how 0.1s vs 0.6s changes the character’s “weight”. {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.}
One-shot emotes that return {Emote một-lần rồi quay về}
A wave or a jump should play once, hold the last frame, then hand control back to whatever the character was doing. {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.} Use LoopOnce + clampWhenFinished, and listen for the mixer’s finished event: {Dùng LoopOnce + clampWhenFinished, và lắng nghe sự kiện finished của mixer:}
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;
}
mixer.addEventListener('finished', () => fadeToState(baseState, 0.3)); // return
That finished event is the whole trick behind game feel: do a thing, then resume idling. {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.}
Speed, weight and additive layers {Tốc độ, trọng số và lớp additive}
Every action exposes live controls: {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
Run two looping actions at partial weight and you get a blend — the basis of locomotion blend-trees (Idle↔Walk↔Run by a single input). {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.} For layering (wave while walking), THREE.AnimationUtils.makeClipAdditive(clip) turns a clip additive so it adds onto the base pose instead of replacing it. {Để xếp lớp (vẫy tay trong khi đi), THREE.AnimationUtils.makeClipAdditive(clip) biến clip thành additive để cộng lên dáng nền thay vì thay thế.}
Expressions = morph targets {Biểu cảm = morph target}
Faces don’t use bones — they use morph targets (blend shapes): named vertex-position deltas you dial from 0 to 1. {Khuôn mặt không dùng xương — nó dùng morph target (blend shape): các delta vị trí đỉnh có tên, chỉnh từ 0 tới 1.} Find the mesh that carries them and set influences: {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
The expression buttons in the demo do exactly this. {Các nút biểu cảm trong demo làm đúng việc đó.} Morph targets can also be animated inside clips, the same as bones. {Morph target cũng có thể được animate bên trong clip, như xương.}
Sharing clips across characters {Dùng chung clip giữa nhân vật}
Want one library of Mixamo clips driving several characters? {Muốn một thư viện clip Mixamo điều khiển nhiều nhân vật?} As long as skeletons match (bone names/hierarchy), retarget with SkeletonUtils: {Miễn skeleton khớp (tên/phân cấp xương), retarget bằng SkeletonUtils:}
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
Mismatched bone names are the #1 reason “the animation plays on the wrong joints”. {Tên xương lệch là lý do số 1 khiến “animation chạy sai khớp”.}
Troubleshooting: symptom → cause → fix {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 |
Master’s warnings {Lời cảnh báo của bậc thầy}
- One mixer per character, updated every frame with a real delta from
THREE.Clock. {Một mixer mỗi nhân vật, cập nhật mỗi frame với delta thật từTHREE.Clock.} - Bone count costs. Each bone is a uniform; mobile GPUs choke past a few hundred. Keep rigs lean. {Số xương tốn tài nguyên. Mỗi xương là một uniform; GPU mobile nghẹn khi quá vài trăm.}
- Mixamo is FBX. Always round-trip through Blender to glTF; don’t ship FBX to the web. {Mixamo là FBX. Luôn vòng qua Blender ra glTF; đừng ship FBX lên web.}
- Name your clips and bones consistently. Future-you retargeting a clip library will thank you. {Đặ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 mixers and skeletons when removing characters, like any GPU resource. {Dispose mixer và skeleton khi xoá nhân vật, như mọi tài nguyên GPU.}
Practice {Thực hành}
- In the demo, set cross-fade to 0.05s then 0.8s and feel the difference between snappy and floaty. {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.}
- Wire keyboard input:
W→ Walking,Shift+W→ Running, release → Idle, all viafadeToState. {Nối phím:W→ Walking,Shift+W→ Running, thả → Idle, đều quafadeToState.} - Grab a Mixamo clip, bring it through Blender to glTF, and play it on this skeleton. {Lấy một clip Mixamo, đưa qua Blender ra glTF, và chạy nó trên skeleton này.}
- Blend two looping clips with
setEffectiveWeightto fake a half-speed walk. {Trộn hai clip loop bằngsetEffectiveWeightđể giả một bước đi nửa tốc độ.}
What’s next {Tiếp theo}
You can now load, compress, and animate characters. {Giờ bạn nạp, nén và animate được nhân vật.} The natural finale is interaction: clicking a character, a click-to-move ground, and a tiny state machine that ties input to the cross-fades you just built — turning a puppet into something that responds. {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.}