1. 水体交互到底在做什么:从“看着像水”到“碰了有反应”
水面渲染这件事,做过的人都知道,静态好看不难,难的是“动起来像真的”。很多项目里水面就是一张贴图加个法线滚动,远看还行,角色一踩进去立刻穿帮——脚陷进去了,水面纹丝不动,连个涟漪都没有。这种割裂感就是水体交互要解决的核心问题。
所谓水体交互,说白了就是让水面能感知到外部物体的介入,并且做出符合直觉的视觉反馈。角色踩进水里,脚下荡开一圈圈波纹;物体落水,溅起水花并向外扩散涟漪;船划过水面,尾迹拖出一条逐渐消散的痕迹。这些效果背后涉及的技术点包括深度检测、法线扰动、涟漪扩散模拟、折射与反射的动态更新等。
这篇文章面向的是有一定Unity Shader基础、想做水面交互但不知道从哪下手的开发者。我会从整体设计思路讲起,把每个关键环节拆开揉碎,包括深度图的读取与处理、涟漪的生成与传播、法线扰动的叠加方式、以及性能优化上的取舍。文中给出的参数和代码都是可以直接参考的,但更重要的是理解为什么这么选。
2. 整体方案设计:为什么不用物理模拟而用Shader近似
2.1 实时水体交互的两条路线
做水体交互,摆在面前的有两条路。一条是走物理模拟,用流体动力学方程去算水面的形变和波动,比如SPH或者网格法。另一条是在Shader层面做视觉近似,用深度信息判断物体和水面的关系,用数学函数模拟涟漪的扩散。
物理模拟的好处是“真”,坏处也是“真”——太贵了。一个中等规模的水面用SPH跑,CPU端计算量直接爆炸,而且和渲染管线的耦合非常麻烦。对于绝大多数游戏和实时应用来说,玩家不会去验证涟漪的传播是否符合Navier-Stokes方程,他们只需要“看起来对”。所以实际项目中,九成以上的水体交互都是Shader近似方案。
Shader近似的核心思路是:不模拟水的体积,只处理水面这一层。通过深度图拿到水面下方物体的信息,判断哪些像素被物体“侵入”了,然后在这些位置生成扰动。扰动以法线偏移或者高度偏移的形式叠加到水面上,配合时间参数让扰动向外扩散并衰减。
2.2 深度图:交互的基石
深度图是整个交互方案的基石。没有深度信息,Shader根本不知道水面下面有没有东西、东西离水面多近。Unity里获取深度图的方式取决于渲染管线。Built-in管线里,Camera的depthTextureMode要设置为Depth或DepthAndNormals,然后在Shader里通过_CameraDepthTexture采样。URP管线里则是通过DeclareDepthTexture声明,用SampleSceneDepth来采样。
深度图拿到的是场景中不透明物体的深度值,水面本身也会写入深度。所以判断物体是否在水面下方,逻辑是这样的:先渲染不透明物体,再渲染水面。水面Shader在渲染时,采样当前像素位置的场景深度,和水面自身的深度做比较。如果场景深度小于水面深度,说明这个位置有物体挡在水面前面——也就是物体在水面之上。如果场景深度大于水面深度,说明物体在水面之下。
但这里有个细节:水面本身是半透明的,通常不会写入深度。所以场景深度图里不包含水面,只包含水面下方的不透明物体。这样一来,水面Shader采样到的深度值就是水下物体的深度,和水面深度比较就能知道物体离水面有多远。
2.3 为什么选择屏幕空间方案
屏幕空间方案的好处是通用性强。不管水下是什么形状的物体,不管它怎么动,只要它被渲染到了深度图里,水面就能感知到。不需要给每个可能入水的物体挂脚本,不需要物理碰撞体,不需要CPU端做任何额外计算。所有的交互逻辑都在GPU端完成,效率极高。
代价是精度受限于深度图的分辨率。如果深度图分辨率不够,水面和物体的交界处会出现锯齿或者硬边。另外,透明物体不会写入深度图,所以透明物体入水时水面感知不到。这两个问题在后面会讲具体的缓解办法。
3. 核心细节拆解:深度检测、涟漪生成与法线扰动
3.1 深度检测的具体实现与参数选择
深度检测的Shader代码核心就是几行。先采样水面自身的深度,再采样场景深度,两者相减得到一个差值。这个差值反映了水下物体离水面的距离。
float sceneDepth = SampleSceneDepth(uv); float waterDepth = SampleSceneDepth(uv); // 水面自身深度通过其他方式获取 float depthDiff = sceneDepth - waterDepth;实际写的时候,水面自身的深度不能直接从深度图采样,因为水面不写入深度。通常的做法是在顶点着色器里计算水面的屏幕空间位置,然后在片元着色器里用这个位置反推深度。或者更简单的方式:用一个额外的Pass把水面深度渲染到一张RT里,但这样开销太大。常见做法是利用水面的世界坐标和相机参数手动计算深度。
深度差值的阈值选择很关键。差值太小,水面和物体的交界处会闪烁;差值太大,交互区域会过宽,看起来像水面在“膨胀”。我一般会设两个阈值:一个最小阈值用来过滤掉深度精度误差导致的噪点,一个最大阈值用来限制交互的影响范围。比如最小阈值设0.01,最大阈值设2.0,中间用smoothstep做平滑过渡。
float interaction = smoothstep(_MinDepth, _MaxDepth, depthDiff);这样得到的interaction值在0到1之间,0表示没有物体,1表示物体紧贴水面。这个值就是后续所有交互效果的强度系数。
3.2 涟漪的生成:从单点扰动到环形扩散
有了interaction值,接下来就是生成涟漪。最简单的涟漪模型是:在物体与水面接触的位置,生成一个向外扩散的环形波。数学上可以用正弦函数或者高斯函数来模拟。
float ripple(float2 uv, float2 center, float time, float speed, float frequency, float decay) { float dist = distance(uv, center); float wave = sin(dist * frequency - time * speed); float attenuation = exp(-dist * decay); return wave * attenuation; }这个函数里,dist是当前像素到扰动中心的距离,frequency控制波纹的密集程度,speed控制扩散速度,decay控制衰减快慢。sin项产生周期性的波峰波谷,exp项让波幅随距离衰减。
但实际场景中,扰动中心不是固定的。角色在移动,物体在漂浮,所以center需要每帧更新。通常的做法是在CPU端维护一个扰动列表,记录每个扰动的位置、生成时间、强度等信息,然后通过材质属性或者ComputeBuffer传给Shader。Shader里遍历这些扰动,把每个扰动的涟漪叠加起来。
扰动数量不能太多,否则Shader里的循环会拖垮性能。一般限制在8到16个同时活跃的扰动。超出的按时间淘汰,或者合并相近的扰动。
3.3 法线扰动的叠加方式
涟漪最终要体现在水面的法线上。水面的法线决定了光照的方向,法线一变,高光和反射就跟着变,视觉上就是水面在波动。
法线扰动的叠加有两种方式。一种是直接修改法线贴图的采样结果,把涟漪的法线偏移加到采样到的法线上。另一种是用水面的高度场来算梯度,从高度差反推法线。第一种方式更直接,性能也更好,但需要把涟漪转换成法线偏移量。
涟漪的法线偏移可以通过对涟漪函数求偏导得到。但实际项目中很少真的去求导,而是用近似:把涟漪的梯度方向近似为从扰动中心向外的径向方向,梯度大小和涟漪的斜率成正比。
float2 rippleNormal = normalize(uv - center) * rippleSlope;然后把所有扰动的法线偏移累加,再和基础法线混合。混合的时候要注意权重,扰动强的地方法线偏移大,扰动弱的地方法线偏移小。
float3 finalNormal = normalize(baseNormal + float3(rippleNormal.x, rippleNormal.y, 0) * interaction);这里interaction就是前面深度检测得到的强度系数,保证只有物体接触水面的位置才有法线扰动。
4. 完整实操流程:从零搭建一个可交互的水面
4.1 场景准备与渲染管线配置
先确认项目用的渲染管线。如果是Built-in管线,在相机上挂一个脚本,设置depthTextureMode。
void OnEnable() { GetComponent<Camera>().depthTextureMode |= DepthTextureMode.Depth; }如果是URP管线,需要在URP Asset里勾选Depth Texture选项,然后在Shader里声明深度纹理。
TEXTURE2D_X_FLOAT(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture);水面网格用一个细分足够多的Plane。细分的目的是让顶点动画有足够的精度,如果只做法线扰动,细分可以低一些,但要做顶点位移的话,细分至少要到128x128。我一般用200x200的网格,配合LOD系统,远处的水面降低细分。
4.2 水面Shader的骨架搭建
水面Shader的Pass结构大概是这样的:先渲染不透明物体,然后渲染水面。水面的渲染顺序要设置成Transparent,Queue设为Transparent,RenderType设为Transparent。ZWrite关掉,ZTest设为LEqual。
Shader的Properties里需要暴露这些参数:水面颜色、深度颜色、法线贴图、法线滚动速度、涟漪频率、涟漪速度、涟漪衰减、交互强度、最大扰动数量等。
Properties { _ShallowColor ("Shallow Color", Color) = (0.2, 0.6, 0.8, 1) _DeepColor ("Deep Color", Color) = (0.05, 0.2, 0.4, 1) _NormalMap ("Normal Map", 2D) = "bump" {} _NormalScale ("Normal Scale", Float) = 1.0 _RippleFrequency ("Ripple Frequency", Float) = 20.0 _RippleSpeed ("Ripple Speed", Float) = 5.0 _RippleDecay ("Ripple Decay", Float) = 3.0 _InteractionStrength ("Interaction Strength", Float) = 1.0 _MaxRipples ("Max Ripples", Int) = 8 }顶点着色器里计算水面的世界坐标和屏幕坐标,片元着色器里做深度检测、涟漪计算、法线混合、光照计算。
4.3 深度检测与交互强度计算
片元着色器里,先用屏幕UV采样场景深度。
float2 screenUV = i.screenPos.xy / i.screenPos.w; float sceneDepth = LinearEyeDepth(SampleSceneDepth(screenUV), _ZBufferParams);然后计算水面自身的深度。水面深度可以通过水面的世界坐标和相机参数算出来。
float waterDepth = LinearEyeDepth(i.positionCS.z / i.positionCS.w, _ZBufferParams);两者相减得到深度差。
float depthDiff = sceneDepth - waterDepth; float interaction = smoothstep(_MinDepth, _MaxDepth, depthDiff);这里有个坑:如果水面下方没有物体,sceneDepth采样到的是天空盒或者远平面的深度,depthDiff会是一个很大的值。所以需要加一个上限判断,超过最大阈值的直接归零。
interaction *= step(depthDiff, _MaxDepth * 1.5);4.4 涟漪扰动的传入与计算
CPU端维护一个扰动数组,每个扰动包含位置(屏幕空间或世界空间)、生成时间、初始强度。每帧更新这些扰动,把活跃的扰动通过MaterialPropertyBlock或者ComputeBuffer传给Shader。
struct RippleData { public Vector4 position; // xy: 屏幕UV, z: 生成时间, w: 强度 } void Update() { for (int i = activeRipples.Count - 1; i >= 0; i--) { if (Time.time - activeRipples[i].time > rippleLifetime) { activeRipples.RemoveAt(i); } } rippleBuffer.SetData(activeRipples); waterMaterial.SetBuffer("_RippleBuffer", rippleBuffer); }Shader里遍历这个Buffer,对每个扰动计算涟漪贡献。
float totalRipple = 0; float2 totalNormalOffset = 0; for (int i = 0; i < _RippleCount; i++) { float4 ripple = _RippleBuffer[i]; float2 center = ripple.xy; float age = _Time.y - ripple.z; float strength = ripple.w * exp(-age * _RippleDecay); float dist = distance(screenUV, center); float wave = sin(dist * _RippleFrequency - age * _RippleSpeed); float attenuation = exp(-dist * _RippleDecay); totalRipple += wave * attenuation * strength; totalNormalOffset += normalize(screenUV - center) * wave * attenuation * strength; }这里用屏幕UV作为涟漪的传播空间,好处是计算简单,坏处是涟漪的扩散速度会受屏幕分辨率影响。如果要做世界空间的涟漪,需要把屏幕UV转成世界坐标,计算完再转回来,开销会大一些。
4.5 法线混合与光照计算
把涟漪的法线偏移和基础法线混合。
float3 baseNormal = UnpackNormal(tex2D(_NormalMap, uv + _Time.y * _NormalSpeed)); float3 rippleNormal = float3(totalNormalOffset * _InteractionStrength, 0); float3 finalNormal = normalize(baseNormal + rippleNormal * interaction);光照计算用Blinn-Phong或者PBR都行。关键是高光部分要能体现出法线的变化,所以高光的粗糙度不能太高,否则法线扰动看不出来。
float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos); float3 halfDir = normalize(lightDir + viewDir); float spec = pow(max(0, dot(finalNormal, halfDir)), _SpecularPower);反射部分可以用Cubemap或者屏幕空间反射。屏幕空间反射效果更好,但开销也更大。如果项目对性能敏感,用Cubemap加一个菲涅尔系数就够了。
5. 常见问题与排查技巧实录
5.1 水面与物体交界处出现硬边或锯齿
这是最常见的问题,根源在深度图的分辨率。深度图的分辨率通常和屏幕分辨率一致,但深度值的精度有限,尤其是在远距离。水面和物体的深度差很小时,精度误差会导致interaction值在0和1之间跳变,视觉上就是硬边。
解决办法有三个。一是用smoothstep代替step,让过渡更平滑。二是对深度差做模糊处理,在Shader里对周围像素的深度差取平均。三是提高深度图的精度,比如用32位浮点格式的深度图。
我一般用smoothstep加上一个小的模糊核,效果就足够了。模糊核不用太大,3x3或者5x5就行,再大性能吃不消。
5.2 涟漪扩散速度看起来不自然
涟漪的扩散速度由_RippleSpeed控制,但这个速度是屏幕空间的,不是世界空间的。如果相机拉远,同样的_RippleSpeed在视觉上会变慢。解决办法是把涟漪的传播距离从屏幕空间转到世界空间。
float3 worldPos = ComputeWorldSpacePosition(screenUV, sceneDepth, UNITY_MATRIX_I_VP); float worldDist = distance(worldPos, rippleWorldPos);然后用worldDist代替屏幕空间的dist来计算涟漪。这样不管相机怎么动,涟漪的扩散速度都是恒定的。
但世界空间的计算量更大,如果扰动数量多,性能会明显下降。折中方案是用一个缩放系数,根据相机距离动态调整_RippleSpeed。
5.3 多个涟漪叠加时出现摩尔纹
当多个涟漪在同一区域叠加时,如果频率相近,会产生干涉条纹,看起来像摩尔纹。这是因为sin函数的周期性导致的。
解决办法是给每个涟漪加一个随机的相位偏移。
float phase = ripple.w * 6.28; // 用强度或者索引生成随机相位 float wave = sin(dist * _RippleFrequency - age * _RippleSpeed + phase);这样不同涟漪的波峰波谷不会对齐,干涉条纹就消失了。
5.4 性能开销过大
水体交互的性能瓶颈主要在两个方面:深度图的采样和涟漪的循环计算。深度图采样每个像素都要做一次,这个开销是固定的。涟漪循环的开销和扰动数量成正比,如果同时有几十个扰动,Shader的指令数会爆掉。
优化手段有几个。一是限制同时活跃的扰动数量,超过上限的按优先级淘汰。二是把涟漪计算放到Compute Shader里,生成一张涟漪法线图,水面Shader直接采样这张图。三是用LOD系统,远处的水面不计算涟漪。
我实测下来,在移动端上,同时活跃的扰动控制在4到6个比较合适。PC端可以到16个。如果还不够,就得考虑用RenderTexture预计算涟漪了。
5.5 透明物体入水没有反应
前面提过,透明物体不写入深度图,所以水面感知不到。解决办法是给透明物体单独渲染一张深度图,或者用Stencil标记透明物体的区域,水面Shader里额外判断Stencil值。
但这样做的开销不小,而且透明物体的深度排序本身就是个麻烦事。如果项目里透明物体入水的情况不多,可以考虑用脚本手动触发涟漪,不走深度检测。
6. 进阶扩展:让水体交互更丰富的几个方向
6.1 顶点位移与波浪形变
前面讲的都是法线层面的扰动,水面本身的几何没有变。如果要做更强烈的交互,比如物体落水时水面凹陷,就需要在顶点着色器里做位移。
顶点位移的实现方式是:在顶点着色器里计算每个顶点到扰动中心的距离,根据距离和涟漪函数计算高度偏移,然后沿法线方向移动顶点。
float heightOffset = rippleFunction(dist, age) * strength; vertexPosition += float3(0, heightOffset, 0);顶点位移需要网格有足够的细分,否则位移看不出来。而且顶点位移会影响深度检测的结果,因为水面深度变了,需要重新计算。所以顶点位移和深度检测要放在同一个Pass里,先算位移再算深度。
6.2 交互产生的泡沫与漂浮物
物体入水时,除了涟漪,还可以生成泡沫。泡沫的实现方式是在interaction值高的区域叠加一层泡沫贴图,泡沫的透明度随时间和距离衰减。
漂浮物则是另一个思路:在水面上生成一些小的面片,随涟漪的波动而上下浮动。漂浮物的位置由涟漪的高度场决定,可以用采样涟漪法线图的方式获取高度。
6.3 基于Compute Shader的涟漪扩散模拟
如果要做更真实的涟漪扩散,可以用Compute Shader跑一个简化的波动方程。在Compute Shader里维护一张高度图,每帧更新高度值,然后把高度图传给水面Shader做顶点位移和法线计算。
这种方式的优点是涟漪的传播更自然,可以模拟反射、干涉等效果。缺点是开销大,而且需要额外的RT来存储高度图。适合对效果要求高、性能预算充足的项目。
7. 我个人在实际操作中的几点体会
做水体交互这几年,踩过的坑不少,有几个经验我觉得值得分享。
第一,深度检测的阈值不要写死,最好根据相机的远近动态调整。相机拉远时,深度精度下降,阈值要适当放大,否则交互区域会闪烁。相机拉近时,阈值要缩小,否则交互区域会过宽。
第二,涟漪的衰减曲线比频率更重要。频率决定波纹的密集程度,衰减决定涟漪能传多远。衰减太快,涟漪刚生成就没了;衰减太慢,水面会一直抖。我一般用指数衰减,衰减系数在2到5之间调。
第三,不要追求物理精确。实时渲染里,视觉上的“对”比物理上的“对”重要得多。玩家不会去算涟漪的传播速度是否符合波动方程,他们只会觉得“看起来像那么回事”。所以参数怎么调好看就怎么来,不用太纠结物理意义。
第四,性能优化要趁早。水体交互的Shader很容易写复杂,指令数一多,移动端直接跪。建议在开发初期就定好性能预算,比如涟漪循环最多8次,深度图采样最多2次,超出的用其他方式替代。
第五,多准备几套参数预设。不同的场景对水面的要求不一样,平静的湖面和湍急的河流,涟漪的频率、速度、衰减都不同。做成可配置的预设,切换场景时直接换参数,比每次手动调省事得多。