Canvas 2D 渲染指南 - 用 TypeScript 實現一個程式入口 Application 類

Seymoe發表於2019-05-13

兩年前,我曾徒手寫過一個執行在 Web 端的小遊戲,就是用 Canvas 來實現的,之後便幾乎從未與 Canvas 打交道,這兩天偶然接觸到一本書《TypeScript圖形渲染實戰:2D架構設計與實現》,又再次讓我對這方面產生了興趣,同時這本書採用的 TypeScript 實現也正合我意,便閱讀一番,跟著敲了敲,感覺收益頗多,於是想整理以下發出來,讓大家也看看。

正文從這裡開始:

為什麼需要一個 Application 類

凡是涉及到 Canvas, 一般都是進行 2D 或者 3D(WebGL) 來繪製動態場景,幀動畫在 Canvas 上的實現就是維持一個主要的幀迴圈,在幀函式中做擦除和重新繪製的操作,除了主要的幀迴圈之外,還有一些其他功能,比如對使用者輸入事件的分發和響應,計時器、幀率計算等等。這麼多的功能如果用程式導向的形式,會導致程式碼結構比較混亂,無法高度複用,而封裝成一個 Application 類則可以將功能和流程封裝起來,將可變的部分提供給第三方使用,非常方便,而且用 TypeScript 實現很酸爽。

實際上,很多遊戲引擎或類庫的入口都會命名為 Application

TypeScript 開發環境搭建

我也是剛接觸 TypeScript,自我感覺書中的搭建開發環境的步驟和結果不太理想,不合自己口味,便在 TypeScript 找到了 TypeScript-Babel-Starter 這個模版庫,然後搭配 webpack 稍微配置一下,一句命令 npm run bundle 就實現了即時編譯成頁面直接引用可用的 bundle 檔案的功能,如果你想試試跟著寫一寫,環境可直接參考我的倉庫:

github.com/seymoe/canv…

對於 TypeScript 語法相關的前置知識,我也沒正經學過,去官網稍微瞄一眼文件,就跟著上手寫了,遇到高階的知識再回過頭去了解吧。

環境搭建好之後,便可以開始分析並實現 Application 類了。

Application 類的實現

功能分析

前文提到,Application 類是對流程和功能的封裝,那我們先來分析一下這個類具體要實現哪些功能:

  1. 封裝動畫主迴圈,並實現啟動迴圈和結束迴圈功能;
  2. 實現基於時間的更新和重繪;
  3. 實現對輸入事件(比如滑鼠或鍵盤事件)進行分發響應;
  4. 可以被繼承擴充套件,比如既可以被用於 Canvas2D 又可以用於 WebGL 渲染;
  5. 計時器功能,應對不用頻繁渲染的情況

1. 基於時間的動畫主迴圈實現

export class Application {
  protected _start: boolean = false
  protected _appId: number = -1
  protected _lastTime!: number
  protected _startTime!: number
  private _fps: number = 0
  public canvas: HTMLCanvasElement

  public constructor(canvas: HTMLCanvasElement) {
    this.canvas = canvas
  }

  public start(): void {
    if (!this._start) {
      this._start = true
      this._appId = -1
      this._lastTime = -1
      this._startTime = -1
      this._appId = requestAnimationFrame(this.step.bind(this))
    }
  }

  public stop() {
    if (this._start) {
      cancelAnimationFrame(this._appId)
      this._appId = -1
      this._lastTime = -1
      this._startTime = -1
      this._start = false
    }
  }

  public isRunning(): boolean {
    return this._start
  }

  public get fps(): number {
    return this._fps
  }

  /**
   * step 基於時間的更新和重繪
   */
  protected step(timeStamp: number): void {
    if (this._startTime === -1) this._startTime = timeStamp
    if (this._lastTime === -1) this._lastTime = timeStamp
    // 計算當前時間點距離第一次呼叫時間點的差值
    let elapsedMsec: number = timeStamp - this._startTime
    // 計算當前時間距離上一次呼叫時間點的差值
    let intervalSec: number = timeStamp - this._lastTime
    // 計算fps
    if (intervalSec !== 0) {
      this._fps = 1000 / intervalSec
    }
    // 將 intervalSec 化為秒
    intervalSec /= 1000
    // 更新上一次呼叫的時間點
    this._lastTime = timeStamp
    // 更新
    this.update(elapsedMsec, intervalSec)
    // 渲染
    this.render()
    // 遞迴呼叫
    this._appId = requestAnimationFrame(
      (elapsedMsec: number): void => {
        this.step(elapsedMsec)
      }
    )
  }

  // 更新,由子類覆寫
  protected update(elapsedMsec: number, intervalSec: number): void { }

  // 渲染,由子類覆寫
  protected render(): void { }
}
複製程式碼

首先,我們宣告瞭一個 Application 的類,從程式碼中我們能夠了解到以下幾點:

  • _appId 型別為 number, 值為 requestAnimationFrame 方法的返回值,用來在停止動畫時取消迴圈;
  • _fps 屬性代表幀率,每秒播放的幀數,在這裡很容易計算,1s / intervalSec,並定義了 getter 屬性來獲取到 fps 屬性;
  • 通過定義 startstop 方法來實現動畫的開始和停止,具體的實現細節也很簡單;
  • 提供了 updaterender 兩個虛方法,將會被子類 Override 覆寫,以實現具體的更新和渲染邏輯;

2. 對事件的響應分發處理

在這裡暫時只處理滑鼠事件和按鍵事件,對事件的分發響應的原理就是當監聽到事件觸發時,根據不同的事件型別,來做響應的處理,而具體的響應處理一般不由 Application 類提供,而是子類自己提供。

如果監聽到事件呢?當然是 addEventListener 介面。在 Application 類中我們能夠取到 canvas 元素,便可以在建構函式中,對此元素監聽滑鼠事件:

this.canvas.addEventListener('mousedown', this, false)
this.canvas.addEventListener('mouseup', this, false)
this.canvas.addEventListener('mousemove', this, false)
複製程式碼

對於按鍵事件只能在 window 上監聽:

window.addEventListener('keydown', this, false)
window.addEventListener('keyup', this, false)
window.addEventListener('keypress', this, false)
複製程式碼

然後,我們注意 addEventListener 介面傳遞的引數,第一個為事件型別的字串,第二個必須為一個實現了 EventListener 介面的物件,或者是一個函式。

很明顯這裡我們傳遞了 this,也就是這個類,那這個類就必須實現了 EventListener 介面,即需要一個 handleEvent 方法來接收事件作為引數進行處理。

public handleEvent(evt: Event): void {
  switch (evt.type) {
    case 'mousedown':
      this.dispatchMouseDown()
      break
    case 'mouseup':
      this.dispatchMouseUp()
      break
    case 'mousemove':
      this.dispatchMouseMove()
      break
    case 'keypress':
      this.dispatchKeyPress()
      break
    case 'keydown':
      this.dispatchKeyDown()
      break
    case 'keyUp':
      this.dispatchKeyUp()
      break
    default:
      break
  }
}
複製程式碼

以上處理通過 switch 來根據事件型別來執行相應的方法,這些方法都會由子類自由覆寫,當然在實現中還有一個 CanvasInputEvent 類以及繼承它的兩個子類,CanvasMouseEventCanvasKeyBoardEvent 分別代表滑鼠事件和按鍵事件的封裝,支援識別同時按住 ctrlaltshift 移動滑鼠或按下其他鍵,具體實現請看 event.js。

3. Application 的兩個子類

  • Canvas2DApplication 即 Canvas 2D 圖形渲染子類
export class Canvas2DApplication extends Application {
  protected context2D: CanvasRenderingContext2D | null

  constructor(canvas: HTMLCanvasElement) {
    super(canvas)
    this.context2D = this.canvas.getContext('2d')
  }
}
複製程式碼
  • WebGLApplication WebGL 3D 渲染子類, 暫時估計是不會關注這個了...
export class WebGLApplication extends Application {
  protected context3D: WebGLRenderingContext | null

  constructor(canvas: HTMLCanvasElement, contextAttributes?: WebGLContextAttributes) {
    super(canvas)
    this.context3D = this.canvas.getContext('webgl', contextAttributes)

    // 檢查webGL相容性
    if (this.context3D === null) {
     this.context3D = this.canvas.getContext('experimental-webgl', contextAttributes)
      if (this.context3D === null) {
        throw Error('無法建立WebGLRenderingContext上下文物件')
      }
    }
  }
}
複製程式碼

4. 計時器功能

Application 類中用了 requestAnimationFrame 來驅動動畫不停更新和重繪,但有的時候可能有些任務不需要不停地重繪,只需要隔一段時間執行一次或者只會執行一次,這個時候就需要實現一個計時器了。

雖然可以直接使用 setTimeout 或者 setInterval,但還是跟著書中在基於時間的重繪上實現了一個“不精確”的計時器功能。

實現原理也很簡單,在 Application 類中維護一個 timers 陣列,一個用於唯一從0開始自增的 _timerId,同時實現了 addTimer 方法來新增一個定時器,removeTimer 方法來移除一個定時器,以及一個 _handleTimers 方法來在 step 函式中呼叫,執行定時器的回撥。

測試

隨意編寫了一個 index.htmlindex.ts 檔案來進行測試,不斷的畫出當前的 _appId,事件能夠正確響應,計時器也能正常執行,並且所有的操作都是在 Canvas2DApplication 的子類上進行的,很好的進行了封裝和多型,可移植性和維護性很強,寫起來也非常舒服。

如果你也想試試,可以看一看這裡:

github.com/seymoe/canv…

總結

本文記錄了實現一個 Application 類的過程,其中很多細節都被省略,只能在程式碼中看到具體實現,寫的過程中自我感覺學到了許多,不枉花時間去跟著實踐。本文也同步釋出在「端技」公眾號,歡迎來玩?

下一部分會是具體的圖形渲染相關的知識,後面還有很多點值得探究,下次再見!

相關文章