Communitygithub.com

Three.js 動畫系統

管理 Three.js 中的關鍵幀動畫、骨架動畫、變形目標與動畫混合。適合用於物件動畫、播放 GLTF 動畫、建立程式化運動或混合多個動畫。

Three.js 動畫系統 是什麼?

這個技能提供完整的 Three.js 動畫系統指引,涵蓋 AnimationClip、AnimationMixer 與 AnimationAction 三大核心元件。開發者可以學習如何建立與控制關鍵幀動畫、載入並播放 GLTF 模型的骨架動畫、操作變形目標 (morph targets) 來改變網格形狀,以及透過混合多個動畫來實現流暢的動作轉換。此外,也包含了程式化動畫模式如平滑阻尼、彈簧物理與振盪運動的實例,以及效能最佳化的建議。本技能適用於任何使用 Three.js 的專案,無論是在網頁應用、遊戲或互動體驗中需要動畫功能,都能快速上手。

相容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/aelyraAI/aelyra-skills/tree/HEAD/threejs-animation

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

Three.js 動畫系統 是做什麼的?

快速開始

import * as THREE from "three";

// 簡易程式化動畫
const clock = new THREE.Clock();

function animate() {
  const delta = clock.getDelta();
  const elapsed = clock.getElapsedTime();

  mesh.rotation.y += delta;
  mesh.position.y = Math.sin(elapsed) * 0.5;

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

動畫系統概覽

Three.js 動畫系統包含三個主要元件:

  1. AnimationClip - 儲存關鍵幀資料的容器
  2. AnimationMixer - 在根物件上播放動畫
  3. AnimationAction - 控制剪輯的播放

AnimationClip

儲存關鍵幀動畫資料。

// 建立動畫剪輯
const times = [0, 1, 2]; // 關鍵幀時間(秒)
const values = [0, 1, 0]; // 每個關鍵幀的值

const track = new THREE.NumberKeyframeTrack(
  ".position[y]", // 屬性路徑
  times,
  values,
);

const clip = new THREE.AnimationClip("bounce", 2, [track]);

關鍵幀軌道類型

// 數值軌道(單一值)
new THREE.NumberKeyframeTrack(".opacity", times, [1, 0]);
new THREE.NumberKeyframeTrack(".material.opacity", times, [1, 0]);

// 向量軌道(位置、縮放)
new THREE.VectorKeyframeTrack(".position", times, [
  0, 0, 0, // t=0
  1, 2, 0, // t=1
  0, 0, 0, // t=2
]);

// 四元數軌道(旋轉)
const q1 = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, 0));
const q2 = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, Math.PI, 0));
new THREE.QuaternionKeyframeTrack(
  ".quaternion",
  [0, 1],
  [q1.x, q1.y, q1.z, q1.w, q2.x, q2.y, q2.z, q2.w],
);

// 顏色軌道
new THREE.ColorKeyframeTrack(".material.color", times, [
  1, 0, 0, // 紅色
  0, 1, 0, // 綠色
  0, 0, 1, // 藍色
]);

// 布林軌道
new THREE.BooleanKeyframeTrack(".visible", [0, 0.5, 1], [true, false, true]);

// 字串軌道(用於變形目標)
new THREE.StringKeyframeTrack(
  ".morphTargetInfluences[smile]",
  [0, 1],
  ["0", "1"],
);

插值模式

const track = new THREE.VectorKeyframeTrack(".position", times, values);

// 插值模式
track.setInterpolation(THREE.InterpolateLinear); // 預設
track.setInterpolation(THREE.InterpolateSmooth); // 三次樣條
track.setInterpolation(THREE.InterpolateDiscrete); // 階梯函數

AnimationMixer

在物件及其子物件上播放動畫。

const mixer = new THREE.AnimationMixer(model);

// 從剪輯建立動作
const action = mixer.clipAction(clip);
action.play();

// 在動畫迴圈中更新
function animate() {
  const delta = clock.getDelta();
  mixer.update(delta); // 必要!

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

混音器事件

mixer.addEventListener("finished", (e) => {
  console.log("動畫完成:", e.action.getClip().name);
});

mixer.addEventListener("loop", (e) => {
  console.log("動畫循環:", e.action.getClip().name);
});

AnimationAction

控制動畫剪輯的播放。

const action = mixer.clipAction(clip);

// 播放控制
action.play();
action.stop();
action.reset();
action.halt(fadeOutDuration);

// 播放狀態
action.isRunning();
action.isScheduled();

// 時間控制
action.time = 0.5; // 目前時間
action.timeScale = 1; // 播放速度(負值 = 倒轉)
action.paused = false;

// 權重(用於混合)
action.weight = 1; // 0-1,對最終姿勢的貢獻
action.setEffectiveWeight(1);

// 循環模式
action.loop = THREE.LoopRepeat; // 預設:無限循環
action.loop = THREE.LoopOnce; // 播放一次後停止
action.loop = THREE.LoopPingPong; // 交替前進/後退
action.repetitions = 3; // 循環次數(預設 Infinity)

// 鉗制
action.clampWhenFinished = true; // 完成時停留在最後一幀

// 混合模式
action.blendMode = THREE.NormalAnimationBlendMode;
action.blendMode = THREE.AdditiveAnimationBlendMode;

淡入/淡出

// 淡入
action.reset().fadeIn(0.5).play();

// 淡出
action.fadeOut(0.5);

// 兩個動畫之間交叉淡入淡出
const action1 = mixer.clipAction(clip1);
const action2 = mixer.clipAction(clip2);

action1.play();

// 稍後,交叉淡入到 action2
action1.crossFadeTo(action2, 0.5, true);
action2.play();

載入 GLTF 動畫

骨架動畫最常見的來源。

import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

const loader = new GLTFLoader();
loader.load("model.glb", (gltf) => {
  const model = gltf.scene;
  scene.add(model);

  // 建立混音器
  const mixer = new THREE.AnimationMixer(model);

  // 取得所有剪輯
  const clips = gltf.animations;
  console.log(
    "可用動畫:",
    clips.map((c) => c.name),
  );

  // 播放第一個動畫
  if (clips.length > 0) {
    const action = mixer.clipAction(clips[0]);
    action.play();
  }

  // 透過名稱播放特定動畫
  const walkClip = THREE.AnimationClip.findByName(clips, "Walk");
  if (walkClip) {
    mixer.clipAction(walkClip).play();
  }

  // 儲存混音器供更新迴圈使用
  window.mixer = mixer;
});

// 動畫迴圈
function animate() {
  const delta = clock.getDelta();
  if (window.mixer) window.mixer.update(delta);

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

骨架動畫

骨架與骨骼

// 從蒙皮網格存取骨架
const skinnedMesh = model.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;

// 存取骨骼
skeleton.bones.forEach((bone) => {
  console.log(bone.name, bone.position, bone.rotation);
});

// 透過名稱尋找特定骨骼
const headBone = skeleton.bones.find((b) => b.name === "Head");
if (headBone) headBone.rotation.y = Math.PI / 4; // 轉頭

// 骨骼輔助器
const helper = new THREE.SkeletonHelper(model);
scene.add(helper);

程式化骨骼動畫

function animate() {
  const time = clock.getElapsedTime();

  // 動畫骨骼
  const headBone = skeleton.bones.find((b) => b.name === "Head");
  if (headBone) {
    headBone.rotation.y = Math.sin(time) * 0.3;
  }

  // 如果同時播放剪輯,更新混音器
  mixer.update(clock.getDelta());
}

骨骼附件

// 將物件附加到骨骼
const weapon = new THREE.Mesh(weaponGeometry, weaponMaterial);
const handBone = skeleton.bones.find((b) => b.name === "RightHand");
if (handBone) handBone.add(weapon);

// 偏移附件
weapon.position.set(0, 0, 0.5);
weapon.rotation.set(0, Math.PI / 2, 0);

變形目標

在不同網格形狀之間混合。

// 變形目標儲存在幾何體中
const geometry = mesh.geometry;
console.log("變形屬性:", Object.keys(geometry.morphAttributes));

// 存取變形目標影響量
mesh.morphTargetInfluences; // 權重陣列
mesh.morphTargetDictionary; // 名稱到索引的對應

// 依索引設定變形目標
mesh.morphTargetInfluences[0] = 0.5;

// 依名稱設定
const smileIndex = mesh.morphTargetDictionary["smile"];
mesh.morphTargetInfluences[smileIndex] = 1;

動畫變形目標

// 程序化
function animate() {
  const t = clock.getElapsedTime();
  mesh.morphTargetInfluences[0] = (Math.sin(t) + 1) / 2;
}

// 使用關鍵幀動畫
const track = new THREE.NumberKeyframeTrack(
  ".morphTargetInfluences[smile]",
  [0, 0.5, 1],
  [0, 1, 0],
);
const clip = new THREE.AnimationClip("smile", 1, [track]);
mixer.clipAction(clip).play();

動畫混合

將多個動畫混合在一起。

// 設定動作
const idleAction = mixer.clipAction(idleClip);
const walkAction = mixer.clipAction(walkClip);
const runAction = mixer.clipAction(runClip);

// 全部播放,權重不同
idleAction.play();
walkAction.play();
runAction.play();

// 設定初始權重
idleAction.setEffectiveWeight(1);
walkAction.setEffectiveWeight(0);
runAction.setEffectiveWeight(0);

// 根據速度混合
function updateAnimations(speed) {
  if (speed < 0.1) {
    idleAction.setEffectiveWeight(1);
    walkAction.setEffectiveWeight(0);
    runAction.setEffectiveWeight(0);
  } else if (speed < 5) {
    const t = speed / 5;
    idleAction.setEffectiveWeight(1 - t);
    walkAction.setEffectiveWeight(t);
    runAction.setEffectiveWeight(0);
  } else {
    const t = Math.min((speed - 5) / 5, 1);
    idleAction.setEffectiveWeight(0);
    walkAction.setEffectiveWeight(1 - t);
    runAction.setEffectiveWeight(t);
  }
}

加法混合

// 基礎姿勢
const baseAction = mixer.clipAction(baseClip);
baseAction.play();

// 加法層(例如呼吸)
const additiveAction = mixer.clipAction(additiveClip);
additiveAction.blendMode = THREE.AdditiveAnimationBlendMode;
additiveAction.play();

// 將剪輯轉換為加法模式
THREE.AnimationUtils.makeClipAdditive(additiveClip);

動畫工具

import * as THREE from "three";

// 依名稱尋找剪輯
const clip = THREE.AnimationClip.findByName(clips, "Walk");

// 建立子剪輯
const subclip = THREE.AnimationUtils.subclip(clip, "subclip", 0, 30, 30);

// 轉換為加法模式
THREE.AnimationUtils.makeClipAdditive(clip);
THREE.AnimationUtils.makeClipAdditive(clip, 0, referenceClip);

// 複製剪輯
const clone = clip.clone();

// 取得剪輯長度
clip.duration;

// 最佳化剪輯(移除冗餘關鍵幀)
clip.optimize();

// 將剪輯重設為第一幀
clip.resetDuration();

程式化動畫模式

平滑阻尼

// 平滑跟隨/線性插值
const target = new THREE.Vector3();
const current = new THREE.Vector3();
const velocity = new THREE.Vector3();

function smoothDamp(current, target, velocity, smoothTime, deltaTime) {
  const omega = 2 / smoothTime;
  const x = omega * deltaTime;
  const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
  const change = current.clone().sub(target);
  const temp = velocity
    .clone()
    .add(change.clone().multiplyScalar(omega))
    .multiplyScalar(deltaTime);
  velocity.sub(temp.clone().multiplyScalar(omega)).multiplyScalar(exp);
  return target.clone().add(change.add(temp).multiplyScalar(exp));
}

function animate() {
  current.copy(smoothDamp(current, target, velocity, 0.3, delta));
  mesh.position.copy(current);
}

彈簧物理

class Spring {
  constructor(stiffness = 100, damping = 10) {
    this.stiffness = stiffness;
    this.damping = damping;
    this.position = 0;
    this.velocity = 0;
    this.target = 0;
  }

  update(dt) {
    const force = -this.stiffness * (this.position - this.target);
    const dampingForce = -this.damping * this.velocity;
    this.velocity += (force + dampingForce) * dt;
    this.position += this.velocity * dt;
    return this.position;
  }
}

const spring = new Spring(100, 10);
spring.target = 1;

function animate() {
  mesh.position.y = spring.update(delta);
}

振盪

function animate() {
  const t = clock.getElapsedTime();

  // 正弦波
  mesh.position.y = Math.sin(t * 2) * 0.5;

  // 彈跳
  mesh.position.y = Math.abs(Math.sin(t * 3)) * 2;

  // 圓周運動
  mesh.position.x = Math.cos(t) * 2;
  mesh.position.z = Math.sin(t) * 2;

  // 8 字形
  mesh.position.x = Math.sin(t) * 2;
  mesh.position.z = Math.sin(t * 2) * 1;
}

效能提示

  1. 共用剪輯:同一個 AnimationClip 可用於多個混音器
  2. 最佳化剪輯:呼叫 clip.optimize() 移除冗餘關鍵幀
  3. 離開畫面時停用:對不可見物件停止混音器更新
  4. 使用 LOD 處理動畫:遠處角色使用較簡單的骨架
  5. 限制活動混音器數量:每個 mixer.update() 都有成本
// 不可見時暫停動畫
mesh.onBeforeRender = () => {
  action.paused = false;
};

mesh.onAfterRender = () => {
  // 檢查下一幀是否可見
  if (!isInFrustum(mesh)) {
    action.paused = true;
  }
};

// 快取剪輯
const clipCache = new Map();
function getClip(name) {
  if (!clipCache.has(name)) {
    clipCache.set(name, loadClip(name));
  }
  return clipCache.get(name);
}

另請參閱

  • threejs-loaders - 載入帶有動畫的 GLTF 模型
  • threejs-fundamentals - Clock 與動畫迴圈
  • threejs-shaders - 著色器中的頂點動畫

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

相關技能