从“法线贴图的意义”到“切线空间公式的推导与验证”

目标

本篇的重点是

  1. 讨论法线贴图的意义
  2. 讨论切线空间的意义
  3. 推导切线空间的计算公式
  4. 根据公式编写代码
  5. 将其计算结果与其他美术软件计算的结果进行比较,以验证公式的正确性。

1. 法线贴图

“法线贴图”是当前实时渲染中一项流行的技术。
不过在讨论它的意义之前,似乎需要先讨论“法线”的意义。

1.1 “法线”的意义

在现实中,光照方向表面方向相对关系,影响了人眼实际看到的颜色。计算机图形学将模拟这一效果:
在这里插入图片描述
不难想象出,对于现实中一个石膏材质的立方体:

  • 背光面肯定是
  • 当表面与光线平行时,也是的。
  • 当光线与表面垂直时,是最的。
  • 当光线方向从平行垂直的过程中,表面由

规定垂直于表面外侧的方向为表面的法线,这样,光线方向表面法线的夹角Θ就能代表“受光”程度:
在这里插入图片描述

  • Θ的表面自然是最亮的
  • Θ变化到90°的过程中,从亮到暗
  • Θ的值如果是90°180°的范围,代表背光,肯定是暗的。

基于此现象,lambert光照模型 认为漫反射光是投射到表面上的光乘以夹角余弦。即:
D i f f u s e L i g h t = L i g h t × c o s θ DiffuseLight = Light \times cos\theta DiffuseLight=Light×cosθ
尽管,在现在更高级的渲染(如 PBR)中,光照并不是由这个公式简单计算出。
但是,表面的法线对于光照计算而言是个必要的数据。

1.2 “法线贴图”的意义

那么,法线的数据从何而来呢?

现在应用于游戏的实时渲染技术是基于多边形的,即一个三维图形被数个小平面(一般是三角面或四边面)所构成。因此法线数据被这些小平面的法线所定义:
在这里插入图片描述
(准确来说,递交给渲染的单位是顶点,因此顶点法线才是最终用到的法线数据)

下面思考一个问题:
我该怎样让模型看起来精度更高?或者说——拥有更多的表面细节?

第一种方法“朴实无华”:增加模型顶点数
但要注意——增加的顶点数会给 顶点着色器 带来更大开销。

第二种方法则“耍了诡计”:使用 法线贴图
法线贴图和一般的贴图一样——都是存储了模型表面的信息。所不同的是——它所存储的信息是向量而非颜色(颜色的RGB三个通道正好对应了空间中的三个轴向)。

由于模型上一个三角面在法线贴图中可能对应很多个像素,而这每一个像素都能定义一个法线方向,因此法线被更高精度地定义了。
设想,要想达到相同的信息密度,使用增加顶点的方式将需要大量的顶点,这带来的性能开销将远高于使用法线贴图。

尽管,法线贴图会穿帮——当视线接近平行于表面的方向时,会看到表面的轮廓是平的(若是实际有更多的顶点,则会有凹凸的轮廓)。
但是,它以相对低廉的开销带来了效果上的明显提升。因此这种技术在实时渲染的情境中很受欢迎。

2. 切线空间

“切线空间”是服务于“法线贴图”的。
而为了讨论这一点,首先需要讨论:法线贴图上的一个数据 (r,g,b) ,将如何与三维空间中一个方向所对应呢 ?

2.1 法线贴图中数据的含义

首先要考虑的问题是:一个三维方向 (x,y,z) 中的每一个分量都可能是负值,但是 (r,g,b) 中每个都是正值。
——这个问题容易解决,只需要把(0,1)的值映射到(-1~1)就行了,具体来说:
( x , y , z ) = ( 2 r − 1.0 , 2 g − 1.0 , 2 b − 1.0 ) (x,y,z) = (2r-1.0,2g-1.0,2b-1.0) (x,y,z)=(2r1.0,2g1.0,2b1.0)

接下来,需要将向量变换到模型空间(因为后续需要将其与模型矩阵 相乘而转换到世界空间 中,这样才能与世界空间中的光照方向做计算)

第一种方式是——法线贴图直接存储模型空间的法线。
这很省事儿,采样的结果直接就是我们需要的向量。

第二种方式是——法线贴图存储的是表面上切线空间中的向量。
所谓的“切线空间”是一个Z轴表面法线方向(或者说XY平面是切面)的空间。
显然,每个表面都有自己的切线空间。
假设切线空间的XYZ三个轴的基向量在模型空间中分别为TBN,那么在切线空间(x,y,z) 向量在模型空间中应该为:
x T ⃗ + y B ⃗ + z N ⃗ x \vec{T} + y\vec{B} + z\vec{N} xT +yB +zN

尽管,使用法线贴图存储模型空间下的法线很方便。
但是,使用法线贴图存储切线空间将拥有更高的灵活度(比如,对于一个“砖块”形状的法线贴图,你可以将其应用于不同朝向的墙面上)。因此,切线空间是现在被应用更多的类型,下面将仅讨论切线空间的法线贴图。

2.2 “切线空间”的定义

那么,该如何得到一个面上的切线空间呢?
毕竟,我们只知道这个切线空间的Z轴是表面法线方向,但是这仅确定下来一个轴,满足此轴有无数个坐标系:
在这里插入图片描述
那么其中哪个坐标系是我们需要的切线空间呢?


我们有个期望:

假设一个三角面在贴图上对应一个直角三角形,其两直角边分别平行于纹理坐标的U轴与V轴:
在这里插入图片描述

如果——在法线贴图中,这个三角形内有一点的颜色(1.0,0.5,0.5),即对应的向量(1,0,0)
那么——直观上,我们觉得它指向的方向应该是贴图的U方向
又因为——三角面中一边(P0到P1)是平行于U轴
所以——我们期望(1,0,0)指向的方向应该和P0到P1的方向一致。

同理——如果向量(0,1,0),那就应该是V方向,即P0到P2的方向。

类似——如果向量的值并不是准确在某个分量上是1,而是各分量在0~1之间变化,那么最终的方向也将得到不同程度的混合。

类似——如果三角形的边并不是准确地平行于U轴和V轴,那么向量对应的方向也将得到不同程度调整。


换种更准确与形象的说法:

可以将贴图的空间看做是一个贴图坐标系:U轴就是X轴,V轴就是Y轴,而垂直于贴图的轴就是Z轴:
在这里插入图片描述

对于每一个三角形,都可以将贴图连同这个贴图坐标系放到切面上,同时可以保证让“贴图上的三角形”与“三维空间中的三角形”完全贴合:
在这里插入图片描述

注意
为了便于讨论,此时假设了在纹理贴图的过程中不存在纹理扭曲形变的现象,即在将纹理三角形映射到3D三角形上时,仅需要执行刚体变换(旋转、平移操作)。

此时的贴图坐标系就是切线空间
一般会称此时的X轴(或者说对应纹理坐标的U轴)为Tangent(切线),称Y轴(或者说对应纹理坐标的V轴)为BiTangent(副切线)。而Z轴精确对应了Normal(法线)。
因此切线空间的XYZ三轴又称为TBN三轴。

3. 切线空间计算公式

现在已经知道,这个切线空间将由三角形的顶点UV确定。那么接下来就构造几何关系等式,来求出切线副切线模型空间的方向

3.1 构造几何关系等式

假设三角形的三点在模型中的坐标分别为 P 0 P_0 P0 P 1 P_1 P1 P 2 P_2 P2
在这里插入图片描述
设:

  • e 1 ⃗ \vec{e_1} e1 表示模型空间中“P0到P1”的向量,即 e 1 ⃗ = P 1 − P 0 \vec{e_1} = P_1-P_0 e1 =P1P0。同理 e 2 ⃗ = P 2 − P 0 \vec{e_2} = P_2-P_0 e2 =P2P0
  • 这三点在法线贴图中的纹理坐标分别为 ( P 0 u , P 0 v ) ({P_0}_u,{P_0}_v) (P0u,P0v) ( P 1 u , P 1 v ) ({P_1}_u,{P_1}_v) (P1u,P1v) ( P 2 u , P 2 v ) ({P_2}_u,{P_2}_v) (P2u,P2v)
  • Δ U 1 \Delta U_1 ΔU1表示纹理坐标中“P0的U值与P1的U值上的差值”,即 Δ U 1 = P 1 u − P 0 u \Delta U_1={P_1}_u-{P_0}_u ΔU1=P1uP0u。同理 Δ U 2 = P 2 u − P 0 u \Delta U_2={P_2}_u-{P_0}_u ΔU2=P2uP0u Δ V 1 = P 1 v − P 0 v \Delta V_1={P_1}_v-{P_0}_v ΔV1=P1vP0v Δ V 2 = P 2 v − P 0 v \Delta V_2={P_2}_v-{P_0}_v ΔV2=P2vP0v
    在这里插入图片描述

设T轴在模型空间的基向量为 T ⃗ \color{Red}\vec{T} T ,设B轴在模型空间的基向量为 B ⃗ \color{Green}\vec{B} B 。这其实就是最终需要计算出的方向。

对于 e 1 ⃗ \vec{e_1} e1 ,如果放在切线空间来看,那么 Δ U 1 \Delta U_1 ΔU1就对应于其在T轴上的分量。不过,由于纹理的空间三维空间的单位不一样,因此我们设 K U T K_{UT} KUT为从纹理空间的U轴转换到三维空间的T轴的缩放系数,即:纹理坐标中U轴上“1.0”的长度,对应于三维空间中T轴上的“ K U T K_{UT} KUT”的长度。

因此, e 1 ⃗ \vec{e_1} e1 在T轴上的分量为 Δ U 1 × K U T \Delta U_1 \times K_{UT} ΔU1×KUT
同理, e 1 ⃗ \vec{e_1} e1 在B轴上的分量为 Δ V 1 × K V B \Delta V_1 \times K_{VB} ΔV1×KVB
同理, e 2 ⃗ \vec{e_2} e2 在T轴上的分量为 Δ U 2 × K U T \Delta U_2 \times K_{UT} ΔU2×KUT
同理, e 2 ⃗ \vec{e_2} e2 在B轴上的分量为 Δ V 2 × K V B \Delta V_2 \times K_{VB} ΔV2×KVB

那么,很明显:
{ e 1 ⃗ = ( Δ U 1 × K U T ) T ⃗ + ( Δ V 1 × K V B ) B ⃗ e 2 ⃗ = ( Δ U 2 × K U T ) T ⃗ + ( Δ V 2 × K V B ) B ⃗ \begin{cases} \vec{e_1} = (\Delta U_1 \times K_{UT}){\color{Red}\vec{T}}+(\Delta V_1 \times K_{VB}){\color{Green}\vec{B}} \\ \vec{e_2} = (\Delta U_2 \times K_{UT}){\color{Red}\vec{T}}+(\Delta V_2 \times K_{VB}){\color{Green}\vec{B}} \end{cases} { e1 =(ΔU1×KUT)T +(ΔV1×KVB)B e2 =(ΔU2×KUT)T +(ΔV2×KVB)B

这个等式将模型空间的三维坐标纹理空间的UV坐标通过切线空间的基向量联系起来。
下面将根据这个公式求出 T ⃗ \color{Red}\vec{T} T B ⃗ \color{Green}\vec{B} B 的值。

3.2 切线空间计算公式

首先,为了求 T ⃗ \color{Red}\vec{T} T ,可以先消去 B ⃗ \color{Green}\vec{B} B 相关的项,因此对等式两端同时乘以一个标量来让 B ⃗ \color{Green}\vec{B} B 的常数项相同:
{ e 1 ⃗ Δ V 2 = ( Δ U 1 Δ V 2 × K U T ) T ⃗ + ( Δ V 1 Δ V 2 × K V B ) B ⃗ e 2 ⃗ Δ V 1 = ( Δ U 2 Δ V 1 × K U T ) T ⃗ + ( Δ V 1 Δ V 2 × K V B ) B ⃗ \begin{cases} \vec{e_1}\Delta V_2 = (\Delta U_1\Delta V_2 \times K_{UT}){\color{Red}\vec{T}}+(\Delta V_1\Delta V_2 \times K_{VB}){\color{Green}\vec{B}} \\ \vec{e_2}\Delta V_1 = (\Delta U_2\Delta V_1 \times K_{UT}){\color{Red}\vec{T}}+(\Delta V_1\Delta V_2 \times K_{VB}){\color{Green}\vec{B}} \end{cases} { e1 ΔV2=(ΔU1ΔV2×KUT)T +(ΔV1ΔV2×KVB)B e2 ΔV1=(ΔU2ΔV1×KUT)T +(ΔV1ΔV2×KVB)B

这样,上边的等式两端同时减去下边等式的两端即可消去 B ⃗ \color{Green}\vec{B} B ,得到等式:
e 1 ⃗ Δ V 2 − e 2 ⃗ Δ V 1 = ( Δ U 1 Δ V 2 × K U T ) T ⃗ − ( Δ U 2 Δ V 1 × K U T ) T ⃗ \vec{e_1}\Delta V_2-\vec{e_2}\Delta V_1=(\Delta U_1\Delta V_2 \times K_{UT}){\color{Red}\vec{T}}-(\Delta U_2\Delta V_1 \times K_{UT}){\color{Red}\vec{T}} e1 ΔV2e2 ΔV1=(ΔU1ΔV2×KUT)T (ΔU2ΔV1×KUT)T

因此:
T ⃗ = e 1 ⃗ Δ V 2 − e 2 ⃗ Δ V 1 Δ U 1 Δ V 2 × K U T − Δ U 2 Δ V 1 × K U T = e 1 ⃗ Δ V 2 − e 2 ⃗ Δ V 1 Δ U 1 Δ V 2 − Δ U 2 Δ V 1 × ( 1 K U T ) \begin{aligned} {\color{Red}\vec{T}} & = \frac{\vec{e_1}\Delta V_2-\vec{e_2}\Delta V_1}{\Delta U_1\Delta V_2 \times K_{UT}-\Delta U_2\Delta V_1 \times K_{UT}}\\ & = \frac{\vec{e_1}\Delta V_2-\vec{e_2}\Delta V_1}{\Delta U_1\Delta V_2-\Delta U_2\Delta V_1}\times(\frac{1}{K_{UT}}) \end{aligned} T =ΔU1ΔV2×KUTΔU2ΔV1×KUTe1 ΔV2e2 ΔV1=ΔU1ΔV2ΔU2ΔV1e1 ΔV2e2 ΔV1×(KUT1)

对于 ( 1 K U T ) (\frac{1}{K_{UT}}) (KUT1)这一项,它是一个额外乘算的标量——只会改变向量的长度而不会改变方向。
由于我们对于 T ⃗ \color{Red}\vec{T} T 的长度其实不感兴趣——它是基向量,长度总为单位1。
因此,可以忽略 ( 1 K U T ) (\frac{1}{K_{UT}}) (KUT1)这一项,只要最终算出结果后再进行 标准化(Normalized) 就行了。
因此:
T ⃗ = e 1 ⃗ Δ V 2 − e 2 ⃗ Δ V 1 Δ U 1 Δ V 2 − Δ U 2 Δ V 1 {\color{Red}\vec{T}} =\frac{\vec{e_1}\Delta V_2-\vec{e_2}\Delta V_1}{\Delta U_1\Delta V_2-\Delta U_2\Delta V_1} T =ΔU1ΔV2ΔU2ΔV1e1 ΔV2e2 ΔV1

现在,等式右边的数据其实都是已知的——他们都是由顶点的模型空间中的三维坐标纹理空间的UV坐标简单求出。

之后 B ⃗ \color{Green}\vec{B} B 的求解方式相同。如果确定三轴是正交的,那么也可以由 T ⃗ \color{Red}\vec{T} T N ⃗ \color{Blue}\vec{N} N 进行叉乘而算出。

4. 代码

#include <iostream>

//模型空间中的三维向量
struct Vector3D
{
    
    
	float x;
	float y;
	float z;

	//三维向量 乘 标量
	Vector3D operator*(const float k) const
	{
    
    
		Vector3D result;
		result.x = x * k;
		result.y = y * k;
		result.z = z * k;
		return result;
	}

	//三维向量 除以 标量
	Vector3D operator/(const float k) const
	{
    
    
		Vector3D result;
		result.x = x / k;
		result.y = y / k;
		result.z = z / k;
		return result;
	}

	//三维向量 减 三维向量
	Vector3D operator-(const Vector3D& other) const
	{
    
    
		Vector3D result;
		result.x = x - other.x;
		result.y = y - other.y;
		result.z = z - other.z;
		return result;
	}

	//标准化
	Vector3D Normalized() const
	{
    
    
		float length = std::sqrt(x * x + y * y + z * z);
		return (*this) / length;
	}
};

//纹理空间中的UV坐标
struct UVCoord
{
    
    
	float u;
	float v;
};

int main()
{
    
    
	//测试的顶点数据:
	const Vector3D P0 = {
    
     0, 0, 0 };	//第0点在模型空间的位置
	const Vector3D P1 = {
    
     1, 0, 0 };	//第1点在模型空间的位置
	const Vector3D P2 = {
    
     0, 1, 0 };	//第2点在模型空间的位置
	const UVCoord C0 = {
    
     0, 0 };		//第0点在贴图上的纹理坐标
	const UVCoord C1 = {
    
     0, 1 };		//第1点在贴图上的纹理坐标
	const UVCoord C2 = {
    
     1, 0 };		//第2点在贴图上的纹理坐标

	//-------------------------------------------------------------------------------------

	Vector3D e1 = P1 - P0;		//模型空间中“P0到P1”的向量
	Vector3D e2 = P2 - P0;		//模型空间中“P0到P2”的向量
	float dU1 = C1.u - C0.u;	//纹理坐标中“P0的U值与P1的U值上的差值”
	float dU2 = C2.u - C0.u;	//纹理坐标中“P0的U值与P2的U值上的差值”
	float dV1 = C1.v - C0.v;	//纹理坐标中“P0的V值与P1的V值上的差值”
	float dV2 = C2.v - C0.v;	//纹理坐标中“P0的V值与P2的V值上的差值”

	//使用公式计算出切线:
	Vector3D Tangent = (e1 * dV2 - e2 * dV1) / (dU1 * dV2 - dU2 * dV1);

	//标准化
	Tangent = Tangent.Normalized();

	//--------------------------------------------------------------------------------------

	//打印结果:
	std::cout << Tangent.x << "," << Tangent.y << "," << Tangent.z << "," << std::endl;
}

作为测试,将数据尽可能设得简单些:

//测试的顶点数据:
const Vector3D P0 = {
    
     0, 0, 0 };	//第0点在模型空间的位置
const Vector3D P1 = {
    
     1, 0, 0 };	//第1点在模型空间的位置
const Vector3D P2 = {
    
     0, 1, 0 };	//第2点在模型空间的位置
const UVCoord C0 = {
    
     0, 0 };		//第0点在贴图上的纹理坐标
const UVCoord C1 = {
    
     0, 1 };		//第1点在贴图上的纹理坐标
const UVCoord C2 = {
    
     1, 0 };		//第2点在贴图上的纹理坐标

(注意,在下面的预览画面中,其空间中的坐标系和之前所讨论的不一样,但是这不影响计算结果)
在这里插入图片描述
很容易能看出来,纹理坐标的U轴正方向变换到三维空间后应该对应于Y轴正方向。

运行程序后计算:
在这里插入图片描述
结果符合预期。

5. 验证——与其他美术软件计算的结果进行比较

Blender在导出FBX格式的模型的时候,可以选择对模型顶点的切线进行计算并存储到文件中。
我想要将自己使用上述代码计算出的结果,与他们的结果进行比较,以验证自己的计算方式的正确性。

下面,先编辑出一个三角形面,其各个顶点的位置摆放地任意一些,其UV也摆放地任意一些:
在这里插入图片描述
(注意,在Blender导出之前要“应用变换”)
随后,选择FBX格式导出
在这里插入图片描述
然后注意:要勾选Tangent Space,这样才能将计算出的切线存入fbx文件中:
在这里插入图片描述
随后,我在Houdini中导入此模型:
在这里插入图片描述

Geometry SpreadSheet中可以看到详细的顶点信息
在这里插入图片描述
下面,将模型空间中的三维坐标纹理空间的UV坐标填入代码中:

//测试的顶点数据:
const Vector3D P0 = {
    
     62.4928, -9.07389, -139.757 };	//第0点在模型空间的位置
const Vector3D P1 = {
    
     54.4998, 44.4528, -49.6361 };		//第1点在模型空间的位置
const Vector3D P2 = {
    
     -55.9398, 33.6682, -96.9241 };	//第2点在模型空间的位置
const UVCoord C0 = {
    
     0.571344, 0.807459 };		//第0点在贴图上的纹理坐标
const UVCoord C1 = {
    
     0.853649, 0.417614 };		//第1点在贴图上的纹理坐标
const UVCoord C2 = {
    
     0.327591, 0.249422 };		//第2点在贴图上的纹理坐标

计算结果为:
在这里插入图片描述
看起来和Blender计算出来的切线方向不完全一样。不过这是因为Blender与Houdini中的坐标系不一致造成的:

  • Blender中计算的切线方向,是在Blender坐标系下的。
  • 而填到代码中计算的位置数据,是Houdini坐标系下的。

即:
在这里插入图片描述

因此结果没有问题。

总结

首先,表面的法线在实时渲染中的光照计算中是一个必要的数据,它直接影响了人眼对表面朝向的感觉。

法线贴图技术是在贴图中存储法线的方向,由于三角面会包含多个像素,因此其定义的法线的精度更高。相比直接增加模型顶点,它的性能更高,因此在当前的实时渲染中很流行。

法线贴图中存储的向量可以是模型空间或者是切线空间。在切线空间中灵活度更高,因此是现在更流行的方式,下面将仅讨论切线空间的法线贴图。

为了将法线贴图中的向量数据转换到模型空间,需要切线空间

每个面的切线空间都不一样,且和顶点在法线贴图中的UV坐标有关。
其计算公式推导为:
T ⃗ = e 1 ⃗ Δ V 2 − e 2 ⃗ Δ V 1 Δ U 1 Δ V 2 − Δ U 2 Δ V 1 {\color{Red}\vec{T}} =\frac{\vec{e_1}\Delta V_2-\vec{e_2}\Delta V_1}{\Delta U_1\Delta V_2-\Delta U_2\Delta V_1} T =ΔU1ΔV2ΔU2ΔV1e1 ΔV2e2 ΔV1

其中:

  • 三角面三个顶点在模型空间的三维坐标分别为: P 0 P_0 P0 P 1 P_1 P1 P 2 P_2 P2,在纹理空间的UV坐标分别为: ( P 0 u , P 0 v ) ({P_0}_u,{P_0}_v) (P0u,P0v) ( P 1 u , P 1 v ) ({P_1}_u,{P_1}_v) (P1u,P1v) ( P 2 u , P 2 v ) ({P_2}_u,{P_2}_v) (P2u,P2v)
  • e 1 ⃗ = P 1 − P 0 \vec{e_1} = P_1-P_0 e1 =P1P0 e 2 ⃗ = P 2 − P 0 \vec{e_2} = P_2-P_0 e2 =P2P0
  • Δ U 1 = P 1 u − P 0 u \Delta U_1={P_1}_u-{P_0}_u ΔU1=P1uP0u Δ U 2 = P 2 u − P 0 u \Delta U_2={P_2}_u-{P_0}_u ΔU2=P2uP0u Δ V 1 = P 1 v − P 0 v \Delta V_1={P_1}_v-{P_0}_v ΔV1=P1vP0v Δ V 2 = P 2 v − P 0 v \Delta V_2={P_2}_v-{P_0}_v ΔV2=P2vP0v

这个公式的代码,详见【4. 代码】。
而其正确性得到了验证(与Blender中计算出的切线方向进行比较验证)

参考资料:
Foundations of Game Engine Development系列Render分卷中的样本内容:7.5 Tangent Space

猜你喜欢

转载自blog.csdn.net/u013412391/article/details/115393011