系列回顾:
第一传送门: 第二传送门: 第三传送门:
试想一下,当我们在游戏场景中放置大量(数百或数千)带有骨骼皮肤动画的单元时,我们会发现帧率已经开始下降。 为什么是这样?
经过多年的研究3D素材,我发现造成这种情况的根本原因是:放的太多了。
但是,在开发某些类型的游戏时(比如策略或即时战略等),通常需要尽可能多地放置小兵或怪物,以烘托战场气氛。
大量的字符呈现和保证性能是一件很麻烦的事情; 如果你也在尝试解决这个问题,还没有找到合适的方法,接下来的内容或许能帮到你; 因为我们将进入本系列的下半部分:总结骨骼蒙皮动画比较成熟的优化方案。
1.1 骨骼皮肤动画的开销
“欲修神功,必先出宫”是笑傲江湖中非常有名的台词,也是《葵花宝典》和《辟邪剑》武林秘籍中的第一句话. 以前听的时候,只觉得是邪门武功的象征; 但是现在想想,当时我还小,所以不懂。 学习任何技能和知识,其实都是一招一式,也是一把打开成功之门的钥匙。
其实这句话的真正含义是:做事,必须打好基础。
因此,要优化骨骼蒙皮动画,有必要简单了解一下其性能瓶颈。
1.2 骨骼蒙皮动画的过程
骨骼蒙皮动画的工作流程大致可以(简单粗暴地)分为以下几个阶段:
动画控制器会根据关键帧信息等调整骨骼的空间属性(旋转、缩放、平移)。
从根骨骼开始,按照层级关系逐一计算每根骨骼的变换矩阵。
这个矩阵连接了骨骼的局部坐标系和角色坐标系(通常在角色的脚下); 也就是说,某一帧动画结束后,将(某根)骨骼坐标系中的坐标或者向量,转换为角色坐标系。
更新网格上每个顶点的属性。
因为动画改变了骨骼的空间属性而不是顶点; 并且网格中的顶点是相对于网格坐标系,而不是字符坐标系。 所以在这个阶段,我们首先需要在制作骨骼蒙皮动画时,根据记录的顶点与骨骼的关系找到对应的骨骼(通过Unity中的Mesh.boneWeights获取,一个顶点最多可以被4个骨骼影响)。
其次,通过网格坐标系到骨骼局部坐标系的转换矩阵(通过Unity中的Mesh.bindposes获取),搭建起网格坐标系到骨骼坐标系的桥梁; 结合前一阶段得到的骨骼坐标系到角色坐标系的变换矩阵,实现动画对骨骼的最终效果到顶点,更新到角色坐标系。
动画控制器“间接”更新顶点属性
顶点转换到角色坐标系后,就可以进行渲染了。 这与普通渲染没有太大区别。 唯一需要注意的是,Unity 不会批处理蒙皮网格渲染器,因此每个骨骼蒙皮动画实例至少需要一个 DrawCall。
1.3 骨骼皮肤动画的开销
骨骼蒙皮动画的主要开销大致可以分为以下几个部分: 这里我创建了一个简单的场景来简单测试一下这些开销。
使用Unity 2018.4.14f1版本创建一个测试场景,包含500个使用相同模型的骷髅皮肤动画角色,并循环播放空闲、移动和攻击动画; 测试机为华为P20(Geekbench5跑分1400左右),通过Profiler查看运行时间。
简单的步兵模型
场景运行后
我们来梳理一下骨骼蒙皮动画相关的主线程计算开销,看看骨骼蒙皮动画工作时哪些计算耗时最多。
骨骼蒙皮动画相关的主线程耗时比
可以发现动画更新耗时最多,其次是蒙皮网格(计算矩阵、蒙皮等)的更新,最后是渲染。
需要指出的是,我使用的Unity(版本2018.4.14f1)将动画更新和换肤放到了工作线程中; 它没有反映在主线程中; 而且我打包的时候没有勾选多线程渲染(Multi-threadedRenderer),所以渲染指令的调用也是发生在主线程中。
动画更新在工作线程上执行
皮肤在工作线程上执行
渲染指令在主线程中被调用
1.4 常用优化方法
在Unity下,可以通过以下两种方式快速优化骨骼蒙皮动画:
两者的优化效果如何?
检查模型导入设置以优化
在同样的测试环境下,再次测试后,可以发现这种方式确实可以产生一定的效果。
耗时略少
原因我认为主要有以下两点:
1) 不要再为骨骼创建不必要的游戏对象
导入模型优化后,Unity不会为骨骼创建实际的游戏对象(我们也可以暴露一些骨骼作为挂点)。
这些消失的游戏对象也在一定程度上降低了CPU性能开销
2)计算矩阵移至工作线程
此外,Unity还将计算骨骼矩阵的操作放到工作线程中,以减少主线程的耗时。
计算矩阵在工作线程中执行
主线程耗时不再包含计算矩阵
开启GPU蒙皮,Unity会通过ComputeShader使用GPU进行蒙皮。
检查设置以启用 GPU 皮肤
GPU上有大量的ALU(Arithmetic Logic Units),可以高效并行进行大量的数值计算,应该很适合这种针对顶点属性的数值计算。
但实际情况是,在移动设备上使用 GPU 换肤实际上会增加主线程的耗时。
开启GPU换肤后主线程耗时增加
通过Profiler可以发现CPU在执行ComputeShader上花费的时间较多。 由于骨骼动画实例较多(500个),调用时间本身就成了性能热点。
在打开 GPU 时将 GPU 蒙皮添加到蒙皮网格更新中
执行 GPU 蒙皮需要很长时间
所以,从目前的情况来看,这种在移动设备上使用GPU蒙皮的方式似乎并不适合处理大量的骨骼蒙皮动画实例(可能是我的使用方式有问题)。
以上我们简要总结了骨骼蒙皮动画的工作方式,分析了主要的性能开销和耗时,并对Unity中常用的两种优化方式进行了比较。
然而,面对千变万化的角色性能需求,无论使用Unity自带的什么样的优化,都显得有些力不从心; 所以下面将介绍两种目前比较有效和成熟的“招数和招数”来。 (在某种程度上)解决这个问题。 即骨骼蒙皮动画单元批量渲染的两种优化方案:烘焙顶点动画和烘焙骨骼矩阵动画,
简而言之,他们的基本思想是将骨骼蒙皮动画的“结果”预先保存在一个纹理中; 然后在运行时通过GPU对该纹理进行采样,并使用采样结果更新顶点属性; 结合实例化技术(GPU instancing),可以达到高效大规模渲染的目的。
如果你之前不知道这种优化方案,看了上面的描述还是一头雾水; 太好了,这篇文章(可能)可以帮助您快速上手这种优化方案。
下面简单介绍一下它的工作过程和原理。
2.1 烘焙顶点动画
其工作流程可以简单分为两个阶段:
烘烤阶段
这个阶段其实就是为后期的播放阶段准备动画资源。 你也可以简单的把这个资源理解为一种特殊类型的动画文件。
首先,在编辑状态下,让带有骨骼皮肤动画的角色以一定的帧率播放动画。
在非运行状态下播放动画
我们知道,在播放动画时,角色网格会通过蒙皮网格渲染器(SkinnedMeshRenderer)进行更新,产生形变(顶点变化); 如果播放动画,则记录每个顶点相对于此时角色坐标的帧的位置(通常在角色的脚下)。 然后,当动画播放完毕后,我们会得到一张“每个顶点在每个关键帧的位置表”,我们姑且称之为“帧顶点位置表”。
但是“框架顶点位置表”这个名字太长了,大家可能不太好记,而且我打字也很麻烦,以后就叫它“表表”吧。
播放动画时,表记录
除了表格,我们还可以获取到它对应的动画信息,比如动画的名称、时长、帧率、总帧数、是否需要循环播放等等。
至此,我们第一阶段需要的内容就准备好了,可以进入下一阶段:动画阶段。
玩舞台
动画播放时,使用一个变量(播放时间)更新播放进度; 结合动画的总时长和上一阶段记录的动画总帧数,可以计算出当前动画已经播放的帧数(当前帧数=已播放时间/动画总时间×动画总帧数帧)。
一旦获取到当前动画帧,就意味着可以通过表锁定一行顶点数据。
通过关键帧找到的顶点数据
接下来,遍历这一行顶点数据即可; 然后根据索引在网格中找到对应的顶点并更新其位置,就相当于完成了蒙皮工作。
每一帧通过表格更新顶点属性,动画开始播放
可以看到,使用这种方法来更新角色动画,其实就是直接使用预处理过的骨骼动画和皮肤网格渲染器的结果。 这是一种以空间换取时间的策略。
既然可以直接通过一个播放进度、表格和一些简单的动画信息来进行蒙皮(更新顶点属性),那么我们就不再需要下面的东西了:
使用CPU读取表并遍历和更新所有的顶点显然不如GPU高效; 所以接下来,我们把这一步放到渲染管线的顶点变换阶段,利用GPU处理顶点的机会来完成表格的使用。 表中的数据更新顶点属性。
2.2 使用贴图保存动画数据
首先,为了方便GPU读取表格,我们将其保存为一张纹理,可以称之为动画纹理。
例如,对于一个有505个顶点的模型,我们可以将表格中的信息保存到一个大小为512 x Height的纹理中。
其中贴图的宽度用来表示顶点个数,贴图的高度用来表示关键帧,所以Height的取值取决于动画的长度和动画的帧率.
将表格中的顶点位置“烘焙”到纹理中
我们通过UV坐标获取这个纹理上的像素点,可以理解为:取第V帧的第U个顶点的坐标。
当然,除此之外,我们还会将动画信息保存为动画资源游戏素材,并将重要信息(动画名称、动画时长、总帧数、是否循环等)序列化。
使用ScriptableObject保存动画信息
下面的事情很简单:播放动画时,CPU将当前播放的关键帧传递给vertex shader; 顶点着色器计算对应的V坐标; 结合顶点索引和动画纹理的宽度计算出U,可以采样到顶点基于角色坐标系中的坐标; 然后使用这个坐标进行后续的空间变换。
法向量
由于顶点的实时位置是在播放动画时从纹理中采样的,而不是从网格中读取的(不再使用蒙皮网格渲染器,不会修改顶点缓冲区中的数据),所以the vertex attribute 中的法线信息也不可用(它始终处于静态状态); 如果需要获取正确的法向量,则需要在烘焙顶点坐标的时候对法线进行烘焙,在顶点变换阶段使用该方法将向量也采样出来。
烘烤正常纹理
有多个动画
通常,一个角色不只包含一个动画; 例如,小兵通常有空闲、移动和攻击三种动画。 如果为每个动画烘焙一个或两个(正常)纹理,纹理的数量很快就会失控。
由于所有动画对应的顶点个数相同,即贴图宽度相同,所以我们可以合并多个动画贴图。
三个动作合并为一个纹理
将多个动画贴图上下排列合并后,我们只需要将每个动画的起始和结束V坐标范围添加到动画资源中即可。 然后在播放和切换动画时,可以根据当前动画的起始位置和播放进度计算出正确的V坐标。
保存动作信息时需要记录一些额外的属性
当然,如果网格的顶点数量少,动画数量多,我们也可以多列放置动画(前提是可以放置)。
多列放置动作
动画过渡
简单的动画转场很容易实现,只要在切换动画时分别计算当前动画和下一个动画的播放位置,然后传递给GPU进行两次顶点位置采样,然后两次采样的结果为内插的。
动画过渡
没有动画的过渡
使用实例化渲染
实例化渲染的特点是使用相同的网格,相同的材质,通过不同的实例属性完成大量具有一定差异的渲染; 并且烘焙的顶点刚好满足实例化渲染的使用需求。
因此unity 顶点动画,我们只需要将控制动画播放的关键属性:如转场动画播放的V坐标、当前动画与下一动画的插值比例等,放入实例化的数据数组中进行调用即可; 然后在vertex shader中,设置key属性即可获取和使用。
实例化渲染时获取的关键属性
当然,如果你想实现更多不同的效果,比如附加一个mask texture来调整diffuse或者做一些特殊的计算,那么你还需要在instanced data中添加控制参数。 .
顶点着色器采样
在vertex shader中,不能使用tex2D进行采样,需要使用tex2Dlod来代替,但是这个特性需要shader model 3.0(#pragma target 3.0)支持。
顶点索引
可以通过语义SV_VertexID获取顶点索引,但是这个特性需要在移动平台上使用OpenGL ES3.0(#pragma target 3.5)(当然也可以在烘焙时将顶点索引保存到mesh属性中阶段)。
与蒙皮网格渲染器的比较
烘焙顶点的主要问题
烘焙顶点的动画长度参考表
2.3 烘烤骨基质
除了烘焙顶点unity 顶点动画,另一种常见的优化方案是烘焙骨骼矩阵动画。
顾名思义,baking bone matrix和baking vertex position的原理很相似; 最大的区别就是他们在烘焙的时候记录的内容不同:烘焙顶点记录的是每个顶点的位置,而烘焙骨骼矩阵记录的是每个骨骼的矩阵是什么,仅此而已。
记录每帧动画播放后各骨骼的矩阵
烘焙骨骼矩阵最大的意义在于弥补了烘焙顶点的不足:受顶点数量限制,烘焙的动画贴图过大,贴图数量多。
动画纹理使用差异
纹理(区域)使用烘焙顶点是由顶点数决定的,可以简单理解为:
纹理区域使用 = 顶点数 x 动画长度 x 内容数
因此,当模型中的顶点数过多(数千个)时,这种烘焙方式要么无法烘焙整个模型(顶点数 > 2048),要么需要一张或多张(法线、切线)大尺寸贴图( 1024)。
但是,烘焙的骨矩阵主要取决于骨骼的数量,可以简单理解为:
贴图面积使用=骨骼数量x动画长度x矩阵烘焙方式(x1、x2或x3)
在移动平台上,通常20根左右的骨骼就能达到不错的性能,所以相比烘焙顶点,烘焙骨骼可以录制更长的动画,而且不再受顶点数量的限制,也不需要对齐线或切线进行处理特别是(因为它们可以在采样后从矩阵中计算出来)。
两种方式烘焙出的动画贴图大小相差较大
烘烤阶段
与烘焙顶点类似,烘焙骨骼也需要在非运行状态下预播放一次动画; 并记录动画播放时每个关键帧下每个骨骼的变换矩阵。
这里需要注意三点。
首先,记录的矩阵是每个骨骼从网格坐标系转换到角色坐标系的矩阵:
Matrix_meshToRole = Matrix_boneLocalToRole x Matrix_meshToBoneLocal
Matrix_meshToBoneLocal可以通过mesh的bindpose获得; 而Matrix_boneLocalToRole可以通过bone的transform计算得到。
其次,网格信息中需要记录每个顶点与骨骼的关系。 这种关系是指哪个骨骼会影响顶点(骨骼索引)和影响的大小(权重值)。
每个顶点最多可以受到 4 个骨骼的影响,但受影响的骨骼越多,播放时需要的采样和矩阵计算就越多。 通常限制在 2 根骨头才能得到好的结果; bone index和weight可以通过mesh的boneWeights知道,在烘焙贴图的时候可以保存在mesh中不用的uv中,这样在vertex shader中就可以获取到。
每个uv可以保存下一个骨骼的index和weight,通常两个uv就够了
第三,对于不同的骨骼动画,烘焙矩阵的方式不一定相同。
例如,如果骨骼动画中的每根骨骼只会相对于上面的骨骼进行旋转,那么我们烘焙一个四元数就足够了,即一个骨骼的矩阵只占一个像素; 如果有平移甚至缩放操作,那么我们需要2-3个像素来存储一个骨骼矩阵。
具有特殊能力需要特殊处理的角色(出自《木偶奇遇记》)
也可以简单地将一个矩阵完全存储在三个像素中
玩舞台
和烘焙顶点一样,烘焙骨骼矩阵也是在顶点变换阶段通过采样动画纹理来完成顶点坐标的计算; 但其计算方法比烘焙顶点复杂。
这里主要是按照烘焙矩阵的方式,通过采样将网格坐标系到字符坐标系的转换矩阵还原出来; 例如,如果在烘焙阶段将完整的矩阵保存在三个像素中,那么就需要进行三次采样进行转换,才能拼凑出一个完整的矩阵。
当然,一旦得到了变换矩阵,就可以通过计算得到角色坐标系中的顶点位置和法向量,继续进行后续的变换。
与烘焙顶点相比
如上所述,烘焙骨骼不再受顶点数量的限制,可以记录更长的动画时间。 烘焙纹理的大小和数量也有明显的优势。
烤骨头动画长度参考表
同机型下烘焙动画长度对比
然而,这种烘焙骨骼的方法需要顶点着色器中有多个顶点样本。 当模型顶点数较多或渲染单元数较多时,其效率会略低于烘焙顶点。
在顶点着色器中为烘焙骨骼进行更多采样
两种烘焙方式的分组比较
华为P20两种烘焙方式对比
2.4 总结
以上是骨骼蒙皮动画单元批量渲染的两种优化方案。
对于这两种方案,我个人认为没有绝对的优势; 如您所见,在处理复杂模型或多动作时,烘焙骨骼更有优势; 但如果渲染数量多,模型顶点数量少,顶点数量少,性能要求弱(不需要法向量参与计算),也值得尝试烘焙顶点,因为它在性能上会更好。
其他优化方案
其实,除了以上两种优化方案外,市场上还有一些特殊的技巧:比如手游《三国之战M》的开局战斗表演中,还实例化了一些动画模型角色来实现批量化渲染的目标。
有些奴才不是简单的“棋子”,而是有模型的单位
但是通过GPA抓帧工具,你会发现同一个模型的mesh在同一个帧中会出现多个不同的“姿势”; 我推测这应该是使用同一个模型的几个骨皮动画的播放“结果”通过实例化渲染复制到多个位置。 由于每个骨皮动画的播放进度略有不同,混合在一起后的效果会更自然,是一种巧妙的做法。 .
每个模型在渲染时都有略微不同的“姿势”
至此,本系列已经更新完毕。