Skip to content

响应式游戏画布

不正确缩放的 canvas 在不同屏幕尺寸下会模糊或错位。我调试过这个问题的次数多到数不过来,让我帮你省点事。

试试不同的缩放模式:

核心问题

Canvas 有两个容易混淆的尺寸。CSS 尺寸控制它在屏幕上显示多大。内部分辨率控制你实际在绘制多少像素。两者不匹配就会模糊或失真。

在 Retina 显示器上,CSS 可能写的是 800x600,但屏幕实际有 1600x1200 物理像素。如果 canvas 的内部分辨率只有 800x600,浏览器会把它放大,看起来就糊。

基础响应式配置

让 canvas 填满窗口且锐利的写法:

js
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 超宽屏上。信箱模式加黑边来保持比例。

js
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 背景设黑:

css
body {
  margin: 0;
  background: #000;
  overflow: hidden;
}

像素艺术的整数缩放

像素艺术在整数倍缩放(1x、2x、3x)下清晰,在 2.7x 这种非整数缩放下糊。即便加了 image-rendering: pixelated,2.5x 缩放也意味着某些像素是 2 个屏幕像素宽,某些是 3 个。

js
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):

js
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 函数开销大,画面就会卡。

js
let resizeTimeout
window.addEventListener('resize', () => {
  clearTimeout(resizeTimeout)
  resizeTimeout = setTimeout(resize, 100)
})

100ms 延迟意味着用户停止拖动后才真正 resize。要在拖动中也平滑响应,可以用 ResizeObserver:

js
const observer = new ResizeObserver(entries => {
  resize()
})
observer.observe(document.body)

ResizeObserver 触发更稀疏,浏览器对它做了更好的优化。

全屏

Fullscreen API 让你接管整个屏幕:

js
async function toggleFullscreen() {
  if (!document.fullscreenElement) {
    await canvas.requestFullscreen()
  } else {
    await document.exitFullscreen()
  }
}

document.addEventListener('fullscreenchange', resize)

必须在用户手势(如点击)的响应中调用 requestFullscreen,否则浏览器会拦截。进入/退出全屏时记得 resize,因为可用空间会变。

整合起来

下面这个类处理常见情况:

js
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 }
}

用法:

js
const responsive = new ResponsiveCanvas(document.getElementById('game'), {
  width: 320,
  height: 180,
  pixelArt: true,
  letterbox: true
})

// 游戏代码里用 responsive.width 和 responsive.height

常见的坑

有几个常见陷阱:

**resize 时 canvas 会被清空。**设置 canvas.widthcanvas.height 会清空 canvas。如果你不是每帧重绘,需要在 resize 后重绘。

**DPR 会变化。**用户把浏览器窗口从 Retina 显示器拖到非 Retina 显示器时,DPR 会变。在 render 循环里检查,不要只在 resize 事件里检查。

**iOS Safari 视口怪癖。**地址栏显示/隐藏时视口高度会变。在移动端用 window.visualViewport 拿到更准确的尺寸。

**上下文状态会被重置。**修改 canvas 尺寸会重置上下文状态,包括变换、填充样式和 imageSmoothingEnabled。resize 后要重新设置。

更多资源

Canvas 2D 游戏循环 涵盖 update/render 循环基础。

像素艺术渲染 深入讲清晰的像素图形。

移动端友好的网页游戏 涵盖触摸输入和视口问题。

WebGL 基础 讲 WebGL 上下文的视口和 canvas 配置。

发布一个快速加载的网页游戏 讲为性能优化 canvas 尺寸。