Unity雨天渲染:Shader实现屏幕雨滴与空间雨幕效果
2026/9/18 7:45:42 网站建设 项目流程

做天气系统那段时间,我最大的感触是:很多团队一提到“下雨”就急着往场景里堆粒子,结果粒子数量冲上几万、Draw Call爆表,画面却依然不像在下雨。后来我把重心挪回Shader本身,用两个纯Shader方案解决了“镜头代入感”和“空间氛围感”两个方向的问题。这篇文章就把这两个效果拆开讲清楚:一个是屏幕空间的雨滴滑落效果,模拟雨水打在镜头上再流下去的折射感;另一个是全屏Shader做的空间雨丝雨幕,带场景遮挡判断,配合粒子做近景补充。两个效果都不依赖第三方插件,适合想自己掌控渲染细节的Unity开发者参考。内容里会包含核心原理、贴图准备、可运行代码片段,以及我在移动端实机测试时踩过的坑,希望能帮你在项目里少走几步弯路。

1. 先把雨天的视觉构成拆开:Shader负责哪几层

1.1 一帧雨天画面里其实藏着四层效果

站在玩家视角看一场雨,画面信息大致可以分为四层。第一层是天空和景物的整体压暗、雾气和云的阴沉感,这一层通常由环境光照和雾效解决。第二层是空间中的雨丝,也就是雨水从天上落下来的线条感,这一层负责告诉玩家“正在下雨”,属于氛围核心。第三层是地面、车顶、树叶等表面的湿润反射,雨后材质会变亮、出现镜面反射,这一层一般靠材质切换或贴图混合。第四层是镜头上的水珠和雨滴滑落痕迹,这一层不是真实世界里“镜头前”存在的东西,而是模拟玩家眼睛或相机镜头上沾了水,属于沉浸感最强的近景层。

很多项目的问题在于把第二层做得太重,中远景铺满粒子,近景却完全没做镜头水痕,导致玩家总觉得“雨和我之间有距离感”。所以我在设计雨天方案时,始终把空间雨丝和屏幕雨滴滑落作为两个互补的必选项,前者负责空间氛围,后者负责镜头代入感。

1.2 两个Shader效果的分工与配合

这篇文章要讲的两个效果,正好覆盖上面说的第二层和第四层。屏幕雨滴滑落效果是后处理方案,它在画面输出前采样当前帧的屏幕结果,按照雨滴法线贴图做UV偏移,再叠加滑落轨迹,模拟出“透过布满雨水的镜头看世界”的效果。空间雨丝雨幕则是用全屏Shader重建世界坐标,在一层或多层虚拟平面上滚动雨丝噪声纹理,让画面中出现持续下落的雨线,而且会被场景物体正确遮挡。

这样分工的好处是性能消耗可控。空间雨幕不会因为雨滴数量增多而增加Draw Call,屏幕雨滴滑落也只是额外一两次纹理采样。相比粒子方案动辄几百上千个粒子实例,Shader方案的设备兼容性和帧率稳定性都要好很多。我在项目里最终采用的是“后处理雨幕 + 屏幕水痕 + 少量近景粒子”的组合方案,效果和性能都比较理想。

2. 效果一:镜头上的雨滴滑落效果

2.1 核心原理:折射的本质就是屏幕UV扰动

屏幕空间雨滴效果的原理,说破了一层窗户纸就能理解:一个正常的不透明物体不会影响画面采样,但水珠是透明的折射体,它会让光线经过时发生偏折,于是我们看到的水珠后面的画面位置就发生了偏移。在屏幕后处理Shader里,这种偏移体现为采样屏幕纹理时的UV坐标修正。

具体来说,后处理默认把当前画面纹理_MainTex原样输出,也就是采样坐标直接用屏幕UV。做了雨滴后,我们在雨滴覆盖的区域对UV加上一个由法线贴图决定的偏移量,再去采样画面纹理,这个区域的画面就会像被水珠扭曲了。法线贴图中R和G通道通常存储法线在切线空间的方向,我们将其映射为UV偏移的方向,B通道则可以作为雨滴覆盖范围的Mask来控制扰动强度范围。

用生活化的类比来说,隔着玻璃杯看手指,手指的位置会发生偏移和扭曲,并不是因为手指动了,而是光路被杯壁改变了。屏幕雨滴效果就是在采样阶段改变了“光路”,画面内容本身并没有变化。

2.2 三张贴图的准备与制作思路

要做这个效果,至少需要准备三张贴图。第一张是雨滴法线贴图,尺寸不用太大,512x512或1024x1024都够用,内容是多颗大小不一的圆润水珠,水滴中心鼓起、边缘较平,法线方向从中心向边缘过渡。第二张是雨量分布图,用低频噪声图就行,灰度值高的区域表示那里雨滴更密集。第三张是滑落条纹图,灰度图即可,内容是有一定倾斜角度的短线或条纹,用来模拟水珠滑落后留下的水痕。

如果手头没有美术资源,法线贴图可以用两张图合成:先用PS画一张雨滴形状的高度图,黑色背景、白色圆形水滴,然后用Shader做Sobel梯度算子求法线,这样获得的就是一张可用的法线贴图。雨量分布图可以直接用Perlin噪声贴图,降低对比度,甚至可以在运行时用Unity的Mathf.PerlinNoise生成到一张Texture2D里。

制作时要注意法线强度不要调得太大,否则出现在屏幕上的扭曲会非常夸张,看起来像空间扭曲而不是水珠折射。我的经验是把法线贴图导入设置里的Create from Grayscale选项配合Bumpiness拉到0.2左右,比较接近物理折射的观感。

2.3 分层实现:静态水珠、滑落尾迹、大颗滑落

直接采样一张法线贴图做UV偏移,出来的效果是“屏幕上均匀分布着静止的水滴”,缺少滑落的动态。要让画面活起来,需要拆成三层来叠加。

第一层是静态水珠层,负责基础折射感。这个层采样雨滴法线贴图,通过雨量分布图控制强度,UV偏移后采样屏幕纹理。第二层是滑落尾迹层,采样条纹图,让条纹沿风向滚动,配合时间偏移,模拟水珠滑落时拉出的细长痕迹。第三层是大颗滑落层,用时间周期控制少量大水滴从上往下滑动,这部分最能增强真实感,因为现实中雨滴在镜头上是持续滑落的,而不是一直静止。

下面给出一段简化但可运行的后处理Fragment代码思路,在Built-in管线里通过OnRenderImage的材质Blit执行:

sampler2D _MainTex; sampler2D _DropNormal; sampler2D _RainMap; sampler2D _StreakTex; float _RainTile; float _SlideSpeed; float _DistortStrength; float4 frag(v2f_img i) : SV_Target { float2 uv = i.uv; // 雨量分布:控制哪些位置有雨 float rainAmount = tex2D(_RainMap, uv * _RainTile).r; // 大颗雨滴滑落:用时间+分布图决定下落进度 float t = frac(_Time.y * _SlideSpeed + rainAmount * 2.0); float2 dropUV = uv * 4.0 + float2(0.0, -t * 2.0); // 雨滴法线扰动 float4 normal = tex2D(_DropNormal, dropUV); float waterMask = smoothstep(0.02, 0.35, normal.b); float2 offset = (normal.rg - 0.5) * _DistortStrength * waterMask * rainAmount; // 滑落尾迹:沿风向滚动条纹图 float2 streakUV = uv * 3.0 + float2(_Time.y * 0.15, 0.0); float streak = tex2D(_StreakTex, streakUV).r; offset.y -= streak * 0.05 * rainAmount; // 边缘UV保护,防止采样到画面外 float2 finalUV = clamp(uv + offset, 0.001, 0.999); return tex2D(_MainTex, finalUV); }

这段代码演示的是核心思路,真正使用还要根据自己的美术需求调整。注意normal.b作为Mask的做法,是因为很多法线贴图的B通道近似代表高度或覆盖率,用它截断可以让UV偏移集中在雨滴覆盖区域,不会让整个屏幕出现无规律的扭曲。

2.4 让镜头水痕更自然的几个参数

这个效果翻车最常见的原因,是扭曲强度失控。_DistortStrength在移动端建议控制在0.02到0.05之间,超过0.08画面上会出现明显的网格状拉伸,尤其是边缘部分。如果你发现雨滴区域的画面出现“融化”效果,先把强度减半再看。

另外一个重点是滑落速度。实际雨滴滑落不是匀速直线运动,它受重力影响是加速的,而且越大的水珠滑落越快。要模拟这种感受,可以用一个时间函数做二次方映射,比如让滑落进度t = frac(_Time.y * _Speed); t = t * t;,这样雨滴在每段滑落周期内会有“加速下坠”的效果,比匀速看起来自然得多。

最后一定要记得处理摄像机移动的情况。后处理是在屏幕空间做的,画面本身随相机变化,水痕固定在镜头表面是合理的。但如果你在第三人称游戏中做这个效果,建议把扰动强度降低到原来的三分之一,或者干脆做成距离摄像机较近时才出现,避免玩家觉得“镜头脏了一天”。

3. 效果二:空间雨丝雨幕效果

3.1 为什么全屏Shader比粒子雨更适合做中远景

中远景的粒子雨在视觉上有很多麻烦。首先是粒子数量问题,想让雨在中远景看起来连续,粒子数量至少几千,移动端压力很大。其次是视觉形态问题,粒子是用四边形面片渲染的,中远景下这些面片会叠加成一条条生硬的短线,观感反而像某种噪点干扰而不是雨。第三是排序和遮挡问题,粒子在半透明队列里与场景物体的深度关系、与雾效的混合顺序,都需要额外处理。

全屏雨幕的思路完全不一样:它不生成任何几何体,而是在一个覆盖屏幕的Pass里,通过深度纹理重建每个像素的世界坐标,再用世界坐标的平面分量去采样雨丝噪声纹理。这样画面中每个像素的雨丝是连续生成的,没有粒子聚散问题,Draw Call也没有新增。

代价是这个方案的空间感是“虚拟”的。你看到的雨丝其实固定在一层或几层虚拟平面上,而不是像真实粒子那样散布在三维空间中。为了弥补这一点,一般会做两层到三层雨幕,远近各一层,通过不同的滚动速度和透明度拉开层次感。

3.2 重建世界坐标并生成连续雨丝

要用世界坐标采样雨丝,首先要从屏幕UV和深度值重建世界坐标。在Unity里,最方便的做法是在顶点着色器里计算一条从相机到远裁剪面的射线方向,然后按线性深度缩放:

struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 viewDir : TEXCOORD1; }; v2f vert(appdata_img v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.texcoord.xy; // 从相机位置出发指向远裁剪面的方向 float3 viewDir = -unity_WorldToCamera._12_22_32; // 简化取法,需按实际管线修正 o.viewDir = viewDir; return o; } float4 frag(v2f i) : SV_Target { // 深度重建 float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); float linearDepth = LinearEyeDepth(depth); float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.viewDir; // 用世界XZ坐标采样雨丝 float2 sheetUV = worldPos.xz * _SheetTile; sheetUV += float2(_Time.y * _FlowSpeed, -_Time.y * _FallSpeed); float rain = tex2D(_RainSheet, sheetUV).r; rain = smoothstep(_Threshold, _Threshold + _Softness, rain); float4 sceneCol = tex2D(_MainTex, i.uv); return sceneCol + float4(_RainColor * rain * _Intensity, 0.0); }

这里有几个值得注意的细节。雨丝纹理建议使用一张高对比度的条纹噪声图,条纹方向偏向垂直,这样才能被滚动成“雨丝”。_FlowSpeed控制水平风向偏移,_FallSpeed控制下落速度,两个值不同会让雨丝呈斜向落下。如果只改一个,雨丝会出现完全水平或完全垂直的滑动,看起来很假。

雨幕平面上限要设置好,否则当视线看向地平线附近时,重建出的世界坐标会非常大,远处采样纹理就会变成一片杂色。常见的做法是把worldPos到相机的距离截断,超过设定距离的部分直接让雨丝透明度归零。

3.3 多雨幕层的近远分层与场景遮挡

单层雨幕效果比较平,就像头顶贴了一张滚动的贴图。要让雨“落进场景里”,至少要有近、远两层。近层雨幕距离相机约2米到4米,雨丝粗、透明度高、下落速度快;远层距离相机约10米到20米,雨丝细、透明度低、下落速度稍慢。两层叠加后,视线方向不同时远近层比例会自然变化,立体感就出来了。

场景遮挡的实现思路是拿当前像素的深度值和雨幕层深度做比较。近层雨幕可以近似放在相机前方2米处,如果场景里某个物体距离相机小于2米,那就说明这个物体在雨幕层之前,雨水应该被它挡住:

float rainLayerDepth = 2.0; float sceneDepth = LinearEyeDepth(depth); float occluded = step(sceneDepth, rainLayerDepth + 0.2); rainMask *= occluded;

但要注意,这个遮挡只是近似判断,它没法做到像粒子雨那样“地面上的雨在地面上方”的精确效果。全屏雨幕在做地面处理时,通常会结合场景法线或Mask图,把朝上的面片区域雨丝透明度降低,否则看起来像是雨丝穿过地面。

原本雾效和后处理顺序也会影响雨幕表现。如果项目启用了指数雾,建议把雨幕Pass放在雾效之前执行,否则远处的雨丝会被雾完全吃掉,导致“雨在近处有、远处没有”的断层感。

3.4 近景粒子雨与后处理雨幕的搭配

后处理雨幕中远景表现很好,但近景缺少真实雨滴的颗粒感。最理想的方案是保留少量近景粒子雨,同时用Shader控制粒子的形态。近景粒子的数量控制在200到500之间,粒子面片需要做Billboard朝向摄像机,并且沿下落方向拉伸。

粒子Shader的核心思路是把粒子的UV在垂直方向做额外缩放,让每个粒子显示成一条细长的雨线,而不是一个方块。叠加模式用Additive,颜色用浅蓝色或灰色,透明度控制在0.3左右。粒子受风场影响时,用一个全局风速向量统一驱动粒子的偏移和雨丝Shader的风向偏移,这样空间雨幕和近景粒子的倾斜方向才一致,不会出现“镜头前雨往左飘,远处雨往右飘”的尴尬。

这套组合下来,Space雨幕负责氛围和距离感,近景粒子负责颗粒感,屏幕雨滴滑落负责镜头代入感。三者配合时,我只在很有限的场景里遇到过性能压力,大部分移动端设备都扛得住。

4. 移动端性能实测与常见坑位

4.1 两类Shader效果的开销对比

我在项目里用Unity 2021.3、URP管线做了对比测试,测试机包括骁龙865、骁龙778G和麒麟990,分辨率统一为1080p。屏幕雨滴滑落效果全屏Pass大约增加2到3次纹理采样,实际帧率影响约为1到2毫秒。空间雨幕效果取决于雨幕层数,单层大约1毫秒,三层大约2.5到3毫秒。近景200个粒子雨的耗时取决于粒子屏幕占比,一般在0.5毫秒左右。

方案新增Draw Call像素采样次数移动端压力场景遮挡画面价值
屏幕雨滴滑落03~4无需镜头代入感强
全屏雨幕(单层)03近似遮挡氛围基础
全屏雨幕(三层)05~7多层遮挡更自然立体感好
200粒子雨1~2(合批后)取决于覆盖真实近景颗粒感

实测下来,屏幕上雨滴滑落因为本身有扰动效果,会掩盖一部分渲染瑕疵,所以可以用比较低的中间分辨率来做,进一步省性能。空间雨幕如果做三层,建议对最远一层单独降低采样密度。

4.2 时间精度、MipMap、半分辨率这些坑

第一个高频坑是_Time.y累计后UV平铺次数变得极大,导致长时间运行后雨丝密度分布不均匀或者闪烁。解决办法是在Shader脚本侧维护一个累计时间,每次帧更新叠加经过缩放的deltaTime,传给Shader。也可以直接用frac(_Time.y)控制周期,但要用较小的周期值。

第二个坑是雨丝纹理没开MipMap。屏幕尺寸较大时,雨丝纹理缩小采样后如果生成MipMap,会出现大量白色噪点闪烁。我在项目里遇到类似问题时,第一反应是优化Shader算法,后来发现只是纹理导入设置漏开了MipMap,打开后问题立刻消失。雨滴法线贴图也一样,建议打开MipMap并适当降低Generate Mip Maps的锐化强度。

第三个坑是在URP 12及以上版本里,OnRenderImage被弃用,需要通过Renderer Feature配合CommandBufferBlit来注入后处理Pass。很多从Built-in迁移过来的项目在这步会卡住,主要原因是Blit的API签名和纹理格式处理方式都变了。需要确认两点:中间RT的格式要支持ColorBuffer类型,以及Camera Depth Texture需要在Renderer Feature里主动启用。

第四个坑是深度纹理精度。移动端常见的深度纹理精度不足,在场景较远的区域,LinearEyeDepth计算出的深度值会出现跳动,导致雨幕遮挡边缘闪烁。我常用的缓解方案是先对深度值做一层平滑,再与雨幕层深度做smoothstep的比较,而不是用硬边step。

4.3 URP Renderer Feature的接入思路

当前项目如果是URP管线,推荐把两个效果打包进同一个Renderer Feature里,在RenderPassEvent.BeforeTransparents之后、后处理之前插入。用ScriptableRenderPass执行一个全屏Blit,Shader里根据关键字区分是雨幕Pass还是雨滴滑落Pass。

接入时有一个容易忽略的细节:命令缓冲里需要先SetRenderTarget到临时RT,再执行材质Pass,最后把临时RT拷回相机目标。如果直接对相机目标执行Blit,在某些移动端GPU上会出现无法读取上一帧内容的问题,导致雨滴滑落效果失效。我自己初期就遇到过这类问题,排查了很久才确认是RT切换顺序不对。

4.4 与场景雾效、抗锯齿的顺滑融合

雨幕和雾效的融合我前面提过,这里再多说一点。URP的雾效在后处理阶段执行,如果雨幕Pass在后处理之前输出,雨丝会被雾覆盖一层;如果在后处理之后输出,则无论多远的雨丝都比场景物清晰,这不符合真实效果。折中做法是给每个雨幕层单独设置一个“雾影响系数”,在Shader里手工把雨丝颜色向雾色过渡,这样远处雨幕自动变淡变灰,近处雨幕保持清晰。

抗锯齿和雨滴滑落也有冲突。使用MSAA时后处理输入的是解析后的颜色,问题不大;但TAA这类时间性抗锯齿会明显降低屏幕空间扰动的稳定性,雨滴边缘可能出现抖动。实测中我发现TAA开启后,雨滴滑落的_DistortStrength超过0.04时边缘拖影明显,建议适当降低强度。

5. 我在实际项目中总结的几条经验

这个部分不写代码,写我踩过之后记住的东西。第一条是关于雨量参数的联动。雨不是平均值,最好在全局脚本里维护一个wetness浮点,从0到1过度,0代表干燥、1代表暴雨。这个值同时驱动雨幕纹理采样密度、雨滴滑落强度、地面材质湿滑程度、雾气浓度和天空盒亮度。只调Shader不动别的,雨永远是一层“贴上去”的特效,不会和场景融为一体。我早期只做了Shader效果,白天切雨天时没有同步压暗天空和地面反射,结果雨看起来就像悬浮在场景外面的半透明噪点。

第二条是关于相机运动的适配。第一人称游戏里,镜头水痕是加分项,玩家会觉得雨打在镜头上了。第三人称游戏里,镜头水痕反而容易让玩家觉得镜头一直被水糊住、视野不干净,甚至会怀疑“镜头脏了”。我的建议是给屏幕雨滴滑落效果加一个基于相机速度的强度曲线:相机快速旋转时水痕轻微,相机静止时水痕慢慢累积变明显。这和真实物理上雨水在静止镜头上累积、风一吹或一动就流走的逻辑是一致的。

第三条是所有Shading效果先跑通最简单的版本,再谈优化。我有段时间折腾雨幕的三层深度遮挡,消耗了大量时间在边缘闪烁问题上,最后发现单层遮挡加两层雨幕滚动已经能达到90%的观感,反而最省性能。先实现、再对比、最后针对性优化,顺序不能反。

如果你也想在项目里做下雨效果,我建议至少把屏幕雨滴滑落和空间雨幕这两个Shader方案搭起来,它们不是复杂技术,但带来的画面信息量非常可观。等这两层稳定了,再去考虑地面涟漪、湿润度这些外围效果也不迟。雨天氛围这件事,Shader决定上限,参数联动决定真实度,剩下的就是不断在真机上微调。

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

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

立即咨询