ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Canvas 2D 粒子爱心动画:从采样到 60 帧流动实战

Canvas 2D 粒子爱心动画:从采样到 60 帧流动实战 简介这是一份面向前端初学者与Canvas动画爱好者的HTML5粒子特效实战素材围绕粒子系统与路径绘制解决如何用代码生成浪漫爱心流动动画的问题。压缩包共4个文件约6KB包含2个JavaScript脚本、1个CSS样式表和1个HTML页面JS负责粒子类定义、状态更新与requestAnimationFrame循环绘制CSS控制画布尺寸与页面样式HTML搭建运行入口结构精简便于直接运行和二次修改。目前已有6348人学习下载热度较高。通过阅读代码读者可以掌握Canvas 2D上下文的路径绘制、fillStyle填充、粒子位置与速度更新、透明度衰减与生命周期管理等关键技巧理解如何用多个心形路径点约束粒子运动形成流动消散的视觉效果并学会将CSS与Canvas结合增强整体表现适合作为课程设计、节日祝福页或前端动效练习的参考案例。1. 粒子爱心动画从 Canvas 绘图到 60 帧流动的完整落地路径很多前端开发者第一次看到粒子流动爱心第一反应是“这得用 WebGL 吧”结果打开 DevTools 一看人家就是纯 Canvas 2D 加几百个粒子在跑。这个标题讲的就是这件事用 HTML5 Canvas 的 2D 上下文把一颗爱心的轮廓采样成粒子坐标再让粒子沿着路径流动、呼吸、聚散最终在浏览器里跑出 60 帧的动画特效。它解决的是“不引入 Three.js、不写 shader也能做出有质感的粒子动效”这个需求。适合两类人一是想给产品落地页、节日活动页加视觉锤的前端二是想搞懂 canvas 绘图引擎里粒子系统到底怎么组织数据、怎么控制帧循环的开发者。下面我按自己实际做过的顺序把选型、采样、动画、性能、避坑一层层拆开。2. 为什么用 Canvas 2D 而不是 DOM 或 SVG 做粒子爱心2.1 三种方案的性能边界对比先把这个选型问题说清楚不然后面写代码心里没底。粒子动画的本质是“每帧重绘大量小图元”DOM 和 SVG 都是声明式节点每个粒子一个节点的话500 个粒子就是 500 次样式计算和布局参与帧率会掉得很难看。Canvas 2D 是命令式绘制所有粒子画在同一张位图上浏览器只需要合成一次。方案500 粒子帧率中端机单粒子可控性上手成本适合场景DOM CSS20~30 fps高可用 CSS 动画低粒子数 100SVG25~40 fps中属性动画中矢量图形为主Canvas 2D55~60 fps高逐帧控制中粒子数 200~2000WebGL60 fps高需 shader高粒子数 5000我一般会先问自己粒子数会不会超过 1000不会就用 Canvas 2D超过再考虑 WebGL。爱心形状的粒子动画通常 400~800 个粒子就能有很好的视觉效果Canvas 2D 完全够用。2.2 最小可运行骨架一张 canvas 加一个帧循环在写粒子逻辑之前先把画布和帧循环搭起来。这一步很多人会忽略 devicePixelRatio导致在高分屏上粒子发虚后面调半天以为是粒子大小问题。!DOCTYPE html html head meta charsetutf-8 title粒子爱心/title style html, body { margin: 0; height: 100%; background: #0a0a12; overflow: hidden; } canvas { display: block; width: 100%; height: 100%; } /style /head body canvas idheart/canvas script const canvas document.getElementById(heart); const ctx canvas.getContext(2d); // 关键按设备像素比放大画布避免高分屏模糊 const dpr window.devicePixelRatio || 1; let W, H; function resize() { W canvas.clientWidth; H canvas.clientHeight; canvas.width W * dpr; canvas.height H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后按 CSS 像素坐标绘制 } resize(); window.addEventListener(resize, resize); function loop() { ctx.clearRect(0, 0, W, H); // 粒子绘制逻辑后续填充 requestAnimationFrame(loop); } loop(); /script /body /html这段代码做了三件事设置画布尺寸、处理高分屏缩放、启动 requestAnimationFrame 循环。setTransform(dpr,0,0,dpr,0,0)这行是血泪经验不加的话在 Retina 屏上所有坐标都要手动乘 dpr代码会变得很脏。clearRect每帧清空是必须的Canvas 不会自动擦除上一帧。2.3 粒子系统的数据结构设计粒子不是随便画的点每个粒子要携带位置、速度、目标位置、颜色、大小、生命周期。我习惯用一个类或者普通对象数组来管理字段设计直接决定后面动画能玩出什么花样。class Particle { constructor(x, y) { this.x x; // 当前 x this.y y; // 当前 y this.tx x; // 目标 x爱心轮廓上的点 this.ty y; // 目标 y this.vx 0; // x 方向速度 this.vy 0; // y 方向速度 this.size Math.random() * 1.5 0.5; // 半径 0.5~2 this.hue Math.random() * 30 340; // 色相 340~370偏红粉 this.alpha Math.random() * 0.5 0.5; // 透明度 this.delay Math.random() * 60; // 入场延迟帧数 } }字段说明tx/ty是粒子最终要去的爱心轮廓位置vx/vy用于弹性运动delay让粒子分批入场而不是齐刷刷出现。hue控制在 340~370 之间这是红色到粉色的区间配合 HSL 颜色模式用。粒子大小控制在 0.5~2 像素半径太大就变成色块失去粒子感。3. 爱心轮廓怎么采样成粒子坐标3.1 爱心参数方程与采样密度控制爱心形状有很多画法我常用的是参数方程版本因为它能均匀采样不会像贝塞尔曲线那样在拐角处粒子堆积。// 爱心参数方程t 从 0 到 2π function heartPoint(t, scale) { const x 16 * Math.pow(Math.sin(t), 3); const y -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)); return { x: x * scale, y: y * scale }; }这个方程输出的 x 范围约 -16~16y 范围约 -17~12。scale是缩放系数根据画布大小算scale Math.min(W, H) / 40左右比较合适。采样时 t 的步长决定粒子密度步长越小粒子越多。const particles []; const COUNT 600; // 目标粒子数 const scale Math.min(W, H) / 42; const cx W / 2, cy H / 2; for (let i 0; i COUNT; i) { const t (i / COUNT) * Math.PI * 2; const p heartPoint(t, scale); // 加一点随机抖动避免粒子排成完美线条显得死板 const jitter 3; const px cx p.x (Math.random() - 0.5) * jitter; const py cy p.y (Math.random() - 0.5) * jitter; particles.push(new Particle(px, py)); }这里有个细节粒子初始位置直接设在目标位置动画就看不到“聚拢”效果。我一般会把初始位置随机撒在画布四周让粒子飞进来。抖动jitter控制在 2~4 像素太多轮廓就糊了。3.2 用离屏采样保证轮廓均匀参数方程在爱心尖端处曲率大等 t 采样会导致尖端粒子稀疏。更稳的做法是先在一个离屏 canvas 上画出爱心然后按像素扫描提取轮廓点再从中随机抽取粒子位置。这样粒子分布和视觉密度一致。function sampleHeartOutline(count) { const off document.createElement(canvas); off.width W; off.height H; const octx off.getContext(2d); octx.fillStyle #fff; octx.beginPath(); const scale Math.min(W, H) / 42; for (let t 0; t Math.PI * 2; t 0.01) { const p heartPoint(t, scale); const x W / 2 p.x, y H / 2 p.y; t 0 ? octx.moveTo(x, y) : octx.lineTo(x, y); } octx.closePath(); octx.fill(); const data octx.getImageData(0, 0, W, H).data; const points []; // 每隔 4 像素扫描一次收集不透明像素作为候选点 for (let y 0; y H; y 4) { for (let x 0; x W; x 4) { const idx (y * W x) * 4; if (data[idx 3] 128) points.push({ x, y }); } } // 从候选点中随机抽取 count 个 const result []; for (let i 0; i count; i) { result.push(points[Math.floor(Math.random() * points.length)]); } return result; }getImageData拿到的是 RGBA 数组alpha 通道大于 128 说明该像素在爱心内部。扫描步长 4 是性能和密度的折中步长 2 会更密但采样慢。这个方法的好处是爱心内部也有粒子不只是轮廓线视觉上更饱满。注意getImageData在部分浏览器里对跨域图片有限制但这里是纯绘制没有这个问题。3.3 粒子入场动画从随机位置到目标位置有了目标点入场动画就是让粒子从画布外或随机位置移动到目标点。我用的是弹性缓动比线性插值更有“吸附”感。function updateParticle(p) { if (p.delay 0) { p.delay--; return; } // 弹性系数0.02~0.08 之间越大越快到位 const ease 0.045; p.vx (p.tx - p.x) * ease; p.vy (p.ty - p.y) * ease; // 阻尼防止无限震荡 p.vx * 0.88; p.vy * 0.88; p.x p.vx; p.y p.vy; }ease控制吸引力0.88是阻尼系数。这两个参数要配合调ease 大阻尼小粒子会冲过头再弹回来ease 小阻尼大粒子慢吞吞飘过去。我一般先用 ease0.05、damping0.85 起步再根据效果微调。粒子到达目标附近后速度趋近于零但不会完全静止会有一点点呼吸感这正是想要的效果。4. 让粒子流动起来的三种动画策略4.1 路径流动沿爱心轨迹循环运动静态爱心看久了会腻流动感来自粒子沿爱心路径移动。做法是给每个粒子记录一个路径参数t每帧递增然后重新计算目标位置。function flowAlongHeart(p, time) { p.t p.speed; // speed 约 0.002~0.005 if (p.t Math.PI * 2) p.t - Math.PI * 2; const pt heartPoint(p.t, p.scale); p.tx W / 2 pt.x; p.ty H / 2 pt.y; }每个粒子的speed可以略有不同这样粒子之间会产生相对运动看起来像在流动而不是整体平移。t超过 2π 后回绕保证循环。注意这里的目标位置每帧都在变所以粒子永远在追目标形成持续的流动感。4.2 呼吸效果用正弦波控制粒子大小和透明度呼吸感靠的是周期性变化。用Math.sin配合时间戳让粒子的 size 和 alpha 同步波动。function breathe(p, time) { const phase time * 0.002 p.phaseOffset; const wave (Math.sin(phase) 1) / 2; // 0~1 p.renderSize p.size * (0.6 wave * 0.8); p.renderAlpha p.alpha * (0.4 wave * 0.6); }phaseOffset是每个粒子的随机相位让呼吸不同步整体看起来像波浪一样起伏。如果所有粒子同相位就会变成整体缩放很假。time用performance.now()传入保证不同刷新率下速度一致。4.3 鼠标交互粒子避让与回弹加一点鼠标交互能显著提升体验。思路是检测粒子与鼠标的距离小于阈值时给一个排斥力鼠标移开后粒子自动弹回目标位置。function repelFromMouse(p, mouse) { if (!mouse.active) return; const dx p.x - mouse.x; const dy p.y - mouse.y; const dist Math.hypot(dx, dy); const R 80; // 影响半径 if (dist R dist 0.01) { const force (1 - dist / R) * 6; p.vx (dx / dist) * force; p.vy (dy / dist) * force; } }R是影响半径80 像素左右比较自然。force系数 6 是试出来的太小没感觉太大粒子会飞出去。因为 updateParticle 里始终有指向目标的吸引力鼠标移开后粒子会自动回去不需要额外写回弹逻辑。鼠标坐标要减去 canvas 的 bounding rect 偏移否则位置会偏。5. 性能调优与避坑排查5.1 粒子数量与帧率的平衡粒子数不是越多越好。我实测下来600 个粒子在 Canvas 2D 上画圆中端手机能稳 55 帧以上超过 1200 个就开始掉帧。优化手段有几个用fillRect代替arc画小粒子方块在 2 像素以下视觉上和圆没区别但快很多把粒子按颜色分组批量绘制避免每帧创建新对象。// 快小粒子用 fillRect ctx.fillStyle hsla(${p.hue}, 80%, 60%, ${p.renderAlpha}); ctx.fillRect(p.x - p.renderSize / 2, p.y - p.renderSize / 2, p.renderSize, p.renderSize); // 慢每个粒子都 arc fill // ctx.beginPath(); // ctx.arc(p.x, p.y, p.renderSize, 0, Math.PI * 2); // ctx.fill();fillRect省掉了路径构建和圆弧计算粒子数多时差距很明显。颜色用hsla字符串每帧拼接会有 GC 压力如果粒子数超过 1000可以预先把颜色缓存成数组。5.2 避坑清单现象、原因、解决现象一高分屏上粒子模糊、边缘发虚。原因是没有按 devicePixelRatio 缩放画布canvas 的位图分辨率低于 CSS 显示尺寸浏览器拉伸导致模糊。解决初始化时设置canvas.width clientWidth * dpr并用ctx.setTransform(dpr,0,0,dpr,0,0)统一坐标系统。现象二动画在不同刷新率屏幕上速度不一致。原因是直接用固定增量更新位置60Hz 和 120Hz 屏幕每帧时间不同。解决用performance.now()计算 deltaTime所有速度乘以deltaTime / 16.67做归一化。现象三粒子在爱心尖端处堆积成一条线。原因是参数方程等 t 采样在曲率大的地方点密度过高。解决改用离屏采样法或者对 t 做非均匀映射在尖端处增大步长。现象四页面切到后台再回来粒子全部飞散。原因是 requestAnimationFrame 在后台暂停恢复时 deltaTime 巨大粒子一步跳过头。解决监听visibilitychange恢复时重置 lastTime或者给 deltaTime 设上限如 50ms。现象五移动端触摸时页面跟着滚动。原因是 canvas 上的 touchmove 没有阻止默认行为。解决给 canvas 加touch-action: none的 CSS或者在 touchmove 里调用preventDefault。5.3 用 FPS 计数器定位性能瓶颈不要凭感觉猜哪里慢加一个简单的 FPS 计数器改一个参数看一眼帧率变化比什么都直接。let frames 0, lastFpsTime performance.now(), fps 0; function tickFps(now) { frames; if (now - lastFpsTime 1000) { fps frames; frames 0; lastFpsTime now; console.log(FPS:, fps); } }把tickFps放在 loop 开头调用。如果帧率低于 50优先降粒子数其次把 arc 换成 fillRect再不行就减少每帧的 getImageData 调用采样只做一次不要每帧做。6. 进阶玩法用粒子文字和 3D 透视提升视觉层次6.1 从爱心到任意形状文字粒子化爱心采样这套逻辑可以直接迁移到文字上。用离屏 canvas 画文字扫描像素点就能得到文字轮廓的粒子坐标。这个技巧在节日活动页里很实用比如把“新年快乐”做成粒子聚散效果。function sampleText(text, fontSize, count) { const off document.createElement(canvas); off.width W; off.height H; const octx off.getContext(2d); octx.fillStyle #fff; octx.font bold ${fontSize}px sans-serif; octx.textAlign center; octx.textBaseline middle; octx.fillText(text, W / 2, H / 2); const data octx.getImageData(0, 0, W, H).data; const points []; for (let y 0; y H; y 3) { for (let x 0; x W; x 3) { if (data[(y * W x) * 4 3] 128) points.push({ x, y }); } } const result []; for (let i 0; i count; i) { result.push(points[Math.floor(Math.random() * points.length)]); } return result; }扫描步长 3 比爱心的 4 更密因为文字笔画细步长太大会断笔。fontSize根据画布宽度算一般取W / 8左右。切换形状时只需要把粒子的tx/ty重新赋值粒子会自动飞过去过渡很自然。6.2 伪 3D 透视给粒子加 z 轴纯 2D 粒子是平的加一点 z 轴变化就能有纵深。做法是给每个粒子一个z值绘制时按scale focal / (focal z)缩放位置和大小。function project(p, focal) { const s focal / (focal p.z); return { x: W / 2 (p.x - W / 2) * s, y: H / 2 (p.y - H / 2) * s, size: p.renderSize * s, alpha: p.renderAlpha * s }; }focal是焦距一般取 300~600。z值让粒子在 -100~100 之间缓慢波动就有了前后浮动的感觉。注意 z 越大粒子越小越透明符合近大远小。这个方案不需要矩阵运算性能开销很小但视觉层次提升明显。6.3 参数速查与调试习惯最后给一张参数速查表方便调效果时对照。参数作用推荐范围调大效果COUNT粒子总数400~800更密但更卡ease目标吸引力0.02~0.08到位更快易过冲damping速度阻尼0.82~0.92越大越飘jitter位置抖动2~4 px轮廓更松散R鼠标影响半径60~100 px交互范围更大focal透视焦距300~600越小透视越强我自己的习惯是每改一个参数就刷新看三秒觉得不对立刻回退不要一次改好几个。粒子动画的玄学在于参数之间会互相影响ease 和 damping 一起调很容易调乱。另外调试时把粒子数临时降到 50能看清每个粒子的运动轨迹确认逻辑没问题再调回正常数量。希望帮到你。本文还有配套的精品资源点击获取
返回列表