
1. 项目概述从屏幕到雨幕的视觉魔法最近在做一个需要强烈氛围感的项目比如一个阴郁的都市侦探游戏开场或者一个沉浸式的驾驶模拟器我一直在琢磨怎么把“下雨”这个感觉做透。不是简单地放个下雨粒子加个湿滑路面反射就完事了那种感觉太“表面”。真正的沉浸感得让玩家感觉雨是打在“眼前”的是落在镜头玻璃上的。这就是我们今天要深挖的Unity 雨滴效果 Shader的核心价值。简单说这个 Shader 不处理世界空间里真实的雨滴而是专门模拟雨滴落在摄像机镜头、窗户玻璃这类透明介质表面的视觉效果。它通过后处理Post-Processing的方式直接在全屏画面上“画”出动态的雨滴包括其生成、滑落、碰撞、融合以及最终蒸发消失的整个过程。这玩意儿对于第一人称游戏、驾驶模拟、影视过场动画的氛围营造简直是“大杀器”。一个写得好、调得妙的雨滴 Shader能瞬间把场景的叙事感和情绪张力拉满。适合谁来搞这个呢如果你是一个对画面表现有追求的 Unity 开发者或者是一个想深入学习 Shader 编写、理解屏幕空间特效原理的技术美术那这个项目再合适不过了。它涉及了 UV 动画、噪声采样、法线扰动、混合模式等 Shader 编程的核心技巧但又有一个非常具体、可视化的目标学起来成就感十足。接下来我就把我折腾这个“Unity-Raindrops”效果的全过程包括思路、代码、踩过的坑和调参心得毫无保留地分享出来。2. 核心思路与方案选型为什么是屏幕空间后处理在动手写代码之前得先想清楚技术路线。实现镜头雨滴主流上有几种思路在摄像机前放一个透明 Plane 粒子系统这是最直观的想法。创建一个面片贴上带 Alpha 通道的雨滴贴图用粒子系统控制其生成和移动。优点是简单、独立不依赖 Shader 知识。但缺点非常明显难以模拟雨滴在玻璃上蜿蜒滑落的复杂路径大量 Overdraw 带来性能开销雨滴与场景的交互如被风吹斜很难做自然最重要的是它无法实现雨滴对背后场景画面的“扭曲”效果——真实的雨滴是透镜会折射光线。使用渲染到纹理Render Texture的全屏后处理 Shader这正是我们采用的方法。它的原理是Unity 在完成场景渲染后会将最终图像送到一张屏幕大小的纹理上。后处理 Shader 就像最后一道滤镜在这张纹理上进行二次加工。我们的雨滴 Shader 就在这个阶段工作。优势真实折射可以采样屏幕纹理根据雨滴的“高度”或“法线”信息偏移 UV 坐标模拟光线透过水滴发生的折射这是灵魂所在。高效统一无论场景多复杂后处理只执行一次全屏绘制性能可控。动态丰富可以方便地利用时间和噪声纹理驱动雨滴的随机生成、流动、融合与消失逻辑全在 Shader 里非常紧凑。挑战需要编写 Shader 代码对图形学基础有一定要求。显然为了追求那种“以假乱真”的质感方案二是唯一的选择。我们的 Shader 将作为一个后处理效果挂载在摄像机上或者通过后处理堆栈Post Processing Stack来集成。2.1 核心效果拆解一滴雨的“生命周期”要把效果做细不能只想着“画个水滴”。我们需要在 Shader 里模拟一滴雨在玻璃上的完整生命周期生成一个新的雨滴如何出现不能是突然蹦出来的。通常我们会利用一张噪声图Noise Texture根据时间因子和屏幕空间位置计算出一个随机阈值当某个“像素”满足条件时就标记为“开始生成雨滴”。初始阶段雨滴可能只是一个半透明的小圆点。滑落与变形雨滴受重力可以简化成向下的速度和风水平方向的速度影响开始移动。但它在玻璃上的运动绝非匀速直线会受到玻璃表面细微不平、污渍或其他雨滴的影响。这就需要我们设计一个“流动场”Flow Field通常用另一张或同一张噪声图的不同通道来模拟让雨滴的 UV 偏移量随时间变化并且带有随机性。生长与融合雨滴在滑落过程中会“收集”周围的水汽在 Shader 里可以理解为采样周围像素的“湿度”信息自身变大。当两个雨滴在移动中靠近时它们应该融合成一个更大的雨滴。这个逻辑在片元着色器Fragment Shader里实现起来有点 tricky但核心思路是判断当前雨滴与“邻居”雨滴的距离和强度进行加权混合。折射与高光这是提升真实感的关键。我们将雨滴视为一个凸透镜。在 Shader 中我们可以根据雨滴的“强度”或一张模拟其形状的法线贴图Normal Map去扰动采样屏幕背景纹理的 UV 坐标。强度大的地方雨滴中心偏移就大模拟更强的折射。同时根据视角和灯光方向为雨滴边缘计算高光Specular让它看起来是湿润的。蒸发或滴落雨滴不会永远存在。当它滑落到屏幕边缘或者时间足够长它的“寿命”值应该衰减透明度逐渐降低直至消失。或者当它变得足够大、重力足够强时可以模拟它突然加速向下“流淌”一段然后消失的效果。在 Shader 中我们无法真正存储每个雨滴的独立状态那需要 Compute Shader 或粒子系统。我们的策略是利用屏幕空间坐标UV和时间作为输入通过一系列可重复的、确定性的数学函数主要是噪声来模拟出上述所有现象的“统计结果”。也就是说观众看到的是无数雨滴整体表现出的、逼真的随机行为而不是追踪每一个特定雨滴。3. Shader 实现详解从理论到代码理解了思路我们开始动手实现。我将使用 Unity 的 ShaderLab 语言来编写一个后处理 Shader。这里会分模块讲解并提供关键代码和注释。3.1 基础结构与属性定义首先我们创建一个新的 Unlit Shader因为它不依赖场景光照更适合后处理。在Properties块中定义所有我们需要从材质面板调节的参数。Shader Hidden/RaindropsEffect { Properties { _MainTex (Screen Texture, 2D) white {} // Unity 会自动传入当前屏幕图像 _RainIntensity (雨滴强度, Range(0, 1)) 0.5 _RainSpeed (雨滴速度, Float) 1.0 _RainSize (雨滴大小, Range(0.1, 5)) 1.0 _Distortion (折射扭曲强度, Range(0, 0.1)) 0.02 _Blur (折射模糊, Range(0, 0.1)) 0.005 // 噪声纹理用于生成雨滴图案和流动场 _NoiseTex (噪声图 (RGB), 2D) gray {} _NoiseScale (噪声缩放, Float) 5.0 _NoiseSpeed (噪声变化速度, Float) 0.2 // 雨滴法线/高度图用于模拟雨滴立体感和折射细节 _DropNormalTex (雨滴法线图, 2D) bump {} _NormalStrength (法线强度, Range(0, 2)) 0.5 // 高光相关 _SpecularInt (高光强度, Range(0, 5)) 1.0 _SpecularPower (高光次幂, Range(1, 128)) 32.0 // 颜色与混合 _TintColor (雨滴色调, Color) (1,1,1,1) _Wetness (玻璃湿润度, Range(0, 1)) 0.3 // 控制整体背景的模糊/变暗 } SubShader { // 后处理标准设置 Cull Off ZWrite Off ZTest Always Blend SrcAlpha OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc // 后续代码将在这里展开 ENDCG } } }注意_MainTex这个属性名在 Unity 后处理中是特殊的。当这个 Shader 作为后处理材质运行时Unity 会自动将摄像机渲染完成的屏幕图像赋值给_MainTex。这是我们进行所有操作的基础。3.2 顶点着色器与基础数据准备顶点着色器很简单它的主要任务是将网格顶点一个覆盖全屏的四边形转换到裁剪空间并计算屏幕 UV 坐标。struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; }3.3 片元着色器效果的核心逻辑这里是所有魔法发生的地方。我们一步步来构建frag函数。第一步采样噪声生成雨滴遮罩。我们利用时间和 UV 对噪声图进行采样得到一个随时间变化的随机值。这个值经过处理可以用来模拟雨滴的出现和强度。fixed4 frag (v2f i) : SV_Target { // 基础纹理采样 fixed4 screenCol tex2D(_MainTex, i.uv); // 计算用于噪声采样的UV加入时间和缩放 float2 noiseUV i.uv * _NoiseScale _Time.y * _NoiseSpeed * float2(0.7, 1.3); // 让X和Y方向速度略有不同更自然 fixed4 noise tex2D(_NoiseTex, noiseUV); // 生成雨滴遮罩 // 方法1简单的阈值法。将噪声的某个通道与一个动态阈值比较。 float timeFactor sin(_Time.y * 0.5) * 0.5 0.5; // 一个在0-1之间波动的因子让雨滴生成有疏密变化 float rainMask step(noise.r, _RainIntensity * timeFactor); // 方法2更平滑使用smoothstep让雨滴边缘有渐变。这里我们使用方法2。 // 将噪声值与一个基于强度和时间的中心值比较 float center 0.5 - _RainIntensity * 0.3; float rainMask smoothstep(center - 0.1, center 0.1, noise.g); rainMask pow(rainMask, _RainSize); // 用_RainSize控制雨滴的“聚集”程度值越大雨滴越稀疏但个体越明显。 // 流动场用另一通道的噪声驱动UV偏移模拟雨滴滑动 float2 flow (noise.ba - 0.5) * 2.0; // 将b和a通道从[0,1]映射到[-1,1] flow * _RainSpeed * 0.01; // 控制速度幅度 float2 dropUV i.uv flow * _Time.y; // 随时间流动的UV }实操心得_NoiseTex的选择非常关键。一张好的、无缝平铺的 Perlin 噪声或 Simplex 噪声图效果远优于随机噪声。你可以准备多张不同尺度的噪声图混合使用一张用于生成雨滴位置大尺度一张用于驱动流动中尺度一张用于添加细节扰动小尺度。第二步采样雨滴法线图计算折射。我们使用一个专门的法线贴图来定义单个雨滴的立体形状。通常这是一张中间凸起、边缘平滑的圆形法线图。// 采样雨滴法线图。注意要用flow偏移后的dropUV来采样这样雨滴形状才会“流动” fixed3 dropNormal UnpackNormal(tex2D(_DropNormalTex, dropUV)); dropNormal.xy * _NormalStrength; // 增强法线影响 dropNormal normalize(dropNormal); // 根据法线和雨滴遮罩计算UV偏移折射 float2 distortionUV dropNormal.xy * _Distortion * rainMask; // 为了模拟雨滴的透镜模糊效果我们可以在偏移后的UV周围进行多次采样取平均简化版 float2 blurUV i.uv distortionUV; fixed4 refractedCol screenCol; // 简单的模糊采样性能较好 refractedCol tex2D(_MainTex, blurUV float2(_Blur, 0)); refractedCol tex2D(_MainTex, blurUV float2(-_Blur, 0)); refractedCol tex2D(_MainTex, blurUV float2(0, _Blur)); refractedCol tex2D(_MainTex, blurUV float2(0, -_Blur)); refractedCol / 5.0;第三步计算高光与最终混合。高光让雨滴看起来是湿的。我们假设光源来自摄像机正前方对于全屏后处理这是合理的简化根据视角方向固定和法线来计算高光。// 计算高光Blinn-Phong简化版 float3 viewDir float3(0, 0, 1); // 屏幕空间视角方向朝里 float3 halfDir normalize(viewDir float3(0,0,1)); // 假设光来自正前方 float spec pow(max(0, dot(dropNormal, halfDir)), _SpecularPower) * _SpecularInt; spec * rainMask; // 只有雨滴区域有高光 // 最终颜色混合 // 1. 背景色应用折射和模糊后的颜色 fixed3 finalColor refractedCol.rgb; // 2. 叠加上高光 finalColor spec; // 3. 整体湿润感将背景色稍微变暗、变冷模拟玻璃被水覆盖 finalColor lerp(finalColor, finalColor * fixed3(0.9, 0.95, 1.0), _Wetness); // 4. 雨滴本身的颜色非常微弱主要是为了色调统一 finalColor lerp(finalColor, finalColor * _TintColor.rgb, rainMask * 0.1); // 输出 return fixed4(finalColor, 1.0); }这样一个基础的、具有动态生成、流动、折射和高光的雨滴效果 Shader 核心就完成了。当然这只是一个框架真正的质感和性能优化还需要大量的调试和技巧。4. 进阶优化与效果增强技巧实录上面实现的是一个基础版本。在实际项目中为了达到电影级的效果我们还需要进行一系列优化和增强。4.1 实现雨滴的碰撞与融合模拟这是让雨滴效果脱离“贴图滑动”感变得有“智能”的关键。纯 Shader 无法进行物理运算但我们可以用一些“黑魔法”来近似。思路利用上一帧的雨滴信息通过 Render Texture 反馈来影响当前帧。这需要两个 Pass 和一张临时渲染纹理。第一遍 Pass更新状态输入上一帧的雨滴状态图存储位置、速度、大小等信息根据噪声和时间更新每个“雨滴粒子”实际上是像素的状态计算碰撞判断邻居像素的强度和融合将两个高强度像素合并输出新的状态图。第二遍 Pass渲染表现使用最新的状态图结合法线贴图等渲染出最终的雨滴视觉效果到屏幕。由于实现较为复杂这里给出一个简化的、单帧内模拟融合的思路在片元着色器中不仅采样当前 UV 的噪声还采样其周围几个固定偏移点的噪声。如果周围点的“雨滴强度”也很高且距离很近则适当增强当前点的强度并平滑过渡模拟融合效果。// 在frag函数中计算基础rainMask后 float2 offsets[4] {float2(0.001, 0), float2(-0.001,0), float2(0,0.001), float2(0,-0.001)}; float neighborMask 0; for(int idx 0; idx 4; idx) { float2 sampleUV i.uv offsets[idx] * _RainSize; fixed4 neighborNoise tex2Dlod(_NoiseTex, float4(sampleUV * _NoiseScale _Time.y * _NoiseSpeed, 0, 0)); float nmask smoothstep(center - 0.1, center 0.1, neighborNoise.g); neighborMask nmask; } neighborMask / 4.0; // 如果自身和邻居都有一定强度则增强自身模拟汇聚 if(rainMask 0.1 neighborMask 0.05) { rainMask saturate(rainMask neighborMask * 0.3); }4.2 性能优化合理使用 Mipmap 与 LOD全屏幕的后处理 Shader 每个像素都会执行性能至关重要。噪声图务必开启 Mipmap在导入设置中为_NoiseTex勾选 Generate Mip Maps。在片元着色器中根据屏幕 UV 的变化率使用ddx/ddy或fwidth自动选择合适 Mip 层级可以显著减少纹理读取的带宽和缓存抖动。// 使用tex2Dlod并手动计算LOD或者依赖硬件自动计算tex2D默认会 // 更精细的控制可以使用 tex2Dgrad float lod 0.0; // 可以根据屏幕像素密度计算一个合适的LOD fixed4 noise tex2Dlod(_NoiseTex, float4(noiseUV, 0, lod));简化模糊采样前面示例用了 5 次采样做模糊开销较大。对于移动平台可以减为 3 次当前像素 两个对角线偏移或者使用更高效的高斯模糊分离采样方法。降低计算精度在效果可接受范围内将一些变量声明为half或fixed类型而非float可以提升在移动 GPU 上的运算速度。4.3 与场景天气系统联动一个孤立的雨滴 Shader 是不够的。它应该和你的全局天气系统联动。强度参数驱动将_RainIntensity,_RainSpeed等参数暴露给 C# 脚本。当游戏进入暴雨状态时动态提高这些值细雨时则降低。风向影响在 Shader 中增加_WindDirection和_WindStrength属性。在流动场计算中让flow向量加上一个由风向控制的恒定分量这样所有雨滴就会朝一个方向倾斜模拟风的效果。雨刷器效果这是一个经典需求。你需要传入一个表示雨刷器刮擦区域的遮罩纹理或函数例如一个从屏幕一侧旋转扫过的扇形。在 Shader 中判断当前像素是否在雨刷器区域内如果是则大幅度降低甚至清零rainMask模拟雨刷刮走雨水的效果。这需要额外的 UV 或屏幕空间坐标计算。5. 常见问题排查与参数调节心得即使代码写对了调不出想要的效果也是常事。下面是我在调试过程中遇到的一些典型问题及解决方法。5.1 问题排查速查表问题现象可能原因排查与解决思路雨滴完全不显示1. Shader 未正确编译或启用。2._RainIntensity值太低。3. 噪声图_NoiseTex未赋值或 UV 缩放_NoiseScale过大/过小。1. 检查材质球是否使用了该 Shader并确认 Shader 编译无错误粉色表示错误。2. 将_RainIntensity调到 1看是否有变化。3. 将_NoiseScale调到 1-10 之间并确保噪声图是有效的纹理。雨滴静止不动1._Time变量未参与计算。2._RainSpeed或_NoiseSpeed为 0。1. 检查noiseUV或dropUV的计算中是否包含了_Time.y。2. 确保_RainSpeed和_NoiseSpeed大于 0。折射效果太强或扭曲怪异1._Distortion值过大。2. 法线图_DropNormalTex强度_NormalStrength过高。3. 法线图本身不符合预期例如不是切线空间法线。1. 将_Distortion降至 0.01 以下微调。2. 降低_NormalStrength。3. 确保法线图是“中间亮蓝色边缘暗”的切线空间法线贴图。在 Unity 导入设置中确认 Texture Type 为 “Normal map”。雨滴边缘锯齿严重1. 使用step函数导致硬边缘。2. 噪声图分辨率不足或未开启滤波。1. 将step替换为smoothstep并调整平滑区间。2. 确保噪声图导入设置中 Filter Mode 不是 Point并检查纹理分辨率是否够用至少 512x512。性能开销巨大帧率骤降1. 在片元着色器中进行了过多纹理采样或复杂循环。2. 未使用 Mipmap 或 LOD。3. 全屏模糊采样次数过多。1. 使用 RenderDoc 或 Unity Frame Debugger 分析 Shader 耗时。简化计算将可提前的计算移到顶点着色器。2. 为所有采样纹理开启 Mipmap。3. 减少模糊采样次数或改用更高效的后处理模糊如 Unity Post Processing Stack 中的 Bloom 或自定义模糊。5.2 参数调节心得找到“感觉”的关键调参是个艺术活这里分享一些经验值和个人体会_RainIntensity (0~1)不要线性调节。在 0.3-0.6 之间变化最明显。低于 0.2 几乎看不见高于 0.7 会显得过于密集和不真实像一层雾。我通常用一条曲线来控制它让中间段的变化更灵敏。_RainSpeed (0~5)1.0 是基础速度。调到 2.0 以上会感觉是暴雨调到 0.3 左右像是凝结的水珠缓慢滑落。通常需要和_NoiseSpeed联动调节让流动看起来自然。_NoiseScale (0.1~20)这个参数控制雨滴的“尺度”。值小如 1-3雨滴小而密集值大如 10-15你会得到大块的水流或水幕效果。我常用两张不同 Scale 的噪声图混合一张大尺度~10决定水流的走向一张小尺度~3添加细碎的雨滴细节。_Distortion (0~0.05)从 0.005 开始微调。0.01 已经能产生明显的折射效果0.03 以上会变得非常夸张适合表现特大水滴或油污。记住折射太强会让背后的场景难以辨认影响游戏性。_NormalStrength (0~2)0.5 是个安全的起点。它主要影响高光的形状和折射的细节起伏。调高它能让雨滴的“凸起感”更强但过强1.5会使效果看起来像塑料球而不是水。_Wetness (0~1)这个参数容易被忽略但它对整体氛围影响巨大。0.1-0.3 是玻璃轻微湿润的感觉画面稍微变冷、变暗。调到 0.5 以上会感觉玻璃上有一层均匀的水膜整体对比度和饱和度下降模拟大雨中视线模糊的效果。最重要的心得是所有参数都不是孤立的。调_RainIntensity时可能也需要微调_RainSize来保持雨滴的视觉大小密度平衡。调快速度时可能也需要加大扭曲因为快速流动的水流折射变化更剧烈。最好的方法是准备几个典型的天气预设细雨、中雨、暴雨、车窗积水把参数组保存下来在游戏中动态切换。最后别忘了在真机上测试尤其是移动设备。PC 上看起来细腻的效果在手机小屏幕上可能就是一坨模糊的色块需要你降低噪声尺度、减少采样次数来适配。Shader 编程就是这样在视觉美感、性能开销和平台兼容性之间不断寻找那个最佳的平衡点。当你看到自己写的几行代码让整个游戏世界瞬间笼罩在逼真的雨幕之中时那种成就感就是驱动我们不断折腾下去的最大动力。