【WebGL之巅】29-层次模型-单关节模型

By 大Van家 on 2021-08-08
阅读时间 26 分钟
文章共 5.5k
阅读量

对应《WebGL编程指南》第九章 43-jointModel

要点:多个简单模型组成的复杂模型、层次结构模型、单关节模型

知识点

一、多个简单模型组成的复杂模型

1.1 引入

我们已经知道如何平移、旋转简单的模型,比如二维的三角形或三维的立方体。但是,实际用到的很多三维模型,如3D游戏中的人物角色模型等,都是由多个小的立方体模型组成的。

绘制由多个小部件组成的复杂模型,最关键的问题是如何处理模型的整体移动,以及各个小部件间的相对移动。这一节就来研究这个问题。首先,考虑一下人类的手臂:从肩部到之间,包括上臂、前臂、手掌和手指,如下图所示:
手臂的结构和可能的运动

手臂的每个部分可以围绕关节运动,如上图所示:

  • 上臂可以绕肩关节旋转运动,并带动前臂、手掌和手指一起运动。
  • 前臂可以绕肘关节运动,并带动手掌和手指一起运动,但不影响上臂。
  • 手掌绕腕关节运动,并带到手指一起运动,但不影响上臂和前臂
  • 手指运动不影响手臂、前臂和手掌

总之,当手臂的某个部位运动时,位于该部位以下的其他部位会随之一起运动,且位于该部位以上的其他部位不受影响。此外,这里的所有运动,都是围绕某个关节的转动。

1.2 层次结构模型

绘制机器人手臂这样一个复杂的模型,最常用的方法就是按照模型中各个部件的层次关系从高到低逐一绘制,并在每个关节上应用模型矩阵

注意,三维模型和现实中的人类或机器人不一样,它的部件并没有真正连接在一起。如果直接转动上臂,那么肘部以下的部分,包括前臂、手掌和手指,只会留在原地,这样手臂就断开了。所以,当上臂绕肩关节转动时,你需要在实现”肘部以下部分跟随上臂转动的逻辑“。具体地,上臂绕肩关节转动了多少度,肘部以下部分也应该绕肩关节转动多少度。

当情况较为简单时,实现”部件A转动带动部件B转动“可以很直接,只要对部件B也施以部件A的旋转矩阵即可。比如,使用模型矩阵使上臂绕肩关节转动30度,然后在绘制肘关节以下的各部位时,为它们施加同一个模型矩阵,也令其绕肩关节转动30度,如下图所示。这样,肘关节以下的部分就能自动跟随上臂转动了。

肘部以下部分跟随上臂转动

如果情况更复杂一些,比如先使上臂绕肩关节转动30度,然后使前臂绕肘关节转动10度,那么对肘关节以下的部分,你就得先施加上臂绕肩关节转动30度的矩阵,然后再施加前臂绕肘关节转动10度的矩阵。将这两个矩阵相乘,其结果可称为”肘关节模型矩阵“,那么在绘制肘关节以下部分的时候,直接应用这个所谓的”肘关节模型矩阵“作为模型矩阵就可以了。

按照上述方式编程,三维场景中的肩关节就能影响肘关节,使得上臂的运动带动前臂的运动;反过来,不管前臂如何运动都不会影响上臂。这就与现实中的情况相符合了。

二、单关节模型

2.1 层次模型分析

先来看一个单关节模型的例子。示例程序 JoinModel 绘制了一个仅由两个立方体部件组成的机器人手臂,其运行结果如下图左所示;手臂的两个部件为 arm1 与 arm2,arm1 接在 arm2 的上面,如图右所示。你可以把 arm1 想象成上臂,而把 arm2 想象成前臂,而肩关节在最下面。
jointModel模型中的层次结构

运行程序,用户可以使用左右方向键控制 arm1 水平转动,使用上下方方向键控制 arm2 绕 joint1 关节垂直转动。比如,先按下方向键,arm2 逐渐向前倾斜,然后按右方向键,arm1 向右旋转。

通过方向键控制操作jointModel中的模型

arm2 绕 joint1 的转动并不影响 arm1,而 arm1 的转动会带动 arm2 一起转动。

2.2 程序分析

和以前的程序相比,main()函数基本没有变化,主要的变化发生在 initVertexBuffers()函数中,它将 arm1 和 arm2 的数据写入了相应的缓冲区。以前程序中的立方体都是以原点为中心,且边长为2.0;本例为了更好地模拟机器人手臂,使用如下图所示的立方体,原点位于底面中心,底面是边长为3.0的正方向,高度为10.0。将原点置于立方体的底面中心,是为了便于时立方体绕关节转动。arm1 和 arm2 都是用这个立方体。
用来绘制机器人前臂和上臂的立方体

main()函数首先根据可视空间、视点和视线方向计算出了视图投影矩阵 viewProjMatrix

1
2
3
4
// 计算视图投影矩阵
var viewProjMatrix = new Matrix4();
viewProjMatrix.setPerspective(50.0, canvas.width / canvas.height, 1.0, 100.0);
viewProjMatrix.lookAt(20.0, 10.0, 30.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0);

然后在键盘事件响应函数中调用 keydown()函数,通过方向键控制机器人的手臂运动。

1
2
3
4
// 注册键盘事件响应函数
document.onkeydown = function(ev){
keydown(ev, gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix);
};

接着定义 keydown()函数本身,以及若干该函数需要用到的全局变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var ANGLE_STEP = 3.0;    // 每次按键转动的角度
var g_arm1Angle = -90.0; // arm1的当前角度
var g_joint1Angle = 0.0; // joint1的当前角度(即arm2的角度)

function keydown(ev, gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) {
switch (ev.keyCode) {
case 38: // 上方向键 - joint1绕z轴正向转动
if (g_joint1Angle < 135.0) g_joint1Angle += ANGLE_STEP;
break;
case 40: // 下方向键 - joint1绕z轴负向转动
if (g_joint1Angle > -135.0) g_joint1Angle -= ANGLE_STEP;
break;
case 39: // 右方向键 - arm1绕y轴正向转动
g_arm1Angle = (g_arm1Angle + ANGLE_STEP) % 360;
break;
case 37: // 左方向键 - arm1绕y轴负向转动
g_arm1Angle = (g_arm1Angle - ANGLE_STEP) % 360;
break;
default: return; // Skip drawing at no effective action
}
// Draw the robot arm
draw(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix);
}

ANGLE_STEP 常量表示每一次按下按键,arm1 或 joint1 转动的角度,它的值是3.0。g_arm1Angle 变量表示 arm1 的当前角度,g_joint1Angle 变量表示 joint1 的当前角度,如图所示:

g_joint1Angle和g_arm1Angle

keydown()函数的任务是,根据按下的是哪个按键,对 g_joint1Angle 或 g_arm1Angle 变量加上或减去常量 ANGLE_STEP 的值。注意,joint1 的转动角度只能在 -135度到 135度之间,这是为了不与 arm1 冲突。最后,draw()函数将整个机器人手臂绘制出来。

2.3 绘制层次模型(draw())

draw()函数的任务是绘制机器人手臂。注意,draw()函数和 drawBox()函数用到了全局变量 g_modelMatrixg_mvpMatrix

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 坐标变换矩阵
var g_modelMatrix = new Matrix4(), g_mvpMatrix = new Matrix4();

function draw(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) {
// Clear color and depth buffer
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

// Arm1
var arm1Length = 10.0; // Length of arm1
g_modelMatrix.setTranslate(0.0, -12.0, 0.0);
g_modelMatrix.rotate(g_arm1Angle, 0.0, 1.0, 0.0); // 绕y轴转
drawBox(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix); // Draw

// Arm2
g_modelMatrix.translate(0.0, arm1Length, 0.0);    // 移至joint1处, 这里用到translate,是在之前的基础上向上平移一个arm1的高度
g_modelMatrix.rotate(g_joint1Angle, 0.0, 0.0, 1.0); // 绕z轴转
g_modelMatrix.scale(1.3, 1.0, 1.3); // 让立方体粗一点
drawBox(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix); // Draw
}

draw()函数内部调用了 drawBox()函数,每调用一次绘制一个部件,先绘制下方较细 arm1,再绘制上方较粗 arm2。

绘制单个部件的步骤是:

  • 调用 setTranslate()translate()进行平移;
  • 调用 rotate()进行旋转;
  • 调用 drawBox()进行绘制。

绘制整个模型时,需要按照各部件的层次顺序,先 arm1 后 arm2,再执行第一步平移,第二步旋转,第三步绘制。

绘制 arm1 的步骤如下:

  • 首先在模型矩阵g_modelMatrix上调用setTranslate()函数,使之平移(0.0, -12.0, 0.0)到稍下方位置
  • 然后调用 rotate()函数,绕 y 轴旋转 g_arm1Angle 角度;最后调用 drawBox()函数绘制 arm1。

接着来绘制 arm2:

  • 与 arm1 在 joint1 处链接,该处上开始绘制 arm2。
  • 但是此时,模型矩阵还是处于绘制 arm1 的状态,所以得先调用translate()函数沿 y 轴向上平移 arm1 的高度 arm1Length。注意这里调用的是translate()而不是 setTranslate(),因为这次平移是在之前的基础上进行的
  • 然后,使用 g_joint1Angle 进行肘关节处的转动,并在 x 和 z 轴稍作拉伸,使前臂看上去粗一些,以便于上臂区分开。
1
2
3
4
5
// Arm2
g_modelMatrix.translate(0.0, arm1Length, 0.0);    // 移至joint1处, 这里用到translate,是在之前的基础上向上平移一个arm1的高度
g_modelMatrix.rotate(g_joint1Angle, 0.0, 0.0, 1.0); // 绕z轴转
g_modelMatrix.scale(1.3, 1.0, 1.3); // 让立方体粗一点
drawBox(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix); // Draw

这样一来,每当 keydown()函数更新了 g_joint1Angle 变量和 g_arm1Angle 变量的值,然后调用 draw()函数进行绘制时,就能绘制处最新状态的机器人手臂,arm1 的位置取决于 g_arm1Angle 变量,而 arm2 的位置取决于 g_joint1Angle 变量。
drawBox()函数的任务是绘制机器人手臂的某个立方体部件,如上臂或前臂。它首先计算模型视图投影矩阵,传递给 u_MvpMatrix 变量。然后根据模型矩阵计算法向量变换矩阵,传递给 u_NormalMatrix 变量,最后绘制立方体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 绘制立方体
function drawBox(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) {
// 计算视图矩阵并传给u_MvpMatrix
g_mvpMatrix.set(viewProjMatrix);
g_mvpMatrix.multiply(g_modelMatrix);
gl.uniformMatrix4fv(u_MvpMatrix, false, g_mvpMatrix.elements);
// 计算法线变换矩阵并传给u_NormalMatrix
g_normalMatrix.setInverseOf(g_modelMatrix);
g_normalMatrix.transpose();
gl.uniformMatrix4fv(u_NormalMatrix, false, g_normalMatrix.elements);
// Draw
gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0);
}

绘制层次模型的基本流程就是这样了。虽然本例只有两个立方体和一个链接关节,但是绘制更加复杂的模型,其原理与本节是一致的,要做的只是重复上述步骤而已。

实例

代码

1
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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
// Vertex shader program
var VSHADER_SOURCE =
'attribute vec4 a_Position;\n' +
'attribute vec4 a_Normal;\n' +
'uniform mat4 u_MvpMatrix;\n' +
'uniform mat4 u_NormalMatrix;\n' +
'varying vec4 v_Color;\n' +
'void main() {\n' +
' gl_Position = u_MvpMatrix * a_Position;\n' +
// Shading calculation to make the arm look three-dimensional
// 光照计算,使场景更逼真
' vec3 lightDirection = normalize(vec3(0.0, 0.5, 0.7));\n' + // Light direction
' vec4 color = vec4(1.0, 0.4, 0.0, 1.0);\n' +
' vec3 ambientLight = vec3(0.2, 0.2, 0.2);'+
' vec3 normal = normalize((u_NormalMatrix * a_Normal).xyz);\n' +//对法向量进行归一化
' float nDotL = max(dot(normal, lightDirection), 0.0);\n' +//计算法向量和光线方向的点积
//计算漫反射光的颜色
' vec3 diffuse = vec3(color) * nDotL;'+
//计算环境光产生的反射颜色
'vec3 ambient = ambientLight * color.rgb;'+
'v_Color = vec4(diffuse + ambient, color.a);'+
// ' v_Color = vec4(color.rgb * nDotL + vec3(0.1), color.a);\n' +//计算环境光产生的反射颜色
'}\n';

// Fragment shader program
var FSHADER_SOURCE =
'#ifdef GL_ES\n' +
'precision mediump float;\n' +
'#endif\n' +
'varying vec4 v_Color;\n' +
'void main() {\n' +
' gl_FragColor = v_Color;\n' +
'}\n';

function main() {
// Retrieve <canvas> element
var canvas = document.getElementById('webgl');

// Get the rendering context for WebGL
var gl = getWebGLContext(canvas);

// Initialize shaders
if (!initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)) {
console.log('Failed to intialize shaders.');
return;
}

// 设置顶点信息
var n = initVertexBuffers(gl);

// Set the clear color and enable the depth test
//gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.enable(gl.DEPTH_TEST);

// 取 u_MvpMatrix 、u_LightColor u_LightDirection u_AmbientLight 变量的存储位置
var u_MvpMatrix = gl.getUniformLocation(gl.program, 'u_MvpMatrix');
var u_NormalMatrix = gl.getUniformLocation(gl.program, 'u_NormalMatrix');

// 计算视图投影矩阵
var viewProjMatrix = new Matrix4();
viewProjMatrix.setPerspective(50.0, canvas.width / canvas.height, 1.0, 100.0);
viewProjMatrix.lookAt(20.0, 10.0, 30.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0);

// 注册键盘事件响应函数
document.onkeydown = function(ev){
keydown(ev, gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix);
};

draw(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix); // Draw the robot arm
}

var ANGLE_STEP = 3.0; // 每次按键转动的角度
var g_arm1Angle = -90.0; // arm1的当前角度
var g_joint1Angle = 0.0; // joint1d的当前角度(即arm2的角度)

function keydown(ev, gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) {
switch (ev.keyCode) {
case 38: // 上方向键 - joint1绕z轴正向转动
if (g_joint1Angle < 135.0) g_joint1Angle += ANGLE_STEP;
break;
case 40: // 下方向键 - joint1绕z轴负向转动
if (g_joint1Angle > -135.0) g_joint1Angle -= ANGLE_STEP;
break;
case 39: // 右方向键 - arm1绕y轴正向转动
g_arm1Angle = (g_arm1Angle + ANGLE_STEP) % 360;
break;
case 37: // 左方向键 - arm1绕y轴负向转动
g_arm1Angle = (g_arm1Angle - ANGLE_STEP) % 360;
break;
default: return; // Skip drawing at no effective action
}
// Draw the robot arm
draw(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix);
}

function initVertexBuffers(gl) {
// Vertex coordinates(a cuboid 3.0 in width, 10.0 in height, and 3.0 in length with its origin at the center of its bottom)
var vertices = new Float32Array([
1.5, 10.0, 1.5, -1.5, 10.0, 1.5, -1.5, 0.0, 1.5, 1.5, 0.0, 1.5, // v0-v1-v2-v3 front
1.5, 10.0, 1.5, 1.5, 0.0, 1.5, 1.5, 0.0,-1.5, 1.5, 10.0,-1.5, // v0-v3-v4-v5 right
1.5, 10.0, 1.5, 1.5, 10.0,-1.5, -1.5, 10.0,-1.5, -1.5, 10.0, 1.5, // v0-v5-v6-v1 up
-1.5, 10.0, 1.5, -1.5, 10.0,-1.5, -1.5, 0.0,-1.5, -1.5, 0.0, 1.5, // v1-v6-v7-v2 left
-1.5, 0.0,-1.5, 1.5, 0.0,-1.5, 1.5, 0.0, 1.5, -1.5, 0.0, 1.5, // v7-v4-v3-v2 down
1.5, 0.0,-1.5, -1.5, 0.0,-1.5, -1.5, 10.0,-1.5, 1.5, 10.0,-1.5 // v4-v7-v6-v5 back
]);

// Normal
var normals = new Float32Array([
0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, // v0-v1-v2-v3 front
1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, // v0-v3-v4-v5 right
0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, // v0-v5-v6-v1 up
-1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, // v1-v6-v7-v2 left
0.0,-1.0, 0.0, 0.0,-1.0, 0.0, 0.0,-1.0, 0.0, 0.0,-1.0, 0.0, // v7-v4-v3-v2 down
0.0, 0.0,-1.0, 0.0, 0.0,-1.0, 0.0, 0.0,-1.0, 0.0, 0.0,-1.0 // v4-v7-v6-v5 back
]);

// Indices of the vertices
var indices = new Uint8Array([
0, 1, 2, 0, 2, 3, // front
4, 5, 6, 4, 6, 7, // right
8, 9,10, 8,10,11, // up
12,13,14, 12,14,15, // left
16,17,18, 16,18,19, // down
20,21,22, 20,22,23 // back
]);

// Write the vertex property to buffers (coordinates and normals)
if (!initArrayBuffer(gl, 'a_Position', vertices, gl.FLOAT, 3)) return -1;
if (!initArrayBuffer(gl, 'a_Normal', normals, gl.FLOAT, 3)) return -1;

// Unbind the buffer object
gl.bindBuffer(gl.ARRAY_BUFFER, null);

// Write the indices to the buffer object
var indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

return indices.length;
}

function initArrayBuffer(gl, attribute, data, type, num) {
// Create a buffer object
var buffer = gl.createBuffer();

// Write date into the buffer object
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);

// Assign the buffer object to the attribute variable
var a_attribute = gl.getAttribLocation(gl.program, attribute);

gl.vertexAttribPointer(a_attribute, num, type, false, 0, 0);
// Enable the assignment of the buffer object to the attribute variable
gl.enableVertexAttribArray(a_attribute);

return true;
}

// 坐标变换矩阵
var g_modelMatrix = new Matrix4(), g_mvpMatrix = new Matrix4();

function draw(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) {
// Clear color and depth buffer
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

// Arm1
var arm1Length = 10.0; // Length of arm1
g_modelMatrix.setTranslate(0.0, -12.0, 0.0);
g_modelMatrix.rotate(g_arm1Angle, 0.0, 1.0, 0.0); // 绕y轴转
drawBox(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix); // Draw

// Arm2
g_modelMatrix.translate(0.0, arm1Length, 0.0);    // 移至joint1处, 这里用到translate,是在之前的基础上向上平移一个arm1的高度
g_modelMatrix.rotate(g_joint1Angle, 0.0, 0.0, 1.0); // 绕z轴转
g_modelMatrix.scale(1.3, 1.0, 1.3); // 拉伸,让立方体粗一点
drawBox(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix); // Draw
}

var g_normalMatrix = new Matrix4(); // 法线的旋转矩阵

// 绘制立方体
function drawBox(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) {
// 计算视图矩阵并传给u_MvpMatrix
g_mvpMatrix.set(viewProjMatrix);
g_mvpMatrix.multiply(g_modelMatrix);
gl.uniformMatrix4fv(u_MvpMatrix, false, g_mvpMatrix.elements);
// 计算法线变换矩阵并传给u_NormalMatrix
g_normalMatrix.setInverseOf(g_modelMatrix);
g_normalMatrix.transpose();
gl.uniformMatrix4fv(u_NormalMatrix, false, g_normalMatrix.elements);
// Draw
gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Single joint model</title>
</head>

<body onload="main()">
<canvas id="webgl" width="400" height="400">
Please use a browser that supports "canvas"
</canvas>
<p>&larr;&rarr;: arm1 rotation(y-axis), &uarr;&darr;: joint1 rotation(z-axis)</p>

<script src="../lib/webgl-utils.js"></script>
<script src="../lib/webgl-debug.js"></script>
<script src="../lib/cuon-utils.js"></script>
<script src="../lib/cuon-matrix.js"></script>
<script src="JointModel.js"></script>
</body>
</html>

效果

使用左右方向键控制 arm1(下半部分)水平转动,使用上下方方向键控制 arm2(上半部分) 绕 joint1 关节垂直转动

<!DOCTYPE html>

LookAtTriangles
Please use the browser supporting "canvas".

Tips: Please indicate the source and original author when reprinting or quoting this article.