ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity内置渲染管线基于Surface Shader的水面渲染实战

Unity内置渲染管线基于Surface Shader的水面渲染实战 如果你在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 { QueueTransparent RenderTypeTransparent } 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 GraphURP高中中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不是完全兼容。如果你项目追求极端DCDraw Call水面这种低频网格其实影响不大不用太纠结。2. 波浪顶点动画正弦波叠加与法线重建2.1 网格准备和顶点函数挂载水面不能直接用Unity自带的大Plane那个网格太密了尤其是移动端跑起来很浪费。但网格也不建议太稀否则波浪顶点不够看起来会跟纸片一样。我实际项目里常用的是自建16x16的平面网格面积为10x10米放在近景远处的水面用另一个低密度的网格做LOD。如果你只用自带Plane100x100硬改顶点位移远处看还行近处会有明显的低模感。在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 ProbeRealtime中动态但开销大湖面、海边局部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管线中最简单的方法是GrabPassGrabPass { _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. 水面最后合成菲涅尔、透明排序与完整Shader5.1 菲涅尔才是水面的“灵魂”反射和折射不能直接相加它们之间应该有一个随视角变化的权重这就是菲涅尔效应。简单理解你低头垂直看水面水下物体会很清晰你贴近水面斜着看水面就亮得像镜子。最常见的近似是Schlick公式float fresnel pow(1.0 - saturate(dot(worldN, V)), _FresnelPower); fresnel lerp(0.02, 1.0, fresnel);dot(worldN, V)在0到1之间视线越倾斜值越接近0fresnel越接近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.AlbedoPBR光照会把高光再叠一遍。我项目里通常采用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 { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } 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 Amplitude0.3增大变海浪减小变死水Wave Freq0.7~1.2控制波宽越大越碎Wave Speed0.5~0.8控制流速Normal Scale1.0增大细节扭曲Refraction Strength0.05超过0.08容易穿帮Fresnel Power5越小反射区越宽Reflection Roughness0.4越大反射越糊Depth Absorption1.5越大水下越黑我个人习惯是先调好波速和菲涅尔再调反射强度最后才碰折射。折射是水面的“锦上添花”如果前面两项没调好折射越强越显得奇怪。6.5 一点补充水面Shader写完之后不要急着扔到各种场景里。不同环境光、不同反射探针、不同天空盒水面的最终观感差异非常大。我的习惯是准备一个“水上移动测试面”在项目里主要场景各跑一遍记录一套适用于该场景的参数。水面不是一个“一次调好、到处通用”的材质读懂参数之间的关系往往比复制代码更重要。
返回列表