资讯动态

HTML5 Canvas粒子流动爱心动画实战:从粒子系统到交互复用

发布时间:2026/9/26 12:10:21 来源:尧图企业网站定制
简介面向需要提升Canvas动画开发能力的Web前端开发者这是一份基于HTML5 Canvas实现粒子流动爱心形状动画的完整代码示例。内容围绕粒子系统展开深入演示了Canvas API中路径绘制函数的使用如开始路径、移动坐标、连接线段等以及利用填充颜色绘制爱心轮廓的关键步骤。同时通过粒子对象的位置、速度、透明度等属性管理配合请求动画帧机制持续刷新画面形成从屏幕一端随机出现并向爱心形状流动、逐渐消散的动态视觉效果。压缩包共包含四个文件其中两个JavaScript文件分别负责粒子类定义、粒子数组初始化、状态更新和绘制循环一个CSS文件用于设置画布尺寸、页面背景与整体布局另一个HTML文件作为入口结构。资源包整体仅六KB轻量且便于阅读。目前已有六千三百四十七人学习下载。研读源码可完整掌握粒子系统从定义、初始化到逐帧更新与渲染的流程并理解如何用CSS与Canvas协同增强页面表现力适合前端初学者夯实基础也可作为交互创意特效的参考。1. 散点聚成心形HTML5 Canvas 粒子流动爱心动画到底解决什么问题产品落地页的开场动画、个人主页的动态背景或者一张想发给特别的人的告白卡片满屏细碎粒子先无序漂浮两三秒后像被磁铁吸住一样聚成一颗心形随后又在轮廓上缓缓流动。这个标题所指向的东西——HTML5 Canvas 粒子流动爱心形状动画特效——就是用浏览器原生 Canvas 2D API 写出来的粒子动画不依赖任何第三方库一个 HTML 文件加一段 JavaScript 就能跑起来。它非常适合做网页粒子动态背景、加载动画、H5 小游戏开场屏这类场景对前端初学者最大的价值是能把 canvas 绘图基础、坐标系换算、动画工作流一次摸透对熟手则是做廉价的视觉氛围渲染。下面我按自己落地这类特效的顺序把从零到可复用的完整链路讲清楚。2. 从零搭粒子系统Canvas 基础与动画主循环的两种写法任何 Canvas 粒子动画拆开看都是同一副骨架清屏、更新粒子坐标、把粒子画到画布上然后不断重复这三件事。先把这个骨架跑通再去想爱心形状是这类特效最常见的落地路径。如果你一上来就套用网上的完整工程后边调整参数时会分不清“哪个变量在影响哪个现象”那才是真正的黑匣子。2.1 一个能跑起来的最小粒子系统canvas 绘图与动画主循环先写一个无差别漂浮的粒子demo。这个文件里包含 canvas 初始化、粒子对象、边界反弹和主循环是后续所有功能的地基!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title粒子流动爱心/title style html, body { margin: 0; height: 100%; overflow: hidden; background: #0a0a1a; } canvas { display: block; width: 100vw; height: 100vh; } /style /head body canvas idcanvas/canvas script const canvas document.getElementById(canvas); const ctx canvas.getContext(2d); let width, height; let particles []; const PARTICLE_COUNT 300; function resize() { // 按设备像素比换算避免高分屏模糊稍后单独说明 const dpr window.devicePixelRatio || 1; width window.innerWidth; height window.innerHeight; canvas.width width * dpr; canvas.height height * dpr; canvas.style.width width px; canvas.style.height height px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } window.addEventListener(resize, resize); resize(); class Particle { constructor() { this.x Math.random() * width; this.y Math.random() * height; this.vx (Math.random() - 0.5) * 0.6; // 初始随机速度 this.vy (Math.random() - 0.5) * 0.6; this.size Math.random() * 3 1; // 粒子半径 1~4 } update() { this.x this.vx; this.y this.vy; // 边界回弹暂时替代后续的目标点约束 if (this.x 0 || this.x width) this.vx * -1; if (this.y 0 || this.y height) this.vy * -1; } draw() { ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); ctx.fillStyle #ff6b9d; ctx.fill(); } } for (let i 0; i PARTICLE_COUNT; i) { particles.push(new Particle()); } function tick() { // 清屏用不透明色覆盖整块画布避免上一帧残留 ctx.fillStyle #0a0a1a; ctx.fillRect(0, 0, width, height); for (const p of particles) { p.update(); p.draw(); } requestAnimationFrame(tick); } tick(); /script /body /html这段代码的逻辑很直接resize 里根据窗口大小重置画布物理尺寸Particle 的 update 负责位移draw 用 arc fill 画出圆点tick 里清屏再逐粒子绘制。关键参数是 PARTICLE_COUNT 和速度初始值 0.6粒子数决定画面密度速度决定漂浮的轻快感。速度低于 0.2 时画面近乎静止高于 1.5 时会像受惊的鱼群这两个边界值常见于所有粒子动画。提示清屏时如果改用 rgba(10,10,26,0.2) 这样的半透明色会留下运动拖影效果很迷幻但不是这个标题要的“流动爱心”我在后文不会展开这条支线。2.2 canvas 高分屏适配devicePixelRatio 是第一道门槛很多新手把 canvas.width 直接设置成 window.innerWidth在普通笔记本上看着没事换到 Retina 屏或手机上一看边缘发虚、粒子像隔着一层毛玻璃。原因是 CSS 像素和物理像素在设备像素比devicePixelRatio简称 dpr大于 1 的屏幕上不是一回事。canvas 的 width/height 属性是物理像素style.width/height 是逻辑像素两者必须分开设置。上面的 resize 函数里已经处理了这件事canvas.width width * dpr让绘图缓冲区跟着物理像素走canvas.style.width width px让元素在页面上占据逻辑尺寸最后的ctx.setTransform(dpr, 0, 0, dpr, 0, 0)把坐标系整体放大这样后续所有绘图代码仍按逻辑像素内宽高书写不需要在 draw 里到处乘 dpr。这一步不做后面做爱心形状坐标时所有位置都会偏且不同屏幕上偏移量不一致排查起来非常折磨人。2.3 驱动动画的循环为什么选 requestAnimationFrame 而非 setIntervalsetInterval(fn, 16) 是新手最容易写出的驱动方式但它有两个原生缺陷一是浏览器切到后台标签页时 setInterval 仍在跑占用 CPU二是它不感知显示器刷新率在 120Hz 屏幕上会掉帧或撕裂。requestAnimationFrame简称 rAF是浏览器专为动画设计的主循环 API它会自动匹配显示器刷新率并且在标签页不可见时暂停调用省电也省心。不过 rAF 有一个新手不知道的参数细节回调函数能收到一个 timestamp表示当前帧相对页面加载的毫秒时间。拿它做帧间隔计算才能让动画速度在不同刷新率屏幕上一致。改进后的主循环骨架是let lastTime 0; let rafId null; function tick(timestamp) { // 钳制最大帧间隔为 50ms防止后台切回时粒子瞬移 const delta Math.min(50, timestamp - lastTime || 16); lastTime timestamp; for (const p of particles) p.update(); draw(); rafId requestAnimationFrame(tick); } rafId requestAnimationFrame(tick);这里把timestamp - lastTime钳制到 50ms 是血泪经验如果用户切到其他标签页再回来rAF 恢复后的第一帧 timestamp 与 lastTime 之间可能隔了几秒若不钳制粒子会以几百毫秒的跨度做一次位移直接飞出画布。后续加爱心目标点后这个问题会表现为“爱心爆开”后面第 5 章还要再提一次。3. 把数学公式画成爱心参数方程、坐标映射与目标点分配粒子系统能跑了下一步是决定“粒子要聚成什么形状”。爱心是最适合做演示的形状因为它的参数方程足够简单生成一组坐标点 20 行代码就能搞定。这一章的关键是把数学坐标系里的爱心无损地搬进屏幕坐标系。3.1 心形曲线的选择极坐标心形与经典参数方程的差别网上最常见的两个心形公式一个是极坐标形式r a(1 - cosθ)θ 从 0 到 2π另一个是参数方程形式x 16sin³(t)y 13cos(t) - 5cos(2t) - 2cos(3t) - cos(4t)。我一般用后者因为它生成的心形轮廓更饱满顶部凹陷和底部尖角的位置关系更接近手绘爱心而极坐标心形偏瘦长在屏幕上远看更像水滴。参数方程采样同样简单把 t 从 0 到 2π 等分成 N 份逐个代入即可。代码里我用-y是提前做了 y 轴翻转原因下一节讲function generateHeartPoints(count) { const points []; const step (Math.PI * 2) / count; for (let i 0; i count; i) { const t i * step; const x 16 * Math.pow(Math.sin(t), 3); // x 范围约 -16 ~ 16 const y 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t); // y 范围约 -17 ~ 15 points.push({ x: x, y: -y }); // 翻转 y让爱心在屏幕上正立 } return points; }注意这里我用的是等步长 t不是等弧长。这意味着心形曲率大的地方比如底部尖角采样点更密集画出来的粒子会在这几处略密一些。很多教程会专门做弧长重采样去消除这个不均匀但我的经验是在流动动画里这个密度差反而强化了心形轮廓的“笔锋感”而且实现成本低得多。如果你需要均匀分布可以再做一次累加弧长插值那就是另一套逻辑了。3.2 从数学坐标到屏幕坐标y 轴翻转与自适应缩放数学坐标系的原点在中心、y 轴向上canvas 坐标系的原点在左上角、y 轴向下。这两个差异不处理爱心会上下颠倒、偏到画布右下角。处理分三步翻转 y、缩放、平移。翻转 y 已经在生成点时做了接下来做缩放和平移。心形参数方程的范围大约在 x[-16, 16]y[-17, 15]整体宽度约 32 个单位。我一般取画布短边的 1/50 作为缩放系数再平移到画布中心function mapToScreen(heartPoints, centerX, centerY, scaleFactor) { return heartPoints.map(p ({ x: centerX p.x * scaleFactor, y: centerY p.y * scaleFactor })); } // 实际调用 const scaleFactor Math.min(width, height) / 50; // 心形约占短边 60% 面积 const targets mapToScreen(heartPoints, width / 2, height / 2, scaleFactor);Math.min(width, height) / 50这个经验系数画出来的心形在竖屏和横屏下都保持合适占比。想变大就除以 40想变小就除以 60。这一行是整段动画里最值得调的参数因为它直接决定爱心在页面上的视觉权重。调完之后注意验证一件事屏幕正中心的坐标必须和width/2, height/2对齐否则后续 resize 时会对着偏了的目标点更容易出问题。3.3 目标点分配策略顺序绑定会让成形动画“穿模”粒子有了目标点有了接下来把两者绑定。最直观的做法是particle[i]对应targets[i]但这会让成形过程出现严重的交叉轨迹左上角的粒子飞向右下角的目标点右上角的粒子飞向左下角中间地带全是穿模的线。解决办法是先把目标点数组洗牌再按顺序绑定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]]; // Fisher-Yates 洗牌 } return arr; } // 粒子初始化时 const shuffledTargets shuffle(targets.slice()); particles.forEach((p, i) { p.targetX shuffledTargets[i].x; p.targetY shuffledTargets[i].y; });洗牌之后每个粒子的目标点变成随机分配飞行路径在视觉上互相交错反而形成了“流动”的初步观感。如果想进一步减少交叉可以改用最近邻贪婪配对每个粒子找最近且未被占用的目标点但那样会损失随机性成形后的流动感会发僵。我的习惯是默认洗牌只有在粒子数量少于 200 且边缘抖动明显时才考虑最近邻。4. 流动感从哪来三段式状态机与缓动参数调优爱心成形只是底线要求“流动”才是这个标题打动人的部分。流动感不是粒子简单地从一个点线性移动到另一个点而是先散乱、再收束、最后在轮廓上持续呼吸。这需要一个简单的状态机来控制粒子的运动模式也需要一套弹簧阻尼参数来制造柔顺的速度变化。4.1 三段式状态机漂浮、聚形、环绕的切换逻辑状态机是这类动画最常见的组织方式三个状态分别是FLOAT初始漂浮约 2 秒、FORM向目标点聚形约 3 秒、ORBIT在目标点附近环绕流动无限循环。我用一个状态变量和一个进入时间记录来管理切换const STATE { FLOAT: float, FORM: form, ORBIT: orbit }; let state STATE.FLOAT; let stateTime 0; let rafId null; let lastTime 0; const PHASE_DURATION { FLOAT: 2200, // 漂浮阶段时长 ms FORM: 3200, // 聚形阶段时长 ms }; function switchState(next, now) { state next; stateTime now; if (next STATE.FORM) { // 重新洗牌并绑定目标点保证每次聚形路径不同 const shuffled shuffle(targets.slice()); particles.forEach((p, i) { p.targetX shuffled[i].x; p.targetY shuffled[i].y; }); } }在 tick 主循环里每帧根据timestamp - stateTime判断是否切换FLOAT 走满 2200ms 就切 FORMFORM 走满 3200ms 就切 ORBIT。这个时间配比是我常用的漂浮太短用户还没建立“这是一片粒子海洋”的预期聚形太快收束的视觉冲击力会被削弱。如果你做的是加载页漂浮可以压缩到 600ms让爱心更快出现。注意switchState 里的洗牌只应在进入 FORM 时执行一次不能在每帧 update 里重复执行否则粒子永远追不上一个不断变化的目标点。4.2 让粒子柔软地飞向目标弹簧阻尼模型而非直接插值新手最容易写的聚形逻辑是p.x (p.targetX - p.x) * 0.05也就是每帧往目标点方向移动 5%。这种线性插值的问题在于速度曲线是前快后慢的指数衰减视觉上像“被拽过去”而不是“飘过去”并且粒子到达目标点后仍会有微小抖动残留停不干净。我一般用弹簧阻尼模型加速度指向目标点速度施加阻尼系数系统自然衰减。核心代码function updateParticle(p, dt, state, now) { if (state STATE.FLOAT) { // 漂浮给随机扰动并限制最大速度 p.vx (Math.random() - 0.5) * 0.05; p.vy (Math.random() - 0.5) * 0.05; capSpeed(p, 1.2); } else if (state STATE.FORM) { // 聚形向目标点加速速度越大接近越快 const dx p.targetX - p.x; const dy p.targetY - p.y; p.vx dx * 0.016; // 弹性系数控制加速度 p.vy dy * 0.016; p.vx * 0.92; // 阻尼系数消耗速度 p.vy * 0.92; capSpeed(p, 4); // 距离小于阈值则吸附到目标点消除残抖 if (dx * dx dy * dy 0.5) { p.x p.targetX; p.y p.targetY; p.vx 0; p.vy 0; } } else if (state STATE.ORBIT) { // 环绕以目标点为中心做低频正弦扰动制造“呼吸感” const dx p.targetX - p.x; const dy p.targetY - p.y; p.vx dx * 0.012; p.vy dy * 0.012; p.vx Math.cos(now * 0.001 p.phaseOffset) * 0.12; p.vy Math.sin(now * 0.001 p.phaseOffset) * 0.12; p.vx * 0.94; p.vy * 0.94; capSpeed(p, 1.6); } p.x p.vx * dt * 0.06; p.y p.vy * dt * 0.06; } function capSpeed(p, maxSpeed) { const speed Math.hypot(p.vx, p.vy); if (speed maxSpeed) { p.vx (p.vx / speed) * maxSpeed; p.vy (p.vy / speed) * maxSpeed; } }这里 dt 来自前面第 2 章的 rAF timeStamp 差值乘dt * 0.06是为了把时间单位换算成模拟步长。弹簧系数 0.016 决定“吸力”大小阻尼 0.92 决定速度记忆长短。理解这两个参数就等于理解了整套粒子流动的手感系数越大粒子越快地冲向目标但过冲也越明显阻尼越小粒子越容易在目标点附近来回摆动。吸附阈值 0.5像素距离平方是我试过很多次的经验值小于这个距离直接锁定动画肉眼看不到任何突跳。4.3 流动感的调参表把玄学变成可见的参数调节很多初次接触粒子动画的开发者调“流动感”全靠试一天调出一版第二天又觉得不对。我把常用参数整理成一张表改动画时照着表调能少走大半年弯路参数建议范围作用调大后的观感粒子数量240 ~ 800画面密度与性能越密越像烟雾越稀疏越像星点弹性系数0.010 ~ 0.020聚形阶段的加速度太大则抽搐太小则拖沓阻尼系数0.90 ~ 0.96速度的衰减速度越大越飘逸越小越粘滞最大速度2 ~ 4聚形阶段防止粒子飞过头限制过冲幅度吸附阈值0.25 ~ 1.0到达目标点的判定距离越小收尾越干净扰动幅度0.08 ~ 0.15环绕阶段的呼吸强度越大轮廓越松散调参顺序也很重要先定粒子数量和心形大小再调阻尼让粒子飞行动线符合预期最后加弹性系数微调收束速度。顺序反了会陷入改一个参数坏两个效果的循环。开发时我习惯直接把这三个关键参数放进 URL 查询串比如?count600damping0.92force0.016页面加载时读一遍秒改秒见效不用反复刷编辑器。5. 粒子动画常见问题排查白屏、爱心偏位与帧率崩掉的 5 个原因到了这章全是实打实踩过的坑。每个问题我都按现象、原因、解决三步写方便你直接对着查。这些坑单独看都不难但组合在一起时排查顺序错了会让人怀疑是自己的数学有问题。5.1 打开页面白屏或黑屏canvas 尺寸为 0 与 dpr 适配缺失现象页面打开后一片空白控制台无报错或者只看到背景色看不到粒子。原因排查有两个位置一是 canvas 的 width/height 被设置成 0常见于把 resize 逻辑放在 DOM 结构加载之前执行拿到的是 0 宽高二是只设置了 canvas.width 而没有同步设置 style.width导致画布在页面上占据 0 高度的空间。解决方法是把 resize 逻辑封装并在页面加载完成后先调用一次同时在 resize 里同时设置物理尺寸和 CSS 尺寸function resize() { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); // 用实际占位尺寸兜底 canvas.width rect.width * dpr; canvas.height rect.height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }如果你用 CSS 把 canvas 设成width: 100vw; height: 100vhgetBoundingClientRect 拿到的就是视口真实尺寸比依赖 window.innerWidth 更不容易出错。我现在的写法是 resize 里 display 属性检查加一层canvas 确定非 none 再去取值这个习惯堵住了大量隐藏 bug。5.2 爱心偏出画面中心y 轴翻转与 center 坐标的不同步现象粒子确实聚成了爱心但爱心整体偏向画布右下角或者上下颠倒。原因是第 3 章的坐标映射里只做了缩放和平移漏了 y 轴翻转或者翻转了但中心点用的是width/2, height/2却忘了一开始生成 heartPoints 时还在用数学坐标的原始值。解决时先确认生成点的代码里 y 已经取负再确认 mapToScreen 的 centerX 和 centerY 与画布中心一致。一个快速自检方法是把scaleFactor临时设为 1在控制台输出 points 数组x 的最大值和最小值应该关于 0 对称y 同理。不对称说明参数方程写错了对称但显示偏说明翻转或平移有问题。5.3 聚形完成后粒子仍在“抽搐”弹簧过冲与目标点分配失误现象爱心已经成形但不少粒子在目标点附近小幅抖动像筛子一样停不下来严重的会看到粒子在两三个目标点之间来回弹。第一原因是第 4.2 节的弹簧模型里弹性系数太大或阻尼太小粒子到达目标点后仍带着残余速度冲到对面又被拉回来。解决方法是调高阻尼到 0.94 以上或者把吸附阈值从 0.5 提到 2.0让粒子提前锁定。第二原因是 FLOAT 阶段结束时粒子的初始速度没有被清空带着 m/s 量级的速度进入 FORM 阶段过冲更明显。我习惯在 switchState 切到 FORM 时把所有粒子的速度衰减一次particles.forEach(p { p.vx * 0.2; p.vy * 0.2; });这样既保留了流动的连贯性又防止带着高速进入聚形阶段。这个细节是很多成品动画和 demo 的差别所在Demo 让你看到收束成品让你看到收束得干净。5.4 改变浏览器窗口大小后粒子全部飞走resize 重算与相对坐标现象窗口一缩放粒子四散奔逃爱心瞬间碎掉。原因是 resize 后画布中心变了但粒子的 targetX/targetY 还指向旧的坐标系位置。解决思路是让目标点不存绝对坐标而是存相对中心的比例或者干脆在 resize 事件里重新生成 whole 目标点序列。我用后面这种代码量更少window.addEventListener(resize, () { resize(); // 先更新 width/height 和 canvas 尺寸 scaleFactor Math.min(width, height) / 50; targets mapToScreen(heartPoints, width / 2, height / 2, scaleFactor); const shuffled shuffle(targets.slice()); particles.forEach((p, i) { p.targetX shuffled[i].x; p.targetY shuffled[i].y; }); });注意 resize 事件要加防抖debounce否则连续拖动窗口边缘时会触发几十次重算粒子数量多时卡顿十分明显。防抖的常见实现是 setTimeout 包一层200ms 内重复触发就重置定时器。5.5 设备发烫、后台标签页卡顿主循环没有刹车机制现象页面挂着不动风扇开始转切到后台再回来粒子位置直接乱掉。原因有两层一是 rAF 在后台会自动暂停但转子恢复后没有处理大 delta对应第 2.3 节的钳制二是粒子数量开到 1000 以上时每帧全量 updatedraw 无论如何都会让低端设备吃力。解决手段按优先级排列先把 delta 钳制加上再监听 visibilitychange 暂停整个循环最后适配低电量模式或低帧率设备降低粒子密度document.addEventListener(visibilitychange, () { if (document.hidden) { cancelAnimationFrame(rafId); } else { lastTime performance.now(); rafId requestAnimationFrame(tick); } });性能上还有一个特别容易被忽视的点给粒子绘制加 shadowBlur 或阴影颜色会触发 canvas 的慢路径渲染性能直接掉一半以上。粒子特效想要光晕感正确的做法是用几层半透明圆形叠加模拟或者用离屏 canvas 预渲染一张发光的圆点 sprite每帧 drawImage 复制而不是每帧重新计算阴影。300 粒子加 shadowBlur 可能看不出问题加到 800 粒子时帧率会从 60 直接掉到 20这是最隐蔽的翻车点之一。6. 把爱心动画做成可复用组件参数化、鼠标交互与一个小彩蛋到这一步核心动画链路已经完整。最后一章讲怎么把这套东西从“能跑的 demo”变成“能放进任何页面的组件”。我会把配置集中到一个对象里再加上两个增强交互鼠标划开粒子和点击重新聚形。先做参数化。把所有可调项收进 CONFIG避免散落在代码各处的魔法数const CONFIG { count: 480, floatDuration: 2200, formDuration: 3200, force: 0.016, damping: 0.92, palette: [#ff6b9d, #ff8e53, #ffc3a0], bg: #0a0a1a };读取 URL 参数覆盖成开发习惯后调试效率会高很多。我用一个小函数解析location.search把 count、force、damping 等字段直接映射进 CONFIG这样不同设备上的效果差异能立刻对照。然后是鼠标交互。让粒子被划开核心不是直接改坐标而是给它一个冲量让弹簧阻尼自然把它拉回来canvas.addEventListener(pointermove, (e) { const mouseX e.clientX, mouseY e.clientY; for (const p of particles) { const dx p.x - mouseX, dy p.y - mouseY; const distSq dx * dx dy * dy; const RADIUS 80; if (distSq RADIUS * RADIUS) { const dist Math.sqrt(distSq) || 1; p.vx (dx / dist) * 0.4; // 沿径向推开 p.vy (dy / dist) * 0.4; } } });用速度不用位置是为了保留粒子的惯性轨迹划过之后爱心会像被风吹过的水面一样慢慢复原。最后一个小彩蛋点击鼠标时让动画重新走一遍漂浮到聚形的过程相当于手动触发一次“重生”。实现也很简单把 switchState 先切到 FLOAT再按 CONFIG.floatDuration 延时切回 FORMcanvas.addEventListener(pointerdown, () { switchState(STATE.FLOAT, performance.now()); setTimeout(() { if (state STATE.FLOAT) { switchState(STATE.FORM, performance.now()); } }, CONFIG.floatDuration); });加上这个这份动画就不再是单调的循环播放而是一个可以被用户“打扰”并且能自我修复的小系统。我现在的习惯是任何 Canvas 动效都先做 CONFIG 参数化再做鼠标交互最后才考虑视觉细节——这个顺序帮助我把第 5 章里那些坑变成了一次性成本。把这个文件拿去做网页动态背景、告白卡片或 H5 作业都够用希望你也能跑出自己满意的流动效果少走我走过的弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑