资讯动态

Canvas+SVG协同实现水泡分裂动画:双图层分工与状态机设计

发布时间:2026/9/14 12:32:07 来源:尧图企业网站定制
简介一份基于HTML5 Canvas与SVG的彩色水泡分裂动画特效源码代码量虽小却完整实现了从绘制到交互的动画链路非常适合前端学习者、动画爱好者及Web交互视觉工程师用来研究两种图形技术的配合方式。压缩包本身只有3KB没有多余文件下载后即可运行调试目前已有174人学习浏览。源码中 Canvas 负责绘制可动态变化的水泡主体SVG 负责补充高清边缘与高光细节而 JavaScript 则控制水泡的移动、碰撞和分裂逻辑并通过样式属性让水泡呈现丰富的色彩层次。此外使用 requestAnimationFrame 保证动画帧率稳定利用离屏Canvas 分担主线程渲染压力这些优化思路都值得在真实项目中借鉴。由于代码精简且无复杂依赖适合逐行阅读作为快速上手 CanvasSVG 混合编程的示例再合适不过。无论是做网页动态背景还是学习图形交互都能从中获得直接启发。1. 为什么「彩色水泡分裂动画」值得把 Canvas 和 SVG 放在同一张画布上处理过 html5 动画特效源码的人大概都有这种经历点开一个漂亮的水泡交互页面以为全是 Canvas 画的打开 DevTools 一看DOM 树里躺着一堆path和circle。这类「CanvasSVG 混排」的项目在源码包里非常常见而彩色水泡分裂恰好是解剖这种协作方式的最佳样本。水泡本体需要高频重绘适合 Canvas分裂瞬间的膜层碎片、高光轮廓和装饰贝塞尔路径用 SVG 定义一次、反复引用成本远低于逐帧画像素。这篇文章要讲的是水泡动画里 Canvas 和 SVG 各自管什么、分裂逻辑怎么用状态机收敛以及把这份源码改造进自己项目时的参数边界。适合想读源码但不想被 demo 带偏的进阶前端也适合要自己从零写一发气泡交互动效的工程师。2. 用 Canvas 绘制水泡主体从「一个圆」到「会呼吸的球」2.1 水泡的最小绘制单元径向渐变与高光偏移水泡和普通圆形按钮的区别在于它需要透光、有厚度、高光位置固定而且颜色是彩色半透明的。常见实现是ctx.createRadialGradient制造内外两层折射再叠加一个偏移的椭圆高光function drawBubble(ctx, bubble) { const { x, y, r, hue, alpha } bubble; // 内层光晕颜色随 hue 变化半透明模拟薄膜折射 const glow ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.1, x, y, r); glow.addColorStop(0, hsla(${hue}, 85%, 92%, ${alpha})); glow.addColorStop(0.55, hsla(${hue}, 80%, 75%, ${alpha * 0.55})); glow.addColorStop(1, hsla(${hue}, 70%, 55%, ${alpha * 0.18})); ctx.fillStyle glow; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); // 左上高光一个更小、更实的椭圆方向固定模拟顶部光源 ctx.save(); ctx.beginPath(); ctx.translate(x - r * 0.3, y - r * 0.4); ctx.scale(1, 0.62); ctx.arc(0, 0, r * 0.32, 0, Math.PI * 2); ctx.fillStyle hsla(${hue}, 100%, 98%, ${alpha * 0.85}); ctx.fill(); ctx.restore(); // 底部一圈反光避免水泡看起来像贴纸 ctx.beginPath(); ctx.arc(x, y, r * 0.92, Math.PI * 0.15, Math.PI * 0.85, false); ctx.strokeStyle hsla(${hue}, 90%, 95%, ${alpha * 0.35}); ctx.lineWidth Math.max(1, r * 0.045); ctx.stroke(); }绘制顺序是有讲究的先铺渐变底再画左上高光最后补底部反光。高光要用save/restore包住因为translate和scale会影响后续绘制。参数里hue用色相角控制「彩色」alpha随水泡生命周期衰减r * 0.35和r * 0.4控制高光相对圆心偏移这两个值在源码包里经常被改成随机量让同一批水泡高光位置略有差异视觉上更接近自然气泡而非统一模板。2.2 分裂触发判定半径阈值、生命周期与随机抖动水泡不会自己一直运动需要一套更新循环决定「什么时候炸开」。常见的触发条件有三种被鼠标点中、碰到画布边缘、生命周期到达随机上限。三种条件最终都走到同一个入口——splitBubblefunction updateBubble(bubble, dt) { bubble.age dt; bubble.phase dt * bubble.vibration; // 1. 半径随机脉动让水泡呼吸 bubble.drawRadius bubble.r * (1 0.035 * Math.sin(bubble.phase)); // 2. 受浮力和阻尼影响方向朝上但速度不断衰减 bubble.vy - 12 * dt; // 模拟浮力 bubble.vx * (1 - 0.6 * dt); bubble.vy * (1 - 0.6 * dt); bubble.x bubble.vx * dt; bubble.y bubble.vy * dt; // 3. 生命周期到期、缩小到临界半径或碰到边界时才分裂 const shrinkLimit bubble.r 6; const ageLimit bubble.age bubble.maxAge; if (shrinkLimit) { bubble.state dying; } else if (ageLimit || isOutOfBounds(bubble)) { bubble.state split; } }vibration是呼吸频率0.035是半径摆幅系数把摆幅改成0.06以上水泡会明显抖动改小则接近静止。vy - 12 * dt是简化的浮力模型真实水泡浮力会随深度变化这里只保证「往上飘」的观感。需要留意的坑是r 6的判定放在最前面因为分裂出的子水泡半径很小如果继续走ageLimit分支会陷入「无限分裂」这是此类源码最常见的递归失控点。2.3 为什么水泡主体用 Canvas 而不是纯 SVG选型理由可以从一个实际数字出发动画特效要维持 60fps而一次分裂会从 1 个水泡变成 36 个数量是倍增的。SVG 每个水泡至少要 23 个节点圆、高光、膜层200 个水泡就意味着几百个 DOM 节点参与每一帧的属性更新。下表是常见结论对比项Canvas 负责主体SVG 只做装饰层每帧更新方式像素重绘无节点成本只有少量节点改 attribute其他静态水泡数量上限几百个仍能维持流畅帧率超过一定节点数明显掉帧事件命中检测需要手写distance r判断自带 pointer-events分辨率适配canvas 尺寸变化需要重绘矢量缩放无压力所以要被点击的水泡用 Canvas 加数学判定装饰性高光、膜层碎片才用 SVG 挂上去。这也是这个源码叫「CanvasSVG 双实现」而不是「二选一」的原因——两个引擎各自承担自己擅长的更新频率。3. SVG 层负责「看得见的细节」高光、膜层与装饰 path3.1 先搭出「Canvas 主层 SVG 装饰层」的页面结构打开 zip 源码包常见结构是index.html里放一个绝对定位的容器Canvas 和 SVG 互相重叠、坐标系对齐div classbubble-stage styleposition: relative; width: 100%; height: 100vh; overflow: hidden; canvas idbubbleCanvas styleposition: absolute; inset: 0; width: 100%; height: 100%; display: block;/canvas svg iddecorLayer viewBox0 0 800 600 preserveAspectRatioxMidYMid slice styleposition: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none;/svg /div关键参数有三个viewBox0 0 800 600建立与 Canvas 坐标系对应的逻辑尺寸preserveAspectRatioxMidYMid slice保证 SVG 层在窗口宽高比变化时跟随 Canvas 一起裁切pointer-events: none让 SVG 装饰层不拦截鼠标事件点击事件仍然落在 Canvas 上。如果漏掉第三个点击水泡时会莫名失效这是读这类源码最容易卡住的点。3.2 用 SVG path 在不重绘 Canvas 的前提下补膜层纹理水泡主体是半透明的但「裂开」的瞬间需要一些非常细的、随机的膜层碎片如果让 Canvas 每帧重绘这些碎片代码量会成倍增加。常见做法是把碎片预先做成path放进defs分裂时只改变transformdefs path idmembranePatch dM0,0 C4,-6 10,-5 12,-1 C15,4 9,9 3,7 C-1,5 -3,3 0,0 Z fillrgba(255,255,255,0.4) strokergba(255,255,255,0.7) stroke-width0.6/ /defs这条 path 的 d 写得刻意不规则两个C曲线段分别控制上缘外凸和下缘回收首尾闭合形成一片「被撕裂的薄膜」。实际使用时把它复制成 46 个use节点各自设置不同的旋转角度和缩放use href#membranePatch x0 y0 transformtranslate(398,296) rotate(-24 0 0) scale(0.6)/ use href#membranePatch x0 y0 transformtranslate(421,305) rotate(77 0 0) scale(0.82)/transform里的顺序有讲究translate决定碎片出现在哪个分裂点rotate后面的0 0是旋转中心的局部坐标先平移后旋转才能让碎片围绕自身中心旋转。很多人把旋转放在 translate 前面碎片会沿画布原点公转瞬间飞出屏幕。3.3 坐标同步把 Canvas 里算出的水泡位置喂给 SVG 节点SVG 层不是独立运动的它要跟随 Canvas 里的水泡坐标。实现上不要每帧查 DOM而是维护一个「水泡 id → SVG 节点」的映射表const decorMap new Map(); function syncDecorToBubble(bubble) { let node decorMap.get(bubble.id); if (!node) { node createDecorNode(bubble); decorMap.set(bubble.id, node); } // 只改 transform不改 x/y 属性减少样式重算 node.setAttribute(transform, translate(${bubble.x}, ${bubble.y})); node.setAttribute(opacity, String(bubble.alpha)); } function createDecorNode(bubble) { const use document.createElementNS(http://www.w3.org/2000/svg, use); use.setAttribute(href, #membranePatch); use.setAttribute(width, 16); use.setAttribute(height, 16); document.getElementById(decorLayer).appendChild(use); return use; }两个细节值得注意一是用createElementNS而不是createElementSVG 节点必须显式声明命名空间否则会被浏览器当成 HTML 元素表现异常二是只更新transform和opacity这两个属性浏览器对这两个属性的变更可以走合成器进程不会触发完整的布局和绘制。同步函数应放在 Canvas 绘制完成之后调用避免装饰比主体早出现造成撕裂感。4. 分裂算法与状态管理让水泡「裂得自然」4.1 状态机NORMAL、SPLIT、DYING 之间的流转水泡的行为不是一个布尔开关能描述的。一次分裂要经历「等待 → 生成子泡 → 父泡淡出」三个阶段如果父泡直接消失再凭空出现子泡动画会显得生硬。源码包里的常见实现是显式维护一个状态字段const BubbleState { NORMAL: normal, SPLIT: split, DYING: dying }; function tick(bubble, dt) { if (bubble.state BubbleState.SPLIT) { bubble.splitTimer dt; bubble.alpha * 0.985; // 父泡快速淡出 if (bubble.splitTimer 0.2) { spawnChildren(bubble); // 只有一次真正生成子泡 bubble.state BubbleState.DYING; } } if (bubble.state BubbleState.DYING) { bubble.alpha - dt * 2.2; if (bubble.alpha 0) removeBubble(bubble); } }SPLIT状态里刻意停留 0.2 秒而不是立刻生成子泡这段时间屏幕上会先看到父泡「鼓起」的征兆。spawnChildren必须保证只执行一次常见的 bug 是在循环里反复调用导致子泡数量指数级爆炸。DYING阶段不绘制高光、不再参与碰撞只保留一个收缩的圆让删除动作从视觉上不那么突兀。4.2 子水泡生成规则面积守恒才有真实感水泡分裂看起来很物理但大多数 html5 动画特效源码并没有真的做流体力学而是用「面积守恒」来模拟体积守恒。父泡半径是 R生成 n 个子泡每个子泡半径由下式给出function spawnChildren(bubble) { const n 3 Math.floor(Math.random() * 4); // 3~6 个子泡 const area Math.PI * bubble.r * bubble.r; const perArea area / n; for (let i 0; i n; i) { const childR Math.sqrt(perArea / Math.PI) * (0.85 Math.random() * 0.3); const angle (Math.PI * 2 / n) * i (Math.random() - 0.5) * 0.6; const dist bubble.r * (0.95 Math.random() * 0.25); bubbles.push({ id: nextId, x: bubble.x Math.cos(angle) * dist, y: bubble.y Math.sin(angle) * dist, r: childR, hue: (bubble.hue 18 Math.floor(Math.random() * 24)) % 360, vx: bubble.vx Math.cos(angle) * (30 Math.random() * 40), vy: bubble.vy Math.sin(angle) * (30 Math.random() * 40), alpha: 0.9, state: BubbleState.NORMAL // 其他字段省略 }); } }子泡半径加了个0.85 rand * 0.3的随机系数所以 n 个子泡的面积之和不严格等于父泡而是略有波动这反而是真实感来源——完全等分的分裂看起来像程序生成的六边形网格。angle的基础分布是均匀的再叠加随机抖动避免子泡排成正多边形。hue在父泡色相附近偏移 1842 度是「彩色」两个字的重点如果子泡完全继承父泡颜色画面会越分越单调偏移后则能在保持色调统一的同时持续产生新鲜感。4.3 五个必调参数与调参方向这类源码最有价值的部分不是绘制函数而是那组控制随机分布的常数。下表列出手动调参时最常用的五个位置及其效果方向参数位置取值范围效果方向浮力加速度vy -6 ~ 20越小水泡飘得越慢适合做页面背景越大越有喷涌感分裂子泡数3 rand*42 ~ 8数量越多画面越碎注意面积守恒计算会随 n 改变高光偏移r * 0.350.2 ~ 0.5调小高光居中像实心球调大高光跑出边缘像玻璃反光色相偏移18 rand*240 ~ 60偏移大则分裂后颜色差异明显适合彩色主题splitTimer停留时间0.1 ~ 0.4 s太短看不到征兆太长子泡出生有延迟感调参时的判断依据不是数值本身而是三类视觉反馈水泡是否像「被戳破」而不是被替换、子泡出生时是否沿父泡边缘散布、整体颜色是否在分裂后保持原有色调又互相区分。优先动分离度最大的参数——先改分裂数量再改色相偏移最后微调浮力。5. 验证与改造一小时上手这份「水泡分裂动画源码包」5.1 用帧率统计验证 Canvas 与 SVG 的分工边界拿到源码包先别急着改参数先加一段 20 行的帧率统计确认瓶颈在 Canvas 绘制还是 SVG 节点更新let frames 0, lastTime performance.now(); function frameLoop(t) { frames; const elapsed t - lastTime; if (elapsed 1000) { const fps frames * 1000 / elapsed; console.log(fps:, fps.toFixed(1), bubbles:, bubbles.length, svgNodes:, decorMap.size); frames 0; lastTime t; } // 原有 requestAnimationFrame 内容最后递归调用 frameLoop }观察一分钟的日志如果水泡数量增加时 fps 先掉说明 Canvas 绘制是瓶颈优先减少每帧渐变运算量比如把不参与交互的远处水泡改用纯色填充如果svgNodes数增长时 fps 掉说明要限制装饰use节点总量比如超过 120 个时复用最早的节点而不是继续新建。5.2 把坐标同步从 attribute 迁移到 CSS transform第 3.3 节的同步函数直接改setAttribute在装饰节点较多时可以进一步优化改用style.transform并把节点放进合成层node.style.transform translate(${bubble.x}px, ${bubble.y}px); node.style.willChange transform;willChange: transform会提示浏览器提前为该节点建立合成层代价是每个节点消耗更多显存。经验值是装饰节点少于 100 个收益明显超过 200 个时显存开销反而可能拖慢帧率。这个技巧在把动画从全屏改成「页面局部区域」时尤其好用因为局部区域节点少合成层的收益更高。5.3 交互触发方式改造从鼠标点击到自动分裂源码包里通常用鼠标事件触发分裂做页面背景时更常见的是「每 23 秒随机选一个水泡触发分裂」。改造代价很小——在update循环外层加一个累积定时器每次到点从存活水泡里随机挑一个调用splitBubblelet splitAccumulator 0; const SPLIT_INTERVAL 2.4; // 秒 function autoSplit(dt) { splitAccumulator dt; if (splitAccumulator SPLIT_INTERVAL) { splitAccumulator 0; const alive bubbles.filter(b b.state BubbleState.NORMAL); if (alive.length 0) { const chosen alive[Math.floor(Math.random() * alive.length)]; chosen.state BubbleState.SPLIT; } } }建议同时把浮力加速度vy调小到 68、把初始水泡控制在 1218 个这样无需任何交互就能得到一张持续扩张又不断自我分裂的彩色背景。配合viewBox的 800×600 逻辑坐标系只需调整缩放比例就能适配不同分辨率页面的水泡密度。提示如果页面还有其他高频交互元素让水泡动画占据满屏可能导致事件区域重叠。更稳的做法是给最外层容器设置pointer-events: none只有明确需要点击的水泡才恢复auto避免误触影响现有业务。这套实现真正可迁移的部分是「状态驱动分裂 双图层按更新频率分工」这两个思路。把 Canvas 绘制、SVG 装饰、状态转移三块拆开读水泡分裂源码就能变成你自己动画库里的一个基础特效模板而不是只能原样跑通的 demo。本文还有配套的精品资源点击获取

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

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

免费获取报价