尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Canvas+SVG混合实现彩色水泡分裂动画

Canvas+SVG混合实现彩色水泡分裂动画 简介HTML5 CanvasSVG实现的彩色水泡分裂动画特效源码主要面向Web前端初学者与动效开发爱好者用来演示两种图形技术在同一个动画中的协作方式。其中Canvas负责水泡主体的像素级绘制与位移、形变SVG则用于高光、阴影等需要缩放不变的矢量细节让分裂后的每个小水泡都保持清晰。压缩包体积仅3KB轻量紧凑便于快速解压并阅读核心逻辑目前已有174人学习浏览。通过源码能够理解请求动画帧函数驱动连续动画的方法掌握依据时间或碰撞条件触发分裂事件以及利用颜色与透明度属性制造彩色水泡变化同时也能对比Canvas与SVG各自的适用场景为后续开发交互式网页动效提供可参考的代码示例。总体而言这份源码的功能虽小但完整呈现了Canvas与SVG混合绘图、动画循环、状态更新和渲染分离的常用思路。1. 什么场景下需要 CanvasSVG 混合写彩色水泡分裂动画一个彩色水泡分裂动画看起来只是个纯视觉 Demo真正动手做的时候会先撞上一个反直觉的结论水泡上浮根本不需要模拟流体力学用速度叠加就能骗过眼睛SVG 在这个项目里不是拿来画气泡的它负责的是背景水波、装饰光晕和尾迹曲线。这个源码包的标题把 Canvas、SVG 放在一起暗示的是一种“高频动态粒子交给 Canvas低频矢量装饰交给 SVG”的分层架构。对要做大屏氛围动画、H5 背景动效、产品展示页的开发者来说拆开这份源码的价值在于先看懂气泡的浮力、分裂、合并三个物理判断怎么写再决定哪些元素放进 Canvas 主循环哪些留在 SVG 静态层。新手可以照着复现一个简化版熟手可以把它改成粒子系统或交互入口。2. 水泡物理模型浮力、体积守恒分裂与碰撞合并做水泡动画的第一步不是写渲染代码而是把每个水泡的数据结构和它每帧怎么变定义清楚。水泡在屏幕上的运动可以分解成三个行为上浮、分裂、合并。上浮由浮力与阻尼决定分裂由体积守恒决定合并由距离判断决定。这三件事都发生在 update 阶段render 阶段只负责把数据画出来。先定数据结构再写行为最后才进渲染。2.1 水泡数据结构与浮力参数速度叠加比流体模拟更省事一个水泡最少需要这样一组字段// 单个水泡的完整状态 class Bubble { constructor(x, y, r) { this.x x; // 圆心横坐标单位 px this.y y; // 圆心纵坐标px this.vx 0; // 水平速度px/s this.vy 0; // 垂直速度px/s向下为正 this.r r; // 半径px this.buoyancy 120; // 浮力加速度px/s^2 this.life 0; // 已存活时间s this.maxLife 8; // 超过后开始淡出 this.hue Math.random() * 360; // 色相基准值 } }浮力在真实液体中与体积成正比气泡越大上浮越快但阻力也会随速度变大而变大。动画里不需要解微分方程常见的做法是给每个水泡一个固定的浮力加速度再加一个与速度成正比的阻尼项让速度收敛到一个终端速度避免水泡越飞越快冲出画面。update 里必须乘 dt把加速度从秒级换算到帧级。update(dt) { // 浮力向上加速所以 y 方向速度减去浮力增量 this.vy - this.buoyancy * dt; // 指数阻尼等价于粘性阻力速度会被收敛 const damping Math.exp(-2.0 * dt); this.vy * damping; this.vx * damping; // 位置积分 this.x this.vx * dt; this.y this.vy * dt; this.life dt; // 色相随时间漂移产生彩色水泡的渐变效果 this.hue (this.hue 12 * dt) % 360; }这段代码的参数说明buoyancy取 80~160 px/s² 之间时水泡上升速度大约保持在 40~120 px/s适合 600px 高的舞台damping用指数形式而不是线性减速度是因为指数衰减不会让速度变成负数后产生来回抖动。dt是上一帧到这一帧的秒数必须作为参数传进来不能在 update 内部用固定值否则帧率变化时气泡运动速度会不一致。hue每帧增加12 * dt相当于每秒色相旋转 12 度多次分裂后每个子泡继承父泡颜色再各自偏移屏幕上的色相自然散开。参数推荐范围说明buoyancy80~160 px/s²越大上升越快配合 damping 一起调damping1.5~3.0指数衰减系数越大越“粘稠”maxLife5~10 s水泡升到顶或超时后触发淡出初始半径8~40 px小于 5px 的小泡看起来很碎很多人上来就把 buoyancy 设成重力加速度的数值比如 980结果水泡在 0.5 秒内飞出屏幕然后靠边界反弹硬拉回来拖出长长的残影。做特效时参数只要“看起来对”就行不必参考真实物理量级。2.2 分裂算法体积守恒公式与随机扰动分裂是这个动画的核心行为。一个半径为 R 的父泡分裂成两个子泡最常见的要求是总体积不变。球体体积公式是 V 4/3πr³约掉常量系数后只需要保证两个子泡半径的立方和等于父泡半径的立方。// 将一个大泡分裂为两个子泡保持体积守恒 function splitBubble(bubble) { const R bubble.r; // k 表示第一个子泡占父泡的体积比例取 0.4~0.6 const k 0.4 Math.random() * 0.2; const r1 R * Math.cbrt(k); // 体积占比 k const r2 R * Math.cbrt(1 - k); // 体积占比 1-k // 沿随机方向把两个子泡错开避免重叠 const angle Math.random() * Math.PI * 2; const offset R * 0.35; return [ createBubble(bubble.x Math.cos(angle) * offset, bubble.y Math.sin(angle) * offset, r1), createBubble(bubble.x - Math.cos(angle) * offset, bubble.y - Math.sin(angle) * offset, r2) ]; }替换掉父泡时子泡的速度要从父泡继承再各加一点随机扰动否则子泡会保持原来的运动轨迹看起来像静止的圆球突然裂开。常用的做法是vx bubble.vx (Math.random() - 0.5) * 60垂直方向同理。这里有个容易写错的点直接用r1 R * Math.sqrt(k)或者r1 R * k都破坏了体积守恒会让画面上的气泡总量在几次分裂后肉眼可见地缩小。用Math.cbrt开立方根是这一步的关键。分裂触发的时机通常有两种每帧按概率触发概率在 0.02~0.08 之间或者当气泡被鼠标点击、碰到边界时触发。概率触发适合纯展示场景点击触发适合交互场景。不管哪种都要在分裂前检查当前气泡总数超过上限就丢弃这次分裂事件。2.3 水泡合并与边缘处理避免 O(n²) 遍历失控气泡数量超过 100 之后两两碰撞检测的 O(n²) 成本开始显现。200 个气泡的距离计算是 4 万次每次只有加法和乘法现代浏览器一帧内可以完成所以这个量级下没有必要引入空间哈希网格。数量上限设在 150~250 之间比较安全。碰撞判断和合并逻辑写成for (let i 0; i bubbles.length; i) { for (let j i 1; j bubbles.length; j) { const a bubbles[i], b bubbles[j]; const dx a.x - b.x; const dy a.y - b.y; const dist Math.hypot(dx, dy); // 距离小于半径和就合并合并后的半径按体积相加 if (dist a.r b.r) { const mergedR Math.cbrt(a.r ** 3 b.r ** 3); const mergedX (a.x b.x) / 2; const mergedY (a.y b.y) / 2; const mergedVx (a.vx b.vx) / 2; const mergedVy (a.vy b.vy) / 2; // 合并掉两个旧泡推入新泡 bubbles.splice(Math.max(i, j), 1); bubbles.splice(Math.min(i, j), 1); bubbles.push({ x: mergedX, y: mergedY, r: mergedR, vx: mergedVx, vy: mergedVy }); i j 0; // 数组变化后重置遍历 } } }这里用Math.hypot求距离比手写Math.sqrt(dx*dxdy*dy)可读性好但hypot有额外函数调用开销。数据量在 200 以内时无所谓如果上限提到 1000建议换成平方距离比较避免开方。合并时用splice两次再 push循环必须重置否则数组索引变化后会漏检。如果只分裂不合并一段时间后气泡会越分越小直到消失加上合并逻辑后画面出现“分久必合、合久必分”的循环这能让动画特效长时间跑下去。3. 用 canvas 绘图引擎画气泡渐变渲染、主循环与对象池物理更新写好之后渲染这一步决定观感。彩色水泡的“彩色”主要靠色相变化“水泡感”靠径向渐变和高光。Canvas 是立即模式绘图引擎每帧重画所有图形所以渲染代码的目标不是“画得精细”而是“画得足够便宜”。一个气泡的绘制成本应该控制在 5~10 条绘图指令之内。3.1 气泡三笔画法径向渐变主体、高光小圆、边缘细线一个让眼睛相信它是水泡的最小绘制方案是三笔先用径向渐变填充圆形主体渐变从左上方的白色高光过渡到半透明的色相再在左上方画一个小高光圆最后加一圈亮度很低的描边表现边缘折射。代码如下function drawBubble(ctx, b) { const x b.x, y b.y, r b.r; // 第一笔主体径向渐变 const grad ctx.createRadialGradient( x - r * 0.35, y - r * 0.35, r * 0.1, // 高光圆心半径缩小起点 x, y, r ); grad.addColorStop(0, rgba(255,255,255,0.85)); grad.addColorStop(0.35, hsla(${b.hue}, 80%, 70%, 0.55)); grad.addColorStop(1, hsla(${b.hue}, 80%, 50%, 0.1)); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle grad; ctx.fill(); // 第二笔高光小圆 ctx.beginPath(); ctx.arc(x - r * 0.35, y - r * 0.35, r * 0.18, 0, Math.PI * 2); ctx.fillStyle rgba(255,255,255,0.9); ctx.fill(); // 第三笔底部细描边表现折射 ctx.beginPath(); ctx.arc(x, y, r - 0.5, 0, Math.PI * 2); ctx.lineWidth 1.2; ctx.strokeStyle hsla(${b.hue}, 70%, 45%, 0.4); ctx.stroke(); }参数说明渐变的addColorStop(0)用纯白高光0.35位置放主色透明度 0.55边缘透明度降到 0.1这样气泡内部是透光的背景能透过来这是“水泡感”的关键。高光位置放在x - r * 0.35处与渐变中心保持一致模拟左上角光源。如果你的设计是右上光源把两个-0.35都改成0.35。很多人想强调透明感就调低透明度结果气泡几乎看不见正确做法是保留边缘不透明、中间透明。这里不要开ctx.shadowBlur。shadowBlur 在每帧绘制大量图形时会触发离屏模糊计算气泡量到 200 个左右掉帧非常明显。如果需要发光效果可以用globalCompositeOperation lighter做一次整体叠加而不是逐气泡阴影。3.2 动画主循环requestAnimationFrame 的时间步进写法动画主循环不能依赖 setInterval也不能假定每帧间隔固定是 16.7ms。正确写法是每次回调时用performance.now()计算真实帧间隔并做上限截断。let lastTime performance.now(); let bubbles []; function frame(now) { // dt 是秒限制最大 0.05防止切后台回来瞬间跳帧 const dt Math.min((now - lastTime) / 1000, 0.05); lastTime now; update(dt); // 遍历 bubbles调用各自的 update render(ctx); // 清屏 绘制所有气泡 requestAnimationFrame(frame); } requestAnimationFrame(frame);dt上限设为 0.05 的原因是浏览器切到后台再切回来时now - lastTime可能达到几秒如果不做截断物理系统会瞬间把所有气泡推出边界。截断后这段“缺失时间”被忽略视觉上没有瞬移。requestAnimationFrame在后台标签页会自动暂停所以恢复后第一次回调的 dt 通常特别大这个保护是必须的。render 函数里清屏有两个选择ctx.clearRect(0, 0, canvas.width, canvas.height)是完全清除适合水泡这种独立对象如果想要水泡拖尾的丝滑感可以用半透明覆盖填充让上一帧的画慢慢淡出。拖尾会显著增加画面信息量但也会让叠加的文字内容变糊作为活动背景时慎用。3.3 对象池与数量上限把 GC 挡在渲染循环之外每次分裂都 new 两个 Bubble每秒几十次偶尔一次 Minor GC 不会卡但动画连续跑三五分钟后内存里的垃圾对象会累积触发频率变高。常见做法是维护一个简单的对象池只复用不再活跃的实例。class BubblePool { constructor(maxSize) { this.pool []; // 空闲列表 this.active []; // 活跃气泡 this.maxSize maxSize; } alloc(b) { // 有空闲就复用没有则新建 const item this.pool.pop() || new Bubble(0, 0, 8); Object.assign(item, b); this.active.push(item); return item; } release(b) { const idx this.active.indexOf(b); if (idx 0) this.active.splice(idx, 1); if (this.pool.length this.maxSize) this.pool.push(b); } }对象池的正确用法alloc 时从池里取气泡死亡升到水面、寿命耗尽或被合并时调用 release 把实例归还。Object.assign(item, b)把初始状态整体灌进去省去逐个字段赋值。池子本身不要无限增长maxSize一般设成气泡数量上限的 1.5 倍防止极端情况下池子过大占内存。数量上限的控制要放在分裂逻辑里当活跃气泡数已经达到上限比如 200触发的分裂事件应该被忽略而不是继续分。用上面的池子统计this.active.length作为判断依据。这一步做完气泡动画的帧率就稳定了接下来去处理 SVG 装饰层。4. SVG 在特效里的角色装饰层、水波纹与气泡尾迹为什么标题里既有 Canvas 又有 SVG因为水泡本身是每帧变化的动态图形适合 Canvas但水波纹背景、玻璃光晕、标题装饰这类静态或低频元素如果也在 Canvas 里每帧重画会浪费大量 fill 调用。SVG 是保留模式元素创建后由浏览器管理重绘适合放这些“不常变”的东西。4.1 动态高频给 Canvas静态低频给 SVG两种绘制模型的分工Canvas 绘制是即时执行每帧代码调用一次绘图 API画完就没有记录下一帧必须重新画。SVG 绘制是声明式的DOM 里的circle、path由浏览器保留修改属性时浏览器只重绘受影响的区域。所以同样是 200 个圆Canvas 每帧 200 次 arc 调用没问题SVG 每帧改 200 个 DOM 属性会触发大量布局与样式计算帧率很快掉到 30fps 以下。混合方案推荐这样拆分水面背景和玻璃质感装饰放 SVG因为它们几乎不变水泡运动轨迹的矢量曲线放 SVG因为曲线是低频数据水泡本体放 Canvas因为它们是高频对象。SVG 层节点数稳定在 10 个以内Canvas 层绘制集中在主循环里两边互不拖累。4.2 用内联 SVG 画水波纹与光晕path 命令与 viewBox 配合水波纹不需要复杂的滤镜一条带透明渐变填充的贝塞尔曲线就能做出水面感觉。下面这段可以贴在 Canvas 下层作为装饰背景的一部分。svg classbubble-bg viewBox0 0 800 600 xmlnshttp://www.w3.org/2000/svg aria-hiddentrue defs linearGradient idwaterGrad x10 y10 x20 y21 stop offset0% stop-color#26c6da stop-opacity0/ stop offset100% stop-color#26c6da stop-opacity0.12/ /linearGradient /defs !-- 一条半透明水波曲线fill 用渐变 -- path dM0 520 Q100 500 200 520 T400 520 T600 520 T800 520 L800 600 L0 600 Z fillurl(#waterGrad)/ /svg这里的Q x1 y1 x2 y2是二次贝塞尔曲线命令T是平滑延伸把前一段的镜像控制点自动补出来所以只写坐标点就能让波浪连续。viewBox0 0 800 600定义了 SVG 内部坐标系实际展示宽度由 CSS 控制和 Canvas 宽度解耦。如果想让水波微微起伏可以给整个path加一行 CSS 动画每秒上下移动 2~3 个像素.bubble-bg path { animation: floatWave 3s ease-in-out infinite alternate; } keyframes floatWave { from { transform: translateY(0); } to { transform: translateY(6px); } }注意只对 path 做 transform 位移不要改变 d 属性因为重新解析 path 数据的成本远高于一次合成变换。SVG 层放在 Canvas 下面它的pointer-events必须设为none否则会挡住 Canvas 的鼠标事件影响后续扩展拖拽交互。在免费 SVG 素材网下载的波形、光晕素材也可以直接贴进装饰层但要确认 viewBox 与舞台宽高比一致否则缩放会失真。4.3 用 SVG path 记录气泡尾迹低频、矢量、可复用如果希望气泡身后带一段渐隐的彩色轨迹最省性能的做法不是每帧在 Canvas 画一串小圆而是每 N 帧记录一次气泡位置把最近 10~20 个点连成一条折线写成 SVG 的polyline或path。因为轨迹点更新频率远低于粒子的渲染频率这条路径的 DOM 操作成本可以忽略。const trail document.getElementById(bubbleTrail); const points []; // 每 4 帧调用一次把当前气泡位置记录进去 function recordTrail(bubble) { points.push(bubble.x.toFixed(1) , bubble.y.toFixed(1)); if (points.length 16) points.shift(); trail.setAttribute(points, points.join( )); }points属性是逗号分隔的坐标对用空格连接折线自动在相邻点之间连线。想更顺滑地表示弧形轨迹可以把相邻三点换成path的C命令三次贝塞尔格式是C cx1 cy1 cx2 cy2 x y但折线在点数足够密16 个时视觉差异很小。给 polyline 设置fillnone和半透明stroke就能得到一个看起来由矢量层提供的气泡尾迹。这样 Canvas 只管画当前帧SVG 只管画一段“历史”两种绘制引擎在时间维度上互补这也是标题把两种技术并列的意义。5. 分裂参数、DPR 适配与帧率验证的收尾技巧5.1 三个必调参数拿到源码后最先应该改的是下面三个参数它们直接决定画面的热闹程度和性能表现。参数推荐范围影响分裂触发概率0.02~0.08 / 帧概率太高气泡数快速到达上限体积占比 k0.4~0.6小于 0.3 会出现极小碎泡气泡数量上限150~250超过后关闭分裂优先合并把触发概率从Math.random() 0.03改成Math.random() 0.06画面活跃度立刻翻倍但上限到达后会开始频繁触发合并。如果场景需要“疏朗”感拉长生成间隔不要只调低概率否则分裂事件会集中在同一帧爆发造成瞬间掉帧。5.2 DPR 适配高清屏默认canvas.width与显示宽度不一致时会模糊。适配的核心是让画布物理像素等于 CSS 像素乘以 devicePixelRatiofunction resizeCanvas(canvas) { const dpr window.devicePixelRatio || 1; const w canvas.clientWidth; const h canvas.clientHeight; canvas.width w * dpr; canvas.height h * dpr; const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }setTransform每次重置变换矩阵避免在 window resize 事件里重复调用scale导致矩阵叠加。注意这个函数要在每次 resize 时都要执行包括浏览器窗口尺寸变化和移动端横竖屏切换否则画面会变模糊或者只占一块区域。5.3 帧率验证写一个内联 FPS 面板每 60 帧统计一次比用 Chrome Performance 面板更直观let frames 0, fps 0, fpsTime performance.now(); function measureFPS(now) { frames; if (now - fpsTime 1000) { fps frames; frames 0; fpsTime now; document.getElementById(fps).textContent fps; } }验证流程先开 200 个气泡跑 5 分钟观察 fps 是否稳定在 55 以上再打开 Performance 录制看 Scripting 时长单帧超过 6ms 就要查对象池、渐变创建和 shadowBlur。气泡之间的重叠面积过大时Canvas 填充率也会成为瓶颈这时优先做两件事减小气泡最大半径、降低渐变透明度视觉变化不大但帧率回升明显。5.4 把分裂接到鼠标事件上想让源码从“自动播放展示品”变成可交互组件最直接的做法是把分裂触发接在鼠标或触摸事件上canvas.addEventListener(pointerdown, (e) { const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top; // 找到离点击最近的活跃气泡调用 splitBubble const nearest bubbles.reduce((a, b) Math.hypot(b.x - x, b.y - y) Math.hypot(a.x - x, a.y - y) ? b : a ); if (nearest nearest.r 10) { const children splitBubble(nearest); bubbles bubbles.filter(b b ! nearest).concat(children); } });pointerdown同时覆盖鼠标、触摸和触控笔比分开监听mousedown和touchstart干净。移动端要用touch-action: none禁止浏览器默认手势否则点击会触发页面滚动。这个交互加进去之后用户会本能地连续点击分裂正好用上之前设好的数量上限和对象池整个动画特效就从单向播放变成了可玩的状态。本文还有配套的精品资源点击获取
返回列表