Learn OpenGL 笔记6.5 Normal Mapping(法线贴图)

我们通过在这些平面三角形上包裹 2D 纹理来增强真实感,隐藏多边形只是很小的平面三角形的事实。

从照明技术的角度来看,确定对象形状的唯一方法是通过其垂直法向量

这种使用每片段法线与每表面法线相比的技术称为法线贴图或凹凸贴图。 应用于砖平面它看起来有点像这样:

 如您所见,它以相对较低的成本在细节方面提供了巨大的提升。 由于我们只更改每个片段的法线向量,因此无需更改照明方程。

基础知识:

1.Normal mapping(法线贴图)

虽然法向量是几何实体,纹理通常只用于颜色信息, 颜色向量表示为具有 rgb 分量的 3D 向量。 我们可以类似地将法向量的 x、y 和 z 分量存储在相应的颜色分量中。 法向量范围在 -1 和 1 之间,因此它们首先映射到 [0,1]:

vec3 rgb_normal = normal * 0.5 + 0.5; // transforms from [-1,1] to [0,1]  

通过将法线向量转换为这样的 RGB 颜色分量,我们可以将源自表面形状的每个片段法线存储到 2D 纹理上。

这(以及您在网上找到的几乎所有法线贴图)都会带有蓝色调。 这是因为法线都紧密地向外指向正 z 轴 (0,0,1):偏蓝的颜色。 颜色偏差表示法向量与一般正 z 方向略有偏移,从而为纹理提供深度感。 例如,您可以看到在每块砖的顶部,朝向上下的法线颜色往往更偏绿,这是有道理的,因为砖的顶部的法线会更多地指向正 y 方向 (0,1,0),这会发生 成为绿色!

加载两个纹理,将它们绑定到适当的纹理单元,并在光照片段着色器中使用以下更改渲染平面:

uniform sampler2D normalMap;  

void main()
{           
    // obtain normal from normal map in range [0,1]
    normal = texture(normalMap, fs_in.TexCoords).rgb;
    // transform normal vector to range [-1,1]
    normal = normalize(normal * 2.0 - 1.0);   
  
    [...]
    // proceed with lighting as normal
}  

 通过随着时间的推移缓慢移动光源,您可以使用法线贴图真正获得深度感。 运行这个法线贴图示例给出了本章开头所示的确切结果:

我们使用的法线贴图具有法线向量,他们都指向一个固定的方向。但是,如果我们换个角度的贴图,使用这个法线向量,同样会指向那个固定的方向,导致法线贴图出问题了。

存在一种解决方案,法线贴图向量始终指向正 z 方向的坐标空间; 然后所有其他光照向量都相对于这个正 z 方向进行转换。 这样我们就可以始终使用相同的法线贴图,无论方向如何。 这个坐标空间称为tangent space切线空间

 

2.Tangent space(切线空间)

法线贴图是在切线空间中定义的,解决问题的一种方法是计算一个矩阵,将法线从切线空间转换到不同的空间,使它们与表面的法线方向对齐:然后法线向量都指向大致在正 y 方向。切线空间的伟大之处在于我们可以为任何类型的表面计算这个矩阵,以便我们可以正确地将切线空间的 z 方向与表面的法线方向对齐。

这样的矩阵称为 TBN (Tangent, Bitangent and Normal vector.)矩阵,其中字母表示切线、双切线和法线向量。 这些是我们需要构建这个矩阵的向量。 为了构造这样一个改变基矩阵,将切线空间向量转换到不同的坐标空间,我们需要三个沿法线贴图表面对齐的垂直向量:向上、向右和向前向量;

我们已经知道向上向量,它是表面的法向量。 右向量和前向量分别是tangent切线bitangent vector双切线向量。

计算切线和双切线向量并不像法线向量那么简单。 我们可以从图像中看到法线贴图的tangent切线bitangent vector双切线向量的方向与我们定义表面纹理坐标的方向对齐。 我们将使用这个事实来计算每个表面的切线和双切线向量。

 从图中我们可以看出三角形的边E2的纹理坐标差(记为ΔU2和ΔV2)与切向量T双切向量B在同一方向上表示。因此我们可以写出两条显示的边 三角形的 E1 和 E2 作为切向量 T 和双切向量 B 的线性组合:

T:tangent vector切向量

B:bitangent vector双切线向量

DV:三角形边E的竖分量

DU:三角形边E的横分量

也可以写成:把每个字母X,都拆成了(X1x,X2x,X3x)

我们可以将 E1,E2分别看作为三角形的两个边向量,将 ΔU 和 ΔV 计算为它们的纹理坐标差。 然后我们剩下两个未知数(切线 T 和双切线 B)和两个方程。 您可能从代数课中记得,这使我们能够求解 T 和 B。

最后一个方程允许我们用不同的形式来写:矩阵乘法:

 转换一下:

 再转换一下:

最后一个方程为我们提供了一个公式,用于根据三角形的两条边及其纹理坐标计算切向量 T 双切向量 B

我们可以从三角形的顶点及其纹理坐标(因为纹理坐标与切线向量在同一空间中)计算切线和双切线

 3.Manual calculation of tangents and bitangents (手动计算切线和副切线)

让我们假设平面是由以下向量构成的(pos1, pos2,pos3 和 pos1, pos3, pos4 作为它的两个三角形):

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

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

glm::vec3 edge1 = pos2 - pos1;
glm::vec3 edge2 = pos3 - pos1;
glm::vec2 deltaUV1 = uv2 - uv1;
glm::vec2 deltaUV2 = uv3 - uv1;  

有了计算切线和副切线所需的数据,我们可以开始遵循上一节中的等式:

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

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);
  
[...] // similar procedure for calculating tangent/bitangent for plane's second triangle

在平面上可视化,TBN 向量将如下所示:

通过为每个顶点定义切线和双切线向量,我们可以开始实现正确的法线贴图。

4.Tangent space normal mapping(切线空间法线贴图)

为了使法线贴图工作,我们首先必须在着色器中创建一个 TBN(tangent和bitangent和normal) 矩阵。 为此,我们将先前计算的切线和双切线向量作为顶点属性传递给顶点着色器:

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in vec3 aTangent;
layout (location = 4) in vec3 aBitangent;  

 创建TBN矩阵:

void main()
{
   [...]
   vec3 T = normalize(vec3(model * vec4(aTangent,   0.0)));
   vec3 B = normalize(vec3(model * vec4(aBitangent, 0.0)));
   vec3 N = normalize(vec3(model * vec4(aNormal,    0.0)));
   mat3 TBN = mat3(T, B, N);
}

我们通过直接为 mat3 的构造函数提供相关的列向量来创建实际的 TBN 矩阵。 请注意,如果我们想要真正精确,我们会将 TBN 向量与法线矩阵相乘,因为我们只关心向量的方向。

从技术上讲,顶点着色器中不需要双切线变量。 所有三个 TBN 向量都相互垂直,因此我们可以通过 T 和 N 向量的叉积在顶点着色器中自己计算双切线: vec3 B = cross(N, T);

有两种方法可以使用 TBN 矩阵(里面有法线坐标系的向量)进行法线贴图:

1.我们对任何向量都把 TBN 矩阵切线坐标系转换到世界空间坐标系,将其提供给片段着色器,并把 TBN 矩阵采样的法线从切线空间转换到世界空间; 然后法线与其他照明变量位于同一空间中。

2.我们对任何向量都通过TBN 矩阵的逆矩阵世界空间转换到切线空间的,并使用该矩阵将其他相关照明变量转换到切线空间而不是法线; 然后法线再次与其他照明变量位于同一空间中。 

通过将 TBN 矩阵传递给片段着色器,我们可以将采样的切线空间法线与该 TBN 矩阵相乘,以将法线向量转换为与其他照明向量相同的参考空间。

总结:3个ts点,能渲染出一片区域,无数个fs点,而第二种,fs中直接用切线空间的数据进行计算,省去了很多矩阵运算,无数个fs点都省去了矩阵运算。具体怎么省去的,可以看后面的fs代码

发送TBN矩阵给fs:

vs:


out VS_OUT {
    vec3 FragPos;
    vec2 TexCoords;
    mat3 TBN;
} vs_out;  
  
void main()
{
    [...]
    vs_out.TBN = mat3(T, B, N);
}

fs:

in VS_OUT {
    vec3 FragPos;
    vec2 TexCoords;
    mat3 TBN;
} fs_in; 

有了这个 TBN 矩阵,我们现在可以更新法线映射代码以包含切线到世界空间的变换:

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

由于生成的法线现在位于世界空间中,因此无需更改任何其他片段着色器代码,因为照明代码假定法线向量位于世界空间中。

第二种情况:

让我们再回顾一下第二种情况,我们取 TBN 矩阵的逆矩阵,将所有相关的世界空间向量变换到采样法向量所在的空间:切线空间。 TBN 矩阵的构造保持不变,但我们在将其发送到片段着色器之前先反转矩阵

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

请注意,我们在这里使用转置函数而不是反函数。 正交矩阵的一个重要特性(每个轴都是一个垂直的单位向量)是正交矩阵的转置等于它的逆矩阵。 这是一个很好的属性,因为逆是昂贵的,而转置不是

在片段着色器中,我们不转换法向量,但我们将其他相关向量转换到切线空间,即 lightDir 和 viewDir 向量。 这样,每个向量都在同一个坐标空间中:切线空间。

vs:

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 shader上运行,对于 fs_in.FragPos 我们可以计算它在顶点着色器中的切线空间位置并让片段差值完成它的工作。实际上不需要将向量转换到片段着色器中所需要的切线空间,而第一种方法则是必要的,因为采样的法线向量特定于每个片段着色器运行

因此,我们不是将 TBN 矩阵的逆发送到片段着色器,而是将切线空间光位置、视图位置和顶点位置发送到片段着色器。这使我们不必在片段着色器中进行矩阵乘法。这是一个很好的优化,因为顶点着色器的运行频率远低于片段着色器。这也是为什么这种方法通常是首选方法的原因。

总结:第一种,每次vs传给fs的切线TBN向量都不同,有可能1个vs点,重复计算10遍fs每次值都不同,而用第二种,1个vs点,可以传给10个fs一样的数,所以不用每次都计算?

vs: 

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

uniform vec3 lightPos;
uniform vec3 viewPos;
 
[...]
  
void main()
{    
    [...]
    //反转TBN,则可以达到,点由世界坐标系转换到法线坐标系中
    mat3 TBN = transpose(mat3(T, B, N));
    vs_out.TangentLightPos = TBN * lightPos;
    vs_out.TangentViewPos  = TBN * viewPos;
    vs_out.TangentFragPos  = TBN * vec3(model * vec4(aPos, 1.0));
}  

在片段着色器中,我们然后使用这些新的输入变量来计算切线空间中的照明。 由于法向量已经在切线空间中,所以光照是有意义的。

fs:

#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()
{           
     // 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.0);
}

 

5.Complex objects(复杂物体,非重点)

 Assimp 有一个非常有用的配置位,我们可以在加载名为 aiProcess_CalcTangentSpace 的模型时进行设置。 当 aiProcess_CalcTangentSpace 位被提供给 Assimp 的 ReadFile 函数时,Assimp 为每个加载的顶点计算平滑的切线和双切线向量,类似于我们在本章中的做法。

const aiScene *scene = importer.ReadFile(
    path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace
);  

在 Assimp 中,我们可以通过以下方式检索计算出的切线:

vector.x = mesh->mTangents[i].x;
vector.y = mesh->mTangents[i].y;
vector.z = mesh->mTangents[i].z;
vertex.Tangent = vector;  
vector<Texture> normalMaps = loadMaterialTextures(material, aiTextureType_HEIGHT, "texture_normal");  

 使用法线贴图,我们可以使用更少的顶点在网格上获得相同级别的细节。 下面来自 Paolo Cignoni 的图片显示了两种方法的很好比较:

 是用低顶点网格替换高顶点网格而不会丢失(太多)细节的好工具。

当在共享大量顶点的较大网格上计算切线向量时,切线向量通常被平均以提供良好且平滑的结果。 这种方法的一个问题是三个 TBN 向量可能最终不垂直,这意味着最终的 TBN 矩阵将不再是正交的。 使用非正交 TBN 矩阵时,法线贴图只会略微偏离,但这仍然是我们可以改进的地方。

使用称为 Gram-Schmidt 过程的数学技巧,我们可以重新正交化 TBN 向量,使每个向量再次垂直于其他向量。 在顶点着色器中,我们会这样做:

vec3 T = normalize(vec3(model * vec4(aTangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(aNormal, 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(N, T);

mat3 TBN = mat3(T, B, N)  

这虽然稍微提高了一点点,但通常会以一点额外的成本改善法线贴图结果。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值