简体中文
简体中文
Lottie 的 3D 运行时的实现

Lottie 的 3D 运行时的实现

technical

缘起

Lottie 是广受欢迎的动画库, 它的诞生一定程度上弥补了前端(也包括客户端)在动画方面的空白——这个空白是 Flash 时代留下的。Lottie 出现之后,动画的设计和开发开始有比较明确的分工:设计师可以使用 Adobe After Effects(以下简称 AE ) 设计动画,然后利用 BodyMovin 插件导出一份 JSON 协议,前端通过一个轻量的运行时读取 JSON 中的数据解析并播放动画。

但是 Lottie 官方提供的运行时 Lottie-web 也有一些局限性:

  1. 最初基于 SVG 实现,DOM操作性能较差;
  2. Canvas 2D 模式下,多个 Lottie 很难在同一个画布上组合使用;
  3. 没有 AE 里的一些高级功能,比如 3D 变换。

我们在开发 Galacean Engine 的 2D 功能时,也遇到在 WebGL 上下文中播放 Lottie 动画的需求。我们的思路是把 Lottie 定位为类似 Spine 的生态组件,也就说算法和渲染是解耦的,各个游戏引擎可以实现自己专有的组件渲染器。

理解协议

数据结构

因为 Lottie 的 JSON 协议是由 AE 制作后导出,所以它的数据结构和 AE 中的图层结构是一致的。这里感谢社区有同学已经整理一份详细的结构图(见参考资料)。如下所示,协议的整体结构是比较简单的:

{
  "v": "5.4.4",	// version:bodymovin的版本;
  "fr": 30, 		// frames per second:帧率;
  "ip": 238,		// in point:第一帧位置;
  "op": 437,		// out point:最后一帧率位置;
  "w": 750,			// width:动画宽度;
  "h": 750,			// h(height):动画高度;
  "nm": "0",		// name:名称;
  "assets": [		// assets:资源;
	  {}
  ],
  "layers": [		// layers:图层;
 		{}
  ],
}

其中最关键的是 assetslayers 两个字段。用一句话概括他们之间的关系:layers 中的某个图层可以引用 assets 里的某个资源,另外一个资源还可以引用其他资源。前半句比较好理解,比如设置图层类型 ty2 (表示包含图片的图层),同时把 refId 设置为 "0" 即可引用到 assets 中 id 为 "0" 的图片资源:

{
  "assets": [
    {
      "id": "0",
      "w": 556,
      "h": 308,
      "u": "",
      "p": "data:image/png;base64...",
      "e": 1
    }  
  ],
  "layers": [
    {
      "ddd": 0,
      "ind": 1,
      "ty": 2,
      "nm": "00000.png",
      "cl": "png",
      "refId": "0",
      "sr": 1,
      "ks": {},
      "ao": 0,
      "ip": 0,
      "op": 1,
      "st": 0,
      "bm": 0
    }
  ]
}

后半句“另外一个资源还可以引用其他资源”相对难理解一点。这里要提到 AE 中重要的一个概念:合成(Composition)。我们可以把合成理解成可复用的图层合集,一个合成可以引用一个图片资源,也可以引用另一个合成。以下面的代码为例,layers 引用了 id 为 "2" 的合成,此合成中有一个图层有引用了 id 为 "3" 的合成:

{
  "assets": [
    {
      "id": "2",
      "layers": [
        {
          "refId": "0"
        },
        {
          "refId": "1",
        },
        {
          "refId": "3",			// 引用 ”3“ 合成
        },
      ]
    },
    {
      "id": "3",
      "layers": [...]
    }
  ],
  "layers": [
    {
      "refId": "2",					// 引用 ”2“ 合成
    }
  ]
}

至此,读者可能已经猜到:Layers 展开后是一个树结构

图层属性

清楚了基本的数据结构之后,再来理解一下每个图层(Layer)的属性:

{
  "ind": 1,				// index:决定绘制顺序,类似 css 里的 z-index
  "ty": 2,				// type:图层类型
  "nm": "1",			// name:图层名称
  "refId": "0",		// referenced asset id:引用的资源
  "sr": 1,				// stretch:时间缩放
  "ks": {},				// keyframes:关键帧变换数据(下面详解)
  "ao": 0,				// auto orient:图层是否沿着运动路径切线方向旋转
  "ip": 407,			// in point:第一帧位置;
  "op": 437,			// out point:最后一帧率位置;
  "st": -72,			// start time:开始时间
}

图层属性中,ind 决定了绘制顺序,这是非常重要的,因为 2D 元素在 3D 空间中位置的 Z 值很可能是一样的,需要借助 ind 的值来做排序。

另外,ty 表示图层类型。Lottie 的图层分为精灵图层(image)、形状图层(shape)、方形图层(solid)、预合成图层(preComp)、文字图层(text)等类型。其中精灵图层即用位图来作为纹理源的图层,是最常用的图层,经过我们自己的业务实践,能满足 90% 以上的场景。所以 Galacean 引擎虽然目前只支持精灵图层,但是也够用了。像形状图层的实现需要将矢量图形做实时三角形剖分(Delaunay),性能也不是很好,所以暂未实现。事实上,在 lottie web 的最佳实践中也是建议少用矢量、蒙版等耗性能的操作。

所以在 Galacean 引擎只支持渲染精灵图层的情况下,我们只需要关心两种类型:

  • 值为 2 :精灵图层;
  • 值为 0 :合成;

接下去我们讲讲其中最关键的属性 ks 关键帧数据 。

关键帧数据

一个图层要发生动画,就需要实时做一些变换(Transform),包括位移、旋转、缩放、透明度的变换。所以 ks 包括以下属性:

"ks": {
  "o": {},	// opacity 透明度
  "r": {},	// rotation 旋转
  "p": {},	// position 位移
  "a": {},	// anchor 锚点
  "s": {}		// scale 缩放
},

这些属性的值里少部分是固定值,比如透明度为 0.5 或者位置为 [0, 100, 0] ,大部分是关键帧数据,结构如下:

{
  "i": {							// input 贝塞尔入点
    "x": [ 0.667 ],
    "y": [ 1 ]
  },
  "o": {							// output 贝塞尔出点
    "x": [ 0.333 ],
    "y": [ 0 ]
  },
  "t": 18.229,				// time 关键帧时间点
  "s": [ 100 ],				// startValue 开始值
  "e": [ 0 ]					// endValue 结束值
}

上面这段数据的意思是在 18.229 这个时间点做一个以开始值为 100 结束值为 0 的贝塞尔曲线的插值,其中贝塞尔曲线的入点是 (0.667, 1),出点为 (0.333, 0)

小结

Lottie 的数据结构是树结构,树上的每个节点都是一个图层,每个图层都有一些变换属性,属性的变化通过贝塞尔曲线的插值来计算。

程序设计和实现

接下去为了和 Galacean 引擎对接,我们可以把程序设计成三大部分:

  • 加载和管理 Lottie 动画的类(红色部分);
  • 对接引擎的 Lottie 图层的元素类(蓝色部分);
  • 计算 Lottie 图层属性的类(绿色部分);

image.png

加载和解析协议

由于 Galacean Engine 是基于组件设计的图形引擎,所以 Lottie 作为一个生态能力也以组件的方式接入,API 设计如下:

// 添加 Lottie 动画组件
const lottie = lottieEntity.addComponent(LottieAnimation);
 
// 设置 lottie 数据源
lottie.source = ...;
 
// 播放动画
lottie.play();

Galacean Engine 提供了 Script 脚本组件基类来扩展自定义功能,因此我们可以继承它来是实现 Lottie 动画组件类:

import { Script } from "@galacean/engine";
 
class LottieAnimation extends Script {
  set source (value: LottieResource) {
  }
  
  play() {
  }
  
  pause() {
  }
}

Lottie 组件一共就三个 API :play 播放 和 pause 暂停,设置数据源。首先,我们来看看这个数据源类 LottieResource 的设计。

Galacean Engine 的资源内存管理采用引用计数的方式,新加的引擎资源通过扩展 EngineObject 类来实现资源的 GC:

import { Engine, EngineObject } from "@galacean/engine";
 
class LottieResource extends EngineObject {
  constructor(engine: Engine, res, atlas?) {
  }
  
  private _buildTree(layers, compsMap) {
    const layersMap = {};
 
		for (let i = 0, l = layers.length; i < l; i++) {
			const layer = layers[i];
			layersMap[layer.ind] = layer;
		}
    
    for (let i = 0, l = layers.length; i < l; i++) {
			const layer = layers[i];
			const { refId, parent } = layer;
 
			if (parent) {
				if (!layersMap[parent].layers) {
					layersMap[parent].layers = [];
				}
 
				layersMap[parent].layers.push(layer);
			}
 
			if (refId && compsMap[refId]) {
        // 克隆合成
				layer.layers = [...compsMap[refId].layers];
        
				// 递归解析和构建树结构
				this._buildTree(layer.layers, compsMap);
			}
		}
 
  }
}

这个类的主要作用就是把 Lottie JSON 协议转成树结构。根据上文中“数据结构”所述,由于“合成”的存在,需要递归地去解析和构建树结构。这里要注意的是,合成是可复用的图层集合,因此被挂到某个树节点前一定要克隆一下这个集合。

为了统一引擎加载资源的接口,事实上我们把添加 LottieAnimation 组件的过程都封装到 LottieLoader 这个类里面,所以用户实际使用起来是这样的:

import { LottieAnimation } from "@galacean/engine-lottie";
 
engine.resourceManager.load({
  urls: [
   // ...
  ],
  type: 'lottie'
}).then((lottieEntity) => {
  root.addChild(lottieEntity);
  const lottie = lottieEntity.getComponent(LottieAnimation);
  lottie.play();
});

创建图层元素

有了这个 LottieResource 资源之后,我们接下去的工作就是递归创建 Lottie 图层元素并在 LottieAnimationonUpdate 函数中更新每个图层的属性和渲染即可。

import { Script } from "@galacean/engine";
 
class LottieAnimation extends Script {
  private _createElements(value: LottieResource) {
    // create elements
  }
  
  onUpdate () {
  	// update elements
  }
}

由于目前只支持精灵图层,所以只要创建两种元素 CompLottieElementSpriteLottieElement

switch (layer.ty) {
  case 0:
    element = new CompLottieElement(layer, this.engine, childEntity, layer.id);
    break;
 
  case 2:
    element = new SpriteLottieElement(layer, this._resource.atlas, this.entity, childEntity);
    break;

其中 CompLottieElement 是一个空的 Entity,承担父节点的功能,只包含变换组件;SpriteLottieElement 是一个包含 SpriteRenderer 组件的 Entity:

class SpriteLottieElement extends BaseLottieElement {
  sprite: Sprite;
  spriteRenderer: SpriteRenderer;
 
  constructor(layer, atlas: SpriteAtlas, entity: Entity) {
    super(layer);
    this.sprite = atlas.getSprite(layer.refId);
    const { atlasRegion, texture } = this.sprite;
 
    const spriteEntity = new Entity(entity.engine, layer.nm);
    const spriteRenderer = spriteEntity.addComponent(SpriteRenderer);
 
    spriteRenderer.sprite = this.sprite;
  }
}

更新图层元素

动画播放有一些约定俗成的逻辑,所有我们设计了以下 API:

  • isLooping: 是否循环播放
  • repeats:播放的重复次数
  • isAlternate:是否交替播放
  • speed: 播放速度
  • direction:播放方向

我们在 LottieAnimationonUpdate 函数(这个函数继承自引擎的 Script 基类)中实现这些 API,实际上是根据不同条件计算 this._frame 的值:

onUpdate(deltaTime: number): void {
  const time = this.direction * this.speed * deltaTime;
 
  // 累计帧数
  this._frame += time / this._resource.timePerFrame;
 
  // 超出结束帧
  if (this._spill()) {
    const { duration } = this._resource;
    this._resetElements();
 
    // 重复播放或循环播放
    if (this.repeats > 0 || this.isLooping) {
      if (this.repeats > 0) {
        --this.repeats;
      }
 
      // 交替播放
      if (this.isAlternate) {
        this.direction *= -1;
        this._frame = Tools.codomainBounce(this._frame, 0, duration);
      }
      else {
        this.direction = 1;
        this._frame = Tools.euclideanModulo(this._frame, duration);
      }
    }
    else {
      this._frame = Tools.clamp(this._frame, 0, duration);
    }
  }
 
  // 更新每个图层元素
  this._updateElements(this._resource.inPoint + this._frame);
}

接下去更新每个元素,即更新元素的一系列变换属性的值。首先,我们设计一个 TransformFrames 类来管理所有的属性,这些属性和 Lottie 协议中 ks 里的属性一一对应:

class TransformFrames {
  p;			// 位置
  x;			// 位置 x 分量
  y;			// 位置 y 分量
  z;			// 位置 z 分量
  a;			// 锚点
  s;			// 缩放	
  r;			// 2d 旋转
  or;			// 3d 旋转
  rx;			// 3d 旋转 x 轴分量
  ry;			// 3d 旋转 y 轴分量
  rz;			// 3d 旋转 z 轴分量
  o;			// 透明度
}

由于 AE 中有些变换属性可以整体设置也可以单独设计,所以 Lottie 导出的属性可能是整体也可能是分量,比如 p 是 [x, y, z] 的集合,or 是 [rx, ry, rz] 的集合。 这些属性的关键帧数据分为四类:

  • ValueProperty :一个数的常量;
image.png
  • MultiDimensionalProperty: 矢量常量;
image.png
  • KeyframedValueProperty 一个数的关键帧数据;
image.png
  • KeyframedMultidimensionalProperty :矢量的关键帧数据;
image.png

其中前两种属性是常量,没有计算量;后两种有 Keyframed 前缀的属性是需要插值计算的,如果是普通的贝塞尔曲线计算插值比较简单:

image.png
// 生成贝塞尔插值函数
const bezier = generateBezierEasing(o.x, o.y, i.x, i.y);
 
// 通过传入归一化的时间获得百分比
const percent = bezier((frameName - t)/(next_t - t));
 
// 在起始值和结束值之间获得当前百分比的值
const value = s + (e - s) * percent;

通过两个控制点可以定义一条三次贝塞尔曲线,generateBezierEasing 可以通过 bezier-easing 库来实现。要注意的是,生成曲线效率较低,所以生成一次之后要缓存下来复用。

image.png

至此,我们认为更新属性是比较简单的,但是如果仔细观察 KeyframedMultidimensionalProperty 类型的属性会发现它可能存在 tito 的切线属性。当存在这两种属性时,bezier-easing 并没有提供现成的生成曲线的方法,这时不得不采用比较暴力的手段,即采样这条曲线的点:

// curveSegments 是曲线要分割的段数,k 表示第几段
const percent: number = k / (curveSegments - 1);
 
// 计算点的坐标值
const value = Math.pow(1 - percent, 3) * s +
      3 * Math.pow(1 - percent, 2) * percent * (s + to) +
      3 * (1 - percent) * Math.pow(percent, 2) * (e + ti) +
      Math.pow(percent, 3) * e;

我们可以勾股定理计算两个点之间的长度,并且通过累加每段的长度得到曲线的总长度:

partialLength += Math.pow(point.x - lastPoint.x, 2);
partialLength += Math.pow(point.y - lastPoint.y, 2);
partialLength = Math.sqrt(ptDistance);
 
segmentLength += partialLength;

在更新属性时,我们先通过获取当前时间所占的曲线长度,然后通过遍历曲线上的点推算出当前在哪两个点之间并做线性插值:

const distanceInLine = segmentLength * percent;
 
for (let i = lastPoint, l = points.length; i < l; i++) {
  const point = points[i];
  const nextPoint = points[i + 1];
  const segmentPercent = (distanceInLine - addedLength) / partialLength;
  const value = point + (nextPoint - point) * segmentPercent;
  
  lastPoint = i;
}

小结

在理解 Lottie 协议和熟悉 Galacean 引擎 API 设计的基础上,把 Lottie 资源设计成 LottieResource 以便实现资引擎资源的垃圾回收,并且通过 LottieLoader 实现加载资源 API 的统一,面向用户设计 LottieAnimation组件的 API。图层元素的变换属性的更新实现是难点,用贝塞尔曲线做插值也是 Lottie 动画的精髓。

一些额外的能力

除了以上功能的实现,考虑到性能和易用性,我们还实现了一些新的能力。

生成图集

Galacean Engine 是一个基于 WebGL 的图形引擎,所以天然具备合批渲染的能力。因此,我们写了小工具(https://www.npmjs.com/package/@galacean/tools-atlas-lottie)可以把 Lottie 协议中的图片资源合并成一个图集(atlas),这样渲染一个 Lottie 组件只需要消耗一个 Drawcall。下图左侧为渲染效果,右图为所用的合并后的图集。

image.png

如果使用了图集资源,则传参的时候只要在 urls 字段的第二项中传入 atlas 链接即可:

engine.resourceManager.load({
  urls: [
    "xxx.json",
    "xxx.atlas"	// 使用了图集则需要传入 atlas 链接,如果不使用
  ],
  type: 'lottie'
});

动画切片

我们经常在业务场景中遇到多个动画片段的需求,这时我们又不希望设计师给我们多个 Lottie 动画导致应用体积和内存变大,所以前端会选择把动画切片,即从某一帧播放到某一帧。

首先,我们在 Lottie JSON 中添加一个私有字段 lolitaAnimations,用来定义有哪些片段:

"lolitaAnimations": [
  {
    "name": "beforePlay",
    "start": 0,
    "end": 71
  },
  {
    "name": "onPlay",
    "start": 72,
    "end": 143
  },
  {
    "name": "afterPlay",
    "start": 144,
    "end": 203
  }
]

然后我们在 LottieAnimationonUpdate 函数中检查是播放完整动画还是片段动画,比如:

if (clip) {
  this._frame = Tools.euclideanModulo(this._frame, clip.end - clip.start);
}
else {
  this._frame = Tools.euclideanModulo(this._frame, duration);
}

这样,我们就非常简单地处理了播放动画片段的需求。

异步语法

我们也经常遇到监听动画结束的需求,为此我们设计了一些语法糖来帮助程序看起来更加美观:

  const lottie = lottieEntity.getComponent(LottieAnimation);
  await lottie.play();

这个比较简单,把 play 方法设计成一个异步函数,返回一个 Promise,然后在 onUpdate 函数中最后一帧播放结束时执行 Promise 的 resolve 方法即可,不详述。

参考资料

Galacean Logo
Make fantastic web apps with the prospective
technologies and tools.
Copyright © 2024 Galacean
All rights reserved.