当前位置: 首页>后端>正文

探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略

前言:

本文写作的目的主要是为了解惑自己平时积攒下的关于Unity Animation动画播放方面的一些问题,我把它们简单罗列如下:

  • Unity的Animator系统是如何存取动画切片(Animation Clip)的?
  • 动画切片数据结构长什么样子?
  • 动画A到动画B的转移(transition)是如何做到平滑过渡(blend)的?
  • 我们为何能在动画播放的任何一帧发起平滑的动画过渡?
  • Animator的CrossFade接口为何能在没有转移的情况下正确的过渡到目标动画?它在运行时开销大么?

如果大家也有同样的疑问,不妨看看后面的文章,兴许能有所收获,另外鉴于Unity底层资料有限,拼凑不易,如果有误烦请指正 :)

正文

在讨论Unity的root motion时(另一个分支是blend shape)我们常常会提到Animator组件及其对应的Animator Controller资产,知道要往Animator Graph中添加LayerState MachineAnimation State,也知道如果希望2个动画切片(Animation Clip)能够顺利过渡,前后衔接,我们还需要在它们之间建立起转移(Transition),并设置合适的转移起始时机+终止时机。动画状态之间的跳转关系一旦正确建立,运行时我们就可以通过向Animator控件发送SetTriggerSetFloat等指令实时修改Parameters参数,进而激活定义在Transition中的跳转条件(Condition),触发向目标动画的过渡。不难观察到,Unity在播放这些Animation Clip时表现得非常丝滑,柔顺,即便将Unity的内置时间调慢数倍,用数倍长的时间去播放一段30帧长的动画切片,我们也很难见到因采样不足而导致的动画失真和跳变。另一方面,对于任意动画状态转移,融合动画也总是表现得同样顺滑,仿佛预先烘焙好似是,可若真个是烘焙的融合动画,似乎又没有见着Unity创建过任何新的融合动画Clip,同时也无法解释为何Unity支持从当前动画播放的任何时刻发起向目标动画的融合转移,难道Unity将当前动画的每一帧到目标动画都做了烘焙?

Clip所包含的数据信息可从Unity的Animation面板中窥见个大概。如下图,左侧是由骨骼节点构成的树状层级结构,依红色箭头所示,每个骨骼分别由Position,Rotation和Scale三个部分组成,正好对应了Transform面板上的TRS三剑客。右侧的横坐标对应了Clip在时间维度上的展开,每一个刻度代表了一帧,纵轴则直接反应了节点的取值。截图中展示的是名为Skinbone005的骨骼节点的旋转(Rotation)三个分量(三种颜色)随动画推进不断变换而形成的曲线。对于还不了解root motion同学我先做个快速回顾,简单来说根运动的本质是基于彼此关联的树状骨骼节点变换矩阵,将root节点视为模型空间的不变量,将每一帧骨骼自身相对其父节点的局部变换矩阵烘焙到数据结构中,然后在运行时采样并解码出当前帧全部骨骼节点的TRS仿射变换矩阵,进而从根节点开始逐步向叶子节点遍历 + 变换,这样就能计算出骨架(Skeleton)中每一块骨头(bone)的正确姿势(bonePose)了,这就是root motion。额外补充一点,我们有了模型架子的正确位置还不够,还需要将皮肤还原到当初之于骨骼的相对位置才行,对于一个SkinnedMesh顶点来说,便是通过骨骼绑定(binding)映射表找到所有绑定的骨骼(从1到4个不等),通过左乘(bonePose_i * bindPose_i)矩阵将顶点变换到每个骨骼原有的相对位置上,最后加权求解出这个SkinnedMesh顶点的最终模型空间位置。在整个动画模型顶点的计算流程中,bindpose是完全静态的常量,它不影响骨骼的运动,只影响骨骼和皮肤之间的相对位置,因此真正体现骨骼动画的部分就是下图的这些骨骼层级结构和每个骨骼所记录的不同帧下的数值,它们是动画的本体,构成了Animation Clip数据主体。

探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略,第1张

回到我们今天的主题之一,Unity是如何记录Animation Clip的呢?特别是那些记录了每帧每块骨骼变换矩阵的信息,显然会随着动画时长(Duration)的增长和帧率(Frame rate)的增加而消耗更多的存储空间:以一段1分钟时长,30FPS,带有100个骨骼的动画Clip为例,总共需要:60 * 30 * 100 * (3 + 3+ 1)* 4byte = 4.9MB ! 考虑到这只是一段动画剪影,实际上我们在应用中可能会出现多至数十上百段Clip同时存在的情况,如果不对动画数据进行处理,那么单单存放动画剪影一项就将耗去数百兆宝贵的内存资源。为了解决这对矛盾,于是就引出了动画压缩(animation compression)的概念。在Unity中动画压缩是一个优化选项(optimization term),你可以在fbx文件 inspector -> Animation tab中找到它们:

探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略,第2张

Unity提供的选项也不多,主要有两类,抽帧或者Optimal,最新版中追加了抽帧和压缩,但并不压缩运行时内存。首先来聊聊抽帧(Keyframe Reduction),顾名思义,抽帧作用于整个动画Clip而不是单独的骨骼节点,它会通过内置算法识别并剔除一些相对来说比较冗余的全局关键帧(Key Frame),从而在总量上消减需要存储的数据。为了方便控制和比较,Unity还为其抽帧算法提供了一套误差估算接口,名叫Animation Compression Error,会对Rotation,Position和Scale分别单独评分,单位是百分比。注意天下没有免费的午餐,在压缩比和错误率之间是有一套此消彼长的规则运行于背后的,我们参考以下一段Unity官方动画片段*在不同抽帧错误率下的压缩比及其表现:

探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略,第3张
原始动画,关闭了所有压缩方案,动画资源在运行时占用 249KB, Error=0
探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略,第4张
接下来开启抽帧,并且将各项错误率Error均调整为0.5,此时压缩后占用降为119KB
探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略,第5张
进一步提升错误率到10(相较0.5提高了20倍!),此时的占用率从119KB进一步下降到69KB,动画表现如图
探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略,第6张
最后试试全损画质表现如何,将错误率上调到100(既Unity支持的最大容错率),可见此时动画资源占用率下降到了14KB,但是动画表现已经和原版相去甚远了

作为最简单直白的压缩方式,它带来的好处和坏处也是简洁明了的,先说好处:抽帧本身不会对数据做额外的编解码,因此若以数据的原始形态存储,那么在运行时通过采样获取数据的效率是最高的,同时对于变化简单(少高频)的动画来说,抽帧可以在维持良好错误率的前提下轻易达到较为理想的压缩比。余下则是缺点:抽帧能提供的“可接受”压缩率会受到动画复杂度的影响,动画越复杂,可接受的容错率就越低,以上面的翻滚动作为例,它的“甜点”错误率大约是1%左右,因为此时高频细节丢失不多,但是相对原始动画体积来说却提供了超过50%的压缩率,可谓相当性价比;但是如果我们对这段动画的期望体积是50KB以下,那么强行抽帧压缩必然会导致不可接受的动作细节损失,很多时候如果我们无法做到“既要又要”,那么抽帧方案可能就废了。

Optimal是Unity提供给我们的另一种选择,官方文档中对它的解释很有趣:“让Unity决定如何压缩”。这看起来像是个黑盒,模模糊糊,但是只要细心一些我们不难从Animation Tab和Clip文件Inspector中窥知一二:

回看前文提及的动画切片曲线(curves)。如下图,首先观察位于右上角红圈中的黄色采样点,很明显这个点并没有经过一旁曲线的峰顶,而且整条曲线除去首尾怎么看都感觉是高阶连续的(至少没有一阶和二阶间断点),这其实暗示了我们Unity若想从这些个采样点中重建样条线(spline),除了存储采样点的值(value)以外,至少还需存有当前点导数信息,否则无法达成图中红圈里的效果。然后观察下图中蓝色箭头所指的两端采样点间隔,同样可以明显观察到动画曲线已经被有选择的抽帧处理过了,也就是在被Optimal压缩后的动画切片中,部分冗余的高频采样点基于某种阈值(用户设定)或自适应方式被Unity裁剪掉了。可见Optimal是一种混合型的压缩方式,至少结合了抽帧和编码处理。

探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略,第7张

当我们点选一段导入Unity的动画切片后,可以在Inspector窗口看到以下信息:

探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略,第8张

以这段名为Atk01的动画为例,它以30FPS的帧率共持续1.867秒时长,全程需要处理1398条动画曲线(Curves Total)后面紧接着出现了3个比较陌生的单词:ConstantDenseStream。经过查阅资料对比文档后,我们可以大胆得作如下理解:

1)Constant

  • 节点存放的是未经编码的原始数据(value)
  • 采样取值时“不会”在相邻的2个Constant节点之间做插值运算
  • 采样取值多少取决于离采样点最近的左侧节点中所记录的数值
图示:
探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略,第9张

2) Dense

  • 节点内存放的是未经编码的原始数据(value)
  • 采样是“会”在相邻的2个Dense节点之间做线性插值运算
图示:
探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略,第10张

3) Stream

  • 节点内存放的是编码后的数据,不是原始值。
  • 具体来说会将节点附近的连续样条线编码为4个Hermite coefficients*
  • 解码时使用当前采样点的横坐标t的前4阶幂指数与4个编码系数做点乘即可
float t = sampleTime - cache.time;
return (t * (t * (t * cache.coeff[0] + cache.coeff[1]) + cache.coeff[2])) + cache.coeff[3]; 
图示:
探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略,第11张

没错,Unity将同一份Animation Clip中具有不同特性的数据拆分成了不同的格式类型,分门别类得处理一番后又存放回了一处。还是以Atk01动画切片为例,它的1300+动画曲线被归类成了620条Constant类型,0Dense类型以及778Stream。这里面存在700多条Steam类型很好理解,因为用参数化的样条线拟合真实动画曲线这个想法很Cool!它一举解决了2个非常关键的痛点,其一是在编码Rotation这类旋转运动时,使用传统的float3数据结构直接存放当前采样点数值的方法显得过于奢侈 -> 对于动画来说,一个骨骼的旋转相性并不要求那么高的精度,有时候甚至half3都嫌多。其二则是它能带来非常顺滑无跳变的插值体验,从而为插帧效果提供了极佳的数据基础。 那么为何会有600多条Constant类型呢?理由请查看如下截图:

探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略,第12张

在许多动画切片中难免会存在如上图中这类自始至终都不曾有一点变化的常量(Constant)类型数值,对付他们的最好办法就是只记录这么一个常量,然后也无需编解码处理,随用随取即可。 这也是Unity设计和保留Constant类型的初衷吧。
最后为何会很少出现Dense类型?按照Unity官方的说明,Dense类型适合编码动画切片曲线看起来非常像噪声(Noise)的数据,也就是说使用Stream编码这些动画只是徒增编解码开销,因为此法门附带的平滑过渡感并不重要,然而退回使用Constant编码动画又会带来过强的跳变感,只有在这种苛刻又奇怪的条件下Unity才会优先考虑使用Dense类型。到底什么样的需求会转化成这样的动画呢?很难想象!所以我们在实际工作生活中也很难遇到被编码到Dense类型的Animation Curves。

为了能够深入理解Unity Animation Clip组织存放动画数据的逻辑,我们不妨将目光聚焦到Stream data上,其他两种类型的数据与之相似,就不做过多解读了。我们知道数据结构的组织形式能够很大程度上影响CPU的计算效率,特别是在当前处理帧动画数据的语境下,Unity面对的是一个既重IO又重逻辑计算环境。虽然Unity很早前就有了所谓的GPU加速动画计算工作流,但那主要处理的是从骨骼到蒙皮顶点的最后一步运算,简单说就是利用了GPU强大的并行运算特性(计算不能太复杂),把大量原始模型状态下的蒙皮顶点与对应的变换矩阵乘法运算安排到了GPU流处理器中执行,反而涉及骨骼动画解算(rebuild bonePose),以及动画之间的融合(blending)等大量业务需求任然在CPU端实时处理。继续回到我们的Stream data上来,基于上述前提,我们不难联想到Unity的Animator会在CPU端计算中大量应用(借用?)DOTs的技术,比如为每个AnimaitonPlayable实例创一个AnimationJob任务,将繁重的IO访问和矩阵运算放在多线程中并行处理。又比如为了提高采样Stream data效率,尽可能减少Cache-Line Miss,Unity将所有编码后的节点数据(CurveKey)按照动画播放的时间先后紧密存放在内存中,参考下图:

探索Unity动画切片(AnimationClip)在存取,播放和平滑跳转方面的实现策略,第13张

其中CurveTimeData相当于数据包的头文件(Head),指明了后面出现的帧节点数据对应了动画帧中的哪个时间点(time),以及有多少个帧节点属于当前时刻的(count)。数据包的主体是在内存上连续且对齐的一系列CurveKey结构,其中coeff[4]数组作为待解码的payload而存在,另一个curveIndex则表明了当前帧节点数据属于哪一个骨骼的哪一个变换分量。很明显,这样安排数据的好处是,Unity能够通过极少的内存读取指令,一口气将当前帧所需的全部原始变换信息加载到cache中。 同时由于在逻辑上具有较高访问概率的前后关键帧数据也都紧挨着当前帧存放,基于CPU自身cache调度算法的加持,实际运算过程中还可能极大缓解Cache Miss的问题。

不妨举个例子来串一串我们到现在所述的数据结构是如何应用的:假设Unity想要获取当前动画帧全部骨骼节点的正确位置(对应角色的某个炫酷造型),那么自然需要先通过采样上述内存区段以找到骨骼变换矩阵的编码信息。于是Unity调用EvaluateClip方法,传入一个指向当前帧内存区段的游标(动画未播放完毕游标不会复位),游标指向的实际位置就是数据结构CurveTimeData实例存放的地址,取出返回后,CPU会检查确认是否为想要的数据包头,随后Unity以游标位置(address) + sizeof(CurveTimeData)作为真实起始地址偏移,一口气读取CurveTimeData.count * sizeof(CurveKey)个字节长度的数据到缓存中去,由于数据对齐的缘故,Unity可以直接将这段存放在缓存中的二进制数据识别为CurveKey数组ck[]。Unity从ck[0]中取出第一个编码结构,通过映射表知道它的curveIndex对应了骨骼节点bone_001的position.x动画曲线,于是通过SampleClip函数和当前播放时间time,将编码信息反解出来,记为X,并按顺序保存到另一个数据结构ValueArray中。Unity步进指向ck[]指针,从ck[1]中取出下一个CurveKey,依据映射表知道curveIndex对应了bone_001骨骼节点的position.y动画曲线,接下来如同处理坐标X一样解码出坐标Y,并保存到ValueArray中。等到position.xyzrotation.xyz以及scale.x全部解码完毕,骨骼节点bone_001的解码数据也就按照同样的顺序存入ValueArray中了,不过别急,这只是一块骨头的数据,假设模型有100块这样的骨头,那么接下来缓存中余下的内容大概会对应bone_002并以此类推,直到Unity处理完全部ck[]数组中的CurveKey数据,解码原始信息的工作就告一段落了,此时解码后数据被缜密压缩在ValueArray数组中,以供后续职能模块提取和转化成对应的Transform,Rotation以及Scaler数据结构。

至此,我们总算明白了开篇第一个问题,既Animation Clip数据到底长什么样,而Unity又是如何存取它们的了。后面的问题似乎还很多,但是一旦我们搞明白了最底层的处理逻辑,那些流于表面的问题自然可以很快得到阐明!简便起见我把余下的问题和解释归纳如下:

Q:动画A到动画B的转移(transition)是如何做到平滑过渡(blend)的?
A: 依靠记录在Transition数据中的start和end time,以及目标动画的起始偏移offsetTime来触发动画融合逻辑,这套逻辑被Unity的内部类AnimationMixerPlayable管理,基本逻辑很简单,在触发transition的时刻开始,到end time位置,每一帧都正常获取当前动画帧的ValueArray_1,同时提前获取下一个动画切片位于offsetTime处的关键帧数据ValueArray_2,依据跳转流逝的时间和总跳转时间计算出权重w,最后通过w插值ValueArray_1ValueArray_2以获得最终的ValueArray

Q:我们为何能在动画播放的任何一帧发起平滑的动画过渡?
A: 因为Unity的动画跳转实例“Transition”中只存放了描述这次跳转的少量元数据,并没有任何形式的预计算Animation Clip生成,一切运行时跳转都是运行时经过采样和插值获得的。

Q: Animator的CrossFade接口为何能在没有转移的情况下正确的过渡到目标动画?
A: CrossFade接口能够临时创建一条只能使用一次的Transition数据,相比于依赖动画状态连线的跳转,使用CrossFade的主要区别就在于Transition数据的临时性,至于跳转执行期间的消耗则同普通跳转没有区别。

Q: 它在运行时开销大么?
A: CorssFade对已有的AnimtionClip消耗不大,但是如果目标Clip是动态添加的,那么在首次播放时会多出数据的迁移和拷贝消耗,但这已经和跳转本身关系不大了。

Ref

Animation-compression-unity
Cubic Hermite spline
Animation Compression: Unity 5
Manual AnimationOverview
Manual AnimationClips


https://www.xamrdz.com/backend/3tm1994309.html

相关文章: