ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Canvas粒子流动爱心动画教程:从零搭建粒子系统与心形曲线动效

Canvas粒子流动爱心动画教程:从零搭建粒子系统与心形曲线动效 简介一款面向Web前端开发者与动画爱好者的HTML5 Canvas粒子动画特效围绕红色粒子流动汇聚成爱心形状这一核心效果展开。资源基于Canvas API实现路径绘制与粒子系统开发者可从中学习如何用beginPath、moveTo、lineTo绘制心形轨迹并通过粒子的位置、速度、透明度等属性动态变化配合requestAnimationFrame驱动动画循环营造持续流动且逐渐消散的视觉体验。压缩包内含4个文件包括2个JavaScript脚本、1个CSS样式及1个HTML页面JS负责粒子逻辑与交互控制CSS用于调整画布布局与页面背景HTML作为整体容器整体体积仅6KB结构轻量、便于阅读和二次修改。目前已有6347人浏览/学习适合用于网页情人节主题展示、前端入门案例教学或Canvas动画玩法探索。1. HTML5 Canvas 粒子流动爱心动画从“会动”到“流动”差在哪第一次在 HTML5 网页上看到粒子流动爱心动画很多人会以为这是提前录好的视频在循环播放。其实它只是靠 Canvas 画布、上千个圆形粒子和每一帧的坐标更新把随机撒出的散点逐渐聚合成一颗爱心聚合过程保留轨迹看起来就是大量光点往轮廓里流动。相比 CSS 动画只能控制整体位移Canvas 粒子动画把控制权下沉到了每个粒子身上因此特别适合做网页粒子动态背景、表白页、HTML5 网页设计作业的首页动效也能当 loading 动画用。这篇笔记会直接从空画布讲起把粒子对象设计、绘制循环、心形采样和连线参数一次性走通保证你能自己跑出第一版。2. 用 Canvas 从零搭粒子系统初始化、坐标更新与绘制循环2.1 先设计粒子对象坐标、速度和生命周期分开存粒子系统的核心思维是“不等待”每个粒子都要独立地、逐帧地推演自身状态。一个粒子至少应该记录四个信息当前坐标 x、y运动方向速度 vx、vy以及外观上的半径 size 和透明度 alpha。颜色这一项可以做成单个粒子的固定属性也可以从一组调色板里随机取取决于你想要的视觉一致性我一般建议先固定颜色跑通后再改。我习惯把“速度”单独拆成一个字段而不是直接把 vx、vy 当作每帧位移来用。很多第一次写粒子动画的人会把速度字段当位移去累加一旦后续想引入阻尼、弹性吸引数据结构就得推倒重来。把速度独立出来后每帧就可以用“目标位置减去当前位置乘以一个系数”算出加速度再把这个加速度累加到速度上。这个思路和粒子群算法很像粒子群算法本质上是把每个粒子看成一个候选解让它们朝全局最优位置靠拢而爱心粒子动画做的其实是同一件事——把落点均匀分布在心形曲线上让粒子们“飞”过去。你可以把这里理解为粒子群算法原理的极简可视化。起步阶段先建一个空项目HTML 里只放一个 canvas 标签和一段 script不要上来就引入任何框架。!-- index.html 的关键结构canvas 不设内联宽高统一在 JS 里初始化 -- canvas idheartCanvas/canvas// main.js粒子对象初始化 const canvas document.getElementById(heartCanvas); const ctx canvas.getContext(2d); const PARTICLE_COUNT 1200; const particles []; for (let i 0; i PARTICLE_COUNT; i) { particles.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, // 目标点后边心形采样完成后写入 targetX: 0, targetY: 0, vx: 0, vy: 0, // 半径和透明度是视觉差异化的重要来源 size: Math.random() * 1.6 0.6, alpha: Math.random() * 0.5 0.3 }); }这里用了一个懒散做法初始 x、y 用均匀随机分布先把 1200 个粒子铺满整个画布targetX、targetY 暂时为空等第 3 章填进来。size 随机到 0.62.2 之间这个区间不建议再大否则粒子还没汇聚时画面会糊成一团alpha 控制在 0.30.8透明度太低看不见太高又缺少光点质感。需要注意 canvas.width 和 canvas.height 是绘图缓冲区的实际分辨率而 CSS 里的 width、height 控制的是页面展示尺寸。二者不一致时粒子动画在普通屏幕上可能看不出来在 Retina 屏上就会整体模糊这个坑后面会单独展开。2.2 主循环更新坐标、清空画布、重绘Canvas 动画的骨架是一个每帧被调用的函数。Modern 浏览器里最稳的是 requestAnimationFrame它和屏幕刷新率同步通常稳定在 60 帧不推荐用 setInterval 或 while 延时因为标签页切到后台时setInterval 依然会唤醒 CPU白白耗电。function animate() { // 先清空整块画布 ctx.clearRect(0, 0, canvas.width, canvas.height); for (let i 0; i particles.length; i) { const p particles[i]; // 目标点已经生成后用阻尼模型朝目标运动 if (p.targetX ! 0 || p.targetY ! 0) { const force 0.08; p.vx (p.targetX - p.x) * force; p.vy (p.targetY - p.y) * force; p.vx * 0.92; // 阻尼系数控制“弹”的幅度 p.vy * 0.92; p.x p.vx; p.y p.vy; } ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fillStyle rgba(255, 120, 150, ${p.alpha}); ctx.fill(); } requestAnimationFrame(animate); } requestAnimationFrame(animate);这个版本的力公式用的是“直接逼近”式的修复把当前位置和目标位置之差乘以 0.08当作当前帧的加速度再乘 0.92 做阻尼。0.08 是速度强度越小轨道越慢0.92 是减速比越小越“涩”粒子几乎是贴地滑过去。两个参数同时调大画面会出现一种很烦人的效果——粒子在目标点附近不停抖动像喝多了酒站不稳通常被称为“过冲”。出现这种情况时把 force 降到 0.05阻尼调到 0.95 附近就会稳定。还需要解释一个细节fillStyle 必须写在粒子循环内部不能提出来一次性设好。因为每个粒子的透明度不同一旦把 fillStyle 提到循环外面最终只会得到一片不透明的色块整个爱心会变成实心填充。2.3 先跑一个基础版本验证绘制循环是否成立在还没写入心形目标点的阶段这段代码直接运行画布上就是 1200 个原地不动的随机小圆点。有些人会在这个阶段怀疑代码写错了其实这正是要的结果先确认渲染循环没问题再往系统里加目标坐标。这个步骤一定不能省否则后边出现“画布空白”你很难判断是粒子对象没建成功还是 canvas 上下文初始化失败。验证方法很简单在 animate 函数的第一行临时加一句 console.log(particles.length)打开浏览器控制台计数打印正常说明 Canvas 上下文和对象数组都没问题。接下来再关心心形采样这才是粒子流动动画的第二个核心步骤。3. 散点成形的关键心形曲线采样与目标点分配3.1 心形参数方程先算公式再谈坐标要让粒子最后排成爱心造型而不是拼一张爱心图片最干净的做法是用数学公式生成目标点集合。这里采用经典的心形参数方程x 16 * sin(t)^3y 13 * cos(t) - 5 * cos(2t) - 2 * cos(3t) - cos(4t)t 从 0 到 2π 变化。这条曲线画出来的爱心轮廓偏胖适合粒子密度高的场景想要瘦一点可以把 x 系数 16 改成 14y 系数 13 改成 12但两个系数不能相差过大否则心尖会过扁。特别提醒Canvas 的 y 轴向下为正直接用公式算出来的 y 值在屏幕上显示是上下颠倒的爱心会变成“倒挂”的模样。这是最容易翻车的第一步。我在生成目标点时做了翻转处理function generateHeartTargets(count) { const targets []; for (let i 0; i count; i) { // t 在 0 到 2π 里随机取值保证点在整条曲线上分布 const t Math.random() * Math.PI * 2; 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); targets.push({ x: x * 12 canvas.width / 2, y: -y * 12 canvas.height / 2 }); } return targets; }这里的乘 12 是缩放因子越大整颗爱心越大y 取负号之前在变量里保留原始公式的计算结果再整体翻转。如果你把负号直接塞进公式里心形会变成镜像形状左右两边反过来处理起来很别扭。3.2 采样密度不均匀往曲线的“稀疏段”补点用 Math.random() 均匀采样 t真的能得到均匀分布在曲线上的点吗不能。原因很简单t 均匀分布并不等价于曲线长度均匀分布。爱心曲线的左上区有一段靠近“凹陷”的弧参数每变动一点对应的实际长度就变大于是那段区域的粒子密度会明显低于心尖。如果直接放手最终画面上的爱心会像一个被晒干瘪的气球有的区域密密麻麻有的区域稀疏破洞。常见做法有两种。第一种是采样数量加倍然后做密度剔除把相邻点距离小于某个阈值的点删掉。第二种是固定 t 的步长采样比如让 t 从 0 到 2π 等间距取 4000 个点再随机从中抽取 1200 个。第二种在工程里更受欢迎因为它能精确控制点总数又不依赖额外循环。function generateHeartTargets(count) { // 先按固定步长生成稠密样本 const denseSamples []; const step (Math.PI * 2) / 4000; for (let t 0; t Math.PI * 2; t step) { 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); denseSamples.push({ x, y }); } // 从 4000 个稠密样本里随机抽 count 个 const targets []; for (let i 0; i count; i) { const idx Math.floor(Math.random() * denseSamples.length); targets.push({ x: denseSamples[idx].x * 12 canvas.width / 2, y: -denseSamples[idx].y * 12 canvas.height / 2 }); } return targets; }step 定为 2π/4000整条曲线得到 4000 个样本点视觉上已经足够平滑。随机抽取 1200 个效果比直接在连续 t 上随机取值要均匀得多至少不会出现某段曲线一个点都没有的破洞。这个“先加密再抽稀”的思路对绘制其他不规则图形路径同样通用。3.3 分配目标坐标顺序要打乱避免连续配对现在把生成的 1200 个目标点分配给 1200 个粒子看似是数组下标一一对应其实不行。如果按粒子初始顺序和目标点顺序直接配对会出现一个非常丑的现象粒子群整体平移、旋转但相互之间维持着初始相对位置画面就是“一坨”在动毫无流动感。所以我会先把 targets 数组打乱再逐个赋值给粒子。打乱用 Fisher-Yates 洗牌随机性比 sort(() Math.random() - 0.5) 更好控制后者受 JS 排序稳定性影响分布规律很容易看出来。function shuffle(arr) { for (let i arr.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [arr[i], arr[j]] [arr[j], arr[i]]; } return arr; } const shuffled shuffle(generateHeartTargets(PARTICLE_COUNT)); particles.forEach((p, index) { p.targetX shuffled[index].x; p.targetY shuffled[index].y; });到这里把动画循环一启动就能看到一堆随机撒点慢慢聚合成爱心的过程。聚合速度由 force 和阻尼共同决定如果想让“流动感”更持久可以把 force 从 0.08 降到 0.04让整个过程拉长到 34 秒。到这一步一个不闪屏、不散架的基础爱心已经成立了。4. 让爱心“流动”起来连接线、正弦扰动与拖尾参数4.1 点与点连线距离阈值定网格质感静态爱心成型后还得处理“流动感”。市面上绝大多数粒子特效不会只画实心圆点而是像蛛网一样距离近的粒子之间连一条线也就是常说的连线效果。这能强化粒子的存在感也让画面从一堆零散光点变成有结构层次的整体。实现逻辑是二重循环每两个粒子之间算一次距离。但 1200 个粒子做两两判断会超过 70 万次距离计算放到 60 帧循环里并不轻松。所以生产环境要限制范围距离超过阈值的直接跳过。我常用阈值是 80 像素。function drawLines(particles, threshold) { for (let i 0; i particles.length; i) { const a particles[i]; for (let j i 1; j particles.length; j) { const b particles[j]; const dx a.x - b.x; const dy a.y - b.y; const dist dx * dx dy * dy; if (dist threshold * threshold) { const opacity (1 - dist / (threshold * threshold)) * 0.25; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.strokeStyle rgba(255, 180, 200, ${opacity}); ctx.lineWidth 0.6; ctx.stroke(); } } } }距离比较直接用平方 dist省掉每次开方这是 Canvas 动画里很实用的小优化。阈值取 80 时心形内部粒子密集会产生密集网格外围只有稀稀落落的短线。想要更干净的“星图感”阈值降到 60 即可。线条透明度我控制在 0.25 封顶太亮会抢掉粒子本身的视觉权重。4.2 正弦扰动与相位差让粒子不必走直线如果只做到这里粒子的运动轨迹几乎是一条直线每个粒子直挺挺冲向目标点像射箭一样。短动画里没问题循环播放 10 秒以上观感就很单调。很多人会在这时候瞎调参数最后要么整颗爱心散了要么看起来像晕船。我的做法是给每个粒子的绘制坐标加一个随时间变化的正弦扰动x 轴幅度先给 36 像素频率给 1.22.0。扰动值必须偏小否则粒子会明显偏移爱心轮廓。const timeOffset Date.now() / 1000; for (const p of particles) { const wobble Math.sin(timeOffset * 1.5 p.size * 20) * 3; const drawX p.x wobble; const drawY p.y Math.cos(timeOffset * 1.2 p.size * 30) * 2; ctx.beginPath(); ctx.arc(drawX, drawY, p.size, 0, Math.PI * 2); ctx.fillStyle rgba(255, 120, 150, ${p.alpha}); ctx.fill(); }注意我并没有修改 p.x 和 p.y只修改了绘制坐标 drawX 和 drawY。这样做的好处是粒子的目标位置不受扰动影响扰动只改变视觉表现不会让粒子在飞行途中偏离到回不来。p.size 作为相位偏移因子可以让不同粒子的扰动节奏错开避免一整排同步晃动。幅度 3 像素左右时爱心轮廓会有轻微呼吸感像心在持续跳动如果你是在做 loading 动画幅度反而要压到 1跳动太明显的 loading 会打扰用户。4.3 拖尾用半透明背景覆盖代替 clearRect纯 clearRect 清空后每帧都是全新一笔粒子的运动路径完全消失只剩同一时刻的点。想要“流动”尾迹就要反其道而行不清空画布改为画一层半透明底色盖住上一帧。上一帧的内容会非常缓慢地消隐视觉上就成了拖尾。// 用 rgba 半透明背景覆盖代替 clearRect上一帧内容会淡出 ctx.fillStyle rgba(20, 20, 30, 0.25); ctx.fillRect(0, 0, canvas.width, canvas.height); // 之后照常画粒子和连线这里的 0.25 是拖尾长度系数值越小拖尾越长残影留存越久值越大上一帧几乎立刻消失效果等同 clearRect。常见设置是 0.150.3 之间以不影响线条清晰度为标准。有一个坑要提前说残影是半透明的旧点会在后续帧被反复覆盖颜色一层层加深最后形成脏脏的色块。所以使用拖尾后要把粒子自身的透明度适当调低让残影能干净地淡出。4.4 连线也参与拖尾残影叠加的透明度控制拖尾只体现在粒子上还不够如果线条没有残影画面会有一种拼接感点有轨迹线没有轨迹。实际上因为画布没被 clear连线也会自动留下残影这条天然成立不需要额外处理。但线条透明度过低时多帧叠加后会把画布整体刷上一层淡红色观感像没擦干净的粉底。我处理的办法是把连线线宽保持在 0.5透明度封顶降到 0.2整体内敛一些。另外一个技巧是把连线的循环频率从“每帧”改成“每两帧执行一次”线条会有极其轻微的跳动感肉眼几乎察觉不到但性能回升明显。这个方案在粒子数高到 3000 以上时特别好用。关于“流动”的视觉效果说白了点、线、拖尾三者的透明度互相牵制没有标准答案自己跑一遍找到整体干净又连绵的那个区间。5. 粒子动画避坑最常让 Canvas 特效翻车的五个细节5.1 爱心整体倒挂y 轴方向搞反了现象粒子聚合后爱心上下颠倒心尖朝上而不是朝下。原因数学坐标系 y 轴向上为正Canvas 的 y 轴向下为正直接套心形公式会导致 cos 相关项的方向反掉。解决生成目标点时要先算完原始 y 值再做一次 y 取负顺序不能反。如果你在公式内部就把负号塞进去翻转后的形状会左右镜像很难一眼看出来建议第一次写完先打印 3 个目标点的坐标核对。5.2 拖动窗口后爱心错位或者直接卡死现象页面 resize 之后爱心位置跑到画布外或者粒子原地不动。原因canvas 的宽高通常只在初始化时设置过一次窗口尺寸变化后坐标仍沿用旧值超出画布的部分被裁剪看似动画“消失了”。解决监听 window resize 事件重新读取 canvas.clientWidth 和 clientHeight同步更新 canvas.width 和 canvas.height。如果不想重建整个粒子系统就把所有粒子坐标按新旧尺寸比例等比缩放并在 resize 回调里加去抖处理避免连续触发导致画面闪烁。5.3 粒子一多掉帧尤其开了连线之后现象粒子数从 1200 提到 3000帧率从 60 掉到 30手机端更夸张。原因每帧执行两层 for 循环3000 个粒子的两两距离计算接近 450 万次半透明背景覆盖的填充又占掉一部分主线程时间。解决至少从两个方向下手。第一个是减少无效距离计算两层循环里先比较两个点的 x 方向差值和 y 方向差值只要任何一个大于阈值这一对直接跳过省略平方和计算。第二个是降低连线阈值1200 个粒子时阈值保持 803000 个粒子时我会降到 50 以下能连上的点对数量大幅减少画面依然自然。还有一个思路连线每两帧执行一次肉眼几乎看不出跳变CPU 占用率却能降下一大截。5.4 粒子到目标点之后来回乱弹爱心“颤抖”现象粒子全部到达后并不停住而是在目标点附近持续振荡整颗爱心看起来像在发抖。原因force 和 damping 配比失调。force 给的速度增量过大阻尼又没办法及时吸收掉剩余动能粒子就在目标点附近反复横跳。解决调大阻尼系数到 0.94 以上同时把 force 压到 0.05 以下。如果还抖再加一个最小位移阈值粒子与目标点距离小于 0.5 像素时直接把坐标强制设为目标值vx、vy 归零彻底止住抖动。阈值不能太大否则粒子会提前“冻结”在半空看起来反而像卡帧。5.5 iOS Safari 上导出 canvas 图片是白图现象桌面浏览器和安卓浏览器里 canvas.toDataURL 能正常导出iOS Safari 上导出的结果是全白。原因iOS 上 canvas 尺寸如果超过内存上限内部缓冲区会被重置成空白动画未暂停时直接调用 toDataURL也可能读到空帧。解决导出前先暂停动画循环等两到三帧再调用 toDataURL。同时把 canvas.width 控制在 2048 以内。如果画面使用了半透明底色覆盖式的拖尾绘制导出时先临时补一层不透明底色再执行导出能规避因为历史像素未完全写入导致的空白帧。6. 进阶一步用参数面板把爱心动画调到能安心上线走到这一步一个能上线的 Canvas 粒子爱心动画其实已经很接近了还差一个能长期维护的接口。我会把核心参数抽成一个配置对象粒子数量、聚合速度、扰动幅度、连线阈值、拖尾长度全放在一起这样不动代码也能快速调出不同气质。const CONFIG { particleCount: 1200, force: 0.06, // 想让动画缓慢流动就往小调 damping: 0.94, // 越大越不弹 wobbleAmplitude: 3, // 想让轮廓“呼吸”就轻微加 lineThreshold: 80, // 连线阈值值越小线条越少 trailAlpha: 0.25 // 拖尾长度值越小拖尾越长 };把 CONFIG 里的字段接到前几章的对应位置再用浏览器开发者工具实时修改一分钟后就能看到差异。另外爱心动画通常不会只在一台设备上展示建议至少测一遍 iPhone 的 Safari、安卓的 Chrome 和桌面浏览器尤其是真机场景。很多开发者电脑上调完直接上线手机上颗粒感严重十有八九是 canvas 分辨率没按设备像素比做适配。常见写法是const dpr window.devicePixelRatio || 1; canvas.width canvas.clientWidth * dpr; canvas.height canvas.clientHeight * dpr; ctx.scale(dpr, dpr);这段代码放在初始化时resize 相关逻辑里再执行一次。设置之后画布实际分辨率同步跟上屏幕粒子边缘才会清晰Retina 屏的模糊问题也顺带解决。关于动画质量的验证我的习惯是开一个循环 30 秒、连续播放 5 遍期间观察三件事粒子从散点到聚合是否在 3 秒左右完成向心运动过程中线段是否出现过冲拖尾淡出后能否在一段时间内恢复清晰画面。整个过程不需要额外工具肉眼加控制台就够。做这类特效最需要注意的是参数之间的平衡而不是追求某一项数值的极端。希望这套从零搭建的方式能帮你快速跑通第一版并在之后的定制里少走一点弯路。本文还有配套的精品资源点击获取
返回列表