用 Canvas 实现一个轻量粒子背景动画:从踩坑到丝滑
不依赖任何动画库,用原生 Canvas API 实现一个性能友好的粒子背景动画:粒子运动模型、鼠标交互吸附、设备像素比适配,以及几个容易掉进去的性能坑。
粒子背景动画在落地页和个人主页里很常见,通常直接上 particles.js 之类的库。但库的体积和配置成本有时候没必要,尤其当你只需要一个简单的飘浮效果时。最近用纯 Canvas 写了一遍,把值得记的东西整理出来。
一、基本结构
整个动画就一个 canvas 元素加一段 JS,核心循环是 requestAnimationFrame 驱动的清屏-更新-绘制三步:
function loop() {
ctx.clearRect(0, 0, width, height);
particles.forEach(p => { p.update(); p.draw(ctx); });
requestAnimationFrame(loop);
}
粒子对象持有位置、速度、半径三个核心属性,update 做边界反弹,draw 画一个圆。就这么简单,几十行就能跑起来。
二、连线效果
粒子之间距离小于阈值时画一条线,透明度随距离衰减。这是粒子动画视觉上最关键的部分,也是性能问题的第一个坑。
直觉写法是双重循环遍历所有粒子对,O(n²)。100 个粒子还好,300 个就开始掉帧。优化思路有两个:
1. 减少绘制频率。 不是每帧都重新计算所有连线,隔一帧算一次,视觉上几乎无差别。
2. 限制连线数量。 每个粒子最多只和最近的 K 个粒子连线,而不是和所有满足距离条件的粒子都连。用距离排序取前 K 个,虽然排序本身也是 O(n²log n),但绘制调用大幅减少,瓶颈在绘制不在计算。
function drawConnections(ctx, particles, maxDist, maxLines) {
for (let i = 0; i < particles.length; i++) {
const dists = [];
for (let j = 0; j < particles.length; j++) {
if (i === j) continue;
const d = distance(particles[i], particles[j]);
if (d < maxDist) dists.push({ j, d });
}
dists.sort((a, b) => a.d - b.d);
dists.slice(0, maxLines).forEach(({ j, d }) => {
ctx.globalAlpha = 1 - d / maxDist;
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
});
}
}
三、鼠标交互
粒子被鼠标吸引或排斥是常见的交互效果。实现上就是在 update 里加一个力:计算粒子到鼠标的距离,距离越近力越大。
坑在于 mousemove 事件的频率和 RAF 的频率不一致。mousemove 一秒可能触发上百次,但 RAF 只有 60 次。如果每次 mousemove 都去更新粒子状态,会产生抖动。正确做法是 mousemove 只记录鼠标位置,粒子状态的更新统一放在 RAF 循环里做:
canvas.addEventListener('mousemove', e => {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
// 在粒子的 update 里读取 mouse.x / mouse.y
四、设备像素比适配
这是 Canvas 最经典的坑。在高 DPI 屏幕上不做 DPR 适配,画面会发虚。做法是:
const dpr = window.devicePixelRatio || 1;
canvas.width = displayWidth * dpr;
canvas.height = displayHeight * dpr;
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';
ctx.scale(dpr, dpr);
注意 canvas.width 和 canvas.style.width 是两回事:前者是绘图缓冲区大小,后者是 CSS 显示大小。设错了要么模糊要么变形。另外 resize 时要重新设置一遍,不能只改 style 不改缓冲区。
五、性能细节
1. globalAlpha 的代价。 每条连线都改 globalAlpha 会触发大量状态切换。更好的做法是把透明度编码到 strokeStyle 的 rgba 里,一次性设置颜色,减少状态变更。
2. 离屏不可见时暂停。 用 IntersectionObserver 监听 canvas 是否在视口内,不可见时停掉 RAF 循环。标签页切走时浏览器会自动降频 RAF,但页面内滚动到不可见区域不会,白白耗电。
3. 粒子数量按屏幕宽度自适应。 手机上 30 个就够了,桌面端可以放到 100+。用 Math.min(120, Math.floor(width / 12)) 这种简单公式做自适应,避免在低端手机上卡死。
六、小结
粒子动画本身不复杂,但要做到不卡、不虚、不费电,细节比想象中多。核心经验就两条:性能瓶颈几乎都在绘制调用而非计算逻辑,能少画就少画;交互事件和渲染循环要解耦,事件只存状态,渲染只读状态。完整代码不到 200 行,没有外部依赖,双击 HTML 就能跑。
- 点赞
- 收藏
- 关注作者
评论(0)