Unity下雨Shader实战:屏幕空间雨滴与世界空间雨幕全解析 做Unity项目只要牵扯到户外场景十有八九要碰上下雨天气。前阵子我在一个数字孪生项目里做天气系统先后试过两条完全不同的下雨Shader路线一条是在屏幕空间做雨滴滑落一条是在世界空间铺雨幕。一开始我也在纠结到底哪个才是“正确做法”后来发现根本不存在唯一标准答案两类效果解决的问题不一样很多项目甚至需要同时上。这次就把两个效果的核心思路、Shader写法、调参经验一次说清楚。第一个是屏幕空间雨滴镜头靠近窗户、角色戴头盔、或者过场动画里想突出“镜头上有水”的时候非常合适第二个是世界空间雨幕适合第三人称、开放世界、以及需要场景大规模降雨氛围的项目。两条路线我都给可运行的伪代码和关键参数也会聊一聊在URP和手机端踩过的坑。1. 两个下雨效果的整体设计思路1.1 屏幕空间雨滴适合近景和镜头特效屏幕空间雨滴效果本质是一个全屏后处理Shader。它不是在三维空间里真的创建雨滴物体而是先在屏幕UV上划分网格然后在每一个网格单元里产生一个或几个“伪雨滴”这些雨滴负责采样场景颜色并做偏移扰动模拟出雨滴贴在镜头上、光线透过水滴产生折射的画面。为什么适合近景因为它直接作用在最终画面像素上表现的是“镜头玻璃”上的水珠而不是场景中正在下的雨。你想表现主角站在没有雨刮器的车里或者角色戴着一个挂水珠的护目镜这种效果既便宜又出片。相比真正做物理水滴全屏Shader完全不需要额外碰撞体渲染开销就是一次全屏采样加几次扰动计算。但要注意它不是万能的。屏幕空间雨滴对场景深度关系天然不敏感你不能让它做“雨滴落在物体表面然后滑下去”这种物理效果。所以很多项目里屏幕雨滴通常只是“氛围叠加”真正场景中的降雨还是要靠雨幕或粒子系统。1.2 世界空间雨幕适合场景内真实降雨世界空间雨幕更常见的是用若干长条面片Quad和Shader混合来实现。每一个面片在顶点着色器里被拉成一条从高空延伸到地面的细长条片元着色器里通过UV滚动和噪声采样模拟出无数根雨丝。因为面片之间可以合批这种方案的Draw Call非常少十几张面片就能达到铺满整个场景的降雨效果。有人说“直接用粒子系统不就行了”粒子系统确实可以做雨而且做近距离大雨时效果很生动。但在远处场景粒子雨会因为粒子大小和密度问题变得非常稀疏很难形成像样的雨幕。面片雨幕的优势在于它从远处看就是一片稳定的半透明纹理近处再看又有明显的雨丝线条无论哪个距离都不会穿帮。实际项目中我最常用的是“近处粒子雨 中远距离面片雨幕 屏幕雨滴”三层组合。粒子负责角色周围近景的活跃雨丝雨幕负责中远景的连续性屏幕雨滴负责镜头玻璃上的氛围。但很多情况下你要么没有预算开粒子要么只是做一个静态展示那单独用雨幕也完全够。1.3 两者怎么选看相机距离和表现目标我通常用两个问题决定选哪个第一镜头会不会有特写画面如果有一定要有屏幕空间雨滴。第二场景里需不需要一整片降雨区域如果需要那就必须有一层世界空间雨幕。项目周期紧的时候只做雨幕不做屏幕雨滴画面会少一层“湿润感”总让人觉得雨是假的只做屏幕雨滴不做雨幕又感觉只有镜头上有水场景里什么都没下。成本方面屏幕雨滴是后处理全屏采样的开销跟分辨率强相关移动端建议在低分辨率一半分辨率下执行或者开启MipBias。世界空间雨幕则是Overdraw开销面片填满屏幕后每个像素都要做透明混合开太多层会直接把GPU帧时间拉爆。两个效果单独看都不复杂难点在于组合使用时如何平衡。2. 效果一屏幕空间雨滴Shader完整拆解2.1 网格化雨滴与随机中心点计算屏幕空间雨滴最常用的实现叫Grid-Based Raindrops核心思路是把屏幕UV分割成等大的网格例如每个格子对应屏幕上的30~60像素。每一帧我们在每个网格里随机挑一个位置作为雨滴中心然后画一个水滴形状的遮罩。用网格而不是完全随机主要是为了避免雨滴扎堆或者大片空白区域保证画面均匀。随机中心点不能每帧跳变否则雨滴会闪。通常我基于网格ID做哈希让每个格子在生命周期内固定住中心点。一个简单的做法是用floor取网格ID再用frac和正弦函数做伪随机float2 gridId floor(screenUv * _GridCount); float2 random frac(sin(dot(gridId, float2(12.9898, 78.233))) * 43758.5453); float2 dropCenter (gridId random) / _GridCount;这段代码里screenUv是屏幕UV_GridCount是每行/每列的网格数量。random值只依赖gridId所以只要网格ID不变雨滴位置就不会跳变。实际使用的时候我会再叠一层时间段噪声让雨滴每隔几秒换一个位置模拟新的水滴落在镜头上。还要注意不同屏幕宽高比下网格划分会导致雨滴被拉长最好用_ScreenParams调整格子的宽高比保证雨滴是接近圆形的。2.2 雨滴下滑、重置与速度差异化水滴不会一直待在同一个位置它会因为重力往下滑。这个下滑过程直接改dropCenter的y坐标即可每帧减去速度乘以时间。关键技巧在于重置雨滴滑到屏幕底部后需要回到顶部重新开始否则过几秒整个屏幕上就看不到水珠了。如果直接让y坐标从0跳到1会看到明显的硬切尤其是一整行雨滴同时重置。我一般用简单的循环取模并在切换时做一个短暂淡入淡出让水滴从上方边缘“长”出来。每个网格内的雨滴还要有不同的速度否则所有雨滴像排队一样一起下落看着很假。可以在网格哈希里生成一个速度系数例如float dropSpeed lerp(_MinSpeed, _MaxSpeed, random.y); float2 center dropCenter; center.y - frac(_Time.y * dropSpeed random.x);注意这里用的是frac而不是取模它能让雨滴在0到1之间循环移动而且由于每个格子的phase不同整体看起来是一种连续下落的观感。讲到这必须提一个容易踩坑的点如果屏幕雨滴在下滑时还叠加了场景的UV扰动雨滴和背景之间的相对运动会变得混乱。我的做法是让雨滴的遮罩平滑过渡只在雨滴内部做扰动边缘处用smoothstep压到零避免出现硬边。2.3 法线扰动与折射伪像雨滴最出效果的部分其实是折射。屏幕空间里没有真实的几何折射所以用“法线贴图扰动采样UV”来模拟。我们在雨滴遮罩范围内采样一张水滴法线贴图把法线的xy分量作为UV偏移量再对原始场景颜色进行一次重采样这样雨滴内部的画面就会像透过水珠一样扭曲变形。法线贴图可以自己画。方法是先在图片里画几个白色的水滴有高光点然后用工具把它生成法线贴图。如果是临时测试用普通的法线扰动也行不改贴图直接在Shader里生成一个带圆环的法线场float3 normal float3(0, 0, 1); float2 dropOffset center - screenUv; float dist length(dropOffset); float radius smoothstep(_DropRadius, _DropRadius * 0.4, dist); normal.xy normalize(dropOffset 1e-5) * radius * _DistortStrength; float2 refractedUv screenUv normal.xy * _RefractionStrength; float4 sceneColor tex2D(_MainTex, refractedUv);这段代码里雨滴边缘到中心的向量方向就是法线朝向直接用距离做平滑遮罩。由于雨滴其实不是一个点而是一条带弧度的水滴你可以把dropOffset的y分量乘一个系数让法线沿着水滴下滑方向被拉长。_RefractionStrength的取值我一般建议在0.002到0.02之间太大画面会糊成一团太小又看不出折射。为了让雨滴内部更立体我通常会叠加一个小高光也就是在雨滴靠近顶部的位置加一个亮斑。这个亮斑不需要真实光照计算用saturate(normal.x * normal.y)这类公式就能近似。2.4 在URP和Built-in里的接入方式屏幕雨滴Shader必须作为全屏后处理运行。在Built-in管线里最方便的是用OnRenderImage配合Graphics.BlitBlit到全屏Quad时把场景图传进_MainTex。在URP里就不能这么写了需要做Custom Renderer Feature或者Full Screen Pass Renderer Feature。URP 12以上的版本直接用Full Screen Pass会简单很多在Shader Graph的Full Screen节点里拿MasterTexture做采样源。一个容易被忽略的问题是采样用的场景图必须是场景渲染完成后的结果。如果你在透明物体之前做后处理雨滴折射会把半透明物体漏掉画面看起来会漂。在URP里把Renderer Feature放在“After Transparents”阶段在Built-in里用OnRenderImage也天然是在所有物体渲染完之后执行所以顺序一般没问题。另外全屏后处理在移动端要特别注意带宽。每多一次全屏采样带宽压力就大一层。我是建议在手机端把雨滴纹理半分辨率渲染再在最终处理时做一次上采样画面损失很小但帧率能稳不少。3. 效果二世界空间雨幕Shader完整拆解3.1 用面片加UV滚动的雨线基础实现世界空间雨幕的基础构成是几张经过拉伸的长条面片。你不需要自己做复杂模型在Unity里直接创建一个Quad然后把它在y方向拉伸就行。Shader里通过UV的v坐标把贴图沿雨线方向多次平铺再让uv.y随时间往下滚动雨线就会动起来。最核心的代码在片元Shader里。首先需要保证uv.y在多次平铺后形成细长的雨丝这不难用一张长方形条纹贴图就能直接采样但条纹太规则会一眼看穿。所以我的做法是用噪声纹理降低局部透明度让雨丝断断续续float2 rainUv uv; rainUv.y _Time.y * _FallSpeed; float4 noise tex2D(_RainNoise, rainUv * _NoiseScale); float line noise.r; float alpha step(_Threshold, line) * _Intensity; return float4(_RainColor.rgb, alpha);step是硬阈值会让雨丝边缘特别锐利现实中雨丝边缘是很柔和的。我建议用smoothstep代替step并保留一个很小的过渡带雨丝看起来才自然。另外UV滚动方向要根据雨的方向调如果是斜风细雨uv.x也得带一个偏移。3.2 风力弯曲与雨线断裂平直的雨线看起来像彩色棉线一点都不像雨。一个低成本的做法是在顶点着色器里沿风方向弯曲。以Quad的中轴线为基准越靠近地面偏移越大这样雨线会呈现一种被风吹弯的曲线。实现方式很多我常用的是根据世界坐标的y值构造一个弯曲偏移float bend sin(worldPos.y * _WaveDensity _Time.y * _WaveSpeed) * _WindStrength; worldPos.x bend * smoothstep(_GroundHeight, _SkyHeight, worldPos.y);这里的逻辑是离地面越远弯曲幅度越大而地面附近的风受遮挡影响会小一些。如果项目里有强风天气把_WindStrength调高雨线会明显斜着甩出去。如果要模拟风从不同方向吹来把偏移分别加到x和z上就行。雨线断裂也很有讲究。现实中雨不是一根完全连续的线而是由很多小水滴连成的一段一段线条。用噪声纹理就能实现断裂但我更推荐在片元里用时间做一次局部淡化。比如给雨丝一个随机的相位让它在下落过程中每隔一段距离消失一下再重新出现。这个可以用floor和frac组合实现避免额外采样噪声图。3.3 距离淡出与透明度管理雨幕最怕的情况是摄像机靠近面片时面片上的纹理被放大成巨大的色块完全穿帮。所以必须做基于距离的透明度衰减。计算方式很简单在片元里用世界坐标到相机的距离做smoothstepfloat dist distance(worldPos, _WorldSpaceCameraPos); float fade 1.0 - smoothstep(_NearFade, _FarDistance, dist);对于近处的雨丝透明度可以高一些但要避免完全覆盖场景。我一般控制在0.15到0.35之间雨大时可以到0.45超过0.5就会严重遮挡玩家视线。远处的雨丝透明度要降得更低因为远处很多雨丝叠加在一起如果每根都不透明远处会变成一片白雾反而没有层次。雨幕还需要处理一个关键问题它是否应该被场景物体遮挡。如果雨幕面片不写入深度、只做透明混合那雨丝会盖在建筑物后面看起来是“穿透”的。因此我一般会给雨幕面片开启深度写入或者在手写Shader里让雨线只渲染在场景深度之后的位置。当然这会带来排序成本所以如果你的项目里雨不会出现在室内也可以偷懒不处理直接用混合模式硬叠过去。3.4 与灯光、雾效和天气参数整合一个只靠纹理和透明度叠加的雨幕在灯光下会显得非常平。想让雨丝有立体感就要让它接收场景光照至少让它受主方向光的强度影响。做法是传入光源方向在片元里点乘法线方向。雨丝没有复杂的法线直接用面片法线再根据风的偏移做一点倾斜即可。雨幕颜色应该跟着场景雾走。下雨天气里雾气很重如果雨丝颜色是纯灰白色而场景雾是蓝灰色雨丝会显得浮在画面上。最简单的解决办法是读取UnityFog参数或者直接取雾的颜色作为雨丝颜色基底再用_Intensity控制它的亮度。这样雨丝不仅能融入雾中还天然带上了天气氛围。如果你做了完整的天气系统我建议把雨量、风速、颜色都做成全局Shader参数。这样不仅雨幕Shader能用屏幕雨滴、粒子系统、甚至反射探针都能引用同一套参数。设置一个全局Float叫_GlobalRainAmount然后用ChangeGlobalFloat在天气切换时平滑过渡效果会比一把一把调材质自然得多。4. 常见问题与实操排查4.1 屏幕雨滴常见问题排查表屏幕雨滴虽然效果好但实际调试中的问题也最多。下面这些是我自己在项目里遇到过的典型状况基本都能对号入座现象可能原因解决方案雨滴边缘发白法线扰动过度采样到了背景之外的透明区域降低_RefractionStrength或者给重采样增加范围钳制雨滴尺寸在不同分辨率下不一致UV网格没有考虑屏幕宽高比用_ScreenParams修正网格宽高比让雨滴接近圆形雨滴位置闪烁随机中心点没有做时间稳定的哈希基于网格ID生成随机点而不是每帧重新随机手机端画面明显变糊全屏采样次数过多带宽不够改用半分辨率渲染雨滴纹理再上采样合成雨滴不像在水滴内部法线贴图的uv平铺重复太高减小法线贴图的uv缩放让它覆盖更大的屏幕区域雨滴有死黑边缘smoothstep范围太窄导致硬过渡拉宽smoothstep的过渡带至少留2像素的羽化范围最让我意外的一次问题是屏幕雨滴在夜景下出现大量噪点。后来排查发现夜景画面暗部信息少雨滴扰动产生的高频数据会被压缩成彩色噪点。解决方法是把雨滴折射强度跟场景亮度挂钩暗部位置自动降低效果。4.2 雨幕渲染的排序与深度问题雨幕的透明度排序是无数人踩过的坑。半透明物体的渲染顺序在Unity里是按“从远到近”排列的但雨幕面片是长条面片与面片之间可能相交相交处的两个半透明面片会因为排序不稳定而交替闪烁。我的建议是尽量少用多层交叉的雨幕面片一层主雨幕加一层辅助雨幕就足够了不要为了追求密度堆到四层五层。如果必须要叠多层就把它们的RenderQueue拉开距离比如主雨幕是3000辅助雨幕是3001Unity会按Queue先后处理。穿墙问题也不能忽视。雨幕如果保留深度写入在室内看室外会显得雨被剪掉了室外看室内又可能看到雨穿透屋顶。我一般把雨幕设置为“同时写入深度但不写入深度测试”的方案先让雨幕在场景物体之后渲染如果雨幕片段深度大于场景深度就把片段丢弃。这个操作可以用一个简单的深度比较完成开销不高但能让雨丝被建筑遮挡得干净利落。还要注意雨幕Shader最好关闭面剔除Cull Off。否则当相机转到面片背面雨丝会直接消失画面突然出现一个空区域观感非常突兀。4.3 移动端性能调优经验移动端做雨效果最容易翻车的就是全屏后处理加多层透明面片组合。以中端安卓机为例一次全屏后处理可能消耗3到5毫秒再加雨幕的Overdraw帧时间会很容易超过16毫秒。所以我会给移动端做一套专门的低配版屏幕雨滴改用半分辨率后处理雨幕面片的数量减半同时把远处雨幕的透明度压得更低。另一个技巧是给雨幕面片加上距离剔除。雨幕本身是半透明的它不能写深度但Unity的CullingGroup可以做视距剔除。我在脚本里按每10米一个格子只有当雨幕中心到相机距离小于某个阈值时才SetActive到对应面片。这个方法在场景面积很大、雨幕面片铺满全图时能省下不少GPU开销。还要注意Draw Call不是面片数而是材质数。雨幕如果每一层都单独给材质面片再多也只能每个替换属性很难合批。最理想的做法是用MaterialPropertyBlock在不同面片之间共享材质然后只修改各自的Matrix和颜色属性。这样几层雨幕加起来也能保持在一个DC左右极大的省出性能预算。5. 两个效果合在一起的完整案例参数有人可能觉得上面拆得太多不知道具体怎么串。我可以给一套我们项目里用于“中雨镜头有水珠”的默认参数组合方便直接上手调。交杂了我在真实项目里的经验值但每个项目画风不同只能作为起点。屏幕雨滴部分网格密度按屏幕宽高比取30到50个单元雨滴半径0.35相对于网格单元折射强度0.008法线缩放1.2雨滴最小下落速度0.15最大下落速度0.6高光强度0.3。这是PC端数值手机端我会把网格密度降到20到30折射强度降到0.005。世界空间雨幕部分主雨幕面片距离相机范围40米辅助雨幕范围80米主雨幕的雨线密度阈值0.65辅助雨幕阈值0.75雨线颜色直接用雾色混合透明度主雨幕0.25辅助雨幕0.15下落速度0.8风速0.3。雨幕数量上主雨幕我放8张面片辅助雨幕放12张总共20张合批后加在一起基本只需要1到2个Draw Call。这套参数在PC上表现很好移动端我会关闭辅助雨幕只留主雨幕8张面片并把主雨幕的透明度和密度都往上调一点因为少了辅助雨幕会让整体变稀需要用更明显的雨丝来补密度。6. 实际项目中我踩过的大坑与最终建议最后分享两个我在实际项目中印象最深的问题。第一个是关于屏幕空间雨滴和后期抗锯齿的冲突。雨滴的折射扰动会把MSAA或TAA的采样结果打乱导致雨滴边缘出现闪烁的像素级变化。这个问题的根源是全屏后处理是在抗锯齿之后进行的当画面整体移动时雨滴内部的采样位置变化会被抗锯齿误判为运动边缘。我的解决办法是给雨滴内部增加一个轻微的固定偏移避免它在像素边界跳动。第二个是雨幕和雾效的叠加顺序。Unity内置雾效是在透明物体渲染之前计算的雨幕作为透明物体采样到的场景颜色其实已经带雾了。但雨幕再叠一层自己的颜色会使远处雨丝的颜色明显偏亮造成一种“雨在雾前面”的假象。后来我在雨幕Shader里把雨丝颜色跟雾色做了一次柔和混合让雨的半透明部分和雾融合这个问题才算解决。个人经验是做下雨效果不能只调Shader你的场景反射探针、天空盒、光强度、粒子衰减都要跟着变。Shader只是把天气视觉化的一部分如果整个场景的光照还停留在晴天再好的雨滴Shader看起来都像贴图。只要先把整个天气氛围的光影调子统一了再来调雨滴和雨幕的参数很快就能得到一套让人信服的下雨效果。