迄今为止,本书通过绘制一些简单的三角形,向你战士了 WebGL 的诸多特性。你对绘制三维对象的基础只是应该已经有了足够的了解。下面,我们就来绘制如下图所示的立方体。其8个顶点的颜色分别为白色、品红色、红色、黄色、绿色、青色、蓝色、黑色。在第5章层提过,为每个顶点定义颜色后,便面上的颜色会根据顶点颜色内插出来,形成一种光滑的渐变效果,新的程序名为 HelloCube。
目前,我们都是调用 gl.drawArrays()方法来进行绘制操作的。考虑以下,如何用该函数绘制出一个立方体呢。我们只能使用 gl.TRIANGLES、gl.TRIANGLE_STRIP 或者 gl.TRIANGLE_FAN 模型来绘制三角形,那么最简单也就最直接的方法就是,通过绘制两个三角形来拼成立方体的一个矩形表面。换句话说,为了绘制四个顶点(v0, v1, v2, v3)组成的矩形表面,你可以分别绘制三角形(v0, v1, v2)和三角形(v0, v2, v3)。对立方体的所有表面都这样做就绘制出了整个立方体。在这种情况下,缓冲区内的顶点坐标应该是这样的:
立方体的每一面由两个三角形组成,每个三角形由三个顶点,所以每个面需要用6个顶点。立方体共有6个面,一共需要36个顶点。将36个顶点的数据写入缓冲区,再调用 gl.drawArrays(gl.TRIANGLES, 0, 36) 就可以绘制处立方体。问题是,立方体实际只有8个顶点,而我们却定义了36个之多,这是因为每个顶点会被多个三角形公用。
或者,你也可以使用 gl.TRIANGLE_FAN 模式来绘制立方体。在 gl.TRIANGLE_FAN 模式下,用4个顶点(v0, v1, v2, v3)就可以绘制出一个四方形,所以你只需要4x6=24个顶点。但是,如果这样做你就必须为立方体的每个面调用一次 gl.drawArrays(),一共需要6次调用。所以,两种绘制模式各有优缺点,没有一种是完美的。
如你所愿,WebGL 确实提供了一种完美的方案:gl.drawElements()。使用该函数替代 gl.drawArryas()函数进行绘制,能够避免重复定义顶点,保持顶点数量最小。为此,你需要知道模型的每一个顶点的坐标,这些顶点坐标描述了整个模型。
我们将立方体拆成顶点和三角形。立方体被拆成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.bindBudder()和 gl.bufferData()来进行上述操作,只不过参数 target 要改为 gl.ELEMENT_ARRAY_BUFFER。
示例程序(HelloCube.js)
HelloCube.js
//顶点着色器程序
var VSHADER_SOURCE =
'attribute vec4 a_Position;'+
'attribute vec4 a_Color;'+
'uniform mat4 u_MvpMatrix;'+
'varying vec4 v_Color;'+
'void main(){'+
'gl_Position = u_MvpMatrix * a_Position;'+
'v_Color = a_Color;'+
'}';
//片元着色器程序
var FSHADER_SOURCE=
'#ifdef GL_ES\n' +
'precision mediump float;\n' +
'#endif\n' +
'varying vec4 v_Color;' +
'void main() {'+
'gl_FragColor = v_Color;'+
'}';
function main() {
//获取canvas元素
var canvas = document.getElementById("webgl");
if(!canvas){
console.log("Failed to retrieve the <canvas> element");
return;
}
//获取WebGL绘图上下文
var gl = getWebGLContext(canvas);
if(!gl){
console.log("Failed to get the rendering context for WebGL");
return;
}
//初始化着色器
if(!initShaders(gl,VSHADER_SOURCE,FSHADER_SOURCE)){
console.log("Failed to initialize shaders.");
return;
}
//设置顶点位置
var n = initVertexBuffers(gl);
if (n < 0) {
console.log('Failed to set the positions of the vertices');
return;
}
//指定清空<canvas>颜色
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.enable(gl.DEPTH_TEST);
//获取 u_ViewMatrix 、u_ModelMatrix和 u_ProjMatrix 变量的存储位置
var u_MvpMatrix = gl.getUniformLocation(gl.program, 'u_MvpMatrix');
if(u_MvpMatrix < 0){
console.log("Failed to get the storage location of u_MvpMatrix");
return;
}
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);
gl.clear(gl.COLOR_BUFFER_BIT || gl.DEPTH_BUFFER_BIT);
//绘制立方体
gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0);
}
function initVertexBuffers(gl) {
var verticesColors = new Float32Array([
//顶点坐标和颜色
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, // v0 白色
-1.0, 1.0, 1.0, 1.0, 0.0, 1.0, // v1 品红色
-1.0, -1.0, 1.0, 1.0, 0.0, 0.0, // v2 红色
1.0, -1.0, 1.0, 1.0, 1.0, 0.0, // v3 黄色
1.0, -1.0, -1.0, 0.0, 1.0, 0.0, // v4 绿色
1.0, 1.0, -1.0, 0.0, 1.0, 1.0, // v5 青色
-1.0, 1.0, -1.0, 0.0, 0.0, 1.0, // v6 蓝色
-1.0, -1.0, -1.0, 0.0, 0.0, 0.0 // v7 黑色
]);
//顶点索引
var indices = new Uint8Array([
0, 1, 2, 0, 2, 3, // 前
0, 3, 4, 0, 4, 5, // 右
0, 5, 6, 0, 6, 1, // 上
1, 6, 7, 1, 7, 2, // 左
7, 4, 3, 7, 3, 2, // 下
4, 7, 6, 4, 6, 5 // 后
]);
//创建缓冲区对象
var vertexColorBuffer = gl.createBuffer();
var indexBuffer = gl.createBuffer();
if(!vertexColorBuffer || !indexBuffer){
console.log("Failed to create thie buffer object");
return -1;
}
//将缓冲区对象保存到目标上
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');
if(a_Position < 0){
console.log("Failed to get the storage location of a_Position");
return -1;
}
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, FSIZE*6, 0);
gl.enableVertexAttribArray(a_Position);
var a_Color = gl.getAttribLocation(gl.program, 'a_Color');
if(a_Color < 0){
console.log("Failed to get the storage location of a_Color");
return -1;
}
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()函数的流程与 ProjectiveView_mvpMatrix.js 一样,回顾一下。我们首先调用 initVertexBuffers()函数将顶点数据写入缓冲区,然后开启隐藏面消除,使 WebGL 能够根据立方体各表面的前后关系正确地进行绘制。
接着,设置视点和可视空间,把模型视图投影矩阵传给顶点着色器中的 u_MvpMatrix 变量。
最后,清空颜色和深度缓冲区,使用 gl.drawElements()绘制立方体。还函数的使用方法和效果是本例与 ProjectiveView_mvpMatrix.js 的主要区别,来看一下。
向缓冲区写入顶点的坐标、颜色和索引
本例的 initVertexBuffers()函数通过缓冲区对象 verticesColors 向顶点着色器中的 attribute 变量传顶点坐标和颜色信息,这一点与之前无异。但是,本例不再按照 verticesColors 中的顶点顺序来进行绘制,所以必须额外注意每个顶点的索引值,我们要通过索引值来指定绘制的顺序。比如说,第一个顶点的索引为0,第2个顶点的索引为1,等等。
function initVertexBuffers(gl) {
var verticesColors = new Float32Array([
//顶点坐标和颜色
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, // v0 白色
-1.0, 1.0, 1.0, 1.0, 0.0, 1.0, // v1 品红色
-1.0, -1.0, 1.0, 1.0, 0.0, 0.0, // v2 红色
1.0, -1.0, 1.0, 1.0, 1.0, 0.0, // v3 黄色
1.0, -1.0, -1.0, 0.0, 1.0, 0.0, // v4 绿色
1.0, 1.0, -1.0, 0.0, 1.0, 1.0, // v5 青色
-1.0, 1.0, -1.0, 0.0, 0.0, 1.0, // v6 蓝色
-1.0, -1.0, -1.0, 0.0, 0.0, 0.0 // v7 黑色
]);
//顶点索引
var indices = new Uint8Array([
0, 1, 2, 0, 2, 3, // 前
0, 3, 4, 0, 4, 5, // 右
0, 5, 6, 0, 6, 1, // 上
1, 6, 7, 1, 7, 2, // 左
7, 4, 3, 7, 3, 2, // 下
4, 7, 6, 4, 6, 5 // 后
]);
//创建缓冲区对象
var vertexColorBuffer = gl.createBuffer();
var indexBuffer = gl.createBuffer();
if(!vertexColorBuffer || !indexBuffer){
console.log("Failed to create thie buffer object");
return -1;
}
//将缓冲区对象保存到目标上
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');
if(a_Position < 0){
console.log("Failed to get the storage location of a_Position");
return -1;
}
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, FSIZE*6, 0);
gl.enableVertexAttribArray(a_Position);
var a_Color = gl.getAttribLocation(gl.program, 'a_Color');
if(a_Color < 0){
console.log("Failed to get the storage location of a_Color");
return -1;
}
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;
}
也许你会质疑道,缓冲区对象 indexBuffer 中的数据来自于数组 indices,该数组以索引值的形式存储了绘制顶点的顺序。索引值是整型数,所以数组的类型是 Unit8Array。如果由超过256个顶点,那么应该使用 Uint16Array。indices 中的元素如下图中的三角形列表所示,每3个索引值为1组,指向3个顶点,由这3个顶点组成一个三角形。通常我们不需要手动创建这点顶点和索引数据,因为三维建模工具在第10章会帮助我们创建它们。
绑定缓冲区,以及向缓冲区写入索引数据的过程与之前示例程序中的很类似,区别就是绑定的目标由 gl.ARRAY_BUFFER 变成了 gl.ELEMENT_ARRAY_BUFFER。这个参数告诉 WebGL,该缓冲区中的内容是定点的索引值数据。
此时,WebGL 系统的内部状态如下图所示:
最后,我们调用 gl.drawElements(),就绘制出了立方体。
gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0);
gl.drawElements()时,WebGL 首先从绑定到 gl.ELEMENT_ARRAY_BUFFER 的缓冲区中获取顶点的索引值,然后根据该索引值,从绑定到 gl.ARRAY_BUFFER 的缓冲区中获取顶点的坐标、颜色等信息,然后传递给 attribute 变量并执行顶点着色器。对每个索引值都这样做,最后就绘制出了整个立方体,而此时你只调用了一次 gl.drawElements()。这种方式通过索引来访问顶点数据,从而循环利用顶点信息,控制内存的开销,但代价是你需要通过索引来间接地访问顶点,在某种程度上使程序复杂化了。所以,gl.drawElements()和 gl.drawArrays()各有优劣,具体用哪一个取决于具体的系统需求。
虽然我们已经证明了 gl.drawElements()是高效的绘制三维图形的方式,但还是漏了关键的一点:我们无法通过将颜色定义在索引值上,颜色仍然是依赖于顶点的。
考虑这样的情况:我们希望立方体的每个表面都是不同的单一颜色(而非颜色渐变效果)或者纹理图像。我们需要把每个面的颜色或纹理信息写入三角形、索引和顶点数据中。
为立方体的每个表面指定颜色
我们知道,顶点着色器进行的是逐顶点的计算,接受的是逐顶点的信息。这说明,如果你想指定表面的颜色,你也需要将颜色定义为逐顶点的信息,并传给顶点着色器。举个例子,你想把立方体的前表面涂成蓝色,前表面由顶点v0、v1、v2、v3组成,那么你就需要将这4个顶点都指定为蓝色。
但是你会发现,顶点v0 不仅在前表面上,也在右表面上和上表面上,如果你将 v0 指定为蓝色,那么它在另外两个表面上也会是蓝色,这不是我们想要的而结果。为了解决这个问题,我们需要创建多个具有相同顶点坐标的点,如下图所示。如果这样做,你就必须把那些具有相同坐标的顶点分开处理。
此时三角形列表,也就是顶点索引值序列,对每个面都指向一组不同的顶点,不再有前表面和上表面共享一个顶点的情况,这样一来,就可以实现前述的结果,为每个表面涂上不同的单色。我们也可以使用类似的方法为立方体的每个表面贴上不同的纹理,只需将上图的颜色值换成纹理坐标即可。
来看一下示例程序 ColoredCube 的代码,它绘制出了一个立方体,其每个表面涂上了不同的颜色。
示例程序(ColoredCube .js)
本例与 HelloCube.js 的主要区别是在于顶点数据存储在缓冲区中的形式:
- 在 HelloCube.js 中,顶点的坐标和颜色数据存储在同一个缓冲区中。虽然有着种种好处,但这样做略显笨重,本例中我们将顶点的坐标和颜色分别存储在不同的缓冲区中。
- 顶点数组、颜色数组和索引素组按照上图的配置进行了修改。
- 为了程序结构紧凑,定义了函数 initArrayBuffer(),封装了缓冲区对象的创建、绑定、数据写入和开启等操作。
ColoredCube.js
//顶点着色器程序
var VSHADER_SOURCE =
'attribute vec4 a_Position;'+
'attribute vec4 a_Color;'+
'uniform mat4 u_MvpMatrix;'+
'varying vec4 v_Color;'+
'void main(){'+
'gl_Position = u_MvpMatrix * a_Position;'+
'v_Color = a_Color;'+
'}';
//片元着色器程序
var FSHADER_SOURCE=
'#ifdef GL_ES\n' +
'precision mediump float;\n' +
'#endif\n' +
'varying vec4 v_Color;' +
'void main() {'+
'gl_FragColor = v_Color;'+
'}';
function main() {
//获取canvas元素
var canvas = document.getElementById("webgl");
if(!canvas){
console.log("Failed to retrieve the <canvas> element");
return;
}
//获取WebGL绘图上下文
var gl = getWebGLContext(canvas);
if(!gl){
console.log("Failed to get the rendering context for WebGL");
return;
}
//初始化着色器
if(!initShaders(gl,VSHADER_SOURCE,FSHADER_SOURCE)){
console.log("Failed to initialize shaders.");
return;
}
//设置顶点位置
var n = initVertexBuffers(gl);
if (n < 0) {
console.log('Failed to set the positions of the vertices');
return;
}
//指定清空<canvas>颜色
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.enable(gl.DEPTH_TEST);
//获取 u_ViewMatrix 、u_ModelMatrix和 u_ProjMatrix 变量的存储位置
var u_MvpMatrix = gl.getUniformLocation(gl.program, 'u_MvpMatrix');
if(u_MvpMatrix < 0){
console.log("Failed to get the storage location of u_MvpMatrix");
return;
}
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);
gl.clear(gl.COLOR_BUFFER_BIT || gl.DEPTH_BUFFER_BIT);
//绘制立方体
gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0);
}
function initVertexBuffers(gl) {
// v6----- v5
// /| /|
// v1------v0|
// | | | |
// | |v7---|-|v4
// |/ |/
// v2------v3
var vertices = new Float32Array([ //顶点坐标
1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0,-1.0, 1.0, 1.0,-1.0, 1.0, // v0-v1-v2-v3
1.0, 1.0, 1.0, 1.0,-1.0, 1.0, 1.0,-1.0,-1.0, 1.0, 1.0,-1.0, // v0-v3-v4-v5
1.0, 1.0, 1.0, 1.0, 1.0,-1.0, -1.0, 1.0,-1.0, -1.0, 1.0, 1.0, // v0-v5-v6-v1
-1.0, 1.0, 1.0, -1.0, 1.0,-1.0, -1.0,-1.0,-1.0, -1.0,-1.0, 1.0, // v1-v6-v7-v2
-1.0,-1.0,-1.0, 1.0,-1.0,-1.0, 1.0,-1.0, 1.0, -1.0,-1.0, 1.0, // v7-v4-v3-v2
1.0,-1.0,-1.0, -1.0,-1.0,-1.0, -1.0, 1.0,-1.0, 1.0, 1.0,-1.0 // v4-v7-v6-v5
]);
var colors = new Float32Array([ // 颜色
0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0,
0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4,
1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4,
1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0
]);
var indices = new Uint8Array([ // 顶点索引
0, 1, 2, 0, 2, 3,
4, 5, 6, 4, 6, 7,
8, 9,10, 8,10,11,
12,13,14, 12,14,15,
16,17,18, 16,18,19,
20,21,22, 20,22,23
]);
//创建缓冲区对象
var indexBuffer = gl.createBuffer();
if(!indexBuffer){
console.log("Failed to create thie buffer object");
return -1;
}
if (!initArrayBuffer(gl, vertices, 3, gl.FLOAT, 'a_Position'))
return -1;
if (!initArrayBuffer(gl, colors, 3, gl.FLOAT, 'a_Color'))
return -1;
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
return indices.length;
}
function initArrayBuffer(gl, data, num, type, attribute) {
var buffer = gl.createBuffer();
if(!buffer){
console.log("Failed to create thie buffer object");
return -1;
}
//将缓冲区对象保存到目标上
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
//向缓存对象写入数据
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
var a_attribute = gl.getAttribLocation(gl.program,attribute);
if(a_attribute < 0){
console.log("Failed to get the storage location of " + attribute);
return -1;
}
gl.vertexAttribPointer(a_attribute, num, type, false, 0, 0);
gl.enableVertexAttribArray(a_attribute);
return true;
}
运行一下: