属性动画和帧动画
web 中的动画主要分为属性动画和帧动画两种, 属性动画是通过改变 dom 元素的属性如宽高, 字体大小或者 transform 的 scale,rotate 等属性, 在一段时间内, 属性值按照时间函数变化来实现的. 帧动画是通过在一段时间内按照一定速率替换图片的方式来实现, 这个和传统的动画方式一致.
帧动画和属性动画各有优缺点: 属性动画不需要加载什么资源, 只需要不断改变属性值, 触发浏览器的重新计算和渲染就可以了. 帧动画能够实现更为复杂的动画效果, 比如游戏角色的技能特效等, 但需要加载一些图片.
Web 中的交互动画特效一般都比较简单, 所以属性动画用的更多, 帧动画比较少. 游戏中的动画效果追求绚丽, 基本都会用帧动画, 部分会结合属性动画.
AE 和 Spine
AE 全称 After Effets, 是 Adobe 公司推出的用于处理视频和图形的软件. ui 界面中的动画效果很多都是用 AE 来做的.
Spine 是针对软件和游戏中的 2d 动画的, 制作动画比 AE 更专业. 游戏中用的比较多.
Lottie 和 Spine Runtime
Lottie 是 Airbnb 推出的可以解析 AE 导出的包含动画信息的 JSON 文件的库, 支持 Android,iOS,React Native 等平台.
Spine Runtime 是 Spine 提供的 Spine 导出的动画解析的库, 支持各种游戏引擎, 如 egret,cocos2d-x 等.
Lottie 渲染时需要提供一系列的图片, 渲染不同帧的时候会使用组合不同的图片. Spine Runtime 使用一个小图片合成的大图片, 渲染时会取不同的部分来渲染.
此外, Lottie 支持 svg,dom,canvas 三种渲染方式, 而 Spine Runtime 只支持 canvas.
实际开发中, Lottie 在应用中用的多, Spine Runtime 在游戏中用的多. 但并不代表他们不能在另外的场景中使用.
在 Web 应用中使用 Spine Runtime
需求中涉及到动画, 设计师没有使用 AE, 而是使用 Spine 来设计的. 导出的文件也是 Spine 特有的格式, 于是我就对 Spine 进行了调研.
经过调研我发现 Spine 的 Runtime 中有 html Canvas, 这就是他可用在 Web 应用中的基础.
我把 demo 下下来看了一下, 通过阅读代码, 替换对应的资源文件, 删减部分无用代码之后, 对 Spine Canvas Runtime 的使用有了一些心得.
动画资源
Spine 导出的文件有 3 个, xxx.atlas,xxx.JSON,xxx.PNG
xxx.JSON 是动画的描述文件, 分为 skeleton,bones,slots,skins,animations 这 5 部分
我们没必要去详细了解, 只需要知道这里的 animations 下有一个叫做 animation 的动画就可以了.
xxx.PNG 是图片文件, 因为图片整合到了一起, 所有有一个 xxx.atlas 文件来描述哪个小图片在什么地方.
资源就这 3 个文件, 接下来就是动画实现的代码了.
动画实现代码
经过分析, 整体流程就是加载资源后, 通过不断的重绘来显示一帧帧的图片, 图片的更新是通过时间的毫秒数来驱动的.
不断重绘的逻辑:
改变绘制内容的逻辑:
每次绘制传入两次绘制的时间差, spine runtime 会计算出当前应该渲染的内容是什么.
上面是核心的不断重绘的机制和更新渲染内容的机制, 整体的流程如下:
先加载资源, 然后不断 re-render.
整体代码如下:
- <!-- saved from url=(0068)http://esotericsoftware.com/files/runtimes/spine-ts/examples/canvas/ -->
- <HTML>
- <head>
- <meta http-equiv="Content-Type" content="text/html; charset=windows-1252">
- <script src="./js/spine-canvas.js"></script>
- <style>
- * { margin: 0; padding: 0; }
- body, HTML { height: 100% }
- canvas { position: absolute; width: 100% ;height: 100%; }
- </style>
- </head>
- <body>
- <canvas id="canvas" width="398" height="588"></canvas>
- <script>
- var lastFrameTime = Date.now() / 1000;
- var canvas, context;
- var assetManager;
- var skeleton, state, bounds;
- var skeletonRenderer;
- // var skelName = "spineboy-ess";
- var skelName = "pk_list_flash";
- // var animName = "walk";
- var animName = "animation";
- function init () {
- canvas = document.getElementById("canvas");
- canvas.width = Windows.innerWidth;
- canvas.height = Windows.innerHeight;
- context = canvas.getContext("2d");
- skeletonRenderer = new spine.canvas.SkeletonRenderer(context);
- // enable debug rendering
- skeletonRenderer.debugRendering = false;
- // enable the triangle renderer, supports meshes, but may produce artifacts in some browsers
- skeletonRenderer.triangleRendering = false;
- assetManager = new spine.canvas.AssetManager();
- assetManager.loadText("assets/" + skelName + ".json");
- assetManager.loadText("assets/" + skelName.replace("-pro", "").replace("-ess","") + ".atlas");
- assetManager.loadTexture("assets/" + skelName.replace("-pro", "").replace("-ess","") + ".png");
- requestAnimationFrame(run);
- }
- function run () {
- if (assetManager.isLoadingComplete()) {
- var data = loadSkeleton(skelName, animName, "default");
- skeleton = data.skeleton;
- state = data.state;
- bounds = data.bounds;
- requestAnimationFrame(render);
- } else {
- requestAnimationFrame(run);
- }
- }
- function loadSkeleton (name, initialAnimation, skin) {
- if (skin === undefined) skin = "default";
- // Load the texture atlas using name.atlas and name.PNG from the AssetManager.
- // The function passed to TextureAtlas is used to resolve relative paths.
- atlas = new spine.TextureAtlas(assetManager.get("assets/" + name.replace("-pro", "").replace("-ess","") + ".atlas"), function(path) {
- return assetManager.get("assets/" + path);
- });
- // Create a AtlasAttachmentLoader, which is specific to the WebGL backend.
- atlasLoader = new spine.AtlasAttachmentLoader(atlas);
- // Create a SkeletonJson instance for parsing the .JSON file.
- var skeletonJson = new spine.SkeletonJson(atlasLoader);
- // Set the scale to apply during parsing, parse the file, and create a new skeleton.
- var skeletonData = skeletonJson.readSkeletonData(assetManager.get("assets/" + name + ".json"));
- var skeleton = new spine.Skeleton(skeletonData);
- skeleton.flipY = true;
- var bounds = calculateBounds(skeleton);
- skeleton.setSkinByName(skin);
- // Create an AnimationState, and set the initial animation in looping mode.
- var animationState = new spine.AnimationState(new spine.AnimationStateData(skeleton.data));
- animationState.setAnimation(0, initialAnimation, true);
- animationState.addListener({
- event: function(trackIndex, event) {
- // console.log("Event on track" + trackIndex + ":" + JSON.stringify(event));
- },
- complete: function(trackIndex, loopCount) {
- // console.log("Animation on track" + trackIndex + "completed, loop count:" + loopCount);
- },
- start: function(trackIndex) {
- // console.log("Animation on track" + trackIndex + "started");
- },
- end: function(trackIndex) {
- // console.log("Animation on track" + trackIndex + "ended");
- }
- })
- // Pack everything up and return to caller.
- return { skeleton: skeleton, state: animationState, bounds: bounds };
- }
- function calculateBounds(skeleton) {
- var data = skeleton.data;
- skeleton.setToSetupPose();
- skeleton.updateWorldTransform();
- var offset = new spine.Vector2();
- var size = new spine.Vector2();
- skeleton.getBounds(offset, size, []);
- return { offset: offset, size: size };
- }
- function render () {
- var now = Date.now() / 1000;
- var delta = now - lastFrameTime;
- lastFrameTime = now;
- resize();
- state.update(delta);
- state.apply(skeleton);
- skeleton.updateWorldTransform();
- skeletonRenderer.draw(skeleton);
- requestAnimationFrame(render);
- }
- function resize () {
- var w = canvas.clientWidth;
- var h = canvas.clientHeight;
- if (canvas.width != w || canvas.height != h) {
- canvas.width = w;
- canvas.height = h;
- }
- // magic
- var centerX = bounds.offset.x + bounds.size.x / 2;
- var centerY = bounds.offset.y + bounds.size.y / 2;
- var scaleX = bounds.size.x / canvas.width;
- var scaleY = bounds.size.y / canvas.height;
- var scale = Math.max(scaleX, scaleY) * 1.2;
- if (scale <1) scale = 1;
- var width = canvas.width * scale;
- var height = canvas.height * scale;
- context.setTransform(1, 0, 0, 1, 0, 0);
- context.scale(1 / scale, 1 / scale);
- context.translate(-centerX, -centerY);
- context.translate(width / 2, height / 2);
- }
- (function() {
- init();
- }());
- </script>
- </body></HTML>
总结
Web 中的动画有属性动画和帧动画两种, 帧动画常用的库有 Lottie 和 Spine Runtime, 用哪一种取决于动效师使用的是 AE 还是 Spine, 其中 Spine 多用于游戏的动画.
从图片资源的管理方式, 支持的渲染方式和平台这几个方面比较了 Lottie 和 Spine Runtime 的区别: Spine 多用于游戏, 图片资源整合到一起并且提供 atlas 文件来标明对应图片位置, 支持 canvas 的渲染方式, 支持各种游戏引擎. Lottie 多用于应用, 图片资源分开存放, 支持 canvas,svg,dom 三种渲染方式, 并且支持 Android,iOS,React Native 等平台. 仅从 canvas 角度看, 两者区别并不大.
因为动效师选择了 Spine 来设计动效, 所以我调研了 Spine Runtime 的动画实现方案, 研究了 Spine 的动画资源和 Spine Cavas Runtime 的代码实现, 运行流程, 全部代码见.
来源: https://juejin.im/post/5bc5be80e51d456f2d710535