目录
前言
组成立方体的面、三角形、顶点坐标和顶点颜色
通过顶点索引绘制物体
gl.drawElements(mode, count, type, offset) 函数规范
示例程序 彩色立方体(HelloCube.js)
代码详解
向缓冲区中写入顶点的坐标、颜色与索引
gl.ELEMENT_ARRAY_BUFFER 绑定顶点索引数据目标
gl.drawElements()详解
示例效果
为立方体的每个表面指定颜色
组成立方体的面、三角形和顶点的关系(为每个面指定不同的颜色)
示例程序 6面6颜色立方体(ColoredCube.js)
我们来绘制如下图所示的立方体(图右侧显示了立方体每个顶点的坐标),其8个顶点的颜色分别为白色、品红色(亮紫色)、红色、黄色、绿色、青色(蓝绿色)、蓝色、黑色。你也许知道,为每个顶点定义颜色后,表面上的颜色会根据顶点颜色内插出来,形成一种光滑的渐变效果(“色体”,相当于二维的“色轮”)。
绘制三角形,我们都是调用gl.drawArrays()方法来进行绘制操作的。考虑一下,如何用该函数绘制出一个立方体呢。我们只能使用gl.TRIANGLES、gl.TRIANGLE_STRIP或者gl.TRIANGLE_FAN模式来绘制三角形,那么最简单也最直接的方法就是,通过绘制两个三角形来拼成立方体的一个矩形表面。换句话说,为了绘制四个顶点(v0,v1,v2,v3)组成的矩形表面,你可以分别绘制三角形(v0,v1,v2)和三角形(v0,v2,v3)。对立方体的所有表面都这样做就绘制出了整个立方体。在这种情况下,缓冲区内的顶点坐标应该是这样的:
立方体的每一个面由两个三角形组成,每个三角形有3个顶点,所以每个面需要用到6个顶点。立方体共有6个面,一共需要6×6=36个顶点。将36个顶点的数据写入缓冲区,再调用gl.drawArrays(gl.TRIANGLES,0,36)就可以绘制出立方体。问题是,立方体实际只有8个顶点,而我们却定义了36个之多,这是因为每个顶点都会被多个三角形共用。
或者,你也可以使用gl.TRIANGLE_FAN模式来绘制立方体。在gl.TRIANGLE_FAN模式下,用4个顶点(v0,v1,v2,v3)就可以绘制出一个四边形,所以你只需要4×6=24个顶点。但是,如果这样做你就必须为立方体的每个面调用一次gl.drawArrays(),一共需要6次调用。所以,两种绘制模式各有优缺点,没有一种是完美的。
如你所愿,WebGL确实提供了一种完美的方案:gl.drawElements()。使用该函数替代gl.drawArrays()函数进行绘制,能够避免重复定义顶点,保持顶点数量最小。为此,你需要知道模型的每一个顶点的坐标,这些顶点坐标描述了整个模型(立方体)。
我们将立方体拆成顶点和三角形,如下图左)所示。立方体被拆成6个面:前、后、左、右、上、下,每个面都由两个三角形组成,与三角形列表中的两个三角形相关联。每个三角形都有3个顶点,与顶点列表中的3个顶点相关联,如下图(右)所示。三角形列表中的数字表示该三角形的3个顶点在顶点列表中的索引值。顶点列表中共有8个顶点,索引值为从0到7。
这样用一个数据结构就可以描述出立方体是怎样由顶点坐标和颜色构成的了。
到目前为止,我们都是使用gl.drawArrays()进行绘制,现在我们要使用另一个方法gl.drawElements()。两个方法看上去差不多,但后者有一些优势,我们稍后再解释。首先,我们来看一下如何使用gl.drawElements()。我们需要在gl.ELEMENT_ARRAY_BUFFER(而不是之前一直使用的gl.ARRAY_BUFFER)中指定顶点的索引值。所以两种方法最重要的区别就在于gl.ELEMENT_ARRAY_BUFFER,它管理着具有索引结构的三维模型数据。
我们需要将顶点索引(也就是三角形列表中的内容)写入到缓冲区中,并绑定到gl.ELEMENT_ARRAY_BUFFER上,其过程类似于调用gl.drawArrays()时将顶点坐标写入缓冲区并将其绑定到gl.ARRAY_BUFFER上的过程。也就是说,可以继续使用gl.bindBuffer()和gl.bufferData()来进行上述操作,只不过参数target要改为gl.ELEMENT_ARRAY_BUFFER。来看一下示例程序。
如下显示了程序的代码。本例使用了金字塔状的可视空间和透视投影变换,顶点着色器对顶点坐标进行了简单的变换,片元着色器接收varying变量并赋值给gl_FragColor,以对片元进行着色。使用gl.drawElements()或是gl.drawArrays()对上述这些内容没有影响,真正影响到的内容在initVertexBuffers()函数中。
var VSHADER_SOURCE =
'attribute vec4 a_Position;\n' +
'attribute vec4 a_Color;\n' +
'uniform mat4 u_MvpMatrix;\n' +
'varying vec4 v_Color;\n' +
'void main() {\n' +
' gl_Position = u_MvpMatrix * a_Position;\n' +
' v_Color = a_Color;\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 mvpMatrix = new Matrix4();
mvpMatrix.setPerspective(30, 1, 1, 100); // 设置投影矩阵
mvpMatrix.lookAt(3, 3, 7, 0, 0, 0, 0, 1, 0); // 设置视图矩阵
gl.uniformMatrix4fv(u_MvpMatrix, false, mvpMatrix.elements); // 将模型视图矩阵传给u_MvpMatrix
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // 清空颜色缓冲区和深度缓冲区
// 绘制立方体
gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0);
}
function initVertexBuffers(gl) {
// Create a cube
// v6----- v5
// /| /|
// v1------v0|
// | | | |
// | |v7---|-|v4
// |/ |/
// v2------v3
var verticesColors = new Float32Array([
// 顶点坐标和颜色(顶点坐标分别对应顶点索引0~7)
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, // v0 White
-1.0, 1.0, 1.0, 1.0, 0.0, 1.0, // v1 Magenta
-1.0, -1.0, 1.0, 1.0, 0.0, 0.0, // v2 Red
1.0, -1.0, 1.0, 1.0, 1.0, 0.0, // v3 Yellow
1.0, -1.0, -1.0, 0.0, 1.0, 0.0, // v4 Green
1.0, 1.0, -1.0, 0.0, 1.0, 1.0, // v5 Cyan
-1.0, 1.0, -1.0, 0.0, 0.0, 1.0, // v6 Blue
-1.0, -1.0, -1.0, 0.0, 0.0, 0.0 // v7 Black
]);
// 顶点索引
var indices = new Uint8Array([
0, 1, 2, 0, 2, 3, // front
0, 3, 4, 0, 4, 5, // right
0, 5, 6, 0, 6, 1, // up
1, 6, 7, 1, 7, 2, // left
7, 4, 3, 7, 3, 2, // down
4, 7, 6, 4, 6, 5 // back
]);
// 创建缓冲区对象
var vertexColorBuffer = gl.createBuffer();
// 创建用于管理顶点索引数据的缓冲区对象
var indexBuffer = gl.createBuffer();
// 将顶点坐标和颜色写入缓冲区对象
gl.bindBuffer(gl.ARRAY_BUFFER, vertexColorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, verticesColors, gl.STATIC_DRAW);
var FSIZE = verticesColors.BYTES_PER_ELEMENT;
// 将顶点坐标和颜色写入缓冲区对象
var a_Position = gl.getAttribLocation(gl.program, 'a_Position');
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, FSIZE * 6, 0);
gl.enableVertexAttribArray(a_Position);
var a_Color = gl.getAttribLocation(gl.program, 'a_Color');
gl.vertexAttribPointer(a_Color, 3, gl.FLOAT, false, FSIZE * 6, FSIZE * 3);
gl.enableVertexAttribArray(a_Color);
// 将顶点索引数据写入缓冲区对象
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
return indices.length;
}
main()函数的流程,我们首先调用initVertexBuffers()函数将顶点数据写入缓冲区(第25行),然后开启隐藏面消除(第27行),使WebGL能够根据立方体各表面的前后关系正确地进行绘制。
接着,设置视点和可视空间(第29~31行),把模型视图投影矩阵传给顶点着色器中的u_MvpMatrix变量。
最后,清空颜色和深度缓冲区(第33行),使用gl.drawElements()绘制立方体(第36行)。该函数的使用方法,下面具体来看一下。
本例的initVertexBuffers()函数通过缓冲区对象verticesColors向顶点着色器中的attribute变量传顶点坐标和颜色信息,这一点与之前无异。但是,本例不再按照verticesColors中的顶点顺序来进行绘制,所以必须额外注意每个顶点的索引值,我们要通过索引值来指定绘制的顺序。比如说,第1个顶点的索引为0,第2个顶点的索引为1,等等。下面是initVertexBuffers()函数的部分代码:
也许你会注意到,缓冲区对象indexBuffer(第76行)中的数据来自于数组indices(第61行),该数组以索引值的形式存储了绘制顶点的顺序。索引值是整型数,所以数组的类型是Uint8Array(无符号8位整型数)。如果有超过256个顶点,那么就应该使用Uint16Array。indices中的元素如下图中的三角形列表所示,每3个索引值为1组,指向3个顶点,由这3个顶点组成1个三角形。通常我们不需要手动创建这些顶点和索引数据,因为三维建模工具会帮助我们创建它们。
绑定缓冲区,以及向缓冲区写入索引数据的过程(第89~90行)与之前示例程序中的很类似,区别就是绑定的目标由gl.ARRAY_BUFFER变成了gl.ELEMENT_ARRAY_BUFFER。这个参数告诉WebGL,该缓冲区中的内容是顶点的索引值数据。
此时,WebGL系统的内部状态如下图所示。
最后,我们调用gl.drawElements(),就绘制出了立方体(第36行)。
gl.drawElements()方法的第2个参数n表示顶点索引数组的长度,也就是顶点着色器的执行次数。注意,n与gl.ARRAY_BUFFER中的顶点个数不同。
在调用gl.drawElements()时,WebGL首先从绑定到gl.ELEMENT_ARRAY_BUFFER的缓冲区(也就是indexBuffer)中获取顶点的索引值,然后根据该索引值,从绑定到gl.ARRAY_BUFFER的缓冲区(即vertexColorBuffer)中获取顶点的坐标、颜色等信息,然后传递给attribute变量并执行顶点着色器。对每个索引值都这样做,最后就绘制出了整个立方体,而此时你只调用了一次gl.drawElements()。这种方式通过索引来访问顶点数据,从而循环利用顶点信息,控制内存的开销,但代价是你需要通过索引来间接地访问顶点,在某种程度上使程序复杂化了。所以,gl.drawElements()和gl.drawArrays()各有优劣,具体用哪一个取决于具体的系统需求。
虽然我们已经证明了gl.drawElements()是高效的绘制三维图形的方式,但还是漏了关键的一点:我们无法通过将颜色定义在索引值上,颜色仍然是依赖于顶点的,如下图。
考虑这样的情况:我们希望立方体的每个表面都是不同的单一颜色(而非颜色渐变效果)或者纹理图像,如下图所示。我们需要把每个面的颜色或纹理信息写入三角形列表、索引和顶点数据中
下面,我们将研究如何解决这个问题,以及如何为每个面指定颜色。
我们知道,顶点着色器进行的是逐顶点的计算,接收的是逐顶点的信息。这说明,如果你想指定表面的颜色,你也需要将颜色定义为逐顶点的信息,并传给顶点着色器。举个例子,你想把立方体的前表面涂成蓝色,前表面由顶点v0、v1、v2、v3组成,那么你就需要将这4个顶点都指定为蓝色。
但是你会发现,顶点v0不仅在前表面上,也在右表面和上表面上,如果你将v0指定为蓝色,那么它在另外两个表面上也会是蓝色,这不是我们想要的结果。为了解决这个问题,我们需要创建多个具有相同顶点坐标的顶点(虽然这样会造成一些冗余),如下图所示。如果这样做,你就必须把那些具有相同坐标的顶点分开处理
此时的三角形列表,也就是顶点索引值序列,对每个面都指向一组不同的顶点,不再有前表面和上表面共享一个顶点的情况。这样一来,就可以实现前述的效果,为每个表面涂上不同的单色了。我们也可以使用类似的方法为立方体的每个表面贴上不同的纹理,只需要将上图中的颜色值换成纹理坐标即可。
现在来看一下示例程序ColoredCube的代码,它绘制出了一个立方体,其每个表面涂上了不同的颜色。
示例程序代码如下所示。本例ColoredCube.js与HelloCube.js的主要区别是在于顶点数据存储在缓冲区中的形式,也就是initVertexBuffers()函数负责的内容。两者的主要区别是:
● 在HelloCube.js中,顶点的坐标和颜色数据存储在同一个缓冲区中。虽然有着种种好处,但这样做略显笨重,本例中我们将顶点的坐标和颜色分别存储在不同的两个缓冲区中。
● 顶点数组、颜色数组和索引数组按照图7.36的配置进行了修改(第83、92、101行)。
● 为了程序结构紧凑,定义了函数initArrayBuffer(),封装了缓冲区对象的创建、绑定、数据写入和开启等操作(第116、119、126行)。
在阅读代码时,请留意程序是如何实现上面第2点——构建上图中的数据结构。