1. 水体渲染到底在做什么从“一滩会动的蓝”说起很多人第一次做水体脑子里想的是“我要做一片海”结果做出来是一块会反光的蓝色塑料板。问题出在水体渲染的本质不是“画水”而是模拟光在水这种介质里的行为。你把光的行为搞对了水就像水搞不对再多的波纹贴图也救不回来。这篇内容面向的是已经能写简单Shader、知道URP大概是怎么回事但一做水体就翻车的Unity开发者。我会把水体渲染拆成几个可以独立调试的模块深度与颜色、法线扰动、反射与折射、边缘泡沫、高光每一块都给出可运行的思路和参数选择的理由。看完你应该能做到在URP下从零搭出一个能看的水面并且知道每个参数调下去会发生什么。先明确一个前提我这里讲的是URP管线下的实时水体不是离线渲染那种追求物理精确的路子。实时渲染的核心矛盾永远是“看起来对”和“跑得动”之间的平衡。所以下面所有的方案选择我都会告诉你为什么这么选以及牺牲了什么。另外说一句标题里提到“塔菲都看得懂”我理解成要把门槛压到足够低。所以我会尽量少甩公式多用“光从哪来、到哪去、被谁挡了”这种大白话解释。但该有的参数计算和代码结构一个都不会少因为水体这东西光靠感觉调是调不出来的。2. 水体渲染的整体设计思路与方案选型2.1 为什么水体不能只用一个Shader搞定新手最容易犯的错是把水面当成一个普通的PBR材质套个蓝色、加点法线贴图就完事。这样做的结果是水面看起来像一块磨砂玻璃完全没有“水”的纵深感和通透感。水体之所以特殊是因为它同时具备三种视觉特征表面反射水面像镜子反射天空和周围环境体积透射光进入水后会被吸收和散射水越深颜色越浓界面交互水面与物体交界处会产生泡沫、折射偏移等效果这三种特征对应的是完全不同的渲染技术。反射需要采样环境贴图或反射探针透射需要深度信息来计算水体厚度界面交互需要深度差检测。所以一个完整的水体Shader实际上是把好几套技术拼在一起。我的建议是先分模块实现再合并。不要一上来就写一个几百行的大Shader那样出了问题你根本不知道是哪一块坏了。先做一个只有深度颜色的版本确认水体厚度计算对了再加反射再加泡沫。每加一块都单独验证。2.2 URP下做水体的三条路线在URP里做水体大致有三条路线各有取舍路线核心做法优点缺点适用场景屏幕空间深度法采样_CameraDepthTexture计算水深效果好能做出通透感需要开启深度纹理移动端有开销PC、主机、中高端移动顶点动画假折射顶点波动UV偏移模拟折射性能好兼容性强效果偏假缺乏体积感低端移动、风格化平面反射深度额外相机渲染反射深度透射反射最真实开销大需要额外RenderTexturePC、展示类项目我下面主要讲屏幕空间深度法因为它是效果和性能平衡最好的方案也是目前URP水体最主流的做法。假折射那套适合风格化项目平面反射适合对反射质量要求极高的场景但那个开销不是一般项目能承受的。选屏幕空间深度法的核心理由是它能拿到场景深度图从而计算出每个像素处水体的实际厚度。有了厚度你才能做出“浅处透明、深处浓重”的效果这是水体真实感的关键。没有深度信息你只能靠固定的颜色和透明度硬凑怎么调都假。2.3 深度纹理的获取与前置配置在URP里拿深度图需要几个前置步骤很多人卡在这里第一步在URP Asset里勾选Depth Texture。这个选项在URP Asset的General设置里不勾的话_CameraDepthTexture就是一张白图你采样出来全是1。第二步确认你的Shader里声明了正确的采样。URP提供了内置的声明方式TEXTURE2D(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture);注意不要用旧的sampler2D写法URP下要用TEXTURE2D和SAMPLER宏否则在某些平台上会出问题。第三步采样深度后要做线性化。_CameraDepthTexture存的是非线性深度NDC深度直接拿来用是错的。需要用LinearEyeDepth转成眼睛空间深度float sceneDepth LinearEyeDepth( SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, screenUV), _ZBufferParams );这里的_ZBufferParams是Unity自动传入的用来处理不同平台深度范围不一致的问题。不乘这个参数在有些平台上深度会算错。注意如果你在Scene视图里看水体是正常的但在Game视图里不对八成是深度图没开或者线性化没做对。这是最常见的两个坑。3. 核心细节解析水体Shader的四大模块3.1 深度与颜色让水有“厚度”水体颜色的核心逻辑是Beer-Lambert定律的简化版光穿过水的时候强度随距离指数衰减。距离越长透过的光越少颜色越接近水的本征色。具体做法是采样当前像素的屏幕UV采样深度图得到场景深度用场景深度减去水面深度得到水体厚度用厚度驱动颜色插值float2 screenUV i.positionCS.xy / i.positionCS.w * 0.5 0.5; float sceneDepth LinearEyeDepth( SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, screenUV), _ZBufferParams ); float waterDepth sceneDepth - i.positionCS.w; float depthFactor saturate(waterDepth / _DepthMaxDistance); float3 waterColor lerp(_ShallowColor.rgb, _DeepColor.rgb, depthFactor);这里_DepthMaxDistance是个关键参数它决定了“多深算深”。设太小水很快就变浓看起来像泥浆设太大水一直很透没有体积感。我的经验值是浅水区比如水池设1到2米湖泊设5到10米海洋设20到50米。这个值要结合你的场景尺度来调没有万能值。_ShallowColor和_DeepColor的选择也有讲究。浅色不要选纯白深色不要选纯黑。浅色建议偏青绿比如0.4, 0.8, 0.7深色建议偏深蓝比如0.02, 0.1, 0.2。纯黑会让水看起来像深渊纯白会让水失去颜色。3.2 法线扰动让水面动起来水面不动就是一块玻璃。让水面动起来最经济的方式是法线贴图UV动画。做法是采样两张法线贴图用不同的速度和方向滚动然后叠加float2 uv1 i.uv * _NormalScale _Time.y * _Speed1; float2 uv2 i.uv * _NormalScale * 1.3 _Time.y * _Speed2; float3 normal1 UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uv1)); float3 normal2 UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uv2)); float3 normal normalize(float3(normal1.xy normal2.xy, normal1.z * normal2.z));这里有几个细节速度不要一样。两张图速度一样的话叠加出来还是同一张图在动没有层次感。建议一张快一张慢方向也错开比如一张往左上一张往右下。缩放要错开。一张用原始UV一张用1.3倍或0.7倍这样波纹大小不同看起来更自然。法线强度要控制。法线扰动太强水面会像沸腾的油锅太弱又像静止的塑料。_NormalScale建议从1开始试根据场景调整。实操心得法线贴图不要用那种特别精细的水体需要的是大尺度的波纹不是细碎的噪点。找一张波浪形的法线贴图比找一张水纹细节图效果好得多。3.3 反射与折射让水“映得出东西”反射这块URP下最简单的做法是采样环境贴图或者反射探针。如果你场景里有Reflection Probe可以用unity_SpecCube0来采样float3 reflectDir reflect(-viewDir, normal); float4 envColor SAMPLE_TEXTURECUBE(unity_SpecCube0, samplerunity_SpecCube0, reflectDir);但环境贴图的问题是它只能反射天空盒和探针烘焙的内容反射不了动态物体。想要反射动态物体就得上平面反射那个开销就大了。我的建议是大部分项目用环境贴图就够了。玩家在水边跑的时候不会盯着水面看反射的是不是自己的角色。反射个天空和远景视觉上就成立了。折射的做法是UV偏移。用当前的法线xy去偏移屏幕UV再采样一张屏幕颜色图或者直接用GrabPass但URP下GrabPass不支持需要用_CameraOpaqueTexturefloat2 refractUV screenUV normal.xy * _RefractStrength; float3 refractColor SAMPLE_TEXTURE2D(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, refractUV).rgb;_RefractStrength控制折射强度建议在0.01到0.05之间。太大画面会扭曲得看不清太小又看不出折射效果。注意_CameraOpaqueTexture需要在URP Asset里勾选Opaque Texture选项否则采样不到。这个和深度图是两个独立的开关别搞混了。3.4 边缘泡沫让水“有边界感”泡沫是水体真实感的重要来源。没有泡沫的水和物体交界处会显得很生硬。泡沫的做法是基于深度差当水体厚度很薄的时候也就是水面接近物体表面的时候说明这里接近交界处应该出现泡沫。float foam 1.0 - saturate(waterDepth / _FoamDistance); foam pow(foam, _FoamPower); float3 finalColor lerp(waterColor, _FoamColor.rgb, foam);_FoamDistance控制泡沫的宽度一般设0.1到0.5米。_FoamPower控制泡沫的锐利程度值越大泡沫越集中在交界处。但纯用深度差做泡沫会有个问题水面和远处地面的深度差也可能很小导致远处出现不该有的泡沫。解决办法是加一个距离衰减让泡沫只在近处出现float distanceFade 1.0 - saturate(waterDepth / _FoamFadeDistance); foam * distanceFade;另外泡沫最好配合一张噪声贴图让边缘不规则float noise SAMPLE_TEXTURE2D(_FoamNoise, sampler_FoamNoise, i.uv * _FoamNoiseScale _Time.y * _FoamNoiseSpeed).r; foam * noise;这样泡沫就不会是一条均匀的线而是有破碎感的自然边缘。4. 实操过程从零搭建一个URP水体Shader4.1 项目准备与文件结构先确认你的项目是URP。如果不是用Package Manager装Universal RP然后创建URP Asset并指派到Graphics设置里。然后创建三个文件Water.shader主Shader文件WaterDepth.shader可选用于需要单独渲染深度的场景WaterMat.mat材质球Shader的Properties块里把需要暴露的参数都列出来Properties { _ShallowColor (Shallow Color, Color) (0.4, 0.8, 0.7, 1) _DeepColor (Deep Color, Color) (0.02, 0.1, 0.2, 1) _DepthMaxDistance (Depth Max Distance, Float) 5.0 _NormalMap (Normal Map, 2D) bump {} _NormalScale (Normal Scale, Float) 1.0 _Speed1 (Speed 1, Vector) (0.1, 0.1, 0, 0) _Speed2 (Speed 2, Vector) (-0.15, 0.05, 0, 0) _RefractStrength (Refract Strength, Float) 0.02 _FoamColor (Foam Color, Color) (1, 1, 1, 1) _FoamDistance (Foam Distance, Float) 0.3 _FoamPower (Foam Power, Float) 2.0 _FoamNoise (Foam Noise, 2D) white {} _FoamNoiseScale (Foam Noise Scale, Float) 5.0 _FoamNoiseSpeed (Foam Noise Speed, Float) 0.5 }参数命名要清晰不要用_Color1、_Float2这种过两天你自己都忘了是什么。4.2 顶点着色器与屏幕UV传递顶点着色器要做的事情不多主要是把位置和UV传下去同时计算屏幕UVVaryings vert(Attributes input) { Varyings output; output.positionCS TransformObjectToHClip(input.positionOS.xyz); output.uv input.uv; output.screenUV output.positionCS.xy / output.positionCS.w * 0.5 0.5; return output; }注意screenUV的计算先做透视除法再映射到0到1。这个UV在片元着色器里用来采样深度图和屏幕颜色图。有个细节在有些平台上positionCS.w可能是负数或者零直接除会出问题。稳妥的做法是在片元着色器里重新计算或者加个保护。不过大部分情况下顶点阶段算就够了。4.3 片元着色器模块化组装片元着色器是整个Shader的核心我习惯按模块写每个模块一个函数最后组装float4 frag(Varyings input) : SV_Target { // 模块1深度与基础颜色 float sceneDepth LinearEyeDepth( SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, input.screenUV), _ZBufferParams ); float waterDepth sceneDepth - input.positionCS.w; float depthFactor saturate(waterDepth / _DepthMaxDistance); float3 waterColor lerp(_ShallowColor.rgb, _DeepColor.rgb, depthFactor); // 模块2法线扰动 float2 uv1 input.uv * _NormalScale _Time.y * _Speed1.xy; float2 uv2 input.uv * _NormalScale * 1.3 _Time.y * _Speed2.xy; float3 normal1 UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uv1)); float3 normal2 UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uv2)); float3 normal normalize(float3(normal1.xy normal2.xy, normal1.z * normal2.z)); // 模块3折射 float2 refractUV input.screenUV normal.xy * _RefractStrength; float3 refractColor SAMPLE_TEXTURE2D(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, refractUV).rgb; waterColor lerp(waterColor, refractColor, 0.5); // 模块4泡沫 float foam 1.0 - saturate(waterDepth / _FoamDistance); foam pow(foam, _FoamPower); float noise SAMPLE_TEXTURE2D(_FoamNoise, sampler_FoamNoise, input.uv * _FoamNoiseScale _Time.y * _FoamNoiseSpeed).r; foam * noise; waterColor lerp(waterColor, _FoamColor.rgb, foam); return float4(waterColor, 1.0); }这个结构的好处是每个模块都可以单独注释掉调试。比如你觉得颜色不对就把模块3和4注释掉只看模块1的输出。这样排查问题非常快。4.4 参数调试的实操记录我拿一个实际场景举例。场景是一个小水池水面到池底大概1.5米。第一轮调试_DepthMaxDistance设了5结果水看起来太透池底看得一清二楚没有水的质感。改成1.5之后水开始有颜色了但深色区太浓像墨水。把_DeepColor从(0.02, 0.1, 0.2)改成(0.05, 0.2, 0.3)稍微提亮了一点看起来自然多了。第二轮调法线。_NormalScale初始设1波纹太细太密像下雨的水面。改成0.5波纹变大更像水池的自然波动。速度方面_Speed1设(0.1, 0.1)_Speed2设(-0.15, 0.05)两张图反向滚动层次感出来了。第三轮调泡沫。_FoamDistance设0.3泡沫宽度合适。但泡沫边缘太整齐加了噪声贴图之后好多了。_FoamNoiseScale设5噪声密度适中。_FoamNoiseSpeed设0.5泡沫缓慢流动。第四轮调折射。_RefractStrength设0.02池底的瓷砖有轻微扭曲但不影响辨识。设0.05的时候扭曲太厉害看着头晕。最后定在0.02。整个调试过程大概花了两个小时核心就是一次只调一个参数调完看效果记下来。不要同时调好几个参数那样你根本不知道是哪个起了作用。5. 常见问题与排查技巧实录5.1 水体发白或发黑深度图没配对这是最高频的问题。表现是水体颜色完全不对要么一片白要么一片黑怎么调颜色参数都没用。排查顺序检查URP Asset里Depth Texture有没有勾上检查Shader里深度采样有没有做线性化检查_ZBufferParams有没有传进去在片元着色器里把waterDepth输出成颜色看看数值对不对如果waterDepth输出全是0或者全是很大的值说明深度计算有问题。正常情况下水面附近的waterDepth应该接近0远处应该逐渐增大。踩坑记录我有一次忘了在URP Asset里开Depth Texture调了一下午颜色参数怎么调都不对。后来把waterDepth可视化出来发现全是1才意识到深度图根本没生效。这个坑一定要先排查。5.2 水面闪烁或抖动法线计算精度问题水面出现高频闪烁通常是法线计算的问题。可能的原因法线贴图的UV缩放太大导致采样频率过高两张法线贴图的速度太快超过了屏幕刷新率法线没有归一化导致光照计算异常解决办法降低_NormalScale降低速度值确保法线做了normalize。如果还闪检查法线贴图的导入设置确保是Normal Map类型不要勾sRGB。5.3 泡沫出现在不该出现的地方泡沫基于深度差但深度差小的地方不一定是物体交界处。比如水面和远处的地面深度差可能也很小就会误判为泡沫。解决办法是加距离衰减让泡沫只在近处出现。另外可以加一个法线朝向判断只有水面法线朝上的时候才出泡沫侧面不出。float upFacing saturate(normal.y); foam * upFacing;这样泡沫就只出现在水面朝上的部分侧面和背面的误判就没了。5.4 移动端性能问题水体Shader在移动端容易成为性能瓶颈主要开销在深度图采样和屏幕颜色采样。优化方向降低深度图和屏幕颜色图的分辨率减少法线贴图的采样次数两张合成一张泡沫噪声用顶点颜色或者简单噪声代替贴图在低端机上关闭折射只用深度颜色我实测下来中端移动机上一个完整的水体Shader大概占2到3毫秒的GPU时间。如果场景里水体面积很大这个开销会更高。所以移动端项目要谨慎使用屏幕空间方案。5.5 常见问题速查表问题现象可能原因排查方法解决方向水体全白/全黑深度图未开启或未线性化可视化waterDepth开Depth Texture加LinearEyeDepth水面闪烁法线精度问题降低法线缩放和速度归一化法线降低采样频率泡沫位置错误深度差误判检查泡沫出现的位置加距离衰减和法线朝向判断折射扭曲过度折射强度太大调小_RefractStrength控制在0.01到0.03移动端卡顿深度和屏幕采样开销大Profiler看GPU时间降分辨率关折射水面颜色太浓深度距离设太小调大_DepthMaxDistance根据场景尺度调整水面没有反射环境贴图未生效检查Reflection Probe加探针或改用天空盒6. 进阶方向与个人经验补充6.1 顶点波动让水面有真实的起伏上面讲的是法线扰动水面本身是平的。如果想要水面有真实的起伏需要加顶点位移。在顶点着色器里用正弦波或者Gerstner波来偏移顶点位置float3 posOS input.positionOS.xyz; float wave sin(posOS.x * _WaveFrequency _Time.y * _WaveSpeed) * _WaveAmplitude; posOS.y wave; output.positionCS TransformObjectToHClip(posOS);Gerstner波比正弦波更真实因为它能让波峰变尖、波谷变宽。但计算量也更大。如果水面网格密度不够顶点波动会看起来很不自然所以要么用高密度网格要么用曲面细分。我的建议是如果水面不大用顶点波动如果水面很大用法线扰动就够了。大水面上的顶点波动玩家根本注意不到反而浪费性能。6.2 交互效果让角色“踩水”角色走进水里的时候水面应该产生涟漪。这个效果需要把角色的位置传给Shaderfloat3 playerPos _PlayerPosition.xyz; float dist distance(i.positionWS, playerPos); float ripple sin(dist * _RippleFrequency - _Time.y * _RippleSpeed) * exp(-dist * _RippleDecay); normal.xy ripple * _RippleStrength;这个效果在展示类项目里很加分但实际游戏里用得不多因为需要每帧更新角色位置而且多个角色同时踩水的时候计算量会上去。6.3 个人经验水体调试的三个习惯第一个习惯永远先可视化中间变量。waterDepth、depthFactor、foam这些值直接输出成颜色看比猜快一百倍。我见过太多人对着最终颜色调半天其实问题出在深度计算上。第二个习惯参数分组调试。颜色一组、法线一组、泡沫一组每次只调一组调完记录数值。水体参数之间会互相影响同时调很容易乱。第三个习惯在目标平台上调。PC上调好的参数到移动端可能完全不一样。因为深度精度、屏幕分辨率、GPU行为都不同。有条件的话尽早拿到目标设备上验证。6.4 后续可以扩展的方向水体渲染往下挖还有很多东西可以做水下视角需要处理水面的背面渲染和雾效焦散效果需要投影纹理到水底动态波浪需要FFT或者更复杂的波形叠加水体交互需要处理物体入水的折射和泡沫。但我的建议是先把基础的水体做扎实。深度颜色、法线扰动、泡沫、折射这四块做好了已经能应付大部分项目需求。那些花哨的效果等基础稳了再加不然很容易做成一个什么都有一点、什么都不对的四不像。最后分享一个我常用的调试技巧在Shader里加一个_DebugMode开关用if分支切换输出不同的中间变量。这样不用改代码就能快速切换调试视图比反复注释代码方便得多。