滿版遊戲的效能瓶頸幾乎總是填充率(fill rate)——每幀要往多少個像素寫值。一支 DPR 3 的旗艦手機滿版是每幀 360 萬像素,一台 DPR 1 的舊筆電只有 105 萬。這個 3.4 倍的差距與 GPU 效能沒有相關性,甚至常常是反向的:便宜的高解析度平板螢幕很大、GPU 很弱,是最容易掉幀的組合。
所以「用同一套設定跑所有裝置」在滿版遊戲上不成立。你需要一個把解析度與版面解耦的旋鈕,也就是 render scale。
render scale:定義與影響
canvas.style.width = cssW + 'px' // 版面:永遠滿版
canvas.style.height = cssH + 'px'
canvas.width = Math.round(cssW * dpr * renderScale) // 實際渲染:可調
canvas.height = Math.round(cssH * dpr * renderScale)像素量與 renderScale 的平方成正比,這是它威力的來源:
| renderScale | 相對像素量 | 主觀畫質 |
|---|---|---|
| 1.00 | 100% | 原生 |
| 0.85 | 72% | 幾乎看不出差別 |
| 0.75 | 56% | 邊緣略柔 |
| 0.60 | 36% | 明顯柔化,仍可玩 |
| 0.50 | 25% | 明顯,適合當保底 |
降到 0.75 就砍掉 44% 的填充成本,而多數玩家在動態畫面中察覺不到。這比任何演算法最佳化都便宜。
動態解析度:一個可直接用的控制器
固定的 render scale 需要事先知道裝置能力,而 UA 字串不可靠、deviceMemory 與 hardwareConcurrency 只是弱訊號。正確做法是量測實際幀時間並自我調節。
const TARGET_MS = 16.7 // 60fps 的預算
const UP_MS = 13.0 // 低於此值才考慮調高(留 hysteresis)
const DOWN_MS = 20.0 // 高於此值才調降
const MIN_SCALE = 0.5
const MAX_SCALE = 1.0
const STEP = 0.1
const WINDOW = 60 // 取樣視窗(幀)
const COOLDOWN = 90 // 調整後的冷卻幀數
let renderScale = 1.0
let samples = []
let cooldown = 0
let lastT = performance.now()
function tick(now) {
const dt = now - lastT
lastT = now
samples.push(dt)
if (samples.length > WINDOW) samples.shift()
if (cooldown > 0) { cooldown--; return }
if (samples.length < WINDOW) return
// 用中位數而非平均:一次 GC 造成的 200ms 尖峰不該觸發降級
const sorted = [...samples].sort((a, b) => a - b)
const median = sorted[Math.floor(sorted.length / 2)]
const p95 = sorted[Math.floor(sorted.length * 0.95)]
let next = renderScale
if (median > DOWN_MS || p95 > DOWN_MS * 2) next = Math.max(MIN_SCALE, renderScale - STEP)
else if (median < UP_MS) next = Math.min(MAX_SCALE, renderScale + STEP)
if (next !== renderScale) {
renderScale = next
relayout() // 重建 backing store
samples = []
cooldown = COOLDOWN // 給新解析度時間穩定,避免來回震盪
}
}四個設計細節值得說明:
用中位數 + p95,不用平均。 平均會被單次 GC 或分頁切換的長幀污染,導致無謂降級。中位數反映常態負載,p95 抓持續性的卡頓。
上下門檻不對稱(13ms vs 20ms)。 這是 hysteresis:若兩個門檻都設在 16.7,系統會在剛好邊界上不斷升降,畫質忽清忽糊比一直糊還難受。
調整後要冷卻。 剛改完解析度的那幾幀包含記憶體配置與重建成本,本身就慢,立刻拿來判斷會造成連續降級。
只降遊戲層,不降 UI 層。 這是主觀畫質的關鍵——文字與 HUD 維持原生解析度,玩家對整體「糊」的感受會下降一個檔次。
分層渲染
MDN 的 canvas 最佳化建議把「使用多層 canvas」列在前面,理由是只重畫真的有變的那一層:
<div class="stack">
<canvas id="bg"></canvas> <!-- 靜態背景:只在關卡載入與 resize 時畫 -->
<canvas id="world"></canvas> <!-- 遊戲物件:每幀畫,套用 renderScale -->
<canvas id="fx"></canvas> <!-- 特效:每幀畫,可用更低的 renderScale -->
<div id="hud"></div> <!-- DOM:瀏覽器合成,原生清晰 -->
</div>.stack { position: absolute; inset: 0; }
.stack > * { position: absolute; inset: 0; width: 100%; height: 100%; }代價是每層都要一份 backing store 記憶體(w × h × 4 bytes)。DPR 3 的滿版手機一層就 14 MB,四層 58 MB——這在 iOS 上是實際會遇到的天花板。取捨原則:背景層用較低的 DPR(背景通常是漸層或模糊圖,沒人看得出來),只有 world 層需要完整解析度。
其他實測有效的最佳化
MDN 列出的建議中,對滿版遊戲影響最大的幾項:
關掉透明度。 getContext('2d', { alpha: false }) 讓瀏覽器知道畫布不透明,可以跳過與底下內容的混合。對滿版背景層是免費的效能。
避免浮點座標。 非整數座標會觸發子像素反鋸齒,同一個矩形的填充成本可能差一倍。在有 scale 的座標系中,可以在 setTransform 的位移項上做像素對齊:
const tx = Math.round(offsetX * dpr)
const ty = Math.round(offsetY * dpr)
ctx.setTransform(scale * dpr, 0, 0, scale * dpr, tx, ty)避免 shadowBlur。 它是 canvas 2D 中最昂貴的單一屬性,一個大範圍的陰影可以吃掉整個幀預算。替代方案是預先把帶陰影的版本畫進離屏 canvas,之後直接 drawImage。
不要在 drawImage() 裡縮放。 每次縮放都是一次重採樣。載入時就把常用尺寸預先畫好快取。
用 requestAnimationFrame,不要用 setInterval。 這不只是流暢度問題——分頁切到背景時 rAF 會暫停,setInterval 不會,那是實打實的電量浪費。
只重畫變動區域。 若你的遊戲畫面大部分靜止(棋類、經營類),維護一個 dirty rect 並只 clearRect + 重畫那塊,成本可以降一個數量級。
OffscreenCanvas 與 Worker
OffscreenCanvas 讓渲染在 Worker 執行緒進行,主執行緒只處理輸入與版面:
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ type: 'init', canvas: offscreen }, [offscreen])好處是主執行緒的 GC、版面計算、DOM 操作不再造成掉幀。代價是 Worker 內拿不到 DOM,resize 必須由主執行緒量測後 postMessage 過去——而這正好與本專欄的架構相容:ResizeObserver 在主執行緒跑,把 { w, h, dpr, renderScale } 傳給 Worker 即可。
支援度已相當廣(Chrome、Firefox、Safari 均支援 2D context 的 OffscreenCanvas),但 transferControlToOffscreen() 之後主執行緒就不能再碰那個 canvas 的 context,架構要一開始就決定。
用什麼量測
performance.now()幀間隔:最直接,就是上面的控制器。- Long Animation Frames API(
PerformanceObserver觀察long-animation-frame):能指出是哪個 script 造成長幀,比舊的 Long Tasks API 更有用。 - DevTools Performance 面板:看
Rasterize/Composite佔比。若 Rasterize 佔大頭,那就是填充率問題,降 render scale 有效;若 Scripting 佔大頭,降解析度沒用,要改演算法。
先確認瓶頸再調旋鈕——render scale 只解決填充率問題,對 script 綁死的情況毫無幫助。
檢查清單
- render scale 與版面完全解耦(改它不會改版面)
- 動態調節用中位數 + hysteresis + 冷卻
- UI/文字層永遠
renderScale = 1 - 背景層用
alpha: false,且可用較低 DPR - 沒有
shadowBlur在每幀路徑上 - 用
requestAnimationFrame,且分頁隱藏時有暫停 - 已用 DevTools 確認瓶頸是 Rasterize 而非 Scripting
- 實測:低階 Android、高 DPR 平板、桌機外接 4K 螢幕
下一篇把前面所有內容整理成症狀導向的排錯表 → 常見錯誤與排錯