目录
一 引入
二 TBN矩阵
三 代码实现
3.1手工计算切线和副切线
3.2 像素着色器
3.3 切线空间的两种使用方法
3.4 渲染效果
四 复杂的物体
本章节源码点击此处
继上篇法线贴图 来熟悉切线空间是再好不过的。对于法线贴图来说,我们知道它就是一个2D的颜色纹理,根据rgb来映射法线对应的xyz,从而达到在同一个平面上有多个不同方向法线的效果,这样就能根据光照的计算结果不同,从而得到凹凸不平(或者说更加细节)的平面。
一 引入
- 我们可以尝试看下面这张图,由于我们的法线贴图中的rgb是固定的,也就是比如原来大多数是指向正z轴方向的法线,对于一个面向正z轴的平面来说是没有问题的,但是如果我们现在要在一个面向正y轴方向的屏幕也采用这个纹理贴图呢?还能够使用这个原有的法线贴图吗?
- 光照看起来完全不对!发生这种情况是平面的表面法线现在指向了y,而采样得到的法线仍然指向的是z。结果就是光照仍然认为表面法线和之前朝向正z方向时一样;这样光照就不对了。
- 有一种方案是要想正确的实现光照效果(也就是正确的法线),那么无非就是为每个单独的平面制作一个单独的法线贴图。如果是一个立方体的话我们就需要6个法线贴图,但是如果模型上有无数的朝向不同方向的表面,这就会变得极其复杂并且繁琐,无论是纹理制作者和使用者可能都容易出错。
- 另一种方案就是,我们在计算光照时不在原有的世界坐标来计算,而是对于这个单独的平面的空间来计算,也就是我们想办法让坐标都变换到这个表平面的空间中。这个坐标空间你也可以理解为纹理空间,我们把纹理空间对应的UV(也就是xy)映射到这个坐标空间里,然后在这个空间中取出每个像素点的颜色值,这样法线贴图向量总是指向这个坐标空间的正z方向;所有的光照向量都相对与这个正z方向进行变换。我们就能始终使用同样的法线贴图,不管朝向问题。这个坐标空间叫做切线空间。
二 TBN矩阵
- 法线贴图中的法线向量并不都指向切线空间的正Z方向。实际上,法线贴图中的每个像素代表的是该点在切线空间中的一个法线向量,这个向量可以指向任意方向,用来表示模型表面在那个点上的微小凸起或凹陷方向。
- 我们需要使用一个特定的矩阵将世界坐标切换到切线空间坐标中,同时也可以使用这个矩阵的逆矩阵将切线空间坐标切换回世界坐标中。
- 这种矩阵叫做TBN矩阵这三个字母分别代表tangent、bitangent和normal向量。TBN矩阵主要用于将不同的向量(如光照方向、视线方向等)从一个空间(通常是世界空间或模型空间)转换到切线空间。或者相互转换。这样做的目的是使光照计算能够在与法线贴图中存储的法线相匹配的坐标系中进行,因为法线贴图中的法线是在切线空间中定义的。,我们需要三个相互垂直的向量,它们沿一个表面的法线贴图对齐于:上、右、前;
- 简单来说:TBN矩阵可以实现切线空间与模型空间/世界坐标相互转换。这取决于你生成TBN矩阵时所用的坐标系。
- T:切向量 Tangent
- B:副切向量 Bitangent
- N:法向量 Normal
- P1,P2,P3纹理中的UV坐标(也就是纹理坐标),而E1和E2就是两个顶点之间的位置坐标
- 注意图中边E2与纹理坐标的差ΔU2、Δ𝑉2构成一个三角形。Δ𝑈2与切线向量T𝑇方向相同,而ΔV2与副切线向量B方向相同。这也就是说,所以我们可以将三角形的边E1与E2写成切线向量T和副切线向量B的线性组合:
- 具体的推导就需要线性代数的知识了,而实际最终我们的开发并不会自己计算,而是利用接口
- 最终计算的TBN矩阵的推导公式如下。
- 当我们知道TBN矩阵的任意两个坐标轴时,另一个都可以通过叉乘得到。
三 代码实现
我们使用的场景是一个简单的2D平面,但能实现其原理。
3.1手工计算切线和副切线
我们仍然使用之前的法线贴图,但是此时我们把顶点的坐标改变,也就是说让这个面面向y轴,
- 首先生成4个顶点也就是组成两个三角形,以及对应的纹理坐标和法线值
- 至于为什么要传入顶点的法线值,是因为对于这个平面来说,由于我们是在顶点着色器中计算使用的TBN矩阵,所以这个法线是相对准确的。
// 首先准备4个顶点, 其实是两个三角形(两个面)
QVector3D pos1(-1.0f, 0.0f, -1.0f);
QVector3D pos2(-1.0f, 0.0f, 1.0f);
QVector3D pos3( 1.0f, 0.0f, 1.0f);
QVector3D pos4( 1.0f, 0.0f, -1.0f);
// 准备对应的纹理坐标
QVector2D uv1(0.0f, 1.0f);
QVector2D uv2(0.0f, 0.0f);
QVector2D uv3(1.0f, 0.0f);
QVector2D uv4(1.0f, 1.0f);
// 法线 这个法线是因为我们是在顶点着色器里面使用的TBN矩阵 所以这个法线应该是准确的
QVector3D nm(0.0f, 1.0f, 0.0f);
- 接下来就是按照上面的公式来生成TB向量了
// 先准备两个平面的TB向量,需要分开计算
QVector3D tangent1, bitangent1;
QVector3D tangent2, bitangent2;
// 第一个三角形
QVector3D edge1 = pos2 - pos1;
QVector3D edge2 = pos3 - pos1;
QVector2D deltaUV1 = uv2 - uv1;
QVector2D deltaUV2 = uv3 - uv1;
// 先计算矩阵前面的系数
float f = 1.0f / (deltaUV1.x() * deltaUV2.y() - deltaUV2.x() * deltaUV1.y());
// 生成TB向量
tangent1.setX(f * (deltaUV2.y() * edge1.x() - deltaUV1.y() * edge2.x()));
tangent1.setY(f * (deltaUV2.y() * edge1.y() - deltaUV1.y() * edge2.y()));
tangent1.setZ(f * (deltaUV2.y() * edge1.z() - deltaUV1.y() * edge2.z()));
bitangent1.setX(f * (-deltaUV2.x() * edge1.x() + deltaUV1.x() * edge2.x()));
bitangent1.setY(f * (-deltaUV2.x() * edge1.y() + deltaUV1.x() * edge2.y()));
bitangent1.setZ(f * (-deltaUV2.x() * edge1.z() + deltaUV1.x() * edge2.z()));
// 第二个三角形计算方法同上
edge1 = pos3 - pos1;
edge2 = pos4 - pos1;
deltaUV1 = uv3 - uv1;
deltaUV2 = uv4 - uv1;
f = 1.0f / (deltaUV1.x() * deltaUV2.y() - deltaUV2.x() * deltaUV1.y());
tangent2.setX(f * (deltaUV2.y() * edge1.x() - deltaUV1.y() * edge2.x()));
tangent2.setY(f * (deltaUV2.y() * edge1.y() - deltaUV1.y() * edge2.y()));
tangent2.setZ(f * (deltaUV2.y() * edge1.z() - deltaUV1.y() * edge2.z()));
bitangent2.setX(f * (-deltaUV2.x() * edge1.x() + deltaUV1.x() * edge2.x()));
bitangent2.setY(f * (-deltaUV2.x() * edge1.y() + deltaUV1.x() * edge2.y()));
bitangent2.setZ(f * (-deltaUV2.x() * edge1.z() + deltaUV1.x() * edge2.z()));
// 这些顶点和法线我们都通过VAO传递进去,由于我们用的是一个2D的平面测试程序,所以法线是同一个,这并不影响。
float quadVertices[] = {
// positions // normal // texcoords // tangent // bitangent
pos1.x(), pos1.y(), pos1.z(), nm.x(), nm.y(), nm.z(), uv1.x(), uv1.y(), tangent1.x(), tangent1.y(), tangent1.z(), bitangent1.x(), bitangent1.y(), bitangent1.z(),
pos2.x(), pos2.y(), pos2.z(), nm.x(), nm.y(), nm.z(), uv2.x(), uv2.y(), tangent1.x(), tangent1.y(), tangent1.z(), bitangent1.x(), bitangent1.y(), bitangent1.z(),
pos3.x(), pos3.y(), pos3.z(), nm.x(), nm.y(), nm.z(), uv3.x(), uv3.y(), tangent1.x(), tangent1.y(), tangent1.z(), bitangent1.x(), bitangent1.y(), bitangent1.z(),
pos1.x(), pos1.y(), pos1.z(), nm.x(), nm.y(), nm.z(), uv1.x(), uv1.y(), tangent2.x(), tangent2.y(), tangent2.z(), bitangent2.x(), bitangent2.y(), bitangent2.z(),
pos3.x(), pos3.y(), pos3.z(), nm.x(), nm.y(), nm.z(), uv3.x(), uv3.y(), tangent2.x(), tangent2.y(), tangent2.z(), bitangent2.x(), bitangent2.y(), bitangent2.z(),
pos4.x(), pos4.y(), pos4.z(), nm.x(), nm.y(), nm.z(), uv4.x(), uv4.y(), tangent2.x(), tangent2.y(), tangent2.z(), bitangent2.x(), bitangent2.y(), bitangent2.z()
};
// 配置顶点缓冲
glGenVertexArrays(1,&quadVAO);
glGenBuffers(1,&quadVBO);
glBindVertexArray(quadVAO);
glBindBuffer(GL_ARRAY_BUFFER,quadVBO);
glBufferData(GL_ARRAY_BUFFER,sizeof(quadVertices),&quadVertices, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0,3,GL_FLOAT,GL_FALSE,14 * sizeof(float),0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 14 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 14 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(3);
glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, 14 * sizeof(float), (void*)(8 * sizeof(float)));
glEnableVertexAttribArray(4);
glVertexAttribPointer(4, 3, GL_FLOAT, GL_FALSE, 14 * sizeof(float), (void*)(11 * sizeof(float)));
glBindVertexArray(quadVAO);
glDrawArrays(GL_TRIANGLES, 0, 6);
glBindVertexArray(0);
3.2 像素着色器
顶点着色器
- 在定点着色器中,我们并没有使用传进来的B向量,因为在顶点着色器中传入的法线向量是准确的,我们只需要将这个法线N和主切线T进行点积就能得到一个正交坐标系。
- 但需要注意的是,在某些情况下法线N与切线T可能不会垂直,我们需要额外处理一下。(试想一下我们计算切线T的时候,如果同一个顶点被多个平面共用,那么这里的纹理坐标可能就会被综合多个平面的效果,导致T切线计算后代结果稍微有偏差。)
- 格拉姆-施密特正交化过程(Gram-Schmidt process)的数学技巧,我们可以对TBN向量进行重正交化,这样每个向量就又会重新垂直了。
- 当然我们也可以直接使用传入的B切线生成,这样都是可以的。
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
// T 向量
layout (location = 3) in vec3 aTangent;
// B 向量
layout (location = 4) in vec3 aBitangent;
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;
uniform bool blin;
void main()
{
// 顶点坐标传出的还是世界坐标
vs_out.FragPos = vec3(model * vec4(aPos, 1.0));
vs_out.TexCoords = aTexCoords;
mat3 normalMatrix = transpose(inverse(mat3(model)));
vec3 T = normalize(normalMatrix * aTangent);
vec3 N = normalize(normalMatrix * aNormal);
// 为了防止法向量和T向量不垂直
T = normalize(T - dot(T, N) * N);
// B向量我们采用N和T的点积计算得到B
vec3 B = cross(N, T);
mat3 TBN = transpose(mat3(T, B, N));
if(blin == true)
{
vs_out.TangentLightPos = TBN * lightPos;
vs_out.TangentViewPos = TBN * viewPos;
vs_out.TangentFragPos = TBN * vs_out.FragPos;
}else
{
vs_out.TangentLightPos = lightPos;
vs_out.TangentViewPos = viewPos;
vs_out.TangentFragPos = vs_out.FragPos;
}
gl_Position = projection * view * model * vec4(aPos, 1.0);
}
片段着色器:
- 在顶点着色器中我们已经将光源,视线,以及顶点坐标转换到切线空间了,这时候我们只需要正常计算光照即可
#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 vec3 lightPos;
uniform vec3 viewPos;
void main()
{
// 从法线贴图中获取法线值
vec3 normal = texture(normalMap, fs_in.TexCoords).rgb;
// 将法线坐标标准化
normal = normalize(normal * 2.0 - 1.0);
// 获取漫反射的颜色值
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.0);
}
3.3 切线空间的两种使用方法
- 第一种方法也就是我们上面使用的方法: 在顶点着色器中将光源,视线,顶点所有相关向量在顶点着色器中转换到切线空间,不用在像素着色器中做这件事,不是把TBN矩阵的逆矩阵发送给像素着色器,而是将切线空间的光源位置,观察位置以及顶点位置发送给像素着色器。这样我们就不用在像素着色器里进行矩阵乘法了。这是一个极佳的优化,因为顶点着色器通常比像素着色器运行的少。
- 第二种方法就是我们只需要在顶点着色器中将TBN传递给片段着色器,然后再片段着色器中将法线贴图的纹理使用TBN矩阵转换到世界坐标即可,这样看起来更简单,但片段着色器运行的次数更多,相对来说消耗更大。
3.4 渲染效果
- 在渲染时我们加上开关,也就是可以控制是否使用切线空间来优化错误的法线贴图,看看他们不同的效果。
- 因为片段着色器没有什么不同,也就是在顶点着色器中加上一个控制变量
- 这个变量用于控制是否使用切线空间。
if(blin == true)
{
vs_out.TangentLightPos = TBN * lightPos;
vs_out.TangentViewPos = TBN * viewPos;
vs_out.TangentFragPos = TBN * vs_out.FragPos;
}else
{
vs_out.TangentLightPos = lightPos;
vs_out.TangentViewPos = viewPos;
vs_out.TangentFragPos = vs_out.FragPos;
}
四 复杂的物体
对于复杂的物体也就是平面(或者说网格)很多的物体,像Assimp这种模型加载库是会提供的,我们只需要利用其提供的API接口生成TBN矩阵即可,在着色器中的使用方法是一样的。