本文以尽可能通俗的语言说清楚 法线贴图、切线空间、TBN矩阵的相关概念
不说给完全整明白,整个差不多明白吧,所有内容仅为自己的理解,仅供参考,如有错误,欢迎指出
如果对您有帮助请点个赞、收个藏、评个论
目录
1 法线贴图
1.1 为什么需要?
参考下面这张图,4个顶点2个三角形组成的一个平面,只是映射了一个颜色纹理贴图,属实非常光滑。
光滑是因为每个顶点定义的法线都是垂直于这个平面的,在像素着色器中,每个像素通过插值得到的法线也都是一模一样垂直于这个平面,然后用每个像素的法线进行光照计算,得到的肯定是这种完全平坦的渲染结果。
想要增加它的细节,让墙面看起来更真实(凹凸不平、麻麻赖赖),一种方法就是用更多的三角形来表现这一面墙,比如成千上万个,但这样的性能开销过大
增加面数太费性能,不可取。那么从光照角度来看,为什么这个表面渲染结果是一个完全平坦的平面?答案是表面每个片段的法向量整整齐齐非常一致(如下图),光照的计算不管你模型什么样,只看法线,因此计算出来的光照在平面上几乎没什么差异,很平滑。
下面这张图可以看到,实际表面(Actual surface)的每个片段法线都很一致,因此通过光照计算,让我们感知到的表面(Perceived surface)就很平坦
这时候自然就有一个想法:像映射颜色贴图一样,搞一个法线贴图,这样不就每个片段的法线都很多样性了嘛!
非常正确,通过一个法线贴图,使得墙表面上的法线杂七杂八的,我们可以欺骗光,使光线相信这个面是由很多微观上的小平面组成,从而使表面在细节上得到巨大的提升。这种技术就是 法线映射
或 凹凸贴图
。
成本相对较低的情况下提供了巨大的提升,只更改每个片段的法向量,因此无需更改关照计算方式
1.2 怎么做法线映射?
使用2D纹理来存储每个片段的法线数据。通过这种方式,我们可以对2D纹理进行采样,以获得该特定片段的法向量。
一般法线纹理上的每个纹理像素(简称纹素texel)
格式都是RGB,取值范围是 [0,1],而一个法线是3D矢量,每一个分量的取值范围是 [-1,1],因此首先要把法线映射到[0,1]范围来存储它。将法向量转换为像这样的 RGB 颜色分量,我们可以将每个片段法线存储到 2D 纹理上。
vec3 rgb_normal = normal * 0.5 + 0.5; // transforms from [-1,1] to [0,1]
为什么几乎所有法线贴图,都是蓝色色调?
—— 在大多数情况下,法线贴图中的法线数据朝着Z方向,而Z方向的法线从[-1,1]映射到[0,1]之后,就成了B通道值占大头。而其深浅略有不同是因为每个片段的法线都相对于正z轴略微有偏移。图中的每块砖的上边缘部分,颜色更偏绿色(0,1,0),是因为现实中的砖,在其上部接缝处的法线就是更接近正y轴,也就会偏绿色多一些。
通过像素着色器中每个片段都采样该法线纹理,计算光照后就能得到很真实的效果,因为效果看起来变得凹凸不平了,所以法线贴图可以叫做凹凸贴图
2 切线空间
2.1 为什么需要切线空间?
前面提到过,法线贴图上的法向量都大致指向正z方向
。上面的图之所以能够正确渲染,是因为砖墙平面正好朝向正z方向。
如果我们对铺设在地面上的一个表面,这个表面朝向正y轴
,映射相同的法线贴图,会怎样?
—— 得到一个完全错误的结果
我现在想要的是各个片段的法线方向大致指向正y轴,并且各不相同,各自表现着细节。但是映射法线贴图之后,所有的片段拿到的法线还是指向正z轴,拿这些法线计算光照,就会产生错误。
一张法线贴图只能用在一个平面上?场景里这样的砖墙有一百个,我要额外存储100个纹理? 开什么国际玩笑
因此,为了解决复用问题,出现了切线空间的概念,而复用只是切线空间众多作用中的一小个
2.2 切线空间是什么?
众所周知法线(Normal)
是垂直于平面的,这里的平面实际上是虚拟平面,可以认为一根法线就代表一个平面,这个平面是该模型在该点上的 切平面 。切线(Tangent Space)
是该切平面内的一个方向,而副切线(Bitangent、Binormal)
则是法线和切线的叉积。这组基向量(法线、切线、副切线)形成了切线空间。所以切线空间就是一个局部空间,一个局部笛卡尔坐标系。
2.3 TBN矩阵
因为光照计算,需要所有的参数(光源位置、模型点的坐标、法向量等)都位于同一个空间,因此需要做空间转换的计算,TBN矩阵可以实现切线空间与模型空间相互转换。
因为我们要把法线贴图上定义在切线空间的法向量,从切线空间转换到模型的局部空间作为该模型上的该点的法线,所以需要该模型提供一些信息,用这些信息经过计算得到一个转换矩阵TBN。
- T:切向量 Tangent
- B:副切向量 Bitangent
- N:法向量 Normal
只要已知T、B、N三个向量就能组成一个TBN矩阵
2.4 TBN矩阵计算
N向量,它是目标平面的法向量,通过组成该表面的顶点计算而来。面法线 = 周围N个顶点的法线平均值
>注意:每个顶点依然有带法线属性,虽然我们没用他们来插值每个片段。而是每个片段映射法线贴图来获取法线
- right vector: 对应于T向量
- forward vector: 对应于B向量
计算T、B向量比较麻烦,需要:该三角形面的3个顶点的位置坐标 和 纹理坐标
如果不喜欢看推导过程可直接不看,把公式翻译成代码计算就行
假设一个三角形为
P
1
,
P
2
,
P
3
P1,P2,P3
P1,P2,P3,纹理坐标分别是
(
U
1
,
V
1
)
,
(
U
2
,
V
2
)
,
(
U
3
,
V
3
)
(U_1,V_1),(U_2,V_2), (U_3,V_3)
(U1,V1),(U2,V2),(U3,V3)。
- 先看边
E
2
E_2
E2
-
E
2
E_2
E2的
纹理坐标差值
为ΔU2和ΔV2,注意这里的两个差值为标量
! -
E
2
E_2
E2的
位置坐标差值
,为向量
(公式中加粗了) - 建立第一个方程(未知数为T、B向量)
E 2 = Δ U 2 T + Δ V 2 B \mathbf{E_2} = \Delta U_2T +\Delta V_2B E2=ΔU2T+ΔV2B
-
E
2
E_2
E2的
- 同理
E
1
E_1
E1边也可以用这种方式得到一个方程
E 1 = Δ U 1 T + Δ V 1 B \mathbf{E_1} = \Delta U_1T +\Delta V_1B E1=ΔU1T+ΔV1B
很明显了吧,两个方程两个未知数,T和B是必然能够求出来的。但是这里面的变量不是一维,因此要用线性代数的知识来解这个矩阵方程(应该可以这么叫吧,线代很多名词已经忘了- -)。
把上面的方程组每个分量都显示出来,则变成下面这种形式:
(
E
1
x
,
E
1
y
,
E
1
z
)
=
Δ
U
1
(
T
x
,
T
y
,
T
z
)
+
Δ
V
1
(
B
x
,
B
y
,
B
z
)
(
E
2
x
,
E
2
y
,
E
2
z
)
=
Δ
U
2
(
T
x
,
T
y
,
T
z
)
+
Δ
V
2
(
B
x
,
B
y
,
B
z
)
(E_{1x},E_{1y},E_{1z})= \Delta U_1(T_x,T_y,T_z) + \Delta V_1(B_x,B_y, B_z) \\ \quad \\ (E_{2x},E_{2y},E_{2z})= \Delta U_2(T_x,T_y,T_z) + \Delta V_2(B_x,B_y, B_z)
(E1x,E1y,E1z)=ΔU1(Tx,Ty,Tz)+ΔV1(Bx,By,Bz)(E2x,E2y,E2z)=ΔU2(Tx,Ty,Tz)+ΔV2(Bx,By,Bz)
再变换成矩阵乘法的形式:
[
E
1
x
E
1
y
E
1
z
E
2
x
E
2
y
E
2
z
]
=
[
Δ
U
1
Δ
V
1
Δ
U
2
Δ
V
2
]
⋅
[
T
x
T
y
T
z
B
x
B
y
B
z
]
\begin{bmatrix} E_{1x}&E_{1y}&E_{1z}\\E_{2x}&E_{2y}&E_{2z} \end{bmatrix} = \begin{bmatrix} \Delta U_1&\Delta V_1 \\ \Delta U_2&\Delta V_2 \end{bmatrix} · \begin{bmatrix} T_x&T_y&T_z \\ B_x&B_y&B_z \end{bmatrix}
[E1xE2xE1yE2yE1zE2z]=[ΔU1ΔU2ΔV1ΔV2]⋅[TxBxTyByTzBz]
再稍微做变换一下,左乘
[
Δ
]
−
1
[\Delta]^{-1}
[Δ]−1(稍微偷懒一下)得到:
[
Δ
U
1
Δ
V
1
Δ
U
2
Δ
V
2
]
−
1
⋅
[
E
1
x
E
1
y
E
1
z
E
2
x
E
2
y
E
2
z
]
=
[
T
x
T
y
T
z
B
x
B
y
B
z
]
\begin{bmatrix} \Delta U_1&\Delta V_1 \\ \Delta U_2&\Delta V_2 \end{bmatrix}^{-1} · \begin{bmatrix} E_{1x}&E_{1y}&E_{1z}\\E_{2x}&E_{2y}&E_{2z} \end{bmatrix} = \begin{bmatrix} T_x&T_y&T_z \\ B_x&B_y&B_z \end{bmatrix}
[ΔU1ΔU2ΔV1ΔV2]−1⋅[E1xE2xE1yE2yE1zE2z]=[TxBxTyByTzBz]
这样的形式,就很明显,左边全是已知量,右边是目标求解的矩阵,逆矩阵我们是极力避免的,因此逆矩阵可以通过公式替换掉
A
−
1
=
A
∗
∣
A
∣
A^{-1}=\Large \frac{A^*}{|A|}
A−1=∣A∣A∗,还好是二阶,伴随矩阵是张口就来【主对调,副变号】所以得到:
[
T
x
T
y
T
z
B
x
B
y
B
z
]
=
1
Δ
U
1
Δ
V
2
−
Δ
U
2
Δ
V
1
[
Δ
V
2
−
Δ
V
1
−
Δ
U
2
Δ
U
1
]
[
E
1
x
E
1
y
E
1
z
E
2
x
E
2
y
E
2
z
]
\begin{bmatrix} T_x&T_y&T_z \\ B_x&B_y&B_z \end{bmatrix}= \frac{1}{\Delta U_1\Delta V_2 - \Delta U_2\Delta V_1} \begin{bmatrix} \Delta V_2 & -\Delta V_1 \\ -\Delta U_2&\Delta U_1 \end{bmatrix} \begin{bmatrix} E_{1x}&E_{1y}&E_{1z}\\E_{2x}&E_{2y}&E_{2z} \end{bmatrix}
[TxBxTyByTzBz]=ΔU1ΔV2−ΔU2ΔV11[ΔV2−ΔU2−ΔV1ΔU1][E1xE2xE1yE2yE1zE2z]
- 根据上面这个公式,通过面内一个三角形的
位置
和纹理坐标
属性 可以计算出切向量T和副切向量B - 因为TBN三个轴相互垂直,N已知,计算出TB中的任何一个,另一个都可以通过叉乘得到,可以少一半计算量
重要:计算出TBN三个轴后,这三个轴对于面内所有三角形的所有点是共用的,所以一个面只需要找一个三角形计算TBN即可
TBN矩阵的组装
- 注意TBN矩阵是通过模型的顶点位置和纹理坐标算出来的,TBN三个轴是位于模型空间的
- 我们一般其实不需要自己手算TBN矩阵,三方模型读取的库已经提供了。顶点着色器拿到TBN后,通常需要先乘上一个Model矩阵中,因为我们是想通过TBN矩阵做世界空间与切线空间的相互转换。注意
- 是乘以model的
逆矩阵的转置矩阵
(这是矢量变换矩阵,具体原因请自行搜索文章查阅啦,涉及标量旋转和矢量旋转的差异
)
void main() { //在顶点着色器中组装TBN矩阵 mat3 vectorMatrix = transpose(inverse(mat3(model))); // 移除位移部分 vec3 T = vectorMatrix * aTangent; vec3 B = vectorMatrix * aBitangent; vec3 N = vectorMatrix * aNormal; mat3 TBN = mat3(T, B, N); }
- 是乘以model的
3 世界空间
、切线空间
的光照计算
3.1 在世界空间计算
-
在
像素着色器
中,使用TBN矩阵将采样拿到的法线从切线空间转换到世界空间,然后法线与光源、相机处于世界空间中,再进行光照计算。vec3 normal = vec3(texture(normalMap, TexCoords)); normal = normal * 2.0 - 1.0; // 采样的法线从3个分量从[0,1]映射回[-1,1] normal = normalize(TBN * normal); //..光照计算..
-
代价:逐像素做1次矩阵乘法
3.2 在切线空间中计算
一般来说:
- 首先计算在顶点着色器中计算
TBN的逆矩阵
并传给像素着色器
(因为要把光源方向和相机方向转换到切线空间,所以需要逆变换) - 在像素着色器中将
光源方向
和视点方向
从世界空间转换到切线空间,采样的法线也位于切线空间,再进行光照计算。按照这个逻辑,应该是下面这样的流程(伪代码)// 在顶点着色器中先对TBN求逆,因为正交矩阵 所以转置即可,之后直接传给像素着色器 TBN = transpose(mat3(T, B, N));
// 像素着色器中不对法线进行空间转换,而把对光入射方向以及视线方向转换到切线空间再进行光照计算 vec3 normal = vec3(texture(normalMap, TexCoords)); normal = normalize(normal * 2.0 - 1.0); vec3 lightDir = TBN * normalize(lightPos - FragPos); // 世界空间光的入射方向转到切线空间 vec3 viewDir = TBN * normalize(viewPos - FragPos); [..光照计算..]
- 代价:逐像素2次矩阵乘法
都说在切线空间计算光照更节省性能,相比世界空间光照计算的1次矩阵乘法,好像切线空间的效率更低啊?----上面这思路错了
正确的切线空间光照计算方式:
- 在
顶点着色器
将所有相关变量(光源位置、相机位置、定点位置)转换到切线空间,再把这些属性传给像素着色器,每个片段插值出相应的属性即可// 记得保证TBN是相互垂直的三个向量 mat3 TBN = transpose(mat3(T, B, N)); vs_out.TangentLightPos = TBN * lightPos; vs_out.TangentViewPos = TBN * viewPos; vs_out.TangentFragPos = TBN * vs_out.FragPos;
- 这种方式在片段着色器中实际上不需要对任何向量做矩阵乘法,而
在世界空间中的光照计算
则必须逐像素做一次,因为采样的法线向量是针对每个片段着色器运行的 - 我们不需要将
TBN矩阵的逆
发送给片段着色器,而是在顶点着色器中将切线空间的光照位置、相机位置和顶点位置转换到切线空间后,发送给片段着色器。这使我们不必在片段着色器中进行矩阵乘法。这是一个很好的优化,因为顶点着色器比碎片着色器的运行频率要低得多。