从Max中导出的数据包括三角形的数据以及顶点的数据,通常对于多个面共享的顶点都需要将这些点复制。对于一个点来说它的法线等于共享它的所有的面的法线之和,切线副法线也一样。
这样做理论上貌似可行,但是实际工程中常常碰到一些问题,例如uv镜像会造成法线贴图反向、uv严重旋转效果不理想的问题等等。
目标
针对传统做法的不足,论文中提出了以下目标:
1、容易集成、高效、鲁棒。
2、支持uv镜像
3、尽量少的顶点分裂
4、和Tesselation无关,解决L型问题。
5、支持UV大于1或者小于0
6、支持常见的法线贴图压缩格式
7、无浮点异常
8、易测试
9、能够支持光滑组
10、能够定义hard edge
分析问题
L型问题
L型问题指的是上图这样,有2个三角形共享顶点B,而有3个三角形共享顶点A,如果按照传统的方法来计算顶点法线的话,那么结果就是图中红色的法线。这个问题叫做Tesselation相关。
解决这个问题的方法是,计算出所有的面法线相加,同时需要用角度做权值,例如:B点的法线,使用面ABE的法线乘以∠ABE,加上面ABD的法线乘以∠ABD。
UV镜像的问题
如图,在UV空间中,三角形ABC和ACD关于AC对称的,A、C两点被两个三角形共享。
UV镜像是一个很简单的问题,一般来说如果一个顶点被N个面共享,那么这个顶点会分裂成N个。
CryEngine中需求是将顶点分裂减到最低,所以CryEngine中只有检测到一个顶点是经过了UV镜像才分裂。
解决镜像问题的第一个问题是:如何判断是否存在镜像关系?
A、C分别隶属于两个三角形,所以A、C分别都具有两组TBN。
方法:对于A点来说,取其中每一组TBN,计算法线和T、B叉乘后的夹角,分别比较这些夹角,如果的方向不一致,即点积后的符号不一致,那么就证明存在有uv镜像,需要分裂顶点。
第二个问题是,如何处理镜像的UV?
镜像的UV照成的结果是切线空间的手相性反了,解决的办法是在顶点中保存手相性,用+1或者-1保存即可,如果T和B的叉积和法线的点积小于0的话,就证明手相性反向了。
顶点中保存的手相性将会被传入到VertexShader中。
UV旋转问题
上图中三角形ABD,和三角形BCD是mesh中的2个三角形。如果美术在Max中使用UV修改器单独旋转了三角形BCD的UV,如果旋转度数比较大的话,需要分裂顶点。
如果检查到顶点的uv是由某一个uv旋转某个角度得来的,且该角度大于90度时,那么就需要分裂顶点。
判断uv旋转的方法是:
取三角面的T、B相加得到的向量HalfUV,将该向量绕着轴k旋转一个角度a,角度a是顶点法线和面法线的夹角,轴k是顶点法线和面法线的叉积结果。
将旋转后的HalfUV和三角形中(T+B)做点积,如果<0的话,就证明有大于90度的旋转。
计算流程
计算TBN的过程发生在CMeshCompiler::Compile中,这个函数中会进行多个操作,计算顶点的TBN只是其中一步,从函数的注释可见一斑:
// . Sort|Group faces by materials
// . Create vertex buffer with sequence of (possibly non-unique) vertices, 3 verts per face
// . For each (non-unique) vertex calculate the tangent base
// . Index the mesh (Compact Vertices): detect and delete duplicate vertices
// . Remove degenerated triangles in the generated mesh (GetIndices())
// . Sort vertices and indices for GPU cache
其中,计算顶点TBN分为了4个步骤
1、计算每个面的TBN
法线计算使用三角形两边的叉乘实现。计算切线副法线就是老掉牙的方法:
在切线空间中,三角形任意一个向量可以表示为T、B线性拟合。
例如:
所以,
注意点:
1、如果求逆矩阵的时候,判别式=0,即uv重合的情况,要返回错误
2、切线和副法线要乘以三角形面积,意思是用三角形面积做权值。
2、计算每个顶点的法线
遍历每一个三角面,收集三角形中每一个顶点法线。计算顶点法线就是将共享该定点的所有三角面的法线相加,同时为了解决L型问题,
需要用三角形中顶点的两条边的夹角做权重乘以面法线。
这个过程中并不需要计算顶点分裂。
通过该过程,初步得出了所有顶点的法线。
接下来还需要计算每个顶点的T、B以及根据uv情况决定是否还要分裂顶点。
3、根据顶点T、B计算是否需要分裂顶点
遍历每一个三角面,计算三角形中每一个顶点的T、B。计算的方法和法线一样,将共享某个顶点的所有三角面的T或者B相加起来。
同时为了解决L型问题,需要用三角形中顶点的两条边的夹角做权重乘以面的T或者B。
这里需要根据每个顶点的T、B计算是否有UV镜像,如果有的话分裂顶点。
还需要计算是否有比较大的UV旋转,如果有的话也需要分裂顶点。
这一步完成以后,一个mesh的全部的顶点切线空间的基就计算完毕了,剩下的只是进行正交化和压缩了。
4、正交化
采用思密特正交化的方法。将u和v分别投影到n所在的平面,然后通过叉积计算新的n,最后归一化。
5、压缩、保存手相性
将每个32位浮点数压缩成16位的定点数,压缩的方式是:
_inline int16f tPackF2B(const float f)
{
return (int16f)(f * 32767.0f);
}
自然,解压缩的方式是:
_inline float tPackB2F(const int16f i)
{
return (float)((float)i / 32767.0f);
}
前面说过,当uv镜像时可能出现手相性的问题,需要给每个切线基保存一个符号,表示手相性。
if ((Tangent cross Binormal) dot TNormal < 0)
{
pBasises[i].Tangent.w =-1
pBasises[i].Binormal.w = -1
}
杂项
论文的最后给出了几条关于法线贴图的Tips,我觉得这写Tips是整个文章中最有用的部分。
其中,最后一条提到,由于使用8位的贴图保存法线贴图,会造成精度丢失的问题,相当于是把-1到1的浮点数转化到8位定点数,
这照成的最大问题就是,当把采样出来的值再映射到-1到1的区间时,0这个值丢失掉了。这很容易证明:
保存法线贴图的时候,首先将数值转化到0到1,所以原本是0的值变成了0.5,然后再乘以255,丢弃掉小数就变成了127。
122这个数再转化为浮点数就就再也变不回0。
当法线贴图和坐标轴平行时,这个问题比较容易看出来。
所以,文章中建议在Shader中不要使用*2-1的方法而是使用*(255/128)-1的方法。
检查切线空间,用下面的图片检查切线空间是否连续
用下面的图片检查uv镜像、uv旋转的问题