1. 从“死水”到“活水”水体交互到底在解决什么问题做过水面效果的人大概都有过这种体验用几个正弦波叠加出来的水面静态截图看着还行一旦角色踩进去水面纹丝不动脚底下像踩在一块玻璃板上。玩家可能说不出哪里不对但那种“假”的感觉会一直存在。水体交互要解决的核心问题就一个——让水面对外部物体做出符合直觉的响应包括踩水时的涟漪扩散、物体入水时的浪花溅射、以及水面被扰动后的波纹衰减。这件事在技术实现上可以拆成两条线来看。一条是视觉反馈线水面需要根据交互事件生成动态的波纹法线让光照在波纹上产生正确的折射和反射变化。另一条是物理反馈线角色或物体在水中的运动需要受到浮力、阻力、流速等物理量的影响反过来这些物理量又会影响水面的形变。两条线交织在一起才构成完整的“水体交互”体验。我见过不少项目只做了视觉线水面波纹确实动起来了但角色在水里走路的速度和陆地上完全一样浮力也是写死的常量。这种半吊子方案在轻度休闲游戏里勉强能用但只要玩家稍微认真一点去感受立刻就能察觉到割裂感。所以这篇文章我会把两条线都讲透从波纹生成算法到浮力计算从性能优化到移动端适配尽量把我在实际项目中踩过的坑和验证过的方案都摊开来说。适合阅读这篇内容的应该是有一定Unity Shader基础、做过至少一个水面渲染Demo、现在想把交互效果做扎实的开发者。如果你还没写过水面Shader建议先补一下顶点位移和法线贴图采样的基础否则后面有些推导可能会跟不上。2. 交互波纹的生成逻辑从事件触发到法线扰动2.1 为什么不用粒子系统做波纹很多人第一反应是用粒子系统在水面上撒一圈涟漪贴图。这个方案我早期也试过问题出在三个地方。第一粒子是独立于水面的渲染层它和水面之间没有真正的法线融合光照方向一变粒子和水面的高光就对不上看起来像贴上去的贴纸。第二粒子的生命周期和波纹的物理衰减很难对齐要么波纹消失得太快显得假要么粒子拖太久导致水面糊成一片。第三当多个交互事件同时发生时粒子的叠加顺序和透明度混合会变得不可控容易出现亮度突变。正确的做法是把波纹信息写进一张动态法线图或者高度图里让水面Shader在采样基础法线的同时叠加这张交互法线图。这样波纹和水面共享同一套光照计算视觉一致性天然就有保障。具体实现上我习惯用一张RenderTexture作为交互信息的载体分辨率通常取水面网格顶点数的1/4到1/2比如水面网格是256x256那RT取128x128或64x64就够了。2.2 交互事件的写入方式与坐标映射写入交互信息的第一步是确定“谁在什么位置以多大强度扰动了水面”。对于角色踩水触发点就是角色的脚底位置对于物体入水触发点是物体的碰撞点。这些位置是世界空间坐标需要转换到水面的UV空间才能写入RT。转换逻辑本身不复杂但有一个容易忽略的细节水面网格的UV映射方式。如果水面是一个简单的平面网格UV直接按世界坐标的XZ平面做线性映射就行。但如果水面有起伏地形或者是不规则形状UV映射就需要额外处理。我一般会在水面Shader里暴露一个_WaterBounds参数记录水面的世界空间包围盒然后用(worldPos.xz - bounds.min) / bounds.size来算UV。这样无论水面网格怎么变交互坐标的映射都是准确的。写入操作通过Graphics.Blit或者CommandBuffer来执行。每次有交互事件发生时把事件的位置、半径、强度打包成一个DrawCall用一个简单的Shader在RT上画一个衰减圆。衰减函数我试过几种高斯衰减最自然但计算量稍大smoothstep的性价比最高实测在移动端也能跑得很稳。// 交互波纹写入Shader的核心片段 float2 uv input.uv; float2 center _InteractPos; float radius _InteractRadius; float strength _InteractStrength; float dist distance(uv, center); float falloff 1.0 - smoothstep(0.0, radius, dist); float wave sin(dist * _WaveFrequency - _Time.y * _WaveSpeed) * falloff * strength; // 将波纹值写入R通道作为高度扰动 return float4(wave, 0, 0, 1);这段代码里_WaveFrequency和_WaveSpeed控制波纹的疏密和扩散速度。频率太高会显得水面很“碎”太低又像果冻在晃。我的经验值是频率取8到15之间速度取3到6之间具体要根据水面世界尺寸来调。如果水面是10米见方频率12、速度4是一个比较稳妥的起点。2.3 多事件叠加与波纹衰减的处理单个交互事件的波纹好做难的是多个事件同时发生时的叠加。如果直接做加法两个波纹重叠的区域亮度会翻倍看起来像水面被烧穿了一个洞。正确的做法是用max或者softmax来叠加保留最强的那个波纹值同时让次强的波纹在边缘处自然融合。衰减方面我建议在RT上做时间衰减而不是依赖波纹自身的扩散衰减。具体做法是每帧用一个极低透明度的黑色覆盖整个RT模拟波纹能量被水体吸收的过程。衰减系数取0.95到0.98之间太低波纹消失太快太高会残留很久导致水面一直有“余波”。这个值需要根据你的游戏节奏来调快节奏动作游戏取0.95左右慢节奏探索游戏可以取0.98。还有一个坑是RT的Clear操作。如果你每帧都Clear RT再重新写入所有活跃波纹性能开销会随波纹数量线性增长。更好的做法是让RT持续累积只靠时间衰减来清理旧波纹。这样每帧只需要写入新增的交互事件性能开销是常数级的。3. 浮力与阻力让角色在水里“有重量感”3.1 浮力计算的简化模型与参数标定浮力的物理公式是F ρ * g * V其中ρ是液体密度g是重力加速度V是排开液体的体积。在游戏里精确计算排开体积是不现实的我一般用浸没深度来近似。具体做法是取角色碰撞体的底部若干个采样点计算每个采样点在水面以下的深度然后累加得到一个“等效浸没体积”。// 浮力计算的简化实现 float totalBuoyancy 0f; foreach (var samplePoint in buoyancySamples) { float depth waterLevel - samplePoint.y; if (depth 0) { // 浸没越深浮力越大但存在上限 float submersion Mathf.Clamp01(depth / samplePoint.maxDepth); totalBuoyancy submersion * buoyancyForcePerSample; } } rigidbody.AddForce(Vector3.up * totalBuoyancy, ForceMode.Acceleration);这里maxDepth是采样点从完全露出到完全浸没的深度范围buoyancyForcePerSample是每个采样点的最大浮力贡献。这两个参数需要根据角色的质量和体积来标定。我的经验是先让角色在水里静止时刚好有一半体积露出水面然后微调buoyancyForcePerSample直到角色在水中上下浮动的幅度在0.1到0.3米之间。幅度太大像在蹦床太小又像在死水里。3.2 水阻力的方向性处理水阻力和空气阻力最大的区别在于方向性。空气阻力通常只和速度平方成正比方向与运动方向相反。但水阻力在不同方向上的表现差异很大水平方向移动时阻力主要来自水的粘滞性垂直方向移动时阻力还包含压差阻力。如果统一用一个阻力系数角色在水里下潜和上浮的速度会一样快这不符合直觉。我的做法是把速度分解到水平和垂直两个方向分别施加不同的阻力系数。水平阻力系数取0.8到1.2垂直阻力系数取1.5到2.5。这样角色在水里横向游动比较顺畅但想快速上浮或下潜时会感觉到明显的“水在拖你”。Vector3 localVel transform.InverseTransformDirection(rb.velocity); float horizontalDrag 1.0f; float verticalDrag 2.0f; Vector3 dragForce new Vector3( -localVel.x * horizontalDrag, -localVel.y * verticalDrag, -localVel.z * horizontalDrag ); rb.AddRelativeForce(dragForce * waterDragMultiplier, ForceMode.Acceleration);还有一个细节是入水瞬间的阻力突变。角色从空气进入水面的那一帧阻力系数应该有一个快速但平滑的过渡而不是瞬间跳变。我一般用Mathf.SmoothDamp在0.2秒内完成过渡这样入水时会有一种“破水而入”的顿挫感而不是像撞上一堵墙。3.3 流速场对角色运动的影响如果水面有流动效果比如河流或海浪角色在水里应该被水流推着走。实现方式是在水面区域定义一个流速向量场可以用一张流速贴图来存储方向和强度也可以在Shader里用噪声函数实时生成。角色进入水域后每帧采样所在位置的流速然后把这个速度叠加到角色的运动上。这里有一个容易犯的错误直接把流速加到角色的速度上。这样做的后果是角色会以水流的速度匀速漂移完全没有自主控制感。正确的做法是把流速作为一种外力施加给刚体让角色可以通过划水动作对抗水流。具体来说流速产生的力应该和角色的质量成正比这样重角色受水流影响小轻角色受水流影响大符合物理直觉。4. 水面Shader的交互融合法线叠加与折射修正4.1 基础法线与交互法线的混合策略水面Shader通常有两层法线一层是基础的水波法线来自法线贴图的滚动采样另一层是交互产生的动态法线来自前面写入的RT。这两层法线的混合方式直接决定了交互波纹的视觉强度。最简单的做法是直接相加然后归一化但这样会导致基础水波在交互区域被“冲淡”看起来像水面突然变平了。更好的做法是按交互强度做插值交互强度为0时完全使用基础法线交互强度为1时完全使用交互法线中间区域做平滑过渡。float3 baseNormal UnpackNormal(tex2D(_NormalMap, uv _Time.y * _FlowSpeed)); float3 interactNormal UnpackNormal(tex2D(_InteractRT, uv)); float interactMask tex2D(_InteractRT, uv).r; // 用交互强度做插值保留基础水波的细节 float3 finalNormal normalize(lerp(baseNormal, interactNormal, interactMask));这里interactMask取的是RT的R通道也就是波纹的高度值。注意在写入RT时要把波纹值映射到0到1之间负值需要做偏移处理否则法线混合会出现方向错误。4.2 折射偏移的交互修正水面的折射效果通常是通过GrabPass或者URP的_CameraOpaqueTexture来实现的原理是根据法线方向对背景纹理做UV偏移。当交互波纹产生时法线方向改变折射偏移也应该随之改变。但这里有一个问题如果直接用法线乘以一个固定的折射强度交互区域的折射偏移会突然变大看起来像水面被撕开了一个口子。我的处理方式是给折射强度加一个基于交互强度的衰减。交互越强的地方折射强度反而要适当降低因为波纹本身已经提供了足够的视觉信息过强的折射反而会干扰对波纹形状的感知。具体来说折射强度乘以(1 - interactMask * 0.5)这样交互区域的折射偏移只有基础区域的一半左右。还有一个细节是折射偏移的方向。基础水波的折射偏移方向是随机的但交互波纹的折射偏移应该有一个明确的中心——也就是交互事件的发生点。我一般会在RT的G通道里存储交互点的方向向量然后在Shader里用这个方向来偏置折射UV让波纹的折射效果呈现出从中心向外扩散的趋势。4.3 高光与反射的交互响应高光是水面质感的关键。交互波纹产生后高光应该沿着波纹的波峰和波谷产生明暗变化。如果高光计算只用了基础法线交互区域的高光会显得很“平”缺乏立体感。我的做法是在高光计算中同时使用基础法线和交互法线但给交互法线一个额外的权重系数。这个系数取0.3到0.5之间比较合适太高会让高光过于锐利太低又看不出交互对高光的影响。具体来说用normalize(baseNormal interactNormal * interactHighlightWeight)作为最终的高光法线。反射方面如果水面用了反射探针或者平面反射交互波纹也应该在反射中体现出来。但平面反射的开销比较大移动端通常用反射探针加屏幕空间反射的混合方案。这种情况下交互波纹对反射的影响主要通过法线扰动来实现——法线变了反射的采样方向就变了反射内容自然会产生扭曲。这个效果在金属度较高的水面上尤其明显。5. 性能优化从DrawCall到RT分辨率的取舍5.1 交互RT的分辨率与更新频率交互RT的分辨率是性能的大头。我做过一组实测在骁龙865的设备上128x128的RT每帧全屏写入的开销大约是0.3ms256x256是0.8ms512x512直接飙到2.5ms。对于移动端来说128x128是性价比最高的选择配合双线性过滤波纹的边缘过渡已经足够平滑。更新频率方面不需要每帧都更新RT。如果游戏逻辑帧是60fpsRT可以每两帧更新一次也就是30fps。波纹的扩散速度在视觉上不会有明显差异但性能开销直接减半。具体实现上可以用一个计数器每两帧执行一次Graphics.Blit。还有一个技巧是按需更新。如果当前没有任何交互事件活跃RT就不需要更新直接跳过Blit操作。这个判断很简单维护一个活跃波纹列表列表为空时跳过更新。在角色静止或者没有物体入水的时候这个优化能省下不少开销。5.2 水面网格的LOD与交互精度水面网格的细分程度直接影响交互波纹的顶点位移精度。如果网格太粗波纹的顶点起伏会呈现出明显的棱角。但网格太细又会导致顶点数爆炸。我的经验是近处用高细分网格远处用低细分网格中间做一个平滑过渡。具体来说可以把水面分成三层LOD。第一层是角色周围10米范围网格分辨率取0.25米一个顶点第二层是10到30米分辨率取0.5米第三层是30米以外分辨率取1米。三层之间用T-junction或者几何着色器来做无缝拼接。这样在保证近处交互精度的同时远处的顶点数只有近处的1/16。交互RT的采样也需要配合LOD。近处的水面采样高分辨率RT远处的水面可以采样低分辨率RT或者干脆不采样交互信息。这个可以通过在Shader里根据距离做分支来实现但要注意分支在移动端GPU上可能造成性能问题更好的做法是用两个Pass分别渲染近处和远处的水面。5.3 移动端的Shader变体精简移动端最怕的就是Shader变体爆炸。一个水面Shader如果包含了交互、折射、反射、焦散、泡沫等所有功能变体数量轻松破百。我的做法是按功能模块拆分Shader基础水面Shader只包含法线和水波交互功能作为一个独立的Keyword折射和反射各自独立。这样在打包时可以根据实际使用情况剔除不需要的变体。还有一个移动端特有的坑是浮点数精度。在移动端GPU上half精度只有10位尾数做波纹的累加计算时很容易出现精度丢失表现为波纹在远处出现明显的阶梯状。解决办法是在RT写入时用float精度在采样时再转成half。另外波纹的累加不要超过3层超过3层后精度问题会变得很明显。6. 那些文档里不会写的踩坑记录6.1 波纹在斜视角下的拉伸问题这个问题我卡了整整两天。水面在俯视角下波纹是圆的但一旦摄像机角度变斜波纹就变成了椭圆形角度越斜拉伸越严重。一开始我以为是UV映射的问题查了半天发现根源在RT的投影方式。如果RT是按世界空间的XZ平面正交投影写入的那它在斜视角下自然会被拉伸。解决办法有两种。一种是改用透视投影写入RT让RT的采样方式与摄像机的视角匹配。但这样做的代价是RT的内容会随摄像机移动而变化需要每帧重新计算投影矩阵。另一种更简单的方案是在Shader里对RT的采样UV做视角补偿根据摄像机的前向向量和水平面的夹角来缩放UV的V方向。我最终用的是第二种方案补偿系数用1 / cos(cameraAngle)来算实测在45度视角下波纹的圆度已经可以接受。6.2 多个角色同时踩水时的RT写入冲突当两个角色同时踩水时如果两个交互事件在同一帧写入RT后写入的会覆盖先写入的。如果两个事件的位置有重叠覆盖会导致波纹形状变得很奇怪。我一开始用Blend One One来做加法混合但前面说过加法混合会导致重叠区域过亮。最终的方案是用Max混合模式。在Shader里把波纹值写入RT的R通道混合模式设为BlendOp Max。这样两个波纹重叠时取的是较大的那个值不会出现亮度翻倍。但Max混合有一个副作用它不支持透明度混合所以波纹的边缘会显得比较硬。为了缓解这个问题我在写入时把波纹的衰减曲线做得更平滑一些用pow(falloff, 1.5)来代替线性的falloff。6.3 角色出水后的波纹残留角色从水里出来之后水面上的波纹应该逐渐消失。但如果RT的衰减系数设得太小波纹会残留很久看起来像水面一直在“沸腾”。我试过几种衰减方案最终发现指数衰减加阈值截断效果最好。具体来说每帧对RT做一次value * 0.96的衰减同时当value小于0.01时直接置零。这样波纹在消失的最后阶段会有一个快速的“收尾”不会留下肉眼可见的残留。还有一个细节是角色出水瞬间的波纹处理。如果角色是跳出水面的出水点应该产生一个反向的波纹——也就是水面先向下凹陷再回弹。这个效果可以通过在出水时写入一个负值的波纹来实现但要注意负值波纹和正值波纹的混合。我一般会在出水时额外写入一个正值的“回弹”波纹延迟0.1秒后写入模拟水面回弹的效果。6.4 水面与地形的接缝处理如果水面和地形有交界交互波纹在靠近岸边时会被地形遮挡但RT的写入是不考虑地形的所以波纹会“穿”到地形下面去。这个问题在浅水区尤其明显角色在岸边踩水时波纹会出现在岸上的地面上。解决办法是在水面Shader里做深度检测。采样当前像素的深度和地形深度如果地形深度比水面深度更近说明这个像素被地形遮挡了交互波纹的强度应该衰减到零。具体来说用LinearEyeDepth采样深度图然后计算terrainDepth - waterDepth这个差值越小说明越靠近岸边交互强度乘以这个差值的归一化结果。这个方案在URP和Built-in管线下的实现略有不同。URP下需要额外开启Depth TextureBuilt-in管线可以直接用_CameraDepthTexture。移动端开启深度图会有一定的性能开销如果游戏里没有其他需要深度图的效果可以考虑用一张预烘焙的地形高度图来代替在Shader里采样高度图来判断遮挡。7. 从Demo到产品一些工程化的建议把水体交互做到能看不难做到能上线却需要额外考虑很多事情。我总结了几条在实际项目中验证过的经验。第一交互参数一定要暴露给策划。波纹的半径、强度、衰减速度、浮力系数、阻力系数这些参数不要写死在代码里用ScriptableObject或者配置表管理。我见过太多项目把参数硬编码在Shader里策划想调一下波纹大小都要找程序改代码重新打包效率极低。第二做好交互事件的优先级管理。当大量交互事件同时发生时比如一群角色同时跳进水里RT的写入压力会骤增。我的做法是给交互事件设一个优先级角色踩水的优先级最高物体入水次之环境扰动最低。当活跃事件超过阈值时低优先级的事件直接丢弃保证核心交互的流畅性。第三移动端和PC端用不同的质量档位。移动端RT分辨率降到64x64波纹层数限制在2层关闭折射和反射的交互响应。PC端可以用256x256的RT波纹层数开到4层折射和反射全开。这些档位通过Quality Setting来控制不需要改代码。第四做好边界情况的测试。我列了一个测试清单角色从高处跳入水中、角色在水下移动、多个角色同时踩水、角色在浅水区行走、水面有流动时角色逆流而上、摄像机从水下看向水面。这些场景覆盖了大部分边界情况每次改完Shader都跑一遍能提前发现很多问题。水体交互这个方向说到底是细节的堆叠。每一个参数、每一行Shader、每一个物理系数单独看都不复杂但把它们组合在一起并且让它们在各种场景下都表现自然就需要大量的调试和迭代。我到现在也不敢说自己的方案是完美的每次做新项目都会发现可以改进的地方。但只要你把上面这些点都覆盖到了做出来的水体交互至少不会让玩家觉得“假”这就已经赢过市面上大部分游戏了。