动画是个巨大的产业,从广告到电子游戏产业,有大量资金都用在了动画制作上,动画在这些产业中扮演着重要角色。
Dec-10-2020 22-58-13.gif

  1. html
  2. <canvas id="canvas" width="200" height="100" style="border:1px solid #d3d3d3;">
  3. 您的浏览器不支持 HTML5 canvas 标签。</canvas>
  4. js
  5. var c=document.getElementById("canvas");
  6. var ctx=c.getContext("2d");
  7. var x = 2;
  8. function animate(){
  9. if(x === 198){
  10. x = 2;
  11. }
  12. x += 0.2;
  13. ctx.beginPath();
  14. ctx.clearRect(0,0, 200, 100);
  15. ctx.fillStyle = '#fff';
  16. ctx.fillRect(0,0, 200, 100);
  17. ctx.fillStyle = 'red';
  18. ctx.arc(x,50,2,0,2*Math.PI);
  19. ctx.fill();
  20. requestAnimationFrame(animate);
  21. }
  22. animate();

动画循环

在canvas中实现动画效果很简单,只需要在播放动画时持续更新并绘制就行了。这种持续的更新与重绘也叫动画循环(animation loop),它是所有动画的核心逻辑。动画是一种持续循环,但是我们没法实现这种持续循环,至少还不能应用于运行于浏览器中的javascript代码。

  1. function animate(){
  2. // Update and draw animation objects
  3. }
  4. while(true){
  5. animate();
  6. }

上面代码中的while循环实际上是个死循环(endless loop),由于浏览器在主线程中执行了Javascript代码,无法相应用户输入且不能得到想要的结果 ,要实现动画效果,必须让浏览器每隔一小段时间有一个喘息的机会。
为了让浏览器得以喘息,我们可以使用setTimeout()或setInterval()来执行循环。
setInterval

  1. function animate(){
  2. // Update and draw animation objects
  3. }
  4. // Start the animation at 60 frames/second
  5. setInterval(animate, 1000 / 60);

setTimeout

  1. function animate(){
  2. var start = +new Date();
  3. // Update and draw animation objects
  4. var finish = +new Date();
  5. setTimeout(animate, (1000 / 60) - (finish -start));
  6. }
  7. // Start the animation at 60 frames/second
  8. animate(); // start the animation

setInterval()方法每隔一定时间就会调用一次传递给它的函数,然后setTimeout()则会在到达指定时间点时,将传递给他的函数调用一次。由于这两个方法在实现机制上有差别,所有使用setInterval()来实现动画时,只需要调用一次就够了, 而使用setTimeout()来做则需要持续地调用它(setTimeout()存在时间间隔的下限,不同浏览器存在差异性)。
虽然setInterval()和setTimeout()有很多用途,然而他们并不是专门用来实现动画的。实现动画的首选方式,是使用W3C标准中名为requestAnimationFrame()的方法。

  1. function animate(){
  2. // Update and draw animation objects
  3. requestAnimationFrame(animate);
  4. }
  5. // ...
  6. requestAnimationFrame(animate);

想播放动画就调用requestAnimationFrame()方法,并将动画播放函数的引用传递给它。W3C也提供了cancelRequestAnimationFrame()用于取消回调函数。requestAnimationFrame()方法会返回一个long型对象,用于标识回调函数身份的句柄(handle),可将其传递给cancelRequestAnimationFrame()用于取消回调函数的执行。

可移植于各浏览器平台的动画循环逻辑

上面提到用requestAnimationFrame()来实现动画,如果浏览器连该方法都没提供,那么我们就需要写一段默认的实现代码,以免程序出错。
可移植动画逻辑:如果当前浏览器支持W3C的标准实现,那么就使用它,否则,就使用浏览器专属的实现方式。如果浏览器既不支持W3C也没有专属方式,那么就借助setTimeout()方法来实现一段每秒60帧的动画播放代码。

  1. window.requestNextAnimationFrame = (function(){
  2. return window.requestAnimationFrame ||
  3. window.webkitRequestAnimationFrame ||
  4. window.mozRequestAnimationFrame ||
  5. window.msRequestAnimationFrame ||
  6. function(callback) { // Assume element is visible
  7. var self = this;
  8. var start, finish;
  9. window.setTimeout(function(){
  10. start = +new Date();
  11. callback(start);
  12. finish = +new Date();
  13. self.timeout = 1000 / 60 - (finish - start);
  14. }, self,timeout);
  15. }
  16. })();

经Polyfill处理过的requestNextAnimationFrame()就可以使用在项目中了,即使浏览器不支持W3C,也会使用我们自定义的动画函数。

帧速率的计算

前面我们多次提到1秒60帧,帧是什么?
动画是由一系列叫做帧(Frame)的图形组成的,这些图像的显示速率就叫“帧速率(Frame rate)”。通常来说,有必要计算一下帧速率,特别是在基于时间的运动中,或是为了保证动画能够播放的足够流畅,我们需要知道动画的帧速率。

  1. var lastTime = 0;
  2. function calculateFps(){
  3. var now = +new Date(),
  4. fps = 1000 / (now - lastTime);
  5. lastTime = now;
  6. return fps;
  7. };
  8. function animate(time){
  9. // Update and draw animation objects
  10. ctx.fillStyle = 'red';
  11. ctx.fillText(calculateFps().toFixed() + 'fps', 20, 20);
  12. window.requestNextAnimationFrame(animate);
  13. }
  14. window.requestNextAnimationFrame(animate);

程序使用了一个简单的等式,根据当前帧距离上一帧的时间,计算出动画每秒钟播放的帧数(Frame per second,简称fps),也就是帧速率。
实际开发中我们会把不同的任务安排在不同帧速率上执行,以达到性能的提示,例如在播放剧情时要显示剧情文本,播放音乐,或是更新游戏得分。此类任务大多不需要以每秒60帧的速度执行。

基于时间的运动

为什么会有基于时间的运动,假设在多人射击游戏中有2个玩家各自沿着某条走廊前行,如果保存速度不变他们将同时到达终点。若是由于某玩家的电脑配置比另一个人的好的多,而导致其电脑播放游戏的动画更快,那么玩家就不会再和比自己电脑配置高的人玩了,因为他们总是提前到达目的地。而实际情况是,不论底层帧速率如何,动画都应该以稳定的速度播放才对。
想让动画以稳定的速度运行,而不受帧速率影响,那就要根据物体的速度计算出它在两帧之间移动的像素数。计算公式如下:
像素 / 帧 = (像素 / 秒) * (秒 / 帧)
上述公式可以计算出物体每一帧应该移动多少像素。

恢复动画背景

实现动画效果所用的大多数技术都比较简单,文章开头的示例也不例外, 借助requestAnimationFrame()每隔一段时间调用一次自定义的animate(),还有就是根据动画内容计算出物体下一次的位置,并将其绘制在新坐标位置,这做起来也比较容易。绘制动画时具有挑战性的环节在于如何处理物体背景。
从本质上讲,无非以下三种方法:

  • 将所有内容擦除,并重新绘制。
  • 仅重绘内容发生变化的那部分。
  • 从离屏缓存区中将内容变化的那部分背景图复制到屏幕上。

将所有内容擦除并重新绘制,这是最直截了当的方法。如果重绘内容发生变化的区域,那么还得擦除并重绘背景,只不过执行操作的范围仅限于屏幕上有变化的那块区域。最后,我们还有第三种选择,那就是从离屏缓冲区中将内容发生变化的那部分背景图像复制到屏幕(这也叫图块复制 blitting)。

全量擦除

不再累赘,示例已经给出,如果背景图比较简单,推荐全量擦除。

利用裁剪区域来处理动画背景

利用裁剪区域可以将所有操作都局限在裁剪区域内,详细参考canvas clip(),下面列出一些主要步骤:

  1. 调用ctx.save(),保存屏幕canvas状态。
  2. 调用beginPath()来开始一段新的路径。
  3. 在ctx对象上调用arc()、rect()等绘图方法。
  4. 调用clip()方法,将当前路径设置为屏幕canvas的裁剪区域,
  5. 将背景图绘制到屏幕canvas中(绘制操作实际上只影响裁剪区域所在的范围)。
  6. 恢复屏幕canvas形态参数,该操作主要是为了重置裁剪区域。
  1. function drawBackground(){
  2. ctx.drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);
  3. }
  4. function animate(){
  5. ctx.save();
  6. ctx.beginPath();
  7. ctx.arc(...);
  8. ctx.clip();
  9. drawBackground();
  10. ctx.restore();
  11. }

利用裁剪区域有时能提高绘制速度,有时则不能,主要取决于绘制物体的数量。

利用图块复制技术处理动画背景

图块复制技术主要来源于canvas绘图特性,canvas支持将另一个canvas上的绘图数据绘制到自身,详细参考putImageData() 和 getImageData(),这两个方法频繁调用时间会存在性能瓶颈,建议按需使用。
绘图关键步骤和利用裁剪区域来处理动画背景相似,唯一的差别在第5步,图片复制技术的数据来源于离屏canvas。

  1. function drawBackground(){
  2. ctx.drawImage(offscreenCanvas, sx, sy, sw, sh, dx, dy, dw, dh);
  3. }

双缓冲技术

仔细回顾一下示例demo,当我们调用ctx.clearRect(0,0, 200, 100)清除整个画布时,如果动画是单缓冲的(single buffered),那么意味着内容会被立刻绘制到屏幕canvas中,这样的话,擦除背景那一瞬间所造成的空白可能会使动画看起来闪烁。
然后我们的设想并没有发生,这是因为浏览器自动采用了双缓冲技术来实现canvas元素。没有将动画内容直接绘制到屏幕canvas中,而是先将所有内容绘制到离屏canvas里面,然后把该canvas的所有内容一次性复制到屏幕canvas中。

  1. var sum = 0;
  2. function animate(now){
  3. eraseBackground(); // erase the onscreen canvas
  4. for(var i=0; i<500000; i++){
  5. sum +=1;
  6. }
  7. // Dine with busy work
  8. drawBackground(); // Draw brackground into onscreen canvas
  9. draw();
  10. requestAnimationFrame(time);
  11. }
  12. requestAnimationFrame(time);

上述代码在擦除canvas背景之后,执行了一个用于模拟繁忙操作的任务循环。由于浏览器使用了双缓冲技术,擦除并不会立马生效,设想的闪烁并不会出现。

视差动画

从不同位置,以不同的视线来观察同一个物体时,就会产生视差,这也是物体近处看上去比远处移动的快的原因,老司机开车时的感觉应该比较明显。canvas动画中可以通过图层不同的滚动速度来模拟该效果。