Journey中的沙丘渲染(及其shader實現)
Journey裡面的沙丘渲染是很久之前一直想做的,但總是沒時間。最近因為項目中在做一個沙丘的場景,所以趁這個機會來做一下。
(在這裡事先聲明,在場景再現中所用到的技術基本上是在尊重Journey原作的精神下進行的個人創作。由於素材,參數以及個人能力等原因,要完全重現Journey裡面的場景十分困難,也沒有意義。另外在製作時不會考慮開銷,一切以效果為重。本作僅希望通過場景復原過程分享與收穫一些風格化渲染的經驗。)
場景最終效果:
官方參考圖,是遊戲里的實景:
圖片來源:Sand Rendering in Journey
這是GDC的一個講座,現在已經可以免費觀看了。講座用的PPT下載鏈接:
http://advances.realtimerendering.com/s2012/thatgamecompany/SandRenderingInJourney_thatgamecompany.pptx
啊 還是忍不住多方几張官方的插圖,因為實在是太漂亮了:
(知乎好像會莫名其妙的降低圖片的解析度,建議下載ppt觀看)
好的,鑒賞完畢,接下來是正片。
建模 Modeling
講座里說了是用一個高度貼圖height map進行建模。
基本方法是把圖片下載下來之後,在Maya新建一個平面,把面片數調節為50*50:
然後打開Surfaces->Sculpt Geometry Tool。打開小窗後,選擇Attribute Maps->Import 點擊import,然後在路徑導航窗口中選中剛才我們使用的height map。導入後每個點的高度變化可能有些小,所以我做了沿Y軸的縮放,完成後效果如下:
講座中提到了他們使用B-spline讓模型更加平滑。這部分內容主要和建模有關係,這裡就不作展開了。
我希望進行渲染的為該場景內的一小部分,所以單獨為這個鏡頭建了一個模,長這樣:
基本上就是用Maya的Sculpting工具修修改改一點點捏出來的。Journey風格的山丘,特點就是山頭很尖,一定要尖,做山頭的時候可以改用soft select 來調節vertex。
改完後把模型導入Unity,打上背光:
陽光的顏色為(253,208,179)。背景的日光有些丑,所以替換成Jouney的抱抱山和遊戲場景中灰黃色的天空。山是遊戲截圖(估計真實的遊戲也就是一個圖片( :3 )),天空是一個外部剔除的球體,大概長這樣:
修改過後鏡頭裡的畫面:
這裡還導入了一個Unity自帶的FirstPersonController。這樣就可以愉快的在沙丘上跑了。寫shader前的準備工作基本完成。接下來本文將會以講座所講內容的倒敘的進行實現(誰叫講座也是倒過來講的呢)。
基本的明暗 Diffuse
新建一個新的Unlit Shader,建好後加上材質給我們的模型貼上。
首先是一些基本的向量轉換的工作,這裡貼一下頂點函數:
struct appdata{ float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; float4 tangent :TANGENT;};struct v2f{ float2 uv : TEXCOORD0; float3 worldPos : TEXCOORD1; // position of this vertex in world float3 view : TEXCOORD2; // view direction, from this vertex to viewer float3 tangentDir : TEXCOORD3; // tangent direction in world float3 bitangentDir : TEXCOORD4; // bitangent direction in world float3 normal : NORMAL; UNITY_FOG_COORDS(5) float4 vertex : SV_POSITION;};v2f vert (appdata v){ v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; // here we dont want the main texture to affect the uv o.worldPos = mul(unity_ObjectToWorld ,v.vertex).xyz; o.view = normalize(WorldSpaceViewDir(v.vertex)); o.normal = normalize( mul( unity_ObjectToWorld , v.normal).xyz ) ; o.tangentDir = normalize( mul( unity_ObjectToWorld , float4( v.tangent.xyz, 0) ).xyz ); o.bitangentDir = normalize( cross( o.normal , o.tangentDir) * v.tangent.w ); UNITY_TRANSFER_FOG(o,o.vertex); return o;}
視線向量view direction和法線向量normal基本上是做渲染必須的。uv的話沒有做轉換,因為除了MainTexture之外還有貼圖需要使用,所以就不作轉換了。world position是一個還算比較常用的向量,所以這裡就順手寫了。之後需要使用Normal Map,所以這裡需要引入tangent和bitangent向量。
基本的明暗關係,作者使用的是OrenNayar模型,具體的代碼在PPT里羅列如下:
並且用如下方法增加對比度:
嗯,所以勤勤懇懇的科學家們呀,你們辛辛苦苦總結的公式,可是會被藝術家們一言不合就改掉的喲~
最後公式修改如下:
fixed OrenNayarDiffuse( fixed3 light, fixed3 view, fixed3 norm, fixed roughness ){ half VdotN = dot( view , norm ); // the function is modifed here // the original one is LdotN = saturate( dot ( light , norm )) half LdotN = saturate( 4 * dot( light, norm * float3( 1 , 0.3 , 1 ) )); half cos_theta_i = LdotN; half theta_r = acos( VdotN ); half theta_i = acos( cos_theta_i ); half cos_phi_diff = dot( normalize( view - norm * VdotN ), normalize( light - norm * LdotN ) ); half alpha = max( theta_i, theta_r ) ; half beta = min( theta_i, theta_r ) ; half sigma2 = roughness * roughness; half A = 1.0 - 0.5 * sigma2 / (sigma2 + 0.33); half B = 0.45 * sigma2 / (sigma2 + 0.09); return saturate( cos_theta_i ) * (A + (B * saturate( cos_phi_diff ) * sin(alpha) * tan(beta)));}
把這個公式引入,面片著色器修改如下:
fixed4 frag( v2f i ): SV_Target{ float4 mainColor = tex2D( _MainTex , _MainTex_ST.xy * i.uv.xy + _MainTex_ST.zw ); float3 lightDirection = normalize( UnityWorldSpaceLightDir( i.worldPos ) ); float4 lightColor = _LightColor0; float3 viewDirection = normalize( i.view ); float3 halfDirection = normalize( viewDirection + lightDirection); float4 ambientCol = unity_AmbientSky; float4 diffuseColor = lightColor * mainColor * OrenNayarDiffuse( lightDirection , viewDirection , normal , _Roughness) ; return diffuseColor}
實現效果如下:
嗯,只有單光源看起來效果不是很好呀,所以多光源支持寫起來~
首先在Pass頭部,CGPROGRAM之前寫上Lighting On。
然後在面片著色器里加上循環代碼:
for ( int k = 1 ; k < 4 ; ++k ) { // handle up to 4 lights float4 lightColork = unity_LightColor[k]; float3 lightDirectionk = unity_LightPosition[k].xyz - i.worldPos * unity_LightPosition[k].w; if ( lightColork.x + lightColork.y + lightColork.z >0 ) { float4 diffuseColk = lightColork * mainColor * ( OrenNayarDiffuse( lightDirectionk , viewDirection , normal , _Roughness) ); diffuseCol += diffuseColk; }}
接下來是打光環節,本人也不是專業的燈光,並且在製作過程中燈光也會不斷的進行調整。所以這部分僅供參考。主光源(Key Light)不變,是一個從遠處往回打的燈光(就是背光的角度),然後Fill Light 打在側面,做到讓場景變得柔和。Rim Light 把山的輪廓強調一下。三個燈光的按添加順序展示效果如下。
然後是不同Roughness的對比圖,左邊是Roughness 為0,右邊是Roughness為1。好像沒什麼區別喉。其實更主要的區別會在之後添加specualar的時候看到,在這裡我們選擇Roughness為0.5。
沙丘表面紋理 Height Map
原作者的方法是把下述的四個高度貼圖(Height Map)整合起來做成沙丘表面的紋理。
x z 方向的貼圖用於不同法線朝向的表面,Steep 和 Shallow貼圖分別用於不同的坡度的表面。(原話為:For each vertex of the detail heightmap, we
chose the X- or the Z-column based on which derivative was greater, and we lerpedbetween the shallow and steep rows based on the total steepness of the terrain.)嗯,這麼來說的確有些難理解,實際上我們做一下實驗就知道了。
if ( _IsNormalXZ > 0 ){ if ( abs( temNormal.z / temNormal.x ) > 1 ) return float3( abs( temNormal.z ) , 0 , 0 ); else return float3( 0 , 0 , abs( temNormal.x ) );}
這裡根據模型法向的xz分量大小,對模型進行紅色和藍色的著色,可以看到,這樣的的分類把模型按方向分成了四份。
然後上實景的參考圖:
可以看到沙丘的紋路總是沿著沙丘的斜面出現。再看看高度貼圖,都是有方向性的,是不是可以理解為,X Z 方向的高度貼圖實際上是對應不同方向的紋路?實驗的結果如下:
中間是帶有紋理方向選擇的,左右是只含單個方向的紋理的,對比來看的確中間的紋理比較有立體感。不過這個紋理的銜接處還是比較突兀,所以我用了一個atan函數平滑了一下,代碼如下:
float3 GetSurfaceNormal( float2 uv , float3 temNormal ){ // get the power of xz direction float xzRate = atan( abs( temNormal.z / temNormal.x) ) ; float3 steepX = UnpackNormal( tex2D( _NormalMapSteepX , _NormalMapSteepX_ST.xy * uv.xy + _NormalMapSteepX_ST.zw ) ) ; float3 steepZ = UnpackNormal( tex2D( _NormalMapSteepZ , _NormalMapSteepZ_ST.xy * uv.xy + _NormalMapSteepZ_ST.zw ) ) ; return lerp( steepX , steepZ , xzRate ) ;}
和視頻中介紹的高度貼圖不同,我使用的是法向貼圖,嗯,好像法向貼圖的代碼寫起來比較簡單。我用的貼圖長這樣(都是網上可以搜到的圖):
再導入貼圖後,要確認貼圖的類型(Texture Type)勾選為Normal map。並且使用的時候需要進行一個坐標轉換,轉換方法如下:
fixed4 frag(v2f i ):SV_Target { ... // Get the surface normal detected by the normal map float3 normalSurface = normalize(GetSurfaceNormal( i.uv , i.normal ) ); // TBN transform the world space into a tangent space // with the inverse matrix, we can transport the normal from tangent space to world float3x3 TBN = float3x3( normalize( i.tangentDir ) , normalize( i.bitangentDir ) , normalize( i.normal )); TBN = transpose( TBN); // equals to i.tangent * ns.x + i.bitangent * ns.y + i.normal * ns.z float3 normal = mul( TBN , normalSurface ); // Merge the surface normal with the model normal normal = normalize( normal * _SurfaceNormalScale + i.normal); ...}
這個轉換簡單來說就是把在表面坐標系(以Tangent, Bitangent 和 Normal為軸)里的法線向量轉換為在世界坐標系(以XYZ為軸)里的法線向量。這個法線方向轉換很重要,不然光照效果就會亂了套(不要問我是怎麼發現的)。同時這裡加入了_SurfaceNormalScale參數,來控制山體表面紋路的深淺。完成後山的法線分布應該是這樣的:
之後要做斜度的方向上的分解,和xz方向類似,同樣使用了atan函數進行平滑,具體的代碼如下:
float3 GetSurfaceNormal( float2 uv , float3 temNormal ){ // get the power of xz direction // it repersent the how much we should show the x or z texture float xzRate = atan( abs( temNormal.z / temNormal.x) ) ; xzRate = saturate( pow( xzRate , 9 ) ); // get the steepness // the shallow and steep texture will be lerped based on this value float steepness = atan( 1/ temNormal.y ); steepness = saturate( pow( steepness , 2 ) ); float3 shallowX = UnpackNormal( tex2D( _NormalMapShallowX , _NormalMapShallowX_ST.xy * uv.xy + _NormalMapShallowX_ST.zw ) ) ; float3 shallowZ = UnpackNormal( tex2D( _NormalMapShallowZ , _NormalMapShallowZ_ST.xy * uv.xy + _NormalMapShallowZ_ST.zw ) ) ; float3 shallow = shallowX * shallowZ * _ShallowBumpScale; float3 steepX = UnpackNormal( tex2D( _NormalMapSteepX , _NormalMapSteepX_ST.xy * uv.xy + _NormalMapSteepX_ST.zw ) ) ; float3 steepZ = UnpackNormal( tex2D( _NormalMapSteepZ , _NormalMapSteepZ_ST.xy * uv.xy + _NormalMapSteepZ_ST.zw ) ) ; float3 steep = lerp( steepX , steepZ , xzRate ) ; return normalize( lerp( shallow , steep , steepness ) );}
好了,這部分完成以後,場景的渲染效果如下(_SurfaceNormalScale分別為0.1,0.5和2) :
風格化高光 Ocean Specualar
首先真的很感慨前輩們在那個光照理論還不很成熟的年代能夠做出這麼好的藝術效果。在講座中作者表示這個像海洋高光一樣的效果其實是不真實的,但是他們試驗之後覺得這個效果好,所以在遊戲里就加上了(渲染這種東西有時候真的是憑感覺的呀)。
所謂海面高光是什麼意思呢,大概就是這樣子的吧:
嗯,大概就是有中間的一條光束。
所以那要怎麼實現呢。。。講座里沒給出具體的演算法。。。所以這部分本人基本靠蒙的。
先是實驗了一些Smith GGX,Beckman之類的模型,效果都不是很好。後來突然想起,之前在做水特效的時候,有出現過類似的效果,所以就去查看了一下。發現好像就是最基本的Blinn模型。。。對,就是Blinn,效果反而意外的不錯:
float MySpecularDistribution( float roughness, float3 lightDir , float3 view , float3 normal , float3 normalDetail ){ // using the blinn model // base shine come use the normal of the object // detail shine use the normal from the detail normal image float3 halfDirection = normalize( view + lightDir); float baseShine = pow( max( 0 , dot( halfDirection , normal ) ) , 10 / baseRoughness ); float shine = pow( max( 0 , dot( halfDirection , normalDetail ) ) , 10 / roughness ) ; return baseShine * shine;}
這裡的高光分為baseShine和shine。baseShine是用來確定高光的邊界,使用的法線是之前所說的加上Normal Map之後的Normal。shine是用來做紋理,就是做出那種波光粼粼的效果,這裡的normal實際上是使用了一個新的細節紋理法向貼圖,並且把貼圖縮小來做到細小的紋理效果。不過由於素材的原因,和目標效果始終有一些微小的差距。最終效果如下:
使用的細節紋理:
和之前的diffuse疊加(線性疊加)在一起後,下過如下:
後期處理的時候加一些bloom效果會進一步提高整體的視覺效果,先賣個關子~
然後我也試著結合BRDF的知識手動添加了一些高光,但是效果也一般,所以這裡就不展開說明了,只是上個效果圖:
貼圖過濾 Anisotropic Filtering(略)
這部分的作用是讓在遠距離的貼圖能夠更加清晰,一個比較典型的例子是這樣的(注意看遠處部分的瓷磚):
這個演算法在Unity里已經被整合好了。在Editor->Quality Settings裡面有一個Anisotropic Textures的選項用於開關這個效果,默認打開。這部分也就不詳細說明了(感覺當年Journey團隊真辛苦,連這個也要自己做)。
亮片效果 Glitter
亮片效果是什麼呢,就是在沙子上blingbling的那種效果:
單拎出來是這樣滴:
按照講座中作者的話來說,他們理解的亮片就是沙堆中有一部分的沙子正好朝向觀察者,那麼它們就會朝你發射光線,從而產生blingbling的效果。嗯,理論上是這樣沒錯,但是這叫我怎麼寫呀。關於Glitter的效果可以參考2017的siggraph里的這篇文章:
http://blog.selfshadow.com/publications/s2017-shading-course/dreamworks/s2017_pbs_dreamworks_notes.pdf個人總結下來,做Glitter效果可以分為兩個步驟,一個是噪點的製作,另一個是高光的製作。
在講座中,作者講了他們的高光製作的過程,一個灰常任性的製作過程。因為傳統的高光函數為的參數為pow(N·H,n),這個公式在上面的海洋高光中也用到了。作者覺得Glitter需要的更多關於人眼方位的信息,所以就把這個公式里的H換成了V,即觀察者方向向量。
兩者的對比大概是這樣的:
嗯。。。。。。。開心就好。
不過光有高光函數是不夠的,因為閃光點並不是在沙子上均勻的出現的,相反,它們的分布十分隨機,這就需要我們設計一個隨機分布的函數來獲取這些亮點分布的位置。在講座中沒有說明噪點函數的生成具體方法,所以接下來又要進入瞎蒙時間。
首先準備一個噪點圖,這次我用的圖大概長這樣:
然後根據這個噪點圖,進行噪點的處理。這個函數是憑經驗試驗出來的,放在這裡僅供參考:
float3 GetGlitterNoise( float2 uv ){ return tex2D( _GlitterTex , _GlitterTex_ST.xy * uv.xy + _GlitterTex_ST.zw ) ;}float GliterDistribution( float Glitterness , float3 lightDir , float3 normal, float3 view , float2 uv , float3 pos ){ ... float p1 = GetGlitterNoise( uv + float2 ( 0 , _Time.y * 0.0005 + view.x * 0.0050 )).r; float p2 = GetGlitterNoise( uv + float2 ( _Time.y *0.001 , _Time.y * 0.001 + view.y * 0.003 )).g; float sum = p1 * p2; // making discrete noise float glitter = max( 0 , pow( sum , _Glitterness ) * _GlitterMutiplyer - 0.5 ) * 2; ...}
製作噪點的效果:
顆粒感可以做到這樣,而且在場景走起來的時候blingbling的感覺還是有的,不過閃光點的密度太大了。之前不是有一個用N·V求出來的分布函數嘛,那我們就把它拿來當做蒙版吧(這一趴系完全的瞎蒙,如果有讀者知道具體的演算法可以留言處告訴我)。完整的代碼是這樣的:
float GliterDistribution( float3 lightDir , float3 normal, float3 view , float2 uv , float3 pos ){ float specBase = saturate( 1 - dot( normal , view ) * 2 ); float specPow = pow( specBase , 10 / _GlitterRange ); // A very random function to modify the glitter noise float p1 = GetGlitterNoise( uv + float2 ( 0 , _Time.y * 0.001 + view.x * 0.006 )).r; float p2 = GetGlitterNoise( uv + float2 ( _Time.y * 0.0006 , _Time.y * 0.0005 + view.y * 0.004 )).g; float sum = 4 * p1 * p2; float glitter = pow( sum , _Glitterness ); glitter = max( 0 , glitter * _GlitterMutiplyer - 0.5 ) * 2; float sparkle = glitter * specPow; return sparkle;}
添加了Glitter效果的沙丘:
Mips Map
這是一個在Texture上的類似於LOD的系統,在講座中有提到,這個技術能夠讓沙子顆粒感更強。這裡就略過了。
距離霧 Fog
通過調用宏實現,在新建Shader的時候自帶的代碼,這裡沿用下來。
後期調整 Post Effect
目前來說渲染的場景長這樣:
和Journey遊戲里的場景好像還有點距離,不過其實只要加上一些些Camera特效就會好很多了。
首先是Bloom,把高光部分的光照質感做出來。這裡使用的是Unity官方出的Post Processing Stack插件(Post Processing Stack - Asset Store)。
然後調一下顏色:
這裡是把Post Processing里的ToneMap 調成Natural,並添加了一個LUT:
最後使用的是一個叫Beautify的插件(Beautify - Asset Store),添加了一下銳化的效果,並且增加了一點對比度:
對比一下官方提供的圖片:
還是挺有意思的( ?′ω`? )
本工程已經同步到Github上了,鏈接(記得點喜歡喲):
AtwoodDeng/JourneySand歡迎吐槽~
推薦閱讀: