Three.js – 動畫與渲染迴圈
物件旋轉、移動與時間管理
簡介
在 3D 網頁開發中,動畫是讓場景活起來的關鍵。無論是簡單的旋轉立方體、角色在地面上行走,或是複雜的粒子系統,都離不開 渲染迴圈(render loop) 與 時間管理。
Three.js 把底層的 WebGL 操作抽象化,使我們只需要關注「物件在什麼時間點該怎麼變化」即可。掌握好這些概念,才能寫出 流暢、可維護且效能良好 的 3D 應用。
本篇文章將從 核心概念、實作範例、常見陷阱與最佳實踐,一路帶你了解如何在 Three.js 中正確地控制物件的旋轉與移動,並以 時間 為基礎驅動動畫。
核心概念
1. 渲染迴圈的本質
Three.js 的渲染迴圈其實只是一個 requestAnimationFrame 的包裝。它會在瀏覽器每次重繪前呼叫一次你的回呼函式,讓你有機會更新場景、相機或任何需要變動的屬性。
function animate() {
requestAnimationFrame( animate ); // ← 每次畫面更新前被呼叫
// 1️⃣ 更新物件狀態
// 2️⃣ 渲染場景
renderer.render( scene, camera );
}
animate(); // 啟動渲染迴圈
- 為什麼使用
requestAnimationFrame而不是setInterval?requestAnimationFrame會自動配合螢幕刷新率(通常是 60Hz),且在分頁隱藏時會自動暫停,節省資源。
2. 時間管理:Delta Time 與 Elapsed Time
在動畫中,我們常用兩種時間概念:
| 名稱 | 取得方式 | 用途 |
|---|---|---|
Delta Time (delta) |
前一次迴圈與本次迴圈的時間差(秒) | 讓動畫與幀率無關,即使 FPS 降低也能保持相同速度 |
Elapsed Time (elapsed) |
從動畫開始累積的總時間(秒) | 用於週期性動畫(如正弦波、循環) |
Three.js 官方提供了 THREE.Clock 來輕鬆取得這兩個值:
const clock = new THREE.Clock(); // 建立時鐘
function animate() {
requestAnimationFrame( animate );
const delta = clock.getDelta(); // 上一幀與本幀的時間差
const elapsed = clock.getElapsedTime(); // 從 start 到現在的總時間
// 以 delta 為基礎更新物件...
renderer.render( scene, camera );
}
animate();
小技巧:在需要 固定時間步長(如物理模擬)時,可使用
clock.getDelta()乘上一個 時間縮放係數(timeScale),方便加速或減速整體動畫。
3. 旋轉與移動的座標系
Three.js 使用右手座標系:
- X:指向右
- Y:指向上
- Z:指向螢幕外(遠離觀察者)
3.1 物件旋轉
物件的旋轉屬性是 object.rotation,它是一個 Euler 物件,包含 x、y、z 三個軸向的弧度值。常見的做法是:
// 讓立方體每秒繞 Y 軸旋轉 45 度
const speed = THREE.MathUtils.degToRad(45); // 45° → rad
function animate() {
requestAnimationFrame( animate );
const delta = clock.getDelta();
cube.rotation.y += speed * delta; // 依 delta 調整旋轉量
renderer.render( scene, camera );
}
注意:直接使用
+=會累積誤差,若需要 精確 的角度(如 0~360° 迴圈),建議使用THREE.MathUtils.euclideanModulo取模。
3.2 物件移動
移動最直接的方式是改變 object.position,同樣以 delta 為基礎:
const moveSpeed = 2; // 單位:公尺/秒
function animate() {
requestAnimationFrame( animate );
const delta = clock.getDelta();
// 讓球體沿 X 軸正方向前進
sphere.position.x += moveSpeed * delta;
renderer.render( scene, camera );
}
若要在 本地座標系(相對於物件自身)移動,可使用 object.translateX/Y/Z( distance ):
// 讓飛船在自身前方推進
ship.translateZ( -moveSpeed * delta ); // Z 正方向指向螢幕內
4. 讓動畫與時間同步:範例彙總
以下提供 五個實用範例,涵蓋旋轉、平移、彈性運動與時間驅動的特效。
範例 1️⃣ 基本旋轉(單軸)
// 旋轉立方體:每秒 30 度繞 X 軸
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0x2194ce })
);
scene.add(cube);
const rotSpeed = THREE.MathUtils.degToRad(30); // 30° → rad
function animate() {
requestAnimationFrame( animate );
const delta = clock.getDelta();
cube.rotation.x += rotSpeed * delta; // 使用 delta 防止幀率影響
renderer.render( scene, camera );
}
animate();
重點:
rotSpeed用弧度表示,每秒 旋轉 30°。
範例 2️⃣ 同時多軸旋轉(使用 elapsed time)
// 讓球體同時繞 Y、Z 軸做週期性旋轉
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(0.5, 32, 32),
new THREE.MeshStandardMaterial({ color: 0xf54291 })
);
scene.add(sphere);
function animate() {
requestAnimationFrame( animate );
const elapsed = clock.getElapsedTime();
// 以正弦波產生平滑的角速度變化
sphere.rotation.y = Math.sin( elapsed ) * Math.PI; // -π ~ +π
sphere.rotation.z = Math.cos( elapsed ) * Math.PI; // -π ~ +π
renderer.render( scene, camera );
}
animate();
說明:
Math.sin/Math.cos讓旋轉速度呈現 自然的加速與減速,適合製作「漂浮」或「擺動」的效果。
範例 3️⃣ 直線移動 + 邊界迴圈
// 讓方塊在 X 軸往返移動,超過邊界時回到起點
const box = new THREE.Mesh(
new THREE.BoxGeometry(0.8, 0.8, 0.8),
new THREE.MeshStandardMaterial({ color: 0x7ed321 })
);
scene.add(box);
const moveSpeed = 3; // m/s
const limit = 5; // 邊界座標 (+/- limit)
function animate() {
requestAnimationFrame( animate );
const delta = clock.getDelta();
box.position.x += moveSpeed * delta;
// 超過右邊界則回到左邊
if (box.position.x > limit) box.position.x = -limit;
renderer.render( scene, camera );
}
animate();
技巧:使用 模組運算 (
THREE.MathUtils.euclideanModulo) 也能寫出無縫迴圈:
box.position.x = THREE.MathUtils.euclideanModulo(
box.position.x + moveSpeed * delta + limit, // 先平移再加上 offset
limit * 2
) - limit; // 重新映射回 -limit ~ +limit
範例 4️⃣ 本地座標系的前進與轉向(FPS 風格)
// 建立一個簡易的「第一人稱」相機控制器
const player = new THREE.Object3D(); // 作為玩家的容器
scene.add(player);
player.add(camera); // 相機掛在玩家上
const forwardSpeed = 5; // m/s
const turnSpeed = THREE.MathUtils.degToRad(120); // 120°/s
// 假設有鍵盤事件控制 forward / left / right
let moveForward = false, turnLeft = false, turnRight = false;
// 監聽鍵盤(此處僅示範)
window.addEventListener('keydown', e => {
if (e.code === 'KeyW') moveForward = true;
if (e.code === 'KeyA') turnLeft = true;
if (e.code === 'KeyD') turnRight = true;
});
window.addEventListener('keyup', e => {
if (e.code === 'KeyW') moveForward = false;
if (e.code === 'KeyA') turnLeft = false;
if (e.code === 'KeyD') turnRight = false;
});
function animate() {
requestAnimationFrame( animate );
const delta = clock.getDelta();
// 轉向
if (turnLeft) player.rotation.y += turnSpeed * delta;
if (turnRight) player.rotation.y -= turnSpeed * delta;
// 前進(沿本地 Z 軸負方向)
if (moveForward) player.translateZ( -forwardSpeed * delta );
renderer.render( scene, camera );
}
animate();
關鍵:
translateZ會自動考慮物件的 旋轉,因此玩家在轉向後仍能向前推進。
範例 5️⃣ 時間縮放(Slow‑motion / Fast‑forward)
// 讓旋轉立方體可以透過滑桿調整速度(0.1x ~ 3x)
const cube2 = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0xffc107 })
);
scene.add(cube2);
let timeScale = 1; // 預設正常速度
// 建立 HTML 滑桿(簡易示意)
const slider = document.createElement('input');
slider.type = 'range';
slider.min = 0.1;
slider.max = 3;
slider.step = 0.1;
slider.value = 1;
document.body.appendChild(slider);
slider.addEventListener('input', () => {
timeScale = parseFloat(slider.value);
});
const baseSpeed = THREE.MathUtils.degToRad(60); // 60°/s
function animate() {
requestAnimationFrame( animate );
const delta = clock.getDelta() * timeScale; // 套用時間縮放
cube2.rotation.y += baseSpeed * delta;
renderer.render( scene, camera );
}
animate();
實務應用:在遊戲或教學影片中常需要 慢動作(slow‑motion)或 加速(fast‑forward),只要把
delta乘上縮放係數即可,不需要改變原始速度變數。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 / 最佳實踐 |
|---|---|---|
直接使用 object.rotation.x += angle 而忽略 delta |
幀率不穩定時會導致動畫快慢不一致。 | 使用 clock.getDelta() 乘上每秒角速度。 |
| 弧度與角度混用 | THREE.Euler 只接受弧度,若傳入度數會出現奇怪的旋轉。 |
統一使用弧度,或利用 THREE.MathUtils.degToRad() / radToDeg() 轉換。 |
忘記重置 clock |
在切換場景或暫停時,累積的 elapsedTime 可能造成不預期的行為。 |
在需要「重新計時」的時機呼叫 clock.start() 或 clock.elapsedTime = 0。 |
使用 setInterval 或 setTimeout 來驅動動畫 |
會與瀏覽器的刷新率脫節,且在頁面隱藏時仍持續執行,浪費資源。 | 永遠使用 requestAnimationFrame。 |
| 不考慮物件的父子階層 | 子物件的座標是相對於父物件的,直接改變 position 可能產生意外的移動。 |
明確了解 本地座標系 與 世界座標系,有需求時使用 object.getWorldPosition() 或 object.worldToLocal()。 |
| 忘記釋放資源 | 產生大量幾何體或材質卻未呼叫 dispose(),會導致記憶體泄漏。 |
在不再使用時,呼叫 geometry.dispose()、material.dispose(),並從場景移除。 |
小技巧
封裝動畫更新
把所有需要每幀更新的行為放入一個陣列updaters,在animate中一次迭代。這樣可以輕鬆 啟用 / 停用 某段動畫。const updaters = []; // 例:加入旋轉 updater updaters.push( (delta) => { cube.rotation.y += rotSpeed * delta; }); function animate() { requestAnimationFrame( animate ); const delta = clock.getDelta(); updaters.forEach( fn => fn(delta) ); renderer.render( scene, camera ); }使用
THREE.MathUtils.lerp產生平滑過渡(如相機跟隨)。避免在渲染迴圈內做大量計算(例如:即時生成大量幾何體),改用 Worker 或 預先緩存。
實際應用場景
| 場景 | 需求 | 主要使用的概念 |
|---|---|---|
| 3D 產品展示 | 讓模型自動旋轉、使用者滑鼠拖曳時停止自轉 | requestAnimationFrame + delta + object.rotation |
| 第一人稱射擊遊戲 (FPS) | 玩家在鍵盤/滑鼠控制下前進、轉向、射擊 | translateZ、本地座標系、clock.getDelta()、時間縮放 |
| 資料視覺化(Bar Chart 旋轉動畫) | 依資料值逐步伸長,完成後緩慢環繞旋轉 | elapsedTime、Math.lerp、THREE.Easing |
| 教育互動模擬(太陽系) | 行星依不同軌道速度繞太陽公轉、同時自轉 | 多個 Clock 或 不同的 speed 乘上 delta |
| AR/VR 體驗 | 依使用者頭部姿態即時更新相機,物件保持相對位置 | object.position + camera.matrixWorldInverse、時間同步 |
案例說明:在太陽系模擬中,地球的自轉速度約為 360°/24h,公轉速度約 360°/365d。把它們分別轉換成 rad/s,再乘上
delta,就能保證不論使用者的裝置 FPS 為多少,動畫都保持正確的比例。
總結
- 渲染迴圈 是 Three.js 動畫的核心,必須使用
requestAnimationFrame取得與螢幕刷新同步的更新時機。 - 時間管理(
delta與elapsed)讓動畫 與幀率無關,確保在不同裝置上都有一致的速度與感受。 - 物件的 旋轉 與 移動 皆以 弧度、座標系 為基礎,配合
clock.getDelta()、translateX/Y/Z、THREE.MathUtils等工具,可寫出流暢且易維護的程式碼。 - 常見的陷阱多與 幀率不穩、單位混淆、資源釋放 有關,遵守最佳實踐(如使用
delta、統一弧度、適時dispose())即可避免大多數問題。 - 透過 封裝更新函式、時間縮放 與 本地座標系,你可以快速擴展到 遊戲、互動展示、教育模擬 等各種實務情境。
掌握了 物件旋轉、移動與時間管理,你就能在 Three.js 中構建出 生動、效能優化且可擴展 的 3D 互動體驗。祝你玩得開心,創作無限! 🚀