Lottie 是广受欢迎的动画库, 它的诞生一定程度上弥补了前端(也包括客户端)在动画方面的空白——这个空白是 Flash 时代留下的。Lottie 出现之后,动画的设计和开发开始有比较明确的分工:设计师可以使用 Adobe After Effects(以下简称 AE ) 设计动画,然后利用 BodyMovin 插件导出一份 JSON 协议,前端通过一个轻量的运行时读取 JSON 中的数据解析并播放动画。
但是 Lottie 官方提供的运行时 Lottie-web 也有一些局限性:
我们在开发 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:图层;
{}
],
}
其中最关键的是 assets
和 layers
两个字段。用一句话概括他们之间的关系:layers 中的某个图层可以引用 assets 里的某个资源,另外一个资源还可以引用其他资源。前半句比较好理解,比如设置图层类型 ty
为 2
(表示包含图片的图层),同时把 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 引擎对接,我们可以把程序设计成三大部分:
由于 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 图层元素并在 LottieAnimation
的 onUpdate
函数中更新每个图层的属性和渲染即可。
import { Script } from "@galacean/engine";
class LottieAnimation extends Script {
private _createElements(value: LottieResource) {
// create elements
}
onUpdate () {
// update elements
}
}
由于目前只支持精灵图层,所以只要创建两种元素 CompLottieElement
和 SpriteLottieElement
:
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:
我们在 LottieAnimation
的 onUpdate
函数(这个函数继承自引擎的 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
:一个数的常量;MultiDimensionalProperty
: 矢量常量;KeyframedValueProperty
一个数的关键帧数据;KeyframedMultidimensionalProperty
:矢量的关键帧数据;其中前两种属性是常量,没有计算量;后两种有 Keyframed
前缀的属性是需要插值计算的,如果是普通的贝塞尔曲线计算插值比较简单:
// 生成贝塞尔插值函数
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 库来实现。要注意的是,生成曲线效率较低,所以生成一次之后要缓存下来复用。
至此,我们认为更新属性是比较简单的,但是如果仔细观察 KeyframedMultidimensionalProperty
类型的属性会发现它可能存在 ti
和 to
的切线属性。当存在这两种属性时,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。下图左侧为渲染效果,右图为所用的合并后的图集。
如果使用了图集资源,则传参的时候只要在 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
}
]
然后我们在 LottieAnimation
的 onUpdate
函数中检查是播放完整动画还是片段动画,比如:
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 方法即可,不详述。