衔接上一篇:第二章:WebGL入门
收获
当你学习完下面内容后你会有如下收获:
- 理解缓冲器对象、将多个顶点信息一次性传入顶点着色器。
- 利用顶点坐标按照不同的规则绘制图形。如:三角形、三角带、三角扇等。
- 如何平移、旋转、缩放图形。
- 矩阵、如何通过矩阵代替数学表达式、平移矩阵、旋转矩阵、缩放矩阵。
1.一次性绘制多个顶点
前一章节我们实现了通过在鼠标点击的位置绘制点,实现的思路是将每次点击的坐标传入一个数组中point[]
。然后遍历该数组,每次遍历就向着色器传入一个点,并调用gl.drawArrays
将这个点绘制出来。显然,这些方法只能绘制一个点。对那些由多个顶点组成的图形,比如三角形、矩形和立方体来说,你需要一次性地将图形的顶点全部传入顶点着色器,然后把图形画出来。
为此WebGL提供一种很方便的机制,即缓冲区对象。我们可以使用缓冲区对象来实现将多个顶点值传入顶点着色器中并渲染。实现的步骤与抽象图如下:

- 创建缓冲对象
- 绑定缓冲区对象
- 将数据写入缓冲区对象
- 将缓冲区对象分配给
attribute
变量 - 开启
attribute
变量
注:看不懂图和步骤没有关系后面会有讲解,只需要知道个大概就行。
1.1 什么缓冲区对象
缓冲区对象可以一次性地向着色器传入多个顶点的数据。缓冲区对象是WebGL系统中的一块内存区域,我们可以一次性地向缓冲区对象中填充大量的顶点数据,然后将这些数据保存在其中,供顶点着色器使用。
1.2 创建缓冲区对象
使用WebGL时,你需要调用gl.createBuffer()
方法来创建缓冲区对象。
js
const vertexBuffer = gl.createBuffer();
代码执行后,会在WebGL中开辟一块内存。如下图所示:
创建前

创建后

这里的gl.ARRAY_BUFFER
与gl.ELEMENT_ARRAY_BUFFER
现在可以暂时忽略。
1.3 绑定缓冲区
第二步我们需要将上一步创建的缓冲区对象绑定到WebGL系统中已经存在的"目标"上。这个"目标"表示新建缓冲区对象的用途,也就是上面提到的gl.ARRAY_BUFFER
。
gl.ARRAY_BUFFER
:是WebGL中的一种缓冲区类型,它用于存储顶点数据。
gl.ELEMENT_ARRAY_BUFFER
:是WebGL中用于存储索引数据(元素数组)的缓冲区对象类型。
注 :将gl.ARRAY_BUFFER
与新建的缓冲区对象绑定是为了告诉WebGL上下文,我们要使用这个新建的缓冲区对象来存储顶点数据。
js
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);

绑定之后,WebGL系统内部状态发生了变化如下图:

1.4 向缓冲区中写入顶点数据
准备三个顶点坐标
js
const vertices = new Float32Array([
0.0, 0.5,
-0.5, -0.5,
0.5, -0.5
])
将准备好的数据写入到缓冲器的"目标"中。
js
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

该方法的效果是,将第2个参数vertices
中的数据写入了绑定到第1个参数gl.ARRAY_BUFFER
上的缓冲区对象。我们不能直接向缓冲区写入数据,而只能向"目标"写入数据,所以要向缓冲区写数据,必须先绑定。该方法执行之后,WebGL系统的内部状态如下图所示:

1.5 将缓冲区对象分配给attribute变量
我们前一章使用的是vertexAttrib[1234]f
系列函数为attribute
变量分配值,但是这些方法一次只能向attribute
变量分配(传值)一个值。而现在,需要将数组中所有的顶点值一次性分配给attribute
。 使用gl.vertexAttribPointer()
方法,它可以将整个缓冲区对象(实际上是缓冲区对象的引用和指针)分配给attribute
变量。
js
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);

WebGL系统的内部状态如下图所示:

1.6 激活attribute变量
为了使顶点着色器能够访问缓冲区内的数据,我们需要使用gl.enablevertexAttribArray()
方法来激活attribute变量。
js
gl.enableVertexAttribArray(a_Position);

当你执行gl.enableVertexAttribArray()
并传入一个已经分配好缓冲区的attribue
变量后,我们就开启了该变量,也就是说,缓冲区对象和 attribute变量之间的连接就真正建立起来了,如下图所示:

1.7 完整代码实现
注:
- 为了整洁美观这里将顶点着色器 和片元着色器 的代码放入的
script
标签中,然后通过innerHTML
获取字符串代码。与直接使用字符串效果上无异。 - 这里我们是一次性绘制3个顶点了,所以
drawArrays()
方法的第三个参数应该为3。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./utils.js"></script>
<style>
body {
margin: 0;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<!-- 顶点着色器 -->
<script id="vertexShader" type="x-shader/x-vertex">
attribute vec4 a_Position;
void main () {
gl_Position = a_Position;
gl_PointSize = 100.0;
}
</script>
<!-- 片元着色器 -->
<script id="fragmentShader" type="x-shader/x-fragment">
void main () {
gl_FragColor = vec4(1.0, 1.0, 0, 1.0);
}
</script>
<script>
const canvas = document.getElementById('canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const gl = canvas.getContext('webgl');
const vsSource = document.getElementById('vertexShader').innerHTML;
const fsSource = document.getElementById('fragmentShader').innerHTML;
const vertices = new Float32Array([0.0, 0.5, -0.5, -0.5, 0.5, -0.5]);
initShader(gl, vsSource, fsSource);
const a_Position = gl.getAttribLocation(gl.program, 'a_Position');
// 创建缓冲区对象
const vertexBuffer = gl.createBuffer();
// 绑定缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
// 向缓冲区中写入顶点数据
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// 将缓冲区对象分配给attribute变量
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
// 激活attribute变量
gl.enableVertexAttribArray(a_Position);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
// TODO:这里drawArrays为绘制的顶点个数,之前是1。此次我们顶点的个数是3个
gl.drawArrays(gl.POINTS, 0, 3);
</script>
</body>
</html>
这里的utils.js
在第二章:WebGL入门这篇文章中有写到,暂时不需要我们理解里面的内容,用就好了。

顶点着色器执行过程中缓冲区数据的传输过程图如下:

2.基本图形
现在,你已经学会了如何将多个顶点的坐标数据一次性传递给顶点着色器,下面我们将尝试绘制一个真正的图形(而不是单个的点)。
WebGL方法gl.drawArrays()
既强大又灵活,通过给第1个参数指定不同的值,我们能就够以7种不同的方式来绘制图形。


这些基本图形的效果如下:

2.1 绘制三角形
绘制三角形其实非常简单,我们只需要对上一份代码稍作修改就能绘制出一个三角形。
首先绘制面的时候不需要我们指定顶点的大小,所以我们需要将其注释掉:
ini
<script id="vertexShader" type="x-shader/x-vertex">
attribute vec4 a_Position;
void main () {
gl_Position = a_Position;
// gl_PointSize = 100.0;
}
</script>
第二步将gl.drawArrays()
方法的第一个参数从gl.POINTS
改为gl.TRIANGLES
。告诉WebGL我们需要绘制三角面。
js
gl.drawArrays(gl.TRIANGLES, 0, 3);
绘制后的效果如下:

注意:面是有正反两个面的。
- 面向我们的面,如果是正面 ,那它必然是逆时针绘制的。
- 面向我们的面,如果是反面 ,那它必然是顺时针绘制的。

2.2 绘制线段
注:线段是单独的,如果顶点的个数是奇数,最后一个顶点将会被忽略。
js
const vertices = new Float32Array([
0.0, 1.0,
0.0, -1.0,
-1.0, 0.0,
1.0, 0.0
]);
js
gl.drawArrays(gl.LINES, 0, 4);

2.3 绘制线条
注:第一个顶点为第一条线段的起点,第二个顶点为第一条线段的终点,即第二条线段的起点。线段与线段之间首尾相连就成了线条。
js
const vertices = new Float32Array([
-0.5, -0.5,
0.0, 0.5,
0.5, -0.5
]);
js
gl.drawArrays(gl.LINE_STRIP, 0, 3);

2.4 绘制回路
注:最后一个顶点会连接最开始的顶点。
js
const vertices = new Float32Array([
-0.5, 0.5,
-0.5, -0.5,
0.5, -0.5,
0.5, 0.5
]);
js
gl.drawArrays(gl.LINE_LOOP, 0, 4);

2.5 绘制三角带
js
const vertices = new Float32Array([
-0.5, 0.3,
-0.4, -0.3,
-0.3, 0.3,
-0.2, -0.3,
-0.1, 0.3,
0.0, -0.3
]);
js
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 6);

上面四个面的绘制顺序是:
-
橙色:v0 -> v1 -> v2
-
绿色:v2 -> v1 -> v3
-
蓝色:v2 -> v3 -> v4
-
紫色:v4 -> v3 -> v5
绘制顺序的规律是:
第一个三角形正常绘制v0 -> v1 -> v2
第偶数个三角形:以上一个三角形的第二条边 + 下一个点为基础,以和第二条边相反的方向绘制三角形。
第奇数个三角形:以上一个三角形的第三条边 + 下一个点为基础,以和第三条边相反的方向绘制三角形。
2.6 绘制三角扇
js
const vertices = new Float32Array([
0.0, 0.0,
0.35, 0.1,
0.4, 0.3,
0.35, 0.5,
0.25, 0.6,
0.1, 0.6
]);
js
gl.drawArrays(gl.TRIANGLE_FAN, 0, 6);

上面4个三角形绘制的顺序是:
- 橙色:v0 -> v1 -> v2
- 绿色:v0 -> v2 -> v3
- 蓝色:v0 -> v3 -> v4
- 紫色:v0 -> v4 -> v5
绘制顺序的规律是:
第一个三角形正常绘制v0 -> v1 -> v2
后面的三角形:以上一个三角形的第三条边 + 下一个点为基础,以和第三条边相反的方向绘制三角形。
2.7 为什么要注意绘制顺序
如果你对上面为什么要提到绘制顺序而感到不解的话那就来看看下面的例子吧:
需求:使用三角带绘制一个正方形。
如果你在定义顶点的时候不考虑三角带的绘制顺序的话就会很容易陷入错误中,像我们在黑板上绘制正方形你一般会像下面一样取点:

当你这样取顶点时,我们看看绘制出来是什么样子吧
js
const vertices = new Float32Array([
0.2, 0.2, // v0
0.2, -0.2,// v1
-0.2, -0.2,// v2
-0.2, 0.2// v3
]);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

发现按照我们所想绘制出来的却不是正方形,这是为什么呢?我们可以用三角带的绘制顺序来解释。

上面两个三角形的绘制顺序如下:
- 橙色:v0 -> v1 -> v2
当绘制第二个三角形时,三角带会以上一个三角形的第二条边 + 下一个点为基础,以和第二条边相反的方向绘制三角形。
- 绿色:v2 -> v1 -> v3
所以我们在绘制图形时一定要注意绘制的顺序,这里我们只需要将第二边作为公共边就行了。
js
const vertices = new Float32Array([
0.2, -0.2,// v1
0.2, 0.2, // v0
-0.2, -0.2,// v2
-0.2, 0.2// v3
]);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

3.移动、旋转和缩放
现在,你已经掌握了绘制图形(如三角形和矩形)的方法。接下来我们将尝试平移、旋转和缩放三角形。这样的操作称为变换(transformations) 或仿射变换(affine transformations)
3.1 平移
如果我们想要平移图形的话需要对顶点坐标的每个分量(x和y),加上三角形在对应轴(如X轴或Y轴)上的平移距离。比如,将点p(x, y, z)平移到p' (x',y', z'),在X轴、Y轴、Z轴三个方向上平移的距离分别为Tx,Ty,Tz,其中Tz为0,如图下图所示。

注 :在GLSL ES
语言中,是可以直接进行向量运算的。
js
<script id="vertexShader" type="x-shader/x-vertex">
attribute vec4 a_Position;
// 平移向量
vec4 u_Translation = vec4(0, 0.2, 0, 0);
void main () {
gl_Position = a_Position + u_Translation;
}
</script>
下面我们将通过用JavaScript
来动态修改平移向量值。
js
<script id="vertexShader" type="x-shader/x-vertex">
attribute vec4 a_Position;
uniform vec4 u_Translation;
void main () {
gl_Position = a_Position + u_Translation;
}
</script>
这里u_Translation
用的是uniform
而不是attribute
呢?因为attribute绑定的数据是跟顶点相关的,而u_Translation
与顶点数据没有直接相关。
js
// 获取u_Translation变量的存储位置
const u_Translation = gl.getUniformLocation(gl.program, 'u_Translation');
// 传递平移数据
gl.uniform4f(u_Translation, 0, 0.7, 0, 0);
下面的代码是将一个三角形向上平移0.7
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body {
margin: 0;
}
</style>
<script src="./utils.js"></script>
</head>
<body>
<canvas id="canvas"></canvas>
<script id="vertexShader" type="x-shader/x-vertex">
attribute vec4 a_Position;
uniform vec4 u_Translation;
void main () {
gl_Position = a_Position + u_Translation;
}
</script>
<script id="fragmentShader" type="x-shader/x-fragment">
void main () {
gl_FragColor = vec4(1.0, 1.0, 0, 1.0);
}
</script>
<script>
const canvas = document.getElementById('canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const vsSource = document.getElementById('vertexShader').innerHTML;
const fsSource = document.getElementById('fragmentShader').innerHTML;
const gl = canvas.getContext('webgl');
const vertices = new Float32Array([0, 0.3, -0.1, 0, 0.1, 0]);
initShader(gl, vsSource, fsSource);
const a_Position = gl.getAttribLocation(gl.program, 'a_Position');
// 获取u_Translation变量的存储位置
const u_Translation = gl.getUniformLocation(gl.program, 'u_Translation');
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(a_Position);
// 传递平移数据
gl.uniform4f(u_Translation, 0, 0.7, 0, 0);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
</script>
</body>
</html>

3.2 旋转
旋转比平移稍微复杂一些,为了描述一个旋转,你必须指名:
- 旋转轴
- 旋转方向
- 旋转角度
在本节中我们这样来表述旋转操作:Z轴,逆时针旋转了β角度。这种表述方式同样适用于绕X轴和Y轴的情况。
旋转方向
- 当物体绕Z轴,从X轴正半轴向Y轴的正半轴逆时针旋转时。这种情况又称正旋转,反之为负。
- 当物体绕X轴,从Y轴正半轴向Z轴的正半轴逆时针旋转时。这种情况又称正旋转,反之为负。
- 当物体绕Y轴,从Z轴正半轴向X轴的正半轴逆时针旋转时。这种情况又称正旋转,反之为负。

旋转角度
我们需要将三角形旋转180度核心代码如下:
js
<script id="vertexShader" type="x-shader/x-vertex">
attribute vec4 a_Position;
uniform float u_CosB, u_SinB;
void main () {
// 下面是x,y是旋转后的坐标位置,至于旋转后的值是按照公式得来的。公式的推导在下面图片中
gl_Position.x = a_Position.x * u_CosB - a_Position.y * u_SinB;
gl_Position.y = a_Position.y * u_CosB + a_Position.x * u_SinB;
gl_Position.z = a_Position.z;
gl_Position.w = 1.0;
}
</script>
js
const u_CosB = gl.getUniformLocation(gl.program, 'u_CosB');
const u_SinB = gl.getUniformLocation(gl.program, 'u_SinB');
// 旋转180度
const ANGLE = 180;
// 转为弧度制
const radian = (Math.PI * ANGLE) / 180;
gl.uniform1f(u_CosB, Math.cos(radian));
gl.uniform1f(u_SinB, Math.sin(radian));

下面是代码与图片中变量的对应关系:
a_Position.x
:xa_Position.y
:yu_CosB
:cosβu_SinB
:sinβ

3.3 缩放
缩放就很简单了,我们可以将缩放理解为对向量长度的改变,或者对向量坐标分量的同步缩放。
js
<script id="vertexShader" type="x-shader/x-vertex">
attribute vec4 a_Position;
uniform float scale;
void main () {
gl_Position.x = a_Position.x * scale;
gl_Position.y = a_Position.y * scale;
gl_Position.z = a_Position.z;
gl_Position.w = 1.0;
}
</script>
通过JS修改顶点着色器的值
js
const scale = gl.getUniformLocation(gl.program, 'scale');
// 缩小0.5倍
gl.uniform1f(scale, 0.5);

4.矩阵
矩阵是数学中的一种术语。在数学中矩阵(Matrix)是一个按照长方阵列排列的复数或实数集合,可以长下面这样数字按照行(水平方向)和列(垂直方向)排列,数字两侧的方括号表示这些数字是一个整体(一个矩阵)。

我们为什么要学习矩阵呢?
因为在WebGL中,矩阵是一种非常重要的数学概念,可以用于进行3D图形的变换和投影。像我们上面的平移 、旋转 、缩放我们都可以用矩阵来表示前面的计算过程。
优势:
- 组合性和复合变换:通过将变换表示为矩阵并执行矩阵乘法,可以轻松地组合多个变换。例如,你可以先进行平移,然后旋转,最后再进行缩放。这种组合变换的方式在矩阵中可以简单地实现,而不需要对每个变换操作单独处理。
- 数学优势:矩阵具有很好的数学性质,例如矩阵乘法满足结合律。这使得它们在进行复杂的图形计算时非常有用。使用矩阵还能够更清晰地表达一些图形变换操作,特别是对于复杂的3D变换。
- 性能优化:使用矩阵进行变换可以利用现代图形硬件的优化特性。许多图形库和API都专门针对矩阵变换进行了优化,使得图形渲染更加高效和快速。
矩阵与矢量相乘
为了能够看懂后面的操作我们还需要先理解一些矩阵与矢量的乘法(如下图),矢量是由多个分量组成的对象,比如顶点的坐标(0.0,0.5,1.0)。

其中中间的3行3列被称为3×3矩阵,矩阵右边的是由x,y,z分量组成的三维矢量。当矩阵与矢量相乘后会产生一个新的矢量,也就是图中的x'、y'、z'。其值的等式如下:

相信聪明的你找到等式中的规律,这里我就不用文字描述了。
注意:
- 只有在矩阵的列数和矢量的行数相等时,才可以将两者相乘。
- 矩阵的乘法不符合交换律,也就是说,A×B和B×A并不相等。
ok接下来我们将利用变换矩阵来替代之前的数学表达式,来对图形进行平移、旋转和缩放。
4.1 旋转矩阵
计算出绕Z轴旋转任意角度后新矢量值的公式如下:

那么我们如何使用矩阵来代替这些等式呢?如下图:

在WebGL中常常使用4×4矩阵来做来处理变换,我们可以将上面的3×3矩阵转换成4×4矩阵。只需要注意矢量的第四个分量w
是1.0即可,然后通过上面的流程即可得到4×4的旋转矩阵。
实现代码
js
const ANGLE = 180;
const radian = (Math.PI * ANGLE) / 180;
const cosB = Math.cos(radian), sinB = Math.sin(radian);
// 注意:WebGL中矩阵是列主序的,也就是你计算出来的矩阵行要放在WebGL矩阵中的列上。
const rotateMatrix = new Float32Array([
cosB, sinB, 0.0, 0.0,
-sinB, cosB, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0,
])
修改顶点着色器,其中mat4
是一种4x4矩阵的数据类型。对应的还有mat3
、mat2
等
js
<script id="vertexShader" type="x-shader/x-vertex">
attribute vec4 a_Position;
uniform mat4 u_RotateMatrix;
void main () {
// 注意矩阵的乘法是不符合交换率的,应该写成u_RotateMatrix(矩阵)* a_Position(矢量)而不是a_Position(矢量)* u_RotateMatrix(矩阵)
gl_Position = u_RotateMatrix * a_Position;
}
</script>
传值
js
const u_RotateMatrix = gl.getUniformLocation(gl.program, 'u_RotateMatrix');
gl.uniformMatrix4fv(u_RotateMatrix, false, rotateMatrix);


4.2 平移矩阵
同样的平移的计算公式如下:

将平移公式的表达式转换成4×4矩阵的过程如下:

实现代码
js
<script id="vertexShader" type="x-shader/x-vertex">
attribute vec4 a_Position;
uniform mat4 u_TranslationMatrix;
void main () {
// 注意矩阵的乘法是不符合交换率的,应该写成 矩阵*矢量 而不是 矢量*矩阵
gl_Position = u_TranslationMatrix * a_Position;
}
</script>
const Tx = 0.0, Ty = 0.7, Tz = 0.0;
const translationMatrix = new Float32Array([
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
Tx, Ty, Tz, 1.0,
])
const u_TranslationMatrix = gl.getUniformLocation(gl.program, 'u_TranslationMatrix');
gl.uniformMatrix4fv(u_TranslationMatrix, false, translationMatrix);

4.3 缩放矩阵
缩放的计算公式如下:

将缩放公式的表达式转换成4×4矩阵的过程如下:

实现代码
js
<script id="vertexShader" type="x-shader/x-vertex">
attribute vec4 a_Position;
uniform mat4 u_ScaleMatrix;
// 注意矩阵的乘法是不符合交换率的,应该写成 矩阵*矢量 而不是 矢量*矩阵
void main () {
gl_Position = u_ScaleMatrix * a_Position;
}
</script>
const scale = 2.0
const scaleMatrix = new Float32Array([
scale, 0.0, 0.0, 0.0,
0.0, scale, 0.0, 0.0,
0.0, 0.0, scale, 0.0,
0.0, 0.0, 0.0, 1.0,
])
const u_ScaleMatrix = gl.getUniformLocation(gl.program, 'u_ScaleMatrix');
gl.uniformMatrix4fv(u_ScaleMatrix, false, scaleMatrix);
