序列帧动画,又称为逐帧动画,是使用多张连续的静态图片快速切换实现视频动画效果的一种技术。在一些移动设备上展现视频动画,如果使用video标签(或其他播放视频的方法),会因为设备性能不足、预加载不完全等问题导致视频卡顿。某些应用场景下,需要使用视频动画作为场景背景时,上述的卡顿情况更加严重。当然,可以使用GIF图片作为动态背景图,但如果需要控制视频循环次数、与背景音乐同步时,GIF又无能为力了。
所以,序列帧动画利用Canvas强大的重绘能力、Image对象的预加载能解决了上述的性能问题、预加载问题。同时,图片切换非常容易控制间隔时间和次数,可以完美与背景音乐匹配。
在线示例请点击这里,本地示例请点击下载后打开demo.html。
frame_ani(option);
接受一个字典作为输入,修改该对象的初始值。option的默认值如下:
this.option = {
canvasTargetId: null, // 目标画布对象ID(必须)
framesUrl : [], // 每一帧的url(必须)
audioObject: null, // 音频的对象(优先级高于ION)
audioIonName: null, // ION音频的名字(优先级高于路径)
audioUrl: "", // 音频路径(优先级最低)
height: 300, // 图片的高度(必须)
width: 300, // 图片的宽度(必须)
onStart : null, // 加载开始回调函数,传入参数total
onComplete : null, // 播放完毕回调
loop: false, // 是否循环
frequency: 25, // 每秒帧数
}
frame_ani.initialize(callback);
初始化包括图片预加载和封面绘制。该函数不返回值,接受一个函数作为回调函数,会在初始化结束后被调用。
frame_ani.play(); // 播放
frame_ani.pause(); // 暂停
frame_ani.reset(); // 重置
实现的原理比较简单:
预加载所有序列图——选择一个Canvas容器——将一张图片画上去——等待间隔时间——将下一帧图片画上去
画完最后一张图,循环计数一次,决定是否要循环下一次
这样就可以完成核心的序列帧动画的播放功能,背景音乐只需要在视频播放开始和结束处理一下就行。
接下来我将整理一下使用到的各个关键技术点:
在JS语言中,给Image对象的src
属性赋值一个图片的URL,就会启动该图片的加载。加载成功会调用Image.onload
函数,加载失败会调用Image.onerror
函数。于是,把这两个函数指向我们的计数函数,就可以得知一个Image对象是否加载完毕。
如果要同时加载一批图片,就新建等量的Image对象,将他们的Image.onload
函数和Image.onerror
函数都赋值为一个计数函数,然后分别给每个Image对象赋值对应的图片URL。计数函数统计加载完毕的数量,如果等于加载总量,就说明这一批图片都加载完成了。
源码中,预加载部分主要由initialize(callback)
函数启动,由loaded()
函数进行计数。所有帧图片加载完毕后,会调用setPoster()
函数绘制第一帧为封面,该函数也是一个绘制Canvas的最小例子,简洁易懂。
图片预加载部分主要参考这篇文章,感谢。
var ctx = document.getElementById(canvas_DOM_id).getContext('2d');
通过上述代码可以获取到Canvas对象的上下文,通过调用上下文ctx
的函数,可以实现Canvas画布的绘制、擦除等操作。
注意,对于一个Canvas标签来说,它本身有Height、Width属性,是用来决定画布的分辨率的,如果不主动设置,那么就是150*150的默认值。同时,CSS规则也可以作用于
,但只会修改它在页面上的高宽,不会修改其分辨率。如果没有设置CSS中的样式高宽,那么就以
上的高宽属性显示。所以,在实际应用时不能将这两组Height、Width混为一谈。
浏览器提供了window.requestAnimationFrame(callback)
函数用于显示动画效果。该函数接受一个函数作为参数,调用这个函数后,下一次浏览器要刷新页面之前,就会调用它的callback
函数,并将一个时间戳作为参数传给这个callback
。那么,在浏览器刷新页面时,新的内容就已经准备好了。
在序列帧应用中,可以简单理解为每次要绘制下一帧时,就调用window.requestAnimationFrame(callback)
函数,并将绘制Canvas的函数作为参数传进去。
在绘制Canvas的函数中,通过处理时间戳,可以用来控制视频播放的快慢(大概来说就是间隔时间太短就不重绘、间隔太长就跳过几帧去绘制)。
处理完时间戳后,如果要进行重绘,就执行以下三个步骤:计算绘制位置(可选)——清空画布——绘制Image对象到Canvas容器中。
完成了一次绘制后,将当前帧数+1,如果小于最大帧数,就继续调用window.requestAnimationFrame(callback)
函数绘制下一帧。如果已经等于最大帧数了,就说明视频放完一轮了,可以调用一下自己的onComplete
回调函数,处理一下背景音乐,决定一下是否要loop。这些都是琐碎的细节,不展开讲了。
用于传给window.requestAnimationFrame(callback)
函数的绘制函数对应源码中的nextFrame(timestamp)
函数。
在一个Canvas容器上绘制图片,使用的是ctx
对象的drawImage
函数。该函数有好三种用法,这里用的是最多参数的一个。函数如下:
void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
函数的第一个参数image
是一个Image对象。sx, sy, sWidth, sHeight
四个参数决定了源图片(Source)要展示的区域;dx, dy, dWidth, dHeight
四个参数决定在目标容器(Destination,即Canvas画布)上,这个源图片要绘制的区域。示意图如下:
详细的函数讲解请点击这里,上图也是引用自这篇文档,本文不细讲这个函数。
在序列帧动画实现的过程中,每次绘制图片时,采用的方式不是固定的。就好比在CSS中使用background-position一样,根据应用场景的需要来决定使用哪种对齐方式。在我的代码实现中,采用的是类似background-position: center bottom;
,显示长边的绘制方式。
用人话来说:图片在画布上水平居中,竖直贴底显示。保证源图片的长宽比不变,多余的部分将被裁去,主要显示长边,保证Canvas容器被填满。
这个部分似乎用人话也讲不清楚,各位可以在示例上改改Canvas容器的宽高比就清楚了。再次重申一次,如何决定剪裁、绘制的位置是不固定的,应当根据应用需要来决定。这部分在源码中,由calculate
函数来计算全局的剪裁、绘制位置。在nextFrame
函数中有被注释掉的一句,在每次计算重绘的时候先调用calculate
函数计算一波。后来觉得这样写不优美,也没想到其他方法来应对canvas容器宽高动态变化,就暂时先注释掉了。
建议:在使用这个库的时候,保证图片的宽高比和Canvas容器的宽高比相同,再将 这一部分不是序列帧动画的重点,只是用序列帧实现动画的附加产物。作为背景音乐,只要能保证视频和音频大致同步就行。老师傅多年的经验告诉我,每秒播放25帧左右,就能和背景音乐同步。当然,这个帧数也与视频导出时的帧数有关,具体来说要在实践中尝试出来。 背景音乐的播放,只是原生的 在代码中, 源码托管于Github:点击这里。 放到一个
背景音乐的处理
,也支持Ion.sound库来播放。在移动设备上,ion.sound可以保证音频的预加载和流畅播放,非常推荐。
this.bgm
变量保存着背景音乐,在视频的播放、暂停、停止、循环开始时,都要进行对应的处理。这些细节也比较琐碎,就不赘述了。源码
遵循MIT协议。