前端工程

在瀏覽器裡穩定跑 60 FPS:彈幕遊戲的效能實作筆記

發佈於 2026-07-30 .約 10 分鐘閱讀

做 GeoBlast 的時候,我給自己的技術目標是:畫面上同時有 500 顆彈幕,在一般筆電上維持 60 FPS。

第一版做出來,200 顆就開始掉到 40 FPS。這篇記錄的是從 200 到 500 的過程中,實際有效的四件事——以及一開始我以為有效、後來發現沒差的幾件事。

先講量測

在做任何優化之前,必須先能回答「時間花在哪裡」。否則你會花三天優化一個佔總時間 2% 的函式。

我用的方法很土但有效:在主迴圈裡對三個階段分別計時。

const t0 = performance.now();
updateEntities(dt);
const t1 = performance.now();
detectCollisions();
const t2 = performance.now();
render(ctx);
const t3 = performance.now();

然後把 t1-t0t2-t1t3-t2 做移動平均顯示在畫面角落。

第一次跑出來的數字讓我很意外:碰撞判定佔了 68%,渲染只佔 21%。我原本以為問題在繪製,差點就去研究 WebGL 了。

60 FPS 代表每幀只有 16.6 毫秒。而且這 16.6 毫秒不是全給你的——瀏覽器自己的合成、GC、其他分頁都會吃掉一部分。實務上要抓 12 毫秒以內才穩。

一、碰撞判定:從 O(n²) 降到接近 O(n)

第一版的碰撞判定是雙層迴圈:每顆彈幕跟每個目標比一次。500 顆彈幕 × 30 個目標 = 15,000 次距離計算,每幀。

解法是空間網格(spatial hash grid):把畫面切成固定大小的格子,每個物件只登記在自己所在的格子裡。判定碰撞時,只檢查同格與相鄰格的物件。

const CELL = 64; // 格子邊長,約等於最大物件直徑
const grid = new Map();

function cellKey(x, y) {
  return ((y / CELL) | 0) * 1000 + ((x / CELL) | 0);
}

function insert(obj) {
  const k = cellKey(obj.x, obj.y);
  let bucket = grid.get(k);
  if (!bucket) grid.set(k, (bucket = []));
  bucket.push(obj);
}

格子大小的選擇是關鍵:太大則每格物件太多,退化回 O(n²);太小則要檢查的相鄰格變多,而且 Map 的操作成本上升。 我的經驗是設成「最大物件直徑」附近,然後實測前後各兩檔。

這一項讓碰撞判定的時間從 11ms 降到 1.8ms。單一改動的效益最大。

另外一個小改動也值得提:比較距離平方,不要開根號。

// 不要這樣
if (Math.hypot(dx, dy) < r1 + r2) { ... }

// 這樣
const r = r1 + r2;
if (dx * dx + dy * dy < r * r) { ... }

在每幀數千次的呼叫下,這個差別是可以量到的。

二、物件池:不要在主迴圈裡配置記憶體

彈幕遊戲每秒會產生與銷毀數百個物件。如果每顆彈幕都是 new Bullet(),垃圾回收就會週期性地介入,表現出來就是每隔幾秒卡一下——平均 FPS 看起來還行,但體感很糟。

解法是預先配置一個固定大小的陣列,用 active 旗標標記使用狀態,不再 new 也不再 delete。

const POOL_SIZE = 2000;
const bullets = Array.from({ length: POOL_SIZE }, () => ({
  x: 0, y: 0, vx: 0, vy: 0, active: false,
}));
let cursor = 0;

function spawn(x, y, vx, vy) {
  for (let i = 0; i < POOL_SIZE; i++) {
    const b = bullets[(cursor + i) % POOL_SIZE];
    if (!b.active) {
      b.x = x; b.y = y; b.vx = vx; b.vy = vy; b.active = true;
      cursor = (cursor + i + 1) % POOL_SIZE;
      return b;
    }
  }
  return null; // 池滿了,這一發就放棄
}

注意最後那一行。池滿的時候,我選擇丟掉這一發子彈,而不是動態擴充池子。玩家不會注意到少了一顆彈幕,但一定會注意到卡頓。

還有一個容易忽略的地方:在更新迴圈裡也不要產生暫時物件。像 {x, y} 這種座標物件、閉包、以及 array.filter() 產生的新陣列,在每幀執行時都會累積 GC 壓力。update 迴圈裡我全部改成原始型別與就地修改。

三、繪製批次:減少狀態切換

Canvas 2D 的效能瓶頸通常不是「畫了多少東西」,而是切換了多少次繪圖狀態。每次改變 fillStyleshadowBlur、或呼叫 save() / restore(),都有固定成本。

原本我的繪製迴圈是逐一畫每顆彈幕,每顆都設定自己的顏色。改法是依顏色分組,同色的一次畫完

for (const color of PALETTE) {
  ctx.fillStyle = color;
  ctx.beginPath();
  for (const b of bulletsByColor[color]) {
    ctx.moveTo(b.x + b.r, b.y);
    ctx.arc(b.x, b.y, b.r, 0, TAU);
  }
  ctx.fill(); // 一次 fill 畫完所有同色圓
}

從 500 次 fillStyle 設定降到 6 次。渲染時間從 3.5ms 降到 1.2ms。

另外,霓虹光暈不要用 shadowBlur。那個屬性非常慢,在彈幕數量多的時候會直接殺死幀率。我改成預先把發光的圓形畫進一張離屏 canvas,之後用 drawImage 貼上去——視覺上幾乎一樣,成本差一個數量級。

四、固定時間步長:讓物理與畫面解耦

如果你直接用 requestAnimationFrame 給的時間差去更新物理,在 144Hz 螢幕和 60Hz 螢幕上,遊戲的手感會不一樣。更糟的是,當某一幀特別久(例如切回分頁時),dt 會變成一個巨大的值,物件會直接穿過牆壁。

標準解法是固定步長累加:

const STEP = 1 / 120;
let acc = 0;

function frame(now) {
  let dt = (now - last) / 1000;
  last = now;
  if (dt > 0.25) dt = 0.25; // 夾住,避免切分頁回來時爆掉
  acc += dt;
  while (acc >= STEP) {
    update(STEP);
    acc -= STEP;
  }
  render(acc / STEP); // 傳插值比例做視覺平滑
  requestAnimationFrame(frame);
}

那個 dt > 0.25 的夾制很重要。沒有它,玩家切到別的分頁再切回來,累積的時間會讓 while 迴圈跑上千次,畫面直接凍結。

幾件我以為有用但沒差的事

改用 TypedArray 存座標。 理論上更省記憶體、快取更友善。實測差異在誤差範圍內,但程式碼可讀性掉了一大截。以這個規模來說不值得。

Web Worker 跑物理。 主執行緒與 worker 之間傳座標的序列化成本,吃掉了平行化省下來的時間。在物件數量到達數千之前,不划算。

改用 WebGL。 這個確實會快,但在 Canvas 2D 已經進到 1.2ms 之後,渲染根本不是瓶頸了。優化不是瓶頸的地方,是最常見的浪費。

最後的數字

階段優化前優化後
更新1.6ms1.4ms
碰撞11.0ms1.8ms
渲染3.5ms1.2ms
總計16.1ms4.4ms

500 顆彈幕,穩定 60 FPS,還有餘裕。

整個過程最重要的一課還是最開始那一句:先量測,再優化。 我如果一開始就照直覺去改渲染,會花掉三天,然後得到 2ms 的改善。

想看實際跑起來的樣子,可以直接玩 GeoBlast,不需要安裝。

文中提到的產品

其他文章

回到文章列表 →

內容有錯誤或想補充?寄信到 support@throuzlabs.com, 我會更新並在文中註明修訂日期。