canvas核心技术-如何实现简单动画

这篇是学习和回顾canvas系列笔记的第四篇,完整笔记详见:canvas核心技术javascript

在前面几篇中,咱们回顾了在canvas中绘制线段,图形,图片等基本功能,当在制做2d游戏或者更为丰富的图表库时,必须提供强大的动画功能。canvas自己不提供像css中animation属性专门来实现动画,可是canvas提供了translatescalerotate等基本功能,咱们能够经过组合使用这些功能来实现动画。css

跟动画有关的概念中,咱们还要理解帧速率。咱们一般说一帧,就是浏览器完整绘制一次所通过的时间。现代浏览器的帧速率通常是60fps,就是在1s内能够绘制60次。若是帧速率太低,就会以为明显的卡顿了。通常是帧速率越高,动画越流畅。在JavaScript中,咱们要在1s内绘制60次,之前的作法是使用setTimeout或者setInterval来定时执行。java

setInterval(() => {
   // 执行绘制操做
}, 1000 / 60);
复制代码

这种经过定时器的方式,虽然能够实现,但不是最好的方式,它只是以固定的时间间隔向执行队列中添加绘制代码,并不必定能跟浏览器的更新频率同步,而且严重依赖当前执行栈的状况,若是某一次执行栈里执行了复杂大量的运算,那么咱们添加的绘制代码可能就不会在咱们设置的时间间隔内执行了。在H5中,现代浏览器都提供了requestAnimationFrame这个方法来执行动画更新逻辑,它会在浏览器的下一次更新时执行传递给它的函数,咱们彻底没必要考虑浏览器的帧速率了,能够更加专一于动画更新的逻辑上。git

const animate = () => {
  // 执行绘制操做
  requestAnimationFrame(animate);
};
animate();
复制代码

固然,若是要兼容之前的浏览器,咱们通常须要结合requestAnimationFramesetTimeout或者setInterval来实现polyfill,简单的处理方式大体以下,更好的实现方式能够查看rAF.jsgithub

function myRequestAnimationFrame(callback) {
  if (requestAnimationFrame) {
    return requestAnimationFrame(callback);
  } else {
    return setTimeout(() => {
      if (performance && performance.now) {
        return callback(performance.now());
      } else {
        return callback(Date.now());
      }
    }, 1000 / 60);
  }
}

function cancelMyRequestAnimationFrame(id) {
  if (cancelAnimationFrame) {
    cancelAnimationFrame(id);
  } else {
    clearTimeout(id);
  }
}

复制代码

平移

在动画处理中,css能够针对某一个具体的元素来执行平移操做,在canvas中,只能平移坐标系,从而间接的改变了canvas中元素的位置。在canvas核心技术-如何绘制线段中,详细讲解了canvas坐标系相关知识,有兴趣的同窗能够先去看看。canvas坐标系默认原点是在左上角,水平向右为X正方向,垂直向下为Y正方向。能够经过平移canvas坐标系,能够把坐标原点移动到canvas中某一块区域,或者canvas可见区域外。canvas

//平移坐标系以前
ctx.strokeStyle = 'grey'; 
ctx.setLineDash([2, 2]);
ctx.rect(10, 10, 100, 100); //绘制矩形
ctx.stroke(); 
//平移坐标系
ctx.translate(120,20); //平移坐标系,往右平移120px,往下平移20px
ctx.beginPath(); //开始新的路径
ctx.strokeStyle='blue'; 
ctx.setLineDash([]); 
ctx.rect(10, 10, 100, 100); //绘制一样的矩形
ctx.stroke(); 
复制代码

咱们在平移以前,在坐标(10,10)处绘制了一个边长都为100的矩形,如图灰色虚线矩形,接着,咱们调用ctx.translate(120,20)把坐标系向左平移120个像素,向下平移了20个像素,以后,咱们有一样的在坐标(10,10)处绘制了一个边长为100的矩形,如图蓝色实线矩形。这两个矩形,咱们绘制的坐标和边长都没有改变,可是坐标系被平移了,因此绘制出来的位置也发生了变化。浏览器

坐标系平移示意图以下,函数

缩放

坐标系不只能够平移,还能够被缩放,canvas提供了ctx.scale(x,y) 来缩放X轴和Y轴。在默认状况下,canvas的缩放因子都是1.0,表示在canvas坐标系中,1个单位就表示绘制的1px长度,若是经过scale函数改变缩放因子为0.5,则在canvas坐标系中,1个单位就表示绘制0.5px长度了,原来的图形被绘制出来就只有一半大小了。post

ctx.strokeStyle = 'grey'; 
ctx.fillStyle = 'yellow'; 
ctx.globalAlpha = 0.5; 
ctx.fillRect(0, 0, width, height); //填充当前canvas整个区域
ctx.globalAlpha = 1;
ctx.setLineDash([2, 2]); 
ctx.rect(10, 10, 100, 100); //绘制矩形
ctx.stroke(); 
ctx.scale(0.5, 0.5); //缩放坐标系,X轴和Y轴都同时缩放为0.5
ctx.beginPath(); //开始新的路径
ctx.fillStyle = 'green';
ctx.strokeStyle = 'red';
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, width, height); //填充缩放以后的canvas整个区域
ctx.globalAlpha = 1;
ctx.setLineDash([]); 
ctx.rect(10, 10, 100, 100); //绘制一样的矩形
ctx.stroke(); 
复制代码

能够看到,咱们将X轴和Y轴同时都缩小为原来的一半,新绘制出来的矩形(红色实线)不只宽高都缩小为原来的一半了,且左上角坐标位置也发生了变化。这里要理解的是,咱们在缩放,是针对坐标系缩放的,黄色区域为缩放以前的canvas坐标系区域,绿色区域为缩放0.5以后的canvas坐标系区域。学习

ctx.scale(0.5, 1); //缩放坐标系,X轴缩放为0.5,Y轴不变
复制代码

能够对X轴和Y轴的缩放因子设置为不同,如上面示例,对X轴缩小为0.5,而Y轴不变,缩放以后的Canvas区域在X轴上就变为原来的一半了。

还有一些其余的技巧,好比制做镜像,设置缩放ctx.scale(-1,1)就能够绘制出Y轴的对称镜像了。同理,设置缩放ctx.scale(1,-1)就能够绘制出X轴的对称镜像了。

ctx.font = '18px sans-serif';
ctx.textAlign = 'center';
ctx.translate(width / 2, 0); //先将坐标系向X轴平移到中间
ctx.strokeStyle = 'grey'; //设置描边样式
ctx.fillStyle = 'yellow';
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha = 1;
ctx.setLineDash([2, 2]); //设置虚线
ctx.rect(10, 10, 100, 100); //绘制矩形
ctx.stroke(); //描边
ctx.setLineDash([]); //设置实线
ctx.strokeText('我是文字', 60, 60);
ctx.scale(-1, 1); //缩放坐标系,X轴和Y轴都同时缩放为0.5
ctx.beginPath(); //开始新的路径
ctx.fillStyle = 'green';
ctx.strokeStyle = 'red'; //设置描边样式
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha = 1;
ctx.setLineDash([]); //设置实线
ctx.strokeText('我是文字', 60, 60);
ctx.rect(10, 10, 100, 100); //绘制一样的矩形
ctx.stroke(); //描边
复制代码

如图,咱们实现了在Y轴对称的镜像,在设置缩放以前先平移了坐标系到X轴的中间,由于不这样的话,咱们缩放以后,绘制出来的部分就在canvas可见区域外面了,就看不到了。

旋转

在canvas中能够经过ctx.rotate(angle)来实现坐标系的旋转,参数angle是弧度值,而不是角度值。1角度等于\frac\pi{180},在调用以前须要先进行角度转弧度,计算公式以下,

//角度转换为弧度
function toAngle(degree) {
  return (degree * Math.PI) / 180;
}
复制代码

咱们来看一个将坐标系旋转15角度的示例,以下,

ctx.font = '18px sans-serif';
ctx.textAlign = 'center';
ctx.strokeStyle = 'grey'; //设置描边样式
ctx.fillStyle = 'yellow';
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha = 1;
ctx.setLineDash([2, 2]); //设置虚线
ctx.rect(10, 10, 100, 100); //绘制矩形
ctx.stroke(); //描边
ctx.setLineDash([]); //设置实线
ctx.strokeText('我是文字', 60, 60);
ctx.rotate(15* Math.PI/180); //将坐标系旋转15角度
ctx.beginPath(); //开始新的路径
ctx.fillStyle = 'green';
ctx.strokeStyle = 'red'; //设置描边样式
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha = 1;
ctx.setLineDash([]); //设置实线
ctx.strokeText('我是文字', 60, 60);
ctx.rect(10, 10, 100, 100); //绘制一样的矩形
ctx.stroke(); //描边
复制代码

黄色区域是旋转原默认canvas坐标系区域,绿色区域就是旋转以后的坐标系区域了,能够看到,旋转操做的实际也是把整个canvas坐标都旋转了,canvas里面的内容都会跟着被旋转。传入的参数angle不只能够是正数,也能够是负数,正数是顺时针旋转,负数表示逆时针旋转。

ctx.rotate(-15* Math.PI/180);  //逆时针旋转15角度
复制代码

这些使用都比较简单,也好理解。在实际中,能够须要同时对canvas坐标系进行平移,缩放和旋转。在这种状况下,咱们能够分别单独的使用上面这些方法进行对应的操做,他们的效果是叠加的。在canvas中,实际还提供了一个方法,能够同时实现平移,缩放,旋转。下面,咱们就来看看这个方法的神奇之处。

transform

在进行坐标系数据变换时,最经常使用的手段就是先建模成单位矩阵,而后对单位矩阵作变换。实际上,上面说的平移,缩放,旋转都是做用到矩阵上的。canvas中ctx.transform(a,b,c,d,e,f)提供了6个参数,在canvas中矩阵是纵向存储的,表明的矩阵为,

\begin{pmatrix}
a&c&e\\
b&d&f\\
0&0&1\\
\end{pmatrix}*\begin{pmatrix}
x\\
y\\
w\\
\end{pmatrix} = \begin{pmatrix}
x^{\prime}\\
y^{\prime}\\
w^{\prime}\\
\end{pmatrix}

在2维坐标系中,表示一个点为(x,y),为了作矩阵变换,咱们须要将标准的2维坐标扩展到3维,须要增长一维w,这就是2维齐次坐标系(x,y,w)。齐次坐标上一点(x,y,w)映射到实际的2维坐标系中就是(x/w,y/w)。若是想要点(x,y,w)映射在实际2维坐标系是(x,y),因此咱们只须要 设置w=1就能够了,更多可查看齐次坐标。而后根据矩阵相乘获得的公式以下,

x^{\prime} = ax + cy + e
\\
y^{\prime} = bx + dy + f

先来看看平移,咱们看看把一个点(x,y)平移到另一个点(x',y')。公式以下,

x^{\prime} = x + d_{x}
\\
y^{\prime} = y + d_{y}

将平移公式代入到上面咱们推到出来的矩阵变换公式中能够获得,a=1c=0e=d_{x}b=0d=1f=d_{y}。咱们用transform实现平移,只须要调用ctx.transform(1,0,0,1,dx,dy),效果跟调用ctx.translate(dx,dy)同样的。

//平移坐标系以前
ctx.strokeStyle = 'grey';
ctx.setLineDash([2, 2]);
ctx.rect(10, 10, 100, 100); //绘制矩形
ctx.stroke();
//平移坐标系
// ctx.translate(120,20); //平移坐标系,往右平移120px,往下平移20px
ctx.transform(1, 0, 0, 1, 120, 20); //使用transform来平移
ctx.beginPath(); //开始新的路径
ctx.strokeStyle = 'blue';
ctx.setLineDash([]);
ctx.rect(10, 10, 100, 100); //绘制一样的矩形
ctx.stroke();
复制代码

能够看到,ctx.translate(120,20);ctx.transform(1, 0, 0, 1, 120, 20);获得的效果是同样的。

再来看看缩放,咱们把一个点(x,y) 经过缩放坐标系k以后,获得的新的点的坐标为(x',y')。公式以下,

x^{\prime} = k * x \\
y^{\prime} = k * y

咱们也将缩放公式代入到矩阵变换公式中,能够获得a = kc = 0e = 0b = 0d = kf = 0。咱们用transform来实现缩放,只须要调用ctx.transform(k,0,0,k,0,0),效果跟调用ctx.scale(k,k)同样的。

ctx.strokeStyle = 'grey';
ctx.fillStyle = 'yellow';
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, width, height); //填充当前canvas整个区域
ctx.globalAlpha = 1;
ctx.setLineDash([2, 2]);
ctx.rect(10, 10, 100, 100); //绘制矩形
ctx.stroke();
// ctx.scale(0.5, 0.5); //缩放坐标系,X轴和Y轴都同时缩放为0.5
ctx.transform(0.5, 0, 0, 0.5, 0, 0); //使用transform来缩放
ctx.beginPath(); //开始新的路径
ctx.fillStyle = 'green';
ctx.strokeStyle = 'red';
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, width, height); //填充缩放以后的canvas整个区域
ctx.globalAlpha = 1;
ctx.setLineDash([]);
ctx.rect(10, 10, 100, 100); //绘制一样的矩形
ctx.stroke(); 
复制代码

能够看到,调用ctx.transform(0.5,0,0,0.5,0,0)ctx.scale(0.5,0.5)效果是同样的。

最后来看看旋转,咱们把一个坐标(x,y)在旋转坐标角度\beta以后获得新的坐标(x',y'),公式以下,

x^{\prime} = \cos(\beta) * x-\sin(\beta)*y
\\
y^{\prime} =   \sin(\beta)*x + \cos(\beta) *y

上面的公式,是根据三角形两角和差公式计算出来的,推导详见2D Rotation。同理,咱们将旋转公式代入到矩阵变换公式能够获得a=\cos(\beta)c=-\sin(\beta)e=0b=\sin(\beta)d=\cos(\beta)f=0。咱们调用ctx.transform(\cos(\beta),\sin(\beta),-\sin(\beta),\cos(\beta),0,0)ctx.rotate(\beta)是同样的。注意,咱们这里的\beta是弧度值。

ctx.font = '18px sans-serif';
ctx.textAlign = 'center';
ctx.strokeStyle = 'grey'; //设置描边样式
ctx.fillStyle = 'yellow';
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha = 1;
ctx.setLineDash([2, 2]); //设置虚线
ctx.rect(10, 10, 100, 100); //绘制矩形
ctx.stroke(); //描边
ctx.setLineDash([]); //设置实线
ctx.strokeText('我是文字', 60, 60);
// ctx.rotate(15* Math.PI/180); //将坐标系旋转15角度
let angle = (15 * Math.PI) / 180; //计算获得弧度值
let cosAngle = Math.cos(angle); //计算余弦 
let sinAngle = Math.sin(angle); //计算正弦
ctx.transform(cosAngle, sinAngle, -sinAngle, cosAngle, 0, 0); //使用transform旋转
ctx.beginPath(); //开始新的路径
ctx.fillStyle = 'green';
ctx.strokeStyle = 'red'; //设置描边样式
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha = 1;
ctx.setLineDash([]); //设置实线
ctx.strokeText('我是文字', 60, 60);
ctx.rect(10, 10, 100, 100); //绘制一样的矩形
ctx.stroke(); //描边
复制代码

能够看到调用ctx.transform(cosAngle,sinAngle,-sinAngle,cosAngle,0,0)ctx.rotate(angle)是同样的效果。

上面三种基本的操做坐标系的方式,咱们均可以经过transform实现,经过组合,咱们能够一次性设置坐标系的平移,旋转,缩放,只须要计算出正确的a,b,c,d,e,f。例如,咱们将上面三种操做同时实现,先平移,再缩放,最后再旋转,分别给出translate+scale+rotate来实现,和transform来实现,

  • translate+scale+rotate组合实现
ctx.font = '18px sans-serif';
ctx.textAlign = 'center';
ctx.strokeStyle = 'grey'; //设置描边样式
ctx.fillStyle = 'yellow';
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha = 1;
ctx.setLineDash([2, 2]); //设置虚线
ctx.rect(10, 10, 100, 100); //绘制矩形
ctx.stroke(); //描边
ctx.setLineDash([]); //设置实线
ctx.strokeText('我是文字', 60, 60);
let angle = (15 * Math.PI) / 180;
ctx.translate(120, 20); //先平移
ctx.scale(0.5, 0.5); //再缩放
ctx.rotate(angle);//最后再旋转
ctx.beginPath(); //开始新的路径
ctx.fillStyle = 'green';
ctx.strokeStyle = 'red'; //设置描边样式
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha = 1;
ctx.setLineDash([]); //设置实线
ctx.strokeText('我是文字', 60, 60);
ctx.rect(10, 10, 100, 100); //绘制一样的矩形
ctx.stroke(); //描边
复制代码

  • transform一次性实现
let angle = (15 * Math.PI) / 180;
// ctx.translate(120, 20);
// ctx.scale(0.5, 0.5);
// ctx.rotate(angle);
let cosAngle = Math.cos(angle);
let sinAngle = Math.sin(angle);
ctx.transform(0.5 * cosAngle, 0.5 * sinAngle, -0.5 * sinAngle, 0.5 * cosAngle, 120, 20);
复制代码

这两种方式最终获得的效果是同样的,其实在将translate+scale+rotate组合用transform一次性实现时,就是在作矩阵的变换计算,

\begin{pmatrix}
1&0&120\\
0&1&20\\
0&0&1\\
\end{pmatrix}*\begin{pmatrix}
0.5&0&0\\
0&0.5&0\\
0&0&1\\
\end{pmatrix}*\begin{pmatrix}
\cos(\beta)&-\sin(\beta)&0\\
\sin(\beta)&\cos(\beta)&0\\
0&0&1\\
\end{pmatrix} = \begin{pmatrix}
0.5*\cos(\beta)&-0.5*\sin(\beta)&120\\
0.5*\sin(\beta)&0.5*\cos(\beta)&20\\
0&0&1\\
\end{pmatrix}

三个矩阵相乘,分别是平移矩阵*缩放矩阵*旋转矩阵,根据计算出来的矩阵,最后代入到公式中,能够获得a=0.5*\cos(\beta)b=0.5*\sin(\beta)c=-0.5*\sin(\beta)d=0.5*\cos(\beta)e=120f=20

transform若是屡次调用,它的效果也是叠加的,例如,咱们也能够分开用transform来实现上面的平移,缩放,旋转,

ctx.transform(1, 0, 0, 1, 120, 20); //使用transform来平移
ctx.transform(0.5, 0, 0, 0.5, 0, 0); //使用transform来缩放
ctx.transform(cosAngle, sinAngle, -sinAngle, cosAngle, 0, 0); //使用transform旋转
复制代码

第二次调用transform来缩放,是在第一次平移以后的坐标系上进行的,第三次调用transform来旋转,是在第一次和第二次结果上来进行的。canvas中提供了setTransform函数,它相似于transform函数,一样接受a,b,c,d,e,f6个参数,且参数含义与transform中一摸同样,跟transform不一样之处在于,它不会叠加矩阵变换的效果,它会先重置当前坐标系矩阵为默认的单元矩阵,以后再执行跟transform同样的矩阵变换。因此,若是咱们在调用transform变换矩阵时,不想屡次调用叠加,那么能够替换使用setTransform。实际上还有一个实验性的函数resetTransform,它的做用就是重置当前坐标系矩阵为默认的单元矩阵,去掉了做用在默认坐标系上的变换效果,注意它是一个实验性的函数,还有不少浏览器都没有提供支持,不建议使用。经过分析,咱们能够获得,

setTransform(a,b,c,d,e,f)=resetTransform() + transform(a,b,c,d,e,f)

小结

这篇文章主要是学习和回顾了canvas中坐标系的变换,咱们是经过矩阵变换来实现canvas坐标系的变化,包括translatescalerotatetransformsetTransform,经过组合使用,能够实现强大的动画效果。实际上,动画效果应该在一段时间内持续变化,这篇文章,只学习了单一的变化,尚未涉及时间等动画因素,下一篇准备学习和回顾动画的高级知识,包括时间因素,物理因素,时间扭曲变化函数等。