從紋理中生成法線貼圖

本篇文章未經作者本人授權,禁止任何形式的轉載,謝謝!

可以移步個人 blog 閱讀:blog 原文

概要

本為主要講解生成法線貼圖的基本方法,並在 unity 中進行實現和測試。

預備知識

法線貼圖和基本的圖形學知識,基本的向量和極限的知識。

高度圖或灰度圖

一張二維紋理有兩個維度 u 和 v,但其實,高度(h)可以算第三個維度。有了高度,一張二維紋理就可以想像成一個三維的物體了。

先來考慮只有 u 方向的情況,如圖所示, A 和 B 是紋理中的兩個點, uv 坐標分別是 (0, 0) 和 (1, 0),上方黑線表示點對應的高度,那麼顯然,只要求出 u 方向上的高度函數在某一點的切線,就能求出垂直於他的法線了。同理, v 方向也是如此。也就是說,如果有紋理的高度信息,那麼就能計算出紋理中每一個像素的法線了。

所以計演算法線需要一張高度圖,它表示紋理中每一個點對應的高度。

但其實並不需要求出每個紋理像素上 uv 方向各自的法線,只需要求出 uv 方向上高度函數的切線,再做一個叉積,即可計算出對應的法線了。

如果沒有高度圖,也可以用灰度圖代替,灰度圖就是把 rgb 三個顏色分量做一個加權平均,有很多種演算法提取灰度值,這裡用一個比較常用的基於人眼感知的灰度值提取公式。

color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722

這個公式是由人眼對不同顏色敏感度不同得來的,這裡無需過多計較,直接把提取出來的灰度值作為高度值即可。

計算方法

當需要求一個點的函數圖像切線的時候,只要求出該點的函數斜率即可,即是導數,這需要和它相臨的點進行計算。顯然,兩個點越接近,結果越精確。所以有如下公式:

 h(u) = lim_{Delta u 	o 0}frac{h(u + Delta u) - h(u)}{Delta u}

h(v) = lim_{Delta v 	o 0}frac{h(v + Delta v) - h(v)}{Delta v}

求出切線後,就得到了兩個方向上的切線向量 (1, h(u)) (1, h(v)) 。之所以是這種形式的二維向量,是因為這裡是按照 uoh 平面和 voh 平面分別計算的,具體的向量形式需要根據實際情況去組合。這裡可以做一個優化,在求導數的時候公式里做了一個除法,因為法線最終會歸一化,切線向量長度不影響叉積後的結果向量方向,所以其實可以直接把求導數時候的除法去掉,即直接將切線向量乘以 Delta u Delta v ,變為 (Delta u, h(u + Delta u) - h(u))(Delta v, h(v + Delta v) - h(v)) 。如果你覺得亂,沒關係,後面看具體的代碼就明白了。

接下來是將兩個向量做叉積,叉積的順序會影響計算出的法線的方向,這個要根據實際情況去決定。

實例

這個例子使用 unity shader 去動態的生成一張紋理中每一個像素的法線,併當作顏色輸出出來,最終在屏幕上會看到一張動態生成的法線貼圖。將紋理放置成平行於屏幕的方向,如下圖所示:

整張紋理處於世界空間 XOY 平面,並且朝向 -Z 軸(unity 使用左手坐標系,且 Z 軸朝向屏幕里)。

由於沒有高度圖,所以提取出灰度值來當作高度圖,演算法根上面描述的一樣,函數名為 GetGrayColor。

float GetGrayColor(float3 color){ return color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722;}

然後可根據高度圖的值來計算 uv 兩個方向的高度函數切線。

float3 GetNormalByGray(float2 uv){ // 代碼後有詳細的講解 float2 deltaU = float2(_MainTex_TexelSize.x * _DeltaScale, 0); float h1_u = GetGrayColor(tex2D(_MainTex, uv - deltaU).rgb); float h2_u = GetGrayColor(tex2D(_MainTex, uv + deltaU).rgb); // float3 tangent_u = float3(1, 0, (h2_u - h1_u) / deltaU.x); float3 tangent_u = float3(deltaU.x, 0, (h2_u - h1_u)); float2 deltaV = float2(0, _MainTex_TexelSize.y * _DeltaScale); float h1_v = GetGrayColor(tex2D(_MainTex, uv - deltaV).rgb); float h2_v = GetGrayColor(tex2D(_MainTex, uv + deltaV).rgb); // float3 tangent_v = float3(0, 1, (h2_v - h1_v) / deltaV.y); float3 tangent_v = float3(0, deltaV.y, (h2_v - h1_v)); float3 normal = normalize(cross(tangent_v, tangent_u)); return normal;}

上面代碼分為 3 段,前兩段為計算 uv 各自方向的高度函數切線,最後一段計算最終法線。

先看第一段,計算 u 方向的高度函數切線。首先,確定步長 Delta u 的大小。MainTexTexelSize 是 unity shader 內置的一個變數,保存著紋理大小相關的信息,是一個 float4 類型的值,具體為 (1 / width, 1 / height, width, height)。_DeltaScale 是一個控制步長縮放的變數,在這個例子中為 0.5,乘以 _DeltaScale 是用來控制法線生成的精確度的,就如之前所說, Delta u 越小,生成的法線就越精確。通常我們會向當前採樣點兩側去採樣,以獲得更精準的結果,這個方法叫做中心差分法。然後可以根據步長分別取當前像素左右兩側的高度值(在這個例子里就是灰度值),在按照上面提到的計算方法計算切線即可。注釋掉的代碼是原始代碼,下面沒注釋的是優化後的代碼,這個也是上面提到的。

有一個問題是,為什麼計算出來的切線向量是 (x, 0, z) 的形式,而不是其他?這是因為前面提到整張紋理是處於 XOY 平面的,而高度是第三個維度,因為 u 和 v 自然是按照 x 和 y 軸處理方便,所以高度 h 就按照 z 軸來處理了。

還有一個可能的疑問是,當 _DeltaScale 特別小的時候,取兩側的像素實際上都是單前像素,則高度差都是 0 了。但實際上這個情況只有在採樣過濾方式為 point 採樣時才會出現,具體採樣過濾方式是如何處理的可以查閱其他資料。

同理,第二段可以計算出 v 方向的高度函數切線,兩個切線向量,做叉積,再歸一化,即可獲得當前像素點表面的法線向量。叉積的順序很重要,因為紋理是朝向 -z 軸的,所以一般來說會讓法線也順著表面所在的朝向,這就是為什麼是 cross(tangentv, tangentu) 而不是 cross(tangentu, tangentv) 的原因。

現在將法線當作顏色輸出出來看一下,當然不能直接輸出,因為法線向量可能包含著負值,可能看到的都是黑色,所以需要轉換一下,這個轉換對於了解過法線貼圖的讀者應該很熟悉了。

fixed4 color = normal * 0.5 + 0.5

直接輸出這個 color,如下圖所示:

看起來跟常見的法線貼圖有些不一樣,常見的是偏藍色的那種。為什麼是偏藍色的呢,因為常見的法線貼圖都是切線空間的,至於切線空間,可以看我這篇博客,講解了切線空間是怎麼計算的:切線空間(Tangent Space) 的計算與應用。

基於切線空間的法線貼圖,z 也就是 b 通道的值都是 0.5 到 1,而 x 和 y 也就是 r 和 g 通道都是 0 到 1,所以看起來會偏藍一些,當然不是絕對。而上面計算出來的法線貼圖,由於叉積的順序,z 分量是朝向 -z 軸的,所以 b 通道都是 0 到 0.5,不信可以用截屏工具看下顏色值。在這個例子里,想要變成切線空間下的法線貼圖是非常簡單的,只需要將 z 分量乘以 -1 即可,

normal.z *= -1;fixed4 color = normal * 0.5 + 0.5

結果如下圖:

根上一張圖比,確實偏藍一些了,但是依然不夠藍。這並不是因為這張紋理特殊,而是還有一些校正的步驟沒有做。

在計算切線向量的時候,是直接用高度差和 Delta 值做計算的,這其實是不合理的,因為 Delta 是非常非常小的,一張 1024 * 1024 大小的圖, Delta 只有 1 / 1024 = 0.00097656,但是高度差卻是 0 到 1 之間某兩個數的差,例如高度為 0.6 和高度為 0.2,正常來說是遠大於 Delta 的,這就導致了切線向量很接近 -z 軸,計算出的法線就很接近於 xoy 平面了,這樣就看起來有很多紅色和綠色,因為 x 和 y 的分量更大。為了解決這個問題,需要引入一個 _HeightScale 變數,來控制高度差的比例。

float3 GetNormalByGray(float2 uv){ ... float3 tangent_u = float3(deltaU.x, 0, _HeightScale * (h2_u - h1_u)); ... float3 tangent_v = float3(0, deltaV.y, _HeightScale * (h2_v - h1_v)); ...}

當這個值為 _HeightScale 值為 0.01 時,法線貼圖結果如下:

這張法線貼圖看起來正常了,而且仔細觀察可以發現,每一個磚塊的上側是偏綠的,因為 y 對應於 g,右側是偏紅的,因為 x 對應於 r。

可以不用中心差分法嗎

可以使用有限差分法,即不取像素兩邊相鄰的點,而是只取一個方向上相鄰的點與當前像素比較,這種方法想想也知道效果一般不如中心差分法的好。

除了高度差縮放,還有別的參數可以調節嗎

有,這裡簡單列舉兩個,因為修改都很簡單,而且效果不適合這裡講的例子,所以不在本文實現了。

凹凸值

圖中每一個磚塊,是凹進去的還是突出來的呢?要改變這個屬性,只需要調整法線 xy 的正負即可,就會改變原有的凹凸方向,稍微想像一下應該就能想出來。

粗糙度

可以在原來的法線題圖基礎上,進一步修改法線貼圖的粗糙度。其實之前的高度差縮放,也是處理粗糙度,但是當你有一張已經生成好的法線貼圖時,想修改就需要做額外的處理了。也很簡單,對法線的 xy 分量進行縮放,然後再重新計算 z = sqrt{(1 - x^2 - y^2)} 即可。

加上光照

法線是為了光照服務的,所以這裡再演試一下加上一個平行光之後的漫反射的效果,並與沒加法線貼圖的效果做一下對比(默認法線為 -z 軸方向)。

首先是沒有法線貼圖的情況。

fixed4 frag (v2f i) : SV_Target{ float3 normal = float3(0, 0, -1); fixed4 texColor = tex2D(_MainTex, i.uv); float diffuse = saturate(dot(normal, normalize(_WorldSpaceLightPos0.xyz))); fixed4 color; color.rgb = texColor.rgb * diffuse *_LightColor0.rgb; return color;}

最終的結果如下圖所示:

這是將光源繞 x 軸和 y 軸都旋轉了 60 度並且使用默認法線得到的 diffuse 結果,和原來沒有光照的原圖比較,有了明暗的變化,但依然只是一張平坦的圖。

接下來是使用了上面演算法動態生成法線貼圖的情況。

fixed4 frag (v2f i) : SV_Target{ float3 normal = GetNormalByGray(i.uv); // normal.z *= -1; normal.xy *= _Camber; fixed4 color; fixed4 texColor = tex2D(_MainTex, i.uv); float diffuse = saturate(dot(normal, normalize(_WorldSpaceLightPos0.xyz))); color.rgb = texColor.rgb * diffuse *_LightColor0.rgb; return color;}

注意這裡的 normal.z 不再乘以 -1 了,因為這個例子一切都是在世界空間下計算的,正常情況下可能在切線空間算效率會更高一些,但這並不是本篇文章的內容。最終輸出的結果如下圖所示:

可以看到,整張圖有了明顯的立體感,磚塊也顯得粗糙了,與之前有了極大的效果提升。再仔細觀察可以發現,每個磚塊左邊和上邊都被照亮,右邊和下邊都變暗了,這正符合平行光的旋轉角度,所以光照結果是正確的。

最後的工作

最後的工作就是把生成的法線貼圖保存到硬碟上,這一步只需要調用引擎的相關 API 把渲染出來的法線貼圖保存為資源即可,也可以直接在 cpu 上操作去生成一張,但這麼做就不方便用實時光照去查看效果了。


推薦閱讀:

《Exploring in UE4》物理模塊淺析[原理分析]
[DOD Series][CppCon14] Data-Oriented Design and C++
伽馬校正小記
[DOD Series][GCAP09] Pitfalls of Object Oriented Programming

TAG:遊戲引擎 | 計算機圖形學 | 遊戲開發 |