Normal Maping
法线贴图是一种特殊的texture,法线贴图中每个像素存储的不是rgb颜色值,而是该片元的法向量。
法向量可以通过模拟物体表面的明暗情况(只是改变了明暗而非在高度上做技巧,和高度贴图是不同的),使得渲染出来的物体更有凹凸感,看起来更为真实。同时性能消耗非常小
图1:
法线贴图的使用非常方便,只需要当做平常的texture一样对它进行采样,采样的值作为normal而非rgb。
下面是一个简单的GLSL Shader的例子。
#version 330 core
out vec4 fragcolor;
in SharedData
{
vec3 worldPos;
vec2 texcoord;
}fs_in;
uniform vec3 lightPos;
uniform vec3 viewPos;
uniform sampler2D diffuse_map;
uniform sampler2D normal_map;
void main()
{
vec3 color = texture(diffuse_map, fs_in.texcoord).rgb;
//采样的值作为normal
vec3 normal = texture(normal_map, fs_in.texcoord).rgb;
//为了把[0,1]的value映射到[-1,1]
normal = normalize(normal * 2.0 - 1.0);
vec3 lightDir = normalize(fs_in.worldPos - lightPos);
vec3 viewDir = normalize(fs_in.worldPos - viewPos);
//ambient
vec3 ambient = 0.2f * color;
//diffuse
float diff = dot(-lightDir, normal) * 0.5 + 0.5;
vec3 diffuse = diff * color;
//specular
vec3 half = -normalize(lightDir + viewDir);
float spec = pow(max(dot(normal, half), 0.0f), 32.0f);
vec3 specualr = spec * color;
fragcolor = vec4(ambient + diffuse + specualr, 1.0f);
}
渲染出来的物体很有凹凸感于是看起来更为真实:
图2:
(上图对应的法线贴图)
图3:
你所能找到的法线贴图几乎都是偏蓝色的,这是因为法向量几乎都是指向Z轴的正向的,也就是说在(0,0,1)之间波动,蓝色的分量更大一些所以整个图看起来是蓝色的。
到现在为止一切都很好,可是在学习法线贴图的时候,有一个无论如何都绕不开的概念称为切线空间。那么我们为什么还要自找麻烦呢?
Tangent Space
原由和概念
其实不是我们自找麻烦,是因为真的有很多麻烦要处理。
上面的例子最后渲染的结果很好是因为原来的图片中的砖块的法线向量恰好是指向Z轴的正向的,但是如果原图是一个坑坑洼洼的不规则复杂地形,例如下图所示:
图4:
表面的法向量用红色标识,如果有无数方向不同的法向量,难不成我们还得给一张原图制作无数张法线贴图吗?显然这不现实。
另一个稍微有点难的解决方案是,在一个不同的坐标空间中进行光照,这个坐标空间里,法线贴图向量总是指向这个坐标空间的正z方向;所有的光照向量都相对与这个正z方向进行变换。这样我们就能始终使用同样的法线贴图,不管朝向问题。这个坐标空间叫做切线空间(tangent space)。
法线贴图中的法线向量在切线空间中,法线永远指着正z方向。切线空间是位于三角形表面之上的空间(切线空间是顶点构造的一个局部坐标系,每一个三角面定义的切线空间是不一样的)因此,切线空间的一大好处是我们可以为任何类型的表面计算出一个这样的矩阵,由此我们可以把切线空间的z方向和表面的法线方向对齐。
我们需要定义一个空间基来描述这一空间,称为TBN矩阵。
TBN矩阵这三个字母分别代表tangent、bitangent和normal向量。这三个向量两两正交,它们沿一个表面的法线贴图对齐于:(B)上、(T)右、(N)前。
图5:
图6:
那么,对于某个给定了uv坐标的三角形面,我们有如下关系:
根据图5可知,我们想要的切线空间的B是平行于V,T是平行于U的。
设图6中:
又
写作矩阵的形式:
所以可知TB的结果是:
继续推导可知:
最后得出的结果进行施密特正交化即可。
下面是一个简单的计算过程的例子:
// positions
glm::vec3 pos1(-1.0, 1.0, 0.0);
glm::vec3 pos2(-1.0, -1.0, 0.0);
glm::vec3 pos3(1.0, -1.0, 0.0);
glm::vec3 pos4(1.0, 1.0, 0.0);
// texture coordinates
glm::vec2 uv1(0.0, 1.0);
glm::vec2 uv2(0.0, 0.0);
glm::vec2 uv3(1.0, 0.0);
glm::vec2 uv4(1.0, 1.0);
// normal vector
glm::vec3 nm(0.0, 0.0, 1.0);
// calculate tangent/bitangent vectors of both triangles
glm::vec3 tangent1, bitangent1;
glm::vec3 tangent2, bitangent2;
// - triangle 1
glm::vec3 edge1 = pos2 - pos1;
glm::vec3 edge2 = pos3 - pos1;
glm::vec2 deltaUV1 = uv2 - uv1;
glm::vec2 deltaUV2 = uv3 - uv1;
GLfloat f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);
tangent1.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tangent1.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tangent1.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
tangent1 = glm::normalize(tangent1);
bitangent1.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
bitangent1.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
bitangent1.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
bitangent1 = glm::normalize(bitangent1);
在TangentSpace还是WordSpace计算光照
现在假设我们已经有了TBN变换矩阵,在shader中我们应该怎么使用它呢?
有两种方法。
- 把所有在WorldSpace的点变换到TBN空间,在TBN计算光照。(这需要TBN的逆矩阵)
- 采样得到的法向量是在TBN空间,我们将这些法向量变换到WorldSpace计算光照。
(TBN矩阵可以将TangentSpace=>WorldSpace )
如果按照第二种办法,我们需要把TBN矩阵传到FragmentShader,然后采样NormalMap,采样得到的值乘以TBN变换到WorldSpace。剩下的工作就是在世界空间进行光照计算。
normal = texture(normalMap, fs_in.TexCoords).rgb;
normal = normalize(normal * 2.0 - 1.0);
normal = normalize(fs_in.TBN * normal);
这样看起来十分美妙也很符合我的逻辑,不过你需要在每次FragmentShader中都多做一次TBN变换到WorldSpace,有点耗费时间。
效率对于Shader来说十分重要。
所以我们选择第一种方法。
在VertexShader中我们计算TBN的逆矩阵,把顶点的TangentSpace坐标记录下来。由于VertexShader的调用次数一定少于FragmentShader,剩下的每个片元的TangentSpace坐标可以由差值自动获取。
它的代码看起来像这样:
#version 330 core
layout(location = 0) in vec3 pos;
layout(location = 1) in vec3 normal;
layout(location = 2) in vec2 texcoord;
layout(location = 3) in vec3 tangent;
layout(location = 4) in vec3 bitangent;
out SharedData
{
vec3 worldPos;
vec3 tangentLightPos;
vec3 tangentViewPos;
vec3 tangentPos;
vec2 texcoord;
}vs_out;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
uniform vec3 lightPos;
uniform vec3 viewPos;
void main()
{
gl_Position = projection * view * model * vec4(pos, 1.0f);
vs_out.worldPos = vec3(model * vec4(pos, 1.0f));
vs_out.texcoord = texcoord;
vec3 T = normalize(tangent);
vec3 B = normalize(bitangent);
vec3 N = normalize(normal);//应当始终保证n是指向z的正向,即N必须为(0, 0, 1)?
mat3 TBN = transpose(mat3(T,B,N));//正交矩阵的逆矩阵是逆矩阵
vs_out.tangentLightPos = TBN * lightPos;
vs_out.tangentViewPos = TBN * viewPos;
vs_out.tangentPos = TBN * vs_out.worldPos;
}
两种方法的最终展现的效果是一样的,但是效率方面第一种方法会更占优势。
图7: