Three.js 動畫
目標版本為 three.js r186。範例使用 WebGLRenderer;WebGPU 與 TSL 部分使用 WebGPURenderer。
快速入門
import * as THREE from "three";
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 0.5, 3);
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshNormalMaterial());
scene.add(mesh);
const timer = new THREE.Timer();
timer.connect(document); // 在分頁被隱藏後也能保持 delta 值很小
function animate() {
timer.update(); // 每幀第一件事:修正此幀的 getDelta()/getElapsed()
const delta = timer.getDelta();
const elapsed = timer.getElapsed();
mesh.rotation.y += delta; // 幀率無關的旋轉
mesh.position.y = Math.sin(elapsed * 2) * 0.25; // 根據絕對時間上下浮動
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
系統如何運作
- AnimationClip:關鍵幀資料——時間和值的軌道,不含播放狀態。
- AnimationMixer:在一個根物件及其所有子物件上播放片段。
- AnimationAction:在一個混合器上控制一個片段的播放。
每幀,mixer.update(delta) 會對活躍的片段進行取樣,並將值寫入一般的物件屬性中。此系統在 CPU 上運行,從不接觸渲染器,因此在任何渲染器下都能以相同方式驅動模型。
AnimationClip 與關鍵幀軌道
// 每個動畫屬性一個軌道。values.length 必須等於 times.length * valueSize。
const times = [0, 1, 2]; // 關鍵幀時間(秒)
const values = [0, 1, 0]; // 數值型軌道中每個關鍵幀對應一個純量
const track = new THREE.NumberKeyframeTrack(".position[y]", times, values); // 根物件的 position.y
const clip = new THREE.AnimationClip("bounce", 2, [track]); // 持續時間(秒)
// 傳入 -1 作為持續時間以從軌道推導
軌道名稱遵循 節點名稱.物件名稱[物件索引].屬性名稱[屬性索引] 格式;除了屬性之外的所有部分都是可選的。.position 綁定根物件的 position,.material.opacity 綁定材質屬性,.position[y] 綁定單個分量,.morphTargetInfluences[smile] 綁定一個形態目標,Hips.position 綁定一個已命名的子物件——這就是骨骼在蒙皮模型中的定址方式。物件名稱可以是 material、materials、bones 或 map。
// 數值型軌道(值大小為 1);創建材質時需設定 transparent: true,否則 opacity 會被忽略
new THREE.NumberKeyframeTrack(".material.opacity", [0, 1], [1, 0]);
// 向量軌道(值大小為 3:每個關鍵幀的 x, y, z)
new THREE.VectorKeyframeTrack(".position", [0, 1, 2], [0, 0, 0, 1, 2, 0, 0, 0, 0]);
// 旋轉軌道(值大小為 4):每個關鍵幀的四元數 x, y, z, w,非尤拉角
const q2 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI);
new THREE.QuaternionKeyframeTrack(
".quaternion",
[0, 1],
[0, 0, 0, 1, q2.x, q2.y, q2.z, q2.w], // 單位四元數,然後是 q2
);
// 顏色軌道(值大小為 3)
new THREE.ColorKeyframeTrack(".material.color", [0, 1], [1, 0, 0, 0, 1, 0]);
// 布林值軌道(始終是離散的)
new THREE.BooleanKeyframeTrack(".visible", [0, 1], [true, false]);
插值
const track = new THREE.VectorKeyframeTrack(
".position",
[0, 1, 2],
[0, 0, 0, 1, 2, 0, 0, 0, 0],
);
track.setInterpolation(THREE.InterpolateLinear); // 預設
track.setInterpolation(THREE.InterpolateSmooth); // 通過關鍵幀的立方插值
track.setInterpolation(THREE.InterpolateDiscrete); // 階梯式,無中間值
// 貝塞爾曲線使用絕對 (時間, 值) 控制點,每個關鍵幀每個分量兩個數字:
// 一個跨距為 1 的兩個關鍵幀軌道需要每個陣列四個條目
const morphTrack = new THREE.NumberKeyframeTrack(
".morphTargetInfluences[smile]",
[0, 1],
[0, 1],
);
morphTrack.setInterpolation(THREE.InterpolateBezier);
morphTrack.settings = { inTangents: [0, 0, 1, 1], outTangents: [0, 1, 1, 0] };
AnimationMixer
const track = new THREE.NumberKeyframeTrack(".rotation[y]", [0, 2], [0, Math.PI * 2]);
const clip = new THREE.AnimationClip("spin", 2, [track]);
// 每個動畫根物件一個混合器;它會影響根物件及其所有子物件
const mixer = new THREE.AnimationMixer(mesh);
const action = mixer.clipAction(clip); // 快取:對同一個片段和根物件再次呼叫會返回同一個 action
action.play();
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
mixer.update(timer.getDelta()); // 必需:推進每個活躍的 action
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
// 事件在混合器更新時觸發;"finished" 需要 LoopOnce 或有限重複次數
mixer.addEventListener("finished", (e) => console.log("finished:", e.action.getClip().name));
mixer.addEventListener("loop", (e) => console.log("looped:", e.action.getClip().name));
AnimationAction
const mixer = new THREE.AnimationMixer(mesh);
const clip = new THREE.AnimationClip("spin", 2, [
new THREE.NumberKeyframeTrack(".rotation[y]", [0, 2], [0, Math.PI * 2]),
]);
const action = mixer.clipAction(clip);
action.play(); // 排入排程並播放
action.reset(); // 倒回時間 0 並取消淡變
action.stop(); // 取消排程;沒有其他已排程 action 動畫的屬性恢復原始值
action.halt(0.3); // 在 0.3 秒內減速至停止
action.time = 0.5; // 在片段內搜尋
action.timeScale = -1; // 負值表示倒放
action.paused = true; // 凍結在目前位置
action.setLoop(THREE.LoopOnce, 1); // 一次循環後停止(或保持)
action.setLoop(THREE.LoopRepeat, Infinity); // 預設
action.setLoop(THREE.LoopPingPong, 4); // 每次循環交替方向
action.clampWhenFinished = true; // 保持最終姿勢而不是重設
淡變與交叉淡變
const mixer = new THREE.AnimationMixer(mesh);
const idleClip = new THREE.AnimationClip("idle", 2, [
new THREE.NumberKeyframeTrack(".position[y]", [0, 1, 2], [0, 0.05, 0]),
]);
const walkClip = new THREE.AnimationClip("walk", 1, [
new THREE.NumberKeyframeTrack(".rotation[y]", [0, 1], [0, Math.PI]),
]);
const idle = mixer.clipAction(idleClip).play();
const walk = mixer.clipAction(walkClip).play();
// 在 0.4 秒內從 idle 過渡到 walk;第三個參數為 true 時也會扭曲
// 兩個時間尺度,使它們的循環長度在淡變期間匹配
idle.crossFadeTo(walk, 0.4, false);
// 從單方面呼叫進行相同的過渡(使用其中一種形式,不要同時使用)
walk.reset().fadeIn(0.4).play(); // 權重從 0 開始漸增
idle.fadeOut(0.4); // 權重逐漸減少到 0
播放 glTF 動畫
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js";
const gltf = await new GLTFLoader().loadAsync("model.glb");
const model = gltf.scene;
scene.add(model);
// GLTFLoader 的預設材質 (MeshStandardMaterial) 需要光照:沒有燈光模型會渲染為黑色
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(3, 5, 2);
scene.add(sun, new THREE.HemisphereLight(0xffffff, 0x444444, 1.5));
// gltf.animations 是解析後的 AnimationClips 陣列
const clips = gltf.animations;
const mixer = new THREE.AnimationMixer(model);
const walkClip = THREE.AnimationClip.findByName(clips, "Walk");
if (walkClip) mixer.clipAction(walkClip).play();
// 第二個角色:克隆蒙皮模型,為複製體建立自己的混合器
const model2 = SkeletonUtils.clone(model);
model2.position.x = 1.5; // 在第一個旁邊
scene.add(model2);
const mixer2 = new THREE.AnimationMixer(model2);
if (walkClip) mixer2.clipAction(walkClip).play(); // 相同片段,兩個混合器
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
const delta = timer.getDelta();
mixer.update(delta);
mixer2.update(delta);
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
骨骼動畫
// mesh 是載入的模型根物件;尋找其第一個 SkinnedMesh
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;
console.log(skeleton.bones.map((b) => b.name));
// 取得一個骨骼來設定姿勢或附加物件
const handBone = skeleton.bones.find((b) => b.name === "RightHand");
// 可視化骨架
const helper = new THREE.SkeletonHelper(mesh);
scene.add(helper);
程序化骨骼控制應在 mixer.update() 之後 執行,否則動畫相同骨骼的片段會覆蓋它:
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const headBone = skinnedMesh.skeleton.bones.find((b) => b.name === "Head");
const mixer = new THREE.AnimationMixer(mesh); // 在其上播放模型的片段(參見「播放 glTF 動畫」)
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
mixer.update(timer.getDelta()); // 片段在此寫入其骨骼姿勢
// 之後覆蓋:update() 會覆蓋正在播放的片段所動畫的每個屬性
if (headBone) headBone.rotation.y = Math.sin(timer.getElapsed()) * 0.3;
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
骨骼是 Object3D,因此附加意味著設定父子關係:
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const handBone = skinnedMesh.skeleton.bones.find((b) => b.name === "RightHand");
const weapon = new THREE.Mesh(
new THREE.BoxGeometry(0.08, 0.08, 0.5),
new THREE.MeshNormalMaterial(),
);
if (handBone) {
handBone.add(weapon); // weapon 現在跟隨手部骨骼的變換
weapon.position.set(0, 0, 0.25); // 在骨骼空間中的偏移量
weapon.rotation.set(0, Math.PI / 2, 0);
}
形態目標
// mesh 帶有形態目標(已載入,或按下方方式手動添加)
const names = Object.keys(mesh.morphTargetDictionary); // 名稱 -> 索引
console.log(names, mesh.morphTargetInfluences); // 每個目標一個權重
// 按索引或名稱混合一個目標;0 = 基礎形狀,1 = 完全目標
mesh.morphTargetInfluences[0] = 0.5;
const smileIndex = mesh.morphTargetDictionary["Mouth_Smile"];
if (smileIndex !== undefined) mesh.morphTargetInfluences[smileIndex] = 1;
手動添加形態目標:
const geometry = mesh.geometry;
// 形態目標是與基礎位置(或法線)屬性並列的全尺寸屬性
const raised = geometry.attributes.position.array.slice(); // 相同的頂點數量
for (let i = 1; i < raised.length; i += 3) {
raised[i] += 0.5; // y 是每第三個分量:向上提升每個頂點
}
const target = new THREE.Float32BufferAttribute(raised, 3);
target.name = "raised"; // 命名的目標可以從軌道定址
geometry.morphAttributes.position = [target];
mesh.updateMorphTargets(); // 重建 morphTargetInfluences 和字典
驅動形態目標:
// 程序化,在 timer.update() 之後的渲染循環內:
// mesh.morphTargetInfluences[0] = (Math.sin(timer.getElapsed() * 2) + 1) / 2;
// 關鍵幀:數值型軌道按名稱驅動一個目標——絕不使用字串型軌道
const track = new THREE.NumberKeyframeTrack(
".morphTargetInfluences[raised]",
[0, 0.5, 1],
[0, 1, 0],
);
const clip = new THREE.AnimationClip("pulse", 1, [track]);
const mixer = new THREE.AnimationMixer(mesh);
mixer.clipAction(clip).play();
按權重混合片段
const mixer = new THREE.AnimationMixer(mesh);
const idleClip = new THREE.AnimationClip("idle", 2, [
new THREE.NumberKeyframeTrack(".position[y]", [0, 1, 2], [0, 0.05, 0]),
]);
const runClip = new THREE.AnimationClip("run", 1, [
new THREE.NumberKeyframeTrack(".position[y]", [0, 0.5, 1], [0, 0.3, 0]),
]);
const idle = mixer.clipAction(idleClip).play();
const run = mixer.clipAction(runClip).play();
// 兩者每幀都取樣;權重混合它們的姿勢
function setBlend(speed) {
const t = Math.min(speed / 5, 1); // 0 = 待機,5 及以上 = 全力奔跑
idle.setEffectiveWeight(1 - t);
run.setEffectiveWeight(t);
}
setBlend(2.5); // 一半待機,一半奔跑
疊加動畫
const mixer = new THREE.AnimationMixer(mesh);
const walkClip = new THREE.AnimationClip("walk", 1, [
new THREE.NumberKeyframeTrack(".rotation[y]", [0, 1], [0, Math.PI]),
]);
const breathClip = new THREE.AnimationClip("breath", 2, [
new THREE.NumberKeyframeTrack(".position[y]", [0, 1, 2], [0, 0.02, 0]),
]);
// 將每個軌道重新基準化到其幀 0,並將片段標記為疊加
THREE.AnimationUtils.makeClipAdditive(breathClip);
mixer.clipAction(walkClip).play(); // 一般基礎片段
mixer.clipAction(breathClip).play(); // 在基礎姿勢之上疊加其偏移量
片段工具函數
const clip = new THREE.AnimationClip("walk", 2, [
new THREE.NumberKeyframeTrack(".rotation[y]", [0, 1, 2], [0, Math.PI, Math.PI * 2]),
]);
const walk = THREE.AnimationClip.findByName([clip], "walk"); // 名稱查找
// 保留幀 [0, 31) 在 30 fps 下的現有關鍵幀(時間 0 和 1);它不會重新取樣
const cut = THREE.AnimationUtils.subclip(clip, "half", 0, 31, 30);
const copy = clip.clone(); // 獨立的深拷貝
clip.optimize(); // 移除重複時間的關鍵幀,以及在任何非平滑軌道上與兩個相鄰關鍵幀相等的關鍵幀(貝塞爾切線不會被壓縮)
clip.resetDuration(); // 從最長的軌道重新計算持續時間——它不會搜尋
console.log(clip.validate()); // 如果 values 與 times 比例為非整數、軌道為空、時間未排序或包含 NaN,則返回 false
程序化運動
const timer = new THREE.Timer();
timer.connect(document);
// 指數近似;lambda 越大,收斂越快。幀率無關
// 衰減擺動: next = lerp(current, target, 1 - exp(-lambda * delta))
// 平滑跟隨: vel += (target - current) * spring * delta; vel *= 1 - damp * delta; current += vel * delta
// 彈性反彈: 根據疊加的三角函數或 lerp 產生反彈
// 等速沿方向移動:
const direction = new THREE.Vector3(1, 0, 1).normalize();
const speed = 2; // 單位/秒
mesh.position.addScaledVector(direction, speed * delta);
// 繞點旋轉:
const pivot = new THREE.Vector3(0, 0, 0);
const radius = 2;
const angle = elapsed * 0.5; // 半徑/秒
mesh.position.copy(pivot).add(new THREE.Vector3(Math.cos(angle) * radius, 0, Math.sin(angle) * radius));
mesh.lookAt(pivot);
WebGPU 與 TSL 動畫
import * as THREE from "three/webgpu";
import { float, uniform, tslFn, uv, vec3, sin, time, modelLocalPosition } from "three/tsl";
const renderer = new THREE.WebGPURenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 0.5, 3);
// 使用 TSL 的 GPU 端頂點動畫
const uniforms = {
frequency: uniform(2),
amplitude: uniform(0.25),
};
const vertexMotion = tslFn(({ position }) => {
const wave = sin(position.z.mul(uniforms.frequency).add(time.mul(2))).mul(uniforms.amplitude);
return position.add(vec3(0, wave, 0));
});
const geometry = new THREE.BoxGeometry(1, 1, 1);
geometry.addAttribute(geometry.attributes.position, vertexMotion);
const material = new THREE.MeshNormalMaterial();
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
function animate() {
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
// 或使用 Timer 進行 CPU 端控制
const timer = new THREE.Timer();
timer.connect(document);
function animateCPU() {
timer.update();
const delta = timer.getDelta();
const elapsed = timer.getElapsed();
mesh.rotation.y += delta;
mesh.position.y = Math.sin(elapsed * 2) * 0.25;
renderer.render(scene, camera);
}
WebGPU 的 AnimationMixer
AnimationMixer 在 WebGPU 和 WebGL 下以完全相同的方式工作。片段驅動 CPU 端屬性,然後像往常一樣傳遞給渲染器。
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
const gltf = await new GLTFLoader().loadAsync("model.glb");
const mixer = new THREE.AnimationMixer(gltf.scene);
const clip = gltf.animations[0];
mixer.clipAction(clip).play();
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
mixer.update(timer.getDelta());
renderer.render(scene, camera);
}
效能注意事項
- AnimationMixer 數量:對 30-50 個角色使用一個混合器比使用 30-50 個單獨的混合器更節省記憶體,但會失去每個角色的獨立時間控制。權衡取捨。
- 關鍵幀軌道數:更少的軌道意味著更少的屬性寫入。如果可能,合併軌道。
- update() 頻率:對於離線或低優先級動畫,可以每隔一幀調用
mixer.update()。 - WebGPU 頂點著色器動畫:將簡單的程序化波紋放在著色器中(使用 TSL),以避免 CPU 成本。
- libgfx 與 WebGPU:在 SharedArrayBuffer 可用時,AnimationMixer 在 WebGPU 下使用共享記憶體路徑。這對使用者來說是透明的,但在分析時值得注意。