# 第十三課:法線貼圖
歡迎來到第十三課!今天講法線貼圖(normal mapping)。
學完[第八課:基本光照模型](http://www.opengl-tutorial.org/beginners-tutorials/tutorial-8-basic-shading/)后,我們知道了如何用三角形法線得到不錯的光照效果。需要注意的是,截至目前,每個頂點僅有一個法線:在三角形三個頂點間,法線是平滑過渡的;而顏色(紋理的采樣)恰與此相反。
## 法線紋理
法線紋理看起來像這樣:

每個紋素的RGB值實際上表示的是XYZ向量:顏色的分量取值范圍為0到1,而向量的分量取值范圍是-1到1;可以建立從紋素到法線的簡單映射:
~~~
normal = (2*color)-1 // on each component
~~~
法線紋理整體呈藍色,因為法線基本是朝上的(上方即Z軸正向。OpenGL中Y軸=上,有所不同。這種不兼容很蠢,但沒人想為此重寫現有的工具,我們將就用吧。后面介紹詳情。)
法線紋理的映射方式和顏色紋理相似。麻煩的是如何將法線從各三角形局部坐標系(切線坐標系tangent space,亦稱圖像坐標系image space)變換到模型坐標系(計算光照采用的坐標系)。
## 切線和雙切線(Tangent and Bitangent)
想必大家對矩陣已經十分熟悉了;大家知道,定義一個坐標系(本例是切線坐標系)需要三個向量。現在Up向量已經有了,即法線:可用Blender計算,或做一個簡單的叉乘。下圖中藍色箭頭代表法線(法線貼圖整體顏色也恰好是藍色)。

然后是切線T:垂直于平面的向量。切線有很多個:

這么多切線中該選哪一個呢?理論上,任何一個都可以。不過我們得和相鄰頂點保持一致,以免導致邊緣出現瑕疵。一個通行的辦法是將切線方向和紋理坐標系對齊:

定義一組基需要三個向量,因此我們還得計算雙切線B(本來可以隨便選一條切線,但選定垂直于其他兩條軸的切線,計算會方便些)。

算法如下:若把三角形的兩條邊記為`deltaPos1`和`deltaPos2`,`deltaUV1`和`deltaUV2`是對應的UV坐標下的差值;此問題可用如下方程表示:
~~~
deltaPos1 = deltaUV1.x * T + deltaUV1.y * B
deltaPos2 = deltaUV2.x * T + deltaUV2.y * B
~~~
求解T和B就得到了切線和雙切線!(代碼見下文)
已知T、B、N向量之后,即可得下面這個漂亮的矩陣,完成從模型坐標系到切線坐標系的變換:

有了TBN矩陣,我們就能把法線(從法線紋理中提取而來)變換到模型坐標系。
可我們需要的卻是與之相反的變換:從切線坐標系到模型坐標系,法線保持不變。所有計算均在切線坐標系中進行,不會對其他計算產生影響。
既然要進行逆向的變換,那只需對以上矩陣求逆即可。這個矩陣(正交陣,即各向量相互正交,請看后面“延伸閱讀”小節)的逆矩陣恰好也就是其轉置矩陣,計算十分簡單:
~~~
invTBN = transpose(TBN)
~~~
亦即:

## 準備VBO
## 計算切線和雙切線
我們需要為整個模型計算切線、雙切線和法線。用一個單獨的函數完成這項工作:
~~~
void computeTangentBasis(
// inputs
std::vector<glm::vec3> & vertices,
std::vector<glm::vec2> & uvs,
std::vector<glm::vec3> & normals,
// outputs
std::vector<glm::vec3> & tangents,
std::vector<glm::vec3> & bitangents
){
~~~
為每個三角形計算邊(`deltaPos`)和`deltaUV`
~~~
for ( int i=0; i<vertices.size(); i+=3){
// Shortcuts for vertices
glm::vec3 & v0 = vertices[i+0];
glm::vec3 & v1 = vertices[i+1];
glm::vec3 & v2 = vertices[i+2];
// Shortcuts for UVs
glm::vec2 & uv0 = uvs[i+0];
glm::vec2 & uv1 = uvs[i+1];
glm::vec2 & uv2 = uvs[i+2];
// Edges of the triangle : postion delta
glm::vec3 deltaPos1 = v1-v0;
glm::vec3 deltaPos2 = v2-v0;
// UV delta
glm::vec2 deltaUV1 = uv1-uv0;
glm::vec2 deltaUV2 = uv2-uv0;
~~~
現在用公式來算切線和雙切線:
~~~
float r = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV1.y * deltaUV2.x);
glm::vec3 tangent = (deltaPos1 * deltaUV2.y - deltaPos2 * deltaUV1.y)*r;
glm::vec3 bitangent = (deltaPos2 * deltaUV1.x - deltaPos1 * deltaUV2.x)*r;
~~~
最后,把這些*切線*和*雙切線*緩存到數組。記住,還沒為這些緩存的數據生成索引,因此每個頂點都有一份拷貝。
~~~
// Set the same tangent for all three vertices of the triangle.
// They will be merged later, in vboindexer.cpp
tangents.push_back(tangent);
tangents.push_back(tangent);
tangents.push_back(tangent);
// Same thing for binormals
bitangents.push_back(bitangent);
bitangents.push_back(bitangent);
bitangents.push_back(bitangent);
}
~~~
## 生成索引
索引VBO的方法和之前類似,僅有些許不同。
若找到一個相似頂點(相同的坐標、法線、紋理坐標),我們不使用它的切線、次法線;反而要取其均值。因此,只需把舊代碼修改一下:
~~~
// Try to find a similar vertex in out_XXXX
unsigned int index;
bool found = getSimilarVertexIndex(in_vertices[i], in_uvs[i], in_normals[i], out_vertices, out_uvs, out_normals, index);
if ( found ){ // A similar vertex is already in the VBO, use it instead !
out_indices.push_back( index );
// Average the tangents and the bitangents
out_tangents[index] += in_tangents[i];
out_bitangents[index] += in_bitangents[i];
}else{ // If not, it needs to be added in the output data.
// Do as usual
[...]
}
~~~
注意,這里沒做規范化。這樣做很討巧,因為小三角形的切線、雙切線向量也小;相對于大三角形(對最終形狀影響較大),對最終結果的影響力也就小。
## Shader
## 新增的緩沖區和uniform變量
新加上兩個緩沖區:分別存放切線和雙切線:
~~~
GLuint tangentbuffer;
glGenBuffers(1, &tangentbuffer);
glBindBuffer(GL_ARRAY_BUFFER, tangentbuffer);
glBufferData(GL_ARRAY_BUFFER, indexed_tangents.size() * sizeof(glm::vec3), &indexed_tangents[0], GL_STATIC_DRAW);
GLuint bitangentbuffer;
glGenBuffers(1, &bitangentbuffer);
glBindBuffer(GL_ARRAY_BUFFER, bitangentbuffer);
glBufferData(GL_ARRAY_BUFFER, indexed_bitangents.size() * sizeof(glm::vec3), &indexed_bitangents[0], GL_STATIC_DRAW);
~~~
還需要一個uniform變量存儲新的法線紋理:
~~~
[...]
GLuint NormalTexture = loadTGA_glfw("normal.tga");
[...]
GLuint NormalTextureID = glGetUniformLocation(programID, "NormalTextureSampler");
~~~
另外一個uniform變量存儲3x3的模型視圖矩陣。嚴格地講,這個矩陣不必要,但有它更方便;詳見后文。由于僅僅計算旋轉,不需要位移,因此只需矩陣左上角3x3的部分。
~~~
GLuint ModelView3x3MatrixID = glGetUniformLocation(programID, "MV3x3");
~~~
完整的繪制代碼如下:
~~~
// Clear the screen
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// Use our shader
glUseProgram(programID);
// Compute the MVP matrix from keyboard and mouse input
computeMatricesFromInputs();
glm::mat4 ProjectionMatrix = getProjectionMatrix();
glm::mat4 ViewMatrix = getViewMatrix();
glm::mat4 ModelMatrix = glm::mat4(1.0);
glm::mat4 ModelViewMatrix = ViewMatrix * ModelMatrix;
glm::mat3 ModelView3x3Matrix = glm::mat3(ModelViewMatrix); // Take the upper-left part of ModelViewMatrix
glm::mat4 MVP = ProjectionMatrix * ViewMatrix * ModelMatrix;
// Send our transformation to the currently bound shader,
// in the "MVP" uniform
glUniformMatrix4fv(MatrixID, 1, GL_FALSE, &MVP[0][0]);
glUniformMatrix4fv(ModelMatrixID, 1, GL_FALSE, &ModelMatrix[0][0]);
glUniformMatrix4fv(ViewMatrixID, 1, GL_FALSE, &ViewMatrix[0][0]);
glUniformMatrix4fv(ViewMatrixID, 1, GL_FALSE, &ViewMatrix[0][0]);
glUniformMatrix3fv(ModelView3x3MatrixID, 1, GL_FALSE, &ModelView3x3Matrix[0][0]);
glm::vec3 lightPos = glm::vec3(0,0,4);
glUniform3f(LightID, lightPos.x, lightPos.y, lightPos.z);
// Bind our diffuse texture in Texture Unit 0
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, DiffuseTexture);
// Set our "DiffuseTextureSampler" sampler to user Texture Unit 0
glUniform1i(DiffuseTextureID, 0);
// Bind our normal texture in Texture Unit 1
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, NormalTexture);
// Set our "Normal TextureSampler" sampler to user Texture Unit 0
glUniform1i(NormalTextureID, 1);
// 1rst attribute buffer : vertices
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, vertexbuffer);
glVertexAttribPointer(
0, // attribute
3, // size
GL_FLOAT, // type
GL_FALSE, // normalized?
0, // stride
(void*)0 // array buffer offset
);
// 2nd attribute buffer : UVs
glEnableVertexAttribArray(1);
glBindBuffer(GL_ARRAY_BUFFER, uvbuffer);
glVertexAttribPointer(
1, // attribute
2, // size
GL_FLOAT, // type
GL_FALSE, // normalized?
0, // stride
(void*)0 // array buffer offset
);
// 3rd attribute buffer : normals
glEnableVertexAttribArray(2);
glBindBuffer(GL_ARRAY_BUFFER, normalbuffer);
glVertexAttribPointer(
2, // attribute
3, // size
GL_FLOAT, // type
GL_FALSE, // normalized?
0, // stride
(void*)0 // array buffer offset
);
// 4th attribute buffer : tangents
glEnableVertexAttribArray(3);
glBindBuffer(GL_ARRAY_BUFFER, tangentbuffer);
glVertexAttribPointer(
3, // attribute
3, // size
GL_FLOAT, // type
GL_FALSE, // normalized?
0, // stride
(void*)0 // array buffer offset
);
// 5th attribute buffer : bitangents
glEnableVertexAttribArray(4);
glBindBuffer(GL_ARRAY_BUFFER, bitangentbuffer);
glVertexAttribPointer(
4, // attribute
3, // size
GL_FLOAT, // type
GL_FALSE, // normalized?
0, // stride
(void*)0 // array buffer offset
);
// Index buffer
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, elementbuffer);
// Draw the triangles !
glDrawElements(
GL_TRIANGLES, // mode
indices.size(), // count
GL_UNSIGNED_INT, // type
(void*)0 // element array buffer offset
);
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glDisableVertexAttribArray(2);
glDisableVertexAttribArray(3);
glDisableVertexAttribArray(4);
// Swap buffers
glfwSwapBuffers();
~~~
## Vertex shader
和前面講的一樣,所有計算都在觀察坐標系中做,因為在這獲取片斷坐標更容易。這就是為什么要用模型視圖矩陣乘T、B、N向量。
~~~
vertexNormal_cameraspace = MV3x3 * normalize(vertexNormal_modelspace);
vertexTangent_cameraspace = MV3x3 * normalize(vertexTangent_modelspace);
vertexBitangent_cameraspace = MV3x3 * normalize(vertexBitangent_modelspace);
~~~
這三個向量確定了TBN矩陣,其創建方式如下:
~~~
mat3 TBN = transpose(mat3(
vertexTangent_cameraspace,
vertexBitangent_cameraspace,
vertexNormal_cameraspace
)); // You can use dot products instead of building this matrix and transposing it. See References for details.
~~~
此矩陣是從觀察坐標系到切線坐標系的變換(若有一矩陣名為`XXX_modelspace`,則它執行的是從模型坐標系到切線坐標系的變換)。可以利用它計算切線坐標系中的光線方向和視線方向。
~~~
LightDirection_tangentspace = TBN * LightDirection_cameraspace;
EyeDirection_tangentspace = TBN * EyeDirection_cameraspace;
~~~
## Fragment shader
切線坐標系中的法線很容易獲取:就在紋理中:
~~~
// Local normal, in tangent space
vec3 TextureNormal_tangentspace = normalize(texture2D( NormalTextureSampler, UV ).rgb*2.0 - 1.0);
~~~
一切準備就緒。漫反射光的值由切線坐標系中的n和l計算得來(在哪個坐標系中計算并不重要,重要的是n和l必須位于同一坐標系中),再用*clamp( dot( n,l ), 0,1 )*截斷。鏡面光用*clamp( dot( E,R ), 0,1 )*截斷,E和R也必須位于同一坐標系中。搞定!S
## 結果
這是目前得到的結果,可以看到:
- 磚塊看上去凹凸不平,這是因為磚塊表面法線變化比較劇烈
- 水泥部分看上去很平整,這是因為這部分的法線紋理都是整齊的藍色

## 延伸閱讀
## 正交化(Orthogonalization)
Vertex shader中,為了計算得更快,我們沒有用矩陣求逆,而是進行了轉置。這只有當矩陣表示的坐標系是正交的時候才成立,而眼前這個矩陣還不是正交的。幸運的是這個問題很容易解決:只需在`computeTangentBasis()`末尾讓切線與法線垂直。I
~~~
t = glm::normalize(t - n * glm::dot(n, t));
~~~
這個公式有點難理解,來看看圖:

n和t差不多是相互垂直的,只要把`t`沿`-n`方向稍微“壓”一下,這個幅度是`dot(n,t)`。[這里](http://www.cse.illinois.edu/iem/least_squares/gram_schmidt/)有一個applet也講得很清楚(僅含兩個向量)
## 左手坐標系還是右手坐標系?
一般不必擔心這個問題。但在某些情況下,比如使用對稱模型時,UV坐標方向是錯的,導致切線T方向錯誤。
檢查是否需要翻轉這些方向很容易:TBN必須形成一個右手坐標系,即,向量`cross(n,t)`應該和b同向。
用數學術語講,“向量A和向量B同向”就是“`dot(A,B)>0`”;故只需檢查`dot( cross(n,t) , b )`是否大于0。
若`dot( cross(n,t) , b ) < 0`,就要翻轉`t`:
~~~
if (glm::dot(glm::cross(n, t), b) < 0.0f){
t = t * -1.0f;
}
~~~
在`computeTangentBasis()`末對每個頂點都做這個操作。
## 高光紋理(Specular texture)
純粹出于樂趣,我在代碼里加上了高光紋理;取代了原先作為高光顏色的灰色`vec3(0.3,0.3,0.3)`,現在看起來像這樣:


注意,現在水泥部分始終是黑色的:因為高光紋理中,其高光分量為0。
## 用立即模式進行調試
本站的初衷是讓大家**不再**使用過時、緩慢、問題頻出的立即模式。
不過,用立即模式進行調試卻十分方便:

這里,我們在立即模式下畫了一些線條表示切線坐標系。
要進入立即模式,得關閉3.3 Core Profile:
~~~
glfwOpenWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_COMPAT_PROFILE);
~~~
然后把矩陣傳給舊式的OpenGL流水線(你也可以另寫一個著色器,不過這樣做更簡單,反正都是在hacking):
~~~
glMatrixMode(GL_PROJECTION);
glLoadMatrixf((const GLfloat*)&ProjectionMatrix[0]);
glMatrixMode(GL_MODELVIEW);
glm::mat4 MV = ViewMatrix * ModelMatrix;
glLoadMatrixf((const GLfloat*)&MV[0]);
~~~
禁用著色器:
~~~
glUseProgram(0);
~~~
然后畫線條(本例中法線都已被歸一化,乘了0.1,放到了對應頂點上):
~~~
glColor3f(0,0,1);
glBegin(GL_LINES);
for (int i=0; i<indices.size(); i++){
glm::vec3 p = indexed_vertices[indices[i]];
glVertex3fv(&p.x);
glm::vec3 o = glm::normalize(indexed_normals[indices[i]]);
p+=o*0.1f;
glVertex3fv(&p.x);
}
glEnd();
~~~
記住:實際項目中不要用立即模式!只在調試時用!別忘了之后恢復到Core Profile,它可以保證不會啟用立即模式!
## 用顏色進行調試
調試時,將向量的值可視化很有用。最簡單的方法是把向量都寫到幀緩沖區。舉個例子,我們把`LightDirection_tangentspace`可視化一下試試
~~~
color.xyz = LightDirection_tangentspace;
~~~

這說明:
-
在圓柱體的右側,光線(如白色線條所示)是朝上(在切線坐標系中)的。也就是說,光線和三角形的法線同向。
-
在圓柱體的中間部分,光線和切線方向(指向+X)同向。
友情提示:
- 可視化前,變量是否需要規范化?這取決于具體情況。
- 如果結果不好看懂,就逐分量地可視化。比如,只觀察紅色,而將綠色和藍色分量強制設為0。
- 別折騰alpha值,太復雜了
>
- 若想將一個負值可視化,可以采用和處理法線紋理一樣的技巧:轉而把`(v+1.0)/2.0`可視化,于是黑色就代表-1,而白色代表+1。只不過這樣做有點繞彎子。
## 用變量名進行調試
前面已經講過了,搞清楚向量所處的坐標系至關重要。千萬別把一個觀察坐標系里的向量和一個模型坐標系里的向量做點乘。
給向量名稱添加后綴“_modelspace”可以有效地避免這類計算錯誤。
## 怎樣制作法線貼圖
作者James O’Hare。點擊圖片放大。

## 練習
- 在`indexVBO_TBN`函數中,在做加法前把向量歸一化,看看結果。
- 用顏色可視化其他向量(如`instance`、`EyeDirection_tangentspace`),試著解釋你看到的結果。
## 工具和鏈接
- [Crazybump](http://www.crazybump.com/) 制作法線紋理的好工具,收費。
- [Nvidia photoshop插件](http://developer.nvidia.com/nvidia-texture-tools-adobe-photoshop)免費,不過Photoshop不免費……
- [用多幅照片制作法線貼圖](http://www.zarria.net/nrmphoto/nrmphoto.html)
- [用單幅照片制作法線貼圖](http://www.katsbits.com/tutorials/textures/making-normal-maps-from-photographs.php)
- 關于[矩陣轉置](http://www.katjaas.nl/transpose/transpose.html)的詳細資料
## 參考文獻
- [Lengyel, Eric. “Computing Tangent Space Basis Vectors for an Arbitrary Mesh”. Terathon Software 3D Graphics Library, 2001.](http://www.terathon.com/code/tangent.html)
- [Real Time Rendering, third edition](http://www.amazon.com/dp/1568814240)
- [ShaderX4](http://www.amazon.com/dp/1584504250)