做像素风地图、独立游戏关卡编辑器、或者那种“拿一堆方块随机拼出一个岛再描边”的小工具时,我估计你大概率遇到过这个需求:以 120×120 为单位的小方块,随机拼接成一块图形,最后给整个图形生成一条干净的描边。这个活儿听起来简单,但真正上手做的时候,坑一个接一个,尤其是最近大家聊得比较多的“描边拖影问题”,稍微不小心就会出现边缘发虚、重影、断线。这篇就从一个实际项目出发,把从“方块数据”到“图形轮廓描边”的完整链路拆开讲清楚,包括思路、算法、Canvas 实操和几个容易踩的坑。
这个需求适合谁来参考呢?如果你是做 H5 小游戏的地图地块生成、像素拼图小工具、或者画布编辑器里需要给不规则图形加外框,这篇可以帮你省不少时间。核心内容分四块:随机方块图形怎么生成、外轮廓边界怎么提取、Canvas 上怎么描边才干净、以及拖影问题到底怎么根治。
1. 先拆需求:方块生成和描边其实是两件事
1.1 “120×120 的方块”到底指什么
如果你接触过地块编辑,应该知道 120×120 通常有两种含义:一种是逻辑上的网格规格,每个小格代表 120 像素,图形由若干小格拼接而成;另一种是实际渲染后的限制,比如每个方块最终在画布上占据 120 像素见方。这两种理解指向同一个问题:我们操作的数据结构是“格子”,人眼要看到的却是“轮廓”。
很多人在这一步就走错了方向:直接遍历所有方块,然后对每个方块调用strokeRect画边框。这个方法听起来很直观,但画出来的效果是“一堆方块各自带边框”,而不是“整个图形有一条连续外轮廓”。而且小方块之间的公共边被重复描两次,叠加出来的颜色更深,视觉上就是一道道毛刺,拖影问题也被无限放大。所以第一件事就是要把“填充思维”切换成“轮廓思维”。
1.2 从零开始的完整链路设计
我的做法是先隔离数据和渲染,整个流程拆成四步:
- 生成网格数据:用二维数组记录哪些格子被占用。
- 随机拼接图形:用算法让这些格子连成一个有意义的形状,比如岛屿、多边形、建筑群。
- 提取外轮廓:扫描格子,找出所有暴露在外的边,并把它们连接成一条闭合路径。
- 渲染描边:在 Canvas 上一次性绘制这条路径,再处理抗锯齿和清除残影。
这样做的好处是,数据层和渲染层完全解耦。后续你想改方块颜色、给轮廓加圆角、加阴影、或者换一套地图碰撞逻辑,都只需要改对应层,不会牵一发动全身。而且描边不是每帧重新算,图形定下来之后轮廓只算一次,性能上有保障。
2. 随机拼成图形的几种实现方式
2.1 方案选型:三种常见方法对比
“随机拼成图形”是个很宽泛的描述,不同玩法适合不同的生成算法。我比较常用的是下面三种:
| 生成方法 | 适合场景 | 图形特点 | 实现难度 |
|---|---|---|---|
| 种子生长法 | 岛屿、区块、迷宫风格 | 连通的曼哈顿式图形,边缘有锯齿感 | 低 |
| 随机多边形转网格 | 标志、抽象拼图、有机轮廓 | 轮廓相对平滑,边缘更自然 | 中 |
| 预置块拼合法 | 建筑、厂房、像素家具 | 结构化明显,适合模块化 | 低 |
如果你只是想快速看到一个“随机图形”,种子生长法最容易上手。如果你希望图形轮廓更像一个“形状”而不是一团点,推荐用随机多边形转网格,这也是我目前在项目里用的方案。
2.2 推荐实操:随机多边形转网格
具体思路是:先在离屏 Canvas 上随机生成一个多边形轮廓,填充成纯色,然后通过读取像素的 alpha 通道,确认哪些网格被多边形的内部覆盖,最后把坐标换算成逻辑格子索引。
这一步非常巧妙,它绕开了“判断点是否在多边形内”这种几何算法。Canvas 的isPointInPath虽然在路径存在时好用,但要一个个格子去判断 120×120 次,性能很浪费。而用离屏像素读取,一次填充后遍历一遍像素即可,速度和代码量都可控。
下面的代码演示如何随机生成一个 6 到 10 个顶点的多边形,然后把它转成逻辑网格数组:
// 生成随机多边形网格 function generateRandomShapeGrid(cols, rows, blockSize = 120) { const scale = 4; // 离屏画布放大倍数,减少采样误差 const offCanvas = document.createElement('canvas'); offCanvas.width = cols * blockSize * scale; offCanvas.height = rows * blockSize * scale; const ctx = offCanvas.getContext('2d'); // 随机生成多边形顶点 const cx = offCanvas.width / 2; const cy = offCanvas.height / 2; const radius = Math.min(offCanvas.width, offCanvas.height) * 0.35; const vertexCount = 6 + Math.floor(Math.random() * 5); const points = []; for (let i = 0; i < vertexCount; i++) { const angle = (i / vertexCount) * Math.PI * 2; const r = radius * (0.6 + Math.random() * 0.6); points.push({ x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r }); } // 填充多边形 ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i].x, points[i].y); } ctx.closePath(); ctx.fillStyle = '#000'; ctx.fill(); // 读取像素,转换成网格 const imageData = ctx.getImageData(0, 0, offCanvas.width, offCanvas.height).data; const grid = Array.from({ length: rows }, () => Array(cols).fill(0)); for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { // 取小块中心点的 alpha 来判断这个格子是否被覆盖 const px = Math.floor((col * blockSize + blockSize / 2) * scale); const py = Math.floor((row * blockSize + blockSize / 2) * scale); const alpha = imageData[(py * offCanvas.width + px) * 4 + 3]; if (alpha > 128) { grid[row][col] = 1; } } } return grid; }注意这里设置了scale = 4,因为离屏画布只有放大 4 倍,才能让每个 120×120 的格子都有足够的采样点,避免格子边缘落在多边形边界上时判断不稳定。fill之后的 alpha 只要大于 128 就认为这块被占用,实际测试中遇到边界模糊像素的概率会小很多。
2.3 种子生长法(备选方案)
如果你想生成的是连通的类似“石头块”的图形,种子生长法更快。核心逻辑是从一个随机点出发,每次随机向上下左右某个方向扩张一个格子,直到数量达标。为了避免生成成一条长蛇,可以维护一个候选边界集合,每次从集合里随机挑选一个格子扩张,这样一个方向就不会被连续强化太多。
function generateGrowthGrid(cols, rows, targetCount) { const grid = Array.from({ length: rows }, () => Array(cols).fill(0)); // 随机起点 const startX = Math.floor(Math.random() * cols); const startY = Math.floor(Math.random() * rows); grid[startY][startX] = 1; let count = 1; // 候选边界集合:已被占用格子的相邻空位 const candidates = new Set(); const addNeighbors = (x, y) => { const dirs = [[0,1],[0,-1],[1,0],[-1,0]]; for (const [dx, dy] of dirs) { const nx = x + dx; const ny = y + dy; if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue; if (grid[ny][nx] === 0) { candidates.add(ny * cols + nx); } } }; addNeighbors(startX, startY); while (count < targetCount && candidates.size > 0) { const idx = [...candidates][Math.floor(Math.random() * candidates.size)]; candidates.delete(idx); const cx = idx % cols; const cy = Math.floor(idx / cols); if (grid[cy][cx] === 1) continue; grid[cy][cx] = 1; count++; addNeighbors(cx, cy); } return grid; }这个方法生成的图形天然连通,不会出现“孤岛碎片”,特别适合做地皮生成。缺点是外围轮廓锯齿感明显,如果你之后要加描边,锯齿会变成一种像素风格,其实还挺好看的。
3. 核心算法:怎么把“填充的格子”变成“干净的轮廓线”
3.1 先找出所有的外露边
图形由一堆格子组成,每个格子最多有四条边:上、下、左、右。当某条边的另一侧没有相邻格子时,这条边就是外露边,也就是整个图形轮廓的一部分。
这一步是整篇的核心,逻辑非常简单:
function extractOutlineEdges(grid, cols, rows, blockSize) { const edges = []; const isSolid = (x, y) => { if (x < 0 || y < 0 || x >= cols || y >= rows) return false; return grid[y][x] === 1; }; for (let y = 0; y < rows; y++) { for (let x = 0; x < cols; x++) { if (!grid[y][x]) continue; const pixelX = x * blockSize; const pixelY = y * blockSize; // 左边外露 if (!isSolid(x - 1, y)) { edges.push([ { x: pixelX, y: pixelY }, { x: pixelX, y: pixelY + blockSize } ]); } // 右边外露 if (!isSolid(x + 1, y)) { edges.push([ { x: pixelX + blockSize, y: pixelY }, { x: pixelX + blockSize, y: pixelY + blockSize } ]); } // 上边外露 if (!isSolid(x, y - 1)) { edges.push([ { x: pixelX, y: pixelY }, { x: pixelX + blockSize, y: pixelY } ]); } // 下边外露 if (!isSolid(x, y + 1)) { edges.push([ { x: pixelX, y: pixelY + blockSize }, { x: pixelX + blockSize, y: pixelY + blockSize } ]); } } } return edges; }这段代码逐个格子检查四条边,一旦发现邻居为空或者越界,就认为该边属于外轮廓。因为是单向判定,所以不会出现同一条边被压入两次的问题。而且这里用的是“格子像素坐标”,也就是说最终描边路径的点坐标已经与真正要渲染的画布坐标系统一了。
注意:越界格子统一视为“空”。这样图形即使贴到画布边缘,描边也能正确向外扩出去。但如果你的画布边缘有留白,最后描边会被截断,建议在生成图形时就留出至少一个格子的边距。
3.2 把散边连接成闭合路径
光有一堆边还不行,它们现在是无序的。比如一个 2×2 方块拼成的图形,外轮廓边是 8 条,顺序打乱之后,绘制出来的路径会像蜘蛛网。所以第二步是把这些边按照“首尾相接”的方式排成一条连续路径。
我常用的方法是贪心连接:先把第一条边作为路径起点,然后循环查找下一条边,只要某条边的起点等于当前路径的终点,就把它接上,更新终点,继续找。因为方块轮廓是闭合且路径是简单多边形,不需要处理复杂的分叉情况。
function buildOutlinePath(edges) { if (edges.length === 0) return []; const path = [ { x: edges[0][0].x, y: edges[0][0].y }, { x: edges[0][1].x, y: edges[0][1].y } ]; let remaining = edges.slice(1); let safety = 0; let currentEnd = path[path.length - 1]; while (remaining.length > 0 && safety < 10000) { safety++; let found = -1; for (let i = 0; i < remaining.length; i++) { const edge = remaining[i]; const edgeStart = edge[0]; const edgeEnd = edge[1]; if (edgeStart.x === currentEnd.x && edgeStart.y === currentEnd.y) { path.push(edgeEnd); currentEnd = edgeEnd; found = i; break; } // 如果一条边的终点是当前终点,等价于反向边,极少出现,但稳妥起见也处理 if (edgeEnd.x === currentEnd.x && edgeEnd.y === currentEnd.y) { path.push(edgeStart); currentEnd = edgeStart; found = i; break; } } if (found >= 0) { remaining.splice(found, 1); } else { // 理论上有断点说明数据有误,这里保守处理:把剩余边塞进去,让Canvas自然画出不整齐线段 path.push(...remaining[0][0], remaining[0][1]); remaining.shift(); } } return path; }这里我加了一个safety计数器,防止极端情况下死循环。正常情况下,路径会一条接一条连到最后,首尾自动闭合。如果你发现路径不闭合,最大原因是isSolid对越界的判定和你画布边界不一致,需要统一。
3.3 用 Canvas 把路径画出来
拿到路径之后,在 Canvas 上描边就非常简单了:
function drawOutline(ctx, path, style) { ctx.beginPath(); ctx.moveTo(path[0].x, path[0].y); for (let i = 1; i < path.length; i++) { ctx.lineTo(path[i].x, path[i].y); } ctx.closePath(); ctx.strokeStyle = style.color || '#4a90d9'; ctx.lineWidth = style.width || 4; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.stroke(); }线条宽度建议不要小于 2。因为 120 像素的大方块已经决定了画布尺寸不会太小,太细的描边在视觉上会显得单薄。另外把lineJoin和lineCap都设成round,可以让轮廓的转折处更圆润,减少直角拼接带来的违和感。
4. 被反复提及的“描边拖影问题”到底是怎么回事
4.1 拖影和虚边的成因分析
最近“描边拖影问题”这个词在相关社区讨论得很多,我排查过不少项目,发现百分之八十的“拖影”都不是一个原因造成的,而是几个因素叠加:
- 多次绘制不清屏:动画循环里没有在每帧开头做
ctx.clearRect,上一帧的描边残影还在,和当前帧叠加在一起,就成了拖影。 - 描边坐标落在半像素上:Canvas 绘制 1px 线条时,默认会把线条中心对齐到坐标点,导致左右各画半像素,产生模糊。如果你用的坐标或尺寸是小数,问题会更严重。
- 多种绘制方式叠加:先逐方块画了边框,又用路径描边,两套边框叠加出一个“双层重影”。
- Canvas 元素的尺寸和 CSS 尺寸不一致:比如 canvas 的
width是 600,CSS 却设置成 400px 显示,浏览器会做拉伸插值,边缘变虚,看起来就像拖影。 - lineWidth 太粗且开启阴影后,如果还用了
ctx.shadowBlur,阴影和实体会形成一个渐变尾巴,视觉效果上也会被称作拖影。
4.2 从根源上消除拖影
针对上面这几点,逐个给解决方案:
第一,养成每帧清屏的习惯。
ctx.clearRect(0, 0, canvas.width, canvas.height);如果你的场景是静态图形,也建议在重绘前清屏,尤其是初次绘制之后外部 Canvas 被 CSS 缩放时。
第二,让像素对齐网格。
Canvas 在绘制线条时有独特的像素对齐规则。画 1 像素横线时,你的坐标 y 应该取整数加 0.5;画 2 像素及以上粗线时,坐标可以用整数。但更省事的方案是在初始化时直接对整个上下文做一次位移:
ctx.translate(0.5, 0.5);这样所有整数坐标都会落在像素中心,画细线时不会出现半像素模糊。
第三,用整条路径描边,不用逐块描边。
前面已经强调过,逐块strokeRect会让公共边重复绘制。即使你把每个方块的颜色调成半透明,两块交界处因为绘制两次,还是会产生一条更深的线。改成整条路径描边之后,这个问题自然消失。
// 错误示例:不要这样做 for (const cell of cells) { ctx.strokeRect(cell.x, cell.y, 120, 120); } // 正确做法:提取轮廓路径后一次 stroke ctx.beginPath(); // ... build path ... ctx.stroke();第四,统一 Canvas 物理尺寸与 CSS 尺寸。
这里我给一个通用的适配函数:
function setupCanvas(canvas, width, height, scale = window.devicePixelRatio || 1) { canvas.width = width * scale; canvas.height = height * scale; canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; const ctx = canvas.getContext('2d'); ctx.setTransform(scale, 0, 0, scale, 0, 0); return ctx; }使用的时候传入你想要的逻辑尺寸,比如 1200×960,内部用 devicePixelRatio 放大,CSS 显示时再缩小,这样在 retina 屏幕上线条不会发虚。
第五,如果还想要特别锐利的像素风边缘,可以用图像二值化。
有些人对描边的要求是“绝对锐利”,不能有任何抗锯齿渐变。这种情况可以在 Canvas 绘制完成后,读取像素数据,把边缘半透明的像素处理成完全不透明:
function hardenAlpha(ctx, width, height) { const imageData = ctx.getImageData(0, 0, width, height); const data = imageData.data; for (let i = 3; i < data.length; i += 4) { if (data[i] > 0 && data[i] < 255) { data[i] = data[i] > 128 ? 255 : 0; } } ctx.putImageData(imageData, 0, 0); }这个处理比较暴力,它会把所有半透明像素按阈值二值化。如果你的描边只有两层颜色(前景和背景),效果会非常干净;但如果你叠加了投影或渐变,这个函数会破坏它们,所以要谨慎使用。
4.3 描边拖影问题排查速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 边缘有半透明拖尾,类似残影 | 上一帧没清屏,残影残留 | 重绘前clearRect |
| 边框发虚,线条像被雾化 | 坐标落在半像素上 | ctx.translate(0.5, 0.5)或使用整数坐标 |
| 图形边界交叉处颜色更深 | 逐方块strokeRect导致公共边重复绘制 | 改为提取轮廓路径一次描边 |
| 边缘有明显锯齿或马赛克 | lineWidth 过小或画布被拉伸 | 增大线宽,统一 canvas 尺寸与 CSS 尺寸 |
| 线条带渐变尾巴,像光晕 | 开启shadowBlur或渐变描边 | 关闭阴影,或控制阴影范围 |
| 轮廓整体是散的,没有闭合 | 边界提取时越界判定不一致 | 检查isSolid的越界处理与画布边界是否一致 |
4.4 一个少见但很隐蔽的坑:离屏画布抗锯齿污染
当你使用“随机多边形转网格”方案时,离屏 Canvas 的imageSmoothingEnabled默认是开启的,这会导致填充多边形边缘有半透明像素。如果这些半透明像素恰好跨越了格子边界,你读取 alpha 时判断会不稳定,最后生成的网格边缘就会有“半个格子被判定为占用”的情况,描边后看起来像被狗啃过一样。
解决它有两种思路。一是在离屏 Canvas 填充后先执行一次阈值二值化,把半透明像素全部转为全不透明或全透明;二是直接关闭抗锯齿:
const ctx = offCanvas.getContext('2d', { antialias: false });如果不行,再用前面的hardenAlpha函数,二选一即可。实测下来,离屏图像二值化对边缘落格子的影响最有效,因为即使有抗锯齿像素,也能被阈值强化成确定状态。
5. 扩展思路:方块配色、圆角描边和交互高亮
5.1 给轮廓做圆角或偏移
当你已经拿到轮廓路径后,想扩展功能就非常简单了。比如给描边加圆角,只需要在生成路径时做一次圆角处理,或者在绘制时用ctx.setLineDash做虚线效果:
ctx.setLineDash([6, 4]); ctx.lineDashOffset = offset; ctx.stroke();如果你想做一个“向外扩展一层”的描边,也就是图形内部填充,外部额外画一圈边框且不遮住图形,可以在数据层面把图形往外扩张一个格子,或者直接用轮廓路径配合stroke在线宽方向上落在路径两边。对于 120 像素的大方块,我的经验是线宽稍微大一点,比如 6 到 8 像素,效果更明显。
5.2 与交互系统的结合
轮廓路径本身也是一份很好的碰撞检测数据。比如鼠标点击时,可以用ctx.isPointInPath(path, x, y)判断是否点击到当前图形。因为路径是闭合的,Canvas 原生支持这个检测接口,完全不用自己写射线法。
如果你后面要做一个“选中高亮”功能,比如鼠标悬停时高亮图形边缘,只需要把同一份 path 再 stroke 一遍,换一个颜色和线宽即可。由于数据与渲染分离,整套逻辑不会出现一处改动引发另一处报错的情况。
6. 一点实操后的个人体会
最后说一下我做了几轮之后最想提醒的点:这个需求真正的精力消耗不在“画”这一步,而在“数据到几何”的转换。很多人一开始就想找一个 Canvas 的魔法 API,希望能一行代码给拼接的方块外围生成描边,但事实上ctx并不理解“这些方块连在一起是一个形状”,它只认路径。把“方块集合”转换成“有序路径”这一步想通了,后面所有问题都会自然消失。
另外,描边拖影问题不要一上来就怀疑是抗锯齿的锅,优先检查清屏和绘制次数,绝大多数情况是这两个原因。只有确认坐标、清屏、绘制方式都没问题之后,再考虑离屏处理抗锯齿像素。项目里真正让你头疼的往往不是某个大算法,而是这些小细节叠加在一起。
如果后面你想让这个工具支持用户手绘一个形状再自动生成方块和描边,整体思路也是一样的:先把用户轨迹转换成网格数据,再走提取轮廓、生成描边的链路。我现在这个方案已经稳定跑在未来吾皇的编辑器里,改起来比想象中轻松很多。