置換貼圖 Displacement Mapping


視差貼圖和法線貼圖都是使用特定的手段來達到欺騙視覺的目的,讓人以為物體的表面是凹凸起伏的。而置換貼圖卻是真的將模型的頂點進行偏移,在原本的平面上創造出凹凸的效果。既然是對頂點進行偏移,那么就需要模型有足夠多的頂點數量,否則達不到比較好的效果。為了達到足以置換的頂點數量,一般會使用 Tessellation 技術來增加低模的面數。查閱了相關資料,Tessellation 是 DirectX11 才有的技術,OpenGL 要到 4.0 才能使用。對於 Tessellation 知道的並不多,再加上移動平台根本無法使用,就沒有去看這部分的內容,這里也不會涉及到。其實目前來看 Tessellation 技術並不是非用不可,很多情況下還是光照和貼圖起到更大的作用。

看下要實現的效果,是一個轉動的地球。地球的表面並不是平坦的,每一塊陸地都是有凸起的,並且可以看到凸起的陸地上山脈和溝壑。球模型本身是 1200 面。

置換前效果 置換后效果 置換后效果

首先是在頂點階段采樣高度紋理,將采樣到的顏色作為高度數據偏移頂點坐標(在頂點階段采樣紋理是sm3.0才支持的,我記得看到過什么資料上說 sm3.0 還是 2.0 是 DirectX 的叫法,和 OpenGL 沒關系,不知道對不對。編譯選項需要加上 #pragma glsl,讓編譯器把 cg 代碼直接編譯成 glsl,而不是中間代碼,否則會報錯)。可以看到如下效果。

// vertex shader
o.wnormal = mul (i.normal, (float3x3)_World2Object);
float dispValue = tex2D (_DisplacementMap, i.texcoord.xy).r * _DisplacementAmount;
o.wpos = mul(_Object2World, i.vertex);
o.wpos.xyz += o.wnormal * dispValue;
o.pos = mul(UNITY_MATRIX_VP, o.wpos);

地形表面已經有了凸起效果了。由於這里沒有任何的光照計算,所以整體效果特別的平,沒有體積感。到此為止還是很簡單的,下面就是要加入光照計算。光照計算最重要的就是法線了,使用法線和光線進行計算來實現明暗效果,表現出物體的體積感。但是現在的問題是,由於模型表面已經不是平坦的了,而是凹凸不平的,如果還使用原來的法線信息肯定會得到錯誤的效果。

模型頂點的法線 置換頂點后錯誤的法線 置換頂點后正確的法線

只要能夠計算出如右圖所示的法線,再計算光照就不是什么難事了。我特意查看了下 Unity 文檔中關於 Displacement Mapping 的代碼,並在 Unity 編輯器中測試了下,光照效果是不正確的,因為當偏移頂點坐標的時候,並沒有對法線進行調整。

從圖中可以看到,左邊的凸起是模型做出來的(可以看到網格),右邊凸起是使用置換貼圖實現的(看不到網格,使用的 Unity 文檔中的代碼實現)。問題就在於右邊的光照是錯誤的,凸起的背光面並沒有變暗,這正是因為偏移了頂點坐標后沒有重新計算法線造成的。至於如何能夠比較精確的計算出新的法線可以看這篇文章,作者對置換貼圖進行了多次采樣,通過計算高度差的方式實現的,感覺類似於 Unity 編輯器將灰度圖轉換成法線圖的功能。然而我們這里使用另一種近似計算的方式,這種方式得到的結果並不是很精確,在模型邊緣可能會出現瑕疵,但是計算量要小得多。我在設備上測試過,是可以得到比較好的效果。如果使用置換貼圖后,光照效果的錯誤是可以忽略的,就不用調整法線了(和 Unity 一樣,並且我看到很多類似的資料里也都沒有講到調整法線)。

下面就來說說如何來近似模擬計算調整后的法線。這里使用到的兩個 cg 函數是 ddxddy。首先要說明的是 ddx 和 ddy 是在屏幕空間中的兩個函數。如果寫了這樣的代碼 ddx(x),得到的結果就是下一個像素(一般當前像素的右邊一個像素)的 ddx(x) 括號中的 x 的值,減去當前像素 ddx(x) 括號中的 x 的值。也就是兩個像素的 x 值相差多少。ddy同理,只是描述的是縱向,而不是橫向。在旋轉的地球這個例子中,如果下一個像素並不是地球模型,而是其他模型,那就可能出現上文所說的邊緣瑕疵,如果你不是太在意,一般不會注意到。之所以這兩個函數能夠做到這點,是因為 GPU 中 fragment 是並行的。原理說明白了就看下代碼。

float4 frag(v2f i)
{
	// 水平方向uv的增量
	float2 uv_dx = ddx(i.uv.xy);
	// 垂直方向uv的增量
	float2 uv_dy = ddy(i.uv.xy);
	// 當前置換距離
	float height = tex2D(_DisplacementMap, i.uv.xy).r;
	// 水平方向下一個像素點的置換距離
	float height_h = tex2D(_DisplacementMap, i.uv.xy + uv_dx).r;
	// 垂直方向下一個像素點的置換距離
	float height_v = tex2D(_DisplacementMap, i.uv.xy + uv_dy).r;
	// 水平方向置換增量
	float t_h = (height_h - height) * _DisplacementScale;
	// 垂直方向置換增量
	float t_v = (height_v - height) * _DisplacementScale;
	
	// 水平方向頂點坐標增量,作為一個假的tangent來使用
	float3 fake_tangent = ddx(i.wpos);
	// 水平方向頂點坐標增量,作為一個假的bintangent來使用
	float3 fake_bintangent = ddx(i.wpos);
	
	// 到這里為止,其實已經可以用 fake_tangent 差乘 fake_bintangent 來得到 fake_normal 了
	// 但是會發現 fake_normal 並不平滑,用來計算光照會出現硬邊
	// 解決辦法是使用從 vertex shader 傳過來的 normal 來進行糾正,頂點上的 normal 是平滑的
	
	// corss 部分就是用平滑的 normal 來重新計算新的 fake_tangent
	// 后面加法是使用置換增量來對其進行擾動
	float3 fake_tangent_new = cross(fake_bintangent, i.wnormal) + i.wnormal * t_h;
	// 同理
	float3 fake_bintangent_new = cross(i.wnormal, fake_tangent) + i.wnormal * t_v;
	// 最終得到平滑的 fake_normal
	float3 fake_normal = cross(fake_bintangent_new, fake_tangent_new);
	fake_normal = normalize(fake_normal);
	
	// 這里就可以使用這個 fake_normal 參與光照計算了
}

至此,所有的要點都說明了。最后我又在設備上進行了測試,發現當圖形接口是 OpenglES 3.0 的時候顯示效果是正確的,但是 Metal 就不正確了。最終發現 Metal 中 ddy 和 OpenglES 3.0 中的 ddy 返回值不同,第一反應是 Metal 在實現上應該不會出問題,因為兩個圖形接口在 ddx 的返回結果是一致的。我把最后一步求平滑的 fake_normal 的差乘兩邊交換了一下,變成 float3 fake_normal = cross(fake_tangent_new, fake_bintangent_new),於是效果就正確了。這說明兩個圖形接口在處理 ddy 時,雖然都是縱向,但是方向相反。

goto blog


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM