做一个像素涂鸦画板,顺便搞懂Canvas的坐标
想做个像素画板:一张网格,点格子填颜色,画完能存成图片。逻辑不复杂,但做出来发现两个毛病——线条发虚,而且点的格子跟鼠标位置对不上。
这两个问题根源是同一个: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() 减掉画布偏移,再除以格子大小向下取整。把这两处搞对,线条清晰、点击准确,剩下的就是填色和导出的事。
- 点赞
- 收藏
- 关注作者
评论(0)