漂流瓶动效实战:CSS+SVG波浪动画与贝塞尔抛掷交互
2026/9/15 4:38:40 网站建设 项目流程

简介:一份模拟漂流瓶交互效果的前端源码,适合前端初学者及对趣味交互、事件驱动编程感兴趣的开发者参考。资源通过事件监听、随机捞取逻辑与界面动效,还原了投掷、漂流、打捞瓶子的完整流程,可帮助理解前端交互设计与随机算法在实际项目中的落地方式。压缩包共39个文件,以png图片、js脚本、gif动画、css样式为主,另含htm入口文件、说明文档及许可证,整体仅321KB。已有557人学习/下载。包内核心脚本、页面结构与样式表分工明确,搭配多张漂流瓶、海洋背景等素材图,便于读者对照源码逐段分析事件绑定、动画切换和随机选择机制;整体目录规整、素材齐全,对于想动手实现类似社交趣味功能的前端开发者颇具参考价值。

1. 漂流瓶效果到底难在哪里:先定义它是个什么效果

同一屏里放十来个玻璃瓶,各自在波浪上慢悠悠地晃,点一下就抛物线飞出去,落到另一片海面上继续漂。真正做过的人会告诉你,难的不是“画出瓶子”,而是让它在停止不动时看起来还在“活”着——波浪不重样、瓶子不穿帮、帧率不掉。这个标题里的源码如果值得参考,参考价值通常集中在三件事:层叠的波形动画、瓶子自身的悬浮写实感、加上一条足够顺手的抛掷轨迹算法。

这篇文章就按这三件事展开。第一件事讲清楚怎么写一个能原地循环的波浪层;第二件把瓶子的运动拆成平移、起伏和倾斜三个独立信号;第三件把点击到落水的完整交互链补上,包含角度插值和落点微调。最后再给一个不需要装任何面板就能跑的验证方法,在浏览器里直接看帧耗时和像素对齐。适合正在做移动端 H5 活动页、Web 端温情类叙事页面,或者单纯想把位移动画做得不“簧片味”的人。

2. 海面与瓶身:把主视觉拆成可叠加的分层

2.1 海面先做两层:渐变底色加上 SVG noise 暗纹

纯靠 CSS 把海面“画”出来的常见做法是上下叠两层:底层用linear-gradient做出水色由浅到深的过渡,上层叠加一张带噪声的 SVG 滤镜,用来打断大色块上过于干净的塑料感。SVG 用内联<svg><filter>标签,不产生额外图片请求,代码量也小。

<svg width="0" height="0" style="position:absolute"> <filter id="seaNoise"> <feTurbulence type="fractalNoise" baseFrequency="0.012 0.03" numOctaves="3" result="noise" /> <feColorMatrix type="matrix" values="0 0 0 0 0.25 0 0 0 0 0.35 0 0 0 0 0.55 0 0 0 0.25 0" in="noise" result="coloredNoise" /> <feBlend in="SourceGraphic" in2="coloredNoise" mode="multiply" /> </filter> </svg>

baseFrequency沿 x 轴取 0.012、沿 y 轴取 0.03,让噪点横向长、纵向短,比较贴近浪纹的直觉;numOctaves="3"叠加三层噪声细节。feBlendmultiply混合后,暗纹会附着在底色上而不是直接用浅色覆盖。

把这一层放在海面容器里作为背景,波浪单元放在它上方。这里有个容易出错的地方:filter 作用在整块背景上时,如果直接设给最外层容器,里面的动画元素也会继承滤镜计算,帧率立刻掉一截。正确做法是让滤镜容器和波浪容器保持同级,各管各的合成。

2.2 瓶身用 SVG 拆层:瓶体、软木塞、标签与水体

漂流瓶在静止状态下最好看的表现形式,是把瓶身拆成几个独立子层,在动画循环里给它们分配差异很小的运动参数,模拟玻璃折射和水体晃动。

子层结构运动分量
瓶体轮廓单条 path,带 2px 描边整体位移、整体旋转
软木塞顶部 rect + 圆角只在 y 轴做 0.5px 级抖动
标签中段圆角矩形,浅色跟随瓶体,旋转量减半
瓶内水体椭圆 path,透明度 0.45椭圆 ry 随波高周期变化
<g id="bottle"> <path id="glass" d="M20 10 Q20 4 26 4 L54 4 Q60 4 60 10 L58 66 Q58 72 52 72 L28 72 Q22 72 22 66 Z" fill="rgba(180,220,255,0.14)" stroke="rgba(255,255,255,0.6)" stroke-width="1.6" /> <rect id="cork" x="24" y="0" width="32" height="8" rx="3" fill="#b08968" /> <rect id="label" x="25" y="36" width="30" height="16" rx="4" fill="rgba(255,250,235,0.85)" /> <ellipse id="innerWater" cx="40" cy="62" rx="14" ry="6" fill="rgba(120,190,220,0.55)" /> </g>

innerWaterry在帧循环里按正弦函数变化,数值范围控制在 4 到 9 之间,模拟瓶子里面水随波浪晃动。玻璃层单独放大fill-opacity的变化幅度,会产生瓶身通透度随视角略变的错觉——效果不算明显,但比完全静止的纯色瓶身要耐看。

2.3 分层渲染的取舍原则

做这类视觉时最容易踩的方向性错误,是试图用大量 DOM 节点去堆水体粒子。常见的可靠方案是:静态形状用 SVG 或 CSS 剪裁路径,动态水体只保留一个椭圆节点,运动量交给 transform 属性。JS 里用Array.from缓存节点引用,避免每帧执行 querySelector。

对于不同瓶子的深浅和水量,可以在生成瓶子时改几个 path 参数。比如玻璃描边透明度取 0.35 到 0.7 之间,内水体透明度与描边透明度反向变化,这样整套瓶子放到同一片海域里时,视觉上不会像同一个模子印出来。每帧更新只发生在 transform 和少量 attribute 上,保留一层合成空间给浏览器做异步渲染。

3. 让漂流瓶“漂”起来:正弦波叠加与异步相位

3.1 为什么不是随机抖动而是正弦叠加

很多初版实现直接给瓶子一个translateY随机值,效果是瓶子上蹿下跳,没有海浪托着走的感觉。海面浮体的运动在低频区间,核心是三个不同周期的正弦波叠加,频率比大约取 1 : 1.7 : 0.32。第一个波决定主浮动节奏,第二个波制造上下浮动的错落感,第三个长波让瓶子在几秒内缓慢横移,打破原地打转的僵硬感。

3.2 三个输入一个输出:时间、相位和波幅参数

每个瓶子的运动状态只依赖三个输入:全局时间戳、瓶子自身的随机相位、三个波的振幅系数。相位差的取值范围应该覆盖整个,保证任何两个瓶子在同一个瞬间都不完全同步。

代码里每个瓶子保存一组参数对象,形态如下:

const bottlePhases = bottles.map((_, index) => ({ // 三个正弦波的初始相位,让每个瓶子的节奏错开 phase1: (Math.PI * 2 * index) / bottles.length, phase2: Math.random() * Math.PI * 2, // 中间那个波的频率略不等比,晃动才不规律 freqRatio: 1.6 + Math.random() * 0.2, // 横向漂移速度,配合长周期波让瓶子不原地打转 driftSpeed: 0.08 + Math.random() * 0.06, }));

freqRatio取值在 1.6 到 1.8 之间时,第二个波和主波形成的合运动看起来最像浮体在海浪上的摆动。取值接近 2 会形成有规律的花样曲线,反而容易察觉动画在重复。

3.3 帧循环里只算位移和旋转

渲染循环只做一件事:把时间t_ms换算成角度,代入三个正弦函数算出 y 偏移、x 偏移和旋转角,然后拼成 transform 一次赋值。不要拆成多次 transform 调用,浏览器每改一次 transform 都可能触发样式重算。

function updateBottle(bottle, params, now) { // 换算成周期约为 5.6s 的主波角度 const t = now * 0.001; const a1 = Math.sin(t * 2.0 + params.phase1); // 第二波频率略高,用来做小幅度扰动 const a2 = Math.sin(t * 2.0 * params.freqRatio + params.phase2); // 长周期横移:约 22 秒一个来回 const a3 = Math.sin(t * 0.28 + params.phase1 * 1.7); const translateY = a1 * 8 + a2 * 3; // 起伏主导 const translateX = a3 * 14 + a2 * 1.5; // 横向漂移 const rotate = (a1 + a2) * 3.2; // 倾角随起伏联动 bottle.style.transform = `translate3d(${translateX.toFixed(2)}px, ${translateY.toFixed(2)}px, 0) rotate(${rotate.toFixed(2)}deg)`; }

旋转角和 y 偏移共用a1 + a2,瓶子浪起时同步朝一侧倾斜,浪落时往反方向回摆。两套运动脱开的话,瓶子的动态看起来会像“生硬的平移后补了一个旋转”。副波振幅定为 3px,倾斜量里副波参与度与主波一致,视觉上会有细碎的颠簸。

帧循环统一放在一个requestAnimationFrame回调里,一次循环处理所有瓶子,避免为每个瓶子单独占用一个 rAF 实例。十几个瓶子的布局计算量远小于单独起循环的调度开销。

3.4 transform-only 的优化价值

这三种写法都推荐按优先级使用:

  • 能用transform就不要改top/left,后者会触发布局。
  • 瓶身倾斜用rotate,不要用 SVG 的transform="rotate(...)"属性,attribute 更新走不了合成器加速。
  • translate3d在移动端某些 WebView 上能强制开启 GPU 合成,但不要滥用。现代浏览器里translatetranslate3d没有显著差异,保持写法统一更重要。

4. 抛掷与回收:一条贝塞尔轨迹的完整事件链

4.1 从点击到落水的几个状态切换

漂流瓶的交互可以拆成五个状态:待机浮漂、拖拽悬停、飞行中、落水回弹、回到待机。状态机不复杂,麻烦在于状态切换时不能让运动参数发生跳变。

事件链通常这样做:

  1. pointerdown时记录点击点与瓶心的偏移,同时停止该瓶子的浮动循环更新。
  2. pointermove阶段更新一个虚拟的拖拽跟随点,瓶子只做位置跟随,不做旋转。
  3. pointerup时以当前位置为起点、落点为终点,生成飞行轨迹。
  4. 飞行结束后执行落水回弹,回弹动画结束再重新接入第 3 章的波浪循环。

4.2 两点一线不够:需要按抛出方向决定弧高

从 A 点飞向 B 点,如果用直线插入位置,瓶子像被瞬移过去。现实中瓶子是抛出去的,需要用一条二次贝塞尔曲线,控制点高度由抛出距离决定。

function calcThrowPath(start, end) { const distance = Math.hypot(end.x - start.x, end.y - start.y); // 抛出距离 1/4 的弧高,落点越远,弧线越高 const peak = distance * 0.25; const mid = { x: (start.x + end.x) / 2, y: Math.min(start.y, end.y) - peak, }; // 将 t 映射为二次贝塞尔上的坐标 return (t) => { const u = 1 - t; return { x: u * u * start.x + 2 * u * t * mid.x + t * t * end.x, y: u * u * start.y + 2 * u * t * mid.y + t * t * end.y, }; }; }

peak取值 0.25 倍距离,是视觉上比较从容的高度;小于 0.12 会像贴地滑行,大于 0.4 会有明显失重感。飞行时间按距离等比例缩放,固定速率的做法在长距离下会显得拖沓。

function animateThrow(bottle, pathFn, duration) { const startTime = performance.now(); return new Promise((resolve) => { function step(now) { let t = (now - startTime) / duration; // 用 easeOutCubic 让瓶子离手快、落水慢 const eased = 1 - Math.pow(1 - t, 3); const pos = pathFn(eased); bottle.style.left = pos.x + "px"; bottle.style.top = pos.y + "px"; // 飞行时瓶子角度随速度方向变化,这里简化为随 t 线性转半圈 bottle.style.transform = `rotate(${120 * eased}deg)`; t < 1 ? requestAnimationFrame(step) : resolve(); } requestAnimationFrame(step); }); }

飞行过程中把transform的 rotate 从 0 线性增加到 120 度,瓶子多半圈掉进海里,这和落水状态的角度衔接比较自然。不能用固定第一次旋转就完事,瓶子轨迹的终点方向不同,入水角度应该跟随当前位置的切线方向变化。

4.3 落水回弹两件事:压扁感与水位线

常见的落水效果是在终点先做一次极小幅度旋转回归,再配合一个 scaleY 压缩和恢复过程。压缩幅度控制在 0.86 到 0.94 之间,持续 120ms,恢复 240ms。这个压缩不要做得太夸张,否则看起来像瓶子拍在石头上,不像入水。

bottle.animate( [ { transform: "rotate(120deg) scale(1, 1)", offset: 0 }, { transform: "rotate(78deg) scale(1, 0.9)", offset: 0.25 }, { transform: "rotate(64deg) scale(1, 1)", offset: 1 }, ], { duration: 360, easing: "cubic-bezier(0.25, 0.8, 0.3, 1)" } );

旋转收尾角度不要设为零,瓶子入水后的姿态应该带一点和航行方向相关的倾斜。比如从左往右抛,落水后最终静止角在 8 到 12 度之间,会显得水流有方向。

4.4 边界条件:抛向边缘和快速连续操作

两个常见边界问题值得写进实现里:

  • 落点靠近容器边缘时,终点应该向内收缩一段距离,避免瓶子半身露在海洋背景外。实际终点用end.x - Math.cos(endAngle) * halfBottleWidth收缩半个瓶宽。
  • 连续快速点击会把一个瓶子打出多条飞行动画链,旧的回调没结束时新的已经覆盖了 transform。用 AbortController 或简单的动画序号自增判断来丢弃旧回调,比每次cancelAnimationFrame更省事。
let throwSeq = 0; async function handleThrow(bottle, start, end) { const seq = ++throwSeq; const duration = Math.min(900, Math.max(450, Math.hypot(end.x - start.x, end.y - start.y) * 0.9)); await animateThrow(bottle, calcThrowPath(start, end), duration); // 只有最后一次操作的动画才能执行入水回弹 if (seq === throwSeq) { await splash(bottle); } }

入水回弹放在序号校验后面,旧动画就算晚到也不会产生叠加抖动。visual 表现上,瓶子掉入水中后就地停顿约一帧,再接入波浪动画,这一帧的静止很关键,接不好会把“刚着陆”的质感丢掉。

5. 验证与收尾:用 DevTools 的 Performance 面板盯三个硬指标

漂流瓶这套效果做完,最容易出的问题不是“不动”,而是“动起来后把页面拖到掉帧”。验证方法不需要引入额外工具,Chrome DevTools 的 Performance 面板足够。

录制一段 5 秒动画,看三个数据:帧率是否稳定在 50fps 以上;Scripting 时间占比是否超过 12ms/帧;Painting 有没有随着瓶子移动出现明显峰值。符合条件再去看渲染细节——在帧列表里点开任意一帧,确认Layout记录不存在,这说明 transform-only 生效了。

掉帧时先用排除法缩小范围,通常是这几类原因:滤镜尺寸过大;瓶子数量超过 20 个但对每个都执行了独立 shadow;把 SVG 的filter挂在了动画元素的父级上。借用 Performance 面板里看占用高的条目,直接按函数名定位,很有效。

收尾阶段还有个容易忽略的细节:页面失焦时用document.visibilitychange暂停requestAnimationFrame循环,恢复时刷新起点时间戳再继续。否则标签页在后台运行一段时间再切回来,动画会表现为一段快速回放。时间戳刷新只要在恢复函数里用performance.now()重新记录基准值即可,代码改动不超过十行。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询