☰
Unity水体交互实战:深度图与交互纹理实现实时波纹泡沫
2026/10/22 9:30:21 网站建设 项目流程

1. 水体交互到底在做什么:从“看着像水”到“摸起来像水”

水面效果在实时渲染里一直是个分水岭。很多项目能把水做得“看着像水”——有反射、有折射、有波纹,静态截图能唬人。但玩家一走进水里,脚边没有涟漪,角色穿过瀑布水帘,帘子纹丝不动,船划过湖面,船尾连个像样的尾迹都留不下。这种割裂感,就是水体交互要解决的问题。

水体交互,说白了就是让水面和场景里的物体产生双向的视觉反馈。物体动,水面跟着动;水面动,物体周围的光影跟着变。它不是一个单一功能,而是一整套围绕“接触”和“扰动”的渲染机制。核心关键词包括:交互波纹、深度检测、折射偏移、岸边泡沫、尾迹生成、实时反射。这套东西做出来,玩家不需要看说明书,脚踩进水里那一瞬间,画面自己会说话。

这篇文章适合谁看?如果你已经写过基础的Unity Shader,知道顶点着色器和片元着色器大概在干什么,但一到“让水面响应物体”就卡壳,那这篇就是给你准备的。我会从整体设计思路讲起,把深度图、波纹生成、泡沫遮罩、折射扰动这几个核心环节拆开揉碎,再给出一套可以直接抄的实操流程。中间会穿插我踩过的坑和实测有效的参数,尽量让你少走弯路。

先说清楚一个前提:水体交互的实现方式有很多种,有基于物理模拟的,有基于屏幕后处理的,也有基于Shader Graph可视化连线的。我下面讲的主要是基于深度图和交互纹理的实时方案,这套方案在移动端和PC端都能跑,性能可控,效果也够用。如果你追求的是流体力学级别的真实模拟,那可能需要另一套思路,但那个开销不是一般项目能承受的。

2. 整体设计思路:为什么是深度图加交互纹理

2.1 核心架构的选型逻辑

做水体交互,第一个要决定的就是:水面怎么知道有物体靠近了?这个问题有三种常见解法。

第一种是物理碰撞检测。给水面加碰撞体,物体进入触发区域后,CPU通知Shader。这种方式精确,但开销大,尤其是多个物体同时交互时,物理查询和消息传递会成为瓶颈。而且水面通常是一个大平面,碰撞体的形状很难贴合波浪起伏。

第二种是屏幕空间深度比较。渲染水面时,采样当前像素对应的场景深度,和水面自身深度做比较。如果场景深度比水面深度近,说明有物体挡在水面前面,也就是物体和水面发生了重叠。这个重叠区域就是交互发生的地方。这种方式完全在GPU上完成,不需要CPU参与,性能极好,而且天然支持任意形状的物体。

第三种是交互纹理绘制。用一个额外的相机从上方俯拍,或者用脚本把物体的位置、半径、强度画到一张RenderTexture上。水面Shader采样这张纹理,就知道哪里被扰动了。这种方式适合做尾迹、涟漪扩散这类需要“记忆”的效果。

我的方案是深度图为主,交互纹理为辅。深度图负责实时检测物体和水面的接触区域,交互纹理负责记录和扩散扰动。两者结合,既能做到即时响应,又能做出涟漪扩散的余韵。

注意:深度图方案有一个硬性前提——你的渲染管线必须支持深度纹理。内置管线里,相机需要开启depthTextureMode;URP里,需要在管线设置里勾选Depth Texture。这个不打开,后面所有东西都是空中楼阁。

2.2 为什么不用纯屏幕后处理

有人可能会问,既然深度图是屏幕空间的,那为什么不干脆把整个水体交互做成一个屏幕后处理效果?答案很简单:屏幕后处理拿不到水面的法线信息,也没法区分“这是水面”和“这是地板”。你需要在渲染水面的那个Pass里,同时拿到水面自身的深度、法线、以及场景深度,才能算出正确的交互效果。屏幕后处理只能拿到颜色和深度,信息不够。

而且,水体交互往往需要和水的其他效果(反射、折射、波浪)耦合。比如物体入水时,折射偏移要跟着扰动变化;泡沫要沿着交互边缘生成。这些都需要在水面的Shader内部统一处理,拆到后处理里反而更麻烦。

2.3 交互纹理的格式选择

交互纹理我一般用ARGBHalf或者ARGB32。R通道存扰动强度,G通道存泡沫强度,B通道存法线扰动的X方向,A通道存Y方向。如果显存紧张,可以压缩成RGBAHalf,但精度会掉一些。纹理分辨率不需要太高,512x512或者1024x1024足够,因为涟漪本身是低频的,高频细节靠法线贴图补。

纹理的投影方式有两种:一种是世界空间俯投影,把物体的世界坐标映射到纹理的UV上;另一种是屏幕空间投影,直接用屏幕UV。俯投影适合做尾迹和固定区域的交互,屏幕空间投影适合做全屏的雨滴涟漪。我一般用俯投影,因为可控性强,而且不受相机旋转影响。

3. 核心细节解析:深度检测、波纹生成与泡沫遮罩

3.1 深度检测的精度陷阱

深度检测的原理很简单:采样场景深度,和水面深度比较,差值小于某个阈值就认为是交互区域。但这里有几个坑。

第一个坑是深度值的非线性。Unity的深度纹理存的是NDC空间下的深度,经过投影矩阵变换后,近处的精度高,远处的精度低。如果你直接用差值做阈值判断,近处可能很敏感,远处就完全没反应。解决办法是把深度值转换到线性空间,用线性深度做比较。

// 把深度纹理的原始值转成线性深度 float sceneDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv)); float waterDepth = LinearEyeDepth(i.depth); float depthDiff = sceneDepth - waterDepth;

第二个坑是水面自身的深度写入。如果水面Shader没有正确写入深度,场景深度和水面深度比较时就会出错。确保水面的Pass里设置了ZWrite On,或者在URP里用正确的DepthOnly Pass。

第三个坑是透明物体的深度。如果场景里有其他透明物体挡在水面前面,深度图里可能没有它们的信息,导致交互检测失效。这个问题的根本解法是让透明物体也写入深度,但那样又会影响渲染顺序。我的做法是接受这个限制,因为大多数情况下,和水面交互的物体都是不透明的。

3.2 交互波纹的生成方式

波纹的生成有两种主流方式:基于法线的扰动和基于高度的位移。

基于法线的扰动,是在片元着色器里,根据交互强度,对水面的法线进行偏移。这种方式开销小,适合做细微的涟漪。但缺点是只能改变光照,不能改变水面的实际形状,所以侧面看的时候会穿帮。

基于高度的位移,是在顶点着色器里,根据交互纹理的强度,把顶点往上抬。这种方式能做出真实的隆起,但需要水面有足够的细分。如果水面是一个只有四个顶点的大平面,顶点位移根本看不出效果。

我的做法是两者结合:顶点位移做大的隆起,法线扰动做细节。顶点位移的幅度不要太大,0.05到0.1个单位就够了,否则水面会像果冻一样弹。法线扰动的强度可以大一些,因为那是视觉上的“波纹感”的主要来源。

// 顶点位移示例 float interactStrength = tex2Dlod(_InteractTex, float4(uv, 0, 0)).r; vertex.y += interactStrength * _WaveHeight;

3.3 泡沫遮罩的生成逻辑

泡沫是水体交互里最出效果的部分。物体入水时,接触边缘会泛起一圈白沫,这个细节能让交互的真实感提升一个档次。

泡沫的生成逻辑是:在深度差值的边缘区域,也就是场景深度和水面深度接近的地方,生成泡沫。具体来说,当深度差小于某个阈值时,泡沫强度逐渐增大;当深度差为负(物体在水面以下)时,泡沫强度达到最大。

float foam = saturate(1.0 - depthDiff / _FoamDistance); foam *= step(0, depthDiff); // 只在水面以上生成

但这样生成的泡沫是静态的,物体不动的时候,泡沫就一直贴在那里。为了让泡沫有动态感,可以叠加一层噪声纹理,用时间做偏移,让泡沫看起来在流动。

实操心得:泡沫的阈值不要设得太死,留一点过渡区间。我一般用smoothstep而不是step,这样泡沫的边缘会更柔和,不会出现硬边。

3.4 折射偏移与交互的耦合

水面的折射效果,通常是用GrabPass或者URP的CameraOpaqueTexture来实现的。采样屏幕颜色时,根据法线偏移UV,就能做出折射的扭曲感。

当物体和水面交互时,折射偏移应该跟着扰动变化。具体来说,交互强度越大,折射偏移越明显。这样物体入水时,水下的部分会因为折射扭曲而显得更“在水里”。

float2 refractionOffset = normal.xy * _RefractionStrength; refractionOffset += interactStrength * _InteractRefraction; float3 sceneColor = tex2D(_CameraOpaqueTexture, uv + refractionOffset).rgb;

这里要注意,折射偏移的方向要和法线方向一致,否则会出现反向扭曲的诡异效果。

4. 实操过程:从零搭建一套可用的水体交互

4.1 场景准备与管线配置

先确认你的渲染管线。内置管线的话,在主相机上挂一个脚本,设置depthTextureMode = DepthTextureMode.Depth。URP的话,在Universal Render Pipeline Asset里勾选Depth Texture和Opaque Texture。

然后创建一个水面平面。不要用Unity自带的Plane,那个只有121个顶点,做顶点位移不够用。我一般用ProBuilder或者Blender做一个细分网格,分辨率至少200x200。如果性能吃紧,可以降到100x100,但波浪的细节会损失一些。

水面的材质,新建一个Shader。我习惯从Unlit Shader开始改,因为不需要默认的光照模型,全部自己控制。

4.2 深度图采样与交互区域计算

在Shader里声明深度纹理和交互纹理:

sampler2D _CameraDepthTexture; sampler2D _InteractTex; float4 _InteractTex_ST;

在顶点着色器里,计算屏幕UV和深度:

struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float4 screenPos : TEXCOORD1; float depth : TEXCOORD2; }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; o.screenPos = ComputeScreenPos(o.pos); o.depth = o.pos.z / o.pos.w; return o; }

在片元着色器里,采样深度并计算差值:

float2 screenUV = i.screenPos.xy / i.screenPos.w; float sceneDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, screenUV)); float waterDepth = LinearEyeDepth(i.depth); float depthDiff = sceneDepth - waterDepth;

这里LinearEyeDepth把非线性深度转成线性深度,单位是世界单位。depthDiff为正表示物体在水面以上,为负表示在水面以下。

4.3 交互纹理的绘制与采样

交互纹理的绘制,我一般用一个正交相机从上方俯拍。相机的orthographicSize设成水面尺寸的一半,nearClipPlane和farClipPlane覆盖水面的高度范围。然后给需要交互的物体挂一个脚本,在OnWillRenderObject或者Update里,把物体的位置和半径画到RenderTexture上。

绘制的方式可以用Graphics.DrawMesh,也可以用CommandBuffer。我习惯用CommandBuffer,因为可以精确控制绘制时机。

CommandBuffer cb = new CommandBuffer(); cb.SetRenderTarget(interactRT); cb.ClearRenderTarget(false, true, Color.clear); cb.DrawMesh(quadMesh, Matrix4x4.identity, interactMaterial); Camera.main.AddCommandBuffer(CameraEvent.BeforeForwardOpaque, cb);

交互材质里,用物体的世界坐标映射到UV,然后画一个圆形渐变。圆心的强度为1,边缘衰减到0。

float2 uv = (worldPos.xz - _InteractAreaMin) / (_InteractAreaMax - _InteractAreaMin); float dist = distance(uv, _ObjectUV); float strength = saturate(1.0 - dist / _Radius); return float4(strength, strength, 0, 0);

水面Shader采样这张纹理时,用世界坐标的XZ映射到UV:

float2 interactUV = (worldPos.xz - _InteractAreaMin) / (_InteractAreaMax - _InteractAreaMin); float4 interact = tex2D(_InteractTex, interactUV);

4.4 波纹扩散与法线扰动

交互纹理里的强度是静态的,物体不动,涟漪就不动。为了让涟漪扩散,可以在采样时对UV做时间偏移,或者用多个采样点做模糊。

我的做法是用一个简单的衰减扩散:在C#脚本里,每帧对RenderTexture做一次模糊,同时把强度乘以一个衰减系数。这样涟漪会自然扩散并消失。

// 每帧调用 Graphics.Blit(interactRT, tempRT, blurMaterial); Graphics.Blit(tempRT, interactRT, fadeMaterial);

模糊材质用高斯模糊或者简单的Box Blur,衰减材质就是把颜色乘以0.98。

法线扰动则是在水面Shader里,根据交互强度,对法线进行偏移:

float3 normal = UnpackNormal(tex2D(_NormalMap, uv + _Time.y * _FlowSpeed)); normal.xy += interact.r * _InteractNormalStrength; normal = normalize(normal);

4.5 泡沫与折射的最终合成

泡沫的合成,是在深度差值的边缘区域,叠加一层白色:

float foam = saturate(1.0 - abs(depthDiff) / _FoamDistance); foam *= interact.g; float3 finalColor = lerp(waterColor, foamColor, foam);

折射的合成,是在采样屏幕颜色时,根据法线和交互强度偏移UV:

float2 refractionUV = screenUV + normal.xy * _RefractionStrength + interact.r * _InteractRefraction; float3 sceneColor = tex2D(_CameraOpaqueTexture, refractionUV).rgb; finalColor = lerp(finalColor, sceneColor, _RefractionBlend);

最后加上反射、高光、菲涅尔,一套完整的水体交互就出来了。

5. 常见问题与排查技巧实录

5.1 深度图采样全黑或全白

这是最常见的问题。原因通常是深度纹理没有正确生成。检查步骤:内置管线看相机的depthTextureMode是否设置;URP看管线设置里Depth Texture是否勾选;如果用了多个相机,确认主相机的深度纹理是否被覆盖。

另一个可能是SAMPLE_DEPTH_TEXTURE的宏在URP里不适用,需要改用SampleSceneDepth。URP的Shader库和内置管线不一样,这个要特别注意。

5.2 交互区域偏移或错位

交互纹理的UV映射和深度图的屏幕UV映射是两套坐标。如果交互区域和水面的实际接触点对不上,大概率是UV映射的公式写错了。检查_InteractAreaMin和_InteractAreaMax是否覆盖了水面的实际范围,以及世界坐标到UV的映射是否用了正确的轴。

避坑技巧:在Scene视图里画一个Gizmo,把交互区域的范围可视化出来,这样一眼就能看出对不对。

5.3 波纹不扩散或扩散太快

波纹的扩散速度取决于模糊的半径和衰减系数。模糊半径太大,波纹会糊成一团;太小,又看不出扩散。衰减系数太大,波纹消失太快;太小,又会一直残留。

我实测下来,模糊半径用2到4个像素,衰减系数用0.96到0.98,效果比较自然。具体数值要根据你的帧率和纹理分辨率调。

5.4 移动端性能优化

深度图采样和交互纹理绘制在移动端是有开销的。如果帧率吃紧,可以降级:深度图用半分辨率,交互纹理用256x256,模糊只做一次。泡沫和折射可以关掉,只保留法线扰动。

另外,交互纹理的绘制不要每帧都做,可以隔帧做,或者只在物体移动时做。静止的物体不需要每帧重绘。

问题现象可能原因排查方向
深度图全黑深度纹理未生成检查相机设置和管线配置
交互区域错位UV映射错误检查世界坐标到UV的转换公式
波纹不扩散模糊或衰减参数不当调整模糊半径和衰减系数
移动端卡顿深度图和交互纹理开销大降分辨率、隔帧绘制、关闭次要效果
泡沫硬边阈值用了step改用smoothstep做过渡

5.5 水面反射与交互的冲突

如果水面用了反射探针或者平面反射,交互波纹可能会被反射效果覆盖。解决办法是在反射采样之后,再叠加交互效果。也就是说,先算反射,再算法线扰动和泡沫,最后合成。顺序反了,交互效果就会被反射盖住。

6. 进阶扩展:让水体交互更有层次感

6.1 基于物理的尾迹生成

船划过水面时,尾迹不是简单的圆形涟漪,而是V字形的扩散波。这个可以用交互纹理的绘制方式来实现:不是画圆,而是画一个V字形的遮罩,随着时间逐渐扩散和衰减。

具体做法是在C#脚本里,根据船的速度和方向,计算V字形的两个边,然后用Graphics.DrawMesh画到交互纹理上。V字形的角度和船速相关,速度越快,角度越小。

6.2 雨滴涟漪的随机分布

下雨场景里,水面需要随机分布的涟漪。这个可以用一个噪声纹理驱动交互纹理的绘制:每帧在随机位置画一个小圆,强度随机,半径随机。为了性能,可以预生成一批涟漪位置,循环使用。

6.3 水下视角的交互反馈

当相机潜入水下时,水面交互应该有不同的表现。水下看水面,应该看到全反射和Snell窗效果。这个需要根据相机的深度,切换水面的渲染模式。具体来说,当相机在水面以下时,菲涅尔项要反转,折射要变成全反射。

这个扩展比较复杂,涉及到相机的深度检测和Shader的变体切换。如果你的项目有水下场景,这个值得做。

6.4 交互纹理的压缩与复用

如果场景里有多个水面,可以共用一张交互纹理。只要它们的交互区域不重叠,或者重叠区域可以接受相互影响。这样可以省显存,也省绘制开销。

纹理的格式可以用R8,只存强度,泡沫和法线扰动用程序化生成。这样显存占用可以降到四分之一。

7. 我个人在实际操作中的几点体会

水体交互这个东西,参数调起来很磨人。同样的Shader,换一个场景,参数就得重调。我的经验是,先把深度检测和交互区域调准,这是地基。地基不稳,后面泡沫和折射怎么调都是歪的。

泡沫的阈值我一般会留一个_FoamSoftness参数,用来控制边缘的柔和度。这个参数在物体快速移动时特别有用,硬边的泡沫会像贴纸一样假,柔和的泡沫才有水花的感觉。

交互纹理的衰减速度,不要设得太快。我见过很多项目,涟漪刚出来就没了,看起来像水面在抽搐。衰减慢一点,让涟漪有扩散的余韵,真实感会强很多。

最后,移动端的话,能省则省。深度图半分辨率,交互纹理256,模糊一次,泡沫和折射二选一。先把法线扰动做出来,那个是性价比最高的效果。等性能有余量了,再加泡沫和折射。

这套方案我在几个项目里都用过,从PC到移动端都跑得动。核心就是深度图加交互纹理,剩下的都是在这个骨架上添肉。你把骨架搭稳了,想加什么效果都方便。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询