You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

358 lines
25 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 04 | GPU与渲染管线如何用WebGL绘制最简单的几何图形
你好我是月影。今天我们要讲WebGL。
WebGL是最后一个和可视化有关的图形系统也是最难学的一个。为啥说它难学呢我觉得这主要有两个原因。第一WebGL这种技术本身就是用来解决最复杂的视觉呈现的。比如说大批量绘制复杂图形和3D模型这类比较有难度的问题就适合用WebGL来解决。第二WebGL相对于其他图形系统来说是一个更“开放”的系统。
我说的“开放”是针对于底层机制而言的。因为不管是HTML/CSS、SVG还是Canvas都主要是使用其API来绘制图形的所以我们不必关心它们具体的底层机制。也就是说我们只要理解创建SVG元素的绘图声明学会执行Canvas对应的绘图指令能够将图形输出这就够了。但是要使用WebGL绘图我们必须要深入细节里。换句话说就是我们必须要和内存、GPU打交道真正控制图形输出的每一个细节。
所以想要学好WebGL我们必须先理解一些基本概念和原理。那今天这一节课我会从图形系统的绘图原理开始讲起主要来讲WebGL最基础的概念包括GPU、渲染管线、着色器。然后我会带你用WebGL绘制一个简单的几何图形。希望通过这个可视化的例子能够帮助你理解WebGL绘制图形的基本原理打好绘图的基础。
## 图形系统是如何绘图的?
首先,我们来说说计算机图形系统的主要组成部分,以及它们在绘图过程中的作用。知道了这些,我们就能很容易理解计算机图形系统绘图的基本原理了。
一个通用计算机图形系统主要包括6个部分分别是输入设备、中央处理单元、图形处理单元、存储器、帧缓存和输出设备。虽然我下面给出了绘图过程的示意图不过这些设备在可视化中的作用我要再跟你多啰嗦几句。
* **光栅**Raster几乎所有的现代图形系统都是基于光栅来绘制图形的光栅就是指构成图像的像素阵列。
* **像素**Pixel一个像素对应图像上的一个点它通常保存图像上的某个具体位置的颜色等信息。
* **帧缓存**Frame Buffer在绘图过程中像素信息被存放于帧缓存中帧缓存是一块内存地址。
* **CPU**Central Processing Unit中央处理单元负责逻辑计算。
* **GPU**Graphics Processing Unit图形处理单元负责图形计算。
![](https://static001.geekbang.org/resource/image/b5/56/b5e4f37e1c4fbyy6a2ea10624d143356.jpg?wh=1920*825)
知道了这些概念,我带你来看一个典型的绘图过程,帮你来明晰一下这些概念的实际用途。
首先数据经过CPU处理成为具有特定结构的几何信息。然后这些信息会被送到GPU中进行处理。在GPU中要经过两个步骤生成光栅信息。这些光栅信息会输出到帧缓存中最后渲染到屏幕上。
![](https://static001.geekbang.org/resource/image/9f/46/9f7d76cc9126036ef966dc236df01c46.jpeg?wh=1920*1080 "图形数据经过GPU处理最终输出到屏幕上")
这个绘图过程是现代计算机中任意一种图形系统处理图形的通用过程。它主要做了两件事一是对给定的数据结合绘图的场景要素例如相机、光源、遮挡物体等等进行计算最终将图形变为屏幕空间的2D坐标。二是为屏幕空间的每个像素点进行着色把最终完成的图形输出到显示设备上。这整个过程是一步一步进行的前一步的输出就是后一步的输入所以我们也把这个过程叫做**渲染管线**RenderPipelines
在这个过程中CPU与GPU是最核心的两个处理单元它们参与了计算的过程。CPU我相信你已经比较熟悉了但是GPU又是什么呢别着急听我慢慢和你讲。
## GPU是什么
CPU和GPU都属于处理单元但是结构不同。形象点来说CPU就像个大的工业管道等待处理的任务就像是依次通过这个管道的货物。一条CPU流水线串行处理这些任务的速度取决于CPU管道的处理能力。
实际上一个计算机系统会有很多条CPU流水线而且任何一个任务都可以随机地通过任意一个流水线这样计算机就能够并行处理多个任务了。这样的一条流水线就是我们常说的**线程**Thread
[![](https://static001.geekbang.org/resource/image/1e/80/1e6479ef37138f051b7a6e5de6977580.jpeg?wh=1920*615 "CPU")](https://thebookofshaders.com/)
这样的结构用来处理大型任务是足够的,但是要处理图像应用就不太合适了。这是因为,处理图像应用,实际上就是在处理计算图片上的每一个像素点的颜色和其他信息。每处理一个像素点就相当于完成了一个简单的任务,而一个图片应用又是由成千上万个像素点组成的,所以,我们需要在同一时间处理成千上万个小任务。
要处理这么多的小任务比起使用若干个强大的CPU使用更小、更多的处理单元是一种更好的处理方式。而GPU就是这样的处理单元。
[![](https://static001.geekbang.org/resource/image/1a/e7/1ab1116e3742611f5cb26c942d67d5e7.jpeg?wh=1920*1080 "GPU")](https://thebookofshaders.com/)
GPU是由大量的小型处理单元构成的它可能远远没有CPU那么强大但胜在数量众多可以保证每个单元处理一个简单的任务。即使我们要处理一张800 \* 600大小的图片GPU也可以保证这48万个像素点分别对应一个小单元这样我们就可以**同时**对每个像素点进行计算了。
那GPU究竟是怎么完成像素点计算的呢这就必须要和WebGL的绘图过程结合起来说了。
## 如何用WebGL绘制三角形
浏览器提供的WebGL API是OpenGL ES的JavaScript绑定版本它赋予了开发者操作GPU的能力。这一特点也让WebGL的绘图方式和其他图形系统的“开箱即用”直接调用绘图指令或者创建图形元素就可以完成绘图的绘图方式完全不同甚至要复杂得多。我们可以总结为以下5个步骤
1. 创建WebGL上下文
2. 创建WebGL程序WebGL Program
3. 将数据存入缓冲区
4. 将缓冲区数据读取到GPU
5. GPU执行WebGL程序输出结果
别看这些步骤看起来很简单但其中会涉及许多你没听过的新概念、方法以及各种参数。不过这也不用担心我们今天的重点还是放在理解WebGL的基本用法和绘制原理上对于新的方法具体怎么用参数如何设置这些我们都会在后面的课程中详细来讲。
接下来,我们就用一个绘制三角形的例子,来讲一下这些步骤的具体操作过程。
### 步骤一创建WebGL上下文
创建WebGL上下文这一步和Canvas2D的使用几乎一样我们只要调用canvas元素的getContext即可区别是将参数从2d换成webgl
```
const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');
```
不过有了WebGL上下文对象之后我们并不能像使用Canvas2D的上下文那样调用几个绘图指令就把图形画出来还需要做很多工作。别着急让我们一步一步来。
### 步骤二创建WebGL程序
接下来我们要创建一个WebGL程序。你可能会觉得奇怪我们不是正在写一个绘制三角形的程序吗为什么这里又要创建一个WebGL程序呢实际上这里的WebGL程序是一个WebGLProgram对象它是给GPU最终运行着色器的程序而不是我们正在写的三角形的JavaScript程序。好了解决了这个疑问我们就正式开始创建一个WebGL程序吧
首先要创建这个WebGL程序我们需要编写两个**着色器**Shader。着色器是用GLSL这种编程语言编写的代码片段这里我们先不用过多纠结于GLSL语言在后续的课程中我们会详细讲解。那在这里我们只需要理解绘制三角形的这两个着色器的作用就可以了。
```
const vertex = `
attribute vec2 position;
void main() {
gl_PointSize = 1.0;
gl_Position = vec4(position, 1.0, 1.0);
}
`;
const fragment = `
precision mediump float;
void main()
{
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;
```
那我们为什么要创建两个着色器呢?这就需要我们先来理解**顶点和图元**这两个基本概念了。在绘图的时候WebGL是以顶点和图元来描述图形几何信息的。顶点就是几何图形的顶点比如三角形有三个顶点四边形有四个顶点。图元是WebGL可直接处理的图形单元由WebGL的绘图模式决定有点、线、三角形等等。
所以顶点和图元是绘图过程中必不可少的。因此WebGL绘制一个图形的过程一般需要用到两段着色器一段叫**顶点着色器**Vertex Shader负责处理图形的顶点信息另一段叫**片元着色器**Fragment Shader负责处理图形的像素信息。
更具体点来说,我们可以把**顶点着色器理解为处理顶点的GPU程序代码。它可以改变顶点的信息**(如顶点的坐标、法线方向、材质等等),从而改变我们绘制出来的图形的形状或者大小等等。
顶点处理完成之后WebGL就会根据顶点和绘图模式指定的图元计算出需要着色的像素点然后对它们执行片元着色器程序。简单来说就是对指定图元中的像素点着色。
WebGL从顶点着色器和图元提取像素点给片元着色器执行代码的过程就是我们前面说的生成光栅信息的过程我们也叫它光栅化过程。所以**片元着色器的作用,就是处理光栅化后的像素信息。**
这么说可能比较抽象,我 来举个例子。我们可以将图元设为线段,那么片元着色器就会处理顶点之间的线段上的像素点信息,这样画出来的图形就是空心的。而如果我们把图元设为三角形,那么片元着色器就会处理三角形内部的所有像素点,这样画出来的图形就是实心的。
![](https://static001.geekbang.org/resource/image/6c/6e/6c4390eb21e653274db092a9ba71946e.jpg?wh=1726*904)
这里你要注意一点因为图元是WebGL可以直接处理的图形单元所以其他非图元的图形最终必须要转换为图元才可以被WebGL处理。举个例子如果我们要绘制实心的四边形我们就需要将四边形拆分成两个三角形再交给WebGL分别绘制出来。
好了,那让我们回到片元着色器对像素点着色的过程。你还要注意,这个过程是并行的。也就是说,**无论有多少个像素点,片元着色器都可以同时处理。**这也是片元着色器一大特点。
以上就是片元着色器的作用和使用特点了关于顶点着色器的作用我们一会儿再说。说了这么多你可别忘了创建着色器的目的是为了创建WebGL程序那我们应该如何用顶点着色器和片元着色器代码来创建WebGL程序呢
首先因为在JavaScript中顶点着色器和片元着色器只是一段代码片段所以我们要将它们分别创建成shader对象。代码如下所示
```
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertex);
gl.compileShader(vertexShader);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragment);
gl.compileShader(fragmentShader);
```
接着我们创建WebGLProgram对象并将这两个shader关联到这个WebGL程序上。WebGLProgram对象的创建过程主要是添加vertexShader和fragmentShader然后将这个WebGLProgram对象链接到WebGL上下文对象上。代码如下
```
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
```
最后我们要通过useProgram选择启用这个WebGLProgram对象。这样当我们绘制图形时GPU就会执行我们通过WebGLProgram设定的 两个shader程序了。
```
gl.useProgram(program);
```
好了现在我们已经创建并完成WebGL程序的配置。接下来 我们只要将三角形的数据存入缓冲区也就能将这些数据送入GPU了。那实现这一步之前呢我们先来认识一下WebGL的坐标系。
### 步骤三:将数据存入缓冲区
我们要知道WebGL的坐标系是一个三维空间坐标系坐标原点是0,0,0。其中x轴朝右y轴朝上z轴朝外。这是一个右手坐标系。
![](https://static001.geekbang.org/resource/image/yy/b1/yy3e873beb7743096e3cc7b641e718b1.jpeg?wh=1920*1080)
假设,我们要在这个坐标系上显示一个顶点坐标分别是(-1, -11, -10, 1的三角形如下图所示。因为这个三角形是二维的所以我们可以直接忽略z轴。下面我们来一起绘图。
![](https://static001.geekbang.org/resource/image/83/c3/8311b485131497ce59cd1600b9a7f7c3.jpeg?wh=1920*1080)
**首先,我们要定义这个三角形的三个顶点**。WebGL使用的数据需要用类型数组定义默认格式是Float32Array。Float32Array是JavaScript的一种类型化数组TypedArrayJavaScript通常用类型化数组来处理二进制缓冲区。
因为平时我们在Web前端开发中使用到类型化数组的机会并不多你可能还不大熟悉不过没关系类型化数组的使用并不复杂定义三角形顶点的过程你直接看我下面给出的代码就能理解。不过如果你之前完全没有接触过它我还是建议你阅读[MDN文档](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/TypedArray),去详细了解一下类型化数组的使用方法。
```
const points = new Float32Array([
-1, -1,
0, 1,
1, -1,
]);
```
**接着我们要将定义好的数据写入WebGL的缓冲区**。这个过程我们可以简单总结为三步分别是创建一个缓存对象将它绑定为当前操作对象再把当前的数据写入缓存对象。这三个步骤主要是利用createBuffer、bindBuffer、bufferData方法来实现的过程很简单你可以看一下我下面给出的实现代码。
```
const bufferId = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);
```
### 步骤四将缓冲区数据读取到GPU
现在我们已经把数据写入缓存了但是我们的shader现在还不能读取这个数据还需要把数据绑定给顶点着色器中的position变量。
还记得我们的顶点着色器是什么样的吗?它是按如下的形式定义的:
```
attribute vec2 position;
void main() {
gl_PointSize = 1.0;
gl_Position = vec4(position, 1.0, 1.0);
}
```
在GLSL中attribute表示声明变量vec2是变量的类型它表示一个二维向量position是变量名。接下来我们将buffer的数据绑定给顶点着色器的position变量。
```
const vPosition = gl.getAttribLocation(program, 'position');获取顶点着色器中的position变量的地址
gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0);给变量设置长度和类型
gl.enableVertexAttribArray(vPosition);激活这个变量
```
经过这样的处理在顶点着色器中我们定义的points类型数组中对应的值就能通过变量position读到了。
### 步骤五:执行着色器程序完成绘制
现在我们把数据传入缓冲区以后GPU也可以读取绑定的数据到着色器变量了。接下来我们只需要调用绘图指令就可以执行着色器程序来完成绘制了。
我们先调用gl.clear将当前画布的内容清除然后调用gl.drawArrays传入绘制模式。这里我们选择gl.TRIANGLES表示以三角形为图元绘制再传入绘制的顶点偏移量和顶点数量WebGL就会将对应的buffer数组传给顶点着色器并且开始绘制。代码如下
```
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, points.length / 2);
```
这样我们就在Canvas画布上画出了一个红色三角形。
![](https://static001.geekbang.org/resource/image/cc/61/ccdd298c45f80a9a00d23082cf637d61.jpeg?wh=1920*1080)
为什么是红色三角形呢?因为我们在片元着色器中定义了像素点的颜色,代码如下:
```
precision mediump float;
void main()
{
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
```
在**片元着色器**里我们可以通过设置gl\_FragColor的值来定义和改变图形的颜色。gl\_FragColor是WebGL片元着色器的内置变量表示当前像素点颜色它是一个用RGBA色值表示的四维向量数据。在上面的代码中因为我们写入vec4(1.0, 0.0, 0.0, 1.0)对应的是红色所以三角形是红色的。如果我们把这个值改成vec4(0.0, 0.0, 1.0, 1.0),那三角形就是蓝色。
我为什么会强调颜色这个事儿呢你会发现刚才我们只更改了一个值就把整个图片的所有像素颜色都改变了。所以我们必须要认识到一点WebGL可以并行地对整个三角形的所有像素点同时运行片元着色器。并行处理是WebGL程序非常重要的概念所以我就多强调一下。
我们要记住不论这个三角形是大还是小有几十个像素点还是上百万个像素点GPU都是**同时处理**每个像素点的。也就是说图形中有多少个像素点着色器程序在GPU中就会被同时执行多少次。
到这里WebGL绘制三角形的过程我们就讲完了。借助这个过程我们加深了对顶点着色器和片元着色器在使用上的理解。不过因为后面我们会更多地讲解片元着色器的绘图方法那今天我们正好可以借着这个机会多讲讲顶点着色器的应用我希望你也能掌握好它。
## 顶点着色器的作用
顶点着色器大体上可以总结为两个作用一是通过gl\_Position设置顶点二是通过定义varying变量向片元着色器传递数据。这么说还是有点抽象我们还是通过三角形的例子来具体理解一下。
### 1\. 通过gl\_Position设置顶点
假如我想把三角形的周长缩小为原始大小的一半有两种处理方式法一种是修改points数组的值另一种做法是直接对顶点着色器数据进行处理。第一种做法很简单我就不讲了如果不懂你可以在留言区提问。我们来详细说说第二种做法。
我们不需要修改points数据只需要在顶点着色器中将 gl\_Position = vec4(position, 1.0, 1.0);修改为 gl\_Position = vec4(position \* 0.5, 1.0, 1.0);,代码如下所示。
```
attribute vec2 position;
void main() {
gl_PointSize = 1.0;
gl_Position = vec4(position * 0.5, 1.0, 1.0);
}
```
这样三角形的周长就缩小为原来的一半了。在这个过程中我们不需要遍历三角形的每一个顶点只需要是利用GPU的并行特性在顶点着色器中同时计算所有的顶点就可以了。在后续课程中我们还会遇到更加复杂的例子但在那之前你一定要理解并牢记WebGL可以**并行计算**这一特点。
### 2\. 向片元着色器传递数据
除了计算顶点之外顶点着色器还可以将数据通过varying变量传给片元着色器。然后这些值会根据片元着色器的像素坐标与顶点像素坐标的相对位置做**线性插值**。这是什么意思呢?其实这很难用文字描述,我们还是来看一段代码:
```
attribute vec2 position;
varying vec3 color;
void main() {
gl_PointSize = 1.0;
color = vec3(0.5 + position * 0.5, 0.0);
gl_Position = vec4(position * 0.5, 1.0, 1.0);
}
```
在这段代码中我们修改了顶点着色器定义了一个color变量它是一个三维的向量。我们通过数学技巧将顶点的值映射为一个RGB颜色值关于顶点映射RGB颜色值的方法在后续的课程中会有详细介绍映射公式是 vec3(0.5 + position \* 0.5, 0.0)。
这样一来,顶点\[-1,-1\]被映射为\[0,0,0\]也就是黑色,顶点\[0,1\]被映射为\[0.5, 1, 0\]也就是浅绿色,顶点\[1,-1\]被映射为\[1,0,0\]也就是红色。这样一来,三个顶点就会有三个不同的颜色值。
然后我们将color通过varying变量传给片元着色器。片元着色器中的代码如下
```
precision mediump float;
varying vec3 color;
void main()
{
gl_FragColor = vec4(color, 1.0);
}
```
我们将gl\_FragColor的rgb值设为变量color的值这样我们就能得到下面这个三角形
![](https://static001.geekbang.org/resource/image/5c/21/5c4c718eca069be33d8a1d5d1eb77821.jpeg?wh=1920*1080)
我们可以看到这个三角形是一个颜色均匀线性渐变的三角形它的三个顶点的色值就是我们通过顶点着色器来设置的。而且你会发现中间像素点的颜色是均匀过渡的。这就是因为WebGL在执行片元着色器程序的时候顶点着色器传给片元着色器的变量会根据片元着色器的像素坐标对变量进行线性插值。利用线性插值可以让像素点的颜色均匀渐变这一特点我们就能绘制出颜色更丰富的图形了。
好了到这里我们就在Canvas画布上用WebGL绘制出了一个三角形。绘制三角形的过程就像我们初学编程时去写出一个Hello World程序一样按道理来说应该非常简单才对。但事实上用WebGL完成这个程序我们一共用了好几十行代码。而如果我们用Canvas2D或者SVG实现类似的功能只需要几行代码就可以了。
那我们为什么非要这么做呢而且我们费了很大的劲就只绘制出了一个最简单的三角形这似乎离我们用WebGL实现复杂的可视化效果还非常遥远。我想告诉你的是别失落想要利用WebGL绘制更有趣、更复杂的图形我们就必须要学会绘制三角形这个图元。还记得我们前面说过的要在WebGL中绘制非图元的其他图形时我们必须要把它们划分成三角形才行。学习了后面的课程之后你就会对这一点有更深刻的理解了。
而且用WebGL可以实现的视觉效果远远超越其他三个图形系统。如果用驾驶技术来比喻的话使用SVG和Canvas2D时就像我们在开一辆自动挡的汽车那么使用WebGL的时候就像是在开战斗机所以千万别着急随着对WebGL的不断深入理解我们就能用它来实现更多有趣的实例了。
## 要点总结
在这一节课我们讲了WebGL的绘图过程以及顶点着色器和片元着色器的作用。
WebGL图形系统与用其他图形系统不同它的API非常底层使用起来比较复杂。想要学好WebGL我们必须要从基础概念和原理学起。
一般来说在WebGL中要完成图形的绘制需要创建WebGL程序然后将图形的几何数据存入数据缓冲区在绘制过程中让WebGL从缓冲区读取数据并且执行着色器程序。
WebGL的着色器程序有两个。一个是顶点着色器负责处理图形的顶点数据。另一个是片元着色器负责处理光栅化后的像素信息。此外我们还要牢记WebGL程序有一个非常重要的特点就是能够并行处理无论图形中有多少个像素点都可以通过着色器程序在GPU中被同时执行。
WebGL完整的绘图过程实在比较复杂为了帮助你理解我总结一个流程图供你参考。
[![](https://static001.geekbang.org/resource/image/d3/30/d31e6c50b55872f81aa70625538fb930.jpg?wh=1196*960 "WebGL绘图流程")](https://juejin.im/post/5e7a042e6fb9a07cb96b1627)
那到这里可视化的四个图形系统我们就介绍完了。但是好戏才刚刚开始哦在后续的文章中我们会围绕着这四个图形系统尤其是Canvas2D和WebGL逐渐深入来实现更多有趣的图形。
## 小试牛刀
1. WebGL通过顶点和图元来绘制图形我们在上面的例子中调用gl.TRIANGLES 绘制出了实心的三角形。如果要绘制空心三角形,我们又应该怎么做呢?有哪些图元类型可以帮助我们完成这个绘制?
2. 三角形是最简单的几何图形如果我们要绘制其他的几何图形我们可以通过用多个三角形拼接来实现。试着用WebGL绘制正四边形、正五边形和正六角星吧
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这一节课分享给你的朋友,我们下节课再见!
* * *
## 源码
[WebGL绘制三角形示例代码](https://github.com/akira-cn/graphics/tree/master/webgl)
## 推荐阅读
\[1\] [类型化数组 MDN 文档](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/TypedArray)
\[2\] [WebGL 的 MDN 文档](https://developer.mozilla.org/zh-CN/docs/Web/API/WebGL_API)