前端工程
在瀏覽器裡穩定跑 60 FPS:彈幕遊戲的效能實作筆記
做 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-t0、t2-t1、t3-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 的效能瓶頸通常不是「畫了多少東西」,而是切換了多少次繪圖狀態。每次改變 fillStyle、shadowBlur、或呼叫 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.6ms | 1.4ms |
| 碰撞 | 11.0ms | 1.8ms |
| 渲染 | 3.5ms | 1.2ms |
| 總計 | 16.1ms | 4.4ms |
500 顆彈幕,穩定 60 FPS,還有餘裕。
整個過程最重要的一課還是最開始那一句:先量測,再優化。 我如果一開始就照直覺去改渲染,會花掉三天,然後得到 2ms 的改善。
想看實際跑起來的樣子,可以直接玩 GeoBlast,不需要安裝。
文中提到的產品
其他文章
內容有錯誤或想補充?寄信到 support@throuzlabs.com, 我會更新並在文中註明修訂日期。