初识 WebGL —— 实战了解缓冲区与平面图形绘制

前言

在上一篇文章中,我们主要是对于 WebGL 的实现原理与渲染管线这部分理论内容进行了学习探讨。由于偏向原理性的知识,所以在其中并没有涉及到 WebGL 的编码。作为一个程序员,这样肯定是不行的。因此,本篇文章,我们通过实战来了解如何编写着色器代码,如果通过 WebGL 实现简单的平面图形与立体几何体;并借此学习了解 WebGL 中的缓冲区与坐标系变化的知识。

上一篇传送门:初识 WebGL —— 渲染管线

1. 一切的基础 —— 点

1.1 点的绘制

点,可以说是平面上最简单的图形。它也可以说是所有平面图形,几何体的最基本组成单位。因此,我们将它的实现放在了最前面。

首先,正如上篇文章中说到的,WebGL 程序分为 JavaScript 程序与着色器程序。因此我们需要先实现我们自己的着色器程序。我们通过 GLSL 编写着色器程序,通知 GPU 在屏幕正中(即裁剪坐标系原点)的位置绘制一个点。

代码很简单,如下所示:

void main() {
  // 声明顶点位置
  gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
  // 声明点大小
  gl_PointSize = 15.0;
}

尽管只是一个简简单单的 main 函数,但是其涉及到部分我们还是得好好了解下。

首先,GPU 在屏幕正中绘制点,这个位置同时也是裁剪坐标系的原点。什么是裁剪坐标系呢?裁剪坐标系是顶点着色器中的 gl_Position内置变量接收到的坐标所在的坐标系。

当一个顶底着色器运行到最后时,OpenGL 希望所有的坐标都会落在一个给定的范围内,而在这个范围外的点,都会被裁剪掉;这个给定的范围,就是所谓的裁剪空间,而裁剪坐标系就是对这个空间进行描述的坐标系。

在我们完成顶点着色器的代码编写后,顶点着色器中的数据,经过图元裁剪装配和光栅化之后,来到了片元着色器这里。在这里,我们可以用颜色对像素进行填充,也就是告知 GPU 将这一像素渲染为红色。

void main() {
  // 设置填充颜色
  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

两个着色器除掉 main 函数的声明语句,加起来才 3 行代码。大家可能会有所疑问,gl_Positiongl_PointSize 是什么?为什么不需要声明就可以直接使用?这段 C 代码如何使用到我们的 HTML 中,vec4 又是干什么的?让我们一个个来解答这些问题。

正如我们在管线篇中说过的,GLSL 作为 C 的一个超集,它相比 C,自带了一些变量,属性与函数。gl_Positiongl_PointSizegl_FragColor 就是它自带的变量,vec4 则是它自带的数据类型。

其中,gl_Position 是用于设置顶点的裁剪坐标系坐标(X, Y, Z, W)。其中,X,Y,Z对应的就是正常的我们常说的三个坐标轴的坐标;这里多出来的 W 又叫做齐次坐标,是用于进行透明除法,将裁剪坐标系的坐标,转化为 NDC (Normalized Device Coordinate)标准化设备坐标。NDC 坐标的值限制在 -1 ~ 1.超过这个范围的坐标将不可见。这些标准化设备坐标会被传入光栅器(Rasterizer),再被转换为屏幕上的二维坐标或像素。

gl_PointSize 则仅适用于点这一种图形,用于设置点的大小。

gl_FragColor 用于设置片元(像素)的颜色,包含 R, G, B, A 四个颜色分量,且每个分量的取值范围在 [0, 1] 之间,不同于我们常用的 [0, 255],因此还需要进行一次额外的换算。GPU 获取这个值作为像素的最终颜色进行着色。

Vec 作为 vector 的缩写,指的是向量。一个向量,可以有多个维度;这里,我们用他来声明色值,需要存储4个值,所以用的是 vec4,同理,也还有 vec2vec3等,可以根据自己实际使用情况进行选择。

完成这段 C 函数的编写后,如何将其注入到我们的 HTML 页面中呢?着色器的源码本质上还是字符串,我们可以将其保存在 js 变量中,或者放在 script标签里面从而达到注入的目的。至此,我们就完成了我们第一个 WebGL 程序中 GLSL 部分代码的开发,接下来让我们看看 JS 部分的代码实现。

JS 部分要做的工作可以分为以下几点:

  1. 获取 WebGL 绘制环境;
  2. 创建顶点着色器;
  3. 创建片元着色器;
  4. 创建着色器程序;
  5. 启用着色器程序;
  6. 绘制。

按照这个流程顺序来,我们先获取 WebGL 的绘制环境。正如我们上一篇文章中说的, WebGL 是基于 Canvas 进行绘制的。因此,我们需要创建一个 Canvas 画布,并获取到这个 DOM,拿到 WebGL 对象(需要注意的是,这里针对不同浏览器,做了兼容处理)。

image20211127181543658.png

image20211127181715487.png

由于上文的着色器代码,我是通过 script 标签的方式注入到页面中的,因而要在这里通过 querySelectorinnerHTML 拿到着色器代码。然后,我们再使用 gl.createShader 创建对应的着色器。创建好着色器之后,我们再将对应的着色器代码分配给着色器对象。这里所创建的对应着色器,是我们通过 gl.VERTEX_SHADER 顶点着色器和 gl.FRAGMENT_SHADER 片元着色器常量来生成的。着色器对象分配完代码后,就可以通过 compilerShader 方法直接编译生成对应的着色器。

我们在生成对应的着色器后,需要创建着色器程序,并将着色器对象挂载上去进行链接。链接完成后,我们就可以使用刚刚创建的着色器程序进行渲染。最后再进行画布颜色的设置,完成绘制。

我们需要注意的是 drawArrays 这个方法。这个方法用于绘制。它有三个参数,分别是 mode 图元类型, first 即绘制的顶点的起始位置,count 为绘制的点的数量。

其中,mode gl.POINTS 表示绘制点,gl.LINE_STRIP 绘制一个线条,gl.LINE_LOOP 绘制一个线圈,gl.LINES 绘制一系列单独线段,gl.TRIANGLE_STRIp 绘制一个三角带,gl. TRIANGLE_FAN 绘制一个三角扇,gl.TRIANGLES 绘制一系列三角形(每三个点作为顶点)。

最终效果如下:

image20211127210156629.png

1.2 进阶:动态绘制点

目前,我们已经成功画出一个静态的点。然而,前端的展示总是离不开和用户的交互,WebGL 也需要通过和用户进行交互,进而改变页面的展示。这里,我们在之前点的基础上稍作修改,使其可以根据用户鼠标点击的位置,动态生成随机颜色的点。

为了实现这一点,我们先需要调整一下着色器的代码。修改后的着色器,需要可以接收 JavaScript 传递过来的数据。

image20211127210413265.png

我们先通过 vec2 声明一个 二维的向量 a_Position,通过它来接收点在 canvas 坐标系上的坐标。然后我们再在 main 方法中将其转化为裁剪坐标系坐标。我们也需要用同样的方法用二维向量 a_Screen_Size 接收 canvas 画布的宽高尺寸。这里为什么我们用的是 vec2 二维向量呢?因为我们是在平面上绘制二维图形的点,它在坐标系中的表示只需要 x,y 这两个坐标值即可。

这里需要注意的一点是,我们额外进行了一步:将屏幕坐标系坐标转化为裁剪坐标系坐标。之所以要进行这一步,是因为我们从 js 程序那边拿到的点击位置坐标是基于屏幕坐标系的,不适用于着色器需要的裁剪坐标(坐标系的详细划分和区别,会在后文进行详细讲述)。我们现在是在着色器阶段完成了坐标系转换:

vec2 position = (a_Position / a_Screen_Size) * 2.0 - 1.0;

实际上,我们通常在 JavaScript 程序中计算出转换矩阵,然后将转换矩阵连同顶点信息一并传递给着色器;着色器根据转换矩阵对顶点进行坐标系转换。这么做可以为我们带来一定程度的性能上的优化。

image20211127211214016.png

片元着色器的代码没有什么可说的,接收 js 传递过来的颜色并转化为着色器可以用的形式而已。这里大家需要注意的是我们声明变量的方法。在顶点着色器中,我们通过 attribute 声明变量,在片元着色器中,我们通过 uniform 声明。

这俩的区别是什么呢?区别在于 attribute 只能在顶点着色器中定义,而 uniform 二者都可使用。还有一种变量类型,叫做 varing,它是用于顶点着色器向片元着色器传递数据。

而 JS 程序这边需要做的事情则是:

  1. 声明变量,存储点击坐标;
  2. 绑定监听 Canvas 的点击事件;
  3. 点击事件触发,写入坐标;
  4. 遍历点击坐标,动态执行 drawArrays 方法进行渲染;

image20211127211436956.png

image20211127211442645.png

着色器的插件,编译,绑定,链接都和之前是一样的,实际上这块我们可以封装成一个公共的方法,后面会经常用到。

这里大家需要注意的主要是 getAttribLocationgetUniformLocationvertexAttrib2funiform4f 这四个方法。

  • getAttribLocation:找到着色器中的 attribute 变量地址;

  • getUniformLocation:找到着色器中的 uniform 变量地址。

通过这两个方法,找到 a_Positiona_Screen_sizeu_Color 这三个变量地址,是为了方便我们后面向这两个变量传递顶点的数据。

  • vertexAttrib2f:给 attribute 变量传递两个浮点数;

  • uniform4f:给 uniform 变量传递四个浮点数。

最终效果:

image20211127211728240.png

到了这里,我们的第一个 WebGL 程序就可以说完成了。虽然很简单,但是涵盖了 WebGL 的各个组成部分,HTML,Js,GLSL。

但是呢,我们做的这个 demo 也有一个不足之处。就是我们是通过遍历数组,每次手动调用 vertexAttrib2f 进行赋值,并通过 drawArrays 方法绘制一个个点,效率较差。为了解决这个问题,我们又需要引入一个新的概念 – 缓冲区。

2. 三角形与缓冲区

2.1 三角形绘制

之前我们也提到过 WebGL 的基本图元类型有三种,分别是点,线段和三角形。我们刚刚已经实现了点。线段则十分简单,两点之间的连线嘛,大家自行看看 WebGL 的文档,就能很快的上手了。而三角形虽然是我们在 WebGL 中接触到的第一个闭合的平面图形,但是本质与实现方式依旧没有变化;依旧是我们提供数据,通过 drawArrays 方法绘制就即可。

WebGL 中提供了 3 种三角形的绘制方法,分别是基本三角形,三角带和三角扇。三角带和三角扇大家可能并不了解,可以自行去看下文档。这里,我们主要还是讲讲最基础的三角形。基本三角形在绘制时,是一个个互不相干的三角形。我们如果提供 [v1,v2,v3,v4,v5,v6] 六个顶点,就会绘制出如图所示的两个三角形。

image20211127215141410.png

接下来,我们希望实现的是在页面点击3个位置,以这三个位置作为顶点,生成一个三角形。如果还是用之前的方法绘制,我们就得重复进行向着色器中传递一个个顶点数据的过程,体验并不是很好。为了解决这个问题,我们引入了缓冲区的概念。

缓冲区是 WebGL 中的一块内存区域,让我们可以一次性填充或者读取大量的顶点数据。

在动态绘制前,让我们先绘制一个普通的三角形。

首先,第一步,和绘制点一样,我们需要修改着色器代码。

image20211127215317080.png

我们这里只是做了一个优化,将坐标系转化的代码提取到 js 中执行。

第二步,获取 HTMl 中的 Canvas 标签。

第三步,js 处理并传输数据。我们需要给 a_position 传入三角形的顶点。这里由于我们需要传入多个坐标数据,所以我们无法像之前绘制点那样进行数据传递。这时,缓冲区就派上了用场。

我们创建并绑定缓冲区,绑定该缓冲区为 WebGL 当前缓冲区 gl.ARRAY_BUFFER。绑定之后,接下来对缓冲区绑定点的任何操作都会基于该缓冲区(即 buffer) 进行。

其中,new Float32Array(positions) 将顶点数组转化为更严谨的类型化数组。我们再使用 gl.bufferData 将类型化后的数组复制到缓冲区中,最后一个参数 gl.STATIC_DRAW 提示 WebGL 我们不会频繁改变缓冲区中的数据,WebGL 会根据这个参数做一些优化处理。这部分属于初始化代码,在后续的渲染过程中,不会再次执行。

这里有个很重要的方法 gl.vertexAttribPointer。这个方法决定目标属性如何从缓冲区 buffer 中读取数据。它一共有六个参数:

  • target:允许哪个属性读取当前缓冲区数据;
  • size: 每次取几个值赋给 target 属性;此处绘制三角形为平面图形,只需要传入 x,y 坐标即可;
  • type:数据类型,一般为浮点数;
  • normalize:是否需要将非浮点数单位化至 [-1,1];
  • stride:步长,每个顶点包含的字节数,默认为0;
  • Offset:步长中,目标属性需要偏移多少字节读取;

我们需要注意的是 stride 这个参数。此处,每个顶点由 x,y 这两个分量组成。这两个分量都是 Float32 数据类型,在字节中占据 4 个字节。依此计算,我们应该得出 stride 为 2 * 4 = 8 才对,但是由于这里的缓冲区仅为 a_position 这一个属性使用,此时缓冲区的数据是连续存放的,所以写成0。如果一个缓冲区有多个属性使用,这个时候就需要计算了。

最终的实现效果如下:

image20211127220031342.png

2.2 进阶:动态绘制三角形

接下来我们进行三角形的动态绘制。和上文的动态绘制点类似,实现原理是一致的,也是需要通过 js 绑定点击事件,并监听点击事件,向着色器输入坐标数据,这里也是相比于绘制静态三角形改动较大的地方,顶点着色器和片元着色器都没有什么变化。

image20211127220403898.png

js 代码的区别在于,我们在每次点击后,需要保存当次点击的鼠标位置,每三次点击后,绘制三角形。

最终效果:

image20211127220543271.png

2.3 深入理解缓冲区

通过三角形的例子,我们知道了缓冲区的作用和如何使用它,对缓冲区有了一个初步的理解。接下来,我们再通过动态绘制渐变三角形,来深入了解一下缓冲区。我们需要了解的,主要有以下四点:

  • 顶点数据在 buffer 缓冲区中的排布方式;
  • bindBuffer 在切换 buffer 时的重要作用;
  • 如何使用单个 buffer 读取多个顶点数据;
  • 如何使用多个 buffer 读取多个顶点数据;

实现动态生成渐变三角形,和上文提到的动态生成三角形的主要区别在于渐变三角形颜色并不相同,在顶点与顶点之间进行颜色的渐变过渡。因此,我们需要的顶点信息除了包含坐标,还要包含颜色数据。通过颜色数据,我们就可以通过片元着色器使用 GPU 根据每个顶点的颜色对顶点与顶点之间的颜色进行插值,自动填补顶点之间像素的颜色,生成渐变三角形。在之前动态生成三角形的顶点着色器程序中,我们只是接收了顶点的坐标数据 a_position。为了接收颜色数据,我们需要新增一个 a_color 来接收顶点颜色。同时,为了将接收到的顶点颜色通过顶点着色器传递给片元着色器,方便 GPU 进行插值,我们还需要在顶点着色器和片元着色器中定义一个 v_color 的变量用于数据传输。

image20211127221212355.png

image20211127221218311.png

着色器部分的代码就此告一段落,接下来是 js 部分的代码。在上面三角形的代码中,缓存区仅需传递坐标数据,所以只创建了一个 position buffer 用于存放坐标数据;同理,我们也可以再创建一个缓冲区,专门用于存放颜色数据。我们将这两个 buffer 缓冲区与对应的属性进行绑定,并设置各自读取 buffer 的方式。这就是缓冲区传递数据的方式之一 —— 通过多个缓存区传递多种数据。

image20211127221350860.png

但是,多个缓冲区对应不同的数据就会导致一个问题:我们如何得知我们目前操作的缓存区是传递哪个数据的。为了解决这个问题,我们引入了 bindBuffer 这个方法。bindBuffer 用于切换缓冲区,它的作用是将我们需要进行操作的缓冲区绑定到 gl.Array_Buffer 上,这样我们才能正常的使用缓冲区。在后续对缓冲区进行操作时,操作的缓冲区对象都是最新绑定的这个缓冲区。只有在绑定缓存区之后,我们才能向缓冲区中传递数据,设置属性读取缓冲区的方式,也就是我们之前做的那些操作。

image20211127221626859.png

我们这里两个缓冲区的 size 并不相同。结合我们之前的对于 vertexArribPointer 解读,size 是用于寻址。坐标缓冲区为 2,是因为对于坐标来说,顶点的数据只有 x,y 两个数据;而顶点的颜色数据,会有 r,g,b,a 这 4个,所以缓冲区每次取值时,需要取4个。

那么我们可不可以只用一个缓冲区来存储数据呢?答案显然是可以的。

单个缓冲区的实现,着色器部分的代码并无改动,只不过在 js 部分,我们只需要创建一个 buffer 即可。

此时,一个顶点会有两个数据,分别为坐标数据和颜色数据,a_positiona_color 这里两个属性都从这个 buffer 读取。这个时候,一个顶点就会有 6 个元素组成,分别是 x,y,r,g,b,a。在读取缓冲区数据时,a_positiona_color 这两个属性的vertexAttrbPoint 的设置差别就体现出来了。

  • a_position:size 为 2,offset 偏移量为 0;

  • a_color:size 为 4,offset 偏移量为 2 * 4 = 8;

这里 a_color 的偏移量之所以为 8,是因为我们从第三个元素开始,一个元素占用 4 个字节,所以要偏移 8 个字节开始寻址。

同理,在监听点击事件,监听顶点数据数组的时候,判断当前是否有3个顶点可以绘制三角形时,也需要由原先的 3 * 2改为 3 * 6。具体的实现代码这里就不贴了,大家有兴趣可以自己实现一下。

3. 结语

到此,WebGL 的三个基本图元类型就已经讲完了。通过这三个基本图元类型,我们可以绘制平面图形;通过平面图形,我们可以构建规则形体,为我们的图形,形体添加纹理贴图,添加环境光,冯氏漫反射,镜面高光等等效果。WegGL 所有的酷炫效果,都是基于我们所说的这些基础实现的。

看到这里的朋友们,可以自己先思考下如何通过这三个基本图元,来绘制一个矩形。实现思路的分享会放在下一篇;同时,我们也会再下一篇中学习坐标系的有关知识,并实战实现空间几何体和简单的动画效果。

Q.E.D.


Take it easy