Three.js – 動畫與渲染迴圈
使用 requestAnimationFrame 建立動畫
簡介
在 3D 網頁開發中,動畫是讓場景活起來的關鍵。無論是旋轉的立方體、角色走路的骨架,或是粒子系統的流動,都需要在每一幀(frame)更新物件的屬性,然後重新渲染畫面。requestAnimationFrame(簡稱 RAF)是瀏覽器提供的高效能動畫 API,它會在瀏覽器即將繪製畫面前呼叫指定的回呼函式,確保動畫與螢幕刷新率同步,降低卡頓與電力消耗。
在 Three.js 中,我們幾乎所有的動畫都會圍繞 RAF 來實作。掌握這個機制不僅能寫出流暢的視覺效果,還能避免常見的效能陷阱,讓你的 3D 專案在桌面與行動裝置上都表現穩定。
核心概念
1. requestAnimationFrame 的工作原理
- 同步螢幕刷新:
RAF會在瀏覽器的下一次重繪(repaint)前執行,通常與螢幕的刷新率(60 Hz、120 Hz…)保持一致。 - 自動節流:當分頁被隱藏或最小化時,
RAF會自動暫停呼叫,降低 CPU/GPU 負擔。 - 回傳 ID:
RAF會回傳一個整數 ID,可用於cancelAnimationFrame取消未來的呼叫。
const id = requestAnimationFrame(callback);
// 若需要停止動畫
cancelAnimationFrame(id);
2. 基本動畫迴圈
在 Three.js 中,我們通常會把 渲染、更新物件狀態、計算時間差 三個步驟放在同一個 RAF 回呼裡,形成所謂的「渲染迴圈」:
function animate(time) {
// time 參數是自頁面載入以來的毫秒數 (DOMHighResTimeStamp)
requestAnimationFrame(animate); // 讓自己在下一幀再次執行
// 1. 更新物件 (例如旋轉)
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
// 2. 渲染場景
renderer.render(scene, camera);
}
// 啟動迴圈
requestAnimationFrame(animate);
小技巧:把
requestAnimationFrame放在函式最前面(如上例)可以保證即使animate本身拋出例外,仍會在下一幀嘗試呼叫。
3. 使用 clock 計算 delta time
直接以固定的角度增量(如 0.01)會在不同裝置上產生不同的速度。利用 THREE.Clock 取得每幀的 時間差 (delta),可以讓動畫 時間基礎(time‑based)而非 幀基礎(frame‑based):
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta(); // 單位:秒
// 依 delta 調整旋轉速度,使不同 FPS 下速度相同
cube.rotation.x += delta * Math.PI; // 每秒旋轉 180°
cube.rotation.y += delta * Math.PI;
renderer.render(scene, camera);
}
animate();
4. 多個動畫函式的協調
在大型專案中,可能會有多個獨立的動畫模組(角色、粒子、相機)。最簡單的做法是 集中管理,把所有子模組的更新函式放入一個陣列,迭代執行:
const updaters = [];
// 範例子模組
function createRotatingBox() {
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshStandardMaterial({ color: 0x2194ce });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// 把更新函式推入 updaters
updaters.push((delta) => {
mesh.rotation.x += delta * 0.5;
mesh.rotation.y += delta * 0.3;
});
}
// 初始化
createRotatingBox();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
// 執行所有更新
updaters.forEach((fn) => fn(delta));
renderer.render(scene, camera);
}
animate();
這種 「註冊式」(registration)方式讓程式碼結構更清晰,也方便在需要時動態加入或移除動畫。
5. 針對不同裝置的適配
手機螢幕的刷新率往往高於 60 Hz,或在省電模式下會降到 30 Hz。若希望動畫在 低 FPS 時仍保持平滑,可結合 插值(interpolation) 或 時間累積 的技巧:
let accumulator = 0;
const fixedStep = 1 / 60; // 目標每秒 60 次更新
function animate(time) {
requestAnimationFrame(animate);
const delta = clock.getDelta();
accumulator += delta;
// 只在累積足夠時間時才執行邏輯更新
while (accumulator >= fixedStep) {
// 例如物理模擬、角色控制等
updateLogic(fixedStep);
accumulator -= fixedStep;
}
// 渲染使用最新的狀態
renderer.render(scene, camera);
}
animate();
這樣即使實際 FPS 低於 60,也能保證 邏輯更新的穩定頻率,避免因為「跳幀」而產生奇怪的運動。
程式碼範例
以下提供 五個實用範例,從最基礎到較進階的應用,幫助你快速上手 requestAnimationFrame。
範例 1 – 基本旋轉方塊
// 基本設定
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 100);
camera.position.z = 3;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
// 立方體
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshNormalMaterial();
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
// 動畫迴圈
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
}
animate();
說明:最直接的寫法,適合測試環境或教學示範。
範例 2 – 使用 THREE.Clock 的時間基礎動畫
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta(); // 秒
// 每秒旋轉 90 度
cube.rotation.x += delta * Math.PI / 2;
cube.rotation.y += delta * Math.PI / 2;
renderer.render(scene, camera);
}
animate();
為什麼要這樣寫?
當不同裝置的 FPS 差異很大時,delta會自動補償,使旋轉速度保持一致。
範例 3 – 註冊式動畫管理(多物件)
const updaters = [];
// 建立一個會上下浮動的球體
function createBouncingSphere() {
const geo = new THREE.SphereGeometry(0.3, 32, 32);
const mat = new THREE.MeshStandardMaterial({ color: 0xff5555 });
const sphere = new THREE.Mesh(geo, mat);
sphere.position.y = -1;
scene.add(sphere);
// 內部狀態
const speed = 2; // 單位:米/秒
const amplitude = 0.5;
updaters.push((delta) => {
sphere.position.y = Math.sin(performance.now() * 0.001 * speed) * amplitude;
});
}
createBouncingSphere();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
updaters.forEach((fn) => fn(delta));
renderer.render(scene, camera);
}
animate();
應用:當專案裡有許多獨立動畫時,可透過
updaters陣列統一驅動,維持程式碼整潔。
範例 4 – 以固定時間步長(Fixed Step)處理物理
let accumulator = 0;
const fixedStep = 1 / 60; // 60Hz
function physicsUpdate(step) {
// 假設有一個球的速度與位置
ball.position.addScaledVector(ball.velocity, step);
// 簡單的重力
ball.velocity.y -= 9.81 * step;
// 地面碰撞
if (ball.position.y < -1) {
ball.position.y = -1;
ball.velocity.y *= -0.6; // 反彈係數
}
}
function animate(time) {
requestAnimationFrame(animate);
const delta = clock.getDelta();
accumulator += delta;
while (accumulator >= fixedStep) {
physicsUpdate(fixedStep);
accumulator -= fixedStep;
}
renderer.render(scene, camera);
}
animate();
重點:物理模擬不應直接使用渲染幀率,而是使用固定步長,避免「不穩定」的數值解。
範例 5 – 取消動畫(場景切換或資源釋放)
let animId = null;
function startLoop() {
function loop() {
animId = requestAnimationFrame(loop);
cube.rotation.x += 0.02;
renderer.render(scene, camera);
}
loop(); // 開始
}
// 在需要切換場景或離開頁面時呼叫
function stopLoop() {
if (animId !== null) {
cancelAnimationFrame(animId);
animId = null;
}
}
// 範例:5 秒後停止
startLoop();
setTimeout(stopLoop, 5000);
實務:在單頁應用(SPA)或多場景切換時,務必
cancelAnimationFrame,避免舊的渲染迴圈仍在背景執行,浪費資源。
常見陷阱與最佳實踐
| 陷阱 | 可能的問題 | 解決方案 / 最佳實踐 |
|---|---|---|
直接使用固定增量 (object.rotation += 0.01) |
在高 FPS 裝置上動畫過快、低 FPS 裝置上過慢 | 使用 clock.getDelta() 或 performance.now() 計算 時間差 |
在 requestAnimationFrame 內部拋出未捕獲例外 |
迴圈斷掉,畫面凍結 | 包裹 try…catch,或把 requestAnimationFrame 呼叫放在最前面 |
忘記取消動畫 (cancelAnimationFrame) |
背景仍在運算,導致記憶體泄漏或 CPU 占用過高 | 在頁面卸載 (window.onbeforeunload) 或場景切換時統一清理 |
在每幀重新建立大量物件(如 new THREE.Geometry()) |
GC 壓力大,帧率下降 | 盡量 重用 物件,或使用 InstancedMesh |
| 未考慮視窗大小變化 | 畫面變形或渲染失真 | 監聽 resize 事件,更新 camera.aspect 與 renderer.setSize |
在 RAF 內部執行耗時運算(如大量矩陣計算) |
造成掉幀 | 把重計算搬到 Web Worker,或使用 GPU 計算(Shader) |
最佳實踐總結:
- 時間基礎:永遠以
delta為單位調整動畫速度。 - 分離更新與渲染:更新邏輯(物理、AI)與渲染分開,便於固定步長或多執行緒化。
- 集中管理迴圈:使用單一
animate函式統一呼叫requestAnimationFrame,避免同時存在多個互相競爭的迴圈。 - 資源回收:切換場景或離開頁面時必須
cancelAnimationFrame、釋放幾何體、材質與紋理。 - 測試多裝置:在桌面、手機、平板上測試不同刷新率與省電模式,確保動畫仍保持流暢。
實際應用場景
| 場景 | 為何需要 requestAnimationFrame |
範例 |
|---|---|---|
| 互動式產品展示(如 3D 商品旋轉) | 使用者拖曳時即時更新模型姿態,需要與螢幕刷新同步,避免卡頓 | 依滑鼠或觸控座標改變模型旋轉角度,RAF 確保即時回饋 |
| 遊戲角色動畫 | 多角色同時移動、碰撞偵測、粒子特效,需要 固定步長 物理模擬 + 時間基礎渲染 | 先用 fixedStep 處理角色位置,最後在 RAF 中渲染 |
| 資料視覺化(如動態條形圖、流體圖) | 資料流入頻率不固定,需在每幀平滑過渡 | 透過 delta 計算插值,使圖形平滑過渡 |
| AR/VR 應用 | 頻繁更新相機姿態與頭部追蹤,對延遲極度敏感 | RAF 提供最小延遲的渲染時機,配合 WebXR API |
| 教學或示範(如程式碼沙盒) | 需要即時展示變化,且在教學過程中常會切換不同範例 | 每個範例共用同一個 animate,只改變 updaters 內容 |
總結
requestAnimationFrame 是 Web 3D 動畫的核心節拍器,配合 Three.js 的渲染管線,我們可以:
- 以時間差 (delta) 為基礎,讓動畫在不同裝置上保持一致的速度。
- 透過
THREE.Clock、fixedStep等技巧,兼顧渲染流暢與物理穩定。 - 集中管理更新函式,讓程式碼結構更易維護。
- 妥善取消與釋放資源,避免背景迴圈造成資源浪費。
掌握以上概念與實作範例,你就能在 Three.js 中建立 流暢、效能優化且易於擴充 的動畫系統,無論是簡單的模型旋轉、複雜的遊戲循環,或是即時資料視覺化,都能得心應手。祝你玩得開心,創作出令人驚豔的 3D 網頁體驗!