SVGA 动效格式调研

6,839 阅读10分钟

人眼通过动态图形的视觉残留产生动感,于是有了动效.

动态图形

无论是在 AE & Animate 中创作图形,
调节关键帧设置变化函数;
还是利用 PS 逐帧绘制导出 GIF,
本质上都是在产生一组连贯的序列画面.
动态图形也包含其中.

SVGA 最初就是为降低序列动画开销而生.
SVGA 描述了组成动效的基本元素 (位图 & 矢量),
又将其在时间轴上的表现 (alpha & matrix) 逐帧导出。

播放逻辑非常简单, 只需结合动效的每帧表现, 逐个渲染动画元素.
高还原动效的同时, 尽可能复用动画元素, 进而从各个方面降低动效开销.

当我们谈论 SVGA 时,我们究竟在讨论什么?

"它是个转换插件,把设计师做的动画导出来,放到设备上去播放。"

converting

"它是一个客户端播放器,一种播放动效的方式,酱紫我们就不用手撸动画,可以早点下班。"

"它是一套协议,降低设计师与技术在动效方面的沟通成本"

"它是一份解决方案,本质上解决的是图形效果与实现成本之间的矛盾问题。"

作为一个客户端播放器,我们可以从两个角度讨论 SVGA:

Animation(Player)

Content(Frames)
Tickers(FPS)
.svga(Parser)
 
序列化
反序列化

SVGA

Animation(Player)

从播放器的角度 SVGA 是一种 (被优化过的) 序列帧动画,
通过一个被设置好间隔的 Ticker 推动序列帧播放。

这时候有同学要问,

"所以 SVGA 与我们常见的序列帧动画有什么区别呢?"

从两个维度可以回答:

  1. 动画元素颗粒度(优化 1):
    SVGA 颗粒非常细而且可控。
    序列帧只有一层。

  2. 动画元素内容(优化 2):
    SVGA 可以使用矢量位图结合,平衡 CPU & GPU 的开销。
    序列帧只能用位图。

虽然也存在矢量序列帧,但不常见,这里不做讨论。
同一个动画,内容组织形式如图:

SVGA:

svga

序列帧:

frames

Lottie:

lottie

播放的时候也是如此:
把动画细分成各个元素,逐帧渲染每个元素的动画,不动的就不需要重新渲染。

UI-Sprites
(好像 UI 元素一样)

.svga(Parser)

设计师电脑中经常会导出很多无法直接预览的 .svga 文件

svga-file

"这些个不可预览的文件,里面装的到底是什么东西呢?"

两年前我想过这个问题。

基于 “万物皆可解压” 的原则,

右键-打开方式-The Unarchiver

Unarchive

(╯‵□′)╯︵┻━┻z
解出来的是什么鬼,剧本不是这么写的啊!
这是因为解压的 .svga 是 2.x,
好奇的话可以使用 1.x 文件解压一下。

2.x 的 .svga 是一个船新的版本,
使用的是当下最流行的 Protocol Buffers 来做序列化,
将素材和动效描述文件储存成二进制格式,自然无法直接解压。

去除了 JSON 解析 SVGA 文件到底快了多少,
请见下文 1.x & 2.x 对比。

序列化 & 反序列化

    通过某种协议(Proto)
     把对象转换为字节序列的过程称为对象的序列化(Serialize)。
    把字节序列恢复为对象的过程称为对象的反序列化(Deserialize)。
    对象的序列化主要有两种用途:
      1) 把对象的字节序列永久地保存到硬盘上,通常存放在一个文件中;
      2) 在网络上传送对象的字节序列。

举个栗子:

demo-serialize

我们通过步骤 1、2、3 将大象装进冰箱里。
装进冰箱就是序列化
从冰箱里拿出来是反序列化
而步骤 1、2、3,就是我们商量好的协议
如果不遵守这个协议,可能从冰箱里拿出来的就不是大象,
可能会拿出什么奇怪的东西。

属性动画

属性包括了缩放、旋转、位移、透明度、颜色等等,
属性动画相关的 API 在 AE 和 客户端上是互通的,
导出之后可以直接赋值使用。

矢量支持

矢量是线性代数中研究的基本元素之一。

如果把矢量看成点跟原点的关系,
那么线性代数研究的问题可以理解为:
“一系列有关系的点在 二维(x y) 或者 三维(x y z) 坐标系中的图形特性。”
而一系列点之间的关系,就是函数。

linervectors

贝塞尔多项式方程就是其中一种函数。
它的图形关系,也就是一系列点的关系图是:

bezie
一个贝塞尔曲线方程对应一条贝塞尔曲线,
一条贝塞尔曲线长这个样子:
bezie

由此可见,(What?)(╯‵□′)╯ ┴─┴
决定一条贝塞尔曲线的关键元素是:

  • starPoint
  • endPoint
  • controllerPoint1
  • controllerPoint2

本质上,这些点影响的是一个贝塞尔方程的多项式系数。

一阶贝塞尔曲线

bezierPath1

starPoint:        :P0
endPoint          :P1 
controllerPoint1  :P0 (重合)
controllerPoint2  :P1 (重合)

二阶贝塞尔曲线

bezierPath2

starPoint:        :P0
endPoint          :P2
controllerPoint1  :P1
controllerPoint2  :P1 (重合)

三阶贝塞尔曲线

bezierPath3

starPoint:        :P0
endPoint          :P3
controllerPoint1  :P1
controllerPoint2  :P2

这样子的话,计算机中的所有线条, 都可以用:

var curve = new Bezier(
    P0.x, P0.y,
    P1.x, P1.y,
    P2.x, P2.y,
    P3.x, P3.y,
);

创建了,万物皆是贝塞尔啊有木有!
┬─┬ ノ( ' - 'ノ) {摆好摆好)

而我们平时在计算机中看到的线条,
或者设计师工具中用【钢笔工具】画出来的曲线:

aebezierPaths
也是这个原理。

这里有细心的同学就要问了:
按上面说的,一条曲线只有四个点,
但我们在 AE 中每拖一条线出来,
会有一个起点和一个终点,
这两个点分别有两个控制点,
酱紫就有六个点了,
这是咋回事啊?

拖动曲线的时候可以按 Alt 键将两个控制点分离,
然后你会发现,真正影响到当前曲线的只有:

  • 起始点的控制点二;
  • 终止点的控制点一;

也就是说,实际上在 AE 中也是符合我们上面说的规律的。
那多出来的两个点又是什么呢?
它们分别是:

  • 上一条曲线的终止点的控制点二;
  • 下一条曲线的起始点的控制点一;

对当前曲线是没有影响的。

SVG 中的 Path

如果你用文本编辑器打开 SVG 文件(XML),
你会发现 SVG 中有一段:

M153 334 
C153 334 151 334 151 334 
C151 339 153 344 156 344 
C164 344 171 339 171 334 
C171 322 164 314 156 314 
C142 314 131 322 131 334 
C131 350 142 364 156 364 
C175 364 191 350 191 334 
C191 311 175 294 156 294 
C131 294 111 311 111 334 
C111 361 131 384 156 384 
C186 384 211 361 211 334 
C211 300 186 274 156 274

里面的 M 对应的是绘制 API 的 MoveTo 方法:

- (void)moveToPoint:(CGPoint)point;

C 对应的就是绘制贝塞尔曲线的:

- (void)addCurveToPoint:(CGPoint)endPoint 
          controlPoint1:(CGPoint)controlPoint1 
          controlPoint2:(CGPoint)controlPoint2;

这里面少了的 starPoint 其实就是上一 endPoint 或者 movePoint,
**所以一个 C 里面三个点就足够了,
它们的顺序是:endPoint, controlPoint1, controlPoint2;
上面这两句话就是导致我这一个星期工作的罪魁祸首。

SVGA 中的 Path

SVGA 中也同样使用了这份 Path 规范,
然而,在 SVGA 的转换器源代码中,发现
C(Curve) 的顺序是:controlPoint1, controlPoint2, endPoint;
而 M(MoveTo) 设置的点是:controlPoint1;

播放器也将错就错,自行更改了 C(Curve) 解析顺序:

SVGAOC

这时 M(MoveTo) 使用的是点:controlPoint1,
导出来的动画长这个样子:

aebicycle

请用行列式求詹瞻心理的阴影面积...
修复方案当然是将 SVGA 中的 Path 修正回来...

SVGA 中的 TrimPath

路径修剪(TrimPath) 是一种矢量动效样式。

TrimPath 其实应该叫做 Trim Bezier,
SVGA 中的线条都是贝塞尔曲线。
它一共有三个相关的属性:

trimPathStart,
trimPathEnd,
trimPathOffset,

它们分别表示 Path 从哪里开始,到哪里结束,距离起点多远。
至于怎么用,就看我们的想象力了。

在 AE 形状图层中可以添加:

add-trimpath

可添加在形状(Shape)属性中,也可以添加在 Shape 之外对多个 Shape 造成影响:

muti-trimpath

动效展示:

trimpathdemo

动效可以拆分成四个部分,
里面一个 Circle fill 的 Scale 动画
外面三个环绕 Circle stroke path 并添加 Trim & Width 动画

通过 SVGA Preview 我们可以清晰了解它的结构:

4circlepath

接下来我们通过 Android 的 PathMeasure (Web 太🐔b 难调试了)
来模拟 SVGA 还原这个动画的修剪效果。

这里实力安利业务中使用 SVGA 来一键还原这些效果,
(把锅彻底甩给产品和设计,酱紫我们就可以早点下班勒~😯)

SVGAnimation Demo

TrimPathActivity

TrimAndroidDemo_540x960_4s

PathUtil 以后再讲。

SVGA AE Converter 中的 trimmedPath
svgatrimmedPath

···

蒙版遮罩

蒙版和遮罩本质上是定向处理绘制图层的 aplha 通道。

mask

在 AE 中通过面板设置蒙版属性:

mask

转换器通过获取的参数,
在 iOS 和 Android & Web 上定制播放方案。

解决方案本质上都是通过获取的参数创建遮罩图层,
用遮罩图层对多个内容图层进行遮罩处理。

Goodness

也可以可以通过设置蒙版属性:

mask

控制图层的 alpha 通道,
达到只显示部分图案的效果。

性能对比

"先定一个能达到的小目标, 比如动画达到 60 fps, 动画解析 100ms 内完成."

设备开销是一个很笼统的概念, 开销过大则设备卡顿,掉帧,发热,耗电,罪魁祸首,万恶之源。

从开发的角度来看,
文件大小, 解析耗时, 占用内存, CPU, GPU 都是可以衡量开销的维度,
以下从这几个维度对 1.x, 2.x, 序列帧 & GIF 做性能分析.

SVGA 1.x

1.x 是 SVGA 最初的版本格式,
实际上是由位图元素资源加上记录各元素每帧表现的 json 数据压缩成的 zip 包.

SVGA 2.x

2.x 的 SVGA 将 json 格式的动画描述文件转成 protobuf 二进制文件,极大提高了文件的解码速度.

序列帧 & GIF

序列帧 & GIF 属于全帧位图,特点是文件大小随动效还原程度拔高,播放的过程中会有长时间的内存高占用.

1.x & 2.x 比较

compare-total-data

概要:

  1. 在相对纯净的demo环境下进行测试;
  2. 测试手机为 iPhone 7P 12.3.1 小米 6 MIUI 9.6;
  3. 动效为 shengli(90% 位图) brithday(90% 矢量) ;
  4. 动效复杂度: shengli > brithday;

iOS 端:

  1. 在动效位图纹理比较多(90%)时,2.x 峰值解码时间比 1.x 快 70%, 平均快 84%;
  2. 在动效矢量元素比较多(90%)时,2.x 峰值解码时间比 1.x 快 66%, 平均快 104%;
  3. 其他数据相近, 解码后的渲染逻辑相同;

Android端:

  1. 在动效矢量元素比较多(90%)时,2.x 峰值解码时间比 1.x 快 308%, 平均快 276%;
  2. 伴随解码时间拉长,1.x 文件解码过程中, CPU 占用会出现长时间居高.
  3. 其他数据相近, 解码后的渲染逻辑相同;

安卓 1.x 动效 CPU 占用表:

android-cpu-cost

结论:

  1. SVGA 2.x 文件的解码速度高于 1.x, 平均高 120%,中低端机型差别更为明显.
  2. 矢量元素越多,动画描述文件越大,解码时间的差距越大.
  3. 后续渲染逻辑相同,其他数据相差不大.

附录

详细数据表

compare-detail-data

compare-detail2-data