对应《WebGL编程指南》第九章 44-MultiJointModel、45-MultiJointModel_segment
要点:层次结构模型、多节点模型
知识点
一、多节点模型
这一节将把 JointModel 扩展为 MultiJointModel,后者绘制一个具有多个关节的完整的机器人手臂,包括基座 base,上臂 arm1,前臂 arm2,手掌 palm,两根手指 finger1 & finger2,全部可以通过键盘来控制。arm1 和 arm2 的连接关节 joint1 位于 arm1 顶部,arm2 和 palm 的连接关节 joint2 位于 arm2 顶部,finger1 和 finger2 位于 palm 一段,如下图所示:
实现功能:用户可以通过键盘操纵机器人手臂,arm1 和 arm2 的操作和 JointModel 一样,此外,还可以使用 x 和 z 键旋转 Joint2,使用 C 和 V 键旋转 finger1 和 finger2。控制这些小部件旋转角度的全局变量。
1.1 程序分析
对应代码:MultiJointModel
示例程序 MultiJointModel
和 JointModel
相比,主要有两处不同:keydown()
函数响应更多的键盘情况,draw()
函数绘制各部件的逻辑更复杂了。
keydown()函数
1 | var ANGLE_STEP = 3.0; // 每次按键转动的角度 |
本例的 keydown()函数,除了需要在方向键被按下时做出响应,更新 g_arm1Angle
和 g_joint1Angle
变量,还需要在Z
键、X
键、V
键和C
键被按下时做出响应,更新 g_joint2Angle
和 g_joint3Angle
变量。在此之后,就调用 draw()函数,把整个模型画出来。
模型的各个部件 base、arm1、arm2、palm、finger1 和 finger2 等虽然都是立方体,但是长宽高各不相同。所以本例扩展了 drawBox()函数,添加了3个参数:
1 | function drawBox(gl, n, width, height, depth, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) |
新增加的3个参数表示部件的宽度、高度和长度(深度),drawBox()会根据这3个参数,将部件分毫不差地绘制出来。
draw()函数—绘制模型部分
1 | // 变换坐标的矩阵 |
draw()函数的任务和 JointModel 中的相同,就是对每个部件进行:
- 平移
- 旋转
- 绘制
首先,base 不会旋转,所以只需要将其移动到合适的位置,再调用 drawBox()
进行绘制。通过向drawBox()
传入参数,指定 base 的宽度是10,高度是2,长度是10,即一个扁平的基座。
然后,按照 arm1、arm2 和 palm 这些部件在模型中的层次顺序,对每一个部件都进行上述三个步骤,这与 JointModel 中的是一样的。
比较麻烦的是 finger1 和 finger2,因为它们并不是上下层的关系,而是都连接在 palm 上,此时要格外注意计算模型矩阵的过程。首先来看 finger1,它相对于 palm 原点沿Z 轴平移了2.0单位,并且可以绕X轴旋转,我们执行上述三个步骤。
1 | g_modelMatrix.translate(0.0, 0.0, 2.0); |
接着看 finger2,如果遵循上述同样的步骤,沿 z 轴平移 -2.0 个单位并绕X轴旋转就会出现问题。在将模型矩阵“沿Z轴平移-2.0个单位”之前,模型矩阵实际上处于绘制 finger1 的状态,这会导致 finger2 连接在 finger1 而不是 palm上,使得 finger1 转动带动 finger2。
所以,我们在绘制 finger1 之前,先将模型矩阵保存起来;绘制完 finger1 后,再将保存的模型矩阵取出来作为当前的模型矩阵,并继续绘制 finger2。可以使用一个栈来完成这项操作:调用 pushMatrix()
并将模型矩阵 g_modelMatrix
作为参数传入,将当时模型矩阵的状态保存起来,然后绘制完 finger1后,调popMatrix()
获取之前保存的矩阵,并赋给 g_modelMatrix
,使模型矩阵又回到绘制 finger1 之前的状态,在此基础上绘制 finger2。
pushMatrix()
函数和popMatrix()
函数如下所示,它们使用全局变量 g_matrixStack
来存储矩阵,前者向栈中压入一个矩阵,而后者从栈中取出一个。
1 | var g_matrixStack = []; // 存储矩阵的栈 |
只要栈足够深,用这种方法就可以绘制任意复杂的层次 结构模型。我们只需要按照层次顺序,从高到低绘制部件,并在绘制“具有兄弟部件”的部件前将模型矩阵压入栈,绘制完再弹出即可。
drawBox()绘制部件
最后看一下 drawBox()
函数,该函数的任务是绘制机器人手臂的一个部件,它接受若干个参数:
1 | function drawBox(gl, n, width, height, depth, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) |
参数 width
、height
和 depth
分别表示待绘制部件的宽度、高度和深度。其他的参数与 JointMode.js 中无异:
- 参数 viewMatrix 表示视图矩阵
- u_MvpMatrix 表示模型视图投影矩阵
- u_NormalMatrix 表示用来计算变换后的法向量矩阵
- 后两者被传给顶点着色器中相应的同名 uniform 变量。
此外,与 JointModel 不同的是,本例中部件的三维模型是标准化的立方体,其边长为1,原点位于底面。drawBox()
函数的定义如下所示:
1 | // Draw rectangular solid |
drawBox()
函数首先将模型矩阵乘以由 width、height 和 depth 参数生成的缩放矩阵,使绘制处的立方体尺寸与设想的一样。然后使用 pushMatrix()函数将模型矩阵压入栈中,使用 popMatrix()再重新获得之。如果不这样做,当绘制 arm2 的时候,对 arm1的拉伸效果还会仍然留在模型矩阵中,并影响 arm2 的绘制。
虽然 pushMareix()函数和 popMatrix()函数使代码变得更复杂了,但这是值得的,因为你只用了一组顶点数据就绘制了好几个大小位置各不相同的立方体部件。或者,我们也可以对每一个部件都单独使用一组顶点数据,接下来看看如何实现。
1.2 drawSegments()—绘制部件
分析
这一节将换一种方式来绘制机器人手臂,那就是,对每一个部件,都定义一组顶点数据,并存储在一个单独的缓冲区对象中。通常,一个部件的定点数据包括坐标、法向量、索引值等,但是这里的每个部件都是立方体,所以你可以让各部件共享法向量和索引值,而仅仅为个部件单独定义顶点坐标。每个部件的顶点坐标分别存储在对应的缓冲区中,在绘制整条机器人手臂时轮流使用。
实例程序的关键点是:
-
为每个部件单独创建一个缓冲区,在其中存储顶点的坐标数据
-
绘制部件之前,将相应缓冲区对象分配给 a_Position 变量
-
开启 a_Position 变量并绘制该部件
main()函数的流程很简明,包括初始化缓冲区,获取 a_Position 的存储地址,然后调用 draw()函数进行绘制等。
initVertexBuffers()
接着来看 initVertexBuffers()
函数,该函数之前定义了若干全局变量,白哦啊是存储各个部件顶点坐标数据的缓冲区对象。本例与 MultiJointModel.js 的主要区别在顶点坐标上,我们不再使用一个立方体经过不同变换来绘制不同的部件,而是将每个部件的顶点坐标分开定义在不同的数组中。
initArrayBufferForLatreUse()
真正创建这些缓冲对象是由 initArrayBufferForLatreUse()
函数完成的,该函数定义如下:
1 | function initArrayBufferForLaterUse(gl, data, num, type){ |
initArrayBufferForLatreUse()
函数首先创建了缓冲区对象,然后向其中写入数据。
注意,函数并没有将缓冲区对象分配给 attribute 变量(gl.vertexAtteibPointer()
)或开启attribute
变量(gl.enableVertexAttribArray()
),这两个步骤将留到真正进行绘制之前再完成。
另外,为了便于将缓冲区分配给 attribute 变量,我们手动为其添加了两个属性 num 和 type。
这里利用了 JS 的一个有趣的特性,就是可以自由地对对象添加新的属性。你可以直接通过属性名为对象添加新属性,并向其赋值。如你所见,我们为缓冲区对象添加了新的 num 属性并保存其中顶点的个数,添加了 type 属性以保存数据类型。当然,也可以通过相同的方式访问这些属性。注意,在使用 JS 的这项特性时应格外小心,如果不小心拼错了属性名,浏览器也不会报错。同样你也应该记得,这样做会增加性能开销。
最后,调用 draw()
函数绘制整个模型,与 MultiJointModel 中一样。但是调用 drawSegments()函数的方式与前例调用 drawBox()函数的方式有所不同,第3个参数是存储了顶点坐标数据的缓冲区对象:
1 | drawSegment(gl, n, buffer, viewProjMatrix, a_Position, u_MvpMatrix, u_NormalMatrix) |
drawSegments()将缓冲区对象分配给 a_Position 变量并开启之,然后调用 gl.drawElements()进行绘制操作。这里使用了之前为缓冲区对象添加的 num 和 type 属性。
1 | // 绘制部件 |
这一次,你不必再像前例中那样,在绘制每个部件时对模型矩阵进行缩放操作了,因为每个部件的顶点坐标都已经事先定义好了。同样也没必要再使用栈来管理模型矩阵,所以 pushMatrix()函数和 popMatrix()也不需要了。
二、着色器和着色器程序对象:initShaders()函数的作用
最后,本章来研究一下以前一直使用的辅助函数 initShaders()
。以前的所有程序都使用了这个函数,它隐藏了建立和初始化着色器的细节。本书故意将这一部分内容留到最后,是为了确保你在学习 initShaders()函数中的复杂细节时,对 WebGL 已经有了比较深入的交接。掌握这部分内容并不是必须的,直接使用 initShaders()函数也能够编写处相当不错的 WebGL 程序,但如果你确实很想知道 WebGL 原生 API 是如何将字符串形式的 GLSL ES 代码编译为显卡中运行的着色器程序,那么这一节的内容将大大满足你的好奇心。
initShaders()函数的作用是,编译 GLSL ES 代码,创建和初始化着色器供 WebGL 使用。具体地,分为以下7个步骤:
- 创建着色器对象(gl.createShader)
- 向着色器对象中填充着色器程序的源代码(gl.shaderSource)
- 编译着色器(gl.compileShader)
- 创建程序对象(gl.createProgram)
- 为程序对象分配着色器(gl.attachShader)
- 连接程序对象(gl.linkProgram)
- 使用程序对象(gl.useProgram)
虽然每一步看上去都比较简单,但放在一起显得复杂了,我们将逐条讨论。
2.1 着色器对象和程序对象
首先,你需要知道这里出现了两种对象:着色器对象和程序对象。
着色器对象:着色器对象管理一个顶点着色器或一个片元着色器。每一个着色器都有一个着色器对象。
程序对象:程序对象是管理着色器对象的容器。WebGL 中,一个程序对象必须包含一个顶点着色器和一个片元着色器。
着色器对象和程序对象间的关系:
2.2 创建着色器对象(gl.createShader())
所有的着色器对象都必须通过调用 gl.createShader()来创建。
gl.createShader()函数根据传入的参数创建一个顶点着色器或者片元着色器。如果不再需要这个着色器,可以调用 gl.deleteShader()函数来删除着色器。
注意,如果着色器对象还在使用,那么 gl.deleteShader()并不会立刻删除着色器,而是要等到程序对象不再使用该着色器后,才将其删除。
2.3 指定着色器对象的代码(gk.shaderSource)
通过 gl.shaderSource()函数向着色器制动 GLSL ES 源代码。在 JS 程序中,源代码以字符串的形式存储。
2.4 编译着色器(gl.compileShader)
向着色器对象传入源代码之后,还需要对其进行编译才能够使用。GLSL ES 语言和 JS 不同而更接近 C 或 C++,在使用之前需要编译成二进制的可执行格式,WebGL 系统真正使用的是这种可执行格式。使用 gl.compileShader()函数进行编译。注意,如果你通过调用 gl.shaderSource(),用新的代码替换掉了着色器中旧的代码,WebGL 系统中的用旧的代码编译处可执行部分不会被自动替换,你需要手动地重新进行编译。
当调用 gl.compileShader()函数时,如果着色器代码中存在错误,那么就会出现编译错误。可以调用 gl.getShaderParameter()函数来检查着色器的状态。
调用 gl.getShaderParameter()并将参数 pname 指定为 gl.COMPILE_STATUS,就可以检查着色器编译是否成功。
如果编译失败,gl.getShaderParameter()会返回 false,WebGL 系统会把编译错误的具体内容写入着色器的信息日志,我们可以通过 gl.getShaderInfoLog()来获取之。
虽然日志信息的具体格式依赖于浏览器对 WebGL 的实现,但大多数 WebGL 系统给出的错误信息都会包含代码出错行的行号。比如,如果你试图编译如下这样一个着色器:
1 | var FSHADER_SOURCE = |
2.5 创建程序对象(gl.createProgram)
如前所述,程序对象包含了顶点着色器和片元着色器,可以调用 gl.createProgram()来创建程序对象。事实上,之前使用程序对象,gl.getAttribLocation()函数和 gl.getUniformLocation()函数的第1个参数,就是这个程序对象。
类似地,可以使用 gl.deleteProgram()函数来删除程序对象。
一旦程序对象被创建之后,需要向程序附上两个着色器。
2.6 为程序对象分配着色器对象(gl.attachShader)
WebGL 系统要运行起来,必须要有两个着色器:一个顶点着色器和一个片元着色器。可以使用 gl.attachShader()函数为程序对象分配这两个着色器。
着色器在附给程序对象前,并不一定要为其指定代码或进行编译。也就是说,把空的着色器赋给程序对象也是可以的。类似的,可以使用 gl.derachShader()函数来解除分配给程序对象的着色器。
2.7 连接程序对象(gl.linkProgram)
在为程序对象分配了两个着色器对象后,还需要将着色器连接起来。使用 gl.linkProgram()函数来进行这一步操作。
程序对象进行着色器连接操作,目的是保证:
-
顶点着色器和片元着色器的 varying 变量同名同类型,且一一对应;
-
顶点着色器对每个varying 变量赋了值;
-
顶点着色器和片元着色器的同名uniform 变量也是同类型的,无需一一对应,即某些 uniform 变量可以出现在一个着色器中而不出现在另一个中;
-
着色器中的 attribute 变量、uniform 变量和 varying 变量的个数没有超过着色器的上学,等等。
在着色器连接之后,应当检查是否连接成功。通过调用 gl.getProgramParameters()函数来实现。
如果程序已经成功连接,我们就得到了一个二进制的可执行模块供 WebGL 系统使用。如果连接失败了,也可以通过调用 gl.getProgramInfoLog()从信息日志中获取连接错误的信息。
2.8 告知 WebGL 系统所使用的程序对象(gl.useProgram)
最后,通过调用 gl.useProgram()告知 WebGL 系统绘制时使用哪个程序对象。
这个函数的存在使得 WebGL 具有了一个强大的特性,那就是在会之前准备多个程序对象,然后在绘制的时候根据需要切换程序对象。
这样,建立和初始化着色器的任务就算完成了。如你所见,initShaders()函数隐藏了大量的细节,我们可以放心地使用该函数来创建和初始化着色器,而不必考虑这些细节。本质上,在该函数顺利执行后,顶点着色器和偏远着色器就已经就位了,只需要调用 gl.drawArryas()或 gl.drawElements()来使整个 WebGL 系统运行起来。
2.9 initShaders()函数的内部流程
cuon-utils.js 中 initShaders()函数的内部流程。
initShaders() 函数将调用 createProgram()函数,后者负责创建一个连接好的程序对象;createProgram()函数则又会调用 loadShader()函数,后者负责创建一个编译好的着色器对象;这3个函数被一次定义在 cuon-utils.js 文件中。initShaders() 函数定义在该文件的顶部,注意该文件中每个函数前面的注释是按照 JavaDoc 的格式编写,它们可以用来自动化地生成文档。
1 | function initShaders(gl, vshader, fshader) { |
initShaders() 函数本身很简单,首先调用 createProgram()函数创建一个连接好的额程序对象,然后告诉 WebGL 系统来使用这个程序对象,最后将程序对象设为 gl 对象的 program 属性。
1 | function createProgram(gl, vshader, fshader) { |
createProgram()函数通过调用 loadShader()函数,创建顶点着色器和片元着色器的着色器对象。loadShader()函数返回的着色器对象已经制定过源码并已经成功编译了。
createProgram()函数自己负责创建程序对象,然后将前面创建的顶点着色器和偏远着色器分配给程序对象。
接着,该函数连接程序对象,并检查是否连接成功。如果连接成功,就会返回程序对象。
最后来看一下 loadShader()函数:
1 | function loadShader(gl, type, source) { |
oadShader()函数首先创建了一个着色器对象,然后为该着色器对象指定源代码,并进行编译,接着检查编译是否成功,如果成功编译,没有出错,就返回着色器对象。
【WebGL之巅】系列完结
实例
代码1
1 | // Vertex shader program |
效果1
通过键盘操纵机器人手臂:
使用左右方向键控制 arm1(下半部分)水平转动;
使用上下方方向键控制 arm2(上半部分) 绕 joint1 关节垂直转动;
使用 x 和 z 键旋转 Joint2;
使用 C 和 V 键旋转 finger1 和 finger2
代码2
1 | // Vertex shader program |
效果2
Tips: Please indicate the source and original author when reprinting or quoting this article.