动画剪辑

动画剪辑是一组动画曲线,包含了所有动画数据。

动画曲线

动画曲线描述了某一对象上某一属性值随着时间的变化。在内部,动画曲线存储了一系列时间点,每个时间点都对应着一个(曲线)值,称为一帧,或关键帧。当动画系统运作时,动画组件根据当前动画状态计算出指定时间点应有的(结果)值并赋值给对象,完成属性变化,这一计算过程称为采样。

以下代码片段演示了如何程序化地创建动画剪辑:

  1. import { AnimationClip, animation, js } from "cc";
  2. const animationClip = new AnimationClip();
  3. animationClip.duration = 1.0; // 整个动画剪辑的周期,任何帧时间都不应该大于此属性
  4. animationClip.keys = [ [ 0.3, 0.6, 0.9 ] ]; // 该动画剪辑所有曲线共享的帧时间
  5. animationClip.curves = [{ // 组件上的属性曲线
  6. modifiers: [ // 目标是当前节点的
  7. // Body 子节点的
  8. new animation.HierarchyPath('Body'),
  9. // MyComponent 的
  10. new animation.ComponentPath(js.getClassName(MyComponent)),
  11. // value 属性
  12. 'value',
  13. ],
  14. data: {
  15. keys: 0, // 索引至 AnimationClip.keys,即 [ 0.3, 0.6, 0.9 ]
  16. values: [ 0.0, 0.5, 1.0 ],
  17. },
  18. }];

上述动画剪辑包含了一条曲线以控制 “Body” 子节点的 MyComponent 组件的 value 属性,曲线有三帧,使得 value 属性在 0.3 秒时变为 0.5,在 0.6 秒时变为 0.5,在 0.9 秒时变为 1.0。

注意:曲线的帧时间是以引用方式索引到 AnimationClip.keys 数组中的。如此一来,多条曲线可以共享帧时间。这将带来额外的性能优化。

目标对象

动画曲线的目标可以是任意 JavaScript 对象。modifiers 字段指定了在 运行时 如何从当前节点对象寻址到目标对象。

modifiers 是一个数组,它的每一个元素都表达了如何从上一级的对象寻址到另一个对象,最后一个元素寻址到的对象就作为曲线的目标对象。这种行为就好像文件系统的路径,因此每个元素都被称为“目标路径”。

当目标路径是 string 或者 number 时,表示寻址到上一级对象的属性,其本身就指定了属性名。否则,目标路径必须是实现接口 animation.TargetPath 的对象。

Cocos Creator 内置了以下几个实现自接口 animation.TargetPath 的类:

  • animation.HierarchyPath 将上一级的对象视为节点,并寻址到它的某个子节点;
  • animation.ComponentPath 将上一级的对象视为节点,并寻址到它的某个组件。

目标路径可以任意组合,只要它们具有正确的含义:

  1. // 目标对象是
  2. modifiers: [
  3. // "nested_1" 子节点的 "nested_2" 子节点的 "nested_3" 子节点的
  4. new animation.HierarchyPath('nested_1/nested_2/nested_3'),
  5. // BlahBlahComponent 组件的
  6. new animation.ComponentPath(js.getClassName(BlahBlahComponent)),
  7. // names 属性的
  8. 'names',
  9. // 第一个元素
  10. 0,
  11. ]

当目标对象不是一个属性,而是必须从一个方法返回时,自定义目标路径就很有用:

  1. class BlahBlahComponent extends Component {
  2. public getName(index: number) { return _names[index]; }
  3. private _names: string[] = [];
  4. }
  5. // 目标对象是
  6. modifiers: [
  7. // "nested_1" 子节点的 "nested_2" 子节点的 "nested_3" 子节点的
  8. new animation.HierarchyPath('nested_1/nested_2/nested_3'),
  9. // BlahBlahComponent 组件的
  10. new animation.ComponentPath(js.getClassName(BlahBlahComponent)),
  11. // 第一个 "name"
  12. {
  13. get: (target: BlahBlahComponent) => target.getName(0),
  14. },
  15. ]

如果希望自定义目标路径是可序列化的,可以将它们声明为类:

  1. @ccclass
  2. class MyPath implements animation.TargetPath {
  3. @property
  4. public index = 0;
  5. constructor(index: number) { this.index = index; }
  6. get (target: BlahBlahComponent) {
  7. return target.getName(this.index);
  8. }
  9. }
  10. // 目标对象是
  11. modifiers: [
  12. // "nested_1" 子节点的 "nested_2" 子节点的 "nested_3" 子节点的
  13. new animation.HierarchyPath('nested_1/nested_2/nested_3'),
  14. // BlahBlahComponent 组件的
  15. new animation.ComponentPath(js.getClassName(BlahBlahComponent)),
  16. // 第一个 "name"
  17. new MyPath(0),
  18. ]

目标对象的寻址是在运行时完成的,这种特性使得动画剪辑可以复用到多个对象上。

赋值

当采样出值后,默认情况下将使用赋值操作符 = 将值设置给目标对象。

然而有时候,并不能用赋值操作符来完成设置。例如,当想要设置材质对象的 Uniform 时,就无法通过赋值操作符来完成。因为材质对象仅提供了 setUniform(uniformName, value) 方法来改变 uniform。对于这种情况,曲线字段 valueAdapter 提供了一种机制,可以自定义将值设置到目标对象。

示例:

  1. class BlahBlahComponent {
  2. public setUniform(index: number, value: number) { /* */ }
  3. }
  4. { // 曲线
  5. valueAdapter: {
  6. // 在实例化曲线时调用
  7. forTarget(target: BlahBlahComponent) {
  8. // 在这里做一些有用的事
  9. return {
  10. // 在每一次设置目标对象的值时调用
  11. set(value: number) {
  12. target.setUniform(0, value);
  13. }
  14. };
  15. }
  16. },
  17. };

如果希望“自定义赋值”是可序列化的,那么可以将它们声明为类:

  1. @ccclass
  2. class MyValueProxy implements animation.ValueProxyFactory {
  3. @property
  4. public index: number = 0;
  5. constructor(index: number) { this.index = index; }
  6. // 在实例化曲线时调用
  7. public forTarget(target: BlahBlahComponent) {
  8. // 在这里做一些有用的事
  9. return {
  10. // 在每一次设置目标对象的值时调用
  11. set(value: number) {
  12. target.setUniform(0, value);
  13. }
  14. };
  15. }
  16. }

animation.UniformProxyFactory 就是这样一种“自定义赋值”的类,它实现了设置材质的 uniform 值:

  1. { // 目标对象是
  2. modifiers: [
  3. // MeshRenderer 组件的
  4. new animation.ComponentPath(js.getClassName(MeshRenderer)),
  5. // sharedMaterials 属性的
  6. 'sharedMaterials',
  7. // 第一个材质
  8. 0,
  9. ],
  10. valueAdapter: new animation.UniformProxyFactory(
  11. 0, // Pass 索引
  12. 'albedo', // Uniform 名称
  13. ),
  14. };

采样

若采样时间点恰好就等于某一关键帧的时间点,则使用该关键帧上的动画数据。否则当采样时间点居于两帧之间时,结果值会同时受两帧数据的影响,采样时间点在两处关键帧的时刻区间上的比例([0,1])则反应了影响的程度。

Cocos Creator 允许将该比例映射为另一个比例,以实现不同的“渐变”效果。这些映射方式,在 Creator 中称为 渐变方式。在比例确定之后,根据指定的 插值方式 计算出最终的结果值。

渐变方式和插值方式都影响着动画的平滑度。

渐变方式

可以为每一帧指定渐变方式,也可以为所有帧指定统一的渐变方式。渐变方式可以是内置渐变方式的名称或贝塞尔控制点。

以下列出了几种常用的渐变方式:

  • linear:保持原有比例,即线性渐变,当未指定渐变方式时默认使用这种方式;
  • constant:始终使用比例 0,即不进行渐变;与插值方式 Step 类似;
  • quadIn:渐变由慢到快;
  • quadOut:渐变由快到慢;
  • quadInOut:渐变由慢到快再到慢;
  • quadOutIn:渐变由快到慢再到快;
  • IBezierControlPoints

展开对比

曲线值与插值方式

有些插值算法需要每一帧的曲线值中存储额外的数据,因此曲线值与目标属性的值类型不一定相同。对于数值类型或值类型,Cocos Creator 提供了几种通用的插值方式。同时,也可以定义自己的插值方式。

当曲线数据的 interpolate 属性为 true 时,曲线将尝试使用插值函数:

  • 若曲线值的类型为 numberNumber,将应用线性插值;
  • 若曲线值继承自 ValueType,将调用 ValueTypelerp 函数完成插值,Cocos Creator 内置的大多数值类型都将其 lerp 实现为线性插值;
  • 若曲线值是 可插值的,将调用曲线值的 lerp 函数完成插值2

若曲线值不满足上述任何条件,或当曲线数据的 interpolate 属性为 false时,将不会进行插值操作,而是永远使用前一帧的曲线值作为结果。

  1. import { AnimationClip, color, IPropertyCurveData, SpriteFrame, Vec3 } from "cc";
  2. const animationClip = new AnimationClip();
  3. const keys = [ 0, 0.5, 1.0, 2.0 ];
  4. animationClip.duration = keys.length === 0 ? 0 : keys[keys.length - 1];
  5. animationClip.keys = [ keys ]; // 所有曲线共享一列帧时间
  6. // 使用数值的线性插值
  7. const numberCurve: IPropertyCurveData = {
  8. keys: 0,
  9. values: [ 0, 1, 2, 3 ],
  10. /* interpolate: true, */ // interpolate 属性默认打开
  11. };
  12. // 使用值类型 Vec3 的 lerp()
  13. const vec3Curve: IPropertyCurveData = {
  14. keys: 0,
  15. values: [ new Vec3(0), new Vec3(2), new Vec3(4), new Vec3(6) ],
  16. interpolate: true,
  17. };
  18. // 不插值(因为显式禁用了插值)
  19. const colorCuve: IPropertyCurveData = {
  20. keys: 0,
  21. values: [ color(255), color(128), color(61), color(0) ],
  22. interpolate: false, // 不进行插值
  23. };
  24. // 不插值(因为 SpriteFrame 无法进行插值)
  25. const spriteCurve: IPropertyCurveData = {
  26. keys: 0,
  27. values: [
  28. new SpriteFrame(),
  29. new SpriteFrame(),
  30. new SpriteFrame(),
  31. new SpriteFrame()
  32. ],
  33. };

以下代码展示了如何自定义插值算法:

  1. import { ILerpable, IPropertyCurveData, Quat, quat, Vec3, vmath } from "cc";
  2. class MyCurveValue implements ILerpable {
  3. public position: Vec3;
  4. public rotation: Quat;
  5. constructor(position: Vec3, rotation: Quat) {
  6. this.position = position;
  7. this.rotation = rotation;
  8. }
  9. /** 将调用此方法进行插值
  10. * @param this 起始曲线值
  11. * @param to 目标曲线值
  12. * @param t 插值比率,取值范围为 [0, 1]
  13. * @param dt 起始曲线值和目标曲线值之间的帧时间间隔
  14. */
  15. lerp (to: MyCurveValue, t: number, dt: number) {
  16. return new MyCurveValue(
  17. // 位置属性不插值
  18. this.position.clone(),
  19. // 旋转属性使用 Quat 的 lerp() 方法
  20. this.rotation.lerp(to.rotation, t), //
  21. );
  22. }
  23. /** 此方法在不插值时调用
  24. * 它是可选的,若未定义此方法,则使用曲线值本身(即 this)作为结果值
  25. */
  26. getNoLerp () {
  27. return this;
  28. }
  29. }
  30. /**
  31. * 创建了一条曲线,它实现了在整个周期内平滑地旋转但是骤然地变换位置。
  32. */
  33. function createMyCurve (): IPropertyCurveData {
  34. const rotation1 = quat();
  35. const rotation2 = quat();
  36. const rotation3 = quat();
  37. vmath.quat.rotateY(rotation1, rotation1, 0);
  38. vmath.quat.rotateY(rotation2, rotation2, Math.PI);
  39. vmath.quat.rotateY(rotation3, rotation3, 0);
  40. return {
  41. keys: 0 /* 帧时间 */,
  42. values: [
  43. new MyCurveValue(new Vec3(0), rotation1),
  44. new MyCurveValue(new Vec3(10), rotation2),
  45. new MyCurveValue(new Vec3(0), rotation3),
  46. ],
  47. };
  48. }

循环模式

可以通过设置 AnimationClip.wrapMode 为动画剪辑设置不同的循环模式。以下列出了几种常用的循环模式:

AnimationClip.wrapMode说明
WrapMode.Normal播放到结尾后停止
WrapMode.Loop循环播放
WrapMode.PingPong从动画开头播放到结尾后,从结尾开始反向播放到开头,如此循环往复

对于更多的循环模式,请参考 WrapMode

1 动画剪辑的所在节点是指引用该动画剪辑的动画状态对象所在动画组件所附加的节点。
2 对于数值、四元数以及各种向量,Cocos 提供了相应的可插值类以实现三次样条插值