目录
绘制由多个小部件组成的复杂模型,最关键的问题是如何处理模型的整体移动,以及各个小部件间的相对移动。现在就来研究这个问题。首先,考虑一下人类的手臂:从肩部到指尖,包括上臂(肘以上)、前臂(肘以下)、手掌和手指,如下图所示。
手臂的每个部分可以围绕关节运动,如上图所示:
● 上臂可以绕肩关节旋转运动,并带动前臂、手掌和手指一起运动。
● 前臂可以绕肘关节运动,并带动手掌和手指一起运动,但不影响上臂。
● 手掌绕腕关节运动,并带动手指一起运动,但不影响上臂和前臂。
● 手指运动不影响上臂、前臂和手掌。
总之,当手臂的某个部位运动时,位于该部位以下的其他部位会随之一起运动,而位于该部位以上的其他部位不受影响。此外,这里的所有运动,都是围绕某个关节(肩关节、肘关节、腕关节、指关节)的转动。
绘制机器人手臂这样一个复杂的模型,最常用的方法就是按照模型中各个部件的层次顺序,从高到低逐一绘制,并在每个关节上应用模型矩阵。比如,在图9.2中,肩关节、肘关节、腕关节,指关节都有各自的旋转矩阵。
注意,三维模型和现实中的人类或机器人不一样,它的部件并没有真正连接在一起。如果直接转动上臂,那么肘部以下的部分,包括前臂、手掌和手指,只会留在原地,这样手臂就断开了。所以,当上臂绕肩关节转动时,你需要在代码中实现“肘部以下部分跟随上臂转动”的逻辑。具体地,上臂绕肩关节转动了多少度,肘部以下的部分也应该绕肩关节转动多少度。
当情况较为简单时,实现“部件A转动带动部件B转动”可以很直接,只要对部件B也施以部件A的旋转矩阵即可。比如,使用模型矩阵使上臂绕肩关节转动30度,然后在绘制肘关节以下的各部位时,为它们施加同一个模型矩阵,也令其绕肩关节转动30度,如下图所示。这样,肘关节以下的部分就能自动跟随上臂转动了。
肘部以下部分随着上臂转动

如果情况更复杂一些,比如先使上臂绕肩关节转动30度,然后使前臂绕肘关节转动10度,那么对肘关节以下的部分,你就得先施加上臂绕肩关节转动30度的矩阵(可称为“肩关节模型矩阵”),然后再施加前臂绕肘关节转动10度的矩阵。将这两个矩阵相乘,其结果可称为“肘关节模型矩阵”,那么在绘制肘关节以下部分的时候,直接应用这个所谓的“肘关节模型矩阵”(而不考虑肩关节,因为肩关节的转动信息已经包含在该矩阵中了)作为模型矩阵就可以了。
按照上述方式编程,三维场景中的肩关节就能影响肘关节,使得上臂的运动带动前臂的运动;反过来,不管前臂如何运动都不会影响上臂。这就与现实中的情况相符合了。
现在,你已经对这种由多个小模型组成的复杂模型的运动规律有了一些了解,下面来看一下示例程序。
先来看一个单关节模型的例子。示例程序JointModel绘制了一个仅由两个立方体部件组成的机器人手臂,其运行结果如下图(左)所示;手臂的两个部件为arm1与arm2,arm1接在arm2的上面,如下图(右)所示。你可以把arm1想象成上臂,而把arm2想象成前臂,而肩关节在最下面(上臂在下而前臂在上,是为了以后加入手掌和手指后看得更清楚)。

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

如你所见,arm2绕joint1的转动并不影响arm1,而arm1的转动会带动arm2一起转动。
如下显示了JointMode.js的代码,所有用来绘制和控制机器人手臂的逻辑都在JavaScript代码中。
- var VSHADER_SOURCE = // p316
- 'attribute vec4 a_Position;\n' +
- 'attribute vec4 a_Normal;\n' +
- 'uniform mat4 u_MvpMatrix;\n' + // 模型视图投影矩阵
- 'uniform mat4 u_NormalMatrix;\n' + // 用于改变法向量的矩阵
- 'uniform vec3 u_LightColor;\n' + // 平行光颜色
- 'uniform vec3 u_AmbientColor;\n' + // 环境光颜色
- 'varying vec4 v_Color;\n' +
- 'void main() {\n' +
- ' gl_Position = u_MvpMatrix * a_Position;\n' +
- // 光照计算,使场景更加逼真
- ' vec3 lightDirection = normalize(vec3(0.0, 0.5, 0.7));\n' + // 归一化光线方向
- ' vec4 color = vec4(1.0, 0.4, 0.0, 1.0);\n' + // 物体颜色
- ' vec3 normal = normalize((u_NormalMatrix * a_Normal).xyz);\n' + // 归一化法向量
- ' float nDotL = max(dot(normal, lightDirection), 0.0);\n' + // 点积 cos
- ' vec3 ambient = u_AmbientColor * color.rgb;\n' + // 环境反射光颜色
- ' vec3 diffuse = u_LightColor * color.rgb * nDotL;\n' + // 漫反射光颜色
- ' v_Color = vec4(diffuse + ambient, color.a);\n' + // 最终颜色
- '}\n';
-
- 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() {
- var canvas = document.getElementById('webgl');
- var gl = getWebGLContext(canvas);
- if (!initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)) return
- var n = initVertexBuffers(gl);
- gl.clearColor(0.0, 0.0, 0.0, 1.0);
- gl.enable(gl.DEPTH_TEST);
- var u_MvpMatrix = gl.getUniformLocation(gl.program, 'u_MvpMatrix');
- var u_NormalMatrix = gl.getUniformLocation(gl.program, 'u_NormalMatrix');
- var u_LightColor = gl.getUniformLocation(gl.program, 'u_LightColor');
- var u_AmbientColor = gl.getUniformLocation(gl.program, 'u_AmbientColor');
- gl.uniform3f(u_LightColor, 1.0, 1.0, 1.0);
- gl.uniform3f(u_AmbientColor, 0.0, 1.0, 1.0);
- // 计算视图投影矩阵
- 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); // 绘制立方体
- }
-
- var ANGLE_STEP = 3.0; // 每次按键转动的角度
- var g_arm1Angle = 0.0; // arml的当前角度
- 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;
- }
- // 绘制手臂
- draw(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix);
- }
- function initVertexBuffers(gl) {
- // 顶点坐标(长方体宽度3.0,高度10.0,长度3.0,原点位于底部中心)
- 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
- ]);
-
- // 法向量
- 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
- ]);
-
- // 顶点的索引
- 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
- ]);
-
- // 将顶点属性写入缓冲区(坐标和法线)
- if (!initArrayBuffer(gl, 'a_Position', vertices, gl.FLOAT, 3)) return -1;
- if (!initArrayBuffer(gl, 'a_Normal', normals, gl.FLOAT, 3)) return -1;
- gl.bindBuffer(gl.ARRAY_BUFFER, null);
- 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) {
- var buffer = gl.createBuffer();
- gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
- gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
- var a_attribute = gl.getAttribLocation(gl.program, attribute);
- gl.vertexAttribPointer(a_attribute, num, type, false, 0, 0);
- gl.enableVertexAttribArray(a_attribute);
- return true;
- }
-
- // 坐标变换矩阵
- var g_modelMatrix = new Matrix4(), g_mvpMatrix = new Matrix4();
-
- function draw(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) {
- 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); // 绘制
-
- // Arm2
- g_modelMatrix.translate(0.0, arm1Length, 0.0); // 移动至joint1处
- 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); // 绘制
- }
-
- 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);
- // 最终绘制
- gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0);
- }
主要的变化发生在initVertexBuffers()函数,它将arm1和arm2的数据写入了相应的缓冲区。以前程序中的立方体都是以原点为中心,且边长为2.0;本例为了更好地模拟机器人手臂,使用如下图所示的立方体,原点位于底面中心,底面是边长为3.0的正方形,高度为10.0。将原点置于立方体的底面中心,是为了便于使立方体绕关节转动(比如,肘关节就位于前臂立方体的底面中心),如上图所示。arm1和arm2都使用这个立方体。
用来绘制机器人前臂和上臂的立方体

main()函数首先根据可视空间,视点和视线方向计算出了视图投影矩阵viewProjMatrix(第44~46行)。

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

接着定义keydown()函数本身(第55行),以及若干该函数需要用到的全局变量(第52、53和54行)。

ANGLE_STEP常量(第52行)表示每一次按下按键,arm1或joint1转动的角度,它的值是3.0。g_arm1Angle变量(第53行)表示arm1的当前角度,g_joint1Angle变量表示joint1的(也就是arm2的)当前角度,如下图所示。
g_joint1Angle和g_arm1Angle

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

如你所见,draw()函数内部调用了drawBox()函数,每调用一次绘制一个部件,先绘制下方较细arm1,再绘制上方较粗arm2。
绘制单个部件的步骤是:(1)调用setTranslate()或translate()进行平移;(2)调用rotate()进行旋转;(3)调用drawBox()进行绘制。
绘制整个模型时,需要按照各部件的层次顺序,先arm1后arm2,再执行(1)平移,(2)旋转,(3)绘制。
绘制arm1的步骤如下:首先在模型矩阵g_modelMatrix上调用setTranslate()函数,使之平移(0.0,-12.0,0.0)到稍下方位置(第133行);然后调用rotate()函数,绕y轴旋转g_arm1Angle角度(第134行);最后调用drawBox()函数绘制arm1。

接着来绘制arm2,它与arm1在joint1处连接,如图g_joint1Angle和g_arm1Angle所示,我们应当从该处上开始绘制arm2。但是此时,模型矩阵还是处于绘制arm1的状态(向下平移并绕y轴旋转)下,所以得先调用translate()函数沿y轴向上平移arm1的高度arm1Length(第138行)。注意这里调用的是translate()而不是setTranslate(),因为这次平移是在之前的基础上进行的。

然后,使用g_joint1Angle进行肘关节处的转动(第139行),并在x和z轴稍作拉伸(第140行),使前臂看上去粗一些,以便与上臂区分开。
这样一来,每当keydown()函数更新了g_joint1Angle变量和g_arm1Angle变量的值,然后调用draw()函数进行绘制时,就能绘制出最新状态的机器人手臂,arm1的位置取决于g_arm1Angle变量,而arm2的位置取决于g_jointAngle变量(当然也受g_arm1Angle的影响)。

drawBox()函数的任务是绘制机器人手臂的某一个立方体部件,如上臂或前臂。它首先计算模型视图投影矩阵,传递给u_MvpMatrix变量(第149~151行),然后根据模型矩阵计算法向量变换矩阵,传递给u_NormalMatrix变量(第153~155行),最后绘制立方体(第157行)。
