<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。 規範規定改變 width/height 會重設 bitmap 為透明黑,並把 drawing state 復原成初始值:變換矩陣變回單位矩陣、fillStyle 變回 #000、font 變回 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,畫面會有極輕微的整體模糊。要完全消除,用 ResizeObserver 的 device-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)。三個原則:
- 字級用設計座標寫(
ctx.font = '24px …'),讓setTransform()統一處理縮放,不要自己乘 scale。 - 能放 DOM 就放 DOM。 HUD 的分數、對話框、選單用 HTML + CSS 疊在 canvas 上,瀏覽器會用子像素反鋸齒與 hinting 渲染,永遠比 canvas 的文字銳利,還免費得到選取、無障礙與 IME。
- 不要對文字用 render scale。 見上一節的分層。
檢查清單
-
canvas.width = cssW × dpr,且有if守衛避免無謂重設 - resize 後有重新套用
setTransform與所有 context 狀態 -
devicePixelRatio有夾上限(建議 2–2.5) - 有
matchMedia監聽 DPR 變化(外接螢幕、瀏覽器縮放) - 像素藝術:
image-rendering: pixelated+imageSmoothingEnabled = false+ 整數倍縮放 - 文字盡量走 DOM 層
下一篇處理「什麼時候該重算、怎麼算才不會抖」 → 重算迴圈