SKILL.md
readonlyread-only
name
threejs-animation
description
Three.js 動畫 - 關鍵幀動畫、骨骼動畫、形態目標、動畫混合。用於動畫物件、播放 GLTF 動畫、建立程序化運動或混合動畫時使用。
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 動畫系統包含三個主要元件:
- AnimationClip - 儲存關鍵幀資料的容器
- AnimationMixer - 在根物件上播放動畫
- 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;
}
效能提示
- 共用片段:同一個 AnimationClip 可用於多個混合器
- 最佳化片段:呼叫
clip.optimize()移除冗餘關鍵幀 - 離螢幕時停用:對不可見物件停止混合器更新
- 對動畫使用 LOD:遠距離角色使用較簡單的骨架
- 限制活躍混合器數量:每個 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- 時鐘與動畫迴圈threejs-shaders- 著色器中的頂點動畫






