如何用Canvas实现真正的可点击绘制无限网格?
实现类似生命游戏网站的无限网格(支持鼠标绘制)的思路
我需要创建一个类似生命游戏网站的无限网格,支持鼠标点击在每个网格单元绘制图像,请问实现思路是什么?我尝试过Stack Overflow上《How can I make infinite grid with canvas?》的方案,但它只是一个范围有限的伪无限网格,相关代码如下:
const init_infgrid = () => { canvas_infgrid = $c('canvas'); canvas_infgrid.width = window.innerWidth; canvas_infgrid.height = window.innerHeight; c_infgrid = canvas_infgrid.getContext('2d'); canvas_infgrid.addEventListener("mousedown", e => { reset(); start_infgrid = getPos_Infgrid(e) }); canvas_infgrid.addEventListener("mouseup", reset); canvas_infgrid.addEventListener("mouseleave", reset); canvas_infgrid.addEventListener("mousemove", e => { if (!start_infgrid) return; let pos = getPos_Infgrid(e); c_infgrid.translate(pos.x - start_infgrid.x, pos.y - start_infgrid.y); draw(); start_infgrid = pos; }); $('#canvasarea').appendChild(canvas_infgrid) draw() }
function draw() { let step = 50; let left = 0.5 - Math.ceil(canvas_infgrid.width / step) * step; let top = 0.5 - Math.ceil(canvas_infgrid.height / step) * step; let right = 2 * canvas_infgrid.width; let bottom = 2 * canvas_infgrid.height; c_infgrid.clearRect(left, top, right - left, bottom - top); c_infgrid.beginPath(); for (let x = left; x < right; x += step) { c_infgrid.moveTo(x, top); c_infgrid.lineTo(x, bottom); } for (let y = top; y < bottom; y += step) { c_infgrid.moveTo(left, y); c_infgrid.lineTo(right, y); } c_infgrid.strokeStyle = "#888"; c_infgrid.stroke(); } // Mouse event handling: let start_infgrid; const getPos_Infgrid = (e) => ({ x: e.clientX - canvas_infgrid.offsetLeft, y: e.clientY - canvas_infgrid.offsetTop }); const reset = () => { start_infgrid = null; //no want to reset translation //c_infgrid.setTransform(1, 0, 0, 1, 0, 0); // reset translation draw(); }
核心实现思路
原方案的问题在于:仅通过Canvas变换平移网格线,但没有持久化存储用户绘制的单元,且网格线的绘制范围是固定的(2*canvasWidth),本质还是有限范围的伪无限。要实现真正的无限网格,需要从数据存储、坐标映射、绘制逻辑三个层面重构:
1. 用稀疏数据结构存储活跃网格单元
无限网格的大部分区域是空的,不需要全量存储。用哈希表(比如JS的Map)仅记录用户修改过的单元:
- 键:用
"x,y"格式的字符串标识网格单元的逻辑坐标(如"3,-5") - 值:存储单元状态(比如是否激活、颜色等)
示例:
const activeCells = new Map(); // 激活某个单元 activeCells.set(`${gridX},${gridY}`, { color: '#000' }); // 取消激活 activeCells.delete(`${gridX},${gridY}`);
2. 维护全局偏移量,实现坐标映射
放弃直接修改Canvas变换矩阵的方式,改用offsetX、offsetY记录当前视口与网格原点的偏移:
- 像素坐标转网格逻辑坐标:将鼠标点击的Canvas像素位置,转换为无限网格中的逻辑坐标
const gridStep = 50; // 网格单元大小 function pixelToGrid(pixelX, pixelY) { const gridX = Math.floor((pixelX + offsetX) / gridStep); const gridY = Math.floor((pixelY + offsetY) / gridStep); return { x: gridX, y: gridY }; } - 网格逻辑坐标转像素坐标:绘制时将逻辑坐标转换为Canvas上的像素位置
function gridToPixel(gridX, gridY) { const pixelX = gridX * gridStep - offsetX; const pixelY = gridY * gridStep - offsetY; return { x: pixelX, y: pixelY }; }
3. 按需绘制网格与活跃单元
只绘制当前视口内及边缘少量区域的内容,保证性能:
- 绘制网格线:计算当前视口覆盖的网格线范围,批量绘制:
function drawGrid() { const canvasWidth = canvas_infgrid.width; const canvasHeight = canvas_infgrid.height; const step = gridStep; // 计算视口覆盖的网格线起始/结束位置 const startX = Math.floor((0 - offsetX) / step) * step - offsetX; const endX = Math.floor((canvasWidth - offsetX) / step) * step + step - offsetX; const startY = Math.floor((0 - offsetY) / step) * step - offsetY; const endY = Math.floor((canvasHeight - offsetY) / step) * step + step - offsetY; c_infgrid.clearRect(0, 0, canvasWidth, canvasHeight); c_infgrid.beginPath(); c_infgrid.strokeStyle = '#888'; // 绘制竖线 for (let x = startX; x <= endX; x += step) { c_infgrid.moveTo(x, 0); c_infgrid.lineTo(x, canvasHeight); } // 绘制横线 for (let y = startY; y <= endY; y += step) { c_infgrid.moveTo(0, y); c_infgrid.lineTo(canvasWidth, y); } c_infgrid.stroke(); } - 绘制活跃单元:遍历哈希表中的单元,仅绘制在当前视口内的部分:
function drawActiveCells() { const canvasWidth = canvas_infgrid.width; const canvasHeight = canvas_infgrid.height; const step = gridStep; c_infgrid.fillStyle = '#000'; for (const [key, state] of activeCells.entries()) { const [gridX, gridY] = key.split(',').map(Number); const { x: pixelX, y: pixelY } = gridToPixel(gridX, gridY); // 判断是否在视口内 if (pixelX >= -step && pixelX <= canvasWidth && pixelY >= -step && pixelY <= canvasHeight) { c_infgrid.fillRect(pixelX, pixelY, step - 1, step - 1); // 留1px间隙显示网格线 } } }
4. 交互逻辑重构
- 平移网格:鼠标拖拽时,更新
offsetX和offsetY,然后重新绘制:let isDragging = false; let lastMousePos = { x: 0, y: 0 }; canvas_infgrid.addEventListener('mousedown', (e) => { isDragging = true; const pos = getPos_Infgrid(e); lastMousePos = pos; }); canvas_infgrid.addEventListener('mousemove', (e) => { if (!isDragging) return; const pos = getPos_Infgrid(e); // 更新偏移量:鼠标拖动的距离就是视口偏移的变化量 offsetX += pos.x - lastMousePos.x; offsetY += pos.y - lastMousePos.y; lastMousePos = pos; // 重绘 drawGrid(); drawActiveCells(); }); canvas_infgrid.addEventListener('mouseup', () => isDragging = false); canvas_infgrid.addEventListener('mouseleave', () => isDragging = false); - 绘制单元:鼠标点击或拖拽时,转换坐标并更新哈希表:
let isDrawing = false; canvas_infgrid.addEventListener('mousedown', (e) => { // 区分平移和绘制:比如按住Ctrl是平移,否则是绘制 if (e.ctrlKey) { isDragging = true; lastMousePos = getPos_Infgrid(e); } else { isDrawing = true; const pos = getPos_Infgrid(e); const gridPos = pixelToGrid(pos.x, pos.y); const key = `${gridPos.x},${gridPos.y}`; activeCells.set(key, { color: '#000' }); drawGrid(); drawActiveCells(); } }); canvas_infgrid.addEventListener('mousemove', (e) => { if (isDrawing) { const pos = getPos_Infgrid(e); const gridPos = pixelToGrid(pos.x, pos.y); const key = `${gridPos.x},${gridPos.y}`; activeCells.set(key, { color: '#000' }); drawGrid(); drawActiveCells(); } }); canvas_infgrid.addEventListener('mouseup', () => { isDragging = false; isDrawing = false; });
5. 性能优化建议
- 对活跃单元进行空间分块:将网格分成固定大小的块(比如10x10单元为一块),只加载视口附近的块,减少遍历次数。
- 重绘时使用requestAnimationFrame,避免频繁同步绘制。
- 鼠标移动事件添加节流,降低重绘频率。
内容的提问来源于stack exchange,提问作者Quan Vu
相关产品推荐
相关产品推荐

