WebGL工作流程解读,一个三角形的诞生

4,935 阅读11分钟

本文示例

本文会把WebGL工作的具体流程梳理一遍,WebGL到底是如何渲染出一个三角形的。我们常说把大象装进冰箱需要三步,那么写一个WebGL程序应该也只需要三步:1、把数据放入缓冲区,2、把缓冲区的数据给着色器,3、着色器把数据给GPU。

下面是梳理的一个WebGL程序的大致流程图:

WebGL程序流程图

创建WebGL上下文

一切的前提那一定是WebGL上下文了。

<canvas id="canvas-gl" width="512" height="512"></canvas>
function main(){
    const canvas = document.getElementById('canvas-gl')
    const gl = canvas.getContext("webgl")
    if(!gl)return
    gl.viewport(0, 0, canvas.width, canvas.height)
    gl.clearColor(1.0, 1.0, 1.0, 1.0)
}

在创建WebGL上下文后,紧接着又用 gl.viewport()函数设置了视口,前两个参数表示视口左下角距画布左下角的偏移量,后面两个是视口的宽和高。这里把视口的大小和canvas的大小设为一样。

举个🌰gl.viewport(canvas.width/2, canvas.height/2, canvas.width/2, canvas.height/2),视口的左下角距离画布左下角偏移量为canvas.width/2, canvas.height/2宽高各为画布的一半

设置视口是为了告诉WebGL如何把顶点着色器提供的裁剪坐标渲染成画布坐标。何为裁剪空间坐标?就是无论你的画布有多大,裁剪坐标的坐标范围永远是 -1 到 1 。

最后gl.clearColor()函数设置画布背景,四个参数分别为RGBA,取值范围0-1。这里设置成白色。

创建着色程序

WebGL就是和GPU打交道,在GPU上运行的代码是一对着色器,一个是顶点着色器,另一个是片元着色器。每次调用着色程序都会先执行顶点着色器,再执行片元着色器。

顶点着色器

顶点着色器主要任务就是把传入的顶点转化成裁剪后的坐标值发送到GPU的光栅化模块中。有几个顶点,顶点着色器就会执行几次,顶点着色器获取数据有三个方式:

  1. Attributes属性从缓冲中获取数据,这个方法也本文是主要讲的方法,后面会讲如何把数据存入缓冲;
  2. Uniforms全局变量WebGL一次的绘制中所有顶点的共有值,例如要把三角形向右平移,那么三角形每个顶点的位置都要加上一个相同的uniform偏移量;
  3. Textures纹理从像素或纹理中获取数据;

Attribute属性的属性有float, vec2, vec3, vec4, mat2, mat3,mat47种类型, float代表32位浮点数,vec2, vec3, vec4分别表示两个值,三个值和四个值, mat2, mat3,mat4分别表示2x2,3x3,4x4矩阵。

Uniforms全局变量的属性除了Attribute属性有的属性还有其他本文就不赘述了。

每个顶点着色器都是包含main入口的完成程序,都会输出一个WebGL内置的状态变量gl_Position,该变量会把顶点着色器处理过的顶点坐标发送到光栅化模块中。

下面是一个简单的顶点着色器,没有对顶点进行任何操作就发送给GPU了,这样传入的顶点必须是裁剪空间坐标。

attribute vec4 vPosition; 
void main() {
  gl_Position = vPosition;
}

上面的着色器通过attribute可以看出从缓冲中获取一个vPosition的变量,它是由四个值组成。

片元着色器

上文讲到顶点着色器把坐标值发送到GPU的光栅化模块中,如果我们要画一个三角形,光栅化就是把顶点着色器传进来的三个顶点组成的三角形用像素画出来,有多少个像素,片元着色器就会执行多少遍,每个像素的颜色需要片元着色器输出的gl_FragColor来决定。

片元着色器获取数据有三个方式:

  1. Varyings可变量 是顶点着色器的输出,同时作为片段着色器的输入。
  2. Uniforms全局变量同顶点着色器。
  3. Textures纹理从像素或纹理中获取数据。

下面是一个简单的片元着色器:

precision mediump float; 
void main() {
  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

可以为变量设置默认精度,可选项为highpmediumplowp,这里为float类型数据设置mediump保证在所有支持WebGL的浏览器上能够运行。颜色的四个值分别为RGBA取值范围0-1,上面的着色器把所有顶点的颜色都设为红色。

Varyings可变量在着色器中的应用:

顶点着色器输出以顶点的xyz为rgb

attribute vec4 vPosition; 
+ varying vec4 fColor;
void main() {
  gl_Position = vPosition;
+ fColor = vec4(vPosition.x, vPosition.y, vPosition.z, 1.0);
}

片元着色器输入

precision mediump float; 
+ varying vec4 fColor;
void main() {
- gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
+ gl_FragColor = fColor;
}

着色程序

添加着色器的方法有很多种,可以把着色器放入glsl文件中加载,

或者是多行模板文字:

const vertexShader = `
attribute vec4 vPosition; 
void main() {
  gl_Position = vPosition;
}
`

也可以放入非JavaScript的标签中:

<script id="vertex-shader" type="x-shader/x-vertex">
   attribute vec4 vPosition; 
   void main() {
     gl_Position = vPosition;
   }
</script>
<script id="fragment-shader" type="x-shader/x-fragment">
   precision mediump float; 
   void main() {
     gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
   }
</script>

这里标签使用的type并不是JavaScript支持的MIME类型,则被当作数据块而不会被浏览器执行。

无论哪种方法最终都是获取到着色器的文本内容,本文使用非JavaScript的标签。

下面是创建着色程序的函数:

// 创建一个着色器
function createShader(gl, source, type){
   const shader = gl.createShader(type) // 创建着色器
   gl.shaderSource(shader, source) // 绑定着色器数据
   gl.compileShader(shader) // 编译着色器
   if(gl.getShaderParameter(shader, gl.COMPILE_STATUS)){
       return shader
   }
}
// 创建着色程序
function createProgram(gl, vertexShaderSource, fragmentShaderSource){
   const vertexShader = createShader(gl, vertexShaderSource, gl.VERTEX_SHADER) //创建顶点着色器
   const fragmentShader = createShader(gl, fragmentShaderSource, gl.FRAGMENT_SHADER) //创建片元着色器
   const program = gl.createProgram() // 创建着色程序
   gl.attachShader(program, vertexShader) // 绑定顶点着色器
   gl.attachShader(program, fragmentShader) // 绑定片元着色器
   gl.linkProgram(program) //链接着色程序
   return program
}

function main(){
     //...
     const vertexShaderSource = document.getElementById("vertex-shader").text // 获取标签中的着色器文本
     const fragmentShaderSource = document.getElementById("fragment-shader").text
     const program = createProgram(gl, vertexShaderSource, fragmentShaderSource)
     gl.useProgram(program)
}

这样就创建了一个着色程序,并使用了它,创建流程如下图:

着色程序流程

数据存入缓冲区

 没有特殊说明的话,js代码默认都在main函数中

上文在写顶点着色器的时候用到了Attributes属性,说明是这个变量要从缓冲中读取数据,下面我们就来把数据存入缓冲中。

首先创建一个顶点缓冲区对象(Vertex Buffer Object, VBO)

const buffer = gl.createBuffer()

gl.createBuffer()函数创建缓冲区并返回一个标识符,接下来需要为WebGL绑定这个buffer

const buffer = gl.createBuffer()
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer)

gl.bindBuffer()函数把标识符buffer设置为当前缓冲区,后面的所有的数据都会都会被放入当前缓冲区,直到bindBuffer绑定另一个当前缓冲区。

gl.ARRAY_BUFFER表示包含顶点属性的Buffer,如顶点坐标,纹理坐标数据或顶点颜色数据。还有一个可选项是gl.ELEMENT_ARRAY_BUFFER表示用于元素索引的Buffer,和绘制函数gl.drawElements()一起使用,本文暂不涉及这些。 缓冲区已经准备完毕,接下就是向里面存入数据了。

const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
+ const vertices = [
+   -0.5, -0.5,
+   0, 0.5,
+   0.5, -0.5
+ ]
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW)

首先我们创建了一个顶点数组,因为我们要渲染一个三角形,所以6个数据每两个表示一个顶点坐标。并且下文从缓冲中取数据时每次只取2个。

其次使用了gl.bufferData()函数为当前缓冲区存入数据。因为JavaScript与WebGL通信必须是二进制的,不能是传统的文本格式,所以这里使用了ArrayBuffer对象将数据转化为二进制,因为顶点数据是浮点数,精度不需要太高,所以使用Float32Array就可以了,这是JavaScript与GPU之间大量实时交换数据的有效方法。

最后使用了gl.STATIC_DRAW指定数据存储区的使用方法,表示缓冲区的内容可能经常使用,但不会经常更改。另外还有两个可选项gl.DYNAMIC_DRAW表示缓冲区的内容可能经常被使用,并且经常更改。gl.STREAM_DRAW表示缓冲区的内容可能不会经常使用。这里我们只是绘制一次三角形,并不会更改缓冲区,所以使用gl.STATIC_DRAW较为合适。

从缓冲中读取数据

初始化部分讲完了,接来下开始渲染部分

现在要从缓冲里取数据给顶点着色器,那到底给谁呢,回想一下之前在顶点着色器使用Attributes属性的变量是vPosition,那么要先从着色程序中获取vPosition的位置索引。

  const vPositionLocation = gl.getAttribLocation(program, 'vPosition')

接下来需要激活vPositionLocation中记录的索引位置。

const vPositionLocation = gl.getAttribLocation(program, 'vPosition')
+ gl.enableVertexAttribArray(vPositionLocation)

最后是从缓冲中读取数据绑定给被激活的vPositionLocation的位置

const vPositionLocation = gl.getAttribLocation(program, 'vPosition')
gl.enableVertexAttribArray(vPositionLocation)
+ gl.vertexAttribPointer(vPositionLocation, 2, gl.FLOAT, false, 0, 0)

gl.vertexAttribPointer()函数有六个参数:

  • 第一个表示读取的数据要绑定到哪

  • 第二个参数表示每次从缓存取几个数据,也可以表示每个顶点有几个单位的数据,取值范围是1-4。这里每次取2个数据,之前vertices声明的6个数据,正好是3个顶点的二维坐标。

  • 第三个表示数据类型,可选参数有gl.BYTE有符号的8位整数,gl.SHORT有符号的16位整数,gl.UNSIGNED_BYTE无符号的8位整数,gl.UNSIGNED_SHORT无符号的16位整数,gl.FLOAT32位IEEE标准的浮点数。

  • 第四个表示是否应该将整数数值归一化到特定的范围,对于类型gl.FLOAT此参数无效。

  • 第五个表示每次取数据与上次隔了多少位,0表示每次取数据连续紧挨上次数据的位置,WebGL会自己计算之间的间隔。

  • 第六个表示首次取数据时的偏移量,必须是字节大小的倍数。0表示从头开始取。

归一化是什么意思呢?如果设为true

  • gl.BYTE会将取值范围从 [-128, 127]转化为[-1, 1] ,

  • gl.SHORT会将取值范围从 [-32768, 32767]也转化为[-1, 1] ,但是要比gl.BYTE的精度高,因为其范围本身也比较大。 ? * 而gl.UNSIGNED_BYTE会从 [0, 255]转化为[0, 1]

  • gl.UNSIGNED_SHORT也会从[0, 65535]转化为[0, 1] 精度仍比gl.UNSIGNED_BYTE高。

    说了这么多,那归一化到底有什么用呢,举个🌰,比如我们画一个点,点的颜色通过Attributes属性获得,我们> 需要在缓冲中存一个颜色并获取:

    const color1 = [0.78, 0.78, 0.78, 1.0]
    const color2 = [200, 200, 200, 255]
    //...
    // color1
    gl.vertexAttribPointer(color1, 4, gl.FLOAT, false, 0, 0)
    // color2
    gl.vertexAttribPointer(color2, 4, gl.UNSIGNED_BYTE, true, 0, 0)
    

    我们声明了两个颜色color1用了4个FLOAT共占16个字节,color2用了4个UNSIGNED_BYTE 共占4个字节。 等我们从缓冲中取数据时color2使用归一化把RGBA四个值转化成[0, 1]之间也是[0.78, 0.78, 0.78, 1.0],这样就节省了¾的空间。

运行着色程序

在运行着色器之前,我们首先要清除画布上的数据,把画布置为上文gl.clearColor()设置的颜色。

gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLES, 0, 3)

最后用gl.drawArrays()绘制三角形

  • 第一个参数表示绘制的类型
  • 第二个参数表示从第几个顶点开始绘制
  • 第三个参数表示绘制多少个点,缓冲中一共6个数据,每次取2个,共3个点

绘制类型共有下列几种

  • gl.POINTS绘制一系列点
  • gl.LINES每两个点绘制一条直线,线与线之间不相连
  • gl.LINE_STRIP绘制一条折线
  • gl.LINE_LOOP首点和末点相连的一条闭合环路
  • gl.TRIANGLES绘制一系三角形,每三个点一个
  • gl.TRIANGLE_STRIP绘制一条三角形带,从第三个点起每次与前两点绘制成一个三角形
  • gl.TRIANGLE_FAN绘制一个三角形扇,从第三个点起每次与第一个点和上一个点绘制成一个三角形

图元类型

这次绘制会调用着色器程序三次,每次把一个二维顶点数据传递给顶点着色器的vPosition属性。顶点着色器通过一系列计算(本文并没有计算,直接输出)由gl_Position输出,经过图元组装,把三个顶点组装成一个三角形,然后输出到光栅化模块,光栅化模块计算出这个三角形内对应的哪些像素,每个像素的颜色由片元着色器的gl_FragColor提供,最后绘制出三角形。

结语

本文只讲述了最基本的三角形的方法,还可以通过顶点着色器的第二个Attributes变量接收顶点的颜色配合Varyings可变量绘制出渐变色的三角形。当然,两个Attributes变量需要执行两次缓冲数据存取的逻辑。可以参考这个例子

更进一步,可以看看这篇如何用WebGL撸一个五子棋

参考