☰
Canvas 雨滴特效从原理到上线:性能调优与移动端适配全解析
2026/10/13 11:11:21 网站建设 项目流程

说句掏心窝的话,前端这行当有时候真的很玄学。你明明只是想简简单单在页面上整点下雨的效果,搜一下教程,满屏都是“三行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.css

rain.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; } }

这里有几个参数看着随意,其实是试出来的:

  • 长度为什么是 8~22?太短了像虚线,太长了像光剑。这个区间在常规屏幕上像雨丝。
  • 速度为什么要和长度大致正相关?现实里雨滴越大终端速度越快,视觉上长雨丝移动慢会有明显的“假”感。我在代码里让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明显卡顿

我的建议是默认 300~500 颗,足够撑起一片密雨,性能也稳。如果想在移动端表现更好,可以用屏幕高度做归一化: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 项目,因为它足够小,但又能逼你处理好移动端适配、性能优化和状态管理这几件大事。如果你做完后还想继续扩展,我建议往两个方向试:一个是让雨滴落地溅出水花,另一个是把颜色从冷色调换成暖色调做“黄昏雨”的效果。前者练粒子生命周期,后者练色彩审美,都是实打实涨经验的事。

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

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

立即咨询