技术串讲——端到端动画方案原理
背景
传统动画开发模式的痛点
在手写动画中, 常见有帧动画和矢量动画两种形式。
**帧动画: **通过连续播放多张静止图片(帧)来产生动画效果
- 优点: 还原度高, 几乎能实现任何的动画效果; 开发简单, 前端播放即可。
- 缺点: 包含了每一帧的位图数据, 体积往往大到无法接受。分辨率固定, 高分屏效果不好。
矢量动画: 通过控制图形的路径、颜色、变形和属性变化来实现动画效果
- 优点:资源体积小, 加载快;可控性强, 性能好。
- 缺点:
- 还原度不高, 很多复杂效果(ae 特效、高斯模糊等等)无法实现
- 和设计师沟通成本大, 设计师可能不知道研发需要什么参数、如何描述一个动画, 或某些参数和研发理解不一致。 需要有统一规范。
- 前端开发成本高, 调试繁琐。
什么是端到端动画方案
端到端动画方案是指覆盖动画 从设计 → 导出 → 渲染 → 控制 全流程的完整解决方案。
- 设计层:动画师在 AE / Rive 等工具中创作
- 导出层:将动画转成可解析中间格式(如 JSON、.pag、.riv)
- 渲染层:跨平台播放sdk解析动画文件并渲染
- 控制层:提供 API 供程序动态控制动画行为
AE 和 AE 对象模型
为了将设计师设计的动画转换为程序可以理解的数据, 需要了解 动画制作工具(ae)及其相关 api。
AE相关概念
AE 是 Adobe After Effects 的简称,它是 Adobe 公司推出的一款强大的视频后期制作软件,广泛用于制作动画、特效、合成以及动态图形。AE 主要用于:
- 视频合成:将多个视频或图像元素组合成一个最终的作品。
- 视觉特效:添加如爆炸、烟雾、火焰等视觉效果。
- 动画制作:通过关键帧控制动画的过渡。
- 动态图形设计:例如制作标志动画、标题动画等。
#合成
合成是 AE 中最核心的概念,可以理解为一个 独立的动画场景或容器
每个合成都可以可以包含:
- 多个图层
- 多个子合成
- 多个特效
- 音频、视频、矢量图形等元素
每个合成都有一条属于自己的时间轴(Timeline), 它的子元素基于这条时间轴上进行一些变换效果, 所有子元素的动画效果进行叠加后得到合成的动画效果。
#图层
图层是构成动画的基本单位, 可以理解为“动画元素”。 每个图层都可以独立设置关键帧、变换、遮罩、特效等效果。
#属性(property)
属性是动画的最小单位, AE 中所有能动、能变的内容,本质上都源于某些属性的变化。
Layer(图层)
├── Transform(变换属性组)
│ ├── Position(位置)
│ ├── Scale(缩放)
│ ├── Rotation(旋转)
│ └── Opacity(不透明度)
├── Masks(遮罩属性组)
└── Effects(特效属性组)
├── Blur Amount(模糊度)
├── Glow Intensity(发光强度)
└── ...
...#关键帧与插值
在 AE 中,所有动画的本质就是属性值随时间变化的过程。
这个变化是通过 关键帧(Keyframe) 和 插值(Interpolation) 实现的。
关键帧定义了动画中某一时刻的 属性状态。
AE 会在不同时间点记录下属性值,例如位置、旋转、透明度等。
举个例子:
- 在 0 秒时,设置圆形在屏幕左边(X = 0)
- 在 2 秒时,设置圆形在屏幕右边(X = 500)
这两个点就是两个关键帧。
AE 在渲染时会自动在两个关键帧之间**生成过渡过程, **这就是插值。
AE对象模型
可以通过编写 ae 脚本和插件, 访问到 ae 项目的数据接口。文档:https://ae-scripting.docsforadobe.dev/
Project
├─ ItemCollection(项目中的所有项目项)
│ ├─ CompItem(合成)
│ │ ├─ LayerCollection(图层集合)
│ │ │ ├─ AVLayer / ShapeLayer / TextLayer / SolidLayer ...
│ │ │ │ ├─ PropertyGroup(属性组)
│ │ │ │ │ ├─ Property(具体属性,如 position、opacity)
│ │ │ │ │ │ ├─ Keyframe(关键帧)
│ │ │ │ │ │ └─ Expression(表达式)由此即可将 ae 动画导出为中间格式。
比如实现图片图层的导出, 并且支持它的位移动画
(function () {
app.beginUndoGroup("Export Image Layers with Position Animation");
var comp = app.project.activeItem;
if (!(comp && comp instanceof CompItem))
return;
}
var exportData = {
compName: comp.name,
frameRate: comp.frameRate,
duration: comp.duration,
layers: []
};
for (var i = 1; i <= comp.numLayers; i++) {
var layer = comp.layer(i);
// 图片素材层
if (layer.source && layer.source.mainSource instanceof FileSource) {
var layerData = {
name: layer.name,
file: layer.source.file ? layer.source.file.fsName : "",
inPoint: layer.inPoint,
outPoint: layer.outPoint,
position: []
};
var posProp = layer.property("Transform").property("Position");
if (posProp.numKeys > 0) {
for (var k = 1; k <= posProp.numKeys; k++) {
var t = posProp.keyTime(k);
var val = posProp.keyValue(k);
layerData.position.push({
time: t,
value: val
});
}
} else {
// 没有关键帧则导出当前值
layerData.position.push({
time: 0,
value: posProp.value
});
}
exportData.layers.push(layerData);
}
}
var jsonStr = JSON.stringify(exportData, null, 2);
var file = new File(Folder.desktop + "/export_layers.json");
file.encoding = "UTF-8";
file.open("w");
file.write(jsonStr);
file.close();
app.endUndoGroup();
})();{
"compName": "MainComp",
"frameRate": 30,
"duration": 2,
"layers": [
{
"name": "bg.png",
"file": "Downloads/背景(3).png",
"inPoint": 0,
"outPoint": 2,
"position": [
{ "time": 0, "value": [960, 540, 0] }
]
}
]
}预合成序列帧
若某些图层应用了难以还原的动画效果, 如粒子效果, 可以将其放到独立的合成中, 在导出时调用 AE api 将其提前渲染为 png 序列或视频, 进而实现在客户端上的还原。
var layer = xxx
var comp = layer.containingComp;
// 创建一个新的空合成
var newComp = proj.items.addComp(
layer.name + "_render",
comp.width,
comp.height,
comp.pixelAspect,
comp.duration,
comp.frameRate
);
// 复制目标图层到新合成
layer.copyToComp(newComp);
// 添加到渲染队列
app.project.renderQueue.items.add(newComp);
rqItem.outputModule(1).applyTemplate("PNG Sequence with Alpha");
rqItem.outputModule(1).file = new File("~/Desktop/" + newComp.name + "_[####].png");
app.project.renderQueue.render();客户端动画渲染
在客户端还原导出的的 ae 动画, 本质可以分为两部分:
- 图形渲染: 如何把它画出来
- 动画驱动: 如何让它动起来
以 lottie-web 实现举例
Lottie 是由 Airbnb 开发的一种跨平台动画解决方案,它能够在 iOS、Android、Web 和 React Native 等平台上以极高的还原度播放由 Adobe After Effects(AE)导出的动画。Lottie 的核心原理是使用 Bodymovin 插件将 AE 动画导出为 JSON 格式的数据文件,这个 JSON 文件描述了动画中各个图层、形状、关键帧和属性的变化。运行时,Lottie 解析这些数据并通过矢量渲染(如 Canvas、SVG 或原生绘图接口)重现动画效果。
lottie 的中间格式
lottie 使用插件 Bodymovin 将 ae 动画导出为如下格式:
{
// ========== 全局信息 ==========
"v": "5.7.1", // Lottie 文件版本
"fr": 30, // 帧率(每秒30帧)
"ip": 0, // 动画开始帧 (in point)
"op": 60, // 动画结束帧 (out point)
"w": 512, // 画布宽度
"h": 512, // 画布高度
"nm": "circle_rotate", // 动画名称
"ddd": 0, // 是否为3D动画(0=否)
// ========== 资源列表(图片或预合成)==========
"assets": [],
// ========== 图层列表 ==========
"layers": [
{
"ddd": 0, // 是否3D层
"ind": 1, // 图层索引
"ty": 4, // 图层类型(4=形状层 shape layer)
"nm": "blue_circle", // 图层名称
"sr": 1, // 时间拉伸(speed ratio)
"ks": { // 关键帧属性(transform keyframes)
// 不透明度(opacity)
"o": { "a": 0, "k": 100 },
// 旋转(rotation)
"r": {
"a": 1, // 有关键帧动画
"k": [
{ "t": 0, "s": [0] }, // 在第0帧时角度为0°
{ "t": 60, "s": [360] } // 在第60帧时旋转到360°
]
},
// 位置(position)
"p": { "a": 0, "k": [256, 256, 0] }, // 固定在画布中心
// 锚点(anchor point)
"a": { "a": 0, "k": [0, 0, 0] },
// 缩放(scale)
"s": { "a": 0, "k": [100, 100, 100] }
},
"ip": 0, // 图层开始帧
"op": 60, // 图层结束帧
"st": 0, // 起始时间偏移
"bm": 0 // 混合模式
}
]
}lottie 的动画循环
lottie的动画循环的实现类似于游戏框架中的循环, 并不是定时触发下一帧的渲染, 而是有一个不断运行的主循环。
每次循环会根据时间差+动画帧率来计算当前应该渲染哪一帧, 这样做保持了动画之间的时间一致性, 不管帧率高低,动画都按真实时间不断进行, 可能会“掉帧”, 但不会出现“卡帧”。
function resume(nowTime) {
var elapsedTime = nowTime - initTime;
....
var i;
for (i = 0; i < len; i += 1) {
registeredAnimations[i].animation.advanceTime(elapsedTime);
}
...
window.requestAnimationFrame(resume);
...
}
function first(nowTime) {
initTime = nowTime
window.requestAnimationFrame(resume);
}
--------------
AnimationItem.prototype.advanceTime = function (delta) {
......
if (this.isPaused || !this.isLoaded) return;
......
// 计算当前应该展示哪帧
const nextFrame = this.currentRawFrame + delta * this.frameModifier;
......
// 更新渲染的帧
// 触发属性插值计算、渲染
this.setCurrentRawFrameValue(nextFrame);
......
};
// frameModifier由 帧率、播放速度、播放方向计算得到
AnimationItem.prototype.updaFrameModifier = function () {
this.frameModifier = this.frameMult * this.playSpeed * this.playDirection;
this.audioController.setRate(this.playSpeed * this.playDirection);
};属性插值
function getProp(elem, data, type, mult, container) {
...
if (!data.k.length) {
p = new ValueProperty(elem, data, mult, container);
} else if (typeof (data.k[0]) === 'number') {
p = new MultiDimensionalProperty(elem, data, mult, container);
} else {
switch (type) {
case 0:
p = new KeyframedValueProperty(elem, data, mult, container);
break;
case 1:
p = new KeyframedMultidimensionalProperty(elem, data, mult, container);
break;
default:
break;
}
}
...
}每个属性值初始化时, 根据是否有关键帧、是否为变换属性被分类为了不同的属性对象。不同的属性对象在插值计算时的方法不同。
静态属性ValueProperty / MultiDimensionalProperty: 值恒定不变, 不进行插值计算
关键帧属性****KeyframedValueProperty:
查找当前帧在哪两个关键帧中间
- 使用
lastIndex缓存上次的位置,避免每次从头查找 - 找到
keyData(起始关键帧) 和nextKeyData(结束关键帧)
- 使用
while (flag) {
keyData = this.keyframes[i];
nextKeyData = this.keyframes[i + 1];
if (i === len - 1 && frameNum >= nextKeyData.t - offsetTime) {
if (keyData.h) {
keyData = nextKeyData;
}
iterationIndex = 0;
break;
}
if ((nextKeyData.t - offsetTime) > frameNum) {
iterationIndex = i;
break;
}
if (i < len - 1) {
i += 1;
} else {
iterationIndex = 0;
flag = false;
}
}- 根据贝塞尔曲线计算缓动函数, 计算最后得到的属性值, 完成插值
outX = keyData.o.x;
outY = keyData.o.y;
inX = keyData.i.x;
inY = keyData.i.y;
fnc = BezierFactory.getBezierEasing(outX, outY, inX, inY).get;
........
perc = fnc((frameNum - keyTime) / (nextKeyTime - keyTime));**变换属性TransformProperty: **
TransformProperty在每次插值完成后, 将变换相关属性(位移、缩放等)合成为矩阵
属性计算采用了懒加载和缓存策略。 每个属性都有 frameId 来避免同一帧重复计算。每个属性有_mdf标记属性是否变化, 只有真正变化才会触发属性更改 ,这是 Lottie 性能优化的关键。
渲染(以 lottie svg渲染器 为例)
SVGRenderer 和 SVGRendererBase
它们的作用:
- 是外界创建、控制动画的入口
- 封装并提供创建svg 元素的接口, 如创建图片元素、形状元素
- 初始化 svg 根元素, 设置画布宽高、分辨率等等, 同时应用全局的遮罩、路径剪切等效果
- 负责管理、触发各图层的构建、更新、销毁等流程
SVGRendererBase.prototype.renderFrame = function (num) {
if (this.renderedFrame === num || this.destroyed) {
return;
}
this.globalData.frameNum = num;
this.globalData.frameId += 1;
this.globalData.projectInterface.currentFrame = num;
this.globalData._mdf = false;
var i;
var len = this.layers.length;
if (!this.completeLayers) {
this.checkLayers(num);
}
for (i = len - 1; i >= 0; i -= 1) {
if (this.completeLayers || this.elements[i]) {
this.elements[i].prepareFrame(num - this.layers[i].st);
}
}
if (this.globalData._mdf) {
for (i = 0; i < len; i += 1) {
if (this.completeLayers || this.elements[i]) {
this.elements[i].renderFrame();
}
}
}
};SVGElement
SVGElement 及其众多子类, 对 AE 对象模型进行了抽象, 如各种图层类型形状图层、文字图层、纯色图层、图像图层等, 在其中实现各种特有的一些渲染逻辑。
如形状图层SVGShapeElement, 它的特点是可以在内容中添加各种矢量图形如椭圆、矩形、星形, 并为其赋予动画效果。
所以它在构建过程中, 需要去遍历形状树, 创建所有的形状元素。
SVGShapeElement.prototype.createContent = function () {
this.searchShapes(this.shapesData, this.itemsData, this.prevViewData, this.layerElement, 0, [], true);
this.filterUniqueShapes();
};遍历的核心方法为searchShapes:
- 样式元素 (
fl/st/gf/gs/no) - 通过createStyleElement()创建填充、描边、渐变等样式 - 组元素 (
gr) - 通过createGroupElement()创建形状组 - 变换元素 (
tr) - 通过createTransformElement()创建变换 - 基础形状 (
sh/rc/el/sr) - 通过createShapeElement()创建路径、矩形、椭圆、星形等 - 修改器 (
tm/rd/ms/pb/zz/op/rp) - 应用修剪路径、圆角等效果
SVGShapeElement.prototype.searchShapes = function (arr, itemsData, prevViewData, container, level, transformers, render) {
var ownTransformers = [].concat(transformers);
var ownStyles = [];
var ownModifiers = [];
for (var i = arr.length - 1; i >= 0; i--) {
var item = arr[i];
var processedPos = this.searchProcessedElement(item);
// === 样式类型 ===
if (item.ty === 'fl' || item.ty === 'st' || item.ty === 'gf' || item.ty === 'gs' || item.ty === 'no') {
if (!processedPos) itemsData[i] = this.createStyleElement(item, level);
ownStyles.push(itemsData[i].style);
if (item._render) container.appendChild(itemsData[i].style.pElem);
// === 分组 ===
} else if (item.ty === 'gr') {
if (!processedPos) itemsData[i] = this.createGroupElement(item);
this.searchShapes(item.it, itemsData[i].it, itemsData[i].prevViewData, itemsData[i].gr, level + 1, ownTransformers, render);
if (item._render) container.appendChild(itemsData[i].gr);
// === 变换 ===
} else if (item.ty === 'tr') {
if (!processedPos) itemsData[i] = this.createTransformElement(item, container);
ownTransformers.push(itemsData[i].transform);
// === 基础图形(path, rect, ellipse, star)===
} else if (item.ty === 'sh' || item.ty === 'rc' || item.ty === 'el' || item.ty === 'sr') {
if (!processedPos) itemsData[i] = this.createShapeElement(item, ownTransformers, level);
this.setElementStyles(itemsData[i]);
// === 修饰器(trim path, repeater, offset path等)===
} else if (item.ty === 'tm' || item.ty === 'rd' || item.ty === 'ms' || item.ty === 'pb' || item.ty === 'zz' || item.ty === 'op' || item.ty === 'rp') {
if (!processedPos) {
var modifier = ShapeModifiers.getModifier(item.ty);
modifier.init(this, item);
itemsData[i] = modifier;
this.shapeModifiers.push(modifier);
}
ownModifiers.push(itemsData[i]);
}
this.addProcessedElement(item, i + 1);
}
// === 收尾 ===
for (var s of ownStyles) s.closed = true;
for (var m of ownModifiers) m.closed = true;
};SVGElementsRenderer
在动画过程中, 动画属性不断变换, 各元素主要在修改属性值。
SVGElementsRenderer 封装了所有属性的修改方式, 并通过工程函数返回:
function createRenderFunction(data) {
switch (data.ty) {
case 'fl':
return renderFill;
case 'gf':
return renderGradient;
case 'gs':
return renderGradientStroke;
case 'st':
return renderStroke;
case 'sh':
case 'el':
case 'rc':
case 'sr':
return renderPath;
case 'tr':
return renderContentTransform;
case 'no':
return renderNoop;
default:
return null;
}
}在各元素创建过程中, 会收集所有的会变化(含关键帧)的属性, 为其创建渲染函数。将动画(属性修改)与 svg 元素渲染解耦
在动画更新过程中, 会直接遍历运行渲染函数。
SVGShapeElement.prototype.renderShape = function () {
var i;
var len = this.animatedContents.length;
var animatedContent;
for (i = 0; i < len; i += 1) {
animatedContent = this.animatedContents[i];
if ((this._isFirstFrame || animatedContent.element._isAnimated) && animatedContent.data !== true) {
animatedContent.fn(animatedContent.data, animatedContent.element, this._isFirstFrame);
}
}
};lottie 这种端到端动画方案让动画从“被实现”变为了“被播放”, 极大减少沟通成本, 提高开发效率。
