Canvas 2D 渲染指南 - 用 TypeScript 实现一个程序入口 Application 类

两年前,我曾徒手写过一个运行在 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 类的过程,其中很多细节都被省略,只能在代码中看到具体实现,写的过程中自我感觉学到了许多,不枉花时间去跟着实践。本文也同步发布在「端技」公众号,欢迎来玩?

下一部分会是具体的图形渲染相关的知识,后面还有很多点值得探究,下次再见!

转载于:https://juejin.im/post/5cd8d179e51d456e51614b8a

你可能感兴趣的:(javascript,ViewUI)