jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 Bone objects, like a posable armature. {Rig / Skeleton / Bones — một phân cấp Bone vô 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ều KeyframeTrack.}
  • 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 RobotExpressive in this demo. {Có sẵn — nhân vật mẫu như RobotExpressive trong 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}

SymptomLikely causeFix
Stuck in T-poseno mixer.update(dt) or nothing playedupdate mixer each frame; action.play()
Exploded / giant meshMixamo 100× scale, bad bind posefix scale in Blender; apply transforms
Animation on wrong jointsbone names don’t matchretarget; keep one rig naming
Snappy, robotic switchesno cross-fadefadeOut/fadeIn or crossFadeTo
Emote snaps back to startmissing clampWhenFinishedset it + handle finished
Character slides (“moonwalk”)root motion vs in-place mismatchuse in-place clips, move the node yourself
Face won’t emotewrong mesh / wrong morph namefind 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}

  1. 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.}
  2. Wire keyboard input: W → Walking, Shift+W → Running, release → Idle, all via fadeToState. {Nối phím: W → Walking, Shift+W → Running, thả → Idle, đều qua fadeToState.}
  3. 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.}
  4. Blend two looping clips with setEffectiveWeight to fake a half-speed walk. {Trộn hai clip loop bằng setEffectiveWeight để 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.}