Three.js - 動畫系統:骨骼動畫(Skeletal Animation)
簡介
在 3D 網頁互動中,骨骼動畫是讓角色、動物或機械模型呈現自然動作的關鍵技術。相較於傳統的頂點動畫(Vertex Animation),骨骼動畫只需要少量的骨骼資訊即可驅動大量頂點,極大降低記憶體與 CPU/GPU 負擔,適合在瀏覽器環境下即時渲染。
Three.js 內建完整的骨骼系統(Skeleton、Bone、SkinnedMesh),結合 AnimationMixer、AnimationClip、KeyframeTrack 等 API,讓開發者能以 宣告式、可重用 的方式製作高品質的角色動畫。本文將從概念到實作,逐步說明如何在 Three.js 中使用骨骼動畫,並提供多個實用範例,協助你快速上手並避免常見陷阱。
核心概念
1. 骨骼(Skeleton)與骨骼節點(Bone)
- Bone:代表一根「骨頭」的變換節點(位置、旋轉、縮放),本身不會渲染,只是提供變形矩陣。
- Skeleton:由多根
Bone組成的層級結構,負責把每根骨頭的矩陣傳遞給綁定的SkinnedMesh。
Tip:在 Three.js 中,
Bone繼承自Object3D,因此可以像一般的Mesh那樣使用position、rotation、scale來調整。
2. 綁定幾何(SkinnedMesh)
SkinnedMesh 是一種特殊的 Mesh,它的頂點資料除了位置、法線等資訊外,還包含 骨骼索引(skinIndex) 與 骨骼權重(skinWeight)。渲染時,GPU 會根據這兩組資料與骨骼矩陣計算最終頂點位置。
3. 動畫剪輯(AnimationClip)與混合器(AnimationMixer)
- AnimationClip:一段完整的動畫資料,內部由多條
KeyframeTrack(位置、旋轉、縮放)組成。 - AnimationMixer:負責在場景中播放、暫停、混合多個
AnimationClip。每個SkinnedMesh通常會對應一個AnimationMixer。
4. 關鍵影格(Keyframe)與時間軸(Timeline)
KeyframeTrack 以 時間-值 的方式儲存變化,Three.js 支援 VectorKeyframeTrack、QuaternionKeyframeTrack、NumberKeyframeTrack 等不同型別。
Note:關鍵影格的插值方式預設是線性(Linear),若需要平滑過渡,可改用
CubicSplineInterpolation。
程式碼範例
以下範例皆以 ES6 模組寫法為前提,並假設已經在 index.html 中載入 three.module.js、GLTFLoader、OrbitControls 等必要套件。
範例 1:載入 GLTF 模型並建立骨骼動畫
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 1.5, 3);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
new OrbitControls(camera, renderer.domElement);
// 加載 GLTF(內含 SkinnedMesh 與 AnimationClip)
const loader = new GLTFLoader();
loader.load('models/character.glb', gltf => {
const model = gltf.scene;
scene.add(model);
// 取得第一個 SkinnedMesh
const skinnedMesh = model.getObjectByProperty('type', 'SkinnedMesh');
// 建立 AnimationMixer 並播放第一個動畫
const mixer = new THREE.AnimationMixer(skinnedMesh);
const clip = gltf.animations[0]; // 假設只有一段跑步動畫
const action = mixer.clipAction(clip);
action.play();
// 於渲染循環中更新 mixer
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
mixer.update(delta);
renderer.render(scene, camera);
}
animate();
});
說明
GLTFLoader會自動解析skinIndex、skinWeight,不需要手動設定。AnimationMixer必須每幀呼叫update(delta),delta為上一幀與本幀的時間差。
範例 2:手動建立骨骼結構與 SkinnedMesh
此範例示範如何從程式碼自行建立一個簡易的「四肢」模型(兩根骨頭)並套用動畫。
// 建立兩根骨頭
const rootBone = new THREE.Bone();
rootBone.name = 'root';
rootBone.position.y = 0; // 原點
const childBone = new THREE.Bone();
childBone.name = 'arm';
childBone.position.y = 1; // 往上 1 單位
rootBone.add(childBone);
// 建立 Skeleton
const bones = [rootBone, childBone];
const skeleton = new THREE.Skeleton(bones);
// 建立簡單的幾何(四個頂點形成一條直線)
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
0, 0, 0, // vertex 0
0, 2, 0, // vertex 1
0, 4, 0, // vertex 2
0, 6, 0 // vertex 3
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
// skinIndex 與 skinWeight(每個頂點只受一根骨頭影響)
const skinIndices = new Uint16Array([
0, 0, 0, 0, // vertex 0 受 rootBone
0, 0, 0, 0, // vertex 1 受 rootBone
1, 0, 0, 0, // vertex 2 受 childBone
1, 0, 0, 0 // vertex 3 受 childBone
]);
const skinWeights = new Float32Array([
1, 0, 0, 0,
1, 0, 0, 0,
1, 0, 0, 0,
1, 0, 0, 0
]);
geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(skinIndices, 4));
geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(skinWeights, 4));
// 建立 SkinnedMesh
const material = new THREE.MeshStandardMaterial({ color: 0x156289, skinning: true });
const skinnedMesh = new THREE.SkinnedMesh(geometry, material);
skinnedMesh.add(rootBone);
skinnedMesh.bind(skeleton);
scene.add(skinnedMesh);
// 動畫:讓 childBone 前後擺動
const times = [0, 1, 2]; // 秒
const values = [0, Math.PI / 4, 0]; // 旋轉角度(rad)
const track = new THREE.QuaternionKeyframeTrack('.bones[arm].quaternion', times, values.map(v => new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, v)).toArray()).flat());
const clip = new THREE.AnimationClip('wave', -1, [track]);
const mixer = new THREE.AnimationMixer(skinnedMesh);
const action = mixer.clipAction(clip);
action.play();
const clock = new THREE.Clock();
function render() {
requestAnimationFrame(render);
const delta = clock.getDelta();
mixer.update(delta);
renderer.render(scene, camera);
}
render();
重點
skinIndex/skinWeight必須以 四個分量(四個骨頭的影響)儲存,即使只使用一根骨頭也須填滿。QuaternionKeyframeTrack的路徑寫法是'.bones[boneName].quaternion',這是 Three.js 內建的屬性路徑語法。
範例 3:混合兩段動畫(走路 + 揮手)
在遊戲或互動應用中,角色常同時執行多段動畫。以下示範如何使用 AnimationMixer 的 crossFade 與 weight 來混合動畫。
loader.load('models/hero.glb', gltf => {
const hero = gltf.scene;
scene.add(hero);
const skinnedMesh = hero.getObjectByProperty('type', 'SkinnedMesh');
const mixer = new THREE.AnimationMixer(skinnedMesh);
const walkClip = THREE.AnimationClip.findByName(gltf.animations, 'Walk');
const waveClip = THREE.AnimationClip.findByName(gltf.animations, 'Wave');
const walkAction = mixer.clipAction(walkClip);
const waveAction = mixer.clipAction(waveClip);
// 設定 walk 為循環,wave 為一次性
walkAction.play();
walkAction.setLoop(THREE.LoopRepeat, Infinity);
waveAction.play();
waveAction.setLoop(THREE.LoopOnce, 1);
waveAction.clampWhenFinished = true; // 完成後保持最後姿勢
// 讓 wave 在第 2 秒開始淡入,持續 0.5 秒
setTimeout(() => {
waveAction.reset(); // 從頭開始
waveAction.crossFadeFrom(walkAction, 0.5, true);
}, 2000);
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
mixer.update(delta);
renderer.render(scene, camera);
}
animate();
});
說明
crossFadeFrom會自動調整兩段動畫的 weight,讓過渡更自然。clampWhenFinished防止一次性動畫在結束後回到原始姿勢,常用於揮手、攻擊等動作。
範例 4:使用 Morph Target 與骨骼動畫結合(臉部表情)
有時候角色需要同時驅動 骨骼動畫(身體)與 形變動畫(表情)。Three.js 允許同時啟用 skinning 與 morphTargets。
loader.load('models/character_with_morph.glb', gltf => {
const model = gltf.scene;
scene.add(model);
const mesh = model.getObjectByProperty('type', 'SkinnedMesh');
// 取得 morph target 名稱陣列
const morphNames = mesh.morphTargetDictionary; // { smile:0, angry:1, ... }
// 建立一個簡單的表情動畫 Clip(從 neutral -> smile)
const times = [0, 0.5, 1];
const values = [0, 1, 0]; // morphWeight 變化
const morphTrack = new THREE.NumberKeyframeTrack('.morphTargetInfluences[smile]', times, values);
const morphClip = new THREE.AnimationClip('Smile', -1, [morphTrack]);
const mixer = new THREE.AnimationMixer(mesh);
const walkClip = THREE.AnimationClip.findByName(gltf.animations, 'Walk');
const walkAction = mixer.clipAction(walkClip);
const smileAction = mixer.clipAction(morphClip);
walkAction.play();
smileAction.play();
const clock = new THREE.Clock();
function render() {
requestAnimationFrame(render);
const delta = clock.getDelta();
mixer.update(delta);
renderer.render(scene, camera);
}
render();
});
重點
NumberKeyframeTrack的路徑使用.morphTargetInfluences[<name>],可直接控制單一表情的權重。- 同時播放骨骼動畫與形變動畫不會衝突,只要確保
material同時開啟skinning與morphTargets。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| 骨骼矩陣未更新 | 在手動修改 Bone 的 position/rotation 後,若未呼叫 skeleton.update(),渲染結果仍是舊姿勢。 |
在每幀結束前(或在 AnimationMixer.update 後)呼叫 skinnedMesh.skeleton.update(); |
| skinWeight 總和不為 1 | GPU 會根據權重比例做線性組合,若權重總和不等於 1,會產生不預期的縮放或拉伸。 | 在建模階段使用 3D 軟體(Blender、Maya)自動正規化,或在程式中手動正規化 skinWeight。 |
| 骨骼層級過深 | 每層骨骼都會產生額外的矩陣乘法,過深的層級會影響效能。 | 盡量保持 2~4 層 的層級結構;不必要的中介節點可合併。 |
| 混合多段動畫時權重忘記重設 | 若切換動畫但未調整 weight,舊動畫仍會留下痕跡。 |
使用 action.reset()、action.setEffectiveWeight(0) 或 crossFadeTo 來確保權重正確。 |
| 材質未開啟 skinning | MeshStandardMaterial 預設 skinning: false,導致骨骼變形無效。 |
在建立材質時加入 { skinning: true },若同時使用 morphTarget,加入 { morphTargets: true }。 |
最佳實踐
- 使用 GLTF 作為主要模型格式:GLTF 原生支援
skin、morphTargets,且載入速度快。 - 在模型中預先分配好
AnimationClip:避免在程式中手動寫大量關鍵影格,保持資料與程式分離。 - 將
AnimationMixer與Clock結合:所有動畫更新統一使用mixer.update(delta),確保時間同步。 - 利用
AnimationAction的loop、clampWhenFinished、fadeIn/fadeOut,實現自然過渡與循環控制。 - 對大型角色採用 LOD(Level of Detail):遠距離時切換到低骨骼數量或預算較低的模型,提升效能。
實際應用場景
| 場景 | 需求 | Three.js 解決方案 |
|---|---|---|
| 線上 3D RPG | 角色走路、跑步、攻擊、表情切換 | 使用 GLTF 載入完整骨骼與多段 AnimationClip,透過 AnimationMixer 動態切換與混合。 |
| 虛擬試衣間 | 人形模型穿戴不同衣服、姿勢調整 | 建立共享 Skeleton,不同衣服模型只需綁定同一套骨骼,即可即時切換服飾。 |
| 教育訓練模擬 | 機械手臂的關節運動與動畫說明 | 手動建立 Bone 階層,配合 QuaternionKeyframeTrack 播放關節旋轉。 |
| 社交平台表情動畫 | 用戶上傳角色,需即時播放「笑」或「驚訝」表情 | 結合 morphTarget 與骨骼動畫,使用 NumberKeyframeTrack 控制表情權重。 |
| AR/VR 互動體驗 | 角色需根據使用者手部追蹤即時變形 | 在每幀讀取手部姿態,直接更新對應 Bone 的 world matrix,並呼叫 skeleton.update()。 |
總結
骨骼動畫是 Three.js 中最具威力且彈性的 3D 動畫技術之一。透過 Skeleton、SkinnedMesh、AnimationMixer 等核心 API,我們可以在瀏覽器內實現高效、可重用且易於維護的角色動畫。本文從概念說明、程式範例、常見陷阱到實務應用,提供了一條完整的學習與實作路徑,期望能幫助讀者在自己的專案中快速上手並產出 流暢、自然 的動畫體驗。
最後提醒:在開發過程中,務必檢查模型的 skinIndex / skinWeight 正確性、材質的
skinning設定,並使用AnimationMixer統一管理時間與混合,這樣才能發揮 Three.js 骨骼動畫的最大效能與表現。祝開發順利! 🎉