天天看点

【前端动画】requestAnimationFrame方法与动画

作者:镜心的小树屋

动画就是不间断地、基于时间的更新与重绘

  • 不间断的: 是指动画需要一个不停地重复循环机制

所以一般有两种选择

  • 1、类似while ( true ) { }之类的死循环,除非满足退出死循环的条件,否则就一直不停的重复相同行为。(在Windows下的D3D / OpenGL开发中,经常使用这种模式来驱动动画不断运行)
  • 2、另一种就是类似定时器的回调。例如使用HTML DOM中的window对象的setTimeout、setInterval、 requestAnimationFrame .

关于这个可以读我的这篇文章, 里面有使用到:

动画类型

  • 逐帧动画
    • 也叫定格动画,其原理即将准备好的素材以固定频率连续播放,从而产生动画效果。如GIF,视频、css3中的animation-timing-function的阶梯函数steps(number_of_steps, direction)实现逐帧动画连续播放。
  • 关键帧动画
    • 需要动画效果的属性,准备一组与时间相关的值,这些值都是在动画序列中比较关键的帧中提取出来的,而其他时间帧中的值,可以用这些关键值,采用特定插值方法计算获得,从而得到比较流畅的动画效果。
    • 关键帧最早出现在动画制作中,画师先完成关键画面,再补全中间帧生成动画,这样就能解放人力,大幅度提升效率。后来随着计算机技术发展。中间帧由计算机完成,插值代替了设计中间帧的动画师。
    • 插值: 一种通过已知的、离散的数据点,在范围内推求新数据点的工程或方法。
      • 生成插值的方法很多,常见的如 贝塞尔曲线
      • 贝塞尔曲线最大的特点是平滑,时间曲线平滑意味着有较少的突兀变化,这是一般动画设计所追求的。
      • 理论上贝塞尔曲线能够拟合任意曲线,如抛物线甚至能够做到完全拟合
function generateCubicBezier (v, g, t){
    var a = v / g;
    var b = t + v / g;
    return [[(a / 3 + (a + b) / 3 - a) / (b - a), (a * a / 3 + a * b * 2 / 3 - a * a) / (b * b - a * a)],
        [(b / 3 + (a + b) / 3 - a) / (b - a), (b * b / 3 + a * b * 2 / 3 - a * a) / (b * b - a * a)]];
}
           

requestAnimationFrame()

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

注意:若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用window.requestAnimationFrame()

当你准备更新动画时你应该调用此方法。这将使浏览器在下一次重绘之前调用你传入给该方法的动画函数(即你的回调函数)。回调函数执行次数通常是每秒60次,但在大多数遵循W3C建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。为了提高性能和电池寿命,因此在大多数浏览器里,当requestAnimationFrame() 运行在后台标签页或者隐藏的<iframe> 里时,requestAnimationFrame() 会被暂停调用以提升性能和电池寿命。

回调函数会被传入DOMHighResTimeStamp参数,指示当前被 requestAnimationFrame() 排序的回调函数被触发的时间。在同一个帧中的多个回调函数,它们每一个都会接受到一个相同的时间戳,即使在计算上一个回调函数的工作负载期间已经消耗了一些时间。该时间戳是一个十进制数,单位毫秒,最小精度为1ms(1000μs)。

请确保总是使用第一个参数(或其它获得当前时间的方法)计算每次调用之间的时间间隔,否则动画在高刷新率的屏幕中会运行得更快。请参考下面例子的做法。
// start 记录的是第一次调用step函数的时间点,用于计算与第一次调用step函数的时间差,以毫秒为单位
let start: number = 0;
// lastTime 记录的是上一次调用step函数的时间点,用于计算两帧之间的时间差,以毫秒为单位
let lastTime: number = 0;
// count用于记录step函数运行次数
let count: number =0;

/**
*  step 函数用于计算:
*  1、获取当前时间点与HTML程序启动时的时间差: timestamp
* 2、 获取当前时间点与第一次调用step时的时间差: elapedMsec
* 3、 获取当前时间点与上一次调用step时的时间差: intervalMsec
*  step函数是作为requestAnimationFrame方法的回调函数使用的
* 因此step函数的签名必须是(timestamp: number) => void
* 
*/

function step (timestamp: number) : void {
  // 第一次调用本函数时,设置start 和latsTime为timestamp;
  if(!start) start = timestamp;
  if(!lastTime) lastTime = timestamp
  
  // 计算当前时间点与第一次调用step时间点的差
  let elapsedMsec: number = timestamp - start;
  // 计算当前时间点与上次调用step时间点的差(可以理解为两帧之间的时间差)
  let intervalMsec: number = timestamp -lastTime;
  // 记录上一次时间戳
  lastTime = timestamp;
  // 计数器,用于记录step函数被调用的次数
  count ++ ;
  console.log(" " + count + "timestamp = " + timestamp)
  console.log(" " + count + "elapsedMsec = " + elapsedMsec)
  console.log(" " + count + "intervalMsec= " + intervalMsec)
  // 使用requestAnimationFrame调用step函数
  window.requestAnimationFrame(step);
  
}

// 使用requestAnimationFrame启动step
// 而step函数中又会调用requestAnimationFrame来回调step函数
// 从而形成不间断地递归调用,驱动动画不停运行
window.requestAnimationFrame(step)           

戳这里查看演示 -> https://codepen.io/AlexZ33/pen/gOMwped?editors=0011

【前端动画】requestAnimationFrame方法与动画

requestAnimationFrame与监视器刷新频率

通过requestAnimationFrame方法启动step回调函数后,每次调用step函数的时间间隔固定在16.66毫秒左右,基本上每秒调用60次step函数(1000 / 60约等于16.66毫秒,其中1秒等于1000毫秒)。每秒step函数调用的次数(频率)实际上是和监视器屏幕刷新次数(频率)保持一致

Windows系统电脑的监视器屏幕刷新频率是60赫兹,可以人为认定监视器每秒刷新重绘60次,将屏幕刷新频率60赫兹更改为48赫兹,来看看结果会怎样。

【前端动画】requestAnimationFrame方法与动画
【前端动画】requestAnimationFrame方法与动画

每次调用step函数的时间间隔固定在20.83毫秒左右,符合当前监视器屏幕刷新频率48赫兹的设置(1000 / 48约等于20.83毫秒),由此可见request Animation Frame是一个与硬件相关的方法,该方法会保持与监视器刷新频率一致的状态。

看上面的step函数,会发现它很简单,只是输出3个时间差,这种操作本身花不了多少时间,因此能保持16毫秒的频率一直稳定运行是很正常的。那么,如果在step中进行大量耗时操作(恢复到监视器屏幕刷新频率60赫兹的情况下),结果会如何呢?

在step函数的count ++代码后面添加如下代码:

【前端动画】requestAnimationFrame方法与动画
【前端动画】requestAnimationFrame方法与动画

两次step调用间隔所耗时间有很明显的波动,但是通过统计,会发现一个很明显的规律

【前端动画】requestAnimationFrame方法与动画

两帧之间的时间间隔(intervalMsec)总是16.66毫秒的倍数(当前监视器屏幕刷新频率60赫兹,折算成每帧需要16.66毫秒刷新一次)。

由此可见,requestAnimationFrame方法会稳定间隔时间:

● 如果当前的回调操作(step函数)在16.66毫米内能完成,那么requestAnimationFrame会等到16.66毫秒时继续下一次step回调函数的调用。

● 如果当前的回调操作(step函数)大于16.66毫秒,则会以16.66毫秒为倍数的时间间隔进行下一次step回调函数的调用。

● 当将监视器屏幕刷新频率60赫兹设置成48赫兹时,结果也类似,两帧之间间隔时间总是20.83毫秒的倍数。

基于时间的更新与重绘

/**
* 需求,假设想让一个物体(例如一个矩形或圆球)沿着水平轴(x轴),以每秒10个像素的速度进行移动,那么应该怎么做呢?
*/

/**
* 涉及两个基本步骤,让物体进行基于时间(每秒10个像素)的* * 更新,以及更新完后进行显示。
*  
* 搭建一个最简单的更新与重绘框架。上一节中,在step回调函* * 数中已经计算出了3个以毫秒表示的时间间隔(时间差),即:

● 当前时间点与当前HTML应用启动时的时间差timestamp。
● 当前时间点与第一次调用step回调函数时的时间差elapsedMsec。
● 当前时间点与上一次调用step回调函数时的时间差intervalMsec。

以上3个时间间隔或时间差中最有用的是第3个时间差,特别适合做基于时间的更新。
*/

let posX: number = 0;
let speedX: number = 10; //单位为秒,以每秒10个个像素的速度进行位移

// 声明一个函数用于更新操作
function update(timestamp: number, elapsedMsec: number, intervalMsec: number): void {
  // 参数都是使用毫秒为单位, 而现在的速度都是以秒为单位
  // 因此需要将毫秒转换为秒来表示
  let t : number = intervalMsec / 1000.0;
  // 线性速度公式: posX = posX + speedX * t;
  posX += speedX * t;
  console.log("current posX: " + posX);
}


// 渲染
// 使用CanvasRenderingContext2D绘图上下文渲染对象进行物体的绘制
function render (ctx: CanvasRenderingContext2D | null) : void {
  // 简单起见,仅仅输出render字符串
  console.log("render")
} 


// 将这两个函数由step函数进行调用,就可以形成一个基本的框架


// start 记录的是第一次调用step函数的时间点,用于计算与第一次调用step函数的时间差, 以毫秒为单位

let start : number = 0;

// lastTime 记录的是上一次调用step函数的时间点,用于计算两帧之间的时间差, 以毫秒为单位
let lastTime : number = 0;
// count 用于记录step函数运行的次数
let count : number = 0;


/**
* step函数用于计算
*  1. 获取当前时间点 与 html程序启动时间差: timestamp
*  2. 获取当前时间点与第一次调用step时的时间差: elapsedMsec
* 3. 获取当前时间点与上一次调用step时的时间差:intervalMsec
*/

function step (timestamp: number) {
  // 第一次调用本函数时, 设置start和lastTime为timestamp
  
  if(!start) start = timestamp;
  if(!lastTime) lastTime = timestamp;
  // 计算当前时间点与第一次调用step时间点的差
  let elapsedMsec = timestamp - start;
  // 计算当前时间点与上一次调用step时间点的差(可以理解两帧之间的时间差)
  let intervalMsec = timestamp - lastTime;
  // 记录上一次的时间戳
  lastTime = timestamp;
  // 进行基于时间的更新
  update(timestamp, elapsedMsec, intervalMsec);
  //调用渲染函数, 目前并没有使用CanvasRenderingContext2D类, 因此设置null
  render(null)
  // 使用requestAnimationFrame调用step函数
  window.requestAnimationFrame(step);
};

// 使用requestAnimationFrame启动step
// 而step函数中通过调用requestAnimtionFrame来回调step函数
// 从而形成不间断的递归调用,驱动动画不停地运行
window.requestAnimationFrame(step);
           

https://codepen.io/AlexZ33/pen/NWrbyMO?editors=1011codepen.io/AlexZ33/pen/NWrbyMO?editors=1011

点击上面的链接查看结果:

【前端动画】requestAnimationFrame方法与动画

会发现每次调用step回调函数后,posX总是根据传入的时间差进行更新,这样的好处不管在哪个浏览器中或是不同运行速度的计算机上,每秒钟的运动距离都是恒定的(每秒10像素)。如果不使用基于时间的更新,那么在不同浏览器中不同的CPU计算机上,会有明显的快慢差别。实际上这是一个很固定的流程,将这个动画流程封装起来形成一个类,以后所有的程序都可以使用该类的子类。下一节就来封装这个流程。

Application的类

可以将动画的相关功能都封装到一个名为Application的类中,该类主要是作为应用程序的入口类。

  • 它能启动或关闭动画循环
  • 进行基于时间的更新与重绘
  • 可以对输入事件(如鼠标事件或者键盘事件)提供事件分发和处理功能,
  • 可以被继承扩展,用于Canvas2D和WebGL渲染。并且具有一个允许以不同帧率运行的计时器。
【前端动画】requestAnimationFrame方法与动画

CanvasInputEvent基类

// CanvasKeyboardEvent和CanvasMouseEvent都继承自本类
// 基类定义了共同的属性,keyboard或者mouse事件都能使用组合键
// 例如可以按住Ctrl键的同时单击鼠标左键做某事
// 当然也可以按住Alt + A 键做另外一些事情
class CanvasInputEvent {
  // 3 个 boolean变量,用来指示Alt、Ctrl、Shift键是否被按下
  public altKey: boolean;
  public ctrlKey: boolean;
  public shiftKey: boolean;
  // type 是一个枚举对象,用来表示当前的事件类型, 枚举类型定义在下面的代码中
  public type : EInputEventType;
  // 构造函数, 使用default参数,初始化时3个组合键都是false状态
  public constructor (altKey: boolean = false, ctrlKey: boolean = false, shiftKey: boolean =false, type: EInputEventType = EInputEventType.MOUSEEVENT){
    this.altKey = altKey;
    this.ctrlKey = altKey;
    this.shiftKey = shiftKey;
    this.type = type;
  }
  
}

// 看一下EInputEventType这个枚举,该枚举罗列出目前支持的各个输入事件,包括鼠标和键盘事件。
enum EInputEventType{
  MOUSEEVENT, // 总类,表示鼠标事件
  MOUSEDOWN, // 鼠标按下事件
  MOUSEUP, // 鼠标弹起事件
  MOUSEMOVE, // 鼠标移动事件
  MOUSEDRAG, // 鼠标拖动事件
  KEYBOARDEVENT, // 总类,表示键盘事件
  KEYUP,// 键按下事件
  KEYDOWN, //键弹起事件
  KEYPRESS, // 按键事件   
}

class CanvasMouseEvent extends CanvasInputEvent {
  // button 表示当前按下鼠标哪个键
  // [ 0: 鼠标左键, 1 : 鼠标中键, 2: 鼠标右键]
  
  public buttton : number;
  // 基于canvas坐标系的表示
  // vec2,表示2D向量
  public canvasPosition: vec2;
  public localPosition: vec2;
  public constructor (canvasPos: vec2, button: number, altKey: boolean=false, ctrlKey: boolean = false, shiftkey: boolean=false) {
    super (altKey, ctrlKey, shiftKey);
    this.canvasPosition = canvasPos;
    this.button = button;
    
    // 暂时创建一个vec2对象
    this.localPosition = vec2.create();
  }
}

class CanvasKeyBoardEvent extends CanvasInputEvent {
  // 当前按下的键的ASCII字符
  public key : string;
  // 当前按下的键的ASCII(数字)
  public keyCode: number;
  // 当前按下的键是否不停地触发事件
  public repeat: boolean;
  public constructor (key: string, keyCode: number, repeat: boolean, altKey: boolean = false, ctrlKey: boolean = false, shiftKey : boolean = false) {
    super (altKey, ctrlKey, shiftKey, EInputEventType.KEYBOARDEVENT);
    this.key = key;
    this.keyCode = keyCode;
    this.repeat = repeat;
  }
}           

这部分内容可以看这位同学的总结:

https://zhuanlan.zhihu.com/p/65556977

【前端动画】requestAnimationFrame方法与动画