滿版遊戲的效能瓶頸幾乎總是填充率(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.00100%原生
0.8572%幾乎看不出差別
0.7556%邊緣略柔
0.6036%明顯柔化,仍可玩
0.5025%明顯,適合當保底

降到 0.75 就砍掉 44% 的填充成本,而多數玩家在動態畫面中察覺不到。這比任何演算法最佳化都便宜。

動態解析度:一個可直接用的控制器

固定的 render scale 需要事先知道裝置能力,而 UA 字串不可靠、deviceMemoryhardwareConcurrency 只是弱訊號。正確做法是量測實際幀時間並自我調節

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 面板:看 RasterizeComposite 佔比。若 Rasterize 佔大頭,那就是填充率問題,降 render scale 有效;若 Scripting 佔大頭,降解析度沒用,要改演算法。

先確認瓶頸再調旋鈕——render scale 只解決填充率問題,對 script 綁死的情況毫無幫助。

檢查清單

  • render scale 與版面完全解耦(改它不會改版面)
  • 動態調節用中位數 + hysteresis + 冷卻
  • UI/文字層永遠 renderScale = 1
  • 背景層用 alpha: false,且可用較低 DPR
  • 沒有 shadowBlur 在每幀路徑上
  • requestAnimationFrame,且分頁隱藏時有暫停
  • 已用 DevTools 確認瓶頸是 Rasterize 而非 Scripting
  • 實測:低階 Android、高 DPR 平板、桌機外接 4K 螢幕

下一篇把前面所有內容整理成症狀導向的排錯表 → 常見錯誤與排錯