本文 AI 產出,尚未審核

Three.js – 動畫與渲染迴圈

使用 requestAnimationFrame 建立動畫


簡介

在 3D 網頁開發中,動畫是讓場景活起來的關鍵。無論是旋轉的立方體、角色走路的骨架,或是粒子系統的流動,都需要在每一幀(frame)更新物件的屬性,然後重新渲染畫面。requestAnimationFrame(簡稱 RAF)是瀏覽器提供的高效能動畫 API,它會在瀏覽器即將繪製畫面前呼叫指定的回呼函式,確保動畫與螢幕刷新率同步,降低卡頓與電力消耗。

在 Three.js 中,我們幾乎所有的動畫都會圍繞 RAF 來實作。掌握這個機制不僅能寫出流暢的視覺效果,還能避免常見的效能陷阱,讓你的 3D 專案在桌面與行動裝置上都表現穩定。


核心概念

1. requestAnimationFrame 的工作原理

  • 同步螢幕刷新RAF 會在瀏覽器的下一次重繪(repaint)前執行,通常與螢幕的刷新率(60 Hz、120 Hz…)保持一致。
  • 自動節流:當分頁被隱藏或最小化時,RAF 會自動暫停呼叫,降低 CPU/GPU 負擔。
  • 回傳 IDRAF 會回傳一個整數 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.aspectrenderer.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 內容

總結

requestAnimationFrameWeb 3D 動畫的核心節拍器,配合 Three.js 的渲染管線,我們可以:

  • 以時間差 (delta) 為基礎,讓動畫在不同裝置上保持一致的速度。
  • 透過 THREE.ClockfixedStep 等技巧,兼顧渲染流暢與物理穩定。
  • 集中管理更新函式,讓程式碼結構更易維護。
  • 妥善取消與釋放資源,避免背景迴圈造成資源浪費。

掌握以上概念與實作範例,你就能在 Three.js 中建立 流暢、效能優化且易於擴充 的動畫系統,無論是簡單的模型旋轉、複雜的遊戲循環,或是即時資料視覺化,都能得心應手。祝你玩得開心,創作出令人驚豔的 3D 網頁體驗!