本文 AI 產出,尚未審核

Three.js - 動畫系統:骨骼動畫(Skeletal Animation)


簡介

在 3D 網頁互動中,骨骼動畫是讓角色、動物或機械模型呈現自然動作的關鍵技術。相較於傳統的頂點動畫(Vertex Animation),骨骼動畫只需要少量的骨骼資訊即可驅動大量頂點,極大降低記憶體與 CPU/GPU 負擔,適合在瀏覽器環境下即時渲染。

Three.js 內建完整的骨骼系統(SkeletonBoneSkinnedMesh),結合 AnimationMixerAnimationClipKeyframeTrack 等 API,讓開發者能以 宣告式可重用 的方式製作高品質的角色動畫。本文將從概念到實作,逐步說明如何在 Three.js 中使用骨骼動畫,並提供多個實用範例,協助你快速上手並避免常見陷阱。


核心概念

1. 骨骼(Skeleton)與骨骼節點(Bone)

  • Bone:代表一根「骨頭」的變換節點(位置、旋轉、縮放),本身不會渲染,只是提供變形矩陣。
  • Skeleton:由多根 Bone 組成的層級結構,負責把每根骨頭的矩陣傳遞給綁定的 SkinnedMesh

Tip:在 Three.js 中,Bone 繼承自 Object3D,因此可以像一般的 Mesh 那樣使用 positionrotationscale 來調整。

2. 綁定幾何(SkinnedMesh)

SkinnedMesh 是一種特殊的 Mesh,它的頂點資料除了位置、法線等資訊外,還包含 骨骼索引(skinIndex)骨骼權重(skinWeight)。渲染時,GPU 會根據這兩組資料與骨骼矩陣計算最終頂點位置。

3. 動畫剪輯(AnimationClip)與混合器(AnimationMixer)

  • AnimationClip:一段完整的動畫資料,內部由多條 KeyframeTrack(位置、旋轉、縮放)組成。
  • AnimationMixer:負責在場景中播放、暫停、混合多個 AnimationClip。每個 SkinnedMesh 通常會對應一個 AnimationMixer

4. 關鍵影格(Keyframe)與時間軸(Timeline)

KeyframeTrack時間-值 的方式儲存變化,Three.js 支援 VectorKeyframeTrackQuaternionKeyframeTrackNumberKeyframeTrack 等不同型別。

Note:關鍵影格的插值方式預設是線性(Linear),若需要平滑過渡,可改用 CubicSplineInterpolation


程式碼範例

以下範例皆以 ES6 模組寫法為前提,並假設已經在 index.html 中載入 three.module.jsGLTFLoaderOrbitControls 等必要套件。

範例 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 會自動解析 skinIndexskinWeight,不需要手動設定。
  • 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:混合兩段動畫(走路 + 揮手)

在遊戲或互動應用中,角色常同時執行多段動畫。以下示範如何使用 AnimationMixercrossFadeweight 來混合動畫。

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 允許同時啟用 skinningmorphTargets

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 同時開啟 skinningmorphTargets

常見陷阱與最佳實踐

陷阱 說明 解決方案
骨骼矩陣未更新 在手動修改 Boneposition/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 }

最佳實踐

  1. 使用 GLTF 作為主要模型格式:GLTF 原生支援 skinmorphTargets,且載入速度快。
  2. 在模型中預先分配好 AnimationClip:避免在程式中手動寫大量關鍵影格,保持資料與程式分離。
  3. AnimationMixerClock 結合:所有動畫更新統一使用 mixer.update(delta),確保時間同步。
  4. 利用 AnimationActionloopclampWhenFinishedfadeIn/fadeOut,實現自然過渡與循環控制。
  5. 對大型角色採用 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 動畫技術之一。透過 SkeletonSkinnedMeshAnimationMixer 等核心 API,我們可以在瀏覽器內實現高效、可重用且易於維護的角色動畫。本文從概念說明、程式範例、常見陷阱到實務應用,提供了一條完整的學習與實作路徑,期望能幫助讀者在自己的專案中快速上手並產出 流暢、自然 的動畫體驗。

最後提醒:在開發過程中,務必檢查模型的 skinIndex / skinWeight 正確性、材質的 skinning 設定,並使用 AnimationMixer 統一管理時間與混合,這樣才能發揮 Three.js 骨骼動畫的最大效能與表現。祝開發順利! 🎉