程序丨面向数据设计的冒险之旅(四):提升蒙皮的性能
译者:崔嘉艺(milan21)
审校:王磊(未来的未来)
系列回顾:
在完成这个系列教程关于数据所有权的第三部分之后,我们将再次关注性能优化和数据布局。 更具体地说,我们将详细介绍如何通过几个简单的代码和数据上的更改来优化角色的蒙皮性能。
我们将要优化的初始示例程序将完成以下工作:
l 蒙皮和渲染100个角色,每个角色有自己的动画和蒙皮。
l 每个角色都可以在不同的动画帧上播放不同的动画,这意味着不会重复使用共享数据。
l 每个顶点可能会受到最多4根骨骼的影响。
l 没有实例化渲染,没有网格的LOD。
我们要使用的角色有大约五千个个三角形,位置和法线数据,以及一组纹理坐标。这是示例的输出结果:
100个单独的带动画的角色。
我们只对角色蒙皮的性能数据感兴趣,比如像是中央处理器对所有100个角色进行蒙皮的时间。我们对渲染方面不感兴趣。
从上面的截图可以看出,每个角色都分配了三种不同的材质之一,并从随机选择的动画帧开始播放三种动画之一(蹲下,走动或是跑),这样数据就不能在角色之间重复使用。
第一个实现
在最初的版本中,角色蒙皮的实现如下:
l 假设每个顶点受到1到4个骨骼的影响。
l 目标顶点流由float3类型的位置数据,float3类型的法线数据和float2类型的纹理坐标数据组成。
l 角色的蒙皮代码单线程运行。
l 角色的蒙皮代码使用SSE优化过的数学例程进行所有矩阵和矢量操作。
在C ++ 伪代码中,角色的蒙皮代码基本如下:
for each vertex i
{
// load indices and weights
uint16_t index0 = jointIndices[i*4 + 0];
uint16_t index1 = jointIndices[i*4 + 1];
uint16_t index2 = jointIndices[i*4 + 2];
uint16_t index3 = jointIndices[i*4 + 3];
float weight0 = jointWeights[i*4 + 0];
float weight1 = jointWeights[i*4 + 1];
float weight2 = jointWeights[i*4 + 2];
float weight3 = jointWeights[i*4 + 3];
// build weighted matrix according to matrix palette and bone weights
matrix4x4_t mat0 = MatrixMul(matrixPalette[index0], weight0);
matrix4x4_t mat1 = MatrixMul(matrixPalette[index1], weight1);
matrix4x4_t mat2 = MatrixMul(matrixPalette[index2], weight2);
matrix4x4_t mat3 = MatrixMul(matrixPalette[index3], weight3);
matrix4x4_t weightedMatrix = MatrixAdd(mat0, MatrixAdd(mat1, MatrixAdd(mat2, mat3)));
// skin position, normal, tangent, bitangent data
vector4_t position = srcVertexData[i].position;
...
vector4_t skinnedPosition = MatrixMul(weightedMatrix, position);
...
dstVertexData[i].position = skinnedPosition;
...
// copy unskinned data
dstVertexData[i].uvs = srcVertexData[i].uvs;
...
}
对于那些熟悉角色的蒙皮的概念的读者来说,应该很清楚代码在做什么。 它首先根据骨骼权重建立加权矩阵,并使用动画系统生成的矩阵调色板。 然后,使用加权矩阵对位置,法线数据等进行蒙皮处理,并且简单地复制诸如纹理坐标,顶点颜色等所有剩余的非蒙皮数据。
通过这个实现,在我的系统(i7-2600K,4x 3.40 GHz)上对100个角色进行蒙皮花了4.15毫秒。 但是还有很多需要改进的地方,所以我们先从最简单的优化开始。
对流进行拆分
在上面的循环中可以注意到的第一件事是对非蒙皮数据的复制。即使这些数据永远不会改变,但每一次都必须被复制。
为什么?在大多数情况下,我们不能锁定/映射数据,而不必再用所有的数据填充顶点缓冲区,举个简单的例子来说,像是使用D3D11_MAP_WRITE_DISCARD的时候。即使个人电脑的API中有不同的标志,或者如果我们直接访问图形处理器内存(就像我们在控制台上做的那样),写部分数据也是一个坏主意。大多数图形处理器的资源驻留在写入组合内存中,在写入数据时留下漏洞是一个特别糟糕的想法。在游戏机上,只将部分数据写入动态缓冲区才是真正的性能杀手!
Molecule引擎解决这个问题的方法是将蒙皮组件的顶点数据流分成两部分。一部分顶点数据流包含所有需要被蒙皮的数据,另一部分顶点数据流包含所有非蒙皮的数据。顶点数据在资源管线中被简单地分成两个离线流,而引擎运行时在渲染的时候使用两个而不是一个顶点流。
这种优化完全消除了所有不必要的非蒙皮数据的拷贝,并将性能数据从4.15毫秒降低到4.00毫秒。有更多的非蒙皮数据(顶点颜色,第二组Uv等)以及我们拥有的顶点越多,节省的成本就越大。
单指令多数据流(SIMD)友好的数据布局
如同在第一个版本的实现中所描述的那样,位置数据(以及任何其他蒙皮数据)作为float3类型存储在目标顶点流中。大多数单指令多数据流(SIMD) ISA(比如像是Intel的SSE指令)一次运行4个浮点数,并且只支持从存储器读取/写入整个float4类型。这意味着为了将3个单独的浮点数(x,y和z)存储到内存中,我们首先必须将它们从单指令多数据流(SIMD)数据类型中提取到不同的浮点数中,然后将它们写入内存。
这不好。所有的数据应该尽可能保持在同一个中央处理器的管线中。标量数据应该停留在标量流水线中,单指令多数据流(SIMD)数据应该保留在单指令多数据流(SIMD)流水线中。将数据从一个管线移动到另一个管线总是会损害性能 - 在主机上,从浮点数据类型到单指令多数据流(SIMD)数据类型(反之亦然!)总是会导致加载上的惩罚,因为数据必须通过内存传播!在整数流水线和浮点数流水线之间移动数据也是如此,但是我们不要深入到基于PowerPC的控制台体系结构的领域中去。
幸运的是,所有这些都可以通过稍微增加顶点缓冲区的大小来解决。不再是期待float3皮肤的数据,Molecule引擎中的所有蒙皮顶点缓冲区都希望数据是float4类型。这使我们能够将SSE数据类型直接写入顶点缓冲存储器。此外,D3D11保证缓冲区数据为16字节对齐的,这意味着我们可以使用对齐的存储(_mm_store_ps,MOVAPS)。
此外,通过将源数据从float3类型更改为float4类型并将其正确对齐到16字节边界,我们也可以使用对齐的读取(_mm_load_ps,MOVAPS)。
这意味着我们可以直接从内存加载到单指令多数据流(SIMD)类型,对这些数据类型执行所有操作,并将结果写回内存,而不必离开单指令多数据流(SIMD)管道。这个优化将性能数据从4.00毫秒降低到3.53毫秒。请注意,即使我们必须读取和写入更多的数据,性能仍然会提高。这是为作业选择正确的数据布局的一个很好的例子,这并不总是意味着保持数 46 36178 46 16939 0 0 6086 0 0:00:05 0:00:02 0:00:03 6086尽可能的小!尽管不得不承认,大部分时间保持读取或写入的数据量尽可能小是一个好主意。
运行时友好的数据布局
请记住,面向数据的设计主要是关于如何读取,转换和写入数据。一个原则是在内存中保持同质的数据流连续,只存储尽可能少的数据。这意味着对于某些类型的数据(主要的例子是粒子数据),以SoA而不是AoS的方式存储数据通常是有益的。
在我们的例子中,我们需要的所有蒙皮数据已经以缓存友好的方式存储好了:
l 所有的联合索引和权重都是连续存储的。它们被顺序访问,没有在内存中出现跳跃的情况。
l 顶点数据只包含了需要蒙皮的数据,已经清除了非蒙皮的数据。
l 顶点数据按顺序读取和写入,不在内存中进行跳转。
没有任何有关数据本身的额外知识,可以说内存布局已尽可能的缓存友好了。
但它并不止于此! 即使数据已经连续存储,考虑数据包含的内容也是有意义的,如果考虑这些事情能够允许我们更好地优化内存访问的话。
请记住,我们在文章的开头部分提到“每个顶点被假定为受到1到4个骨骼影响”。 这意味着对于骨骼影响小于4的顶点,相应的权重将为零,在构建加权矩阵的时候,我们正在做很多不必要的工作。
我们怎样才能摆脱多余的操作? 新手程序员经常通过分支条件来尝试摆脱额外的工作,如果条件不成立,则不做任何事情。 在我们的例子中,我们可以简单地分支不同的权重,并检查它们是否实际上对加权矩阵有贡献,如下所示:
if (weight0 != 0.0f)
{
weightedMatrix = MatrixAdd(weightedMatrix, MatrixMul(matrixPalette[index0], weight0));
}
if (weight1 != 0.0f)
{
weightedMatrix = MatrixAdd(weightedMatrix, MatrixMul(matrixPalette[index1], weight1));
}
if (weight2 != 0.0f)
{
weightedMatrix = MatrixAdd(weightedMatrix, MatrixMul(matrixPalette[index2], weight2));
}
if (weight3 != 0.0f)
{
weightedMatrix = MatrixAdd(weightedMatrix, MatrixMul(matrixPalette[index3], weight3));
}
坏消息是,通过引入这些额外的分支,代码很可能会比原来的执行速度更慢! 硬件分支预测在检测某些特定模式的分支方面非常出色,但如果是面对或多或少具有一些随机性的分支相比并不是那么的号。 而且,像目前的PowerPC控制台这样的有序中央处理器的分支预测惩罚每个都要花费大约24个周期,因此无论如何你最好做下数学上的计算。
如果你的蒙皮网格有很多顶点,受到1或2个骨骼影响,代码可能运行得更快,但是我们真的希望在每种情况下都能确保运行速度更快,不仅是在边缘情况下。
另一个选择是考虑存储每个顶点受到骨骼影响的数量,并用单独的循环代替构建加权矩阵的代码,类似于以下代码:
for each influence j
{
weightedMatrix = MatrixAdd(weightedMatrix, MatrixMul(matrixPalette[index_j], weight_j));
}
请注意,我们现在需要存储每个顶点受到影响的骨骼的数量,并且为每个顶点添加了一个内部循环的开销。是的,在这些地方循环确实有可测量的开销。此外,由于每个顶点的影响数量,我们需要从内存中获取更多的数据。如果你的蒙皮网格有很多3或4个骨骼影响的顶点,那么由于额外的开销和内存的提取,“优化的”代码很可能会运行得更慢 - 所以我们再次针对边缘情况进行了优化。
我们真的希望代码能够很好地适应输入数据,而不管数据是什么。
存储每个顶点受到骨骼影响的数量已经走向正确的方向,但引入了太多的开销。但是我们可以做一件简单的事:不是存储每个顶点受到骨骼影响的数量,为什么不对数据进行排序,而是存储具有一定影响数量的顶点数呢?我们知道我们有1,2,3或4种影响,所以我们需要存储的是4个循环次数!
因此,我们不是以任何顺序存储顶点,而是重新排列它们(连同索引缓冲区),以便源数据首先包含所有只受到1个骨骼影响的顶点,然后是所有受到2个骨骼影响的顶点,依此类推。 除此之外,我们还存储了受到1个骨骼影响的顶点的数量,受到2个骨骼影响的数量,等等。
所有这些都可以在资源管道中方便地完成,所以不会产生额外的运行成本。
好消息是,这使得我们只能通过为每个路径编写专门的代码来为每个顶点做最小的工作。 此外,这开创了新的优化可能性,因为我们现在完全知道数据包含什么! 举例来说,我们不需要存储只受到1个骨骼影响的权重 - 无论如何这个权重是1.0f。 这节省了内存,并导致更少的内存访问。
最后,代码现在看起来类似于以下内容:
for numVertices1Bone:
{
uint16_t index0 = jointIndices[i];
vector4_t position = srcVertexData[i];
vector4_t skinnedPosition = MatrixMul(matrixPalette[index0],position);
dstVertexData[i] = skinnedPosition;
}
for numVertices2Bones:
{
uint16_t index0 = jointIndices[i*2 + 0];
uint16_t index1 = jointIndices[i*2 + 1];
float weight0 = jointWeights[i*2 + 0];
float weight1 = jointWeights[i*2 + 1];
matrix4x4_t mat0 = MatrixMul(matrixPalette[index0], weight0);
matrix4x4_t mat1 = MatrixMul(matrixPalette[index1], weight1);
matrix4x4_t weightedMatrix = MatrixAdd(mat0, mat1);
vector4_t position = srcVertexData[i];
vector4_t skinnedPosition = MatrixMul(weightedMatrix, position);
dstVertexData[i] = skinnedPosition;
}
// code for 3 and 4 influences omitted
请注意,我们只能执行绝对需要的操作。没有内部循环,没有额外的分支,只是简单的代码。
正如你可能已经猜到的那样,这个优化产生了最大的效果 - 它将性能数据从3.53毫秒降到了2.00毫秒。当然,这取决于每个顶点所受到骨骼影响的数量,但是这种方案在任何情况下都是最优的,而不仅仅是边缘情况。对于五千个测试角色,每个顶点的影响百分比粗略地分布如下:
30%的顶点受到4个骨骼的影响,25%的顶点受到3个骨骼的影响,20%的顶点受到2个骨骼的影响,25%的顶点受到1个骨骼的影响。
最后的优化
作为最后的优化,代码可以被重写,使其更易编译,从而将性能数据从2.00毫秒降低到1.78毫秒。因为这些优化是针对特定编译器的,所以在这里我不会进行详细讨论。 MSVC似乎喜欢紧密的代码,因为这会导致更少的寄存器溢出堆栈上。 GCC似乎赞成更详细的代码(类似于上面所写的代码),它清楚地显示了常量和无别名的变量。
最后但并非最不重要的是,构建一个64位可执行文件会带来额外的15%的加速,因为有更多的SSE寄存器可用,导致寄存器溢出的减少。
结论
最后,通过一些数据转换,一些代码更改以及64位系统的构建,我们设法获得了2.75倍的性能提高体验。所有这一切花了大约半天的时间,绝对值得。利用Molecule引擎的任务系统并完全多线程化可以进一步提高性能,在所有可用内核之间几乎完美地(线性)缩放。如上所示,代码仅由4个直接循环组成,可以并行化。
最后要说明的是,数据转换对基于计算着色器的体系结构也是有益的,因为数据读取的次数少,工作量也减少了。
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权。
今日推荐
你没见过的另类迪士尼公主,美国艺术家TsaoShin作品欣赏
一键添加
加小编微信,享双重福利
1.加入GAD程序猿交流群,获取行业干货;
2.领取60G腾讯内部分享等独家程序资料。