做一个像素涂鸦画板,顺便搞懂Canvas的坐标

举报
海是岛思念的泪 发表于 2026/09/28 14:19:15 2026/09/28
【摘要】 想做个在格子上涂色的像素画板,做出来发现两个毛病:线条模糊、画的格子跟鼠标对不上。根源都在 Canvas 的"两套尺寸"上——CSS 尺寸和画布尺寸不是一回事。这篇讲清楚 devicePixelRatio 怎么处理、鼠标坐标怎么换算成格子索引,以及画完怎么导出成图片。

想做个像素画板:一张网格,点格子填颜色,画完能存成图片。逻辑不复杂,但做出来发现两个毛病——线条发虚,而且点的格子跟鼠标位置对不上。

这两个问题根源是同一个:Canvas 有"两套尺寸"。搞明白之后一切都顺了。

一、两套尺寸:CSS 尺寸和画布尺寸

这是 Canvas 最经典的坑。看这段代码:

<canvas id="board" width="400" height="400" style="width: 400px; height: 400px"></canvas>

看着没问题,但 <canvas> 其实有两个尺寸:

由什么决定 作用
画布尺寸(drawing buffer) width / height 属性 实际有多少个像素,绘图的坐标系
CSS 尺寸 CSS 的 width / height 在页面上显示多大

这俩不一致时,浏览器会把画布拉伸或压缩到 CSS 尺寸,图像就模糊了。

在高分屏(比如 Retina,设备像素比是 2)上更明显:一个 CSS 宽度 400px 的画布,实际需要 800 个物理像素才能清晰显示。

正确做法:

const canvas = document.getElementById('board')
const ctx = canvas.getContext('2d')

const cssSize = 400                       // 想显示多大
const dpr = window.devicePixelRatio || 1  // 设备像素比

canvas.style.width = cssSize + 'px'       // CSS 尺寸
canvas.style.height = cssSize + 'px'
canvas.width = cssSize * dpr              // 画布尺寸 = CSS 尺寸 × 像素比
canvas.height = cssSize * dpr

ctx.scale(dpr, dpr)                       // 缩放绘图上下文
ctx.imageSmoothingEnabled = false         // 关掉抗锯齿,像素画要硬边

加了 ctx.scale(dpr, dpr) 之后,你后面写代码还是按 400×400 的逻辑坐标来,不用自己乘系数,很省心。

imageSmoothingEnabled = false 是像素画的必需品——不然浏览器会给放大后的格子做平滑,边缘糊掉,就不是像素风了。

二、鼠标坐标怎么换算成格子

第二个问题:鼠标点在哪,怎么知道是第几个格子?

canvas.addEventListener('click', e => {
  const rect = canvas.getBoundingClientRect()

  // 鼠标在画布内的位置(注意减去画布左上角的偏移)
  const x = e.clientX - rect.left
  const y = e.clientY - rect.top

  // 除以格子大小,向下取整得到索引
  const col = Math.floor(x / cellSize)
  const row = Math.floor(y / cellSize)

  paintCell(col, row)
})

关键点:

  • 必须减掉 rect.left / rect.top。e.clientX 是相对浏览器窗口左上角的,而我们要的是相对画布左上角的。画布在页面上有偏移时,不减就整体错位。
  • 用 getBoundingClientRect() 而不是 canvas.offsetLeft。前者更准,还能处理页面滚动和 CSS 变换的情况。
  • 用 Math.floor 向下取整。第 0 个格子覆盖的是 0 到 cellSize 这一段,所以 x=15、cellSize=20 时属于第 0 个格子。

三、画格子

const GRID = 16                                   // 16 × 16 的格子
const cellSize = cssSize / GRID                   // 每个格子多大

// 用二维数组记录每个格子的颜色,null 表示没涂
const cells = Array.from({ length: GRID }, () => Array(GRID).fill(null))

function paintCell(col, row, color = '#3a72d4') {
  if (col < 0 || col >= GRID || row < 0 || row >= GRID) return   // 越界保护
  cells[row][col] = color
  draw()
}

function draw() {
  ctx.clearRect(0, 0, cssSize, cssSize)          // 清空

  // 先画格线
  ctx.strokeStyle = '#e0e0e0'
  ctx.lineWidth = 1
  for (let i = 0; i <= GRID; i++) {
    ctx.beginPath()
    ctx.moveTo(i * cellSize, 0)
    ctx.lineTo(i * cellSize, cssSize)
    ctx.stroke()

    ctx.beginPath()
    ctx.moveTo(0, i * cellSize)
    ctx.lineTo(cssSize, i * cellSize)
    ctx.stroke()
  }

  // 再填色块
  for (let r = 0; r < GRID; r++) {
    for (let c = 0; c < GRID; c++) {
      if (cells[r][c]) {
        ctx.fillStyle = cells[r][c]
        ctx.fillRect(c * cellSize, r * cellSize, cellSize, cellSize)
      }
    }
  }
}

四、拖动连续涂色

点一下涂一格太慢,得支持按住拖动连续涂:

let painting = false

canvas.addEventListener('mousedown', e => {
  painting = true
  paint(e)
})

canvas.addEventListener('mousemove', e => {
  if (painting) paint(e)
})

window.addEventListener('mouseup', () => {
  painting = false
})

function paint(e) {
  const rect = canvas.getBoundingClientRect()
  const col = Math.floor((e.clientX - rect.left) / cellSize)
  const row = Math.floor((e.clientY - rect.top) / cellSize)
  paintCell(col, row, currentColor)
}

mouseup 要挂在 window 上而不是 canvas 上。因为用户可能在画布外面松开鼠标,挂 canvas 上的话那个事件收不到,painting 就永远是 true,之后鼠标移到画布上会自动涂色。

mousemove 会高频触发,可以在 paintCell 里判断一下"这个格子已经是这个颜色了就跳过",省掉重复绘制:

if (cells[row][col] === color) return

五、导出成图片

function exportPNG() {
  const url = canvas.toDataURL('image/png')

  const a = document.createElement('a')
  a.href = url
  a.download = 'pixel-art.png'
  a.click()
}

因为画布尺寸已经是 cssSize × dpr,导出的图片天然就是高清的,不用额外处理。

有个安全限制:如果画布上画过跨域的图片,toDataURL 会直接抛安全错误(Tainted canvases may not be exported)。这是浏览器防止你把别人的图片拿走。要么确保图片同源,要么给图片设 crossOrigin="anonymous" 且对方服务器允许跨域。

我们这个画板全是自己画的,没有这个问题。

几个容易漏的点

  • 窗口 resize 后要重设尺寸。改变 CSS 尺寸后画布尺寸不会自动跟着变,得重新执行一遍设置宽高的代码,然后重绘。
  • 格线画在整数位置会糊。1px 的线如果坐标是整数,浏览器会把它画在两个像素中间,变成 2px 的灰线。想画锐利的 1px 线,坐标加 0.5:ctx.moveTo(i * cellSize + 0.5, 0)。
  • 触屏要单独处理。移动端用 touchstart / touchmove,而且要 e.preventDefault() 防止页面跟着滚。
  • ctx.clearRect 用的是逻辑坐标。因为前面 scale(dpr, dpr) 过,所以这里传 400 而不是 800。这一点容易搞混——scale 之后所有绘图 API 都用逻辑坐标。
  • 格子太多会卡。32×32 有 1024 个格子,每次 mousemove 都全量重绘可能掉帧。可以改成只重绘变化的那一个格子,但代码会复杂些,16×16 的量级全量重绘完全够用。
  • 别忘保存进度。用 localStorage 把 cells 数组存下来,刷新页面画的东西还在。

小结

像素画板的实现不难,真正的门槛在 Canvas 那"两套尺寸"——CSS 尺寸决定显示多大,画布尺寸(属性)决定实际像素数,高分屏上画布尺寸要乘 devicePixelRatio,再用 ctx.scale() 让后续代码按逻辑坐标写。另一个要点是鼠标坐标换算:用 getBoundingClientRect() 减掉画布偏移,再除以格子大小向下取整。把这两处搞对,线条清晰、点击准确,剩下的就是填色和导出的事。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。