1. 爲什麼使用法線映射?
在開始正式討論法線映射之前,先來看下以下兩張圖片:
這兩張依然是之前一篇文章中用到的仙劍五前傳中兩張截圖,兩圖中顯示的爲同一地點,不同的觀察角度。在左邊的圖中,根據紋理圖,它給人一種很粗糙的岩石壁的感覺,但右邊圖中卻出現了強烈的高光反射。這顯然是有點相互矛盾的,因爲強烈的全反射只有在表面比較光滑的表面上纔會出現,而從左圖中來看,它應該是凹凸不平的。造成這種現象的原因很簡單:紋理的使用給我們帶來了像素級別上的物體表面的細節,而模型本身是由有限個頂點組成的,這樣在像素着色器中,經過插值計算得到的各像素的法線是平滑過渡的,而不再是各像素本身應該有的法線值。這樣平滑過渡的法線在經過光照計算後,就很容易造成這種比較明顯的高光反射現象。
要修正這種現象,根本問題在於修改像素的法線值,使其與真實法線趨於一致,這樣在光照計算後將會得到與實際逼近的結果。要實現這種效果,有兩種方法:一種是增加模型的細節,即頂點個數,這樣就可以爲模型表面指定更多的法線,而不再是在像素着色階段依賴簡單的插值計算得到,以得到更加真實的效果。這種方法是可行的,但有一個缺陷,更多的頂點意味着更大的計算量,因爲在頂點着色器中,每個頂點都要經過各自的各種矩陣變換。因此這種方法能夠提供的細節程度是有限的,一般不足以滿足我們的要求。 另一種效率非常高而且效果很好的方法,即這篇文章的主題:Normal Mapping。
2. 法線貼圖 及其 數據格式
在Normal Mapping技術中,需要使到到一張紋理。與普通紋理不同的是,這張紋理中的每個像素(texel)存放的並不是顏色值,而是法線,因此也稱之爲法線貼圖(Normal Map)。我們知道,紋理的使用給基於頂點的幾何模型帶來了像素級別上的細節,同樣,法線貼圖的使用,使用我們能夠得到模型表面在像素級別上的法線值,這樣的法線值是直接通知讀取紋理獲得的,而不再是經過插值得到,因此可以根據現實需求由美工們靈活設定,以獲得想要的逼真效果。
在數據存放格式上,法線貼圖與普通貼圖並無差別,依然是RGB格式或者RGBA格式。只是這裏的R、G、B、A不再是顏色的不同分量,而是三維法線向量的各分量。R、G、B分別代表法向量的X、Y、Z分量,如果是RGBA格式,則一般可以用A分量來存放高度信息。這個高度信息也是非常有用的,在很多地方需要與法線值配對使用,比如Parallax Mapping(後面介紹)。這裏我們主要來關注RGB分量。一般情況下各分量佔8個位(無符號),因此取值區間位於[0, 255]。但在實際情況下,經過歸一化的法線向量,總長度爲1,因此各分量都位於[-1, 1]之間。因此要想使用8位來存放,需要把[-1, 1]範圍映射到[0, 255]之間。 方法其實很簡單,令x爲[-1, 1]中任意值,通過y = (x + 1) /2 * 255,即得到了位於[0, 255]之間的y。相反,對於從貼圖中讀取到的y值,我們可以通過反向變換:x = 2*y / 255 - 1,得到我們想要的範圍區間內的值。
在HLSL中,通過內置函數 Sample(與之前讀取紋理的函數一樣),我們可以直接得到位於[0, 1]之間的數據,因此我們只需要進行2*x - 1的變換即可。如下所示:
normal = g_normalMap.Sample(samplerTex, pin.tex).rgb; //Get normal from the normal map
normal = 2 * normal - 1; //From [0,1] to [-1,1]
3. 切線空間 到 世界空間
通過Sample函數,我們得到了任意像素上對應的法線值,下一步就可以利用這個法線來進行光照計算了。但實際上,這時得到的法線是不能直接用於光照計算的,而需要先進行相應的空間變換。這就是上一節中提到的“切線空間”的用途了。在上面提到的法線貼圖中,裏面所存放的法線值正是位於切線空間內,而場景中所提供的光源位於世界空間。要想進行正確的光照計算,需要把光源和法線轉換到同一個空間中進行,要麼統一位於切線空間,要麼統一位於世界空間。(這裏我們統一在世界空間進行光照計算)
關於在切線空間定義法線的目的,在這裏我再進行一下補充。如果對切線空間還不是很理解,按我的經驗,可以這樣來理解,即把切線空間類比爲3D世界中的局部空間。之所以要有局部空間,就是方便在製作模型時能夠只專注於模型本身,而不必考慮模型在場景中可能出現的各種位置及朝向。在不同的位置、朝向下,模型中針對同一個頂點而言,其位置等信息是不一樣的,如果沒有局部空間,就需要爲每種情況製作不同的模型,這樣顯然會很麻煩,甚至不可能,也很浪費,因爲這些不同位置、朝向處的模型本質上是同一種。如果使用局部空間來定義模型,而通過爲場景中不同模型指定各自的世界變換,就很容易地能夠實現單個模型的重複利用。 同樣,法線貼圖也是一個道理。一個模型的不同部位,甚至多個模型之間,可能會具有同樣特點的表面,但顯然由於其位置、朝向的不同,這些表面針對同一處的法線也是不一樣的。比如一個正方體的六個表面,可以具有完全類似的特點,但各個面朝向不同,對應的法線也不再一樣。沒有切線空間,將不得不對每個表面制定單獨的法線貼圖,很浪費。如果把法線定義在切線空間,而針對每個面,都有其相應的切線空間,這樣將可以使用同一張法線圖來用於六個面。
那麼,對於任意像素,從哪裏可以獲取其對應的切線空間呢?這時就要用到新的頂點格式,切線空間的信息正是通過在輸入階段由頂點傳過來的,在像素着色階段,每個像素對應的切線空間通過其所在三角形的三個頂點的切線空間進行插值得到。新的頂點格式即上節最後給出的:
struct VertexIn
{
float3 pos : POSITION; //Local space position
float3 normal : NORMAL; //normal
float3 tangent : TANGENT; //tangent
float2 tex : TEXCOORD; //texture coordiation (u,v)
};
這裏每個頂點除了位置座標、紋理座標外,還存放了法線、切線向量。而切線空間TBN需要三個向量。不過這裏爲了節省資源佔用,只提供了切線與法線信息,另外一維bitangent可以在運行時通過該兩向量的叉乘得到。相應的HLSL代碼如下所示:
//Get TBN space
float3 N = normal;
float3 T = normalize(pin.tangent - N * pin.tangent * N);
float3 B = cross(N, T);
注意這裏的法線不再是我們後面進行光照計算用到的法線,光照計算所用到的所有法線都是從法線圖中獲取的,這裏的法線只是用來代表該頂點所在切線空間。
代碼中第一行直接獲取切線空間的法線部分(N),注意前提是要保證法線是已經經過歸一化的。
第二行的目的是獲取切線空間的切線部分(T),這裏使用了一點小技巧,主要是爲了保證切線與法線的相互垂直關係。因爲在經過頂點着色器中的世界變換後,原本相互垂直的T與N可能由於精度的關係而不再垂直,這裏需要來對它們進行一下修正,以相互滿足垂直關係。方法即如上:normalize(pin.tangent - N * pin.tangent * N);
第三行通過對N、T進行叉乘,從而得到了切線空間的bitangent向量。注意進行叉乘的N和T的先後順序!絕對不能是T x N!
有了切線空間的三個向量,我們也就得到了從切線空間到世界空間的轉換矩陣了,即:
有了這個矩陣,我們繼而可以很方便把從法線圖讀取到的法線轉換到世界空間中了:
float3x3 T2W = float3x3(T, B, N);
normal = g_normalMap.Sample(samplerTex, pin.tex).rgb; //Get normal from the normal map
normal = 2 * normal - 1; //From [0,1] to [-1,1]
normal = normalize(mul(normal, T2W)); //Transform the normal to world space
第一行通過切線空間T、B、N向量直接得到從切線空間到世界空間的變換矩陣。float3x3類型有接受三個三維行向量的構造函數,以利用三個行向量獲得一個3x3矩陣。
第二行即之前介紹的把法線的每一維從[0, 1]區間轉換到[-1, 1]區間。
第三行通過剛得到的切線空間到世界空間的矩陣T2W(Tangent to World),把讀取到的法線轉換到世界空間。
4. 小結 及 完整的pixel shader
好了,到這步爲止,我們夢寐以求的法線得到了,法線映射的所有工作也就此結束了~
之後所有的像素着色器代碼與之前的完全一樣。本質上講,上面介紹到的所有這些內容,其實特等效於以前代碼中的:
float3 normal = normalize(pin.normal);
後面要做的即使用這個法線進行光照計算、紋理處理、霧效等過程了。不同之處僅僅是這裏通過從紋理中讀取法線來代替了之前直接從像素中獲取經過插值的法線。
爲了更清晰地展示法線映射在像素着色器中的應用,這裏給出完整的pixel shader:
注意:這段代碼中還有一些尚未進行介紹的內容,比如parallax mapping(法線映射的進階),shadow mapping(生成陰影的常用算法之一)。暫時可以把這些內容忽略,以更好的關注normal mapping部分。
//Pixel shader
float4 PS(VertexOut pin,
uniform int numLights, //光源數量(平行光)
uniform bool useTexture, //是否使用紋理
uniform bool alphaClipEnable, //是否使用紋理的alpha值進行裁剪
uniform bool useNormalMap, //是否使用法線映射
uniform bool useParallaxMapping, //是否使用視差映射
uniform bool useShadowMap, //是否shadow mapping
uniform bool pcfShadowEnable, //是否開啓PCF軟陰影
uniform bool useReflection, //是否使用cube mmapping實現反射
uniform bool fogEnable //霧效?
): SV_TARGET
{
//To eye vector
float3 toEye = g_eyePos - pin.posL;
float dist = length(toEye);
toEye /= dist;
//默認使用插值得到的法線
float3 normal = normalize(pin.normal);
//Get TBN space
float3 N = normal;
float3 T = normalize(pin.tangent - N * pin.tangent * N);
float3 B = cross(N, T);
//Parallax mapping
if(useParallaxMapping)
{
float height = g_normalMap.Sample(samplerTex,pin.tex).a; //從alpha分量得到高度信息
height = (height - 1.f) * g_heightScale; //高度倍增(向內)
float3x3 W2T = transpose(float3x3(T,B,N));
float3 toEyeTangent = mul(toEye, W2T); //世界 -> 切線空間
float2 offset = toEyeTangent.xy * height; //通過世界空間內座標的offset獲取紋理offset
offset *= g_texOffsetScale;
pin.tex += offset; //紋理座標偏移
}
//法線映射
if(useNormalMap)
{
float3x3 T2W = float3x3(T, B, N);
normal = g_normalMap.Sample(samplerTex, pin.tex).rgb; //從法線圖讀取法線向量
normal = 2 * normal - 1; //從[0, 1] -> [-1, 1]
normal = normalize(mul(normal, T2W)); //從切線空間 -> 世界空間
}
float4 texColor = float4(1.f,1.f,1.f,1.f);
if(useTexture)
{
texColor = g_texture.Sample(samplerTex,pin.tex);
}
float4 color = texColor;
//光照計算
if(numLights > 0)
{
float4 ambient = float4(0.f,0.f,0.f,0.f);
float4 diffuse = float4(0.f,0.f,0.f,0.f);
float4 specular = float4(0.f,0.f,0.f,0.f);
//Shadow mapping
//計算陰影係數(即位於陰影之內的百分比)
//默認不在陰影內:1.0
float3 shadowFactor = {1.f, 1.f, 1.f};
if(useShadowMap)
{
//PCF軟陰影
if(pcfShadowEnable)
shadowFactor[0] = CalculateShadowFactor3x3(samShadow,g_shadowMap,pin.shadowTex);
else
shadowFactor[0] = CalculateShadowFactor(samShadow,g_shadowMap,pin.shadowTex);
}
[unroll]
for(int i=0; i<numLights; ++i)
{
float4 A, D, S;
ComputeDirLight(g_material, g_dirLights[i], normal, toEye, A, D, S);
ambient += A;
diffuse += D * shadowFactor[i];
specular += S * shadowFactor[i];
}
//與紋理顏色進行調製:modulate
color = color * (ambient + diffuse) + specular;
}
//霧效
if(fogEnable)
{
clip(g_fogStart + g_fogRange - dist);
float factor = saturate((dist - g_fogStart) / g_fogRange);
color = lerp(color, g_fogColor, factor);
}
//反射
if(useReflection)
{
float3 refDir = reflect(-toEye, normal);
float4 refColor = g_cubeMap.Sample(samplerTex, refDir);
color = lerp(color, refColor, g_material.reflection);
}
color.a = texColor.a * g_material.diffuse.a;
return color;
}
5. 示例程序
好了,法線映射的基礎就介紹到這兒,最後是附帶的一個簡單示例程序,用於展示法線映射的效果。 該示例程序中場景極其簡單,僅僅是一個地面,加一個可以自由行走的照相機。可以通過按鍵1 -> 6來開啓、關閉不同的效果,從而對使用法線映射與不使用法線映射的差別有更直觀的感受。(按鍵‘3’ 和 ‘6’分別針對parallax mapping,暫時可以不管)。 以下是幾張運行截圖,懶得下載代碼的話可以從這兒的圖中來感受下區別:
1. 僅僅光照計算下的一個平面地板:(按鍵 ‘1’)
2. 光照計算 + 法線貼圖: (按鍵‘2’)
3. 光照計算 + 紋理, 不使用法線貼圖: (按鍵 ‘4’)
4. 光照計算 + 紋理 + 法線貼圖: (按鍵 ‘5’)
怎麼樣?是不是有很強烈的凹凸感?
本節完。