资讯动态

前端下雨效果实战:Canvas粒子系统从选型到性能优化

发布时间:2026/10/13 11:11:23 来源:尧图企业网站定制
你别笑我每次在搜索引擎里敲下“前端 下雨 效果”这几个字的时候心里都挺复杂。需求描述起来就一句话“页面上下下雨”但搜出来的结果能把你从 CSS 基础拉到图形学入门中间还掺杂着各种几年前的 demo 和“复制这段代码即可”的残缺片段。这行当就是这样大部分时候你以为是找答案实际是在做信息考古。这篇就当作我给自己留的一份笔记也顺便给刚入坑前端、想给页面加点氛围感的同行踩出一条相对靠谱的路。我会从需求拆解讲到技术选型再给出一套我用 Canvas 实现的完整方案、参数调优思路以及那些文档里不会写的坑。1. 为什么“页面下雨”会变成一门玄学先说个结论真正玄的不是技术是需求本身。“下雨效果”这四个字的信息量跟“做个按钮”完全不在一个量级。按钮你问清楚颜色、圆角、文案就可以了下雨你得先弄清楚用户脑子里想的是哪一种雨。1.1 需求本身埋着雷我遇到过至少三种理解偏差很大的场景。第一种是背景氛围雨就是页面首页、落地页这种地方用户想看的是淡淡的疏离感雨丝细得几乎察觉不到更像一层动态纹理。第二种是玻璃雨常在 H5 活动页里出现雨滴打在透明玻璃上要能聚集、滑落、模糊后面的元素这个复杂度直接往上翻几倍。第三种是沉浸式暴雨配合音效、闪电动画用来讲故事或者做游戏开场。你把这三种需求都搜成“下雨效果”搜索引擎给你的自然是五花八门。所以拿到需求第一件事不是打开编辑器而是反问一句你要的是哪种雨这个雨是背景装饰还是核心视觉是点缀互动还是必须吸引用户停留需求界定的颗粒度决定了后面所有工作的方向。另外一个很隐蔽的问题是“简单”这个词的语义漂移。提需求的人说“简简单单下个雨”他心里默认的是浏览器自己就会下雨像 HLSL 里调个函数一样。但前端的现实是你用 CSS 动画搞点雨丝不难要做到雨滴连续、随机、不重复、不掉帧、可交互难度是阶梯式上升的。所以“玄学感”的根源在于预期和实现成本之间几乎没有共识。1.2 搜索结果的三大流派以我的搜索体验主流方案分三拨。第一拨是“素材流”告诉你去找一张带透明通道的雨丝 PNG然后平铺加位移。这是最快见效的做法但问题也最明显素材分辨率限制了清晰度循环重复感很强而且一旦页面宽度超过素材尺寸穿帮是必然的。第二拨是“动画流”用 CSS 的 keyframes 配合多条雨丝 div 做位移动画适合做装饰性的细小雨丝但粒子数量一多DOM 节点爆炸性能就难看了。第三拨是“画血流”用 Canvas 或者 WebGL 逐帧绘制雨滴位置。这是正路也是这篇文章的主题。讽刺的是搜索结果里第三拨方案往往是最好用的但信息密度也最高新手在“概率密度”“随机分布”“对象池”这些词面前很容易退却最后又绕回素材流陷入“玄学循环”。其实画血流的核心思想极简单把屏幕当作坐标系每帧移动一堆点的位置仅此而已。1.3 雨滴看起来到底像什么这里有一个反直觉的视觉细节值得单独拎出来说雨滴在屏幕上不是一个圆点而是一条线。原因也很简单人眼看到雨滴时它正在高速下落视觉暂留效应让它拖出轨迹。所以你在 Canvas 里画雨正确的做法是draw一条短线段起点和终点分别代表雨滴头部和尾迹而不是画一个实心圆。这个细节直接影响观感我见过不少人把雨滴画成密密麻麻的小圆点结果远看像下雪、像虫群就是不像雨。搞清楚“雨滴高速运动的短线段”这个模型后面的物理模拟就有方向了。每条雨丝就是一个粒子你需要为它维护坐标、下落速度、线段长度以及一个随机数种子来避免所有雨丝同步运动。把这些想明白之后再回看那些搜索结果你会发现玄学感瞬间消失剩下的只是技术选型和性能权衡。2. 技术选型五种方案的真实对比既然需求千差万别技术选型就不能只看“能不能实现”还得看“维护成本”和“容错空间”。我把常见方案按从简到繁排了个序附带我实际使用后的体感这样可以帮你快速找到自己需要的档位。2.1 CSS 模拟适合有节制的氛围场景CSS 方案最典型的做法是用一个或多个半透明渐变背景叠加重复背景动画制造雨丝下落的视觉。另一种常见做法是生成十几条 div 细线每条设定不同的 animation-delay 和 animation-duration让它们循环下落。这种方案的价值在于零依赖、代码量少也不会增加 Canvas 那一层渲染负担。缺点是颗粒度太粗你很难控制雨滴的随机分布动画一旦循环就容易看出规律。我的经验是它只适合“锦上添花”的场景比如一个纯展示的落地页用户扫一眼就走不会盯住屏幕看两分钟。除此之外如果设计要求雨滴碰到页面底部还有溅射效果CSS 基本就得靠额外 hack成本反而上去了。2.2 Canvas 2D最均衡的折中方案如果你要的是“看起来是真的在下雨”同时希望代码量可控Canvas 2D 是性价比最高的选择。Canvas 本质上是一块画布你每帧用 JS 清空再重绘雨滴只是画布上的线段集合。这种方式最大的好处是雨滴数量、速度、方向、透明度、颜色、长度全部是变量你可以随时调参所见即所得。性能上Canvas 2D 的绘制能力足以支撑几百根雨丝在 60fps 下流畅运行这对绝大多数页面场景都够用了。配合 requestAnimationFrame 做帧循环再加上设备像素比适配效果可以非常顺滑。维护成本也低核心代码不到 200 行就能跑起来后面加风、加闪电、加水花都有扩展空间。如果你没有明确的高性能渲染诉求我建议就用这个不要在选型上耗太久。2.3 SVG细节控的偏好SVG 也能实现雨滴动画一些开发者喜欢用它因为 SVG 元素可以单独设置 CSS 类、绑定事件语义化程度比 Canvas 高。你可以用line或者path声明一条雨丝然后用 SMIL 动画或者 CSS 控制位移。但我实际用下来SVG 在“大量粒子”场景下并不占优。每个雨滴对应一个真实 DOM 节点几十个还能接受上百个之后即便 SVG 是矢量渲染DOM 操作和布局开销也会让性能变得不稳定。而且不同浏览器的 SVG 动画性能差异较大移动端更容易卡。所以我把 SVG 定位为“细节控的偏好”适合雨滴数量少、需要独立交互的场景比如每个雨滴点击后有反应而不是大规模雨幕。2.4 WebGL重度效果的终局如果你追求电影级的雨效——景深、粒子数量过万、雨滴折射、玻璃水滴物理模拟那 Canvas 2D 就有点勉强了要上 WebGL。WebGL 的粒子系统是在 GPU 上跑的几万根雨丝同时渲染几乎是家常便饭。问题在于上手门槛陡增你要理解着色器、缓冲区、纹理等一系列概念。如果你只是想在页面上点缀一点逼真的细雨用 WebGL 属于“杀鸡用牛刀”开发周期会明显拉长后续调参也需要一定的图形学基础。我的建议是除非你的项目已经有成熟的 WebGL 渲染架构或者明确需要和 3D 场景联动否则别直接上先评估一下 Canvas 2D 的上限是不是真的不够。2.5 背景视频最省事的偷懒法还有一种方案是直接找一段下雨的视频或 GIF 当背景设置循环播放和透明度。效果嘛确实很真实因为它就是真下雨的画面。但它的问题同样致命视频文件体积大加载慢版权归属难确认透明的视频素材不好找。如果你又不想露馅页面上其他元素还得往视频里的透视关系、色调上靠限制太多了。我做项目时只在一种情况下会用视频客户对效果要求极高、发布时间紧张且能找到高质量免版权素材。平时我更愿意用代码实现因为代码实现出来的雨幕是可以跟页面交互联动的比如鼠标经过雨滴扩散、滚动页面雨速变化这是视频素材做不到的。2.6 一个简单的选型对照表方案视觉效果上限开发成本性能成本适用场景CSS 背景动画低最低低静态落地页氛围点缀Canvas 2D中高中中大部分页面雨幕效果SVG 动画中中中偏高少量雨滴加交互WebGL 粒子高高低GPU重度视觉、3D 场景视频/GIF 背景高真实最低高加载临时方案、高保真素材看完表格你应该有谱了接下来我用 Canvas 2D 方案给你完整拆一遍包括每一步的参数计算逻辑和优化细节。3. Canvas 实现我的实操过程拆解方案定下来剩下的就是动手。以下从零开始搭一个 Canvas 雨幕代码我会分段解释确保你不是直接复制粘贴而是真能理解每一行的作用。3.1 先搭一个最基础的渲染循环Canvas 动画的第一件事是拿到画布上下文然后定义一个主循环。主循环里每帧做三件事清空画布、更新粒子状态、重新绘制。用requestAnimationFrame而不是setInterval前者会跟随屏幕刷新率避免掉帧时任务堆积还能在页面切后台时自动暂停省电省 CPU。const canvas document.getElementById(rain-canvas); const ctx canvas.getContext(2d); let width, height; function resize() { width canvas.width window.innerWidth; height canvas.height window.innerHeight; } resize(); window.addEventListener(resize, resize); function frame() { // 清空画布 ctx.clearRect(0, 0, width, height); // 更新与绘制 updateAndDraw(); requestAnimationFrame(frame); } requestAnimationFrame(frame);这段代码有个细节需要注意canvas.width直接赋值成window.innerWidth会自动清空画布所以要用clearRect也行但宽度重置时更省事。另一个细节是如果你要支持高分屏就得把物理像素和逻辑像素分开const dpr window.devicePixelRatio || 1; canvas.width window.innerWidth * dpr; canvas.height window.innerHeight * dpr; canvas.style.width window.innerWidth px; canvas.style.height window.innerHeight px; ctx.scale(dpr, dpr);不处理 DPR 的话雨丝在 Retina 屏幕上会发虚这个后面视觉效果差距非常明显属于必须做的适配。3.2 雨滴的物理模型与坐标系回到之前那个模型每一个雨滴就是一条短线段。在线段落地之前它沿 Y 轴方向匀速运动沿 X 轴甚至可以有风带来的偏移。一个雨滴粒子的数据结构大致是这么几项class RainDrop { constructor() { this.reset(); } reset() { this.x Math.random() * width; this.y Math.random() * height - height; // 从屏幕上方外开始 this.speed 3 Math.random() * 7; // 下落速度 this.length 8 Math.random() * 12; // 雨丝长度 this.opacity 0.2 Math.random() * 0.4; // 透明度 } }这里有几个参数很关键。speed代表每帧下落的像素偏移范围放在 3 到 10 之间比较合适太慢像飘雪太快又容易让人眼睛疲劳。length代表雨丝的长度8 到 20 像素在普通屏幕上刚好能体现出“线”而不是“点”的感觉太长反而失真。opacity设置在 0.2 到 0.6 之间低于 0.2 几乎看不见高于 0.6 会显得雨幕很脏。有了这些数据以后每帧更新就是简单地在 Y 坐标上累加speed如果超过屏幕高度就重置回屏幕顶部。当y height时调用reset()把这条雨丝挪到上方重新开始。这就是最朴素的粒子循环。3.3 从一颗雨滴到整个雨幕单颗雨滴撑不起氛围我们需要同时维护一批雨滴。在这里代码量和视觉效果的平衡点是一个需要实践的问题。const drops []; const MAX_DROPS 300; for (let i 0; i MAX_DROPS; i) { drops.push(new RainDrop()); } function updateAndDraw() { ctx.clearRect(0, 0, width, height); for (const drop of drops) { drop.y drop.speed; if (drop.y height) { drop.reset(); } ctx.strokeStyle rgba(180, 210, 255, ${drop.opacity}); ctx.lineWidth 1; ctx.beginPath(); ctx.moveTo(drop.x, drop.y); ctx.lineTo(drop.x, drop.y - drop.length); ctx.stroke(); } }我把最大粒子数量定在 300这个数字不是拍脑袋。在我自己的项目里200 到 400 是一个甜点区低于 200 显得雨量稀疏氛围感不足高于 400Canvas 2D 的stroke操作开始产生可见的 CPU 压力帧率会掉到 50 以下。你可以在调试面板里实时调整这个值观察帧率曲线的变化。注意雨滴的初始y是负值也就是在屏幕上方之外这样第一帧画面就是完整的雨幕而不是从上往下“播种”的过程。3.4 参数标定密度、速度、透明度、风偏“像不像下雨”完全是参数堆出来的。我整理了一份自己常用的参数表给不同天气状态做推荐值按需微调即可。参数小雨中雨暴雨粒子数量120200400速度范围2-54-98-15雨丝长度6-128-2014-28透明度范围0.2-0.40.3-0.50.4-0.7风偏移量00.5-1.51-3风偏移量这个概念简单说就是每帧让雨滴的x坐标额外移动一点。比如风从左往右吹就给drop.x加上windSpeed。风太强时雨丝会变得明显倾斜视觉上反而更真实因为屏幕上的雨本来就受环境风影响。drop.x windSpeed; if (drop.x width 20) { drop.x -20; }注意左右边界要留出冗余避免雨滴直接消失在同一侧产生突兀感。我通常在width外扩 20 像素再判断这样雨丝是“走出屏幕外”而不是“被屏幕裁掉”。3.5 加入风、溅射、湿润感基础雨幕成型后真正让效果从“能用”变成“好看”的是几个加在细节上的小动作。第一个小动作是分层。传统的平铺雨幕有一种“纸片感”因为所有雨滴都落在同一个平面上。改进方法是定义两层雨滴近处的雨丝更长、更透明、速度更快远处的雨丝更短、更淡、速度更慢。近景和远景叠加之后透视感和空气感就出来了。第二个小动作是地面溅射。当雨滴到达屏幕底部时不要让它直接消失而是画一个极小的半透明圆弧模拟水花。这个效果可以用一个独立的水花数组维护生命期很短大概 3 到 5 帧就会淡出。第三个小动作是“雨落在屏幕上的湿润感”。严格模拟玻璃上的水渍需要复杂的物理模型但我们可以用一条从上到下逐渐加深的渐变叠加层来模拟屏幕潮湿。具体做法是绘制一个垂直方向的半透明渐变顶部深、底部浅混合模式用multiply或者overlay。代码量不大但整体质感提升非常明显。3.6 完整可运行代码把上面的逻辑合并就是一个可以直接存为 HTML 文件打开看效果的版本!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleCanvas 雨幕效果/title style body { margin: 0; overflow: hidden; background: #0a0e1a; } canvas { display: block; } /style /head body canvas idrain-canvas/canvas script const canvas document.getElementById(rain-canvas); const ctx canvas.getContext(2d); let width, height; const dpr window.devicePixelRatio || 1; function resize() { width window.innerWidth; height window.innerHeight; canvas.width width * dpr; canvas.height height * dpr; canvas.style.width width px; canvas.style.height height px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } resize(); window.addEventListener(resize, resize); class RainDrop { constructor() { this.reset(); } reset() { this.x Math.random() * width; this.y Math.random() * height - height; this.speed 3 Math.random() * 7; this.length 8 Math.random() * 12; this.opacity 0.2 Math.random() * 0.4; this.windOffset Math.random() * 1.2; } } const drops []; const MAX_DROPS 300; for (let i 0; i MAX_DROPS; i) { drops.push(new RainDrop()); } function frame() { ctx.clearRect(0, 0, width, height); for (const drop of drops) { drop.y drop.speed; drop.x drop.windOffset * 0.5; if (drop.y height 20) { drop.reset(); drop.y -10; } if (drop.x width 20) { drop.x -20; } ctx.strokeStyle rgba(180, 210, 255, ${drop.opacity}); ctx.lineWidth 1; ctx.beginPath(); ctx.moveTo(drop.x, drop.y); ctx.lineTo(drop.x - drop.windOffset * 2, drop.y - drop.length); ctx.stroke(); } requestAnimationFrame(frame); } requestAnimationFrame(frame); /script /body /html这个版本已经可以直接跑出自然的多雨效果。如果你想让它更精致下一步就该认真考虑性能问题尤其是移动端和低配电脑。4. 性能优化与移动端适配写特效有句老话效果好不好要看低端机上卡不卡。Canvas 雨幕的瓶颈集中在粒子数量和绘制 API 的调用频次上所以优化思路基本围绕这两点展开。4.1 把粒子数压到“看不见上限”很多初学者一上来就把粒子数拉到 1000看桌面电脑全屏 60fps以为没问题。一上手机、一开 devtools 的 CPU 降频模拟立刻暴露。Canvas 2D 的stroke是一个开销很高的操作每帧调用 1000 次stroke()和只调用 300 次差别的帧耗时往往是几倍的关系。我的实践是先把粒子数压到 200然后按“你转动屏幕或移动页面时看不出破绽”为标准往上加。如果加到 400 已经流畅就停在 400如果需要性能余量就退回 300。另外可以把雨丝长度和透明度做成随机分布这样即使粒子数只有 180视觉上也不会显得稀疏因为每个粒子面积和明暗都不同。还有一个容易忽略的点移动端页面滚动的场景Canvas 雨幕如果固定在视口内那雨滴只跟着屏幕走如果放在全屏背景容器里那么滚动时 canvas 内容必须正确跟随。我遇到过的坑是把 canvas 设为position: fixed结果页面滚动时雨还在原地这是理所当然的因为 fixed 定位本来就不滚。需要根据产品意图决定雨到底是背景还是装饰层。4.2 用对象池代替频繁创建粒子雨滴需要持续复用不要每帧 new 一个。下面的代码更合理直接预先创建一批粒子用循环轮流复位const drops new Array(MAX_DROPS).fill(0).map(() new RainDrop());当雨滴超出屏幕调用reset()重置状态而不是splice()后重新 push。频繁创建和销毁对象会产生垃圾回收压力垃圾回收一频繁动画就会出现周期性 “跷跷板式”掉帧。我见过有人为了“看起来更随机”每个雨滴掉落之后都用new RainDrop()生成一个新对象并立即替换这带来了明显的 GC 停顿。用对象池之后一次探测到的 GC pause 从平均 7ms 降到了 2ms 以下体感差别很大。4.3 降级策略低端机不要硬扛不是所有手机都能承受 300 根雨丝逐帧刷新。合理的降级策略是渐进式的检测到帧率连续低于某个阈值就动态削减粒子数量。实现方式很直接用requestAnimationFrame的回调时间戳计算 FPS如果连续 30 帧低于 45就把MAX_DROPS砍掉一半。如果设备硬件本身较低端还可以直接减少绘制透明层的叠加次数或者把雨丝改成纯色短直线不再使用半透明rgba。半透明混合的开销一般比纯色高这在低端机上是有效果的。另有更适合移动端的调整技巧canvas 尺寸不要铺满全屏的物理分辨率可以用逻辑分辨率加上ctx.scale(dpr, dpr)。在性能不足的机型上还可以把dpr强制限制到 1.5 或 2牺牲一点清晰度换流畅度。我实测过dpr从 3 降到 2帧率提升将近 20%画面锐度损失肉眼几乎不可见。4.4 移动端和低配电脑的实测表现我找了几台不同档位的设备做过简单测试数据大致是设备档位粒子数帧率表现调整建议旗舰手机40058-60fps可维持中端手机30045-55fps降到 220入门口袋机20030-40fps降到 120禁阴影低配笔记本30050-55fps可维持减少透明度层如果你的页面本身已经有很多动画、图片懒加载、滚动监听雨幕的预算还要再压缩。我一般建议雨幕占据整机渲染预算的 5% 到 10%这才算一个合格的装饰层。超过这个比例在复杂页面上很容易拖垮整体交互体验。5. 常见问题与排查技巧实录写特效的日常就是改 bug雨幕也不例外。我把自己踩过的一些典型坑整理成一张速查表外加几个比较隐蔽的排查技巧遇到类似问题可以直接对照。5.1 常见问题速查表现场可能原因解决方案雨丝全是小圆点只画了头没画尾或者线段长度太短检查lineTo终点与起点 Y 坐标差雨不动帧循环里忘记调用requestAnimationFrame确认递归调用存在雨丝模糊canvas.width未按 DPR 设置按dpr设置物理像素并setTransform大面积透明闪烁没调用clearRect旧帧残留每帧开头先清空画布滚动页面雨偏了canvas 定位错误检查position: fixed或滚动容器低端机狂掉帧粒子数过多、半透明混合多按降级策略削减粒子数和透明度明明 60fps 却出现周期性停顿频繁new对象导致 GC改成对象池复用粒子5.2 几个容易忽视的深层问题网格线问题对设计侧很致命。某些情况下雨丝长度如果固定成 10 像素那么所有雨丝间距离总保持 10 的倍数关系视觉上会浮现横向的间隔线看着像雨幕被整齐切割了。解决方案很简单让雨丝长度在一个连续范围内随机分布比如 8 到 20而不是固定整数档位。另一个问题来自 Canvas 的lineCap。默认lineCap是butt线段端点呈直角雨丝头部会显得僵硬尤其在贴近屏幕顶部的瞬间。改成round之后雨丝两端带半圆帽视觉连续性和手绘质感会好很多。代价仅是轻微的性能损耗可以忽略。半透明雨的叠加问题更隐蔽。大量半透明雨丝叠加后画面有时会越来越灰白像起了雾。这是因为半透明线段互相叠加颜色向白色靠拢。解决办法是控制总体的透明度上限或者在绘制时统一降低雨丝颜色的蓝色通道让整体色调偏冷、偏暗而不是偏白。5.3 让雨更有层次感的三个技巧层次感最能拉开普通实现和高级实现的差距。我的做法有三个。第一个是视差。把雨幕分成两个 canvas 层叠加一个渲染疏雨、一个渲染暴雨两层之间加不同的transform: translateY速度滚动时高层雨更快地跟随鼠标低层雨缓慢移动。这种多层视差给人的立体感非常明显。第二个是时间调制。雨不是一直均匀下的偶尔会出现一阵急促的雨然后变缓。用正弦函数调制粒子刷新速率就能实现“一阵一阵”的自然节奏。比如每帧给雨滴总数乘一个0.6 0.4 * Math.sin(t / 800)的系数这比恒定数量的雨幕生动得多。第三个是模拟近光和远光氛围。雨幕不要整屏统一亮度中间区域可以稍亮一些边缘稍暗。这种方式人眼天然会聚焦中部信息区又不影响页面的可读性。实现起来也不难在 canvas 上叠一层径向渐变遮罩中心透明、边缘半透明即可。最后聊两句实在的效果写到这一步回头看搜索引擎那些“玄学”结果其实不冤。真正玄的从来不是技术而是需求被简化到了不可执行的程度。哪怕只是一个下雨效果背后也藏着粒子系统、动画循环、性能预算和大量调参经验。按照我个人的习惯只要项目没有硬性要求我一直坚持“先 Canvas 2D不上 WebGL不上第三方库”。第三方依赖虽然能省事但在动画领域它们往往带来更大的体积和不可控的兼容成本。自己写 200 行代码反而能在调参时获得最大的自由。最后再分享一个平时不怎么有人提的技巧调参时别直接看屏幕先录一段慢动作视频。慢动作下你能清楚看到雨丝是平滑移动还是有抖动、循环边界是否穿帮、雨滴末梢是否生硬。修完慢动作里看到的问题正常速度下的效果基本就能过关了。希望这套从拆需求到实现、优化的完整流程能让你在做“页面下雨”这个需求时少走点弯路。

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

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

免费获取报价 →
↑