资讯动态

Canvas 雨滴特效从原理到上线:性能调优与移动端适配全解析

发布时间:2026/10/13 11:11:23 来源:尧图企业网站定制
说句掏心窝的话前端这行当有时候真的很玄学。你明明只是想简简单单在页面上整点下雨的效果搜一下教程满屏都是“三行CSS实现雨滴特效”“十行JS让你的页面下雨”。等你真上手想把它做得好看一点、在手机上也流畅、丢到线上不翻车才发现这一整条路上全是坑。这篇就把我做这个“Canvas 雨滴特效”的全过程写下来从方案选型、核心算法、性能调优到排错实录尽量不留死角。这篇文章适合谁看刚接触 Canvas 想做点视觉效果练手的新人以及在业务里被“加个下雨背景”这种需求折磨过的同学。我会把代码、参数、踩坑过程都展开讲保证你照着能做出来也能知道自己改的每一行到底在影响什么。1. 先说结论这个特效不难难在“做完”1.1 “下雨效果”到底在做什么我见过很多人在需求评估阶段把下雨特效定性为一个“背景装饰”小任务排期半天。实际上拆开了看它至少包含四个独立模块雨滴的运动模型速度、方向、长度、密度这些参数互相耦合。渲染方式是用 CSS 动画做少量雨丝还是用 Canvas 每一帧重绘整片雨幕。残影与拖尾效果这个细节决定了雨是“干净的线条”还是“有质感的雨丝”。设备适配和性能兜底低端机不能卡高分屏不能糊切后台回来不能失控。这四个模块全打通之后你得到的才算一个能交付的“效果”而不是一段 Demo。网上那些“三行 CSS”实现的下雨本质上是背景图滚动或者几条直线的位移动画离“能上线”的质感差得很远。1.2 三条技术路怎么选我做之前先把方案在脑子里过了一遍主流就三条路纯 CSS、Canvas 2D、WebGL/Shader。把它们放在一起对比结论会非常清晰方案实现成本性能上限视觉效果适合场景纯 CSS 动画 渐变背景低中低线条一多就卡平面化雨丝感弱装饰性背景对质感要求不高Canvas 2D 逐帧绘制中中高支持数百粒子流畅可控制拖尾、模糊、溅射大部分落地需求推荐WebGL 粒子 Shader高极高上万粒子轻松可做景深、体积光、物理溅射高精度视觉页、移动端大型互动我最后选了 Canvas 2D原因很朴素WebGL 的 Shader 调试成本高需求方往往只是要“下雨”这个感觉犯不着为它搭一套 WebGL 管线。而 Canvas 2D 只要控制好粒子数量和绘制开销跑几百颗雨滴完全没问题效果上限也远高于 CSS。这是投入产出比最高的一套。2. 基础环境与画布初始化跨端开局的三个细节2.1 工程与依赖这个项目不需要任何重量级依赖。我用的是 Vite 搭了一个原生 JS 模块结构非常简单rain-eff/ ├── index.html ├── src/ │ ├── main.js │ ├── rain.js │ └── style.cssrain.js负责粒子的生成和绘制逻辑main.js负责启动和 resize 监听style.css只放页面布局。如果你用的是 Vue 或 React把rain.js设计成类并导出在mounted或useEffect里初始化就行Canvas 操作和框架解耦。2.2 画布尺寸与 devicePixelRatio为什么必须处理这个细节是“玄学”的重灾区。很多新手会让 Canvas 画布宽高等于 CSS 宽高结果在高分屏上一看雨丝全是糊的锯齿。原因很简单Canvas 的位图宽高默认是 CSS 像素值而设备物理像素可能是它的两倍甚至三倍。比如一台devicePixelRatio简称 DPR为 3 的手机逻辑宽度 375物理宽度 1125。你不把 Canvas 内部尺寸放大到物理像素系统就只能用 375 个位图像素去铺满 1125 个物理像素每一根雨丝都被强制插值放大自然糊。正确的初始化逻辑是function setupCanvas(canvas, ctx) { const dpr window.devicePixelRatio || 1; const { clientWidth, clientHeight } canvas.parentElement; canvas.width clientWidth * dpr; canvas.height clientHeight * dpr; canvas.style.width clientWidth px; canvas.style.height clientHeight px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }setTransform这一步必须做它会把坐标系缩放回逻辑像素后续所有绘制代码都能直接按 CSS 像素来写不用在每个坐标值上乘 DPR。这个写法我复用到好多项目里至今没出过问题。2.3 初始化时机玄学白屏的源头项目里如果 Canvas 放在一个初始隐藏的容器里或者容器大小依赖图片加载完成千万别在DOMContentLoaded时就量尺寸。我踩过一次某模块的落地球在图片加载完才定位Canvas 却在页面加载时就初始化了量出来的高度是 0画布尺寸变成 0×0后面怎么画都是白屏。后来改成在容器可见后再调用setupCanvas问题立刻消失。这也算是我对“前端玄学”的第一个理解大部分诡异问题归根结底是一个时序问题。3. 核心渲染算法从一颗雨滴到一片雨幕3.1 雨滴的数学模型一颗雨滴在屏幕上不是一个点而是一条线段。它的状态由五个参数决定起点坐标、长度、速度、斜率偏移、透明度。为了让雨看起来不呆板所有初始值都要随机而且要符合视觉直觉。我用的初始分配大概是这样的class RainDrop { constructor(width, height) { this.x Math.random() * width; this.y Math.random() * height; this.length 8 Math.random() * 14; this.speed 4 Math.random() * 6; this.opacity 0.2 Math.random() * 0.6; // 雨丝并不是完全垂直会受一点“风”的影响 this.slope 0.1 Math.random() * 0.1; } }这里有几个参数看着随意其实是试出来的长度为什么是 822太短了像虚线太长了像光剑。这个区间在常规屏幕上像雨丝。速度为什么要和长度大致正相关现实里雨滴越大终端速度越快视觉上长雨丝移动慢会有明显的“假”感。我在代码里让length和speed用同一个随机区间本质上保证长的跑得快短一点的慢慢飘这个细节直接决定真实感。更新逻辑非常轻量update(delta) { this.y this.speed * delta; this.x this.speed * this.slope * delta; }delta是上一帧到当前帧的秒数用来保证在不同刷新率屏幕上雨滴速度一致。这一点不接受反驳你用固定的 speed在 60Hz 和 144Hz 显示器上的速度观感差 2.4 倍很多“玄学”的时快时慢就是这么来的。3.2 残影与拖尾的实现原理这一节是这个特效的灵魂。普通做法是每一帧先clearRect清屏再画线画出来的雨是干净但单薄的线条。为了做出雨丝的“连成一线”的感觉要用一个很经典的技巧不清除画布而是覆盖一层半透明黑色。ctx.fillStyle rgba(0, 0, 0, 0.05); ctx.fillRect(0, 0, canvasWidth, canvasHeight);你可能会问这不就把雨滴位置对应的像素也一起糊掉了吗对但这就是残影出现的原理。上一帧画出的雨线还没有完全消失新的半透明覆盖层叠加在上面旧线的对比度被压低一点逐渐淡出。每一帧都这样积累就形成了雨丝后面拖着的“尾巴”。这个 alpha 值的物理意义是“上一帧信息保留比例”。0.05 对应保留 5% 的旧痕迹尾巴会比较长有细雨朦胧的感觉。如果你把它调成 0.2尾巴就短很多雨变得干净干脆。我做了一个预设表alpha 值视觉效果适用场景0.02~0.04长拖尾梦幻朦胧情感向页面、雨夜氛围0.05~0.08标准雨丝有质感默认推荐0.1~0.2短干净雨滴感强写实向、雨势较大3.3 雨滴落地与重新开始雨滴掉出屏幕之后不能一直白白循环下去不然粒子总量会不断膨胀。我的处理是掉出画布底部后直接重置到顶部并随机横向位置。雨滴降落到地面时的“溅射”效果很有质感但涉及粒子生命周期管理这里不做展开只提一个思路落地时生成几个极短寿命的斜向小粒子一两帧后消失。if (this.y canvasHeight) { this.y -this.length - Math.random() * 20; this.x Math.random() * canvasWidth; }注意这里重置的y是负值不能是 0。如果从 0 开始雨滴会先画出屏幕外再逐渐掉进来视觉效果就是雨从顶部突然“冒出来”而不是“落下来”这个负偏移量是很多 Demo 不写但体验差距极大的细节。完整绘制代码合在一起就是这个样子draw(ctx) { const endX this.x - this.slope * this.length; const endY this.y - this.length; ctx.beginPath(); ctx.moveTo(this.x, this.y); ctx.lineTo(endX, endY); ctx.strokeStyle rgba(180, 210, 255, ${this.opacity}); ctx.lineWidth 1; ctx.stroke(); }一条雨丝从(x, y)画到(endX, endY)后者是往左上角偏移的配合斜向运动雨就整体往右下倾。颜色用淡蓝偏白是因为雨夜里人眼对冷色调更敏感也更符合“下雨”的固有印象。4. 性能与体验调优把“能跑”变成“好用”4.1 每帧工作量的控制Canvas 2D 每帧的消耗主要在strokeStyle字符串解析和路径绘制上。字符串rgba(180, 210, 255, ${this.opacity})每次都要解析一遍量一大就是不可忽略的开销。优化办法有两个第一个把透明度分层缓存。雨滴透明度离散化为几个档位比如 0.3、0.5、0.7每档对应一个预先生成的strokeStyle绘制时直接取用不拼字符串。第二个控制粒子总量。我做过一个简单的性能曲线测试粒子数量60Hz PC 帧耗时中端手机帧耗时体感2000.5ms2ms流畅5001.1ms4.5ms流畅10002.3ms9ms轻微掉帧20005.5ms22ms明显卡顿我的建议是默认 300500 颗足够撑起一片密雨性能也稳。如果想在移动端表现更好可以用屏幕高度做归一化particleCount Math.min(500, Math.floor(height * 0.8))屏幕越大粒子越多但总量有上限。4.2 对象池与位图缓存别再每帧 new 对象粒子系统最常见的性能杀手就是每帧生成新对象。如果每一帧遍历所有雨滴都new RainDrop()旧对象又交给垃圾回收帧率会被 GC 卡到一抽一抽。我的做法是初始化时一次性生成固定数量的雨滴实例每帧只更新它们的坐标不做增删constructor(width, height, count) { this.drops []; for (let i 0; i count; i) { this.drops.push(new RainDrop(width, height)); } }循环里做的就是update和draw不存在任何动态分配。这一点对移动端尤其重要。还有一个进阶套路把雨丝预渲染到一个小 Offscreen Canvas 上。因为雨滴的形状基本固定只是位置和透明度不同你可以画几种标准长度的雨丝每帧用drawImage把它们贴到主画布上。实测能省下一半以上的绘制时间代价是灵活性下降。这个看需求我最后没用因为想保留透明度渐变和长度随机但如果你要跑上万颗雨滴这几乎是必经之路。4.3 后台标签页的调度问题页面切到后台再回来的时候requestAnimationFrame在后台会被暂停回来后如果直接用delta累加坐标雨滴会瞬间跳走一大截。这种“瞬移”也是最常见的玄学之一。解决办法很简单给delta设置一个上限值比如单帧最多只让雨滴移动 0.05 秒的量。超过的部分直接丢弃雨滴回到前台的瞬间只是正常延续不会变成闪电。const dt Math.min((now - lastTime) / 1000, 0.05);另外如果页面完全不可见可以暂停整个渲染循环等visibilitychange触发再重新开始省电也省 CPU。这是我在做移动端项目时养成的好习惯后台消耗为零产品那边还很满意。5. 常见问题与排错实录那些“玄学”时刻5.1 白屏与半透明覆盖的死循环很多人做残影效果时会遇到一个诡异的现象页面越来越“脏”最后变成一团黑色。这不是算法错而是覆盖层的叠加速度超过了雨滴的绘制速度。比如你的 alpha 是 0.5每帧覆盖一次帧率按 60fps 算两秒内同一个像素位置被叠加 120 次黑度早就累积到看不清雨了。排查方法也简单把覆盖层的 alpha 改成 0.05看两秒内页面是否恢复清爽。如果还是黑说明你的残影叠加没有经过“衰减”机制旧画的线永不消失问题一定出在一个地方——你在覆盖半透明层之前又调用了clearRect或者残影生成和实际绘制在同一个循环里被重复执行了。对照代码里fillRect和stroke的调用顺序一查一个准。5.2 移动端为什么格外卡移动端 Canvas 表现差很多时候不是 CPU 不够而是 GPU 参与度太低。Canvas 2D 默认走 CPU 渲染碰到大面积渐变背景和透明叠加就会吃力。优化方向有三个能用整数坐标就不要用浮点。雨滴x、y是浮点没关系但绘制前可以Math.floor一下减少像素插值计算。把lineWidth固定为 1不要用太细或太宽的线半透明细线是 Canvas 2D 相对高效的绘制单元。避免在同一帧内连续切换shadowBlur。阴影模糊是移动端 Canvas 性能杀手雨丝不需要阴影把阴影效果去掉。按这三步优化之后我在一个千元机上实测500 粒子稳定在 50fps 上下视觉上已经算流畅。5.3 上线后效果“变了”这个坑尤其让人崩溃本地开发一切正常打包上线后发现雨滴颜色不对或者 Canvas 一片空白。我在某个项目里遇到过的是 Canvas 尺寸读取容器高度时上线后容器高度依赖异步数据渲染初始化时拿到的是 0。本地开发时数据加载快肉眼感觉不到时序差线上服务器慢了一拍就暴露了。另一个高频问题是生产环境对应的静态资源路径。你如果用了纹理图片来渲染雨滴图片路径必须处理成可访问的地址否则drawImage画不出东西而报错往往还不会打断主流程最后表现就是天气晴朗。这个排查思路分享给遇到“上线就雨停”的朋友先看 Console 有没有资源请求 404再看容器尺寸是不是 0最后再怀疑算法。5.4 常见问题速查现象可能原因解决办法雨丝模糊未处理 DPR按 2.2 的初始化放大像素雨丝带残影但页面变黑覆盖层 alpha 过大alpha 下调到 0.05 左右切后台回来雨滴瞬移delta 累计过大给 delta 设置上限移动端掉帧每帧拼接字符串 / 阴影缓存 strokeStyle去掉 shadowBlur容器隐藏时初始化白屏尺寸为 0容器可见后再初始化上线后雨停资源 404 / 容器高度 0检查网络请求与初始化时序最后分享一点实际感受把这一连串做完之后我再听到“前端很玄学”这种话其实不太认同了。大部分所谓玄学背后都有明确的技术原因只是它藏在 DPR、时序、渲染上下文这些不常被人提起的角落里。下雨效果算是我推荐给前端新手的第一个 Canvas 项目因为它足够小但又能逼你处理好移动端适配、性能优化和状态管理这几件大事。如果你做完后还想继续扩展我建议往两个方向试一个是让雨滴落地溅出水花另一个是把颜色从冷色调换成暖色调做“黄昏雨”的效果。前者练粒子生命周期后者练色彩审美都是实打实涨经验的事。

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

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

免费获取报价 →
↑