【动画片段】
前文我们介绍了骨骼动画,在Unity中骨骼动画的部分静态数据存储在SkinedMeshRender中,而另一部分动态的关键帧数据就是存储在AnimationClip中的。
关键帧数据来自与FBX、OBJ等动画模型文件,可以在动画导入后的Animation选项卡中查看动画,Unity将其分为了四个区域,可以在动画预览区域,播放动画和查看特定帧的动画。
(A区和B区是在运行时并不会用到,C区严格来说,属于动作系统的一部分和动画片段没关系,但因为和动画系统联系紧密,会和AnimationClip关联起来,在后文中再详细说说)
预览动画所需的数据就在AniamtionClip中,图中所示的即为AniamtionClip文件:
可以双击来查看具体的动画数据,如下所示:
由于这里的AniamtionClip数据来自动画模型文件,所以是不可以修改的。在win的资源管理器中,也看不到这个文件,因为在Unity工程中看到的是Object而不是Asset。(Object与Asset的区别)
可以将AnimationClip的数据Copy一遍,生成单独的文件后,即可编辑。Unity提供了交互的方式,但可以通过代码自动生成,例如:
public void CopyAnimationClip(GameObject go)
{
AnimationClip[] clips = AnimationUtility.GetAnimationClips(go);
foreach (AnimationClip clip in clips)
{
AnimationClip newClip = new AnimationClip();
newClip.name = clip.name + "_auto";
newClip.frameRate = clip.frameRate;
newClip.legacy = clip.legacy;
var setting = AnimationUtility.GetAnimationClipSettings(clip);
AnimationUtility.SetAnimationClipSettings(newClip, setting);
EditorCurveBinding[] bindings = AnimationUtility.GetCurveBindings(clip);//https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/Animation/EditorCurveBinding.bindings.cs
foreach (var binding in bindings)
{
AnimationCurve curve = AnimationUtility.GetEditorCurve(clip, binding);
AnimationUtility.SetEditorCurve(newClip, binding, curve);
}
AssetDatabase.CreateAsset(newClip, "Assets/" + newClip.name + ".anim");
AssetDatabase.SaveAssets();
}
AssetDatabase.Refresh();
}
随后,我们就可以找到该文件,可以发现其实质是个YAML文本文件,其中存放了关键帧的数据
【关键帧数据】
一般UI动画为30帧,局内动画为60帧,前文列举的骨骼层级结构中有30个骨骼,每个骨骼至多有10个通道的数据,分为是S缩放、Q旋转(一般用四元数表示)、T位移。
因此,在数据结构设置上,AnimationClip需要是这样的:
/// Class AnimationClip
/// {
/// Bone* boneData //需要持有每个骨骼,这里不一定是要引用或者指针,每个骨骼可以有一个ID标识符,看游戏引擎如何计算了,通过标识符索引骨骼数据也可以。长度骨骼数,这里是30
/// }
/// Class Bone
/// {
/// string bonename
/// int boneid
/// Channel* channelData //需要持有每个通道的数据,这里不一定是要引用或者指针,每个通道可以有一个ID标识符,看游戏引擎如何计算了,通过标识符索引通道数据也可以。长度至多是10
/// }
/// Class Channel
/// {
/// string channelname
/// int channelid
/// float* data //30帧有31个数据,60帧有61个数据,这里就是真正的动画数据
/// }
(为了提高性能,降低内存,这些在引擎底层一般会用struct而不是class)
以时间为X轴,以动画数据中某个骨骼的某个通道的值为Y轴,得到的是一系列孤立的点,点与点之间的数据通常通过插值得到。
通常都会用线性插值,也即我们可以在XY图中,直接将两点依次用直线连接起来,得到折线图。
折线图的变化陡峭(看点的切线变化,基本的数学知识),如果角色前后两帧的变化不大那么直接用折线图没问题,否则我们会看到角色动作不连贯,不流畅。
(注意,数学知识针对的是两个点,不是前后两帧的动作,可能前后两帧动作变化很小,但对动作中某些骨骼的某些通道的值而言变化很大;还有可能前后两帧动作变化很大,但对动作中某些骨骼的某些通道而言变化很小)
为解决动作不流畅的问题,我们需要用曲线去拟合这些点。
通常情况下,我们了解到的曲线拟合,大多都是用一条能够用一段函数描述的曲线去拟合不同点的分布。
这里的拟合要求曲线必须经过这些点,因此,我们需要用一个多段函数来描述曲线。
每段用什么函数可以是任意的,但考虑到对性能等的要求,在游戏中基本都用贝塞尔曲线或三次多项式。
【贝塞尔曲线】
原理
基本原理见链接:https://juejin.cn/post/7082701281969569829
更详细的见视频:https://www.youtube.com/watch?v=aVwxzDHniEw
应用
基本上三阶贝塞尔曲线就够用了,unity中各类曲线的编辑、游戏中道路、水管等的建设、移动轨迹、配置数据等都可以用到贝塞尔曲线
拓展
可以看到,几个点即可描述一条贝塞尔曲线。描述二阶贝塞尔曲线,需要三个点;描述三阶贝塞尔曲线,需要四个点。
如果只有两个已知点,怎么构造二阶贝塞尔曲线。
必须借助这两个点和其他默认数据算出默认的第二个点:(已知P1 P3,需要得到P2)例如:
- 第二个点和这两个点构成等边三角形
- 第二个点在这两个点连线的中间,和连线的距离是连线的长度(即构成等腰三角形)
- 第二个点在这两个点连线的中间,和连线的距离是连线的长度* t 。对于不同组的两个点,t可以始终是一个默认的值;也可以是根据其他因素算出来的一个值。这里额外引入了一个参数
- 第二个点和两个点的水平距离通过参数t1控制,和连线的距离通过参数t2控制。对于不同组的两个点,t1、t2可以始终是一个默认的值;也可以是根据其他因素算出来的一个值。这里又额外引入了一个参数
- 上述方式不能控制曲线的倾斜,需要通过斜率来控制。引入两个参数t1、t2,分别表示两个点的斜率,斜率的连线交点为第三个点。对于不同组的两个点,t1、t2可以始终是一个默认的值;也可以是根据其他因素算出来的一个值,获取从某个地方读取/获取的值。
按照上述方式,只有三个已知点,也可以构造出三阶贝塞尔曲线。
如果只有两个已知点,怎么构造三阶贝塞尔曲线。可想而知,需要有更多的参数来计算出其他两个点:
- P2、P3和已知的两个点构成正方形
- P2、P3和已知的两个点构成长放形,其宽为长度*t
- P2、P3和已知的两个点构成等腰梯形,其高为长度*t1,另一个底为长度*t2
- P2、P3和已知的两个点构成梯形,其高为长度*t1,P2和P1的距离为长度*w1,P3和P4的距离为长度*w2。这里又引入了额外的w1和w2两个权重参数
- 在4的基础上引入斜率参数t1和t2以替代高
- P2、P3和已知的两个点构成四边形,在5的基础上,对x和y采用相同的权重
- 在6的基础上,对y采用不同的权重,再额外引入两个参数w3、w4
【动画曲线及API】
AnimationCurve
AnimationClip中最为重要和核心的数据是AnimationCurve,对应上文说的Bone。我们可以通过GetCurve和SetCurve来从AnimationClip中获取和设置曲线,这个过程就像是从Dictionary中Get和Set一样。
(Add和Delete有时候可以合并到Set中,Set时Key是新的,表示Add;Set是Data是空的,表示Delete)
Get和Set操作都需要Key和Data,此时的Key就是每个骨骼,也即每个的名字,考虑到名字会有重复的,会用相对于根节点的路径来表示,通过相对路径可以找到每个节点;Data就是AnimationCurve了。
一个节点至少有10个Channel的数据,因此,还需要第二个Key来表示哪个Channel,因此GetCurve和SetCurve方法至少要是这样的:
AnimationClip.SetCurve(string relativePath,string channel,AnimationCurve data)
AnimationCurve中最核心和重要的数据是KeyFrame,对应上文说的Channel。同样的,AnimationCurve有对KeyFrame增删改查的接口。
两个KeyFrame做曲线拟合时用的是二阶或三阶贝塞尔曲线,有上文的拓展就可以轻松看懂KeyFrame的给个字段的含义和作用了。
注意,上文说的只是两个点之间做曲线拟合时的情况,实际上两个点之间的路径多种多样,可能保持不变的(Constant)、也可能是一条直线(Constant)、也可能是曲线,这就是KeyFrame的TangentMode。
在Unity中,可以通过AnimationUtility.Get/SetKeyLeftTangentMode系列接口给KeyFrame设置TangentMode。
更多曲线
当然,曲线不仅仅是Unity中可用的这几种,DoTween中的Ease曲线给我们展示了更多的曲线
而数学上的各类曲线会更多,只不过在游戏中有些用的很少罢了。
如果引入更多的曲线,我们可以给KeyFrame再添加一个CurveMode字段。因为不同的曲线所需的参数有差别,我们势必给KeyFrame添加新更多的新的参数,也即KeyFrame中必须包含所有CurveMode的所需参数的所有字段。
这是常见的解决方式,我们在面向对象编程中也经常如此,例如,在某个类中引入一个字段只为了解决某个特殊情况,大多数情况下却不需要此字段;如果多数情况下需要,在面向对象中可用继承,只在特定子类中有该字段。而这里并不是对象,也不能做成对象,因此只能包含所有的方式。
随着CurveMode越多,这必然导致很多字段是空着的,在数量极为庞大时占用很多内存却没有作用。解决该问题,就需要针对每种Curve的特点,想办法共享或合并参数,从而导致复杂度上升,也即后来者的理解成本变大了。
更长动画曲线
两个点之间的曲线是路径曲线,一系列点组成的曲线叫动画曲线AnimationCurve。他们是不同层级的对象,AnimationCurve除了KeyFrame的核心数据外,还有自己的其他数据。例如:
- 长度——多长时间的动画
- WrapMode:——时间是有限的,动画长度是有限的,当前时间超过动画长度时表现是怎么样的,一般为:
- Once——仅一次,超出时间都取0
- Loop——循环,超出时间从开始再计
- PingPong——来回
- Clamp
- 最大值
- 最小值
- 极值
- 高度:最大值与最小值之差
- 等等
曲线操作
我们可以对某个已知的动画曲线本身做一些特殊操作,例如:
- 增加一些新的点数据
- 删掉一些新的点数据
- 改变曲线的高度或长度
- 修改某些点的数据
- 拉伸拉高或压缩压低曲线
- 镜像反转曲线
- 分割成多个子动画曲线
还可以对动画曲线之间做操作,例如:
- 将两个动画曲线拼接起来合成一个新的动画曲线
- 从一个或多个动画曲线中截取一部分拼接成新的动画曲线
这些操作都可以在代码中实现,可能要引入一些新的参数并给这些参数一些默认的值。
从代码架构上来看,我们会将这些操作作为静态方法放在AnimationCurve类中
如果某些操作不会在运行时用到,我们会将这些静态方法放在一个Utility类中以便做代码裁剪。
这些操作更多的其实更多的不是从代码上自动做的,而是要提供交互界面给人编辑的。人工编辑时相当于给参数赋值了。
编辑曲线
在Unity中,可以手动编辑动画曲线。一般来说,从动画文件中获取得到的动画曲线是不允许编辑的。
那么我们编辑动画曲线用于什么样的场景呢?这就涉及到动画曲线的本质了。
其本质就是一个值随时间变化的曲线,这个值不一定是动画中的,可以是其他任意的。
在游戏场景中,其可以是Cube的位置随时间变化的曲线。
因此,在编辑时,我们需要指定对象并指定对象中的哪个属性与曲线关联,对象可以是我们自定义的某个类,属性可以是这个类中的某个字段。
所以,AnimationClip的Get/SetCurve方法,可以变成这样:
AnimationClip.SetCurve(string relativePath,Type type,string propertyName,AnimationCurve Data)
在Unity的动画中,常用的Type是Transform,propertyName就是Transform的字段了。
拓展
AnimationCurve用于编辑时,只能对简单的对象做些简单运动,如果对象繁多且变化复杂,那么其会存在性能、内存、编辑效率等问题,在实际的工程中应用不多。
但是其原理时可以扩展的,如果有一个固定的时间轴的,我们可以选择任意多个物体,及物体上任意的MonoBehaviour的属性,那么就可以编辑很多动画而不是在代码中取实现了。这就是Timeline的作用。