转载请注明出处为KlayGE游戏引擎,本文的永久链接为http://www.klayge.org/?p=2038

前言:对tangent frame的压缩,其实在2008年做All-frequency rendering of dynamic, spatially-varying reflectance这篇paper的过程中,就曾在茶余饭后讨论过这个事情。这其实是个很trivial的问题,但看到Crytek和Avalanche分别在两年的SIGGRAPH talk上都煞有介事地提到这个问题,才想写这篇blog介绍一下。

Avalanche Studios的Emil Persson,也就是Humus,在SIGGRAPH 2012上的talk Creating Vast Game Worlds中提到了他们在游戏中如何压缩顶点数据,其中对于tangent frame的压缩与去年Crytek的Spherical Skinning with Dual-Quaternions and QTangents的压缩方法极其相似。但在两者的介绍中,由于某些原因,总有一些细节的陷阱没有提到。然而,在实际使用中,对付这些陷阱上所花的时间往往会超过实现方法本身。所以,本文试图通过对整个方法以及细节陷阱的描述,集中地把这个问题阐释清楚,希望对有兴趣的读者有所帮助。

Tangent frame

在现代游戏中,要做per-pixel lighting少不了用到tangent frame。完整的tangent frame由tangent、binormal和normal三个向量来表示。如果每一个向量都用保存,并且每个分量都用float的话,就需要3*3*4=36字节。对于vertex来说,这个消耗是巨大的。

压缩到28字节

最直接的想法就是,因为tangent、binormal和normal是正交的,所以只要保存其中2个,另一个在runtime通过cross计算出来就行了。(至于要省略哪个其实都一样,但由于KlayGE里省略的是binormal,本文就按照这一点来描述。)这样就压缩到了2*3*4=24字节,但是要注意一个大陷阱。

陷阱1

很多artist在做对称物体的纹理坐标时,都是只做了一半的纹理坐标,然后通过max或者maya的镜像方式布上另一半。在这种情况下,cross(normal, tangent)的结果是-binormal,而不是binormal。如果不注意这点,镜像部分的normal map就会得到相反的结果,如Crytek的这张图所示,左边的考虑了镜像,右边的没考虑:

Mirror

所以除了tangent和normal,还需要多保留一个称为reflection的值,取-1或+1。在计算binormal的时候,也就成了binormal = cross(normal, tangent) * reflection。所以为了和原始的tangent frame达到一样的表达能力,最终需要(2*3+1)*4=28个字节。Avalanche Studios的方法完全忽略了这个问题,他们估计只能通过限制artist的操作来回避了。

压缩到8字节

28字节对于vertex来说仍然太大,而且很明显可以对其做进一步压缩。因为tangent和normal都是单位向量,而且精度要求没那么高,每个分量都用float来保存实属浪费。最贴近的格式是A2BGR10。这种格式的BGR都是10-bit,A是2-bit,对于tangent来说,RGB分别存xyz,A存reflection。对于normal来说,舍弃A也只是浪费2-bit。如果硬件不支持A2BGR10,用ARGB8也可以,肉眼基本看不出区别。通过这种方式,tangent frame被压缩到了8个字节。这也是KlayGE 4.1之前使用的存储方法。

压缩到4字节

8字节已经比一开始少得多了,还能做进一步压缩吗?能,我们的目标是4字节!tangent和normal的方法,在8字节已经走到头了,我们需要回到原点,回到原始的tangent frame表达。从本质来说,tangent frame表示的是一个局部坐标系的旋转(当然,还有可能有镜像),和原点本身的位置无关,很自然可以想到用quaternion来表示tangent frame。一个正交的3*3的matrix,可以通过quaternion用4个分量无损地表达出来。这正好适合我们的要求,但也需要注意几个大陷阱。

陷阱1

把matrix转成quaternion的时候,默认的假设是matrix是正交的。也就是说,你的tangent frame必须正交。但因为在建立tangent frame的时候,往往是先单独算normal,在生成tangent和binormal,正交并不一定能直接得到完全的保证。所以在计算出tangent的时候需要额外做一次正交化:

tangent = normalize(tangent - normal * dot(tangent, normal));

经过这步操作,tangent和normal就互相垂直了,符合quaternion的条件。注意上式的normal需要事先normalize过。

陷阱2

这里仍然有个镜像的问题。送去生成quaternion的matrix必须去掉reflection,在得到quaternion之后再把reflection加回去。注意到quaternion有个很好的性质,就是q = -q(q的类型是quaternion),所以我们就可以开始打.w分量的主意了:

if (q.w < 0)
   q = -q;
if (reflection < 0)
   q = -q;

经过这两个if,reflection就保存到了q.w的符号位上,而q本身表示的旋转没有改变。在shader中,只需要简单地:

reflection = q.w < 0 ? -1 : +1

就可以恢复出reflection。

陷阱3

如何把quaternion保存到4字节?做法之一是和前面一样,用A2BGR10的格式,RGB分别保存xyz,A保存符号。因为quaternion是归一化的,所以可以在vertex shader中通过计算来恢复w。这么做的优点是精度较高,缺点是计算量增加了。

陷阱4

能不能就把quaternion保存到ARGB8?很可惜,还是有个陷阱。如果完全按照IEEE的浮点规范,0也是有+0和-0的区别的,所以即便q.w == 0,q.w的符号照样可以用来保存reflection。但如果保存到8-bit的通道,就已经不再是浮点数,所以不会在遵循那个规则,+0和-0都会变成+0。为了保住q.w的符号,这里必须保证q.w不等于0,但也不能大到影响q本身。(Crytek认为这是因为GPU不完全遵守IEEE浮点规范造成的,但很显然,保存到8-bit之后就和浮点规范无关了。)对于需要量化到8-bit的情况下,最小的值就等于1/127。同时,为了保证q仍然是归一化的,需要把xyz三个分量都乘上一个系数:

float bias = 1.0f / ((1UL << (8 - 1)) - 1);
if (q.w < bias)
{
	q.xyz *= sqrt(1 - bias * bias);
	q.w = bias;
}

最终,我们得到了一个既不浪费计算量,又能把tangent frame压缩到4字节的方法。

红利

用quaternion来表示tangent frame,除了能压缩数据之外,还有一些额外的红利。如果用了dual quaternion形式的骨骼动画,可以在带权累积DQ之后,直接乘上tangent frame的quaternion,而不必把quaternion解出tangent、binormal和normal后分别计算骨骼变换。

未来

在Crytek的ppt里,还提到了一些未来可能能做的事情。包括了quaternion的传递和把quaternion保存在G-Buffer。这两者其实都涉及到插值的问题。目前硬件在VS/GS->PS的时候,可以分属性选择线性插值、不插值、不透视矫正,但quaternion的插值是圆周插值,硬做线性插值会导致错误的结果。所以如果在未来的硬件中,或者像salvia这样的软件光栅化渲染器中,提供了slerp这样的插值方式,就能顺利地把tangent frame的quaternion传递到PS,并且能保存到G-Buffer中。如果G-Buffer保存的是完整的tangent frame,而不光只有normal,就可以在计算lighting的时候使用更为复杂的各向异性BRDF。