<canvas> 是唯一一個「CSS 尺寸」與「內容解析度」完全脫鉤的元素。canvas.style.width = '400px' 決定它在版面上佔 400 個 CSS 像素;canvas.width = 400 決定它的 bitmap(backing store)有 400 個像素欄。兩者不相等時,瀏覽器會像縮放圖片一樣把 bitmap 拉伸——這就是 canvas 模糊的全部原因。

在 DPR 3 的手機上,400 CSS 像素等於 1200 個實體像素。若 backing store 只有 400,每個 bitmap 像素被拉伸成 3×3 的螢幕像素,再經雙線性插值,結果是所有邊緣糊掉一圈。

標準修正

MDN 的規範寫法只有三步:

const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
 
const size = 200
canvas.style.width  = `${size}px`     // 1. 版面尺寸(CSS 像素)
canvas.style.height = `${size}px`
 
const scale = window.devicePixelRatio   // 2. backing store 乘上 DPR
canvas.width  = Math.floor(size * scale)
canvas.height = Math.floor(size * scale)
 
ctx.scale(scale, scale)                 // 3. 座標系換回 CSS 像素

第 3 步是為了讓後續所有繪圖程式碼不必知道 DPR 的存在:呼叫 ctx.fillRect(0, 0, 100, 100) 畫出來仍是 100 CSS 像素見方。在有遊戲縮放的情境下,這一步要跟 scale 合併成一次 setTransform()(見 畫布縮放策略):

ctx.setTransform(gameScale * dpr, 0, 0, gameScale * dpr, offsetX * dpr, offsetY * dpr)

五個必踩的坑

一、canvas.width 一設就清空並重設 context。 規範規定改變 widthheight 會重設 bitmap 為透明黑,並把 drawing state 復原成初始值:變換矩陣變回單位矩陣、fillStyle 變回 #000font 變回 10px sans-serif、clip 區域清除。這代表兩件事——resize 之後必須重設所有 context 狀態;以及設成相同的值也會觸發清空,所以務必用 if (canvas.width !== next) 守衛。

二、devicePixelRatio 會變。 使用者調整瀏覽器縮放(Ctrl +/−)、把視窗拖到不同 DPI 的外接螢幕,DPR 都會改變。捏合縮放則不會改變它。監聽方式是 matchMedia 的解析度查詢,而且必須在每次觸發後重新建立監聽(因為查詢字串綁死了當下的 DPR):

let dprMedia
function watchDPR(onChange) {
  if (dprMedia) dprMedia.removeEventListener('change', handler)
  dprMedia = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
  dprMedia.addEventListener('change', handler, { once: true })
  function handler() { onChange(window.devicePixelRatio); watchDPR(onChange) }
}

三、Math.round 造成的半像素誤差。 dispW * dpr 幾乎不會是整數(例如 393 CSS px × 3 = 1179,但 dispW 常常是 392.7 這種小數)。四捨五入後 backing store 與 CSS 尺寸的比值不再精確等於 DPR,畫面會有極輕微的整體模糊。要完全消除,用 ResizeObserverdevice-pixel-content-box,它直接給你實體像素的整數尺寸

const ro = new ResizeObserver(entries => {
  for (const e of entries) {
    const box = e.devicePixelContentBoxSize?.[0]
    if (box) resizeCanvas(box.inlineSize, box.blockSize)   // 已是實體像素整數
    else     resizeCanvas(e.contentRect.width * dpr, e.contentRect.height * dpr)
  }
})
try {
  ro.observe(canvas, { box: 'device-pixel-content-box' })
} catch {
  ro.observe(canvas)          // 舊瀏覽器對未知的 box 值會丟 TypeError
}

device-pixel-content-box 量的是「套用 CSS transform 之前、以裝置像素計的內容區尺寸」,是 canvas 場景設計出來的專用選項。它比其他兩種 box 值晚支援,必須 try/catch 降級。

四、非整數座標的反鋸齒成本。x = 10.5 畫一條 1px 直線,瀏覽器會用兩排半透明像素模擬——既模糊又慢。MDN 的效能建議明確列出「避免浮點座標,用 Math.floor() 取整」。在有 scale 的座標系裡,取整要在乘完 scale 之後做,這通常代表你得自己做像素對齊(pixel snapping),或乾脆讓 scale 只取整數值(見下)。

五、DPR 沒有上限的話會燒掉效能預算。 backing store 的記憶體是 width × height × 4 bytes。一支 1290×2796 邏輯解析度、DPR 3 的手機滿版跑,等於 3,606,840 像素 × 4 ≈ 14.4 MB 每層。三層 canvas 就 43 MB,而且每幀要填滿 360 萬像素。實務上把 DPR 夾在 2 到 2.5 之間,肉眼幾乎看不出差別,像素量卻少 30–55%:

const dpr = Math.min(window.devicePixelRatio || 1, 2.5)

render scale:把清晰度變成可調旋鈕

把 DPR 上限一般化,就得到 render scale——一個獨立於版面的解析度係數:

canvas.width  = Math.round(cssW * dpr * renderScale)
canvas.height = Math.round(cssH * dpr * renderScale)
canvas.style.width  = cssW + 'px'     // 版面尺寸不變
canvas.style.height = cssH + 'px'

renderScale = 1 是原生解析度,0.75 是四分之三解析度(像素量 −44%),0.5 是半解析度(−75%)。畫面會變柔和但不會跑版,因為版面尺寸完全沒動。這是主機與 PC 遊戲的動態解析度(dynamic resolution)在網頁上的等價物,配上每幀時間量測就能自動調節,演算法見 效能:render scale 與動態解析度

分層設定:UI 層永遠 renderScale = 1(文字必須銳利),遊戲層才調降。這樣即使降到 0.5,HUD 與字仍然清楚,主觀畫質下降遠小於整體降解析度。

像素藝術:反過來要「別平滑」

像素風遊戲的需求完全相反——你要的是硬邊的方塊,不是插值。兩個開關都要打開:

canvas {
  image-rendering: pixelated;   /* CSS 層放大時用最近鄰 */
}
ctx.imageSmoothingEnabled = false   // drawImage 縮放時用最近鄰

而且縮放係數必須是整數,否則不同像素會被分配到不同數量的螢幕像素,造成「有些像素比較胖」的不均勻感:

// 像素藝術專用:只取整數倍縮放,剩餘空間補黑邊
const raw = Math.min(cssW / DESIGN_W, cssH / DESIGN_H)
const scale = Math.max(1, Math.floor(raw))

代價是縮放粒度很粗——螢幕能放下 2.9 倍時只能用 2 倍,浪費 31% 的空間。折衷做法是「整數倍渲染 + 小數倍 CSS 拉伸」:先用整數倍畫到離屏 canvas 保證像素均勻,再用 transform: scale(1.45) 把它拉到滿版。因為是 GPU 合成、且來源已經是硬邊,觀感遠好於直接用 2.9 倍渲染。

文字的特別處理

canvas 內的文字是清晰度最脆弱的一環,因為它經過兩次縮放(DPR + 遊戲 scale)。三個原則:

  1. 字級用設計座標寫ctx.font = '24px …'),讓 setTransform() 統一處理縮放,不要自己乘 scale。
  2. 能放 DOM 就放 DOM。 HUD 的分數、對話框、選單用 HTML + CSS 疊在 canvas 上,瀏覽器會用子像素反鋸齒與 hinting 渲染,永遠比 canvas 的文字銳利,還免費得到選取、無障礙與 IME。
  3. 不要對文字用 render scale。 見上一節的分層。

檢查清單

  • canvas.width = cssW × dpr,且有 if 守衛避免無謂重設
  • resize 後有重新套用 setTransform 與所有 context 狀態
  • devicePixelRatio 有夾上限(建議 2–2.5)
  • matchMedia 監聽 DPR 變化(外接螢幕、瀏覽器縮放)
  • 像素藝術:image-rendering: pixelatedimageSmoothingEnabled = false + 整數倍縮放
  • 文字盡量走 DOM 層

下一篇處理「什麼時候該重算、怎麼算才不會抖」 → 重算迴圈