如何实现一个Canvas渲染引擎(六):webGL渲染(Part 2)

414 阅读12分钟

友情提示

  1. 请先看这里 👉 引言
  2. 阅读本文需要有一些webGL知识,学习webGL基础可以看这里 👉 WebGL Fundamentals
  3. 由于webGL的内容比较多,所以我打算分成2篇文章来阐述,本文是第2篇
  4. 源码的GitHub地址 👉 源码
  5. 本文内容对应的代码在feat/webgl这个分支上,建议切到这个分支查看代码。

1. 前言

上一篇文章中提到,webGL支持的最复杂的图形就是三角形了,对于任何我们想要绘制的图形,我们都需要将其转化成三角形(三角剖分),而上一篇文章的内容,主要是讲述如何对各种图形进行三角剖分,所以到目前为止,我们已经可以得到任何图形的顶点数组顶点下标数组了,本文的内容则是讲述如何将这些数据送入GPU,让GPU帮我们将其绘制出来。

2. 顶点的处理

2.1 顶点的结构

目前,我们只需要进行纯几何图形渲染,还不涉及到texture,所以顶点的结构目前也比较简单,有如下结构:

  • 顶点的坐标信息(2个Float32)
  • 顶点的颜色(一个UInt32)

所以,每个顶点我们需要2x4+4也就是12个byte的空间

2.2 顶点的颜色的处理

2.1中说了,对于颜色的处理,只需要一个UInt32就够了,但是,webGL实际上要求4个0-1的Float32的数值分别作为颜色的r、g、b、a的,那为什么只需要用一个UInt32就够了呢?

webGL提供了对某些数据类型进行正交化的功能,其中,就包括了Unsigned Byte类型,webGL对于Unsigned Byte类型的正交化处理,会将其转换成一个0-1的Float32类型的数值,简单理解就是,会除以255。

在CSS中,我们一般使用4个0-255的int类型数值来表示r、g、b、a,0-255正是Unsigned Byte类型能表示的范围,所以我们会用4个Unsigned Byte类型来表示r、g、b、a,经过webGL的正交化处理,就变成了4个0-1的Float32类型的数值,正好符合webGL对r、g、b、a的要求。

经过上述操作,我们用4个字节就能表示r、g、b、a了,而不用16个字节,这样的话每个顶点就省下来了12个字节的空间

3. 投影矩阵

canvas的坐标系和webGL的坐标系并不是相同的。

3.1 canvas坐标系和webGL坐标系

在canvas坐标系中,原点在画布的左上角,x轴正方向向右,y轴正方向向下,右下角坐标为:(canvas.width,canvas.height),假设我们有一个width=800px,height=600px,那么坐标点的分布看起来是这样的(粉色区域为canvas画布区域):

image.png

而webGL坐标系则相当于数学中的笛卡尔坐标系,其原点在画布中心点,x轴正方向向右,y轴正方向向上,并且范围永远都是-1到1,还是用上面的width=800px,height=600px的canvas来举例,webGL的坐标点的分布如下:

image.png

webGL的坐标系是不受canvas的尺寸的影响的,其x,y的范围恒为-1到1,假设我们把上面的canvas的width改为1200,那么canvas的坐标点分布为:

image.png 相比之下,webGL的坐标点分布为:

image.png

3.2 canvas坐标系到webGL坐标系的转换

canvas坐标系和DOM坐标系是一样的,为了在这个渲染引擎里让用户能够保留DOM的操作习惯,我们会在内部将canvas坐标系转换成webGL坐标系,而不用让用户手动来转换,毕竟没有人愿意写(0.1, 0.3)、(0.5,-0.9)这种坐标。

我们会用一个投影矩阵来将canvas坐标转换成webGL坐标,首先,我们要将x,y坐标分别乘以缩放因子scaleX和scaleY,scaleX和scaleY分别为(1/canvas.width)*2, -(1/canvas.height)*2,然后往x轴负方向移动1,再往y的正方向移动1,这样下来,canvas坐标系就转换成了webGL坐标系了。这个投影矩阵为:

[(1/canvas.width)2010(1/canvas.height)21001]\begin{bmatrix} (1/canvas.width)*2 & 0 & -1 \\ 0 & -(1/canvas.height)*2 & 1 \\ 0 & 0 & 1 \\ \end{bmatrix}

对于这个投影矩阵,我们会在顶点着色器里用一个uniform变量存着。

4. 着色器的编写

4.1 顶点着色器

4.1.1 代码

precision highp float;
attribute vec2 a_position;
attribute vec4 a_color;
varying vec4 v_color;
uniform mat3 u_root_transform;
uniform mat3 u_projection_matrix;
void main(){
  v_color = a_color;
  gl_Position = vec4((u_projection_matrix * u_root_transform * vec3(a_position, 1.0)).xy, 0.0, 1.0);
}

顶点坐标叠加u_root_transform和u_projection_matrix两个矩阵,就得到了最终的顶点坐标。

4.1.2 各个变量的含义

  • a_position:表示顶点的坐标
  • a_color:表示顶点的颜色
  • u_projection_matrix:表示前面讲述的投影矩阵
  • u_root_transform:表示根元素,也就是stage的变换矩阵,由于对画布进行旋转、平移等操作是一个比较常用的操作,所以给stage元素专门配备一个变换矩阵,可以在stage的变换矩阵发生变化时,不用更新所有子节点的变换矩阵,这样的话可以提高性能。
  • v_color:顶点的颜色,a_color会被赋值给这个变量,然后经过插值,传递给片元着色器。

4.2 片元着色器

代码:

precision mediump float;
varying vec4 v_color;
void main(){
  gl_FragColor = v_color;
}

片元着色器的代码比较简单,顶点着色器传过来什么颜色就用什么颜色给片元着色。

4.3 告诉webGL如何读取顶点数组

在前面讲述了一个顶点包含2个Float32和一个UInt32,共12个字节,一个顶点数组将会包含n个这样的顶点,也可以理解为n个组,每个组都包含2个Float32和一个UInt32

4.3.1 顶点位置

const aPositionLoc = gl.getAttribLocation(program, `a_position`)
gl.vertexAttribPointer(
  aPositionLoc, // attribute变量的location
  2, // 读2个单元
  gl.FLOAT, //类型
  false, //不需要正交化
  BYTES_PER_VERTEX, //跨度(12个byte)
  0 // 从每组的第几个字节开始读
)
gl.enableVertexAttribArray(aPositionLoc)

从每个组中读取2个Float32,传递给a_position变量,不需要正交化,不需要偏移。

4.3.2 顶点颜色

const aColorLoc = gl.getAttribLocation(program, `a_color`)
gl.vertexAttribPointer(
  aColorLoc, // attribute变量的location
  4, // 读4个单元
  gl.UNSIGNED_BYTE, //类型
  true, //需要正交化
  BYTES_PER_VERTEX, //跨度(12个byte)
  8 // 从每组的第几个字节开始读
)
gl.enableVertexAttribArray(aColorLoc)

在每个组中先偏移8个字节,然后读取4个Unsigned Byte,然后正交化成4个Float32,传递给a_color变量。

这里有一个字节序的问题需要注意,webGL要求ArrayBuffer或者TypedArray(UInt32 Array、Float32 Array等)作为顶点数组,在主要的计算机操作系统中(Windows,MacOS等),都采用了小端字节序,所以TypedArray内部也采用小端字节序读写数据,所以,在将r、g、b、a写入顶点数组的时候,如果是以UInt32的形式一次性将r、g、b、a(4个Unsigned Byte)写入,那么需要将r、g、b、a的顺序倒过来,否则在着色器中取到的r、g、b、a就是错的(着色器是依次读取4个Unsigned Byte作为r、g、b、a),如果是分4次分别写入r、g、b、a,每次写入一个Uint8,则不用担心这个问题,本文用了第一种方式写入,读者可以根据自己的喜好随意选择一种方式。

5. 批处理

和canvas2D渲染一样,在webGL渲染中我们依然会使用前序遍历的方式,来保证层级关系的正确性,可以参考这里 👉 前序遍历对象树

5.1 renderCanvasrenderWebGL

目前,我们已经在Graphics类里面实现了renderCanvas函数,调用这个函数,会立即将自身(Graphics类实例)包含的所有子图形绘制出来,在每次渲染(Renderer.render),我们都会前序遍历对象树,依次执行每个对象的renderCanvas,将整棵对象树绘制出来。

同样,在webGL渲染中,我们会在Graphics类上实现一个renderWebGL函数,在每次渲染的时候,我们都会前序遍历对象树,依次执行每个对象的renderWebGL,将整棵对象树绘制出来。

但是,renderWebGL并不会直接调用webGL的API绘制自身(Graphics类实例),为了最大化webGL带来的性能提升,我们会对图形进行批处理

5.2 批处理

就像往数据库里面插入数据一样,对于一组数据,我们希望只调用一次INSERT INTO语句,将这一组数据一次性插入到数据库里,而不是遍历这一组数据,每个都执行INSERT INTO语句;而webGL渲染亦是如此,我们在一次drawCall(webGL的绘制API)中,要尽可能绘制更多的图形出来。

5.2.1 batch

每个填充或者描边,我们都会视作一个batch,一个图形(Graphics类实例)会有很多个batch(每个子图形都可能有填充和描边),我们会用一个BatchPool来存放这些batch,每次调用Graphics类实例的renderWebGL函数,都会把这些batch推入BatchPool(BatchPool.push),BatchPool.push的代码如下:

public push(batch: Batch) {
  // 在顶点数量即将要超过65536的时候执行flush
  // MAX_VERTEX_COUNT === 65536
  if (batch.vertices.length / 2 + this.vertexCount > MAX_VERTEX_COUNT) {
    this.flush()
  }

  this.vertexCount += batch.vertices.length / 2
  this.vertexIndexCount += batch.vertexIndices.length
  this.batches[this.batchesCount] = batch
  this.batchesCount++
}

当然我们不可能攒无数个顶点,顶点数量肯定是有上限的,目前我设置的上限是65536,可以看到,在顶点数量即将要超过65536的时候,会执行flush函数,将所有图形绘制出来,为什么是65536个呢?因为目前我采用了UInt16数组来存放顶点下标,65536是UInt16类型支持的最大值,也就是2的16次方。当然,如果你想采用UInt32来存也是可以的,这样的话就可以将这个上限设置得更大了。

5.2.2 flush

在flush函数里面才会真正的调用webGL的绘制API,在这里,我们会选取2个容量合适的ArrayBuffer,分别作为顶点数组和顶点下标数组,然后把当前攒下来的所有batch里的数据塞到这2个ArrayBuffer中,最后传递给webGL,让其将这些数据绘制出来。

flush函数(BatchPool.flush)代码:

public flush() {
  if (this.batchesCount === 0) {
    return
  }

  // 选取2个容量合适的ArrayBuffer,分别作为顶点数组和顶点下标数组
  this.setBuffer(this.vertexCount, this.vertexIndexCount)

  // 把当前攒下来的所有`batch`里的数据塞到2个ArrayBuffer中
  this.packData()
  
  // 调用webGL API将所有数据绘制出来
  this.draw()

  // flush完了后,将一些数据初始化
  this.vertexCount = 0
  this.vertexIndexCount = 0
  this.batches.length = 0
  this.batchesCount = 0
}

draw函数(BatchPool.draw)的代码:

private draw() {
  const gl = this.renderer.gl
  gl.drawElements(gl.TRIANGLES, this.curElementCount, gl.UNSIGNED_SHORT, 0)
}

5.2.3 renderWebGL

在renderWebGL函数中,我们会对当前图形进行顶点化和三角剖分,当然,这个过程只会在图形发生改变的时候执行;接下来会构建当前图形的batch,和之前一样,这个过程只会在图形发生改变的时候执行,然后我们会根据当前图形的变换矩阵,更新所有顶点的位置,之后就是把所有batch推入BatchPool了。

renderWebGL函数(Graphics.renderWebGL)代码如下:

/**
 * 使用webGL绘制自身
 */
protected renderWebGL(renderer: WebGLRenderer) {
  this.startPoly()
  
  // 对图形进行顶点化和三角剖分处理
  this._geometry.buildVerticesAndTriangulate()

  // 构建当前图形的batch
  this.buildBatches()
  
  // 更新顶点位置
  this.updateVertices()

  // 把所有batch推入BatchPool
  for (let i = 0; i < this.batches.length; i++) {
    renderer.batchPool.push(this.batches[i])
  }
}

6. 测试一下

测试设备:14寸MacBook Pro,M1 Pro,16GB内存

6.1 层级关系测试

6.1.1 测试代码

const app = new Application({
  view,
  backgroundColor: 'grey',
  backgroundAlpha: 0.3,
  // prefer: 'canvas2D',
  width,
  height
})

const redGraphic = new Graphics()
redGraphic.beginFill('red')
redGraphic.drawRect(0, 0, 100, 100)
redGraphic.alpha = 0.5

const greenGraphic = new Graphics()
greenGraphic.beginFill('green')
greenGraphic.drawRect(0, 0, 100, 100)
greenGraphic.alpha = 0.5
greenGraphic.rotation = Math.PI / 4
greenGraphic.position.set(100, 100)

const c1 = new Container()
const g1 = new Graphics()
g1.beginFill('brown')
g1.drawRect(0, 0, 100, 100)
g1.rotation = Math.PI / 6
g1.scale.set(2, 2)
g1.skew.set(1, 1)
g1.position.set(100, 100)
c1.addChild(g1)

const g2 = new Graphics()
g2.beginFill('yellow')
g2.drawRect(200, 200, 100, 100)
g2.pivot.set(30, 30)
g2.skew.set(-1, -1)
g2.position.set(400, 200)
c1.addChild(g2)

const c2 = new Container()

const c3 = new Container()
const g3 = new Graphics()
g3.beginFill('#182835')
g3.drawRect(300, 300, 100, 100)
g3.skew.set(-1, -1)
g3.rotation = Math.PI / 2
g3.scale.set(0.6, 0.8)
g3.position.set(400, 200)
c3.addChild(g3)

const g4 = new Graphics()
g4.beginFill('pink')
g4.drawRect(200, 200, 100, 100)
g4.pivot.set(30, 30)
g4.skew.set(-1, -1)
g4.position.set(400, 200)
c3.addChild(g4)

const g5 = new Graphics()
g5.beginFill('#275821', 0.5)
g5.drawRect(0, 0, 100, 100)
g5.position.set(250, 400)
g5.rotation = Math.PI / 1.5
g5.scale.set(2, 3)
g4.addChild(g5)

c2.addChild(c3)
c2.position.set(50, 50)

app.stage.addChild(redGraphic)
app.stage.addChild(greenGraphic)
app.stage.addChild(c1)
app.stage.addChild(c2)

6.1.2 webGL渲染表现

image.png

6.1.3 canvas2D渲染表现

prefer: 'canvas2D',

image.png

可以看到两者基本一致。

6.2 画线测试

6.2.1 测试代码

const app = new Application({
  view,
  backgroundColor: 'blue',
  backgroundAlpha: 0.3,
  // prefer: 'canvas2D',
  width,
  height
})

const g = new Graphics()
  .lineStyle({
    width: 60,
    color: 'gold',
    join: LINE_JOIN.ROUND,
    cap: LINE_CAP.ROUND
  })
  .moveTo(100, 100)
  .lineTo(200, 200)
  .lineTo(400, 100)
  .lineTo(200, 500)
app.stage.addChild(g)

6.2.2 webGL渲染表现

image.png

6.2.3 canvas2D渲染表现

prefer: 'canvas2D',

image.png

可以看到两者基本一致。

6.3 五万个静态矩形描边渲染

6.3.1 测试代码

const app = new Application({
  view,
  backgroundColor: 'blue',
  backgroundAlpha: 0.3,
  // prefer: 'canvas2D',
  width,
  height
})
const gArr: Graphics[] = []

for (let i = 0; i < 50000; i++) {
  const g = new Graphics()
    .lineStyle(1, 'black')
    .drawRect(
      0,
      0,
      15 + (Math.random() - 0.5) * 10,
      10 + (Math.random() - 0.5) * 10
    )
  const randomX = Math.random() * width
  const randomY = Math.random() * height
  g.position.set(randomX, randomY)
  app.stage.addChild(g)
  gArr.push(g)
}

app.stage.pivot.set(width / 2, height / 2)
app.stage.position.set(width / 2, height / 2)
app.stage.addChild(
  new Graphics().lineStyle(3, 'red').drawRect(0, 0, width, height)
)

app.stage.scale.set(0.8)

6.3.2 webGL渲染表现

image.png

image.png

每个task约15ms

6.3.3 canvas2D渲染表现

prefer: 'canvas2D',

image.png

image.png

每个task约38ms

6.4 三万个动态矩形描边渲染

6.4.1 测试代码

const app = new Application({
  view,
  backgroundColor: 'pink',
  backgroundAlpha: 0.3,
  // prefer: 'canvas2D',
  width,
  height
})

const gArr: Graphics[] = []

for (let i = 0; i < 30000; i++) {
  const g = new Graphics()
    .lineStyle(1, 'black')
    .drawRect(
      0,
      0,
      15 + (Math.random() - 0.5) * 10,
      10 + (Math.random() - 0.5) * 10
    )
  g.dirX = 1
  g.dirY = 1
  g.speedX = Math.random() * 2 + 1
  g.speedY = Math.random() * 2 + 1
  const randomX = Math.random() * width
  const randomY = Math.random() * height
  g.position.set(randomX, randomY)
  app.stage.addChild(g)
  gArr.push(g)
}

app.stage.pivot.set(width / 2, height / 2)
app.stage.position.set(width / 2, height / 2)
app.stage.addChild(
  new Graphics().lineStyle(3, 'red').drawRect(0, 0, width, height)
)

app.stage.scale.set(0.8)

const reqFunc = () => {
  for (let i = 0; i < gArr.length; i++) {
    const g = gArr[i]
    const { x, y } = g.position
    if (g.dirX === 1 && x > width) {
      g.dirX = -Math.abs(g.dirX)
    }
    if (g.dirX === -1 && x < 0) {
      g.dirX = Math.abs(g.dirX)
    }

    if (g.dirY === 1 && y > height) {
      g.dirY = -Math.abs(g.dirY)
    }
    if (g.dirY === -1 && y < 0) {
      g.dirY = Math.abs(g.dirY)
    }
    g.position.set(x + g.dirX * g.speedX, y + g.dirY * g.speedY)
    // g.rotation += 0.02
  }

  app.stage.rotation += 0.005
  requestAnimationFrame(reqFunc)
}

reqFunc()

6.4.2 webGL渲染表现

image.png

image.png

每个task约15ms

6.4.3 canvas2D渲染表现

prefer: 'canvas2D',

image.png

image.png

每个task约43ms

7. 总结

可以看到,webGL带来的性能提升,是非常巨大的,但是它实在是过于底层了,所以我们需要考虑非常多的东西,canvas提供的一套API基本要重新实现一遍,工作量还是不小的,所谓有得必有失。

谢谢观看🙏,如果觉得本文还不错,就点个赞吧👍。