gitbook/跟月影学可视化/docs/268865.md
2022-09-03 22:05:03 +08:00

436 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 18 | 如何生成简单动画让图形动起来?
你好,我是月影。
前面我们用了3个模块的时间学习了大量的图形学和数学知识是不是让你的脑袋有一点昏沉没关系你只是需要一点时间来消化这些知识而已。我能给你的建议就是多思考、多练习有了时间的积累你一定可以掌握这些基础知识和思维方法。
从这一节课开始我们要学习一个非常有意思的新模块那就是动画和3D绘图。对于可视化展现来说动画和3D都是用来强化数据表达吸引用户的重要技术手段。它们往往比二维平面图形能够表达更复杂的数据实现更吸引人的视觉效果。
那今天,我们先来聊聊动画的实现。实际上,我们之前也实现了不少动态效果,但你可能还是不知道怎么去实现动画。接下来,我们就来系统地梳理一下动画实现的标准方法。
## 动画的三种形式
什么是动画呢?简单来说,动画就是将许多帧静止的画面以固定的速率连续播放出来。一般来说,动画有三种形式,分别是固定帧动画、增量动画和时序动画。
第一种形式是我们预先准备好要播放的静态图像,然后将这些图依次播放,所以它叫做**固定帧动画**。**增量动画**是在动态绘制图像的过程中,我们修改每一帧中某个或某几个属性的值,给它们一定的增量。第三种形式是在动态绘制图像的过程中,我们根据时间和动画函数计算每一帧中的关键属性值,然后更新这些属性,所以它叫做**时序动画**。
这么说还是比较抽象下面我就以HTML/CSS为例来带你熟悉这三种动画的基本形式。为什么选HTML/CSS呢因为一般来说HTML/CSS、SVG和Canvas2D实现动画的方式大同小异所以我就直接选择你最熟悉的HTML/CSS了。而WebGL实现动画的方式和其他三种图形系统都有差别所以我会在下一节课单独来说。
### 1\. 实现固定帧动画
首先,我们来说说如何实现固定帧动画。
结合固定帧动画的定义我们实现它的第一步就是为每一帧准备一张静态图像。比如说我们要实现一个循环播放3帧的动画就要准备3个如下的图像。
![](https://static001.geekbang.org/resource/image/80/0e/80532862be3yy41356172c40b547f30e.jpeg "3个静态图像")
第二步我们要依次播放这些图像。在CSS里实现的时候我们使用图片作为背景就可以让它们逐帧切换了。代码如下所示
```
.bird {
position: absolute;
left: 100px;
top: 100px;
width:86px;
height:60px;
zoom: 0.5;
background-repeat: no-repeat;
background-image: url(https://p.ssl.qhimg.com/t01f265b6b6479fffc4.png);
background-position: -178px -2px;
animation: flappy .5s step-end infinite;
}
@keyframes flappy {
0% {background-position: -178px -2px;}
33% {background-position: -90px -2px;}
66% {background-position: -2px -2px;}
}
```
![](https://static001.geekbang.org/resource/image/e5/8a/e5cfe9afc454013c3913bfbb03b9548a.gif "动态的小鸟")
虽然固定帧动画实现起来非常简单,但它不适合生成需要动态绘制的图像,更适合在游戏等应用场景中,生成由美术提供现成图片的动画帧图像。而对于动态绘制的图像,也就是非固定帧动画,我们通常会使用另外两种方式。
### 2\. 实现增量动画
我们先来说比较简单的增量动画即每帧给属性一个增量。怎么理解呢我举个简单的例子我们可以创建一个蓝色的方块然后给这个方块的每一帧增加一个rotate角度。这样就能实现蓝色方块旋转的动画。具体的代码和效果如下所示。
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.block {
width: 100px;
height: 100px;
top: 100px;
left: 100px;
transform-origin: 50% 50%;
position: absolute;
background: blue;
}
</style>
</head>
<body>
<div class="block"></div>
<script>
const block = document.querySelector('.block');
let rotation = 0;
requestAnimationFrame(function update() {
block.style.transform = `rotate(${rotation++}deg)`;
requestAnimationFrame(update);
});
</script>
</body>
</html>
```
![](https://static001.geekbang.org/resource/image/fe/41/fe5fd08150d43ff6305042b6ea2ba041.gif "旋转的蓝色方块")
在上面的例子中我们重点关注第22到26这5行JavaScript代码就行了关键逻辑在于我们修改rotatation值每次绘制的时候将它加1。这样我们就实现增量动画是不是也很简单
确实增量动画的优点就是实现简单。但它也有2个缺点。首先因为它使用增量来控制动画从数学角度来说也就是我们直接使用了一阶导数来定义的动画。这样的绘图方式不太好控制动画的细节比如动画周期、变化率、轨迹等等所以这种方法只能用来实现简单动画。
其次增量动画定义的是状态变化。如果我们要在shader中使用动画就只能采用后期处理通道来实现。但是后期处理通道要进行多次渲染实现起来比较繁琐而且性能开销也比较大。所以更加复杂的轨迹动画我们一般采用第三种方式也就是通过定义时间和动画函数来实现。
### 3\. 实现时序动画
还是以旋转的蓝色方块为例我们改写一下它的JavaScript代码。
```
const block = document.querySelector('.block');
const startAngle = 0;
const T = 2000;
let startTime = null;
function update() {
startTime = startTime == null ? Date.now() : startTime;
const p = (Date.now() - startTime) / T;
const angle = startAngle + p * 360;
block.style.transform = `rotate(${angle}deg)`;
requestAnimationFrame(update);
}
update();
```
首先我们定义2个变量startAnglehe和T。其中startAnglehe是起始旋转角度T是旋转周期。在第一次调用update的时候我们设置初始旋转的时间为startTime那么在每次调用update的时候当前经过的时间就是 Date.now() - startTime。
接着我们将它除以周期T就能得到旋转进度p那么当前角度就等于 startAngle + p \* 360。然后我们将当前角度设置为元素的rotate值就实现了同样的旋转动画。
总的来说时序动画的实现可以总结为三步首先定义初始时间和周期然后在update中计算当前经过时间和进度p最后通过p来更新动画元素的属性。虽然时序动画实现起来比增量动画写法更复杂但我们可以更直观、精确地控制旋转动画的周期速度、起始角度等参数。
也正因为如此,这种方式在动画实现中最为常用。那为了更方便使用和拓展,我们可以把实现时序动画的三个步骤抽象成标准的动画模型。具体怎么做呢?我们接着往下看。
## 定义标准动画模型
首先,我们定义一个类 Timing用来处理时间具体代码如下
```
export class Timing {
constructor({duration, iterations = 1} = {}) {
this.startTime = Date.now();
this.duration = duration;
this.iterations = iterations;
}
get time() {
return Date.now() - this.startTime;
}
get p() {
const progress = Math.min(this.time / this.duration, this.iterations);
return this.isFinished ? 1 : progress % 1;
}
get isFinished() {
return this.time / this.duration >= this.iterations;
}
}
```
然后我们实现一个Animator类用来真正控制动画过程。
```
import {Timing} from './timing.js';
export class Animator {
constructor({duration, iterations}) {
this.timing = {duration, iterations};
}
animate(target, update) {
let frameIndex = 0;
const timing = new Timing(this.timing);
return new Promise((resolve) => {
function next() {
if(update({target, frameIndex, timing}) !== false && !timing.isFinished) {
requestAnimationFrame(next);
} else {
resolve(timing);
}
frameIndex++;
}
next();
});
}
}
```
Animator构造器接受{duration, iterations}作为参数它有一个animate方法会在执行时创建一个timing对象然后通过执行update({target, frameIndex, timing})更新动画并且会返回一个promise对象。这样在动画结束时resolve这个promise我们就能够很方便地实现连续动画了。
接下来你可以想一个动画效果来试验一下这个模型的效果。比如说我们可以用Animator实现四个方块的轮换转动让每个方块转动的周期是1秒一共旋转1.5个周期即540度。代码和效果如下所示。
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.container {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
width: 300px;
}
.block {
width: 100px;
height: 100px;
margin: 20px;
flex-shrink: 0;
transform-origin: 50% 50%;
}
.block:nth-child(1) {background: red;}
.block:nth-child(2) {background: blue;}
.block:nth-child(3) {background: green;}
.block:nth-child(4) {background: orange;}
</style>
</head>
<body>
<div class="container">
<div class="block"></div>
<div class="block"></div>
<div class="block"></div>
<div class="block"></div>
</div>
<script type="module">
import {Animator} from '../common/lib/animator/index.js';
const blocks = document.querySelectorAll('.block');
const animator = new Animator({duration: 1000, iterations: 1.5});
(async function () {
let i = 0;
while(true) { // eslint-disable-next-line no-await-in-loop
await animator.animate(blocks[i++ % 4], ({target, timing}) => {
target.style.transform = `rotate(${timing.p * 360}deg)`;
});
}
}());
</script>
</body>
</html>
```
![](https://static001.geekbang.org/resource/image/d2/81/d28f5b8c8cdf8f20dc20566ab45f7181.gif "顺序旋转的四个方块")
## 插值与缓动函数
我们前面说过,时序动画的好处就在于,它能更容易地控制动画的细节。那针对我们总结出的这个标准的动画模型,它又如何控制动画细节呢?
假设,我们已知元素的起始状态、结束状态和运动周期。如果想要让它进行不规则运动,我们可以使用插值的方式来控制每一帧的展现。比如说,我们可以先实现一个匀速运动的方块,再通过插值与缓动函数来实现变速运动。
首先我们用Animator实现一个方块让它从100px处**匀速运动**到400px处。注意在代码实现的时候我们使用了一个线性插值方法left = start \* (1 - p) + end \* p。线性插值可以很方便地实现属性的均匀变化所以用它来让方块做匀速运动是非常简单的。但如果是让方块非匀速运动比如匀加速运动我们要怎么办呢
```
import {Animator} from '../common/lib/animator/index.js';
const block = document.querySelector('.block');
const animator = new Animator({duration: 3000});
document.addEventListener('click', () => {
animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
});
```
实现技巧也很简单我们仍然可以使用线性插值只不过要对插值参数p做一个函数映射。比如说如果要让方块做初速度为0的匀加速运动我们可以将p映射为p^2。
```
p = p ** 2;
const left = start * (1 - p) + end * p;
```
再比如说如果要让它做末速度为0的匀减速运动我们可以将p映射为p \* (2 - p)。
```
p = p * (2 - p);
const left = start * (1 - p) + end * p;
```
那为什么匀加速、匀减速的时候p要这样映射呢要理解这一点我们就得先来回忆一下匀加速和匀减速运动的物理计算公式。
假设某个物体在做初速度为0的匀加速运动运动的总时间为T总位移为S。那么它在t时刻的位移和加速度的计算公式如下
![](https://static001.geekbang.org/resource/image/fb/f3/fb1502fd6d2bca1db9921a5847409cf3.jpeg "匀加速运动的计算公式")
所以我们把p映射为p的平方。
还是同样的情况下如果物体在做匀减速运动那么它在t时刻的位移和加速度的计算公式如下
![](https://static001.geekbang.org/resource/image/5f/d2/5f9726b346e444775080ac98b8e93dd2.jpeg "匀变速运动的计算公式")
所以我们把p映射为p(2-p)。
除此以外我们还可以将p映射为三次曲线 p \* p \* (3.0 - 2.0 \* p) 来实现smoothstep的插值效果等等。那为了方便使用以及实现更多的效果我们可以抽象出一个映射函数专门处理p的映射这个函数叫做**缓动函数**Easing Function
我们可以在前面实现过的Timing类中直接增加一个缓动函数easing。这样在获取p值的时候我们直接用 this.easing(progress) 取代之前的 progress就可以让动画变速运动了。修改后的代码如下
```
export class Timing {
constructor({duration, iterations = 1, easing = p => p} = {}) {
this.startTime = Date.now();
this.duration = duration;
this.iterations = iterations;
this.easing = easing;
}
get time() {
return Date.now() - this.startTime;
}
get p() {
const progress = Math.min(this.time / this.duration, this.iterations);
return this.isFinished ? 1 : this.easing(progress % 1);
}
get isFinished() {
return this.time / this.duration >= this.iterations;
}
}
```
那带入到具体的例子中我们只要多给animator传一个easing参数就可以让一开始匀速运动的小方块变成匀加速运动了。下面就是我们使用这个缓动函数的具体代码
```
import {Animator} from '../common/lib/animator/index.js';
const block = document.querySelector('.block');
const animator = new Animator({duration: 3000, easing: p => p ** 2});
document.addEventListener('click', () => {
animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
});
```
## 贝塞尔曲线缓动
现在我们已经缓动函数的应用了。缓动函数有很多种其中比较常用的是贝塞尔曲线缓动Bezier-easing准确地说是三次贝塞尔曲线缓动函数。接下来我们就来一起来实现一个简单的贝塞尔曲线缓动。
我们先来复习一下三次贝塞尔曲线的参数方程:
![](https://static001.geekbang.org/resource/image/50/6e/50dea3d53d0d81a49b6a3cb982629e6e.jpg)
对于贝塞尔曲线图形来说t是参数P是坐标。而贝塞尔曲线缓动函数则是把Px作为时间参数p把Py作为p的映射。这样我们就知道了参数方程和缓动函数之间映射关系了。
[![](https://static001.geekbang.org/resource/image/f7/0b/f764285ab9554604937917e38f7c440b.jpg "贝塞尔缓动函数图盘来源React.js")](https://react.rocks/example/bezier-easing-editor)
那要想把三次贝塞尔曲线参数方程变换成贝塞尔曲线缓动函数,我们可以使用一种数学方法,叫做[**牛顿迭代法**](https://baike.baidu.com/item/%E7%89%9B%E9%A1%BF%E8%BF%AD%E4%BB%A3%E6%B3%95/10887580?fr=aladdin)Newtons method。因为这个方法比较复杂所以我就不展开细说了。
我们可以使用现成的JavaScript库[bezier-easing](https://github.com/gre/bezier-easing)来生成贝塞尔缓动函数,例如:
```
import {Animator} from '../common/lib/animator/index.js';
const block = document.querySelector('.block');
const animator = new Animator({duration: 3000, easing: BezierEasing(0.5, -1.5, 0.5, 2.5)});
document.addEventListener('click', () => {
animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
});
```
这样,我们能得到如下的效果:
![](https://static001.geekbang.org/resource/image/a8/18/a81289f987f354f7bdd1e983c9472418.gif)
实际上CSS3动画原生支持bezier-easing。所以上面的效果我们也可以使用CSS3动画来实现。
```
.container {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
width: 300px;
}
.block {
width: 100px;
height: 100px;
position: absolute;
top: 100px;
left: 100px;
background: blue;
flex-shrink: 0;
transform-origin: 50% 50%;
}
.animate {
animation: mymove 3s cubic-bezier(0.5, -1.5, 0.5, 2.5) forwards;
}
@keyframes mymove {
from {left: 100px}
to {left: 400px}
}
```
其实贝塞尔缓动函数还有很多种,你可以去[easing.net](https://easings.net/)这个网站里看一看,然后尝试利用里面提供的缓动函数,来修改我们例子代码中的效果,看看动画过程有什么不同。
## 要点总结
这节课,我们讲了动画的三种形式和实现它们的基本方法,并且我们重点讨论了由时序动画衍生的标准动画模型,以及在此基础上,利用线性插值和缓动函数来控制更多动画细节。
首先,我们来回顾一下这三种形式的实现方法和各自的特点:
* 第一种,固定帧动画。它实现起来最简单,只需要我们为每一帧准备一张图片,然后循环播放就可以了。
* 第二种,增量动画。虽然在实现的时候,我们需要在每帧给元素的相关属性增加一定的量,但也很好操作,就是不好精确控制动画细节。
* 第三种是使用时间和动画函数来描述的动画,也叫做时序动画。这种方法能够非常精确地控制动画的细节,所以它能实现的动画效果更丰富,应用最广泛。
然后为了更方便使用我们根据时序动画定义了标准动画模型实现了Animator类。基于此我们就可以使用线性插值来实现动画的匀速运动通过缓动函数来改变动画的运动速度。
在动画的实现中比较常用贝塞尔曲线缓动函数。它是通过对贝塞尔曲线方程进行牛顿迭代求出我们可以使用bezier-easing库来创建贝塞尔缓动函数。CSS3动画原生支持bezier-easing所以如果使用HTML/CSS方式绘制元素我们可以尽量使用CSS3动画。
## 小试牛刀
最后我希望你能利用我们今天学到的时序动画来实现一个简单的动画效果。就是我们假设有一个半径为10px的弹性小球我们让它以自由落体的方式下落200px高度。在这个过程中小球每次落地后弹起的高度会是之前的一半然后它会不断重复自由下落的过程直到静止在地面上。
你能试着用标准动画模型封装好的Animator模块来实现这个效果吗Animator模块的代码你可以在Github仓库中找到也可以直接按照我们前面讲解内容自己实现一下。
* * *
## 源码
本节课的完整示例代码见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/animate)
## 推荐阅读
\[1\] [牛顿迭代法](https://zh.wikipedia.org/wiki/%E7%89%9B%E9%A1%BF%E6%B3%95)
\[2\] [Bezier-easing](https://github.com/gre/bezier-easing)
\[3\] [Easing.net](https://easings.net/)