响应式游戏画布
不正确缩放的 canvas 在不同屏幕尺寸下会模糊或错位。我调试过这个问题的次数多到数不过来,让我帮你省点事。
试试不同的缩放模式:
核心问题
Canvas 有两个容易混淆的尺寸。CSS 尺寸控制它在屏幕上显示多大。内部分辨率控制你实际在绘制多少像素。两者不匹配就会模糊或失真。
在 Retina 显示器上,CSS 可能写的是 800x600,但屏幕实际有 1600x1200 物理像素。如果 canvas 的内部分辨率只有 800x600,浏览器会把它放大,看起来就糊。
基础响应式配置
让 canvas 填满窗口且锐利的写法:
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
function resize() {
const dpr = window.devicePixelRatio || 1
const width = window.innerWidth
const height = window.innerHeight
// 把内部分辨率设成匹配物理像素
canvas.width = width * dpr
canvas.height = height * dpr
// 把 CSS 尺寸设成匹配逻辑像素
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
// 缩放上下文,让你能按逻辑像素绘制
ctx.scale(dpr, dpr)
}
window.addEventListener('resize', resize)
resize()ctx.scale(dpr, dpr) 这行是关键。这之后,你在 (100, 100) 绘制,不论 DPR 多少都会显示在正确位置。canvas 在 2x 显示器上内部按 200x200 渲染,但你的代码不需要知道这件事。
一个坑:ctx.scale 是累积的。多次调用 resize 会越缩越大。要么在 scale 之前用 ctx.setTransform(1, 0, 0, 1, 0, 0) 重置变换,要么只 scale 一次。
固定宽高比 + 信箱模式
很多游戏需要固定宽高比。你不希望 16:9 游戏被拉伸到 21:9 超宽屏上。信箱模式加黑边来保持比例。
const GAME_WIDTH = 1920
const GAME_HEIGHT = 1080
const ASPECT = GAME_WIDTH / GAME_HEIGHT
function resizeWithLetterbox() {
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const windowAspect = windowWidth / windowHeight
let width, height
if (windowAspect > ASPECT) {
// 窗口比游戏宽,两侧加黑边
height = windowHeight
width = height * ASPECT
} else {
// 窗口比游戏高,上下加黑边
width = windowWidth
height = width / ASPECT
}
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
canvas.style.position = 'absolute'
canvas.style.left = (windowWidth - width) / 2 + 'px'
canvas.style.top = (windowHeight - height) / 2 + 'px'
// 内部分辨率保持固定
canvas.width = GAME_WIDTH
canvas.height = GAME_HEIGHT
}让黑边显示出来,把 body 背景设黑:
body {
margin: 0;
background: #000;
overflow: hidden;
}像素艺术的整数缩放
像素艺术在整数倍缩放(1x、2x、3x)下清晰,在 2.7x 这种非整数缩放下糊。即便加了 image-rendering: pixelated,2.5x 缩放也意味着某些像素是 2 个屏幕像素宽,某些是 3 个。
const GAME_WIDTH = 320
const GAME_HEIGHT = 180
function resizePixelPerfect() {
const maxWidth = window.innerWidth
const maxHeight = window.innerHeight
// 找到适合的最大整数倍缩放
const scaleX = Math.floor(maxWidth / GAME_WIDTH)
const scaleY = Math.floor(maxHeight / GAME_HEIGHT)
const scale = Math.max(1, Math.min(scaleX, scaleY))
const width = GAME_WIDTH * scale
const height = GAME_HEIGHT * scale
canvas.width = GAME_WIDTH
canvas.height = GAME_HEIGHT
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
canvas.style.imageRendering = 'pixelated'
}代价是游戏不会恰好填满屏幕。在 1920x1080 显示器上跑 320x180 游戏,得到 5x 缩放(1600x900),边缘留黑边。有的玩家更喜欢这样;有的希望游戏铺满屏幕,哪怕略糊。
WebGL 画布缩放
对 WebGL,除了 canvas 尺寸,还要更新视口(viewport):
function resizeGL(gl, canvas) {
const dpr = window.devicePixelRatio || 1
const width = canvas.clientWidth * dpr
const height = canvas.clientHeight * dpr
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
gl.viewport(0, 0, width, height)
return true // 已调整大小,你可能需要更新投影矩阵
}
return false
}在 render 循环开头调用它,而不仅仅是在 resize 事件里。一些浏览器在窗口被拖到另一台显示器时会改 DPR,你得能接住这种情况。
给 resize 事件做防抖
用户拖动窗口边缘时 resize 事件会高频触发。如果你的 resize 函数开销大,画面就会卡。
let resizeTimeout
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(resize, 100)
})100ms 延迟意味着用户停止拖动后才真正 resize。要在拖动中也平滑响应,可以用 ResizeObserver:
const observer = new ResizeObserver(entries => {
resize()
})
observer.observe(document.body)ResizeObserver 触发更稀疏,浏览器对它做了更好的优化。
全屏
Fullscreen API 让你接管整个屏幕:
async function toggleFullscreen() {
if (!document.fullscreenElement) {
await canvas.requestFullscreen()
} else {
await document.exitFullscreen()
}
}
document.addEventListener('fullscreenchange', resize)必须在用户手势(如点击)的响应中调用 requestFullscreen,否则浏览器会拦截。进入/退出全屏时记得 resize,因为可用空间会变。
整合起来
下面这个类处理常见情况:
class ResponsiveCanvas {
constructor(canvas, options = {}) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.gameWidth = options.width || 1280
this.gameHeight = options.height || 720
this.pixelArt = options.pixelArt || false
this.letterbox = options.letterbox !== false
this.bind()
this.resize()
}
bind() {
window.addEventListener('resize', () => this.resize())
document.addEventListener('fullscreenchange', () => this.resize())
}
resize() {
const aspect = this.gameWidth / this.gameHeight
let width = window.innerWidth
let height = window.innerHeight
if (this.letterbox) {
if (width / height > aspect) {
width = height * aspect
} else {
height = width / aspect
}
}
if (this.pixelArt) {
const scale = Math.max(1, Math.floor(Math.min(
window.innerWidth / this.gameWidth,
window.innerHeight / this.gameHeight
)))
width = this.gameWidth * scale
height = this.gameHeight * scale
}
this.canvas.width = this.gameWidth
this.canvas.height = this.gameHeight
this.canvas.style.width = width + 'px'
this.canvas.style.height = height + 'px'
this.canvas.style.position = 'absolute'
this.canvas.style.left = (window.innerWidth - width) / 2 + 'px'
this.canvas.style.top = (window.innerHeight - height) / 2 + 'px'
if (this.pixelArt) {
this.canvas.style.imageRendering = 'pixelated'
}
}
get width() { return this.gameWidth }
get height() { return this.gameHeight }
}用法:
const responsive = new ResponsiveCanvas(document.getElementById('game'), {
width: 320,
height: 180,
pixelArt: true,
letterbox: true
})
// 游戏代码里用 responsive.width 和 responsive.height常见的坑
有几个常见陷阱:
**resize 时 canvas 会被清空。**设置 canvas.width 或 canvas.height 会清空 canvas。如果你不是每帧重绘,需要在 resize 后重绘。
**DPR 会变化。**用户把浏览器窗口从 Retina 显示器拖到非 Retina 显示器时,DPR 会变。在 render 循环里检查,不要只在 resize 事件里检查。
**iOS Safari 视口怪癖。**地址栏显示/隐藏时视口高度会变。在移动端用 window.visualViewport 拿到更准确的尺寸。
**上下文状态会被重置。**修改 canvas 尺寸会重置上下文状态,包括变换、填充样式和 imageSmoothingEnabled。resize 后要重新设置。
更多资源
Canvas 2D 游戏循环 涵盖 update/render 循环基础。
像素艺术渲染 深入讲清晰的像素图形。
移动端友好的网页游戏 涵盖触摸输入和视口问题。
WebGL 基础 讲 WebGL 上下文的视口和 canvas 配置。
发布一个快速加载的网页游戏 讲为性能优化 canvas 尺寸。