canvas核心技术-如何实现复杂的动画

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

在上一篇canvas核心技术-如何实现简单的动画笔记中,咱们详细学习了如何进行canvas坐标系的平移,缩放,旋转等操做来实现一些比较简单和单一的动画。可是在实际动画中,影响一个动画的因素是不少的,好比一个小球自由落体运动,咱们不只要考虑小球的初始速度和初始方向,还要考虑重力加速度,空气阻力等外界因素。这一篇笔记,咱们会详细学习复杂动画的相关知识。css

核心逻辑

咱们理解的动画,应该是在一段时间内,物体的某些属性,好比颜色,大小,位置,透明度等,发生改变。判断动画流程度的单位是动画刷新的速率,在浏览器中通常是浏览器的帧速率。帧速率越大,动画就越流畅。在现代浏览器中,咱们通常是使用requestAnimationFrame来执行动画。java

let raf = null;
let lastFrame = 0;

//动画
function animate(frame) {
  // todo:这里能够执行一些动画更新
  console.log(frame)
  raf = requestAnimationFrame(animate);
  lastFrame = frame;
}

function start() {
  // 一些初始化的操做
  init();

  // 执行动画
  animate(performance.now());
}

function stop() {
  cancelAnimationFrame(raf);
}
复制代码

通常大体的结构就是这样的,经过requestAnimationFrame不断地在浏览器下一帧中执行animate,且animate函数会接受一个当前帧开始执行的时间戳的参数。若是想中断当前进行的动画,只须要调用cancelAnimationFrame,那么在下一帧中就不会执行animate函数了。上一帧执行的时间,能够用frame - lastFrame,而后再根据这个差值就能够计算出当前动画的帧速率了,以下,css3

let fps = 0;
let lastCalculateFpsTime = 0;
function calculateFps(frame) {
  if (lastFrame && (fps === 0 || frame - lastCalculateFpsTime > 1000)) {
    fps = 1000 / (frame - lastFrame);
    lastCalculateFpsTime = frame;
  }
}
//动画
function animate(frame) {
  // todo:这里能够执行一些动画更新
  calculateFps(frame);
  raf = requestAnimationFrame(animate);
  lastFrame = frame;
}
复制代码

在计算fps时,咱们是用1s除以上一帧执行的时间,因为frame的单位是毫秒,因此是用1000除的。上面咱们还作了一个优化,就是每1s才会去计算一次fps,而不是每帧都去计算,由于每帧都去计算,意义不大,且增长了额外的计算。git

时间因素

在绘制动画时,咱们必须按照基于时间的方式来设计,而不是当前浏览器的帧速率。由于,不一样浏览器会有不一样的帧速率,同一浏览器在不一样的GPU负载下帧速率也可能会不一样,因此咱们的动画必须是基于时间的,这样才能保证一样的速度在相同的时间内,动画变化的是一致的。好比,咱们在考虑小球垂直下落时,必须设置小球的下落的速度v,而后再根据公式s = v * t,得出小球在当前时间段内的移动距离,计算出当前帧内的坐标。github

/* 初始化 */
  private init() {
    this.fps = 0; 
    this.lastFrameTime = 0;
    this.speed = 5; // 设置小球初速速度为:5m/s
    this.distance = 50; //设置小球距离地面高度为:50m
    let pixel = this.height - this.padding * 2;
    if (this.distance <= 0) {
      this.pixelPerMiter = 0;
    } else {
      this.pixelPerMiter = (this.height - this.padding * 2) / this.distance;
    }
  }
复制代码

在上面代码中,咱们在初始化的时候,设定了小球的初始速度为5m/s,小球距离地面的高度为50m,以及计算出了物理高度与像素高度的比值pixelPerMiter,这个值在后面计算小球的坐标时是有用到的。canvas

/* 更新 */
  private update() {
    if (this.fps) {
      this.ball.update(1000 / this.fps); //更新小球
    }
  }
复制代码

而后,在每帧更新小球位置时,咱们将上一帧进过的时间值传递给ball.update浏览器

/* 移动 */
  static move(ball: Ball, elapsed: number) {
    //小球是静止状态,不更新
    if (ball.isStill) {
      return;
    }
    let { currentSpeed } = ball;
    let t = elapsed / 1000; //elapsed是毫秒, 而速度单位是m/s,因此要除1000
    let distance = ball.currentSpeed * t; 
    if (ball.offset + distance > ball.verticalHeight) {
      ////若是小球是否已经超过实际高度,则落到地面了
       ball.isStill = true;
       ball.currentSpeed = 0;
       ball.offset = ball.verticalHeight;
    } else {
      ball.offset += distance;
    }
  }
复制代码

再根据公式计算出上一帧时间里,小球下落的距离distance,累加每一帧下落的距离,则能够获得当前小球下落的总距离,若是小球下落的总距离大于了小球距离地面的实际高度,则表示小球落到地面了,就中止小球下落。函数

/* 绘制小球 */
  public render(ctx: CanvasRenderingContext2D) {
    let { x, y, radius, offset, pixelPerMiter } = this;
    ctx.save();
    ctx.translate(x, y + offset * pixelPerMiter); //offset * pixelPerMiter获得下落的像素
    ctx.beginPath();
    ctx.arc(0, 0, radius, 0, Math.PI * 2, false);
    ctx.fill();
    ctx.stroke();
    ctx.restore();
  }
复制代码

最后在绘制小球时,先要根据下落的实际高度offset和前面计算获得的实际高度与像素高度的比值,来获得小球在屏幕上下落的像素值(offset * pixelPerMiter)。post

上面就是咱们在写小球自由落体时的大体思路,重点是设置小球的初始下落速度,以及在每一帧里计算出小球下落的距离,最后根据实际高度与像素高度比,计算出小球在屏幕上下落的像素高度。这个过程当中,咱们尚未考虑重力加速度和空气阻力等物理因素,下面,咱们就来考虑物理因素对动画的影响。

物理因素

为了使动画或者游戏表现的更加真实,一般须要考虑真实世界中物理因素的影响,好比咱们继续考虑小球自由落体运动,真实世界中,小球自由落体运动会收到重力加速度,空气阻力,空气流向,反弹等的影响,从而改变小球下落的速度。

/* 建立小球 */
  private createBall() {
    let { width, height, padding, speed, radius, pixelPerMiter, distance } = this;
    this.ball = new Ball(width / 2, padding - radius, radius, { verticalHeight: distance, pixelPerMiter, useGravity: true });
    this.ball.setSpeed(speed);
    this.ball.addBehavior(Ball.move);
  }
复制代码

在建立小球时,咱们给了一个参数userGravity来表示是否使用重力加速度,这里咱们设置为true,同时咱们也传递了小球的初始坐标和半径,以及初始速度等。

const GRAVITY = 9.8; //重力加速度9.8m/s 
  /* 移动 */
  static move(ball: Ball, elapsed: number) {
    // ...
    //若是应用了重力加速度,则更新速度
    if (ball.useGravity) {
      ball.currentSpeed += GRAVITY * t;
    }
   // ...
  }
复制代码

而后在更新小球时,咱们增长了对小球当前速度的计算,根据公式v = g * t 计算出上一帧的速度,这样,随着时间,小球的速度其实是不断增长的,小球下落的会愈来愈快。

前面咱们在处理小球落到地面时,只是单纯的让小球停在地面上。可是在实际生活中,咱们下落的小球,碰到地面后,都会反弹必定高度,而后又下落,直到小球静止在地面上。为了更加真实模拟小球下落,咱们来考虑反弹物理因素。

//建立小球
this.ball = new Ball(width / 2, padding - radius, radius, { verticalHeight: distance, pixelPerMiter, useGravity: true, useRebound: true });
复制代码

在建立小球时,咱们传递了useRebound:true,表示当前小球应用了反弹效果,在更新小球时,须要判断小球在落地时,将当前速度方向反向,且大小减为原来的0.6倍,这个0.6系数只是一个经验值,在具体游戏中,能够调整,达到想要的效果。系数越大,反弹越高。

/* 移动 */
  static move(ball: Ball, elapsed: number) {
    //小球是静止状态,不更新
    if (ball.isStill) {
      return;
    }
    let { currentSpeed } = ball;
    let t = elapsed / 1000; //elapsed是毫秒, 而速度单位是m/s,因此要除1000
    //更新速度
    if (ball.useGravity) {
      ball.currentSpeed += GRAVITY * t;
    }
    let distance = ball.currentSpeed * t; 
    if (ball.offset + distance > ball.verticalHeight) {
      //落到地面了
      //使用反弹效果
      if (ball.useRebound) {
        ball.offset = ball.verticalHeight;
        ball.currentSpeed = -ball.currentSpeed * 0.6; //速度方向取反,大小乘0.6
        if ((distance * ball.pixelPerMiter) / t < 1) {
          //当前移动距离小于1px,应该静止了,
          ball.isStill = true;
          ball.currentSpeed = 0;
        }
      } else {
        ball.isStill = true;
        ball.currentSpeed = 0;
        ball.offset = ball.verticalHeight;
      }
    } else {
      ball.offset += distance;
    }
  }
}
复制代码

在应用反弹效果时,咱们判断当前速度在1s内在下落位移小于1px时,就将小球中止,这样,防止小球在反弹距离很小很小时,进行没必要要的计算。

至于其余物理因素,好比风向,阻力等,咱们就不具体讨论了,具体思路跟上面同样,先进行物理建模,而后在更新过程当中根据物理公式计算受影响的属性,最后再根据属性值来绘制。

这里是个人小球自由落体完整在线示例

时间轴扭曲

动画是持续一段时间的,咱们能够事先给定具体的持续时间值,让动画在这段时间内持续执行,就像css3中animation-duration,而后经过扭曲时间轴,可让动画执行非线形运动,好比咱们常见缓入效果缓出效果缓入缓出效果等。

时间轴扭曲,是经过一系列对应的缓动函数,根据当前的时间完成比率compeletePercent,计算获得一个扭曲后的值effectPercent,最后根据这2个值获得扭曲后的时间值elapsed

eplased = actualElapsed * effectPercent/compeletePercent

线性函数,

static linear() {
    return function(percent: number) {
      return percent;
    };
  }
复制代码

缓入函数,

static easeIn(strength: number = 1) {
    return function(percent: number) {
      return Math.pow(percent, strength * 2);
    };
  }
复制代码

缓出函数,

static easeOut(strength: number = 1) {
    return function(percent: number) {
      return 1 - Math.pow(1 - percent, strength * 2);
    };
  }
复制代码

缓入缓出函数,

static easeInOut() {
    return function(percent: number) {
      return percent - Math.sin(percent * Math.PI * 2) / (2 * Math.PI);
    };
  }
复制代码

这里是个人时间轴扭曲完整在线示例

更复杂的缓动函数还有弹簧效果,贝塞尔曲线等,详细能够参见EasingFunctions

小结

本篇笔记主要讨论了在canvas中如何实现复杂的动画效果,从一个小球的自由落地运动为示例,咱们在计算小球的下落距离时,是以时间维度来计算,而不是当前浏览器的帧速率,由于帧速率不是一个恒定可靠的值,它会使小球的运动变得不明确。当咱们以时间为计算值时,小球在一样时间内下落的距离值,咱们是能够计算出来的,是一个准确不受帧速率影响的值。为了使小球下落的更加真实,咱们又考虑了影响小球下落的物理因素,好比重力加速度,反弹效果等。在制做其余一些非线性运动的动画时,咱们可使用常见的缓动函数,好比,缓入,缓出等,它们的本质都是经过扭曲时间轴,使得当前的运动受时间因素影响。

在制做canvas游戏时,基本都会运用到动画,有物体运动,那么必定会发生碰撞,好比上面咱们的小球下落,就会发生小球与地面的碰撞,咱们进行了简单的碰撞检测。下一篇笔记,咱们详细讨论,如何在canvas中进行碰撞检测。