本文 AI 產出,尚未審核

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)

最佳實踐總結:

  1. 時間基礎:永遠以 delta 為單位調整動畫速度。
  2. 分離更新與渲染:更新邏輯(物理、AI)與渲染分開,便於固定步長或多執行緒化。
  3. 集中管理迴圈:使用單一 animate 函式統一呼叫 requestAnimationFrame,避免同時存在多個互相競爭的迴圈。
  4. 資源回收:切換場景或離開頁面時必須 cancelAnimationFrame、釋放幾何體、材質與紋理。
  5. 測試多裝置:在桌面、手機、平板上測試不同刷新率與省電模式,確保動畫仍保持流暢。

實際應用場景

場景 為何需要 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 網頁體驗!