ARTICLE DETAIL

资讯详情

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

Canvas漂流瓶效果源码解析:从渲染循环到组件化封装

Canvas漂流瓶效果源码解析:从渲染循环到组件化封装 简介这是一份模拟QQ漂流瓶互动玩法的前端源码包可作为社交类小功能开发的参考资料适合有一定HTML、CSS和JavaScript基础、希望学习事件交互与界面实现的开发者。资源共39个文件以31个png图片为主涵盖海洋背景、瓶子、捞取成功图标等视觉素材配合2个js脚本实现投掷与打捞逻辑另含htm页面入口、css样式表和说明文档整体约321KB压缩包结构清晰便于直接拆解阅读。目前已有557人学习下载。源码中体现了事件监听、随机捞取交互、界面布局与资源加载等常见前端处理思路附带jQuery库和多个动画/背景素材能够帮助读者快速还原漂流瓶的完整视觉流程并在此基础上扩展自己的创意玩法。1. 漂流瓶效果源码一个 Canvas 示例为何值得翻十遍一个运营 H5 首页要做互动经常会放一个透明玻璃瓶在蓝色海面上浮动点开瓶盖浮出一段情话或者一个跳转链接。这个视觉被称为“漂流瓶效果”。它没有多深的技术机密但源码的价值从来不在“画一个瓶子”本身而在组织方式。判断一份漂流瓶效果源码值不值得参考我会先看三件事渲染时间是不是统一由requestAnimationFrame的时间戳驱动还是每个模块拿自己的定时器各跑各的海面和瓶子是不是同一个坐标系下的状态变化效果变体是不是只靠参数切换而不是复制粘贴多个setInterval块。满足这些条件的源码不管文件名叫“漂流瓶”还是“海浪”都可以当一套前端动画模板来读。下面按这类源码的常见实现把骨架、参数、性能优化和组件化路径逐层讲清楚。2. 漂流瓶效果源码的渲染循环与参数表把瓶子扛进海面2.1 从一个最小 Canvas 渲染循环开始多数值得参考的漂流瓶效果源码不会一上来就堆细节而是先定义一个每秒刷新 60 次的帧函数然后每帧只做两件事清屏、重画。清屏用ctx.clearRect重画往里塞海面和瓶子。挂驱动的是requestAnimationFrame它会把当前毫秒时间戳传给回调源码里所有动画相位都从这个时间戳换算而不是在回调里自己计数。后者的坏处是掉帧时动画会突然加速换成时间戳驱动后即使中间卡了几帧下一个位置也能根据真实时间算回来。下面这份最小实现是很多漂流瓶效果源码的公共起点// 最小可跑起来的漂流瓶海面 const canvas document.querySelector(canvas); const ctx canvas.getContext(2d); const cfg { seaLevel: 0.6, // 海平面在画布高度的比例 waveAmplitude: 10, // 瓶子与波浪的上下浮动幅度 wavePeriod: 4, // 一个完整波浪周期约 4 秒 bottleWidth: 24, bottleHeight: 60, angleAmplitude: 0.08, // 瓶身最大倾斜角单位弧度 driftSpeed: 0.02 // 瓶身左右漂移速度 }; function frame(now) { const t now * 0.001; // 秒为单位的时间 const x canvas.width * 0.5 Math.sin(t * 0.3) * cfg.driftSpeed * 100; const y canvas.height * cfg.seaLevel Math.sin(t * 2 * Math.PI / cfg.wavePeriod) * cfg.waveAmplitude; const angle Math.sin(t * 1.2) * cfg.angleAmplitude; ctx.clearRect(0, 0, canvas.width, canvas.height); fillSea(ctx, t); drawBottle(ctx, x, y, angle); requestAnimationFrame(frame); } function fillSea(ctx, t) { // 先铺一层从浅蓝到深蓝的渐变 const grad ctx.createLinearGradient(0, 0, 0, canvas.height); grad.addColorStop(0, #bfe9ff); grad.addColorStop(0.55, #7bd0f5); grad.addColorStop(1, #1673a8); ctx.fillStyle grad; ctx.fillRect(0, 0, canvas.width, canvas.height); } function drawBottle(ctx, x, y, angle) { ctx.save(); ctx.translate(x, y); ctx.rotate(angle); // 这里先画一个圆角矩形瓶身玻璃高光在 3.2 节补上 ctx.strokeStyle rgba(255,255,255,0.7); ctx.lineWidth 1.5; ctx.beginPath(); ctx.roundRect( -cfg.bottleWidth / 2, -cfg.bottleHeight / 2, cfg.bottleWidth, cfg.bottleHeight, 10 ); ctx.stroke(); ctx.restore(); } requestAnimationFrame(frame);这个循环的逻辑很简单位置不是存下来的而是每帧用t算出来的。好处是任何时候暂停再恢复瓶子都能自动回到正确相位不需要手动校正。angle和瓶子上下浮动共用同一个时间轴但频率不同看起来才有“被海浪托着”而不是“机械上下抖”。如果目标浏览器不支持ctx.roundRect可以用普通rect或手动画四条线与圆弧替换不影响整体结构。2.2 源码里值得抄下来的参数表在一份结构清晰的漂流瓶效果源码里配置块通常独立于绘制函数任何参数改动都不是改函数体而是改配置。下面这张参数表覆盖了从源码里抄作业最常碰到的几项参数单位作用参考值调整方向seaLevel比例海平面相对画布顶部的高度0.6增大让瓶子更靠下waveAmplitudepx瓶子随波浪上下摆动的幅度10海浪“情绪”越强越调大wavePeriods一个波浪周期时长4小于 2 容易晕大于 6 偏催眠bottleWidth/Heightpx瓶身外框尺寸24 / 60改造时按 dpr 统一缩放driftSpeed比例水平漂移速度0.02越大越像被洋流带走angleAmplituderad瓶身最大倾斜角0.08配合浪高调避免瓶子翻过去glassOpacity0~1玻璃高光的透明度0.35超过 0.5 会遮住海面细节新手最容易在这里犯的错是把waveAmplitude和wavePeriod混在一起调结果瓶子上下起伏很大但频率很慢看起来不像漂流更像电梯。正确做法是先固定周期再调幅度幅度调完回过来微调angleAmplitude让倾斜方向和波浪切线方向一致。源码注释里往往还会标一组“安全区间”实际就是这套参数在低端机上验证过的范围。2.3 “值得参考”的核心状态和绘制分离很多漂流瓶效果源码之所以值得当成模板不是因为画得漂亮而是它把“海面状态”“瓶身状态”和“绘制海面”“绘制瓶子”拆开了。状态是数据绘制是副作用。这样后续加气泡、加文字、加点击交互都只是在状态上演算再往绘制里插一步。代码组织上最好的状态块不是一堆let而是一个state对象里面只存位置、相位、上一帧时间。绘制函数只接受ctx和state不读全局变量。这样源码里每个函数都能单独跑单元测试也给后文把它包装成业务组件留下了接口。如果你在源码里看到所有绘制函数都直接改canvas.width那基本可以判定参考价值有限真正值得抄的是“每帧只改数据绘制统一走一个出口”的形式。3. 漂流瓶效果源码中的海浪、渐变与 10 灯光变体3.1 三层正弦波叠加海面才有“海水味”只画一条正弦波做海面远处看像心电图。真实海浪是不同波长、不同相位、不同传播方向的正弦波叠加。漂流瓶效果源码里常见做法是画多层错落的海面每层用不同的横向偏移值再填充不同透明度的蓝色从而形成近实远虚的纵深感。我用的是三层正弦叠加返回每个横向坐标对应的海面高度function getSeaY(x, t) { const layer1 Math.sin(x * 0.008 t * 0.9) * 8; const layer2 Math.sin(x * 0.003 - t * 1.4) * 14; const layer3 Math.sin(x * 0.02 t * 0.55) * 3; return layer1 layer2 layer3; }三个分量的系数很有讲究。第一层负责主波浪波长最宽视觉上是“涌”第二层反向传播制造波峰之间的交错第三层频率最高、幅度最小相当于海面细碎光斑。这样组合之后海面的起伏不再是一眼能读完的规则曲线才有了水的随机感。瓶子的y坐标在画出来之前也直接借用同一个getSeaY算在瓶子所在x上。瓶身倾斜角也应该由海面曲线斜率决定。取x 1和x - 1两个位置的海面高度差再转成角度瓶子是“架”在浪头上而不是凭空按自己节奏摇头const dy getSeaY(bottleX 1, t) - getSeaY(bottleX - 1, t); const angle Math.atan2(dy, 2) * 0.6;这个细节比固定Math.sin(t * 1.2)真实得多。源码里真正划算的参考价值往往就在这种一两行的细节里而不是大面积渲染特效。3.2 玻璃高光和渐变怎么用才不脏漂流瓶的材质是玻璃玻璃在画布上的视觉重点是高光而不是填充色。最简单有效的高光是一道白色到透明的线性渐变沿着瓶子轴线方向倾斜 30 到 45 度制造圆柱面反光。另一种做法是画两条细长的rgba(255,255,255,alpha)路径再用globalCompositeOperation screen叠加。我一般用线性渐变保存alpha控制因为动态修改方便。function drawBottleGlass(ctx) { const x 0, y 0; const w cfg.bottleWidth / 2; const h cfg.bottleHeight; const highlight ctx.createLinearGradient(x - w, y - 20, x w, y 20); highlight.addColorStop(0, rgba(255,255,255,0.45)); highlight.addColorStop(0.35, rgba(255,255,255,0.05)); highlight.addColorStop(0.5, rgba(255,255,255,0)); highlight.addColorStop(0.65, rgba(255,255,255,0.18)); highlight.addColorStop(1, rgba(255,255,255,0)); ctx.fillStyle highlight; ctx.fillRect(x - w, y - h / 2, w * 2, h); ctx.stroke(); }渐变点越多材质感越强但高光条太多会显得瓶子像贴了反光条。0.35 的白色透明度是面积较大的高光上限超过它瓶子后面的海面细节就全被盖住。如果读过 UGUI 源码解析会发现 UI 渐变纹理的预生成思路和这里很想通高频创建渐变对象不如缓存一份颜色查找表绘制阶段只做填充。Canvas 2D 的渐变成本比 UGUI 低普通移动端每帧创建一两个问题不大超过五个就要小心。3.3 用参数切换 10 灯光变体在比较完整的漂流瓶效果源码里“效果”往往不只有瓶子而是一组可开关的灯光和粒子效果海面细碎光斑、瓶身扫光、拖动拖尾、气泡上升、入水水花、文字浮现、月光束、深色海底、波浪渐变色、瓶口指示灯。把这些效果全部做成分支用配置项决定渲染顺序能形成一张可供挑选的“效果清单”// 配置里的灯光效果开关 const effects { moonLight: true, // 月光束 sparkle: true, // 海面光斑 trailing: false, // 瓶子拖尾 bubbles: true, // 上升气泡 bottleLamp: true, // 瓶口指示灯 waveGradient: true, // 海面渐变 scrollText: false // 滚动文字背景 };遍历这份配置按固定顺序调用对应粒子函数。这种套路本质上是把“做效果”从写死变成配置化运营上线后可以直接指定要“月光 气泡”还是“闪烁 拖尾”不需要改代码。如果我要自己写一个“渐变/海浪/滚动等 10 灯光效果”的演示页也会直接用这样的数组驱动。4. 漂流瓶效果源码的性能调优从 60FPS 到低端机不掉帧4.1 先管好 DPR不要在高分屏上白耗像素很多源码在 PC 上很流畅iPhone 一到就掉帧八成是canvas.width直接写成了 CSS 宽高导致高分屏用 1 倍物理像素渲染需要缩放模糊另一部分则是设置成完整物理像素画布面积变成 4 倍绘制量翻倍。正确做法是把像素比压到 2 就收手const dpr Math.min(window.devicePixelRatio || 1, 2); function resize() { const w canvas.clientWidth; const h canvas.clientHeight; canvas.width w * dpr; canvas.height h * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } window.addEventListener(resize, resize); resize();把最大像素比限制在 2既保证文字和光晕边缘清晰又不会在 3x 屏幕上白算一倍像素。所有绘制逻辑继续用 CSS 像素坐标setTransform统一放大代码里不需要到处乘除。如果源码里每帧都重新设置canvas.width那等于同时触发一次画布清空和上下文状态重置性能损失非常明显。4.2 减少每帧对象创建和路径重建Canvas 性能杀手不是绘制本身而是每帧重复分配Path2D、gradient、Float32Array。源码里如果每帧new Path2D()在粒子多的时候 GC 会频繁触发。常见的优化做法是预创建对象池把瓶身、气泡、光束路径放到初始化函数里生成一次之后每帧直接ctx.fill(path)。class BottleEffect { constructor() { this.path new Path2D(); this.path.roundRect(-12, -28, 24, 56, 10); this.bubbles Array.from({ length: 20 }, () ({ x: Math.random() * 100, y: canvas.height, r: 1 Math.random() * 2, speed: 0.5 Math.random() })); } }另一种有效优化是显式减少计算量。用每帧一次的update()统一推进粒子位置绘制循环里只调用drawBottle和drawParticles不在绘制函数里做if (i % 2 0)这类隐藏逻辑。低端机上的表现很大程度上取决于剔除掉多少看不见的粒子屏幕外或者瓶身背后被完全遮挡的粒子直接跳过会比任何渲染技巧都见效快。4.3 用可见性状态暂停渲染循环页面切换到后台时requestAnimationFrame会停止但事件监听、音频和 WebSocket 不会。漂流瓶效果源码里最常见的性能问题是resize监听器一直没有移除或者在可见性变化时没有取消循环。加上两行保护即可document.addEventListener(visibilitychange, () { if (document.hidden) { cancelAnimationFrame(rafId); } else { rafId requestAnimationFrame(frame); } });配合pagehide事件再兜底一次移动端浏览器切后台的时机不可预测所以不依赖单一事件。顺手把resize事件也做成 debounce300ms 内重复触发只执行一次避免多开窗口拖动时每帧 resize。这些细节在手机浏览器里比桌面端更重要也是源码从“demo”变成“产品”的一道分水岭。4.4 用帧耗时表验证调优效果不要凭感觉判断性能。调优后在控制台打performance.now()差值只统计绘制耗时不统计浏览器布局。更实用的是打印每个阶段的耗时找出瓶颈阶段取值方法耗时阈值低端机海面渐变填充performance.now()前后差值小于 8ms瓶身高光单独监测小于 3ms气泡 / 灯光粒子累积计时小于 12ms全部绘制总帧耗时小于 16ms如果总耗时超过 16ms回到对应阶段去砍效果取消shadowBlur、减少海面层数、把大面积渐变改为预渲染离屏画布。漂流瓶效果源码“非常值得参考”的这一层就是这些数据驱动的调优手段比一句“优化一下”要具体得多。5. 从参考到引用把漂流瓶效果源码封装成可配置组件5.1 对外只暴露配置和事件源码作为独立页面跑没问题但落到业务里需要包一层。好的封装方式是暴露new BottleEffect(el, config)内部维护 canvas、循环和对象池外部只操作config和onMessage回调。示例import { BottleEffect } from ./bottle-effect; const bottle new BottleEffect(document.getElementById(stage), { waveAmplitude: 12, wavePeriod: 3.5, effects: { sparkle: true, bubbles: true } }); bottle.onMessage((msg) { // 用户点击瓶子时回调业务方 showModal(msg); }); bottle.setConfig({ waveAmplitude: 5 }); bottle.destroy(); // 移除事件、取消帧循环内部把cfg做成响应式改动后下一个帧直接读取新值不做自动重启动画保证视觉无缝切换。销毁方法要完整cancelAnimationFrame、移除resize、visibilitychange事件、清理画布。5.2 点击命中检测与消息条“漂流瓶”通常不只是观赏而是交互入口。命中检测不需要复杂光栅存好瓶子的中心位置和旋转外接矩形点击坐标和中心距离小于瓶身高度一半就算命中。用源码里已有的状态免去额外坐标计算。给用户反馈时在瓶身上方弹出一条半透明信息卡卡片文字逐字浮现正好和前面的渐变思路统一。这个设计也可以用于社交场景用户捞起来之后显示“写于 XX”再跳转详情页。性能上注意信息卡不要做成全新 canvas 层直接在主画布上绘制但把卡片背景放到globalCompositeOperation destination-over的前一个绘制步骤中。5.3 三个被反复踩的坑从全局变量到时间戳回跳第一个坑是环境变量污染。业务页面可能同时存在多个 canvas 效果源码里常见的let全局变量会互相覆盖。第二个坑是resize时把整个 canvas 重设导致之前的离线缓存失效高光像素发虚。第三个坑是动画状态里保存了Date.now()而事件触发时解析到的时间比渲染帧的时间戳早造成短暂回跳。这些问题的根源都在于没有把状态绑定到实例上。调试时把config暴露到window下可以从浏览器控制台直接调参数window.__bottle bottle;然后用__bottle.setConfig({ waveAmplitude: 20 })快速看效果。这是一条很顺手的调试路径不刷新页面就能把参数试完确认后再回源码里固化避免频繁重载。本文还有配套的精品资源点击获取
返回列表