(三):Canvas动画🔥上——动画原理及匀速、变速运动(大量示例及代码)

3,671 阅读9分钟

本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

动画就是运动,运动就是一个物体随着时间在空间中改变它的状态(位置、形状、大小等)。

动画帧,将一系列离散的图像以极快的速度连续播放,从而模拟物体运动或变化。帧率保持在每秒24帧,人脑就会将其看做运动。而且人眼无法识别更高频率的帧。

几乎所有的投影运动媒体都使用帧实现动画。

程序帧,程序中的帧是对一个图像描述(而不再是动画帧中某一时刻的图像)。连续的帧就是遵循特定的规则构建后续帧。程序帧可以减少体积,但复杂程序帧可能对程序性能有很高的要求。

动画的基本步骤

  1. 清空canvas : 在绘制每一帧动画之前,要清空所有。清空最简单的方法是clearRect()
  2. 保存canvas状态 : 如果在绘制的过程中会更改 canvas 的状态(颜色、移动了坐标原点等),又在绘制每一帧时都是原始状态的话,则最好保存下canvas的状态。
  3. 绘制动画图形 : 绘制当前帧的动画图形。
  4. 恢复canvas状态 : 如果前面保存过canvas状态,在当前帧绘制完后要恢复。

为了执行动画,需要 定时执行重绘 的方法,可以通过setInterval()setTimeout()requestAnimationFrame()三个函数实现页面渲染的重绘(推荐使用 requestAnimationFrame)。

以下代码均在如下 800*500 的画布上实现:

<canvas id="c1" width="800" height="500" style="border: 1px solid #000;"></canvas>

setInterval() 定时执行函数

setInterval() 是一个定时器,用于定时执行一段代码,通过这个特定可以定时调用会canvas的重绘,实现动画效果。

setInterval(function, milliseconds) 定时执行并不是严格的间隔固定时间执行。因为 milliseconds 会包含 function 执行的耗时。

对于先要实现 固定间隔 执行一段代码的效果,推荐使用 setTimeout() 函数。

如下,创建一个矩形,通过 setInterval 定制重新绘制该矩形,绘制时实现不断改变x轴坐标,匀速运动。

let canvas = document.getElementById('c1');
let ctx = canvas.getContext('2d');
// 矩形
let rectProps = {
   x: 0, y: 0,
   width: 100, height: 100, 
   fillStyle: 'red',
   strokeStyle:"blue"
};

let StartX = rectProps.x, EndX = canvas.width - rectProps.width;
let NowX = StartX;
let Speed = 10;

// 移动
let translateRect=function(){
   NowX += Speed;

   if (NowX >= EndX) {
       NowX = EndX;                
   }
   // 移动横坐标
   rectProps.x = NowX;

   // 清除画布,用一个Rect指明清除区域的范围大小
   ctx.clearRect(0, 0, canvas.width, canvas.height);
   // 绘制矩形
   drawRect(rectProps);
   return NowX === EndX;
}
// 绘制
function drawRect(r) {
   ctx.beginPath();
   ctx.rect(r.x, r.y, r.width, r.height);
   ctx.fillStyle = r.fillStyle;
   ctx.fill();

   ctx.strokeStyle = r.strokeStyle;
   ctx.lineWidth = 2;
   ctx.stroke();
}

// 定时执行移动函数 translateRect
let timer = setInterval(function () {
   if (translateRect()) {
       clearInterval(timer);
   }
}, 1000 / 24); // 每秒24次 

通过定时执行上面的setInterval,可以得到一个移动的红色矩形:

setTimeout()延迟执行

setTimeout() 用于延迟指定的时间执行一次函数,通过 setTimeout 定时执行的回调中嵌套调用 setTimeout() 可以实现,固定间隔的执行一段代码。

具体使用和 setInterval 类似,不再赘述

requestAnimationFrame

setTimeoutsetInterval有可能无法按指定时间运行,同时也无法保证在正确的页面重绘时执行,影响浏览器的绘制性能等,所以通常在动画中,推荐使用requestAnimationFrame()

setTimeoutsetInterval指定的回调函数,必须等到本轮事件循环的所有同步任务都执行完,才会开始执行。

由于前面的任务到底需要多少时间执行完,是不确定的,所以没有办法保证,setTimeout和setInterval指定的任务,一定会按照预定时间执行。

requestAnimationFrame是html5专门请求动画帧的API。由系统决定回调函数的执行(根据显示器刷新频率等多种因素),不会引起丢帧和卡顿。

requestAnimationFrame要想循环执行,需要在回调中再次调用requestAnimationFrame

window.requestAnimationFrame() 告诉浏览器 —— 你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。

该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

使用requestAnimationFrame,实现上面 矩形匀速位移 的动画:

// 替换上面 setInterval 部分的代码。其他保持不变即可。
window.requestAnimationFrame(function anima(){
    if (!translateRect()) {
        window.requestAnimationFrame(anima);
    }
})

/*
//或
(function loop(){
    window.requestAnimationFrame(()=>{
      if (!translateRect()) {
          loop();
      }
    })
})();
*/

效果和之前类似。

物体的基本运行

长方形类和小球类

新建一个长方形类和小球类,里面完成长方形和小球的绘制,后面的动画绘制中都直接使用这两个类的方法。

  • rect.js
/* 
长方形类
*/
class Rect{
    constructor(props){
        this.x = 0;
        this.y = 0;
        this.width = 100;
        this.height=70;

        //描边和填充颜色
        this.strokeStyle = "rgba(0,0,0,0)";//透明黑色,不描边
        this.fillStyle = "red"
        this.alpha = 1;

        Object.assign(this, props);// 初始化props配置参数
        return this;
    }
    render(ctx) {
        let { x, y, r, width, height, fillStyle, strokeStyle, alpha } = this;
        ctx.save();
        // 坐标原点移动到绘制坐标
        ctx.translate(x, y);
        ctx.strokeStyle = strokeStyle,
        ctx.fillStyle = fillStyle;
        ctx.globalAlpha = alpha;
        ctx.beginPath();
        ctx.rect(0, 0, width, height);
        ctx.fill();
        ctx.stroke();

        ctx.restore();
        return this;
    }
}
  • ball.js
/* 
小球类
*/
class Ball{
    constructor(props){
        // 小球坐标 默认0,0 半径 20
        this.x=0;
        this.y=0;
        this.r=20;
        // 横向 纵向 缩放倍数
        this.scaleX=1;
        this.scaleY=1;

        //描边和填充颜色
        this.strokeStyle="rgba(0,0,0,0)";//透明黑色,不描边
        this.fillStyle="red" 
        this.alpha=1;

        Object.assign(this,props);// 初始化props配置参数
        return this;
    }
    render(ctx){
        let {x,y,r,scaleX,scaleY,fillStyle,strokeStyle,alpha}=this;
        ctx.save();
        // 坐标原点移动到绘制坐标
        ctx.translate(x,y);
        ctx.scale(scaleX,scaleY);
        ctx.strokeStyle=strokeStyle,
        ctx.fillStyle=fillStyle;
        ctx.globalAlpha=alpha;
        ctx.beginPath();
        ctx.arc(0,0,r,0,2*Math.PI);
        ctx.fill();
        ctx.stroke();

        ctx.restore();
        return this;
    }
}

匀速运动——动画的处理流程(六步)

匀速运动可以看作物体从一个状态的值匀速的变化到另一个状态的值的过程

因此 匀速运动 动画的处理流程如下(一共六步):

  1. 设定初始值和结束值。 求出改变量 delta=末值-初值
let start = 10, end = 100;
let delta=end-start;

可以将开始和结束假设为x位置的变化。

  1. 设定完成动作的总步长step,和当前步数nowStep
let nowStep = 0, step = 80;
  1. 改变nowStep,其中k是一个常量。 匀速运动 是要保证 当前步数的每次变化都相同。
nowStep+=k;
  1. 求当前的状态值,并进行绘制

    当前的值为 初始值+总改变量*当前步数占总步数的的比例 (即 初始值+当前完成量)。

    【当前步数为0,当前值等于开始值;当前步数等于总步数,执行完成,当前值等于开始值+改变量】

now=start+ delta*(nowStep/step)
  1. 使用当前值进行绘制。

  2. 循环从第3步开始的过程,直到当前步数>=总步数

这六步基本就是动画绘制的总体处理流程,不同的运动形式(变化形式),只是状态值的变化和处理不同。

实现一个匀速变化的动画

如下为一个矩形位置和大小从一个状态到另一个状态匀速变换的过程:

let rectProps = {
            x: 0, y: 0,
            width: 100, height: 100, 
            fillStyle: 'red',
            strokeStyle:"blue"
        };

//1、 设定初始值和结束值 求出改变量 delta=末值-初值
let startX = rectProps.x, endX = 300;
let startY = rectProps.y, endY = 400;
let startW = rectProps.width, endW = rectProps.width + 200;
//2、 设定完成动作的 总步长step,和当前的步数  
let nowStep = 0, step = 280;
//3、 匀速运动 是要保证 当前步数的每次变化都是相同的
// 改变 nowStep
//4、 求当前的状态值,并进行绘制
//    当前的值为初始值加上总改变量*当前步数占总步数的的比例
//    【当前步数为0,当前值等于开始值,当前步数等于总步数,执行完成,当前值等于开始值+改变量】
// now = start + delta * (nowStep / step)

//5、使用当前值进行绘制
//6、循环从3开始的过程,直到当前步数>=总步数

let rect=new Rect(rectProps).render(ctx);

(function loop() {
  window.requestAnimationFrame(function () {
      if (nowStep >= step) {
          nowStep = step;
      }

      nowStep += 1;
      // 改变属性
      rect.x = startX + (endX - startX) * (nowStep / step);
      rect.y = startY + (endY - startY) * (nowStep / step);
      rect.width = startW + (endW - startW) * (nowStep / step);

      ctx.clearRect(0, 0, canvas.width, canvas.height);

      // 重绘
      rect.render(ctx);

      if (nowStep < step) {                
          loop();
      }
  });
})();

变速运动

匀变速运动的原理

匀变速可以分为匀加速和匀减速,核心在当前步数 nowStep 的每次变化上,变化量是恒定增大或减小的

整体处理流程和上面匀速运动是一样的,就是第3步 nowStep 变化的变化量也是改变的。

具体变化值的处理原理如下:

// 加速度dv=dv+a  a是常量(正值 匀加速 ; 负值 匀减速;0 为匀速) v表示速度,每次速度都变快或变慢。
// 同时可指定一个初始的加速度 正值 。
dv += a;

// 当前的平均速度v=v+dv/2  即 上一次的速度+当前加速度的平均值
v += dv/2;

// 当前步数 = 上次步数+当前速度(变化的速度)
nowStep += v;

// 去掉常量2后的简化表示是:
dv += a;
nowStep += dv;

实现匀加速动画

let startX = rectProps.x, endX = 300;
let startY = rectProps.y, endY = 400;
let startW = rectProps.width, endW = rectProps.width + 200;
let nowStep = 0, step = 80;

// 设置初始加速度 0
let dv =0; 
let a=0.1; // 加速度每次增加0.1

let rect=new Rect(rectProps).render(ctx);

(function loop() {
  window.requestAnimationFrame(function () {
      if (nowStep >= step) {
          nowStep = step;
      }

      // 改变属性
      rect.x = startX + (endX - startX) * (nowStep / step);
      rect.y = startY + (endY - startY) * (nowStep / step);
      rect.width = startW + (endW - startW) * (nowStep / step);
   
      ctx.clearRect(0, 0, canvas.width, canvas.height);   
      // 重绘
      rect.render(ctx);

      if (nowStep < step) {
          // 匀加速
          dv += a;
          nowStep += dv;
       
          loop();
      }
  });
})();

匀减速

给定一个初始加速度和负值的加速度变量(可适当修改步长进行调整,否则效果不易查看)。注意速度为0时的处理

如下:

let startX = rectProps.x, endX = 300;
let startY = rectProps.y, endY = 400;
let startW = rectProps.width, endW = rectProps.width + 200;
let nowStep = 0, step = 280;

let dv =20; 
let a=-0.7; // 匀减速

let rect=new Rect(rectProps).render(ctx);

(function loop() {
  window.requestAnimationFrame(function () {
      if (nowStep >= step) {
          nowStep = step;
      }

      ctx.clearRect(0, 0, canvas.width, canvas.height);
      // 改变属性
      rect.x = startX + (endX - startX) * (nowStep / step);
      rect.y = startY + (endY - startY) * (nowStep / step);
      rect.width = startW + (endW - startW) * (nowStep / step);
      
      // 重绘
      rect.render(ctx);

      if (nowStep < step && dv>0) {                
          // 匀速运动
          //nowStep += 1;

          // 匀加速
          dv += a;
          nowStep += dv;
       
          loop();
      }
  });
})();

模拟重力加速度(弹跳小球)

重力加速度是向下的一种特殊的加速度,加速度值固定不变。速度损耗由空气摩擦等导致。

如下,模拟重力加速度实现小球弹跳:

window.onload=()=>{
   let canvas = document.getElementById("c1");
   let ctx = canvas.getContext("2d");

   let H=canvas.height;
   let W=canvas.width;
   let ball=new Ball({
       x:W/2,
       y:H/5
   }).render(ctx);

   let speedY=0,dy=0.5;
   let m= -0.8;//速度损耗系数

   (function move(){ 

       // 防止无效弹跳 速度小于加速度*m就不需绘制了  笼统地 Math.abs(speedY)<dy
       if (ball.y === (H - ball.r)&&Math.abs(speedY)<= Math.abs(dy * m)) {                    
       }
       else{
           speedY += dy;
           ball.y += speedY;

           if (ball.y + ball.r >= H) {
               speedY *= m;//速度方向, m的摩擦损耗
               ball.y = H - ball.r;
           }
           ctx.clearRect(0, 0, W, H);

           ball.render(ctx);

           window.requestAnimationFrame(move);
       }
   })();
}

大量随机弹跳小球

同时由此可以实现如下大量随机弹跳小球的效果,随机生成一定数量的小球,随机位置、随机颜色:

let canvas = document.getElementById("c1");
let ctx = canvas.getContext("2d");

let H = canvas.height;
let W = canvas.width;
let ballTotal= 150;
let balls = [];
for (let i = 0; i < ballTotal; i++) {
    balls.push(new Ball({
        x: W * Math.random(),
        y: 50 * Math.random(),
        fillStyle: `rgb(${Math.floor(256 * Math.random())},${Math.floor(256 * Math.random())},${Math.floor(256 * Math.random())})`,
        r: 8,
        speedY: 0
    }))
}

let  dy = 0.5;
let m = -0.9;//速度损耗系数

let renderCount=0;
function drawBalls(){
    // 逐步绘制
    if (balls.length!== renderCount) {
        balls[renderCount].render(ctx);
        renderCount += 1;
    }
    ctx.clearRect(0, 0, W, H);
    for (let i = 0; i < renderCount; i++) {
        // 防止无效弹跳
        if (balls[i].y === (H - balls[i].r) && Math.abs(balls[i].speedY) <= Math.abs(dy * m)) {
        }
        else {
            balls[i].speedY += dy;
            balls[i].y += balls[i].speedY;
            if (balls[i].y + balls[i].r >= H) {
                balls[i].speedY *= m;//速度方向, m的摩擦损耗
                balls[i].y = H - balls[i].r;
            }                       
        }
        balls[i].render(ctx);
    }
}

(function move() {
    window.requestAnimationFrame(move);
    drawBalls();
    
})();

变速运动

其他类型的变速运动——非匀变速的运动,可根据需要设置速度的变化量的变化(比如变化量的值 dy 随机)等。

下面只介绍一种——递进方式减速。

递进方式减速

如下方式,可以设置每次都改变剩余变化量的1/k(下图为1/4),直到到达目标值:

则,此时动画处理流程中的第3步的计算,变为:

nowStep += (step-nowStep)*k

如下,通过每次改变剩余变换量的1/10,实现小球速度和颜色的变化:

 let circle = {
    x: 100, y: 100, r: 50, fillStyle: 'rgba(255, 0, 0, 1)'
};

let startX = circle.x, endX = 750;
let startAlpha = 1, endAlpha = 0.4;
let nowStep = 0, step = 600;
let k=1/10;

let ball=new Ball(circle);

(function loop() {
    window.requestAnimationFrame(function () {
        // console.log(nowStep, step)
        if (nowStep >= step) {
            nowStep = step;
            // console.log(123);
        }

        // 先擦除
        ctx.clearRect(0, 0, canvas.width, canvas.height);

        ball.x = startX + (endX - startX) * (nowStep / step);
        ball.fillStyle = 'rgba(255, 0, 0, ' + (startAlpha + (endAlpha - startAlpha) * (nowStep / step)) + ')';        

        ball.render(ctx);
        if (nowStep < step) {
            // 由于每次 增加剩余1/n,永远无法到达目标量,如下,设置向上取整
            nowStep = nowStep + Math.ceil((step - nowStep) * k);
            loop();                
        }
    })
})();

由此出发,还可以设置递增速、无规律变化量等实现变速运动。