天天看點

【前端動畫】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方法與動畫