本文主要是介绍从“法线贴图的意义”到“切线空间公式的推导与验证”,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
目录
- 目标
- 1. 法线贴图
- 1.1 “法线”的意义
- 1.2 “法线贴图”的意义
- 2. 切线空间
- 2.1 法线贴图中数据的含义
- 2.2 “切线空间”的定义
- 3. 切线空间计算公式
- 3.1 构造几何关系等式
- 3.2 切线空间计算公式
- 4. 代码
- 5. 验证——与其他美术软件计算的结果进行比较
- 总结
目标
本篇的重点是
- 讨论法线贴图的意义
- 讨论切线空间的意义
- 推导切线空间的计算公式
- 根据公式编写代码
- 将其计算结果与其他美术软件计算的结果进行比较,以验证公式的正确性。
1. 法线贴图
“法线贴图”是当前实时渲染中一项流行的技术。
不过在讨论它的意义之前,似乎需要先讨论“法线”的意义。
1.1 “法线”的意义
在现实中,光照方向与表面方向的相对关系,影响了人眼实际看到的颜色。计算机图形学将模拟这一效果:
不难想象出,对于现实中一个石膏材质的立方体:
- 其背光面肯定是暗的
- 当表面与光线平行时,也是暗的。
- 当光线与表面垂直时,是最亮的。
- 当光线方向从平行到垂直的过程中,表面由暗变亮。
规定垂直于表面外侧的方向为表面的法线,这样,光线方向与表面法线的夹角Θ
就能代表“受光”程度:
Θ
为0°的表面自然是最亮的Θ
从0°变化到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)=(2r−1.0,2g−1.0,2b−1.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=P1−P0。同理 e 2 ⃗ = P 2 − P 0 \vec{e_2} = P_2-P_0 e2=P2−P0
- 这三点在法线贴图中的纹理坐标分别为 ( 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=P1u−P0u。同理 Δ U 2 = P 2 u − P 0 u \Delta U_2={P_2}_u-{P_0}_u ΔU2=P2u−P0u, Δ V 1 = P 1 v − P 0 v \Delta V_1={P_1}_v-{P_0}_v ΔV1=P1v−P0v, Δ V 2 = P 2 v − P 0 v \Delta V_2={P_2}_v-{P_0}_v ΔV2=P2v−P0v。
设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)Be2=(Δ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)Be2Δ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ΔV2−e2Δ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ΔV2−e2ΔV1=ΔU1ΔV2−ΔU2ΔV1e1ΔV2−e2Δ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ΔV2−e2Δ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ΔV2−e2Δ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=P1−P0, e 2 ⃗ = P 2 − P 0 \vec{e_2} = P_2-P_0 e2=P2−P0
- Δ U 1 = P 1 u − P 0 u \Delta U_1={P_1}_u-{P_0}_u ΔU1=P1u−P0u, Δ U 2 = P 2 u − P 0 u \Delta U_2={P_2}_u-{P_0}_u ΔU2=P2u−P0u, Δ V 1 = P 1 v − P 0 v \Delta V_1={P_1}_v-{P_0}_v ΔV1=P1v−P0v, Δ V 2 = P 2 v − P 0 v \Delta V_2={P_2}_v-{P_0}_v ΔV2=P2v−P0v。
这个公式的代码,详见【4. 代码】。
而其正确性得到了验证(与Blender中计算出的切线方向进行比较验证)
关于在实际工程中的应用,可见《图形API学习工程(25):实现法线贴图》
参考资料:
Foundations of Game Engine Development系列Render分卷中的样本内容:7.5 Tangent Space
这篇关于从“法线贴图的意义”到“切线空间公式的推导与验证”的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!