戰(zhàn):深度圖與交互紋理實(shí)現(xiàn)實(shí)時(shí)波紋泡沫)
1. 水體交互到底在做什么從“看著像水”到“摸起來(lái)像水”水面效果在實(shí)時(shí)渲染里一直是個(gè)分水嶺。很多項(xiàng)目能把水做得“看著像水”——有反射、有折射、有波紋靜態(tài)截圖能唬人。但玩家一走進(jìn)水里腳邊沒(méi)有漣漪角色穿過(guò)瀑布水簾簾子紋絲不動(dòng)船劃過(guò)湖面船尾連個(gè)像樣的尾跡都留不下。這種割裂感就是水體交互要解決的問(wèn)題。水體交互說(shuō)白了就是讓水面和場(chǎng)景里的物體產(chǎn)生雙向的視覺(jué)反饋。物體動(dòng)水面跟著動(dòng)水面動(dòng)物體周圍的光影跟著變。它不是一個(gè)單一功能而是一整套圍繞“接觸”和“擾動(dòng)”的渲染機(jī)制。核心關(guān)鍵詞包括交互波紋、深度檢測(cè)、折射偏移、岸邊泡沫、尾跡生成、實(shí)時(shí)反射。這套東西做出來(lái)玩家不需要看說(shuō)明書腳踩進(jìn)水里那一瞬間畫面自己會(huì)說(shuō)話。這篇文章適合誰(shuí)看如果你已經(jīng)寫過(guò)基礎(chǔ)的Unity Shader知道頂點(diǎn)著色器和片元著色器大概在干什么但一到“讓水面響應(yīng)物體”就卡殼那這篇就是給你準(zhǔn)備的。我會(huì)從整體設(shè)計(jì)思路講起把深度圖、波紋生成、泡沫遮罩、折射擾動(dòng)這幾個(gè)核心環(huán)節(jié)拆開揉碎再給出一套可以直接抄的實(shí)操流程。中間會(huì)穿插我踩過(guò)的坑和實(shí)測(cè)有效的參數(shù)盡量讓你少走彎路。先說(shuō)清楚一個(gè)前提水體交互的實(shí)現(xiàn)方式有很多種有基于物理模擬的有基于屏幕后處理的也有基于Shader Graph可視化連線的。我下面講的主要是基于深度圖和交互紋理的實(shí)時(shí)方案這套方案在移動(dòng)端和PC端都能跑性能可控效果也夠用。如果你追求的是流體力學(xué)級(jí)別的真實(shí)模擬那可能需要另一套思路但那個(gè)開銷不是一般項(xiàng)目能承受的。2. 整體設(shè)計(jì)思路為什么是深度圖加交互紋理2.1 核心架構(gòu)的選型邏輯做水體交互第一個(gè)要決定的就是水面怎么知道有物體靠近了這個(gè)問(wèn)題有三種常見(jiàn)解法。第一種是物理碰撞檢測(cè)。給水面加碰撞體物體進(jìn)入觸發(fā)區(qū)域后CPU通知Shader。這種方式精確但開銷大尤其是多個(gè)物體同時(shí)交互時(shí)物理查詢和消息傳遞會(huì)成為瓶頸。而且水面通常是一個(gè)大平面碰撞體的形狀很難貼合波浪起伏。第二種是屏幕空間深度比較。渲染水面時(shí)采樣當(dāng)前像素對(duì)應(yīng)的場(chǎng)景深度和水面自身深度做比較。如果場(chǎng)景深度比水面深度近說(shuō)明有物體擋在水面前面也就是物體和水面發(fā)生了重疊。這個(gè)重疊區(qū)域就是交互發(fā)生的地方。這種方式完全在GPU上完成不需要CPU參與性能極好而且天然支持任意形狀的物體。第三種是交互紋理繪制。用一個(gè)額外的相機(jī)從上方俯拍或者用腳本把物體的位置、半徑、強(qiáng)度畫到一張RenderTexture上。水面Shader采樣這張紋理就知道哪里被擾動(dòng)了。這種方式適合做尾跡、漣漪擴(kuò)散這類需要“記憶”的效果。我的方案是深度圖為主交互紋理為輔。深度圖負(fù)責(zé)實(shí)時(shí)檢測(cè)物體和水面的接觸區(qū)域交互紋理負(fù)責(zé)記錄和擴(kuò)散擾動(dòng)。兩者結(jié)合既能做到即時(shí)響應(yīng)又能做出漣漪擴(kuò)散的余韻。注意深度圖方案有一個(gè)硬性前提——你的渲染管線必須支持深度紋理。內(nèi)置管線里相機(jī)需要開啟depthTextureModeURP里需要在管線設(shè)置里勾選Depth Texture。這個(gè)不打開后面所有東西都是空中樓閣。2.2 為什么不用純屏幕后處理有人可能會(huì)問(wèn)既然深度圖是屏幕空間的那為什么不干脆把整個(gè)水體交互做成一個(gè)屏幕后處理效果答案很簡(jiǎn)單屏幕后處理拿不到水面的法線信息也沒(méi)法區(qū)分“這是水面”和“這是地板”。你需要在渲染水面的那個(gè)Pass里同時(shí)拿到水面自身的深度、法線、以及場(chǎng)景深度才能算出正確的交互效果。屏幕后處理只能拿到顏色和深度信息不夠。而且水體交互往往需要和水的其他效果反射、折射、波浪耦合。比如物體入水時(shí)折射偏移要跟著擾動(dòng)變化泡沫要沿著交互邊緣生成。這些都需要在水面的Shader內(nèi)部統(tǒng)一處理拆到后處理里反而更麻煩。2.3 交互紋理的格式選擇交互紋理我一般用ARGBHalf或者ARGB32。R通道存擾動(dòng)強(qiáng)度G通道存泡沫強(qiáng)度B通道存法線擾動(dòng)的X方向A通道存Y方向。如果顯存緊張可以壓縮成RGBAHalf但精度會(huì)掉一些。紋理分辨率不需要太高512x512或者1024x1024足夠因?yàn)闈i漪本身是低頻的高頻細(xì)節(jié)靠法線貼圖補(bǔ)。紋理的投影方式有兩種一種是世界空間俯投影把物體的世界坐標(biāo)映射到紋理的UV上另一種是屏幕空間投影直接用屏幕UV。俯投影適合做尾跡和固定區(qū)域的交互屏幕空間投影適合做全屏的雨滴漣漪。我一般用俯投影因?yàn)榭煽匦詮?qiáng)而且不受相機(jī)旋轉(zhuǎn)影響。3. 核心細(xì)節(jié)解析深度檢測(cè)、波紋生成與泡沫遮罩3.1 深度檢測(cè)的精度陷阱深度檢測(cè)的原理很簡(jiǎn)單采樣場(chǎng)景深度和水面深度比較差值小于某個(gè)閾值就認(rèn)為是交互區(qū)域。但這里有幾個(gè)坑。第一個(gè)坑是深度值的非線性。Unity的深度紋理存的是NDC空間下的深度經(jīng)過(guò)投影矩陣變換后近處的精度高遠(yuǎn)處的精度低。如果你直接用差值做閾值判斷近處可能很敏感遠(yuǎn)處就完全沒(méi)反應(yīng)。解決辦法是把深度值轉(zhuǎn)換到線性空間用線性深度做比較。// 把深度紋理的原始值轉(zhuǎn)成線性深度 float sceneDepth LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv)); float waterDepth LinearEyeDepth(i.depth); float depthDiff sceneDepth - waterDepth;第二個(gè)坑是水面自身的深度寫入。如果水面Shader沒(méi)有正確寫入深度場(chǎng)景深度和水面深度比較時(shí)就會(huì)出錯(cuò)。確保水面的Pass里設(shè)置了ZWrite On或者在URP里用正確的DepthOnly Pass。第三個(gè)坑是透明物體的深度。如果場(chǎng)景里有其他透明物體擋在水面前面深度圖里可能沒(méi)有它們的信息導(dǎo)致交互檢測(cè)失效。這個(gè)問(wèn)題的根本解法是讓透明物體也寫入深度但那樣又會(huì)影響渲染順序。我的做法是接受這個(gè)限制因?yàn)榇蠖鄶?shù)情況下和水面交互的物體都是不透明的。3.2 交互波紋的生成方式波紋的生成有兩種主流方式基于法線的擾動(dòng)和基于高度的位移?;诜ň€的擾動(dòng)是在片元著色器里根據(jù)交互強(qiáng)度對(duì)水面的法線進(jìn)行偏移。這種方式開銷小適合做細(xì)微的漣漪。但缺點(diǎn)是只能改變光照不能改變水面的實(shí)際形狀所以側(cè)面看的時(shí)候會(huì)穿幫?;诟叨鹊奈灰剖窃陧旤c(diǎn)著色器里根據(jù)交互紋理的強(qiáng)度把頂點(diǎn)往上抬。這種方式能做出真實(shí)的隆起但需要水面有足夠的細(xì)分。如果水面是一個(gè)只有四個(gè)頂點(diǎn)的大平面頂點(diǎn)位移根本看不出效果。我的做法是兩者結(jié)合頂點(diǎn)位移做大的隆起法線擾動(dòng)做細(xì)節(jié)。頂點(diǎn)位移的幅度不要太大0.05到0.1個(gè)單位就夠了否則水面會(huì)像果凍一樣彈。法線擾動(dòng)的強(qiáng)度可以大一些因?yàn)槟鞘且曈X(jué)上的“波紋感”的主要來(lái)源。// 頂點(diǎn)位移示例 float interactStrength tex2Dlod(_InteractTex, float4(uv, 0, 0)).r; vertex.y interactStrength * _WaveHeight;3.3 泡沫遮罩的生成邏輯泡沫是水體交互里最出效果的部分。物體入水時(shí)接觸邊緣會(huì)泛起一圈白沫這個(gè)細(xì)節(jié)能讓交互的真實(shí)感提升一個(gè)檔次。泡沫的生成邏輯是在深度差值的邊緣區(qū)域也就是場(chǎng)景深度和水面深度接近的地方生成泡沫。具體來(lái)說(shuō)當(dāng)深度差小于某個(gè)閾值時(shí)泡沫強(qiáng)度逐漸增大當(dāng)深度差為負(fù)物體在水面以下時(shí)泡沫強(qiáng)度達(dá)到最大。float foam saturate(1.0 - depthDiff / _FoamDistance); foam * step(0, depthDiff); // 只在水面以上生成但這樣生成的泡沫是靜態(tài)的物體不動(dòng)的時(shí)候泡沫就一直貼在那里。為了讓泡沫有動(dòng)態(tài)感可以疊加一層噪聲紋理用時(shí)間做偏移讓泡沫看起來(lái)在流動(dòng)。實(shí)操心得泡沫的閾值不要設(shè)得太死留一點(diǎn)過(guò)渡區(qū)間。我一般用smoothstep而不是step這樣泡沫的邊緣會(huì)更柔和不會(huì)出現(xiàn)硬邊。3.4 折射偏移與交互的耦合水面的折射效果通常是用GrabPass或者URP的CameraOpaqueTexture來(lái)實(shí)現(xiàn)的。采樣屏幕顏色時(shí)根據(jù)法線偏移UV就能做出折射的扭曲感。當(dāng)物體和水面交互時(shí)折射偏移應(yīng)該跟著擾動(dòng)變化。具體來(lái)說(shuō)交互強(qiáng)度越大折射偏移越明顯。這樣物體入水時(shí)水下的部分會(huì)因?yàn)檎凵渑で@得更“在水里”。float2 refractionOffset normal.xy * _RefractionStrength; refractionOffset interactStrength * _InteractRefraction; float3 sceneColor tex2D(_CameraOpaqueTexture, uv refractionOffset).rgb;這里要注意折射偏移的方向要和法線方向一致否則會(huì)出現(xiàn)反向扭曲的詭異效果。4. 實(shí)操過(guò)程從零搭建一套可用的水體交互4.1 場(chǎng)景準(zhǔn)備與管線配置先確認(rèn)你的渲染管線。內(nèi)置管線的話在主相機(jī)上掛一個(gè)腳本設(shè)置depthTextureMode DepthTextureMode.Depth。URP的話在Universal Render Pipeline Asset里勾選Depth Texture和Opaque Texture。然后創(chuàng)建一個(gè)水面平面。不要用Unity自帶的Plane那個(gè)只有121個(gè)頂點(diǎn)做頂點(diǎn)位移不夠用。我一般用ProBuilder或者Blender做一個(gè)細(xì)分網(wǎng)格分辨率至少200x200。如果性能吃緊可以降到100x100但波浪的細(xì)節(jié)會(huì)損失一些。水面的材質(zhì)新建一個(gè)Shader。我習(xí)慣從Unlit Shader開始改因?yàn)椴恍枰J(rèn)的光照模型全部自己控制。4.2 深度圖采樣與交互區(qū)域計(jì)算在Shader里聲明深度紋理和交互紋理sampler2D _CameraDepthTexture; sampler2D _InteractTex; float4 _InteractTex_ST;在頂點(diǎn)著色器里計(jì)算屏幕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; }在片元著色器里采樣深度并計(jì)算差值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;這里L(fēng)inearEyeDepth把非線性深度轉(zhuǎn)成線性深度單位是世界單位。depthDiff為正表示物體在水面以上為負(fù)表示在水面以下。4.3 交互紋理的繪制與采樣交互紋理的繪制我一般用一個(gè)正交相機(jī)從上方俯拍。相機(jī)的orthographicSize設(shè)成水面尺寸的一半nearClipPlane和farClipPlane覆蓋水面的高度范圍。然后給需要交互的物體掛一個(gè)腳本在OnWillRenderObject或者Update里把物體的位置和半徑畫到RenderTexture上。繪制的方式可以用Graphics.DrawMesh也可以用CommandBuffer。我習(xí)慣用CommandBuffer因?yàn)榭梢跃_控制繪制時(shí)機(jī)。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);交互材質(zhì)里用物體的世界坐標(biāo)映射到UV然后畫一個(gè)圓形漸變。圓心的強(qiáng)度為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采樣這張紋理時(shí)用世界坐標(biāo)的XZ映射到UVfloat2 interactUV (worldPos.xz - _InteractAreaMin) / (_InteractAreaMax - _InteractAreaMin); float4 interact tex2D(_InteractTex, interactUV);4.4 波紋擴(kuò)散與法線擾動(dòng)交互紋理里的強(qiáng)度是靜態(tài)的物體不動(dòng)漣漪就不動(dòng)。為了讓漣漪擴(kuò)散可以在采樣時(shí)對(duì)UV做時(shí)間偏移或者用多個(gè)采樣點(diǎn)做模糊。我的做法是用一個(gè)簡(jiǎn)單的衰減擴(kuò)散在C#腳本里每幀對(duì)RenderTexture做一次模糊同時(shí)把強(qiáng)度乘以一個(gè)衰減系數(shù)。這樣漣漪會(huì)自然擴(kuò)散并消失。// 每幀調(diào)用 Graphics.Blit(interactRT, tempRT, blurMaterial); Graphics.Blit(tempRT, interactRT, fadeMaterial);模糊材質(zhì)用高斯模糊或者簡(jiǎn)單的Box Blur衰減材質(zhì)就是把顏色乘以0.98。法線擾動(dòng)則是在水面Shader里根據(jù)交互強(qiáng)度對(duì)法線進(jìn)行偏移float3 normal UnpackNormal(tex2D(_NormalMap, uv _Time.y * _FlowSpeed)); normal.xy interact.r * _InteractNormalStrength; normal normalize(normal);4.5 泡沫與折射的最終合成泡沫的合成是在深度差值的邊緣區(qū)域疊加一層白色float foam saturate(1.0 - abs(depthDiff) / _FoamDistance); foam * interact.g; float3 finalColor lerp(waterColor, foamColor, foam);折射的合成是在采樣屏幕顏色時(shí)根據(jù)法線和交互強(qiáng)度偏移UVfloat2 refractionUV screenUV normal.xy * _RefractionStrength interact.r * _InteractRefraction; float3 sceneColor tex2D(_CameraOpaqueTexture, refractionUV).rgb; finalColor lerp(finalColor, sceneColor, _RefractionBlend);最后加上反射、高光、菲涅爾一套完整的水體交互就出來(lái)了。5. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄5.1 深度圖采樣全黑或全白這是最常見(jiàn)的問(wèn)題。原因通常是深度紋理沒(méi)有正確生成。檢查步驟內(nèi)置管線看相機(jī)的depthTextureMode是否設(shè)置URP看管線設(shè)置里Depth Texture是否勾選如果用了多個(gè)相機(jī)確認(rèn)主相機(jī)的深度紋理是否被覆蓋。另一個(gè)可能是SAMPLE_DEPTH_TEXTURE的宏在URP里不適用需要改用SampleSceneDepth。URP的Shader庫(kù)和內(nèi)置管線不一樣這個(gè)要特別注意。5.2 交互區(qū)域偏移或錯(cuò)位交互紋理的UV映射和深度圖的屏幕UV映射是兩套坐標(biāo)。如果交互區(qū)域和水面的實(shí)際接觸點(diǎn)對(duì)不上大概率是UV映射的公式寫錯(cuò)了。檢查_InteractAreaMin和_InteractAreaMax是否覆蓋了水面的實(shí)際范圍以及世界坐標(biāo)到UV的映射是否用了正確的軸。避坑技巧在Scene視圖里畫一個(gè)Gizmo把交互區(qū)域的范圍可視化出來(lái)這樣一眼就能看出對(duì)不對(duì)。5.3 波紋不擴(kuò)散或擴(kuò)散太快波紋的擴(kuò)散速度取決于模糊的半徑和衰減系數(shù)。模糊半徑太大波紋會(huì)糊成一團(tuán)太小又看不出擴(kuò)散。衰減系數(shù)太大波紋消失太快太小又會(huì)一直殘留。我實(shí)測(cè)下來(lái)模糊半徑用2到4個(gè)像素衰減系數(shù)用0.96到0.98效果比較自然。具體數(shù)值要根據(jù)你的幀率和紋理分辨率調(diào)。5.4 移動(dòng)端性能優(yōu)化深度圖采樣和交互紋理繪制在移動(dòng)端是有開銷的。如果幀率吃緊可以降級(jí)深度圖用半分辨率交互紋理用256x256模糊只做一次。泡沫和折射可以關(guān)掉只保留法線擾動(dòng)。另外交互紋理的繪制不要每幀都做可以隔幀做或者只在物體移動(dòng)時(shí)做。靜止的物體不需要每幀重繪。問(wèn)題現(xiàn)象可能原因排查方向深度圖全黑深度紋理未生成檢查相機(jī)設(shè)置和管線配置交互區(qū)域錯(cuò)位UV映射錯(cuò)誤檢查世界坐標(biāo)到UV的轉(zhuǎn)換公式波紋不擴(kuò)散模糊或衰減參數(shù)不當(dāng)調(diào)整模糊半徑和衰減系數(shù)移動(dòng)端卡頓深度圖和交互紋理開銷大降分辨率、隔幀繪制、關(guān)閉次要效果泡沫硬邊閾值用了step改用smoothstep做過(guò)渡5.5 水面反射與交互的沖突如果水面用了反射探針或者平面反射交互波紋可能會(huì)被反射效果覆蓋。解決辦法是在反射采樣之后再疊加交互效果。也就是說(shuō)先算反射再算法線擾動(dòng)和泡沫最后合成。順序反了交互效果就會(huì)被反射蓋住。6. 進(jìn)階擴(kuò)展讓水體交互更有層次感6.1 基于物理的尾跡生成船劃過(guò)水面時(shí)尾跡不是簡(jiǎn)單的圓形漣漪而是V字形的擴(kuò)散波。這個(gè)可以用交互紋理的繪制方式來(lái)實(shí)現(xiàn)不是畫圓而是畫一個(gè)V字形的遮罩隨著時(shí)間逐漸擴(kuò)散和衰減。具體做法是在C#腳本里根據(jù)船的速度和方向計(jì)算V字形的兩個(gè)邊然后用Graphics.DrawMesh畫到交互紋理上。V字形的角度和船速相關(guān)速度越快角度越小。6.2 雨滴漣漪的隨機(jī)分布下雨場(chǎng)景里水面需要隨機(jī)分布的漣漪。這個(gè)可以用一個(gè)噪聲紋理驅(qū)動(dòng)交互紋理的繪制每幀在隨機(jī)位置畫一個(gè)小圓強(qiáng)度隨機(jī)半徑隨機(jī)。為了性能可以預(yù)生成一批漣漪位置循環(huán)使用。6.3 水下視角的交互反饋當(dāng)相機(jī)潛入水下時(shí)水面交互應(yīng)該有不同的表現(xiàn)。水下看水面應(yīng)該看到全反射和Snell窗效果。這個(gè)需要根據(jù)相機(jī)的深度切換水面的渲染模式。具體來(lái)說(shuō)當(dāng)相機(jī)在水面以下時(shí)菲涅爾項(xiàng)要反轉(zhuǎn)折射要變成全反射。這個(gè)擴(kuò)展比較復(fù)雜涉及到相機(jī)的深度檢測(cè)和Shader的變體切換。如果你的項(xiàng)目有水下場(chǎng)景這個(gè)值得做。6.4 交互紋理的壓縮與復(fù)用如果場(chǎng)景里有多個(gè)水面可以共用一張交互紋理。只要它們的交互區(qū)域不重疊或者重疊區(qū)域可以接受相互影響。這樣可以省顯存也省繪制開銷。紋理的格式可以用R8只存強(qiáng)度泡沫和法線擾動(dòng)用程序化生成。這樣顯存占用可以降到四分之一。7. 我個(gè)人在實(shí)際操作中的幾點(diǎn)體會(huì)水體交互這個(gè)東西參數(shù)調(diào)起來(lái)很磨人。同樣的Shader換一個(gè)場(chǎng)景參數(shù)就得重調(diào)。我的經(jīng)驗(yàn)是先把深度檢測(cè)和交互區(qū)域調(diào)準(zhǔn)這是地基。地基不穩(wěn)后面泡沫和折射怎么調(diào)都是歪的。泡沫的閾值我一般會(huì)留一個(gè)_FoamSoftness參數(shù)用來(lái)控制邊緣的柔和度。這個(gè)參數(shù)在物體快速移動(dòng)時(shí)特別有用硬邊的泡沫會(huì)像貼紙一樣假柔和的泡沫才有水花的感覺(jué)。交互紋理的衰減速度不要設(shè)得太快。我見(jiàn)過(guò)很多項(xiàng)目漣漪剛出來(lái)就沒(méi)了看起來(lái)像水面在抽搐。衰減慢一點(diǎn)讓漣漪有擴(kuò)散的余韻真實(shí)感會(huì)強(qiáng)很多。最后移動(dòng)端的話能省則省。深度圖半分辨率交互紋理256模糊一次泡沫和折射二選一。先把法線擾動(dòng)做出來(lái)那個(gè)是性價(jià)比最高的效果。等性能有余量了再加泡沫和折射。這套方案我在幾個(gè)項(xiàng)目里都用過(guò)從PC到移動(dòng)端都跑得動(dòng)。核心就是深度圖加交互紋理剩下的都是在這個(gè)骨架上添肉。你把骨架搭穩(wěn)了想加什么效果都方便。