如果你在Unity里调过水面,大概率有过和我一样的经历:网上找到的水面Shader不是装了一堆插件依赖,就是代码写得太“学究”,复制到项目里一跑,帧率直接掉一半。我后来在项目里把水的效果收敛成了一套基于表面着色器(Surface Shader)的本地实现,波浪、折射、反射、菲涅尔全塞在一个文件里,维护起来也不费劲。这篇文章不做“能看不能用”的概念演示,而是把我在内置渲染管线(Built-in Render Pipeline)里用Surface Shader做水面的完整思路、代码和调参心得讲清楚。适合已经能看懂Unity Shader基础、想自己掌控水面效果的朋友。
1. 为什么我用表面着色器而不是自写光照模型来调水面
1.1 Surface Shader到底帮你做了什么
Surface Shader不是一种独立的Shader语言,而是Unity帮开发者生成顶点/片元Shader代码的模板。你在surf函数里填材质属性,Unity在编译时会自动生成对应的顶点函数、片元函数、光照计算、阴影宏、GI和反射探针相关代码。对水面这种“既要及时调整,又要兼顾环境光照”的半透明材质来说,它能省掉大量样板代码。
最简单的Surface Shader结构长这样:
SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off CGPROGRAM #pragma surface surf Standard vertex:vert struct Input { float2 uv_MainTex; }; void vert(inout appdata_full v) { // 这里改顶点 } void surf(Input IN, inout SurfaceOutputStandard o) { o.Albedo = 0; o.Emission = _WaterColor.rgb; o.Alpha = _WaterColor.a; } ENDCG }你只需要关心vert和surf两个函数,剩下的光照模型、阴影、探针这些乱七八糟的宏,Unity都会在背后给你填上。这一点在项目节奏很快时非常救命。
1.2 水面为什么需要“表面”而不是“片元”
水面最终要同时处理四件事:顶点位移、法线重建、反射采样、折射采样。如果我用最原始的顶点/片元着色器,除了写这些核心逻辑之外,还得自己处理投影坐标、法线变换、光照函数、菲涅尔公式,甚至还要处理多光源的循环。写完后代码量翻倍,Debug成本也翻倍。
用Surface Shader的好处是,你可以在Input结构里声明worldPos、screenPos、worldRefl这些变量,Unity会自动帮你填充。比如worldRefl直接就是世界空间反射向量,省掉了reflect(-viewDir, normal)这一步的手动计算。这对新手特别友好。
我用一个表格对比一下常见路线的选择:
| 方案 | 开发效率 | 移动端性能 | 可控精度 | 典型场景 |
|---|---|---|---|---|
| Surface Shader(内置管线) | 高 | 中 | 中 | 水面、自定义PBR材质 |
| Vertex Fragment Shader | 低 | 高 | 高 | 全定制特效、描边、粒子 |
| Shader Graph(URP) | 高 | 中 | 中 | URP项目可视化调效 |
| Unlit Shader | 高 | 高 | 低 | 不受光的水面、贴图动画 |
这里说的“移动端性能中”,其实不是Surface Shader本身慢,而是编译器生成的大包代码可能包含你不需要的光照分支。解决办法是用#pragma surface surf Standard vertex:vert noshadow这类指令关掉用不到的特性。
1.3 使用限制:URP、SRP Batcher和可编渲染管线
Surface Shader主要面向内置渲染管线。现在的Unity项目如果是URP,我强烈建议不要直接拿Surface Shader硬怼。URP的Lit模型和内置管线的光照流程不一样,Surface Shader虽然也能编译,但很多内置宏和Pass会冲突,效率反而不如Shader Graph或者自定义Lit。
我文章里所有内容都基于Built-in Render Pipeline + Unity 2021及以上版本。如果你项目用了URP,思路可以照抄,但实现上需要把GrabPass换成_CameraOpaqueTexture,反射探针采样也要改成URP的方式。
还有一个容易踩的坑:Surface Shader默认走的是传统Pass,和SRP Batcher不是完全兼容。如果你项目追求极端DC(Draw Call),水面这种低频网格其实影响不大,不用太纠结。
2. 波浪顶点动画:正弦波叠加与法线重建
2.1 网格准备和顶点函数挂载
水面不能直接用Unity自带的大Plane,那个网格太密了,尤其是移动端跑起来很浪费。但网格也不建议太稀,否则波浪顶点不够,看起来会跟纸片一样。
我实际项目里常用的是自建16x16的平面网格,面积为10x10米,放在近景;远处的水面用另一个低密度的网格做LOD。如果你只用自带Plane(100x100),硬改顶点位移,远处看还行,近处会有明显的低模感。
在Surface Shader里挂顶点函数只要在#pragma后面加vertex:vert:
#pragma surface surf Standard vertex:vert noshadow然后在vert函数里修改v.vertex.y。注意这里的v是对象空间(Object Space),如果你想让波浪方向跟随世界坐标,最好先把顶点转到世界空间,计算完再转回来,或者直接在世界空间计算位移再转回对象空间。
2.2 叠加正弦波:从湖面涟漪到近海浪
最基础的水面波动是一组方向波的叠加:
[ y = \sum_i A_i \cdot \sin(\mathrm{dot}(d_i, xz) \cdot f_i - t \cdot s_i) ]
其中:
A是振幅,控制波高度d是波传播方向(二维向量)f是频率,越大波越密s是速度xz是世界XZ坐标t是时间
我实际用了两组方向波作为基准,再加一张法线贴图做细节波动。两组波的好处是能模拟出交叉波浪,不会像单一正弦波那样机械重复。
float WaveHeight(float2 xz, float t, float4 p) { float2 dir = normalize(p.xy); return _WaveAmplitude * sin(dot(dir, xz) * p.z - t * p.w); } void vert(inout appdata_full v) { float t = _Time.y; float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; float2 xz = worldPos.xz; float h1 = WaveHeight(xz, t, _WaveParams1); float h2 = WaveHeight(xz, t, _WaveParams2); v.vertex.y += h1 + h2; }_WaveParams1和_WaveParams2是Vector属性,我用xy存方向,z存频率,w存速度。这套参数在材质面板上调起来非常直观。
2.3 法线重建:比逼真更重要的是“够用”
只改顶点位置还不够,水面光照需要法线。如果你只改顶点而不管法线,Unity插值出来的法线还是原来平面的法线,光照看起来会非常平。
对方向波来说,法线可以解析求解。以世界坐标Y轴为水面高度函数,表面法线约等于:
float d1 = _WaveAmplitude * _WaveParams1.z * cos(dot(dir1, xz) * _WaveParams1.z - t * _WaveParams1.w); float d2 = _WaveAmplitude * _WaveParams2.z * cos(dot(dir2, xz) * _WaveParams2.z - t * _WaveParams2.w); float3 worldNormal = normalize(float3( -(d1 * dir1.x + d2 * dir2.x), 1.0, -(d1 * dir1.y + d2 * dir2.y) )); v.normal = mul((float3x3)unity_WorldToObject, worldNormal);这段代码的本质:对高度函数求X方向和Z方向的偏导,再把偏导组成的切平面法线标准化。这样每个顶点都能得到随波浪摆动的法线,水面在光照下就会有起伏感。
不过这种解析法线只能覆盖“大波浪”,水面细节仍然不够。所以我往往在surf里再叠一张法线贴图:
float3 normalTS = UnpackScaleNormal(tex2D(_MainTex, UV), _NormalScale); float3 worldN = WorldNormalVector(IN, normalTS);WorldNormalVector会把法线贴图的世界切空间转换和顶点法线合并,这一步是Surface Shader最方便的地方。
3. 反射采样:探针、平面反射与粗糙度的配合
3.1 反射源为什么不能只靠一张Cubemap
水面的反射如果直接用一张静态Cubemap,环境一变就露馅。反射源通常有三个选择:
| 反射源 | 成本 | 效果 | 适用场景 |
|---|---|---|---|
| Reflection Probe(烘焙) | 低 | 静态反射 | 移动端、固定场景 |
| Reflection Probe(Realtime) | 中 | 动态但开销大 | 湖面、海边局部 |
| Planar Reflection(平面反射) | 高 | 反射准确 | 室内水面、贴近水面视角 |
| Screen Space Reflection | 很高 | 实时准确 | 主机/PC,需要后处理 |
我项目里多数时候用Baked Reflection Probe。它不吃每帧性能,只要在场景里放一个能拍到周围环境的探针,水面就能有基本反射。如果湖面离山很近、远处有重要建筑,我会插一个Realtime探针,但更新频率控制在不高于每帧一次。
3.2 在Surface Shader里采样反射探针
Surface Shader中拿到世界反射向量非常容易,在Input里声明float3 worldRefl;和INTERNAL_DATA,然后就可以直接用:
struct Input { float2 uv_MainTex; float3 worldPos; float3 worldRefl; INTERNAL_DATA }; void surf(Input IN, inout SurfaceOutputStandard o) { float3 V = normalize(_WorldSpaceCameraPos - IN.worldPos); float3 worldN = WorldNormalVector(IN, float3(0, 0, 1)); float3 worldRefl = reflect(-V, worldN); half4 reflCol = UNITY_SAMPLE_TEXCUBE_LOD( unity_SpecCube0, worldRefl, _ReflectionRoughness * 7.0 ); }注意UNITY_SAMPLE_TEXCUBE_LOD里的unity_SpecCube0是Unity自动绑定的环境反射Cube,不需要你自己声明。如果场景里没有反射探针,它会采样天空盒。LOD参数越大,采样出来的反射越模糊。
这里有个非常关键的细节:worldRefl在你声明后,Unity会默认填充当前视角的反射向量,但它用的是原始法线。如果你在surf里用法线贴图修改了法线,就必须自己用reflect(-V, worldN)重新算一次,否则反射方向不会跟着波浪走。
3.3 用Mip级别模拟粗糙水面反射
水面不是镜面,风吹过之后反射应该是模糊的。最简单的近似是直接让反射采样走一个较高的Mip Level。
UNITY_SAMPLE_TEXCUBE_LOD的LOD参数范围一般是0到最高Mip。我习惯把_ReflectionRoughness控制在0到1,然后乘一个7把范围拉满。粗糙度为0时反射清晰,粗糙度接近1时反射糊成一片。
这个数值和PBR里的Smoothness不是同一个概念,但视觉上很接近。如果项目要求物理正确,应该在光照模型里用GGX分布去模糊反射;但如果只是做水面效果,LOD采样性价比更高。
我还习惯给反射颜色乘一个_ReflectionStrength系数,因为反射太强会让水面“镀铬”。湖面反射强度大概在0.3~0.6就很好看。
4. 折射抓屏:GrabPass和_CameraOpaqueTexture各自的使用边界
4.1 GrabPass的原理与保险用法
折射在Shader里的本质根本不是光线追踪,而是“扭曲屏幕坐标”。水下的物体还在原地,只是采样位置被法线偏移了,看起来就像被水折射了一样。所以你需要先抓取当前屏幕内容。
在Built-in管线中,最简单的方法是GrabPass:
GrabPass { "_WaterGrab" }这行代码会在渲染这个物体之前,把当前屏幕颜色抓取到名为_WaterGrab的纹理里。之后在surf中这样采样:
sampler2D _WaterGrab; float2 grabUV = IN.screenPos.xy / IN.screenPos.w; half3 grabCol = tex2D(_WaterGrab, grabUV).rgb;screenPos是Surface Shader自动填充的屏幕投影坐标,手动除以w得到标准的0到1采样UV。这里不要直接用IN.screenPos.xy,因为正交相机和透视相机的w不同。
4.2 法线扰动偏移采样
折射的“扭曲感”来自采样UV的偏移:
grabUV += worldN.xz * _RefractionStrength;worldN.xz是法线的横向分量,波浪法线越大,采样偏移越大,看起来水下的东西扭得越厉害。_RefractionStrength我一般设置在0.02~0.08之间,太大就会出现边缘黑边和“水面像放大镜”的突兀感。
这个方案虽然简单,但对大面积水面来说够用。更真实的方法是使用折射系数做Snell定律近似,把视线方向换算到水下的折射方向,再计算屏幕偏移。但那个计算需要知道相机和水面的距离,而且移动端开销更大,我一般只在需要“放大水下物体”的特写镜头里用。
4.3 深度感知的水色混合
如果只采样GrabPass,水面就是一块半透明玻璃,没有“浅水透明、深水浑浊”的层次感。要加层次,就必须知道每个像素的水深。
在Surface Shader里可以这样拿深度:
sampler2D _CameraDepthTexture; float rawDepth = SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(IN.screenPos)); float sceneZ = LinearEyeDepth(rawDepth); float waterDepth = sceneZ - IN.screenPos.w;_CameraDepthTexture是Unity自动填充的深度图。IN.screenPos.w约等于当前水面片元的视角空间深度,sceneZ是场景中不透明物体的最深深度,两者相减就是近似水深。得到水深后,把抓屏颜色混合到深水色:
grabCol = lerp(grabCol, _DeepColor, saturate(waterDepth * _Absorb));_Absorb决定水色吸收速度。浅水区waterDepth小,偏向原本抓屏颜色,水下石头的颜色能透出来;深水区waterDepth大,慢慢混入深水色,看起来就有深度了。
需要注意:_CameraDepthTexture在移动端有平台兼容性差异,有些设备不生成。保险做法是声明后用一个#if defined(_CAMERA_DEPTH_TEXTURE)判断,或者干脆在PC上调试,移动端再做降级。
5. 水面最后合成:菲涅尔、透明排序与完整Shader
5.1 菲涅尔才是水面的“灵魂”
反射和折射不能直接相加,它们之间应该有一个随视角变化的权重,这就是菲涅尔效应。简单理解:你低头垂直看水面,水下物体会很清晰;你贴近水面斜着看,水面就亮得像镜子。
最常见的近似是Schlick公式:
float fresnel = pow(1.0 - saturate(dot(worldN, V)), _FresnelPower); fresnel = lerp(0.02, 1.0, fresnel);dot(worldN, V)在0到1之间,视线越倾斜,值越接近0,fresnel越接近1,反射占比越高。_FresnelPower一般取5,水面反射过渡会非常自然。如果取太小的1、2,水面的反射区域会铺得很开,像刷了层油。
5.2 反射、折射、高光的组合公式
我的合成逻辑是:
half3 finalCol = lerp(grabCol, reflCol.rgb, fresnel); finalCol *= _WaterColor.rgb;grabCol是折射采样,reflCol.rgb是反射采样。菲涅尔控制权重。最后乘_WaterColor把水面整体色调压一压,这样就不会出现“屏幕截图直接贴在水面上”的塑料感。
如果还想加高光,可以在finalCol后面加上specular项。常见做法是BlinnPhong高光:
float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); float3 halfVec = normalize(V + lightDir); float spec = pow(saturate(dot(worldN, halfVec)), _Glossiness * 64); finalCol += spec * _LightColor0.rgb * fresnel;但注意,如果你把finalCol输出到o.Emission,这个高光完全是手动加的。如果输出到o.Albedo,PBR光照会把高光再叠一遍。我项目里通常采用Emission输出,因为水面的颜色已经是一整套计算好的“结果色”。
5.3 完整的Surface Shader水面示例
下面是一个能直接运行的Surf Shader示例,基于Unity Built-in管线。不是工业级成品,但足以让你串起前面所有思路。直接新建Shader,复制进去,新建材质,指定一张带Alpha的Normal Map就能看效果。
Shader "Custom/WaterSurfaceExample" { Properties { _MainTex ("Normal Map", 2D) = "bump" {} _NormalScale ("Normal Scale", Range(0,2)) = 1 _WaterColor ("Water Color", Color) = (0.1, 0.45, 0.55, 0.6) _DeepColor ("Deep Color", Color) = (0.0, 0.1, 0.25, 1) _Absorb ("Depth Absorption", Range(0,5)) = 1.5 _WaveAmplitude ("Wave Amplitude", Range(0,2)) = 0.3 _WaveParams1 ("Wave 1 (DirX, DirY, Freq, Speed)", Vector) = (1, 0.3, 1.2, 0.5) _WaveParams2 ("Wave 2 (DirX, DirY, Freq, Speed)", Vector) = (-0.5, 1, 0.7, 0.8) _RefractionStrength ("Refraction Strength", Range(0,0.3)) = 0.06 _ReflectionRoughness ("Reflection Roughness", Range(0,1)) = 0.4 _FresnelPower ("Fresnel Power", Range(1,8)) = 5 _Glossiness ("Glossiness", Range(0,1)) = 0.8 _Metallic ("Metallic", Range(0,1)) = 0 _Tiling ("Normal Map Tiling", Float) = 4 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } GrabPass { "_WaterGrab" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off CGPROGRAM #pragma surface surf Standard vertex:vert noshadow #pragma target 3.0 sampler2D _MainTex; half _NormalScale; fixed4 _WaterColor; fixed4 _DeepColor; half _Absorb; half _WaveAmplitude; float4 _WaveParams1; float4 _WaveParams2; half _RefractionStrength; half _ReflectionRoughness; half _FresnelPower; half _Glossiness; half _Metallic; float _Tiling; sampler2D _WaterGrab; sampler2D _CameraDepthTexture; struct Input { float2 uv_MainTex; float4 screenPos; float3 worldPos; INTERNAL_DATA }; float WaveHeight(float2 xz, float t, float4 p) { float2 dir = normalize(p.xy); return _WaveAmplitude * sin(dot(dir, xz) * p.z - t * p.w); } void vert(inout appdata_full v) { float t = _Time.y; float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; float2 xz = worldPos.xz; float2 dir1 = normalize(_WaveParams1.xy); float2 dir2 = normalize(_WaveParams2.xy); float h1 = WaveHeight(xz, t, _WaveParams1); float h2 = WaveHeight(xz, t, _WaveParams2); v.vertex.y += h1 + h2; float d1 = _WaveAmplitude * _WaveParams1.z * cos(dot(dir1, xz) * _WaveParams1.z - t * _WaveParams1.w); float d2 = _WaveAmplitude * _WaveParams2.z * cos(dot(dir2, xz) * _WaveParams2.z - t * _WaveParams2.w); float3 worldNormal = normalize(float3( -(d1 * dir1.x + d2 * dir2.x), 1.0, -(d1 * dir1.y + d2 * dir2.y) )); v.normal = mul((float3x3)unity_WorldToObject, worldNormal); } void surf(Input IN, inout SurfaceOutputStandard o) { float2 uv = IN.uv_MainTex * _Tiling + _Time.y * 0.05; float3 normalTS = UnpackScaleNormal(tex2D(_MainTex, uv), _NormalScale); float3 worldN = WorldNormalVector(IN, normalTS); float3 V = normalize(_WorldSpaceCameraPos - IN.worldPos); float fresnel = pow(1.0 - saturate(dot(worldN, V)), _FresnelPower); fresnel = lerp(0.02, 1.0, fresnel); // Reflection float3 worldRefl = reflect(-V, worldN); half4 reflCol = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, worldRefl, _ReflectionRoughness * 7.0); // Refraction float2 grabUV = IN.screenPos.xy / IN.screenPos.w; grabUV += worldN.xz * _RefractionStrength; half3 grabCol = tex2D(_WaterGrab, grabUV).rgb; // Depth absorption float rawDepth = SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(IN.screenPos)); float sceneZ = LinearEyeDepth(rawDepth); float waterDepth = sceneZ - IN.screenPos.w; grabCol = lerp(grabCol, _DeepColor, saturate(waterDepth * _Absorb)); half3 finalCol = lerp(grabCol, reflCol.rgb, fresnel); finalCol *= _WaterColor.rgb; o.Albedo = 0; o.Emission = finalCol; o.Alpha = _WaterColor.a; o.Glossiness = _Glossiness; o.Metallic = _Metallic; } ENDCG } }这里用一个“偷懒”的技巧:o.Albedo = 0,把最终颜色直接放o.Emission。这样Unity的PBR光照不会二次破坏我们计算好的反射和折射颜色。如果你后续要加高光,可以在finalCol后面手动加Specular,而不是依赖Standard光照模型。
6. 实测中踩过的坑与优化经验
6.1 反射和阴影不对齐
我最早把水面Shader做完后,放在一个有树、有建筑的场景里,发现水面反射里经常看不到阴影,或者树的位置和反射位置对不上。排查下来有两个原因。
第一,反射探针更新频率太低。Baked探针只烘焙一次,如果你用的是Realtime探针,又把Refresh Mode设成Every Frame,性能会撑不住。折中做法:设成Via Scripting,用脚本在关键时间点手动调用RenderProbe()。第二个原因是半透明材质的ZWrite Off导致水面不能直接写深度,水面反射在深度图中没有自己的记录,这本来是正常的,但如果你用“透明队列+不写深度”的水面去做平面反射,反射相机可能把水面也渲染进去,产生无限回退的反射。解决办法是给水面单独一个Layer,在反射相机的Culling Mask里排除这个Layer。
6.2 折射边缘穿帮
法线扰动偏移太大时,水面边缘会出现非常突兀的拉伸,甚至能看到屏幕边缘外的黑色区域。尤其当相机贴在水面时,grabUV偏移后可能超出[0,1]范围。
我的处理方式有三个:
- 把
_RefractionStrength限制在0.05左右,不要贪 - 用
waterDepth做衰减:边缘浅水偏移小,深水偏移大 - 对
grabUV做一次clamp或fmod,避免直接采到边缘外
实际项目里,第一个方法最省事。_RefractionStrength如果超过0.08,大浪时的扭曲就会显得“假”。
6.3 移动端性能基线
移动端做水面,性能和效果必须做取舍。以下是我项目里的基线:
- 波浪最多用2层方向波,再多就换噪声贴图模拟,不做实时顶点噪声
- GrabPass会额外造成一次全屏采样,内存和带宽都有消耗;如果不需要水下透视,可以直接关闭折射,只保留半透明混合
- 反射探针尽量用Baked,如果一定要动态,更新频率控制在0.5~1秒一次
- 水面网格用LOD,近处可以64x64,远处16x16甚至更少
- 法线贴图Tiling不要太大,过高的频率会在移动端产生摩尔纹
同样一个水面,在PC上开4层波和实时探针可能流畅,但手机上必须砍。我建议先在移动端Profile里跑一次,再决定要不要加细节波纹。
6.4 调参清单与个人习惯
我把初始参数给一组比较稳的数值,适合普通湖面:
| 参数 | 推荐初始值 | 调参方向 |
|---|---|---|
| Wave Amplitude | 0.3 | 增大变海浪,减小变死水 |
| Wave Freq | 0.7~1.2 | 控制波宽,越大越碎 |
| Wave Speed | 0.5~0.8 | 控制流速 |
| Normal Scale | 1.0 | 增大细节扭曲 |
| Refraction Strength | 0.05 | 超过0.08容易穿帮 |
| Fresnel Power | 5 | 越小反射区越宽 |
| Reflection Roughness | 0.4 | 越大反射越糊 |
| Depth Absorption | 1.5 | 越大水下越黑 |
我个人习惯是先调好波速和菲涅尔,再调反射强度,最后才碰折射。折射是水面的“锦上添花”,如果前面两项没调好,折射越强越显得奇怪。
6.5 一点补充
水面Shader写完之后,不要急着扔到各种场景里。不同环境光、不同反射探针、不同天空盒,水面的最终观感差异非常大。我的习惯是准备一个“水上移动测试面”,在项目里主要场景各跑一遍,记录一套适用于该场景的参数。水面不是一个“一次调好、到处通用”的材质,读懂参数之间的关系往往比复制代码更重要。