不久前玩了神秘海域4和盜賊之海,對游戲中形狀生動顏色通透的大海產(chǎn)生了興趣,于是開始查著資料學(xué)習(xí)自己動手寫一個海洋的水效渲染。這個shader基本是邊學(xué)邊寫的,編寫過程中我學(xué)到了很多新的知識。
目前的效果:
波形
主要參考了GPU Gems的Chapter 1。
Chapter 1. Effective Water Simulation from Physical Models
實時水面模擬與渲染
海面波形是由多個不同頻率分量單個波形疊加起來的,相對較為真實的方法是對真實采樣的海面波形做FFT得到一個二維頻譜,再在應(yīng)用程序中通過IFFT還原為水波的高度數(shù)據(jù)。不過在此我還是先使用一種較為簡單并且能直觀地在GPU中計算的疊加Gerstner波的方法。使用這種波頂點會不僅有高度分量y的變化,同時在xz平面也會有一定的位移,我們在控制xz平面頂點位移大小的同時也控制了波峰是尖還是平緩。
Shader中我使用了12種不同波長,每種波長的分量是6個方向不同的同頻率波的疊加,于是最后用72個波的疊加得到了目前的效果。
水面顏色渲染
總體上來講我們將得到的顏色分為反射分量,折射分量以及加性的高光分量,折射和反射分量的比重用菲涅爾效應(yīng)的公式計算。這里采用了一種快速菲涅爾方法:
float R_0 = (_AirRefractiveIndex - _WaterRefractiveIndex) / (_AirRefractiveIndex + _WaterRefractiveIndex);
R_0 *= R_0;
return R_0 + (1.0 - R_0) * pow((1.0 - saturate(dot(I, N))), _FresnelPower);
反射分量
在應(yīng)用了波形和法線貼圖后,海水的表面就不再是鏡面,因此完全正確的反射顏色就不再是鏡面反射。當然我們?nèi)匀豢梢杂嬎阏_反射的天空盒子顏色,但是由于這里采樣的只有天空盒子于是其他物體便不會出現(xiàn)在反射中。然而正確不是首要考慮的因素,在這里我仍然使用了鏡面反射的反射貼圖。
獲取鏡面貼圖的方式非常直觀,直接在當前相機關(guān)于水平面的對稱位置再生成一個(視線也對稱)相機采樣即可。注意這里為了只正確反射水面上的物體,我們需要將對生成的對稱相機的近端裁剪平面修改為水面。在腳本中使用cam.CalculateObliqueMatrix(clipPlane)
可以得到以clipPlane
(clipPlane
參考系是觀察空間)為近端裁剪平面的相機投影矩陣,然后再設(shè)置相機投影矩陣為得到的這個矩陣即可。具體運算較為復(fù)雜,我參考了斜視錐體深度投影和裁剪這個教程。
得到反射貼圖后,首先根據(jù)當前fragment的法線xz分量扭曲UV,再根據(jù)從vertex shader中傳來的世界空間高度值抬高反射貼圖的采樣點,最后還是能夠得到較為不錯的效果。當然這只是一種簡單的近似效果,一旦波形起伏大了馬上就會穿幫。
折射分量
這里我將折射分量(來自水中的光)分為了透明和天空光散射、和直射散射三個部分。
這里我在shader中計算了視深,即水底視深度減去減水面視深度。
float backgroundDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, grabPos));
float surfaceDepth = grabPos.w;
如果要正確得到水底深度,那么我們必須將這個shader的RenderQueue
設(shè)置為Transparent
,否則SAMPLE_DEPTH_TEXTURE_PROJ
得到的還是已經(jīng)寫入深度數(shù)據(jù)的水面視深度,水底也不會出現(xiàn)陰影。
計算得到視深后就可以根據(jù)此參數(shù)決定透明分量和散射分量的比重。
折射分量-透明
此處我采用了GrabPass { "_WaterBackground" }
的方法直接得到之前已經(jīng)渲染的背景貼圖,同樣再根據(jù)fragment的法線xz分量扭曲UV即可。同時我們也要對視深度采取扭曲UV采樣。但是,僅僅這樣做會導(dǎo)致如圖的結(jié)果:
其原因是我們?nèi)绻苯訉Ρ尘安蓸覷V位移,會導(dǎo)致原本位于海面上的不應(yīng)扭曲的物體背景扭曲到海面上,于是產(chǎn)生了如圖的錯誤。因此我們要計算一次扭曲后采樣點的視深度,如果此深度小于0那么這個點就不應(yīng)該被扭曲。注意此處如果扭曲歸零了的話,同時也要把視深度還原為原本的數(shù)據(jù)。
折射分量-天空光散射
這個顏色就是我們平常能夠感受到的“海的顏色”。海水散射的光線來自于四面八方的光線,淺水處海水更淺,深水處海水更深,于是這里我們就要考慮海底的高度值。
起初我直接導(dǎo)出地形的8位高度圖放到PS里做處理再導(dǎo)入Unity,但是這樣做首先精度非常低,我們需要用到和能觀察到的在海平面附近的高度基本只有4-5個量級,并且一旦改變一次地形就要重新做一次貼圖,于是我很快就拋棄了這個做法。
取而代之的是另一個自動生成高度的方法。我們只要在地形上方生成一個視線垂直向下正交相機,將其CullingMask設(shè)置為僅限地形層,再使用cam.SetReplacementShader
替換成輸出深度數(shù)據(jù)傳入GPU即可。
于是根據(jù)此高度圖我們就能夠決定當前位置是淺水還是深水。
后來我在觀察和搜索中發(fā)現(xiàn),現(xiàn)實中的淺水散射其實非常明亮。
因此我在shader中將淺水的散射改為了加性,讓其略有一些發(fā)光的效果。
上圖是水深對海水顏色影響的展示。為了看清淺處水的顏色,這里調(diào)低了水的清澈度。
折射分量-直射光透射
現(xiàn)實生活中,我們看到的陽光直射下的海浪是通透明亮的,如下圖所示。
其原因是來自直射光的透射。為了在Unity中合適地模擬這種效果,我參考了GDC 2011 – Approximating Translucency for a Fast, Cheap and Convincing Subsurface Scattering Look來模擬這種效果。
在原鏈接PPT中最后的透射分量還乘上了一個Thickness Factor, 很直觀的就是說越厚的物體最后得到的透射量越小。PPT中講到的做法是反轉(zhuǎn)表面法線,烘焙出AO然后再反轉(zhuǎn)顏色儲存進貼圖中,當然我們這里是行不通的。于是我簡單引入了波高模擬了一下效果,越高的波產(chǎn)生越高的透射量。
float4 CalculateSSSColor(float3 lightDirection, float3 worldNormal, float3 viewDir,float waveHeight, float shadowFactor,float F)
{
float lightStrength = sqrt(saturate(lightDirection.y));
float SSSFactor = pow(saturate(dot(viewDir ,lightDirection) )+saturate(dot(worldNormal ,-lightDirection)) ,_DirectTranslucencyPow) * shadowFactor * lightStrength * _EmissionStrength;
return _DirectionalScatteringColor * (SSSFactor + waveHeight * 0.6);
}
高光分量
這個沒有太多需要說明的地方,主要就是用到Phong高光公式。
float3 specularColor = pow(max(0.0, dot(reflect(lightDirection, mappedWorldNormal),viewDir)), _Shininess);
問題修復(fù)
現(xiàn)在有一個問題是處于直射陽光被遮擋的情況下,水面仍然會發(fā)出直射次表面散射光和Phong高光,如下圖所示。
首先可以知道,不會被陽光直射到的地方不會產(chǎn)生直射次表面散射光和反射高光,接下來我們就要找到哪些地方不會被陽光直射到,也相當于找到水面的陰影。由于我們已經(jīng)將shader的
RenderQueue
設(shè)置為Transparent
,因此水面并不會寫入深度,從而也不會接收到陰影。如果我們將RenderQueue
改變?yōu)槠渌担敲辞懊媸褂?code>_CameraDepthTexture時又不會讀取到背景的深度數(shù)據(jù)。我實在也沒有想到比較好的辦法,于是重新生成了一個和主相機參數(shù)相同的相機專門獲取ShadowMap。這個相機不能拍攝到水面,能拍攝到的有:一個主相機不可見的海平面重合的平面,地形,以及其他生成陰影的物體。這里同樣使用了cam.SetReplacementShader
,最后輸出的是整張屏幕的陰影圖。在shader中我們讀取到這張貼圖,采取一定的UV扭曲(同樣和法線xz分量以及高度相關(guān)),采樣點為0的地方我們移除次表面散射光和反射高光。在陰影處我同時對 海水的天空光散射做了一定的衰減,模擬陰影的效果。(注:此處可以用CommandBuffer減少一個相機,找個時間重寫一下)另外還可以注意到,由于反射高光是類似于鏡面反射的,因此有倒影的地方也不會產(chǎn)生反射高光。只需要將反射相機中的深度值貼圖輸出給shader,然后shader在讀取到深度不為無窮的地方將反射高光去掉即可。
Whitecap泡沫/數(shù)據(jù)緩存
參考論文是Dupuy, Jonathan, and Eric Bruneton. "Real-time animation and rendering of ocean whitecaps." SIGGRAPH Asia 2012 Technical Briefs. ACM, 2012.
實時水面模擬與渲染(一)
使用雅可比行列式計算當前點的撕扯程度,雅可比行列式越小的地方撕扯程度越大,從而產(chǎn)生泡沫。
目前此shader還是采用的直接疊加Gerstner波生成的波形數(shù)據(jù),而計算Whitecap需要用到臨近點(世界空間)的水平擾動數(shù)據(jù),因此,如果不在更早的生成波形階段使用緩存數(shù)據(jù)保存擾動數(shù)據(jù)(還有法線/Whitecap數(shù)據(jù)等)的話,只能在水波渲染過程中重新計算,這樣顯然是毫無效率的。
因此還可以在更早階段(水面渲染之前)就先計算好位移、法線、Whitecap數(shù)據(jù)等等。于是可以考慮通過海洋學(xué)統(tǒng)計模型生成頻譜然后IFFT還原波形得到更為真實的模擬結(jié)果。
這個階段可以放在shader里使用GPU完成。
另外,我們可以觀察到水面的泡沫其實是有一個逐漸消散的過程,因此我們可以使用之前幀的Whitecap數(shù)據(jù)緩存和當前計算的新的Whitecap數(shù)據(jù)合成最終輸出的泡沫數(shù)據(jù)。
見筆記②
LOD(待完成)
目前采用的網(wǎng)格還是直接從外部導(dǎo)入的固定頂點網(wǎng)格,這樣做實際上降低了效率,因為在遠處的部分我們根本不需要消耗大量性能去計算細節(jié)。目前初步設(shè)想是Mesh Tile跟隨主相機移動而移動,xz坐標離玩家越近的Tile擁有更高的分辨率,相機越靠近水面離相機最近的Tile擁有越高的分辨率。
另外前面提到了緩存頂點位移、Whitecap等數(shù)據(jù),在這里針對不同大小的tile我們可以都使用同樣分辨率的緩存以提高性能(可放在一張貼圖里)。
(接縫問題是否可見?)
水下效果(待完成)
焦散(待完成)
性能分析/Profiler初識
【unity】使用Profiler進行性能分析
Optimizing graphics rendering in Unity games
打開Profiler首先能看到占據(jù)很大一部分Timeline的是WaitForTargetFPS, 也就是說我們現(xiàn)在是打開了垂直同步固定了幀率,CPU需要消耗一段空閑時間加長該幀的時間去達到目標FPS。于是在Project Settings里關(guān)閉Vsync,WaitForTargetFPS消失。此時CPU時間是5.2ms,GPU 時間是3.5ms,可見目前影響幀率的主要是CPU。
仔細觀察后發(fā)現(xiàn)Profiler里竟然有5個Camera.Render,原來是忘了把ShadowMaskCam和HeightMapCam設(shè)置非活躍,導(dǎo)致Camera.Render在PlayerLoop里繼續(xù)被調(diào)用,
Disable掉相機之后問題解決,SetPass Calls減少,CPU時間變成了3.6ms左右。
GPU部分中占用最高的顯而易見是Render.TransparentGeometry。
FrameDebugger中,我直接用的自帶地形組件貌似不能被batch真的會引起非常多的DrawCall,況且這里我還使用了多個相機,每多一個相機就多一大堆的DC去渲染地形,也許最好直接導(dǎo)入預(yù)先在其他DCC上做好的地形模型…