一文彻底掌握什么是法线贴图、切线空间、TBN矩阵

9 篇文章 8 订阅
8 篇文章 1 订阅

本文以尽可能通俗的语言说清楚 法线贴图、切线空间、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 P1P2P3,纹理坐标分别是 ( 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 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|} A1=AA,还好是二阶,伴随矩阵是张口就来【主对调,副变号】所以得到:
[ 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 Δ V 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 V_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ΔV1][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);
    }
    

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矩阵的逆发送给片段着色器,而是在顶点着色器中将切线空间的光照位置、相机位置和顶点位置转换到切线空间后,发送给片段着色器。这使我们不必在片段着色器中进行矩阵乘法。这是一个很好的优化,因为顶点着色器比碎片着色器的运行频率要低得多。

参考文章:Learn OpenGL:Normal Mapping

  • 9
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

宗浩多捞

您的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值