大家好,我是程序员kenney,在我之前的一篇文章《OpenGL 3D渲染技术:光照原理》中提到过法向纹图(normal texture),它是将法向量信息以颜色值的方式存储在纹理图中,以此来得到像素级的法向量信息,这篇文章就来讨论一下法向量相关的变换。
法向纹理
法向纹理就是将法向量以颜色值的方式存储在纹理图中,一般情况下,就算没有法向纹理,模型也是有法向量的,那为什么还需要法向纹理呢?因为模型中的法向量数据,是和顶点一一对应的,而一个三角面片又是一个平面,因此一个三角面片上的法向量就是顶点的法向量,如果你注意观察,你会发现对于一个三角形来说,三个顶点的法向量是一样的,这里要注意顶点复用的情况,一个顶点位置有可能会对应多个法向量,取决于这个顶点是哪个三角形的顶点。这样对于一个三角面片来说,由于这个面所有地方的法向量都一样,因此在做光照计算时,就会得光照在一个非常光滑的平面上的效果,但是我们有时想得到一些粗糙表面的效果,比如这个经典的砖墙:
既然一个三角形是一个很平的平面,其中的法向量都一样,那如何得到粗糙的效果?可以做很多很密集的小三角形,让这些小角形朝向不同的地方,这样做有个显而易见的问题就是顶点数量变非常多,于是有大佬发明了法向纹理这个玩意,它将法向量存储在一张纹理中,在fragment shader中对法向量进行采样,得到像素级的法量向,能很方便地实现像素级凹凸不平的效果。
下面这张图展示了法向纹理能帮助我们得到像素级的法向量:
下面就来解释如何通过法向纹理得到模型上不同位置的法向量,前面提到过法向纹理是将法向量以颜色值的方式存储在纹理图中,也就是将(x,y,x)
以(r,g,b)
的方式存储,那么直接将颜色值读出来当作法向量的(x,y,z)
使用就行了?没那么简单,比如上面那个立方体,我们渲染时可以只用一张法图纹理,就能让6个面都有一样的凹凸不平的效果,如果说读出来的颜色值直接当法向量用,那么对于每个面上的对应位置,在法向纹理上采样得到的值是一样的,这样就会导致每个面上的这个点,法向量一样,如下面左所示,这显然不是我们想要的,我们想要的是下图右的效果:
当然也可以做6个法向图,每个面用自己的那个,也能解决这个问题,但是这样又会引入一些缺点,比如资源占用比较大,现在一般都不这样做了,那么只有一张法向纹理的情况下,是怎么实现在不同的面上能正确使用的呢?这里的关键点就在于,存储在里面的法向量的数据,它不是模型坐标系下的,直接读出来并不能使用。
切线空间
为了能实现法向量在不同的面上都能正确使用,引入了切线空间(Tangent Space)这个概念,特别注意一点,切线空间定义在每个三角面片上,切线空间通常也叫作TBN坐标系,T是切线(Tangent),B是副切线(BiTangent),N是法线(Normal),T与纹理坐标系的U轴相同,B与纹理坐标系的V轴相同,法向贴图中的RGB
颜色值读出来后,从01范围稍微转换一下到-11,就是对应切线空间中的xyz
:
tips:法向纹理中存储的法向量是切线空间下的,和模型具体如何旋转无法,大致都是指向切线空间的z轴方向,因此z分量也就是RGB的B分量一般要比其它2个分量要大很多,因此法向纹理通常看起来是偏蓝色的。
那么问题来了,既然法向贴图中的RGB
颜色值是表示切线空间中的xyz
,也就是说它是切线空间下的法向量,而光源位置、模型顶点都在模型坐标系下,怎样才能用这个切线空间下的法向量呢?那就要把他们转换到同一个坐标系下。这里就要用到线性代数的一个知识点,如果知道一组坐标基在另一个坐标系下的表示,那用这组坐标基下的坐标乘上这组坐标基在另一个坐标系下的表示,就可以将这组坐标基下的坐标转换到另一个坐标系下,听起来有点绕,给大家找了一个学习资料:zhuanlan.zhihu.com/p/69069042
具体到我们这里,TBN
就是一组在模型坐标系下的基向量,因此用TBN
去变换法向纹理中的法向量,结果就是得到了模型坐标系下的法向量,这样就都在同一个坐标系中,之后就可以使用了,比如都变换到世界坐标系中去。
那么如何计算得到TBN
三个向量呢?网上也有不少的推导文章,网上大多数文章的推导过程,是以T与U方向相同、B与V方向相同从而构造出2个纹理坐标转换到模型顶点坐标的方程,从而求解出T和B的,本篇文章也是类似,但是这样直接推导,会造成些疑惑,至少我看起来会有一些疑惑,也有可能是我个人没理解,但我想跟大家分享一下自己的理解。
这里是一篇LearnOpenGL中的推导:learnopengl-cn.github.io/05%20Advanc… 网上很多文章都是这样推导的,先来看两张图:
T与U方向相同、B与V方向相同,由这两张图可得:
单看这步其实也好理解,E是模型坐标系中两个顶点之间构成的向量,这两个式子含义其实就是前面提到的线性代数的知识点,它将纹理坐标系下的表示,通过T和B的变换,转成了模型坐标系下的表示,换句话说,T和B的作用就是把纹理坐标转换成了模型坐标。再回想我们一开始要的是什么,我们是想要这样一个TBN
,它能把切线空间下的法向量转换成模型坐标系下的法向量,而我们求解T和B时却是用纹理坐标转换成模型坐标建议等式来建立约束,看起来我们是在求一个能把纹理坐标转换成了模型坐标的TB。
为什么这样求得的T和B,再加上N,为什么就能实现能把切线空间下的法向量转换成模型坐标系下的法向量?
这一点我没看到有文章能说清楚,这里我尝试按我的理解来解释下,重新强调一下我们的求解目标:求TBN坐标系的三个基向量,用它能把切线空间下的法向量转换成模型坐标系下的法向量。
我的思路是对于模型的某个三角形,把法向量纹理图按这个三角形的对应的纹理坐标对齐,再把T轴B轴分别与U轴V轴对齐,大概会得到上图第3个图那个样子,这时TBN坐标系就固定了下来,就是我们最终要求的,只是现在还不知道TBN三个坐标基是多少,然后我们把TBN坐标系及法向纹理图直接抽走,保留这个形态,得到上图最右边的样子。那么对于那个黄色三角形以及绿色的法向量来说,从上图第3个图到上图最右图的转换实际上就是从切线空间到模型坐标系的转换,转换的方法就是前面提到的线性代数知识点,将TBN坐标系下的坐标值,用TBN三个基在模型坐标系中对应的值来变换。
现在要求TBN,我们来列些式子,我们不知道绿色的法向量变换后的坐标,因为它就是我们最终想通过TBN变换求得的,但是我们知道黄色三角形变换后的坐标,也就是三角形对应的模型顶点在模型坐标系下的坐标,这样就能通过把TBN坐标系下的那个三角形变换到模型坐标系下的那个三角形建立变换等式,也就是前面这个式子:
通过这两个式子就能求解出T和B:
求解过程并不复杂,就是简单的线性代数计算,LearnOpenGL中步骤也比较清楚了,这里不再展开。求得T和B之后,再拼上模型的法向量,就得到TBN矩阵,此时就求解完成了。
虽然说最后得到的这个式子不算太复杂,但是如果在计算光照时,每个片元的都这么算一下,开销还是不少的,毕竟fragment shader的执行次数会比较多,一般来说有2个优化策略:一个将TB提前算好,另一个是将光照计算转换到切线空间进行。
提前计算TB
大家仔细观察可以发现,TB的计算仅依赖模型中的数据,因此可以提前算好存储在模型中,glTF格式也支持这种做法,以下是glTF格式attribute中对tangent数据的说明:
Name | Accessor Type(s) | Component Type(s) | Description |
---|---|---|---|
TANGENT |
"VEC4" |
5126 (FLOAT) |
XYZW vertex tangents where the w component is a sign value (-1 or +1) indicating handedness of the tangent basis |
来源:github.com/KhronosGrou…
看一些glTF官方的样例模型也能找到这样的字段:
...
"meshes" : [
{
"name" : "TwoSidedPlane",
"primitives" : [
{
"attributes" : {
"NORMAL" : 2,
"POSITION" : 1,
"TANGENT" : 3,
"TEXCOORD_0" : 4
},
"indices" : 0,
"material" : 0,
"mode" : 4
}
]
}
]
...
复制代码
注意glTF只支持tangent,不支持同时存tangent和bitangent,我猜测原因是bitangent能够通过normal和tangent叉乘得到,所以不需要存,如果能存的话也能少掉这个叉乘的计算,但模型也会变大些。
将光照计算转换到切线空间
要得到法向纹理中的法向量,要对法向纹理进行采样,最简单直接的做法就是在fragment shader中对一个片元计算光照时从法向纹理中采样得到法向量,再用TBN矩阵转换到模型坐标系,最终用模型矩阵转到世界坐标系中计算光照,但由于fragment shader执行的次数一般比较多,就会导致这样的TBN矩阵变换次数较多,有一个常用的优化方法,就是将光照计算放到切线空间中:
// vertex shader
...
out vec3 v_tangentLightPos;
out vec3 v_tangentViewPos;
out vec3 v_tangentFragPos;
void main() {
...
mat3 normalMatrix = transpose(inverse(mat3(u_modelMatrix)));
// 将TBN三个坐标轴转到世界坐标系
mat3 worldTBNMatrix = mat3(
normalize(normalMatrix * tangent),
normalize(normalMatrix * bitangent),
normalize(normalMatrix * a_normal.xyz)
);
// 将模型坐标系下的顶点坐标转换到世界坐标系
vec3 fragPos = (u_modelMatrix * a_position).xyz;
// 求世界坐标系下TBN三个坐标轴矩阵的逆矩阵
mat3 inversedWorldTBNMatrix = transpose(worldTBNMatrix);
// 将光源位置从世界坐标系转换到切线空间
v_tangentLightPos = inversedWorldTBNMatrix * u_lightPos;
// 将观察点位置从世界坐标系转换到切线空间
v_tangentViewPos = inversedWorldTBNMatrix * u_viewPos;
// 将模型顶点坐标从世界坐标系转换到切线空间
v_tangentFragPos = inversedWorldTBNMatrix * fragPos;
...
}
// fragment shader
...
in vec3 v_tangentLightPos;
in vec3 v_tangentViewPos;
in vec3 v_tangentFragPos;
...
void main() {
...
// 从法向纹理中读取法向量坐标,转成-1~1范围并归一化
vec3 normal = texture(normalTexture, v_texCoord0).rgb;
normal = normalize(normal * 2.0 - 1.0);
// 光源方向
vec3 lightDir = normalize(tangentLightPos - tangentFragPos);
// 视点方向
vec3 viewDir = normalize(tangentViewPos - tangentFragPos);
// 反射光方向
vec3 reflectDir = reflect(-lightDir, normal);
// 光照计算
...
}
复制代码
vertex shader中一开始求了一下模型矩阵的逆矩阵,然后又转置了一下,是干什么用的?这个矩阵是为了解决有些情况下将模型的法线与顶点一起变换后,法线不再垂直于三角形平面的问题。
举个简单的例子:
假设有左图中的三角面片及法向量,施加一个模型矩阵变换,此模型矩阵的变换为将x缩放0.5,缩放后法向量n=(0.5,1,1)
,A'C=(-0.5,0,1)
,显然n与A’C已经不垂直了,因此变换后法向量不再垂直于这个三角面片。为什么将模型矩阵的逆矩阵转置了一下能解决这个问题?给大家找了一篇文章:zhuanlan.zhihu.com/p/72734738, 感兴趣的朋友可以去研究下。
我们把tangent和bitangent也像a_normal那样用normalMatrix变换到世界坐标系,变换后的tangent、bitangent和a_normal组成的矩阵我称为worldTBNMatrix,它的作用从原来的从切线空间转换到模型坐标系变成了从切线空间转换到世界坐标系,为什么?回想文章前面提到的线性代数知识点,变换后的tangent、bitangent和a_normal已经是世界坐标系下的了。我们的目标是希望在切线空间计算光照,现在有了一个能从切线空间转换到世界坐标系的矩阵,那么它的逆矩阵就能从世界坐标系转换到切线空间,又因为worldTBNMatrix是由三个两两垂直的坐标基组成的,因此它是正交矩阵,由线性代数基础知识可知道正交矩阵有一个性质,就是正交矩阵的逆矩阵等于它的转置,因此直接转置就得到了它的逆矩阵,省去了复杂的求逆计算。v_tangentLightPos、v_tangentViewPos和v_tangentFragPos通过varying的方式传递到fragment shader中,由于v_tangentLightPos和v_tangentViewPos分别对于一个三形的3个顶点是一样的,所以插值后也还是一样的,而v_tangentFragPos是会在3个顶点坐标之间插值得到不一样的坐标。
上面的过程稍微有些复杂,我画张图表示下:
好了,这篇文章就先说到这,谢谢阅读!
参考: