把遊戲畫面放大到滿版有兩條路:維持一個小的 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 更可靠的原因:你之後加上旋轉、加上父層的縮放、加上瀏覽器縮放,這段程式碼都不用改。
三個常見錯誤:
用 offsetX/offsetY。 它們相對於事件目標,而事件目標可能是 canvas 上疊的某個 DOM 元素,換算基準就錯了。一律用 clientX/clientY + 自己算矩形。
快取 getBoundingClientRect() 卻沒在 resize 時失效。 它是相對於視口的,所以捲動、resize、transform 改變都會讓快取失效。滿版遊戲因為 position: fixed 不捲動,快取相對安全,但仍要掛在 relayout() 裡更新(見 重算迴圈)。每幀呼叫一次其實也還好——它是讀取幾何,只有在「剛寫過樣式」時才昂貴。
用 backing store 尺寸而非 CSS 尺寸當分母。 ev.clientX - r.left 是 CSS 像素,除以 canvas.width(實體像素)會得到差一個 DPR 倍的結果。分母必須是 r.width。
用 Pointer Events 統一輸入
pointerdown/pointermove/pointerup 一套涵蓋滑鼠、觸控、觸控筆,且提供 pointerType、pressure、pointerId(多點觸控):
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 倍時,點擊落點都精準
下一篇處理不同裝置形態的差異 → 方向與裝置