WebGL从零开始(三)—— 平移、旋转、缩放与矩阵
# 一、前置准备
平移、旋转、缩放是空间上面的常规操作,我们将以一个立方体来进行演练展示。
# 1. 数据准备
webgl的代码准备就不多说了,就是获取canvas->创建webgl对象->初始化着色程序->渲染绘制,可以参考 WebGL从零开始(一)——认识WebGL并绘制一个点,完整代码后续会附上。
构建一个立方体,以(0,0)点作为第一个点,以 WebGL 的坐标系的 0.5 作为其边长,则其
# 1.1 顶点坐标
const verticesLib = [
0.0, 0.0, 0.0,
0.0, 0.5, 0.0,
0.5, 0.5, 0.0,
0.5, 0.0, 0.0, // z轴为0 的四个顶点
0.0, 0.0, 0.5,
0.0, 0.5, 0.5,
0.5, 0.5, 0.5,
0.5, 0.0, 0.5, // z轴为0.5 的四个顶点
]
2
3
4
5
6
7
8
9
10
11
顶点之后就是线,线用的是线段 LINES,两个点作为连接,一个立方体是12条边,那么就需要12对顶点坐标。
# 1.2 12条边的顶点索引
然后根据边的顶点索引 创建绘制边所需的顶点坐标
const indices = [
0,1,
1,2,
2,3,
3,0, // 底四边
0,4,
1,5,
2,6,
3,7, // 四个柱边
4,5,
5,6,
6,7,
7,4, // 上四边
];
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 顶点索引
顶点索引的概念就是,顶点索引集合(上面的indices)中的每个数字,如 0 对应顶点坐标集合(verticesLib)中的第一个点的坐标,也就是(0.0, 0.0, 0.0)。
# 1.3 绘制边的顶点坐标
通过遍历顶点索引,生成新的顶点坐标集合
const borderVertices = []; // 边所需的顶点坐标
indices.forEach(item => {
const i = item * 3; // 对应顶点的 x 坐标
borderVertices.push(
verticesLib[i],
verticesLib[i + 1],
verticesLib[i + 2]
)
});
2
3
4
5
6
7
8
9
# 通过WebGL api实现根据顶点索引自动处理顶点坐标
const verticesLib = new Float32Array([
0.0, 0.0, 0.0,
0.0, 0.5, 0.0,
0.5, 0.5, 0.0,
0.5, 0.0, 0.0, // z轴为0 的四个顶点
0.0, 0.0, 0.5,
0.0, 0.5, 0.5,
0.5, 0.5, 0.5,
0.5, 0.0, 0.5, // z轴为0.5 的四个顶点
])
const indices = new Unit8Array([
0,1,
1,2,
2,3,
3,0, // 底四边
0,4,
1,5,
2,6,
3,7, // 四个柱边
4,5,
5,6,
6,7,
7,4, // 上四边
]);
// 后面写入缓冲数据的时候就不用 new Float32Array(borderVertices),而直接先用 verticesLib
// gl.bufferData(gl.ARRAY_BUFFER, verticesLib, gl.STATIC_DRAW);
// 然后再新增顶点索引缓冲区,并绑定顶点索引缓冲区,注意这里的类型用的是 gl.ELEMNT_ARRAY_BUFFER
const indicesBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMNT_ARRAY_BUFFER, indicesBuffer)
gl.bufferData(gl.ELEMNT_ARRAY_BUFFER, indices, gl.STATIC_DRAW)
// 绘图的时候就不用 drawArrays 方法了,改为 drawElemnts(mode, count, type, offset)
gl.drawElemnts(gl.LINES, indices.length, gl.UNSIGNED_BYTE, 0);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 2. 渲染
# 2.1 通过缓冲数据进行写入渲染
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
// 变量名
const a_Position = gl.getAttribLocation(gl.program, 'a_Position');
// 顶点属性
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(a_Position);
// 写入数据
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(borderVertices), gl.STATIC_DRAW);
// 修改顶点数据
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.LINES, 0, indices.length);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 2.2 效果图:
# 2.3 完整代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>动态绘制点</title>
</head>
<body>
<canvas id='webgl' width='500' height='500'></canvas>
<script id="vertex-shader-source" type="notjs">
attribute vec4 a_Position;
void main() {
gl_Position = a_Position;
gl_PointSize = 10.0;
}
</script>
<script id="fragment-shader-source" type="notjs">
precision mediump float;
void main() {
gl_FragColor = vec4(1, 0, 0.5, 1);
}
</script>
<script>
let canvas = document.getElementById('webgl');
let gl = canvas.getContext('webgl');
if (!gl) {
alert('您的浏览器不支持WebGL!');
// …… 其他操作
}
const vsSource = document.querySelector('#vertex-shader-source').text
const fsSource = document.querySelector('#fragment-shader-source').text
initProgram(gl, vsSource, fsSource);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
const verticesLib = [
0.0, 0.0, 0.0,
0.0, 0.5, 0.0,
0.5, 0.5, 0.0,
0.5, 0.0, 0.0, // z轴为0 的四个顶点
0.0, 0.0, 0.5,
0.0, 0.5, 0.5,
0.5, 0.5, 0.5,
0.5, 0.0, 0.5, // z轴为0.5 的四个顶点
];
const indices = [
0, 1,
1, 2,
2, 3,
3, 0, // 底四边
0, 4,
1, 5,
2, 6,
3, 7, // 四个柱边
4, 5,
5, 6,
6, 7,
7, 4, // 上四边
];
const borderVertices = []; // 边所需的顶点坐标
indices.forEach(item => {
const i = item * 3; // 对应顶点的 x 坐标
borderVertices.push(
verticesLib[i],
verticesLib[i + 1],
verticesLib[i + 2]
)
});
// 缓冲区 及绑定
const pointBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, pointBuffer);
const a_Position = gl.getAttribLocation(gl.program, 'a_Position');
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, 0, 0);
// 开启对应属性
gl.enableVertexAttribArray(a_Position);
// 写入数据
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(borderVertices), gl.STATIC_DRAW);
gl.drawArrays(gl.LINES, 0, indices.length);
// 初始化着色程序
function initProgram(gl, vertexShaderSource, fragmentShaderSource) {
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource)
var program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
var success = gl.getProgramParameter(program, gl.LINK_STATUS);
if (success) {
gl.useProgram(program);
gl.program = program; // 挂在到gl对象上,便于后续使用
return program;
}
console.log(gl.getProgramInfoLog(program));
gl.deleteProgram(program);
}
// 创建Shader
function createShader(gl, type, source) {
const shader = gl.createShader(type); // 创建着色器对象
gl.shaderSource(shader, source); // 提供数据源
gl.compileShader(shader); // 编译 -> 生成着色器
const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (success) {
return shader;
}
console.log(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
}
</script>
</body>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# 2.4 为什么看到的是平面?
这是因为本篇使用的是默认的正交投影,顶点的坐标没有任何投影矩阵和视图矩阵(gl_Position = a_Position;),正交投影是从 z 轴“往下”看,并且没有近大远小,所以我们只能看到上四边,下面的棱柱、底边都被遮住了。
# 二、平移
# 1. 平移的原理
平移在数学上极其简单:
如果要把物体沿 X 轴移动 Tx,沿 Y 轴移动 Ty,沿 Z 轴移动 Tz,那么对于顶点 (x, y, z),它的新位置就是:
新X = x + Tx 新Y = y + Ty 新Z = z + Tz
在三维空间中,物体的每一个顶点都可以看作一个位置向量 $\vec{P}$=(x,y,z)。平移就是要把它变成一个新的位置向量 $\vec{P′}$。
平移的数学公式极其简洁,就是向量加法:
$\vec{P'}$ = $\vec{P}$ + $\vec{T}$
其中,$\vec{T}$*=(*Tx,Ty,Tz) 就是平移向量(位移向量)。
# 2. GLSL代码实现
那么对应的就是我们将 gl_Position = a_Position; 的 a_Position 加一个偏移量,这里使用 gl 中提供的 vec* 方法来创建偏移量:
attribute vec4 a_Position;
vec3 u_Translation=vec3(-0.1,0.1,0.0); // 这里z设为0 暂时不进行偏移,因为目前还是正交投影图
void main() {
// 平移就是 向量加法
gl_Position = a_Position + vec4(u_Translation, 0.0); // vec4(u_Translation, 0.0) 就是我们的平移向量,这里的齐次坐标的第四个值 w 为 0.0 是可以的,后续用矩阵的话就不能是 0.0了
}
2
3
4
5
6
# 3. JS控制平移
我们可以将GLSL中的平移向量通过 属性 暴露出去,然后通过js动态设置。
attribute vec4 a_Position;
attribute vec4 u_Translation; // 使用 attribute 暴露 或者使用 uniform 暴露
void main() {
// 平移就是 向量加法
gl_Position = a_Position + u_Translation;
}
2
3
4
5
6
然后使用gl.vertexAttrib3f更改u_Translation来更新顶点坐标。
// 通过 gl.getAttribLocation 来获取 u_Translation 变量对象
const u_Translation = gl.getAttribLocation(gl.program, 'u_Translation');
// 通过 requestAnimationFrame 来改变平移位置
let deltaY = 0.01;
!(function ani(){
deltaY += 0.01;
if(deltaY > 1.0) {
deltaY = -1.0;
}
gl.vertexAttrib4f(u_Translation, 0.0, deltaY, 0.0, 0.0); // vertexAttrib4f 之前有讲过,第一个参数是 Attribute变量对象,后续参数为方法名后面的数量,如这里的4f,指的是 4个浮点参数
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.LINES, 0, indices.length);
requestAnimationFrame(ani);
})()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 三、旋转
# 1. 旋转的原理
旋转在数学上需要借助三角函数(初中数学)
首先要知道旋转轴,例如绕 z 轴旋转(z坐标不变)
然后是旋转方向:顺时针(旋转角度是正的)、逆时针(旋转角度是负的)
最后是旋转角度:如旋转 θ 角度
以点 A(x,y,z) 为例,以原点(0,0,0) 为旋转点,绕z轴旋转 θ 角度后的 B(x', y', z) 点,计算公式:
Bx‘ = Ax × cosθ − Ay × sinθ
By'= Ax × sinθ + Ay × cosθ
图解:

用到了三角函数的基本定义:直角三角形中 sinA = $\frac{对边}{斜边}$ cosA = $\frac{邻边}{斜边}$ tanA = $\frac{对边}{邻边}$
以及三角函数的两角和差公式:
sin(α + β)= sinα cosβ + cosα sinβ
cos(α + β)= cosα cosβ − sinα sinβ
# 2. GLSL代码实现
- 旋转轴; 2. 旋转方向; 3. 旋转角度; 绕z轴旋转30° 向量实现旋转:(是以(0.0)点进行旋转) 目标的x = 初始点的xcos(角度) - 初始点的ysin(角度) 目标的y = 初始点ycos(角度) + 初始点xsin(角度) 目标的z = 初始点的z (二维)
// 顶点着色器
attribute vec4 a_Position;
float a_angle=radians(90.0);
float sinB = sin(a_angle);
float cosB = cos(a_angle);
void mian () {
gl_Position = vec4(
a_Position.x*cosB - a_Position.y*sinB,
a_Position.y*cosB + a_Position.x*sinB,
a_Position.z, a_Position.w
);
}
2
3
4
5
6
7
8
9
10
11
12
13
或者用 二阶旋转矩阵来乘以a_Position的x,y:
// 顶点着色器
attribute vec4 a_Position;
float a_angle=radians(90.0);
float sinB = sin(a_angle);
float cosB = cos(a_angle);
// webgl 中是列矩阵
mat2 rotate = mat2(
cosB, sinB,
-sinB, cosB
);
void mian () {
gl_Position = vec4(
rotate*vec2(a_Position),
a_Position.z, a_Position.w
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 3. JS控制旋转
我们可以把 a_angle 通过 attribute 变量暴露出去,然后通过 gl.getAttribLocation 获取,再通过 gl.vertexAttrib1f 来修改弧度值。
// 通过 gl.getAttribLocation 来获取 a_angle 变量对象
const a_angle = gl.getAttribLocation(gl.program, 'a_angle');
// 通过 requestAnimationFrame 来改变旋转的角度
let deltaY = 0.01;
!(function ani(){
deltaY += 0.01;
if(deltaY > 1.0) {
deltaY = -1.0;
}
gl.vertexAttrib1f(a_angle, deltaY);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.LINES, 0, indices.length);
requestAnimationFrame(ani);
})()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 四、缩放
# 1.缩放的原理
模型缩放的原理起始很简单,就是模型顶点坐标 x,y,z 乘以一个缩放系数。
# 2.GLSL代码实现
// 顶点着色器
attribute vec4 a_Position;
float scale = 1.5;
void main () {
gl_Position = vec4(
scale*vec3(a_Position),
a_Position.w
);
}
2
3
4
5
6
7
8
9
# 五、矩阵
从旋转那儿可以看出,我们对 a_Position 的改变,可以用矩阵来实现,一个 二维矩阵*二维向量 得到新的二维向量,同样的一个 四维矩阵*四维向量得到新的四维向量。
# 1.矩阵平移
数学中的矩阵是行矩阵,矩阵*向量(同阶) = 矩阵每一行*向量对应的列。如项目的单位矩阵和向量P(1,3,5)
$$\left(\begin{array}{ccc} 1 & 0 & 0 \ 0 & 1 & 0 \ 0 & 0 & 1 \end{array}\right)$$
第一行矩阵元素乘以向量第一列得到
x = 1*1 + 0*1 + 0*1 = 1
第二行矩阵元素乘以向量第二列得到
y = 0*3 + 1*3 + 0*3 = 3
第三行矩阵元素乘以向量第三列得到
z = 0*5 + 0*5 + 1*5 = 5
最终得到新向量 (1,3,5)
WebGL的矩阵是列矩阵,矩阵*向量(同阶) = 矩阵每一列*向量对应的列。 步骤类似行矩阵。
那么平移的矩阵在WebGL中就可以写成:
attribute vec4 a_Position;
float tx = 0.5;
float ty = 0.5;
float tz = 0.5;
mat4 translate = mat4(
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
);
void main() {
// 矩阵 乘以 顶点坐标(向量)
gl_Position = translate*a_Position;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 2.矩阵旋转
attribute vec4 a_Position;
float a_angle=radians(90.0);
float sinB = sin(a_angle);
float cosB = cos(a_angle);
mat4 translate = mat4(
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
);
void main() {
// 矩阵 乘以 顶点坐标(向量)
gl_Position = translate*a_Position;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 3.矩阵缩放
attribute vec4 a_Position;
float sx = 1.5;
float sy = 1.5;
float sz = 1.5;
mat4 translate = mat4(
sx, 0.0, 0.0, 0.0,
0.0, sy, 0.0, 0.0,
0.0, 0.0, sz, 0.0,
0.0, 0.0, 0.0, 1.0
);
void main() {
// 矩阵 乘以 顶点坐标(向量)
gl_Position = translate*a_Position;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 4.复合矩阵
平移可以和缩放或旋转合在一个矩阵中,缩放和旋转矩阵需要乘以后才行
attribute vec4 a_Position;
float tx = 0.5;
float ty = 0.5;
float tz = 0.5;
float sx = 0.5;
float sy = 0.5;
float sz = 0.5;
float a_angle=radians(0.0);
float sinB = sin(a_angle);
float cosB = cos(a_angle);
// 平移和旋转合在一个矩阵中
mat4 translate = mat4(
cosB, sinB, 0.0, 0.0,
-sinB, cosB, 0.0, 0.0,
0.0, 0.0, sz, 0.0,
tx, ty, tz, 1.0
);
// 缩放矩阵
mat4 scale = mat4(
sx, 0.0, 0.0, 0.0,
0.0, sy, 0.0, 0.0,
0.0, 0.0, sz, 0.0,
0.0, 0.0, 0.0, 1.0
);
// 最终变化矩阵 = 平移旋转矩阵*缩放矩阵
mat4 matrix = translate*scale;
void main() {
// 最终变化矩阵 乘以 顶点坐标(向量)
gl_Position = matrix*a_Position;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 六、本篇涉及的WebGL-API
本篇主要讲顶点的位置变换实现,通过绘制一个立方体来直观感受模型的平移、旋转和缩放。
绘制立方体使用了缓冲区,并且介绍了顶点索引,以及通过WebGL的api来实现根据顶点索引自动计算顶点坐标的方式。
顶点索引缓冲区的类型不同于顶点缓冲区,使用的是 gl.ELEMENT_ARRAY_BUFFER。
使用顶点索引缓冲区来绘制图形:drawElemnts(mode, count, type, offset)
- mode:绘图方式,同 drawArrays 一样
- count:元素数量
- type:缓冲区数据的类型
- offset:当前系列的字节索引位