ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Canvas绘制圆点完全指南:坐标系、arc()参数与粒子动画实战

Canvas绘制圆点完全指南:坐标系、arc()参数与粒子动画实战

刚学Canvas那会儿,我一度不太敢碰它。画个圆点而已,翻翻文档不就是ctx.arc()然后fill()一下吗?结果自己上手一跑,要么圆不圆,要么整个画布被一个巨型色块铺满,要么圆点边缘糊成一片,最气人的是颜色跟设的根本不是同一个。后来把坐标系、路径状态、像素比这些底层逻辑串起来之后才发现,新手期踩过的坑几乎都指向同一个原因——没有真正理解Canvas到底是怎么把图"画"上去的。

这篇文章就是要把这些事讲透,从Canvas的坐标体系、arc()参数含义开始,讲清楚一个圆点为什么是这么画出来的,再给一套可以参考的动态粒子 Demo。刚接触前端、需要在页面里画圆点做图表或者动效的同学,按着里面的步骤走一遍,再把避坑部分对照一遍,基本就不会再“懵圈”了。

1. 一张画布两种坐标:先搞懂Canvas到底是怎么画圆的

很多教程上来就甩代码,ctx.arc(100, 100, 50, 0, Math.PI * 2),然后说“这样就画了个圆”。对老手来说确实就这么简单,但对新手来说,这个100到底是画在哪?为什么Math.PI * 2是转一整圈?角度从哪儿开始数?这几个问题一旦含糊,后面所有代码都是空中楼阁。

1.1 坐标系原点在左上角,Y轴还是反的

Canvas的坐标系和初中数学课本上教的坐标系不一样。数学课上的坐标系,原点一般在左下角,Y轴往上增长。Canvas不一样,它的原点(0, 0)在画布的左上角,X轴往右增长,Y轴往下增长。

这个“Y轴往下”的设定是新手最容易懵的点。你想画一个在视觉上位于画布上方的圆点,坐标的 Y 值反而要写得小;想画到下方,Y 值就要写大。我见过有人想画一排从上往下排列的圆点,结果 Y 坐标从 0 往上加,圆点全跑到了肉眼范围内的画布外面,回头还以为是代码哪里写错了。

1.2 画布尺寸的两层概念:属性宽高 vs 样式宽高

还有一个特别容易搞混的:Canvas标签的width和height属性,跟 CSS 里设置的width和height是两个东西。

  • width/height属性决定的是画布的分辨率,也就是你能在上面画多少个像素点。
  • CSS 里的宽高决定的是这个画布在页面上显示成多大。

如果这两个不一致,Canvas 内容就会被拉伸或压缩。比如你写<canvas width="300" height="300">,然后 CSS 里又设置了width: 600px; height: 600px;,那么你画的圆点会被横向纵向各放大一倍,原本清晰的边缘会变得模糊。这个问题后面避坑部分还会详细说,这里先记住一个结论:画之前,先把这两层尺寸统一。

1.3 弧度制才是arc的默认语言

ctx.arc()接受的起始角和结束角,单位是弧度,不是角度。一个完整的圆是2π弧度,也就是Math.PI * 2。半圆是Math.PI,四分之一圆是Math.PI / 2。

这里有两点对新手特别不友好:

第一,角度不是从我们习惯的正上方(12点钟方向)开始数的,而是从3点钟方向开始,也就是X轴正方向。假如你要画一个从12点钟方向开始的圆弧,起始角要写成-Math.PI / 2,用角度来理解就是-90°。

第二,角度的增长方向是顺时针。学过数学坐标系的人可能下意识认为是逆时针,但Canvas默认是顺时针。不过arc()提供了第五个参数counterclockwise,可以切换方向,默认false表示顺时针。

说实话,我到现在画圆弧的时候还会心里默念一句“3点钟方向开始,顺时针转”。这不是笨不笨的问题,是这个API的设计本身就跟人类直觉不一致,需要刻意记。

2. 核心API拆解:真正决定圆点成败的是这四个细节

当我们说“画一个圆点”,拆到Canvas API层面,其实就三个动作:beginPath()开一条新路径,arc()描述圆的轮廓,fill()或者stroke()把图形呈现出来。每个动作背后都有容易被忽略的细节。

2.1 beginPath的重要性:路径是会“记仇”的

新手最常见的翻车现场是:画了好几个圆,颜色居然互相串了,或者某些圆的颜色不是自己设置的颜色。

原因就在这里——Canvas的路径是“记仇”的。如果你在画第二个圆之前没有调用beginPath(),那么第二个圆的路径会包含第一个圆的轮廓,fill()的时候会把两个区域的叠加部分再填充一遍。如果颜色是半透明的,叠加部分的颜色会明显加深;如果两个圆有交集,交集区域会变成混合色。

所以我的习惯是:每画一个独立的圆点,beginPath()必须写在最前面,一行都不能省。

2.2 arc的参数到底怎么理解

ctx.arc(x, y, r, startAngle, endAngle, counterclockwise)这六个参数,我按自己的理解拆一下:

参数含义新手容易犯的错
x, y圆心坐标把坐标写到了画布外面,圆点直接消失
r半径半径写得过大,比如r写成500,把一个圆点画成了全屏色块
startAngle起始角忘了是从3点钟方向开始数的
endAngle结束角想画完整圆却用Math.PI,结果画了半个
counterclockwise是否逆时针默认false是顺时针,跟数学直觉相反

一个完整的圆点,最稳妥的写法是ctx.arc(x, y, r, 0, Math.PI * 2),从0到2π绕一整圈,闭合成一个圆。这里0, Math.PI * 2配合的用法最保险,因为不管你圆心设在哪,它都会从3点钟方向出发,顺时针转完整整一圈再回到起点。

2.3 fill和stroke,一个负责填色一个负责描边

fill()是填充圆的内部区域,stroke()是描边。新手经常把两者混在一起用,画完一个圆,填充了颜色,又描了一圈边,结果圆点边缘多出一圈奇怪的轮廓。

想画一个实心圆点,只需要fill()。想画一个空心圆环,才需要在fillStyle之外再设置strokeStyle和lineWidth,然后调用stroke()。

还有一点要留心:fillStyle、strokeStyle、lineWidth这些样式属性,必须在对应的fill()或stroke()调用之前设置。顺序反了,样式不会立刻生效到这个圆上,而是会影响下一个图形。

2.4 closePath在这个场景下到底要不要用

closePath()的作用是把路径的终点和起点连起来,画一条直线。对于arc()画出来的一段完整圆弧,它本身已经是一个闭合的形状了,不需要closePath()就能正常填充。但如果你画的是带缺口的圆弧,比如起始角0、结束角Math.PI,然后调用fill(),Canvas会自动用一条直线把缺口的两个端点连起来再填充,这时候你得到的是个半圆形状,不是完整的圆。

我在画圆点场景下的建议就一句话:画完整圆,不需要closePath();画圆弧,心里要清楚fill()会自动闭合缺口。

3. 从静态到动态:一个圆点粒子画板的完整实现

光讲API太抽象了,我们直接做一个能跑的东西。下面这个例子,把前面讲的所有知识点都串进去了:坐标系、路径状态管理、样式设置、动画循环,还加了设备像素比的处理。做完之后你会看到一批圆点像气泡一样在画布里缓慢漂浮,鼠标移上去,附近的圆点会稍微“躲开”光标,这个交互效果其实就是围绕圆点绘制做的核心逻辑。

3.1 画布准备:宽高、像素比、坐标系初始化

第一步先把画布铺好。这里有一个关键操作:处理设备像素比(DPR)。

现在的屏幕普遍是2倍甚至3倍物理像素密度。如果直接用CSS像素作为Canvas的分辨率,在Retina屏幕上画出来的圆点边缘会发虚。处理方法如下:

const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); // 获取设备的像素比 const dpr = window.devicePixelRatio || 1; // 获取CSS显示尺寸 const rect = canvas.getBoundingClientRect(); const cssWidth = rect.width; const cssHeight = rect.height; // 把画布分辨率设置成 物理像素 大小 canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; // 把画布显示尺寸设置成 CSS 大小 canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; // 坐标系缩放,让后续代码可以直接用CSS像素来绘制 ctx.scale(dpr, dpr);

这一步做完之后,后面所有绘制代码都按照CSS像素的坐标来写,不用关心屏幕上到底有多少物理像素。这个习惯我从第一次写Canvas到现在一直保持,能帮你避开大半的“模糊”问题。

3.2 粒子数据结构的定义与初始化

粒子本身就是一个对象,里面存了位置、速度、半径和颜色:

const particles = []; const COLORS = ['#2b7de9', '#f5a623', '#e94e77', '#42b883', '#9b59b6']; function createParticle() { return { x: Math.random() * cssWidth, y: Math.random() * cssHeight, vx: (Math.random() - 0.5) * 1.2, vy: (Math.random() - 0.5) * 1.2, r: 4 + Math.random() * 8, color: COLORS[Math.floor(Math.random() * COLORS.length)] }; }

这里的速度vx和vy用了(Math.random() - 0.5),保证速度有正有负,粒子才不会全部往同一个方向跑。半径范围是4到12像素,这个数值范围适合大多数常见页面场景,可以自行调整。

初始化粒子数量我控制在120个左右。数量太少画面单薄,数量太多动画帧率会掉,尤其是不做性能优化的初学者代码。120是一个比较安全的起步值。

3.3 绘制与更新的主循环逻辑

动画的核心是requestAnimationFrame,它会告诉浏览器“下一帧渲染之前,你先执行我这段代码”,这样动画就能跟显示器的刷新率保持同步。

function drawFrame() { // 清空画布 ctx.clearRect(0, 0, cssWidth, cssHeight); // 更新并绘制每个粒子 particles.forEach((p, index) => { // 更新位置 p.x += p.vx; p.y += p.vy; // 边界反弹 if (p.x < p.r || p.x > cssWidth - p.r) p.vx = -p.vx; if (p.y < p.r || p.y > cssHeight - p.r) p.vy = -p.vy; // 鼠标避让效果 if (mouse.x && mouse.y) { const dx = p.x - mouse.x; const dy = p.y - mouse.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 80) { const force = (80 - dist) / 80; p.x += dx * 0.02 * force; p.y += dy * 0.02 * force; } } // 绘制圆点 ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fillStyle = p.color; ctx.fill(); }); requestAnimationFrame(drawFrame); } drawFrame();

这段代码里,每一帧干的活其实就四件事:清屏、算新位置、画圆点、再请浏览器安排下一帧。清屏用clearRect(),把整个画布区域都擦掉,否则上一帧的圆点会一直留在画布上,变成一团拖尾。

边界反弹的边界判断用了p.x < p.r而不是p.x < 0,是因为圆点的圆心不能跑到半径之外,否则圆会有一半露在画布外面,看起来像是被切断一样。

3.4 鼠标跟踪:让交互自然融入圆点

鼠标交互逻辑其实很朴素:每帧计算每个粒子和鼠标之间的距离,如果距离小于80像素,就施加一个小小的推力,把它弹开。

let mouse = { x: null, y: null }; canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); mouse.x = e.clientX - rect.left; mouse.y = e.clientY - rect.top; }); canvas.addEventListener('mouseleave', () => { mouse.x = null; mouse.y = null; });

这里注意e.clientX拿到的是相对于浏览器视口的坐标,必须减去画布相对于视口的偏移量rect.left / rect.top,换算成画布内的坐标。忘了这一步的后果是:鼠标看起来在画布左边,圆点却在右边开始避让,交互和视觉完全错位。

避让的力度用(80 - dist) / 80计算,越靠近鼠标的粒子受到的推力越大,视觉上会比较自然。如果距离超过80,就不做任何处理,避免所有粒子一拥而上全都乱跑。

3.5 resize复盘:窗口尺寸变了别崩

窗口尺寸变化时,画布宽高属性也要跟着更新,不然粒子跑一会儿就会出现坐标超出画布范围的情况。监听resize事件后,重新做一遍第一节里的初始化逻辑就行:

window.addEventListener('resize', () => { const rect = canvas.getBoundingClientRect(); cssWidth = rect.width; cssHeight = rect.height; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; ctx.scale(dpr, dpr); });

这里有一个极其容易踩的坑:resize触发后,之前的ctx.scale(dpr, dpr)会被画布重置,因为重设canvas.width会清空画布状态,包括坐标系变换。所以要重新调用一次ctx.scale(dpr, dpr),否则坐标会错位,绘制的圆点全跑到角落去。我在项目里遇到过这个问题,排查了好一阵才发现是坐标系被重置了。

4. 避坑指南:模糊、叠色、卡顿、越界……新手翻车现场实录

这应该是全文最有价值的部分了。我之前带过几个同学做Canvas小练习,把他们的报错和异常现象汇总一下,发现高频问题翻来覆去就那几个,下面统一列出来。

4.1 常见问题速查对照表

现象根本原因解决办法
圆点边缘模糊Canvas分辨率与物理像素不匹配按DPR设置canvas.width/canvas.height,再用ctx.scale(dpr, dpr)
多个圆点颜色互相污染漏了beginPath()每个圆点绘制前都调用ctx.beginPath()
圆被拉伸成椭圆属性宽高和CSS宽高不一致检查width/height属性和CSS样式是否一致
圆点跑出画布一半边界判断写成了p.x < 0用p.x < p.r判断
圆点位置和鼠标位置对不上没有减去画布偏移量用e.clientX - rect.left
动画帧率很低用了setInterval或者粒子数量过多改用requestAnimationFrame,控制粒子数量
重设canvas大小后绘制错位ctx.scale()被重置每次重设宽高后重新ctx.scale()
圆点有强烈的锯齿感低分辨率下绘制超大半径圆配合DPR提高画布分辨率

把这几个记牢,新手期大部分疑难杂症就都解决了。

4.2 三个典型的“翻车现场”复盘

第一个是叠色问题。A同学画了五个红色圆点,其中三个变成了深红色。我把他的代码一看,第二个圆点绘制前没调用beginPath(),导致后面每个圆点的路径都包含了前一个圆的轮廓,fill()的时候叠加区域被填了两次。

第二个是圆形变椭圆。B同学给canvas设置了width="300" height="300",但是CSS里写的是width: 600px; height: 300px;,画出来的圆在水平方向被拉长了一倍。这其实就是1.2节里说的“属性宽高”和“样式宽高”不一致的问题。

第三个是鼠标交互失灵。C同学说自己的圆点“不理”鼠标,我让他打印mouse.x和mouse.y,发现坐标一直是几百几千的“异常值”,仔细一问,他拿到的是e.clientX之后没有减画布偏移。这个故事后来成了我带人时必讲的经典案例。

4.3 调试Canvas的一个实用习惯:把“看不见的”画出来

Canvas画布上,圆点的轮廓默认是看不见的。新手出问题时往往盯着代码看半天也看不出所以然。我自己的调试习惯是:先临时给所有圆点加一圈半透明描边,把隐含的路径边界显示出来。

ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(255, 0, 0, 0.3)'; ctx.stroke(); ctx.fillStyle = p.color; ctx.fill();

描边出来的圈圈能帮你一眼看出:圆心坐标对不对、半径是不是写大了、圆是不是超出了画布范围。排查完毕之后,把strokeStyle和stroke()删掉就行。

另外,我习惯在画布上临时画一些辅助虚线坐标网格,定位圆心位置。用setLineDash([5, 5])画半透明虚线,显示X轴和Y轴的刻度线。确定坐标系没问题之后再去掉。这种“把调试信息画出来”的做法,比单纯看控制台日志直观得多。

4.4 一个小众但高效的排查思路:单步绘制

动画开启之后,圆点每帧都在移动,出了问题很难看清单个圆点到底画在哪。我的办法是:把动画暂停,只绘制一帧,然后基于这一帧去排查问题。

具体做法是在代码里加一个布尔变量isDebug,如果为true,就不启动requestAnimationFrame,只调用一次drawFrame()。这样你可以对着静止的画面逐个检查圆点的位置和半径。排查完毕,把isDebug设为false,动画恢复。

我这个方法也是在一次项目里被坑出来的。当时画了一堆圆点,但其中一个圆点总在屏幕边缘闪来闪去,开启动画完全看不清楚是哪儿的问题。暂停动画后发现,那个粒子一开始就被初始化在了画布外,边界反弹又把它弹回了视口边缘,视觉上确实像在“闪烁”。

5. 顺着圆点往下走:接下来还能玩点什么

Canvas圆点画明白之后,后面的路就顺畅很多了。圆点是Canvas世界里的“hello world”,但它的延伸场景覆盖了数据可视化、游戏粒子特效、交互艺术设计等一大堆方向。

想继续深入的话,可以做这几个事:

第一个是图表方向的尝试。数据可视化里的散点图、气泡图,本质就是一堆圆点加上坐标系和刻度。你已经会画圆点,接下来无非是给圆点绑定数据、设置不同颜色和大小,然后把坐标轴画上去。

第二个是粒子特效。把圆形换成图片、把直线运动改成曲线运动,加入重力模拟或者弹簧动力学,就能做出星空、烟花、雪花这类效果。回头再看本章的粒子Demo,只需要在受力模型上下点功夫就行。

第三个是给圆点加上事件交互。Canvas本身不提供DOM那种点击事件检测,你得像判断鼠标距离那样,自己做一个“圆点命中测试”。原理很简单:鼠标点到一个位置,遍历所有圆点,计算当前点到圆心的距离,小于半径就认为点中了。这其实是很多图形编辑器的基础逻辑。

绘画板、表情包生成器、动态数据大屏……所有这些看起来“高级”的东西,底层都在反复使用beginPath()、arc()、fill()这三个老朋友。别嫌这三个动作简单,真正稳妥的代码,有时恰恰就是把最简单的动作,在正确的位置反复做对。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进