本文 AI 產出,尚未審核

Three.js 動畫系統(Animation System)

主題:GLB 模型動畫播放


簡介

在 3D 網頁應用中,模型動畫往往是最能提升沉浸感與互動性的元素。
Three.js 作為目前最流行的 WebGL 框架,已內建完整的動畫系統,能直接讀取包含骨架、關節與關鍵幀的 GLB(binary glTF)檔案並在瀏覽器中即時播放。

本篇文章將從 概念實作常見陷阱 以及 最佳實踐 四個面向,逐步說明如何使用 Three.js 讀取 GLB 模型、建立動畫混合器(AnimationMixer),以及控制動畫的播放、暫停、切換與混合。
適合 初學者 想快速上手,也能為 中階開發者 提供在大型專案中管理多段動畫的技巧與範例。


核心概念

1. GLB 與動畫的基本概念

名稱 說明
glTF 「JPEG of 3D」的開放格式,支援材質、貼圖、骨架與動畫。
GLB glTF 的二進位版,所有 JSON、二進位緩衝與貼圖打包成單一檔案,方便網路傳輸。
AnimationClip 一段完整的動畫資料,包含多條 KeyframeTrack(位置、旋轉、縮放等)。
AnimationMixer 負責在場景中播放、混合、暫停 AnimationClip 的核心物件。
AnimationAction AnimationMixer 為每個 AnimationClip 產生的控制介面,提供 play、stop、fadeIn、setLoop 等方法。

Tip:GLB 檔案內可以同時包含多個動畫(例如「走路」+「跳躍」),只要在載入後正確取得對應的 AnimationClip,就能自由切換或混合。


2. 使用 GLTFLoader 載入模型

import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

// 建立基礎場景
const scene = new THREE.Scene();
const clock = new THREE.Clock(); // 用於更新 mixer

// 載入 GLB
const loader = new GLTFLoader();
loader.load(
  'models/character.glb',
  (gltf) => {
    const model = gltf.scene;
    scene.add(model);

    // 取得動畫剪輯陣列
    const clips = gltf.animations; // Array<THREE.AnimationClip>
    initAnimationMixer(model, clips);
  },
  (xhr) => console.log(`GLB loading: ${(xhr.loaded / xhr.total) * 100}%`),
  (error) => console.error('GLB load error:', error)
);

GLTFLoader.load 的第三個參數是 進度回呼,在開發大型模型時可以即時顯示 loading bar;第四個參數則是錯誤處理。


3. 建立 AnimationMixerAnimationClipAnimationAction

let mixer;               // 全域變數,供 render loop 使用
let actions = {};        // 用於儲存各動畫的 Action

function initAnimationMixer(model, clips) {
  // 每個可動畫的物件(通常是根骨架)都需要一個 Mixer
  mixer = new THREE.AnimationMixer(model);

  // 依照 clips 名稱建立 Action,方便後續存取
  clips.forEach((clip) => {
    const action = mixer.clipAction(clip);
    actions[clip.name] = action;

    // 預設不自動播放,讓開發者自行決定時機
    action.enabled = true;
    action.setLoop(THREE.LoopRepeat);
    action.clampWhenFinished = true; // 停止時保持最後幀
  });

  // 直接播放預設動畫(例如第一個)
  const firstClipName = clips[0].name;
  actions[firstClipName].play();
}

重點AnimationMixer 必須在每一幀 (render loop) 呼叫 mixer.update(deltaTime),才能讓動畫前進。


4. 控制播放、暫停、快轉、循環

// 在 UI 按鈕或鍵盤事件中呼叫
function playAnimation(name, fadeTime = 0.5) {
  const toPlay = actions[name];
  if (!toPlay) {
    console.warn(`Animation "${name}" not found`);
    return;
  }

  // 先淡出目前正在播放的動畫
  const currentlyPlaying = Object.values(actions).find(a => a.isRunning());
  if (currentlyPlaying && currentlyPlaying !== toPlay) {
    currentlyPlaying.fadeOut(fadeTime);
  }

  // 淡入新動畫
  toPlay.reset().fadeIn(fadeTime).play();
}

// 暫停 / 繼續
function togglePause() {
  const playing = Object.values(actions).find(a => a.isRunning());
  if (playing) playing.paused = !playing.paused;
}

// 調整播放速度(1 = 正常、2 = 2 倍速、0.5 = 半速)
function setSpeed(name, speed) {
  const action = actions[name];
  if (action) action.timeScale = speed;
}
  • fadeIn / fadeOut 讓動畫之間的過渡更自然,避免突兀的姿勢切換
  • reset() 會把時間歸零,常在切換動畫前使用。

5. 多動畫混合與過渡(Cross‑Fade)

假設角色同時要跑步並揮手,我們可以把「跑步」設為基礎循環,然後把「揮手」加到上層,使用 加權 (weight) 來混合。

// 假設已經有兩段 Clip:run、wave
function playMixedAnimation(baseName, overlayName, overlayWeight = 0.8, fadeTime = 0.3) {
  const base = actions[baseName];
  const overlay = actions[overlayName];

  // 基礎動畫永遠循環播放
  base.reset().setEffectiveWeight(1.0).play();

  // Overlay 動畫只在需要時播放
  overlay.reset()
    .setEffectiveWeight(overlayWeight) // 控制影響程度
    .setLoop(THREE.LoopOnce, 1)        // 只播放一次
    .fadeIn(fadeTime)
    .play()
    .clampWhenFinished = true;         // 完成後保持最後姿勢
}

實作要點

  1. setEffectiveWeight:決定每條動畫對最終骨架姿勢的貢獻度。
  2. LoopOnce:Overlay 動畫常用一次性播放,避免持續干擾基礎動作。
  3. clampWhenFinished:保留最後幀,讓角色在揮手結束後自然回到跑步姿勢。

6. 渲染迴圈與時間管理

function animate() {
  requestAnimationFrame(animate);

  const delta = clock.getDelta(); // 取得自上一幀的秒數
  if (mixer) mixer.update(delta); // 更新所有 Action

  renderer.render(scene, camera);
}
animate();

使用 THREE.Clock 可以自動處理 變動的幀率,保證動畫速度不會因電腦效能不同而失真。


常見陷阱與最佳實踐

陷阱 說明 解決方案
忘記呼叫 mixer.update(delta) 動畫不會前進,或卡在第一幀。 必須在每一幀的渲染迴圈中更新 Mixer。
同時播放多段 LoopRepeat 動畫 會產生權重衝突,導致姿勢異常。 為每個動畫設定適當的 weight,或使用 fadeOut 先停掉舊動畫。
GLB 中的動畫名稱不一致 直接使用硬編碼名稱會找不到 Clip。 在載入後列印 gltf.animations.map(c => c.name),確認名稱或使用索引。
動畫速度過快/過慢 timeScale 設為 0 或負值會停住或倒放。 確認 timeScale 為正數,必要時限制最小值(如 Math.max(0.1, speed))。
重複建立 AnimationMixer 每次載入模型都新建 Mixer,導致記憶體泄漏。 只為每個 可動畫的根物件 建立一次 Mixer,若要重新載入則先 mixer.uncacheRoot(model)

最佳實踐

  1. 統一管理 Action:將所有 AnimationAction 放入一個物件(如 actions),便於查找與切換。
  2. 使用 fadeIn/fadeOut:即使是簡單的切換,也建議加上淡入淡出,提升視覺品質。
  3. 分離載入與播放:先完成模型與動畫的載入,之後再根據 UI/遊戲狀態觸發播放,避免在載入過程中即時執行 play()
  4. 針對手機端優化:使用 GLTFLoader.setDRACOLoader 壓縮幾何體,減少下載與解碼時間。
  5. 記憶體回收:場景切換或模型銷毀時,務必呼叫 mixer.uncacheRoot(model)renderer.dispose(),避免 WebGL 記憶體泄漏。

實際應用場景

場景 需求 可能的實作方式
角色控制器(RPG、FPS) 需要「走路、跑步、跳躍、攻擊」等多段動畫,且在切換時保持流暢。 使用 playAnimation() 搭配 fadeIn/fadeOut,把「跑步」設為基礎 LoopRepeat,其他動作以 LoopOnce 混合。
產品展示(機械、家具) 需要展示「開門、旋轉、組裝」等序列動畫。 依序呼叫 playAnimation(),利用 setLoop(THREE.LoopOnce) 控制每段動畫只播放一次,並在完成後自動切換下一段。
AR/VR 互動 動畫必須在不同裝置上保持同步且低延遲。 透過 THREE.Clock 取得精確時間,並在每個 frame 前先更新 Mixer,確保即使在低 FPS 情況下仍能保持正確時間進度。
UI/介面動畫 小型圖示或按鈕的「跳動」效果。 可將 GLB 中的簡單動畫(如「彈跳」)作為 AnimationClip,使用 setLoop(THREE.LoopPingPong, 2) 產生往返效果。
多角色協同動畫 多個角色同時執行不同動畫,如「領隊走路」+「隊員揮手」。 為每個角色建立獨立的 AnimationMixer,但可透過同一套 UI 控制多個 Mixer 的 playAnimation(),保持同步。

總結

  • GLB 為現代 Web 3D 所推薦的模型與動畫封裝格式,能一次傳遞幾何、材質與多段動畫。
  • Three.js 的動畫系統核心是 AnimationMixerAnimationClipAnimationAction,只要正確取得 Clip、建立 Action,就能自由控制播放、暫停、淡入淡出與權重混合。
  • 實務上,建議:
    1. 先載入再播放,避免在尚未取得 Clip 前就呼叫 play()
    2. 使用 fadeIn/fadeOut,讓動畫切換更自然。
    3. 妥善管理 Mixer 與 Action,避免記憶體泄漏。
    4. 結合 THREE.Clock,確保在不同裝置與幀率下動畫速度一致。

掌握以上概念與範例後,你即可在 角色遊戲產品展示AR/VR 互動 等各種 Web 3D 專案中,靈活且高效地播放 GLB 動畫,為使用者帶來更具沉浸感的體驗。祝開發順利,玩得開心!