把遊戲畫面放大到滿版有兩條路:維持一個小的 backing store,用 transform: scale() 讓 GPU 把它拉大;或把 backing store 直接做成螢幕大小,每個像素都是真的畫出來的。兩者畫面品質與效能剛好相反,選擇取決於你的畫風與效能預算,而不是哪一個「比較正確」。

兩條路線的比較

CSS transform 縮放直接 resize backing store
backing store固定(如 1280×720)隨螢幕變(如 2560×1440)
每幀填充像素92 萬(固定)368 萬(DPR 2 滿版)
放大方式GPU 合成,雙線性插值原生解析度渲染
畫面銳利度放大倍率越高越柔永遠銳利
resize 成本幾乎為零(只改一個矩陣)重新配置記憶體、清空、重建狀態
記憶體固定隨解析度平方成長
文字品質差(被拉伸)
MDN 效能建議明確推薦(GPU 加速)

MDN 的 canvas 最佳化章節直接把「用 CSS transform 縮放」列為建議,並補上一句關鍵限制:放大(用較小的 canvas)可以,縮小(用較大的 canvas)不行——後者等於白白渲染看不到的像素,還多一次降採樣。

什麼時候選 transform

  • 像素藝術/低解析度美學:本來就要硬邊或柔化,放大不損失什麼;配 image-rendering: pixelated 甚至是必要的。
  • 效能吃緊的裝置:填充率是行動 GPU 的主要瓶頸,把像素量從 368 萬砍到 92 萬是四倍的差距。
  • resize 極頻繁:桌機拖曳視窗、可調整大小的嵌入式遊戲。

什麼時候選 resize

  • 有大量文字或細線條:任何拉伸都會讓它們糊。
  • 向量風格 / 幾何圖形:既然是程式畫的,用原生解析度畫成本沒有增加多少,品質卻明顯較好。
  • 需要精確的視覺驗收:美術要看到的是最終像素。

混合路線(實務上最常用)

分層:遊戲世界用 transform 縮放的固定 canvas,HUD 與文字用原生解析度的第二層 canvas 或 DOM。

<div class="game-root">
  <canvas id="world" width="1280" height="720"></canvas>  <!-- transform 放大 -->
  <div id="hud"></div>                                     <!-- DOM,原生清晰 -->
</div>
#world {
  position: absolute; top: 50%; left: 50%;
  width: 1280px; height: 720px;          /* CSS 尺寸 = backing store 尺寸 */
  transform: translate(-50%, -50%) scale(var(--scale));
  transform-origin: center center;
  will-change: transform;                 /* 提示瀏覽器提升為合成層 */
}
root.style.setProperty('--scale', String(view.scale))

transform 動的是合成層,不觸發版面重算也不觸發重繪,是最便宜的縮放方式。will-change: transform 讓瀏覽器預先把它提升為獨立的合成層;不要濫用(每個合成層都吃 GPU 記憶體),但對這個唯一的主畫布是划算的。

輸入座標換算:唯一正確的寫法

不論走哪條路線,把螢幕座標換回設計座標的方法都一樣,而且不需要知道 scale 是多少

function toDesign(ev, canvas, view) {
  const r = canvas.getBoundingClientRect()
  // r 已經包含所有 CSS transform、捲動位置、縮放的結果
  const nx = (ev.clientX - r.left) / r.width     // 0..1
  const ny = (ev.clientY - r.top)  / r.height
  return {
    x: nx * view.worldW + view.originX,          // worldW = 這塊畫布代表的世界寬度
    y: ny * view.worldH + view.originY,
  }
}

getBoundingClientRect() 回傳的是元素在視覺上實際佔據的矩形,已經套用了 CSS transform。這是它比自己記錄 scale 更可靠的原因:你之後加上旋轉、加上父層的縮放、加上瀏覽器縮放,這段程式碼都不用改。

三個常見錯誤:

offsetXoffsetY 它們相對於事件目標,而事件目標可能是 canvas 上疊的某個 DOM 元素,換算基準就錯了。一律用 clientXclientY + 自己算矩形。

快取 getBoundingClientRect() 卻沒在 resize 時失效。 它是相對於視口的,所以捲動、resize、transform 改變都會讓快取失效。滿版遊戲因為 position: fixed 不捲動,快取相對安全,但仍要掛在 relayout() 裡更新(見 重算迴圈)。每幀呼叫一次其實也還好——它是讀取幾何,只有在「剛寫過樣式」時才昂貴。

用 backing store 尺寸而非 CSS 尺寸當分母。 ev.clientX - r.left 是 CSS 像素,除以 canvas.width(實體像素)會得到差一個 DPR 倍的結果。分母必須是 r.width

用 Pointer Events 統一輸入

pointerdownpointermovepointerup 一套涵蓋滑鼠、觸控、觸控筆,且提供 pointerTypepressurepointerId(多點觸控):

canvas.addEventListener('pointerdown', ev => {
  canvas.setPointerCapture(ev.pointerId)    // 手指滑出畫布外仍持續收到事件
  const p = toDesign(ev, canvas, view)
  game.onPress(ev.pointerId, p, ev.pointerType)   // 'mouse' | 'touch' | 'pen'
})
 
canvas.addEventListener('pointermove', ev => {
  // 高頻率取樣:拖曳軌跡要平滑就讀合併事件
  const events = ev.getCoalescedEvents?.() ?? [ev]
  for (const e of events) game.onMove(e.pointerId, toDesign(e, canvas, view))
})

setPointerCapture() 解決的是「按住拖到畫布外就斷線」的老問題;getCoalescedEvents() 讓你拿到瀏覽器為了配合 rAF 而合併掉的中間取樣點,畫線類遊戲一定要用。

搭配 touch-action: none(見 滿版容器)才能收到完整的 pointer 事件序列——沒有它,瀏覽器可能在判定為捲動手勢後發出 pointercancel 並停止後續事件。

一個容易忽略的細節:transform 下的滑鼠精度

transform: scale(0.5) 縮小時,一個實體像素的滑鼠移動對應到兩個設計像素,玩家會覺得操作「跳格」。放大時則相反,操作變得比預期更精細。若你的遊戲有精準瞄準需求,應該把靈敏度乘上 1 / scale 做補償,或直接改用 Pointer Lock API 取得原始的 movementX/movementY(不受縮放影響):

canvas.requestPointerLock()
document.addEventListener('mousemove', ev => {
  if (document.pointerLockElement === canvas) {
    aim.x += ev.movementX * sensitivity   // 原始位移,與畫面縮放無關
  }
})

檢查清單

  • 已明確選定路線,並知道自己為什麼選(畫風 vs 效能)
  • transform 路線:will-change: transform、只放大不縮小
  • 座標換算用 getBoundingClientRect()clientX/Y,分母是 r.width
  • 使用 Pointer Events + setPointerCapture
  • touch-action: none,否則觸控事件會被中途取消
  • 實測:畫布縮放到 0.5 與 2.0 倍時,點擊落點都精準

下一篇處理不同裝置形態的差異 → 方向與裝置