*原創文章,轉載請註明出處*
openGL CG 系列教程06 – Normal Mapping (法線貼圖)
Normal Mapping(法線貼圖),不論是在遊戲開發還是其他計算機圖形開發中都是使用很廣泛的技術。如果一個物體的表面粗糙不平,物體頂點的法線也就朝向各個不同的方向,所以物體看起凹凸不平。要表現這樣的物體,當然可以使用相當多的包含不同法線的頂點數據,這樣做的效率可想而知是很低的。在要求及時性很高的交互式圖形程序,比如遊戲中,顯然這種方法不適用。爲了解決這個問題,提出了法線貼圖的概念。
法線貼圖是一張普通的紋理貼圖,但是和一般紋理貼圖不一樣的是,法線貼圖中的每個像素保存的是法線數據。一般通過高度圖來生成法線貼圖。高度圖是8位灰度圖,顏色越深代表高度越低,顏色越淺代表高度越高。如圖Fig1的(a)就是用photoshop生成的一張高度圖。在圖Fig1中可以看到,從高度圖(a)生成法線圖(b)然後貼在物體表面時(c),淺色的部分就表示物體表面“凸”的部分,高度圖中顏色深的部分就表示物體表面“凹”的部分。
(a).高度圖 |
(b).法線圖 |
(c).應用到物體表面 |
Fig1. 高度圖和法線圖
先來看看如何從高度生成法線圖。利用高度圖中的數據,計算兩個差向量(1, 0, Hr – Hg)和(0, 1, Ha – Hg),而法線就等於它們的外積。其中Hg是當前像素,Ha是當前像素上面的一個像素,Hr是當前像素的右面的一個像素。從下面的圖Fig2可以清楚的看出它們的關係。
|
Ha |
|
|
Hg |
Hr |
|
|
|
Fig2. Hg,Ha,Hr
計算出兩個差向量的外積並單位化,就可以的到法線計算的公式。
要注意的是,法線貼圖時保存在紋理數據裏然後傳到shader裏的,一般紋理數據保存的數據類型是無符號的整型,範圍是0到255,或者用無符號浮點數0到1表示。而我們計算出來的法線是帶符號的浮點類型數據,所以這樣要把計算出來的法線轉換爲0到1之間的無符號浮點數據類型。由於單位化後的法線各分量的範圍是-1到1的浮點數,那麼將法線轉換爲顏色可以表示爲
Color = Normal × 0.5 + 0.5
如果使用的GPU支持帶符號的浮點數類型的話,也可以不做這樣的轉換。如果GPU不支持,則需要轉換後再傳入到shader,在shader中使用的時候不要忘了把顏色再次轉換到-1到1之間的法線。
Normal = 2 × (Color - 0.5)
HeightMap2Normal.cpp
IplImage *pImg = cvLoadImage("HeightMap.bmp"); //從文件讀取高度圖 int q = pImg->nChannels;
CvSize csize; csize.width = 128; csize.height = 128;
IplImage *nP = cvCreateImage(csize, 8, 3); //創建一個帶有RBG三個通道的深度爲8的法線圖
CvScalar s; CvScalar r; for(int i = 0;i<128;i++) { for(int j = 0 ;j<128;j++) { int Hg,Ha,Hr; s=cvGet2D(pImg,i,j); Hg = s.val[0];
if(i-1<0) Ha = 0; else { s = cvGet2D(pImg,i-1,j); Ha = s.val[0]; }
if(j+1>127) Hr = 0; else { s = cvGet2D(pImg,i,j+1); Hr = s.val[0]; }
r = cvGet2D(nP, i, j); r.val[2] = (Hg-Ha)*0.5+128; // 爲了保存法線圖爲位圖,這裏將法線轉換爲[0,255]之間的整數 r.val[1] = (Hg-Hr)*0.5+128; r.val[0] = 255; cvSet2D(nP,i,j,r); } } |
上面這段代碼將高度圖轉換爲法線貼圖,爲了簡便,使用了OpenCV。由於高度圖是灰度圖,RGB各分量的值都相等,所以代碼中只適用了0通道。OpenCV默認bmp圖片保存方式爲BGR,最後計算出的法線各分量的值對應的順序也要改變。Fig1中的圖(b)就是從高度圖(a)中計算出法線後,轉換爲顏色值保存到bmp圖中的結果。另外在計算法線的時候,如果在公式分子法線向量的x,y分量乘以大於0的常數,那麼將得到更加分明的凹凸效果,如圖Fig3。
(a).乘以常數1 |
(b).乘以常數5 |
Fig3. 法線貼圖的比較
生成好法線貼圖後,就可以傳入shader中使用了。法線貼圖中的法線信息是每個像素的法線,因此就要像pixel lighting一樣在fragment shader中將計算光照所要用的法線從法線貼圖中取出即可。
06vs.cg
void vs_main(float4 position : POSITION, float2 texCoord : TEXCOORD0, //法線貼圖的紋理座標
out float4 oPosition : POSITION, out float2 oTexCoord : TEXCOORD0, out float3 objPos : TEXCOORD1, // 物體本地座標系中的頂點座標
uniform float4x4 MVP) {
oPosition = mul(MVP, position); oTexCoord = texCoord; objPos = position.xyz;
} |
這段vertex shader很簡單,主要是將法線貼圖的紋理座標和物體的個頂點座標傳入fragment shader中。下面是fragment shader的代碼。
06fs.cg
float3 expand(float3 v) { return (v-0.5) * 2.0; }
void fs_main(float2 normalMapCoord : TEXCOORD0, float3 objPos : TEXCOORD1,
out float4 color : COLOR,
uniform float3 lightPosition, uniform float3 eyePosition, uniform float4 LMd, uniform float4 LMs, uniform sampler2D normalMap) {
float3 normalTex = tex2D(normalMap, normalMapCoord).xyz; float3 normal = expand(normalTex);
float3 lightDir = normalize( lightPosition-objPos ); float3 eyeDir = normalize( eyePosition - objPos );
float3 H = normalize( lightDir + eyeDir );
float diffuse = saturate(dot(normal, lightDir)); float specular = saturate(dot(normal, H));
color.xyz = LMd * diffuse + LMs*pow(specular, 64); color.w = 1.0;
} |
可以看到fragment shader代碼和前面pixel lighting中計算光照差不多。這裏增加了一個函數
float3 expand(float3 v) { return (v-0.5) * 2.0; } |
該函數的功能就是前面所講到的,法線數據被轉換爲紋理顏色傳入shader,使用的時候就要將它再次還原爲法線數據。fragment shader中的傳入參數多了個uniform sampler2D normalMap和float2 normalMapCoord,分別就是法線貼圖和紋理座標,然後通過紋理查詢函數tex2D()就可以得到每個像素對應的法線數據,然後將此法線作爲後面光照計算所用的法線即可。如果將這段shader應用到一個在xy平面上的四邊形,就可以得到Fig4的效果。
(a) |
(b) |
(c) |
Fig4 法線貼圖應用到xy平面的四邊形
從上面的圖中可以看到光源移動後所顯示出的不同,並且結果是正確的。現在如果地面要使用法線貼圖來顯示地面的凹凸不平,我們試着將這個法線貼圖用到xz平面上。
(a) |
(b) |
Fig5 不同平面應用法線貼圖的效果
應用到xz平面上我們發現結果不正確,從Fig5圖(a)中可以看到,紅色的點是光源的位置,如果光源在地面的上方,地面後半部分應該也被光照亮,而不應該是黑色的。圖(a)中地面有一片黑色的部分,很明顯這裏效果不正確。我們希望得到圖(b)的效果,下面來看看哪裏出了問題。
到現在爲止所以針對法線的計算,都是基於物體本地座標系的。從高度圖開始,由於圖片是xy平面上的像素組成的,生成法線圖的時候當然也是在這個本地座標系。所以我們將法線圖應用於xy平面上的物體物體是效果是正確的。一旦對象的本地座標系發生改變,再用本地座標系改變前所生成的法線貼圖效果就不正確了,就像我們從Fig5圖(a)中看到的一樣。要得到正確的效果,可以再座標系發生變換後,重新生成法線圖。很明顯這樣做效率太低,如果應用法線貼圖的物體是運動的,那麼每一幀都要重新生成法線貼圖。爲了解決這個問題,我們使用貼圖座標系(Texture Coordinate)。
對每個頂點分配紋理座標的時候,將每個頂點看成座標原點,然後分別使用該頂點的切線T (Tangent),第二法線B (Binormal)和法線N (Normal)構成一個座標系。在幾何上,由T B N組成的座標系稱爲Frenet Frame。於是這個貼圖座標系F就可以表示爲
F = [ T B N ]
只要知道F其中任意兩個分量,都可以求出第三個分量,它們之間有這樣的關係
T = B × N
B = N × T
N = T × B
爲了得到正確的效果,之前shader中的代碼就在做部分修改,爲了構成貼圖座標系,就要將法線和切線傳入shader中。
06vs.cg
void vs_main(float4 position : POSITION, float2 texCoord : TEXCOORD0, float3 normal : NORMAL, float3 tangent : TEXCOORD2,
out float4 oPosition : POSITION, out float2 oTexCoord : TEXCOORD0, // textrue coordinate out float3 oNormal : TEXCOORD1, // normal vector out float3 oTangent : TEXCOORD2, // tangent vector out float3 objPos : TEXCOORD3, // object space vetex
uniform float4x4 MVP) {
oPosition = mul(MVP, position);
oTexCoord = texCoord; oNormal = normal; oTangent = tangent; objPos = position.xyz;
} |
現在vertex shader中多傳入了兩個參數,tangent和normal,然後將它們傳入fragment shader,在fragment shader中,將光照計算要用到的所有向量都轉換到貼圖座標系中。於是之前的fragment shader只需要做以下的修改即可。
float3 T = tangent; float3 N = normal; float3 B = cross(N,T);
float3x3 M = float3x3(T,B,N);
float3 lightDir = normalize( mul(M, lightPosition-objPos) ); float3 eyeDir = normalize( mul(M, eyePosition - objPos) ); |
現在shader代碼已經修改,我們再次應用到程序中,這次我們增加一個牆面,使用2個牆面和一個地面,然後再來看看效果。
|
|
Fig6 法線貼圖用於不同平面
Fig7 法線貼圖於圓環
從Fig6和Fig7中可以看出,利用貼圖座標系的法線貼圖可以應用於各種幾何體。最後想說的一點的是,整個法線貼圖的過程都是基於fragment shader的,因爲我們要計算每個像素的顏色。於是在fragment shader中要涉及到單位化向量的操作,有的GPU並不支持在fragment shader使用normalize函數。於是有一種更快速更普遍的方法,那就是使用Normalization Cube Map,該方法是向fragment shader再傳入一個cube map,這樣就不必向fragment shader傳入光源位置和相機位置來計算光照需要的方向向量了,而是可以在vertex shader中計算每個頂點的關於光照計算所需要的方向向量,然後傳入fragment shader,利用函數texCUBE即可得到每個像素單位化後的相關向量。具體的實現方法大家可以google一下。
*原創文章,轉載請註明出處*