OpenGL核心技术之切线空间

笔者介绍:姜雪伟,IT公司技术合伙人,IT高级讲师,CSDN社区专家,特邀编辑,畅销书作者;已出版书籍:《手把手教你架构3D游戏引擎》电子工业出版社和《Unity3D实战核心技术详解》电子工业出版社等。

CSDN视频网址:http://edu.csdn.net/lecturer/144

继续接着OpenGL核心技术之法线贴图介绍,法线贴图中的法线向量在切线空间中,法线永远指着正z方向。切线空间是位于三角形表面之上的空间:法线相对于单个三角形的本地参考框架。它就像法线贴图向量的本地空间;它们都被定义为指向正z方向,无论最终变换到什么方向。使用一个特定的矩阵我们就能将本地/切线空寂中的法线向量转成世界或视图坐标,使它们转向到最终的贴图表面的方向。

我们可以说,上个部分那个朝向正y的法线贴图错误的贴到了表面上。法线贴图被定义在切线空间中,所以一种解决问题的方式是计算出一种矩阵,把法线从切线空间变换到一个不同的空间,这样它们就能和表面法线方向对齐了:法线向量都会指向正y方向。切线空间的一大好处是我们可以为任何类型的表面计算出一个这样的矩阵,由此我们可以把切线空间的z方向和表面的法线方向对齐。

这种矩阵叫做TBN矩阵这三个字母分别代表tangent、bitangent和normal向量。这是建构这个矩阵所需的向量。要建构这样一个把切线空间转变为不同空间的变异矩阵,我们需要三个相互垂直的向量,它们沿一个表面的法线贴图对齐于:上、右、前;已知上向量是表面的法线向量。右和前向量是切线(Tagent)和副切线(Bitangent)向量。下面的图片展示了一个表面的三个向量:

OpenGL核心技术之切线空间_第1张图片

计算出切线和副切线并不像法线向量那么容易。从图中可以看到法线贴图的切线和副切线与纹理坐标的两个方向对齐。我们就是用到这个特性计算每个表面的切线和副切线的。需要用到一些数学才能得到它们;请看下图:

OpenGL核心技术之切线空间_第2张图片

上图中我们可以看到边E2纹理坐标的不同,E2是一个三角形的边,这个三角形的另外两条边是ΔU2ΔV2,它们与切线向量T和副切线向量B方向相同。这样我们可以把边E1E2用切线向量T和副切线向量B的线性组合表示出来(注意TB都是单位长度,在TB平面中所有点的T,B坐标都在0到1之间,因此可以进行这样的组合):


我们也可以写成这样:


E是两个向量位置的差,ΔUΔV是纹理坐标的差。然后我们得到两个未知数(切线T和副切线B)和两个等式。你可能想起你的代数课了,这是让我们去接TB

上面的方程允许我们把它们写成另一种格式:矩阵乘法


尝试会意一下矩阵乘法,它们确实是同一种等式。把等式写成矩阵形式的好处是,解TB会因此变得很容易。两边都乘以ΔUΔV的逆矩阵等于:


这样我们就可以解出TB了。这需要我们计算出delta纹理坐标矩阵的拟阵。我不打算讲解计算逆矩阵的细节,但大致是把它变化为,1除以矩阵的行列式,再乘以它的共轭矩阵。


有了最后这个等式,我们就可以用公式、三角形的两条边以及纹理坐标计算出切线向量T和副切线B

如果你对这些数学内容不理解也不用担心。当你知道我们可以用一个三角形的顶点和纹理坐标(因为纹理坐标和切线向量在同一空间中)计算出切线和副切线你就已经部分地达到目的了(注意:上面的推导已经很清楚了,如果你不明白可以参考任意线性代数教材,记住求得切线空

void main()
{
   [...]
   vec3 T = normalize(vec3(model * vec4(tangent,   0.0)));
   vec3 B = normalize(vec3(model * vec4(bitangent, 0.0)));
   vec3 N = normalize(vec3(model * vec4(normal,    0.0)));
   mat3 TBN = mat3(T, B, N)
}

间的公式也行,不过不管怎样都得理解切线空间的含义)。

下面我们来手工计算出表面的切线和副切线向量。假设平面使用下面的向量建立起来(1、2、3和1、3、4,它们是两个三角形):

// 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);

我们先计算第一个三角形的边和deltaUV坐标:

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);  

[...] // similar procedure for calculating tangent/bitangent for plane's second triangle

我们预先计算出等式的分数部分f,然后把它和每个向量的元素进行相应矩阵乘法。如果你把代码和最终的等式对比你会发现,这就是直接套用。最后我们还要进行标准化,来确保切线/副切线向量最后是单位向量。

因为一个三角形永远是平坦的形状,我们只需为每个三角形计算一个切线/副切线,它们对于每个三角形上的顶点都是一样的。要注意的是大多数实现通常三角形和三角形之间都会共享顶点。这种情况下开发者通常将每个顶点的法线和切线/副切线等顶点属性平均化,以获得更加柔和的效果。我们的平面的三角形之间分享了一些顶点,但是因为两个三角形相互并行,因此并不需要将结果平均化,但无论何时只要你遇到这种情况记住它就是件好事。

最后的切线和副切线向量的值应该是(1, 0, 0)和(0, 1, 0),它们和法线(0, 0, 1)组成相互垂直的TBN矩阵。在平面上显示出来TBN应该是这样的:

OpenGL核心技术之切线空间_第3张图片
每个顶点定义了切线和副切线向量,我们就可以开始实现正确的法线贴图了。为让法线贴图工作,我们先得在着色器中创建一个TBN矩阵。我们先将前面计算出来的切线和副切线向量传给顶点着色器,作为它的属性:

#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;
layout (location = 3) in vec3 tangent;
layout (location = 4) in vec3 bitangent;
在顶点着色器的main函数中我们创建TBN矩阵:

void main()
{
   [...]
   vec3 T = normalize(vec3(model * vec4(tangent,   0.0)));
   vec3 B = normalize(vec3(model * vec4(bitangent, 0.0)));
   vec3 N = normalize(vec3(model * vec4(normal,    0.0)));
   mat3 TBN = mat3(T, B, N)
}

我们先将所有TBN向量变换到我们所操作的坐标系中,现在是世界空间,我们可以乘以model矩阵。然后我们创建实际的TBN矩阵,直接把相应的向量应用到mat3构造器就行。注意,如果我们希望更精确的话就不要讲TBN向量乘以model矩阵,而是使用法线矩阵,但我们只关心向量的方向,不会平移也和缩放这个变换。

从技术上讲,顶点着色器中无需副切线。所有的这三个TBN向量都是相互垂直的所以我们可以在顶点着色器中庸T和N向量的叉乘,自己计算出副切线:vec3 B = cross(T, N); 现在我们有了TBN矩阵,如果来使用它呢?基本有两种方式可以使用,我们会把这两种方式都说明一下:

我们可以用TBN矩阵把所有向量从切线空间转到世界空间,传给像素着色器,然后把采样得到的法线用TBN矩阵从切线空间变换到世界空间;法线就处于和其他光照变量一样的空间中了。 我们用TBN的逆矩阵把所有世界空间的向量转换到切线空间,使用这个矩阵将除法线以外的所有相关光照变量转换到切线空间中;这样法线也能和其他光照变量处于同一空间之中。 我们来看看第一种情况。我们从法线贴图重采样得来的法线向量,是以切线空间表达的,尽管其他光照向量是以世界空间表达的。把TBN传给像素着色器,我们就能将采样得来的切线空间的法线乘以这个TBN矩阵,将法线向量变换到和其他光照向量一样的参考空间中。这种方式随后所有光照计算都可以简单的理解。

把TBN矩阵发给像素着色器很简单:

out VS_OUT {
    vec3 FragPos;
    vec2 TexCoords;
    mat3 TBN;
} vs_out;  

void main()
{
    [...]
    vs_out.TBN = mat3(T, B, N);
}
在像素着色器中我们用mat3作为输入变量:

in VS_OUT {
    vec3 FragPos;
    vec2 TexCoords;
    mat3 TBN;
} fs_in;
有了TBN矩阵我们现在就可以更新法线贴图代码,引入切线到世界空间变换:

normal = texture(normalMap, fs_in.TexCoords).rgb;
normal = normalize(normal * 2.0 - 1.0);   
normal = normalize(fs_in.TBN * normal);

因为最后的normal现在在世界空间中了,就不用改变其他像素着色器的代码了,因为光照代码就是假设法线向量在世界空间中。

我们同样看看第二种情况,我们用TBN矩阵的逆矩阵将所有相关的世界空间向量转变到采样所得法线向量的空间:切线空间。TBN的建构还是一样,但我们在将其发送给像素着色器之前先要求逆矩阵:

vs_out.TBN = transpose(mat3(T, B, N));

注意,这里我们使用transpose函数,而不是inverse函数。正交矩阵(每个轴既是单位向量同时相互垂直)的一大属性是一个正交矩阵的置换矩阵与它的逆矩阵相等。这个属性和重要因为逆矩阵的求得比求置换开销大;结果却是一样的。

在像素着色器中我们不用对法线向量变换,但我们要把其他相关向量转换到切线空间,它们是lightDir和viewDir。这样每个向量还是在同一个空间(切线空间)中了。

void main()
{           
    vec3 normal = texture(normalMap, fs_in.TexCoords).rgb;
    normal = normalize(normal * 2.0 - 1.0);   

    vec3 lightDir = fs_in.TBN * normalize(lightPos - fs_in.FragPos);
    vec3 viewDir  = fs_in.TBN * normalize(viewPos - fs_in.FragPos);    
    [...]
}

第二种方法看似要做的更多,它还需要在像素着色器中进行更多的乘法操作,所以为何还用第二种方法呢?

将向量从世界空间转换到切线空间有个额外好处,我们可以把所有相关向量在顶点着色器中转换到切线空间,不用在像素着色器中做这件事。这是可行的,因为lightPos和viewPos不是每个fragment运行都要改变,对于fs_in.FragPos,我们也可以在顶点着色器计算它的切线空间位置。基本上,不需要把任何向量在像素着色器中进行变换,而第一种方法中就是必须的,因为采样出来的法线向量对于每个像素着色器都不一样。

所以现在不是把TBN矩阵的逆矩阵发送给像素着色器,而是将切线空间的光源位置,观察位置以及顶点位置发送给像素着色器。这样我们就不用在像素着色器里进行矩阵乘法了。这是一个极佳的优化,因为顶点着色器通常比像素着色器运行的少。这也是为什么这种方法是一种更好的实现方式的原因。

out VS_OUT {
    vec3 FragPos;
    vec2 TexCoords;
    vec3 TangentLightPos;
    vec3 TangentViewPos;
    vec3 TangentFragPos;
} vs_out;

uniform vec3 lightPos;
uniform vec3 viewPos;

[...]

void main()
{    
    [...]
    mat3 TBN = transpose(mat3(T, B, N));
    vs_out.TangentLightPos = TBN * lightPos;
    vs_out.TangentViewPos  = TBN * viewPos;
    vs_out.TangentFragPos  = TBN * vec3(model * vec4(position, 0.0));
}

在像素着色器中我们使用这些新的输入变量来计算切线空间的光照。因为法线向量已经在切线空间中了,光照就有意义了。

将法线贴图应用到切线空间上,我们会得到混合教程一开始那个例子相似的结果,但这次我们可以将平面朝向各个方向,光照一直都会是正确的:

glm::mat4 model;
model = glm::rotate(model, (GLfloat)glfwGetTime() * -10, glm::normalize(glm::vec3(1.0, 0.0, 1.0)));
glUniformMatrix4fv(modelLoc 1, GL_FALSE, glm::value_ptr(model));
RenderQuad();
看起来是正确的法线贴图:
OpenGL核心技术之切线空间_第4张图片

最后把实现的源代码给读者展示一下,顶点着色器代码如下所示:

#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;
layout (location = 3) in vec3 tangent;
layout (location = 4) in vec3 bitangent;

out VS_OUT {
    vec3 FragPos;
    vec2 TexCoords;
    vec3 TangentLightPos;
    vec3 TangentViewPos;
    vec3 TangentFragPos;
} vs_out;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;

uniform vec3 lightPos;
uniform vec3 viewPos;

void main()
{
    gl_Position = projection * view * model * vec4(position, 1.0f);
    vs_out.FragPos = vec3(model * vec4(position, 1.0));   
    vs_out.TexCoords = texCoords;
    
    mat3 normalMatrix = transpose(inverse(mat3(model)));
    vec3 T = normalize(normalMatrix * tangent);
    vec3 B = normalize(normalMatrix * bitangent);
    vec3 N = normalize(normalMatrix * normal);    
    
    mat3 TBN = transpose(mat3(T, B, N));  
    vs_out.TangentLightPos = TBN * lightPos;
    vs_out.TangentViewPos  = TBN * viewPos;
    vs_out.TangentFragPos  = TBN * vs_out.FragPos;
}

片段着色器代码如下所示:

#version 330 core
out vec4 FragColor;

in VS_OUT {
    vec3 FragPos;
    vec2 TexCoords;
    vec3 TangentLightPos;
    vec3 TangentViewPos;
    vec3 TangentFragPos;
} fs_in;

uniform sampler2D diffuseMap;
uniform sampler2D normalMap;

uniform bool normalMapping;

void main()
{           
    // Obtain normal from normal map in range [0,1]
    vec3 normal = texture(normalMap, fs_in.TexCoords).rgb;
    // Transform normal vector to range [-1,1]
    normal = normalize(normal * 2.0 - 1.0);  // this normal is in tangent space

    // Get diffuse color
    vec3 color = texture(diffuseMap, fs_in.TexCoords).rgb;
    // Ambient
    vec3 ambient = 0.1 * color;
    // Diffuse
    vec3 lightDir = normalize(fs_in.TangentLightPos - fs_in.TangentFragPos);
    float diff = max(dot(lightDir, normal), 0.0);
    vec3 diffuse = diff * color;
    // Specular
    vec3 viewDir = normalize(fs_in.TangentViewPos - fs_in.TangentFragPos);
    vec3 reflectDir = reflect(-lightDir, normal);
    vec3 halfwayDir = normalize(lightDir + viewDir);  
    float spec = pow(max(dot(normal, halfwayDir), 0.0), 32.0);
    vec3 specular = vec3(0.2) * spec;
    
    FragColor = vec4(ambient + diffuse + specular, 1.0f);
}

关于法线贴图还有最后一个技巧要讨论,它可以在不必花费太多性能开销的情况下稍稍提升画质表现。

当在更大的网格上计算切线向量的时候,它们往往有很大数量的共享顶点,当发下贴图应用到这些表面时将切线向量平均化通常能获得更好更平滑的结果。这样做有个问题,就是TBN向量可能会不能互相垂直,这意味着TBN矩阵不再是正交矩阵了。法线贴图可能会稍稍偏移,但这仍然可以改进。

使用叫做格拉姆-施密特正交化过程(Gram-Schmidt process)的数学技巧,我们可以对TBN向量进行重正交化,这样每个向量就又会重新垂直了。在顶点着色器中我们这样做:

vec3 T = normalize(vec3(model * vec4(tangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(tangent, 0.0)));
// re-orthogonalize T with respect to N
T = normalize(T - dot(T, N) * N);
// then retrieve perpendicular vector B with the cross product of T and N
vec3 B = cross(T, N);

mat3 TBN = mat3(T, B, N)

这样稍微花费一些性能开销就能对法线贴图进行一点提升。

你可能感兴趣的:(3D引擎,图形学编程,3d渲染,OpenGL,切线空间)