盒子 / 面剔除
上篇文章我们学到了如何变换物体,现在我们可以让着色器中的立方体动起来了。
为了看见立方体所有的面,我们让立方体绕 X 和 Y 轴旋转。上篇文章中我们总结了各种旋转矩阵,我们可以创建一个工具矩阵类,以后可以直接使用这些工具矩阵方法。
class Mat4 { static fromXRotation(rad) { const s = Math.sin(rad) const c = Math.cos(rad) return [ 1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1 ] }
static fromYRotation(rad) { const s = Math.sin(rad) const c = Math.cos(rad) return [ c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1 ] }
static multiply(a, b, out = []) { const a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]; const a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]; const a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11]; const a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15];
let b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3]; out[0] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; out[1] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; out[2] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; out[3] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
b0 = b[4]; b1 = b[5]; b2 = b[6]; b3 = b[7]; out[4] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; out[5] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; out[6] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; out[7] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
b0 = b[8]; b1 = b[9]; b2 = b[10]; b3 = b[11]; out[8] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; out[9] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; out[10] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; out[11] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
b0 = b[12]; b1 = b[13]; b2 = b[14]; b3 = b[15]; out[12] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; out[13] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; out[14] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; out[15] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; return out; }}这里直接使用上篇文章中的旋转矩阵,需要注意这里将它转置了一下,因为 OpenGL 中的矩阵是列主序的。
下面在着色器中的立方体代码的基础上添加新的代码。
const gl = createGl()
const program = createProgramFromSource(gl, `attribute vec4 aPos;attribute vec4 aColor;uniform mat4 uMat;varying vec4 vColor;
void main() { gl_Position = uMat * aPos; vColor = aColor;}`, `precision mediump float;varying vec4 vColor;
void main() { gl_FragColor = vColor;}`)
const points = new Float32Array([ -0.5,0.5,-0.5, 0.5,0.5,-0.5, 0.5,-0.5,-0.5, -0.5,-0.5,-0.5, 0.5,0.5,0.5, -0.5,0.5,0.5, -0.5,-0.5,0.5, 0.5,-0.5,0.5 // 立方体的 8 个顶点])const colors = new Float32Array([ 1,0,0, 0,1,0, 0,0,1, 1,0,1, 0,0,0, 0,0,0, 0,0,0, 0,0,0 // 每个顶点的颜色])const indices = new Uint8Array([ // 面的索引,值是 points 的下标 0, 1, 2, 0, 2, 3, // 前 1, 4, 2, 4, 7, 2, // 右 4, 5, 6, 4, 6, 7, // 后 5, 3, 6, 5, 0, 3, // 左 0, 5, 4, 0, 4, 1, // 上 7, 6, 3, 7, 3, 2 // 下])
const [posLoc, posBuffer] = createAttrBuffer(gl, program, 'aPos', points)const [colorLoc, colorBuffer] = createAttrBuffer(gl, program, 'aColor', colors)const indexBuffer = gl.createBuffer()gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer)gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 0, 0)gl.enableVertexAttribArray(posLoc)
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer)gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, 0, 0)gl.enableVertexAttribArray(colorLoc)
const matLoc = gl.getUniformLocation(program, 'uMat')
gl.enable(gl.DEPTH_TEST)gl.clearColor(0, 0, 0, 0)
let r = 0function draw() { gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) gl.uniformMatrix4fv(matLoc, false, Mat4.multiply(Mat4.fromXRotation(r), Mat4.fromYRotation(r))) gl.drawElements(gl.TRIANGLES,indices.length,gl.UNSIGNED_BYTE,0) r += 0.01 requestAnimationFrame(draw)}draw()
function createShader(gl, type, source) { const shader = gl.createShader(type) gl.shaderSource(shader, source) gl.compileShader(shader) return shader;}
function createProgramFromSource(gl, vertex, fragment) { const vertexShader = createShader(gl, gl.VERTEX_SHADER,vertex) const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragment) const program = gl.createProgram() gl.attachShader(program, vertexShader) gl.attachShader(program, fragmentShader) gl.linkProgram(program) gl.useProgram(program) return program}
function createAttrBuffer(gl, program, attr, data) { const location = gl.getAttribLocation(program, attr) const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer) gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW) return [location, buffer]}上面代码中通过 uniformMatrix4fv 给顶点着色器传递旋转矩阵,它的第二个参数为是否转置矩阵,因为我们的矩阵就是列主序的,所以无需转置。
上篇文章中介绍了使用矩阵的好处之一就是可以组合变换,这里我们用 Mat4.multiply(Mat4.fromXRotation(r), Mat4.fromYRotation(r)) 将两个旋转结合起来,然后在应用到立方体的各个顶点,让立方体绕 X 和 Y 轴旋转起来。
Box#
我们发现渲染出来立方体是渐变色的,这是因为我们指定了 8 个顶点的颜色,每个顶点颜色都不同,WebGL 会将顶点着色器传递给片段着色器的颜色进行插值。
如果我们想要每个面都是不同色纯色,我们需要指定每个面的颜色值相同,这样 WebGL 对相同颜色直接进行插值,还是相同的颜色。为了实现这个效果我们就不能共用顶点了,因为每个面颜色都不同。
我们需要单独创建 6 个面然后将这个 6 个面组合起来。为了方便后面使用,我们将它封装成通用函数。
function createBox(width = 1, height = 1, depth = 1, widthSeg = 1, heightSeg = 1, depthSeg = 1) { const segWidth = width / widthSeg const segHeight = height / heightSeg const segDepth = height / depthSeg const halfWidth = width / 2 const halfHeight = height / 2 const halfDepth = depth / 2
const position = [] const index = []
let numVertex = 0 // front, bottom buildPlane(widthSeg, heightSeg, segWidth, segHeight, halfWidth, halfHeight, halfDepth, 1, -1, 0, 1, 2) buildPlane(widthSeg, heightSeg, segWidth, segHeight, halfWidth, halfHeight, -halfDepth, -1, -1, 0, 1, 2)
// left, right buildPlane(depthSeg, heightSeg, segDepth, segHeight, halfDepth, halfHeight, -halfWidth, 1, -1, 2, 1, 0) buildPlane(depthSeg, heightSeg, segDepth, segHeight, halfDepth, halfHeight, halfWidth, -1, -1, 2, 1, 0)
// top, bottom buildPlane(widthSeg, depthSeg, segWidth, segDepth, halfWidth, halfDepth, halfHeight, 1, 1, 0, 2, 1) buildPlane(widthSeg, depthSeg, segWidth, segDepth, halfWidth, halfDepth, -halfHeight, 1, -1, 0, 2, 1)
function buildPlane(uSeg, vSeg, uLen, vLen, halfU, halfV, depth, uDir, vDir, ix, iy, iz) { const maxU = uSeg + 1 const maxV = vSeg + 1
let x, y, pos = [] for (let i = 0; i < maxV; i++) { y = i * vLen - halfV for (let j = 0; j < maxU; j++) { x = j * uLen - halfU pos[ix] = x * uDir pos[iy] = y * vDir pos[iz] = depth position.push(...pos) } }
let a, b, c, d for (let i = 0; i < vSeg; i++) { for (let j = 0; j < uSeg; j++) { a = numVertex + j + maxU * i b = numVertex + j + maxU * (i + 1) c = b + 1 d = a + 1 index.push(a, b, c, a, c, d) } }
numVertex += (maxU * maxV) }
return { position: new Float32Array(position), index: numVertex > 65536 ? new Uint32Array(index) : new Uint16Array(index), }}我们可以指定盒子的宽度、高度和深度,并且还可以设置每个面的有几行和几列。它会返回一个对象里面包含顶点位置和索引。
上面代码有点复杂,需要花点时间消化一下,基本思路是分别构建盒子的 6 个面。比如在构建正面时,我们将面移动到坐标轴的正中间并反转 Y 轴,这样我们就可以从左上角第一个点作为起点。需要注意的是我们上面采用的是右手坐标系,正面的 Z 正值,背面是负值。
现在我们需要指定每个面的颜色。
const box = createBox()let colors = []let size = box.position.length / 6 / 3pushColor(1, 0, 0)pushColor(0, 1, 0)pushColor(0, 0, 1)pushColor(1, 1, 0)pushColor(0, 1, 1)pushColor(1, 0, 1)function pushColor(r, g, b) { for (let i = 0; i < size; i++) { colors.push(r, g, b) }}colors = new Float32Array(colors)看上方的渲染结果,不知道有没有同学注意到我们将正面设置为红色,背面设置为绿色,但是渲染出来却是绿色的面在前。这是因为 createBox 中使用的是右手坐标系,这也是 OpenGL 传统坐标系,不过在坐标系中有强调,标准坐标系中实际上是左手坐标系,Z 值小的在 Z 值大的前面。
面剔除#
上面渲染的立方体,我们一次并不能看见它的所有的面,只能看见 3 个面,但是我们却渲染了所有的面。
为了节省性能,我们可以只渲染用户能看到的面,看不到的面就不渲染了。为了做到这一点我们可以只渲染正面面向用户的面,而丢弃面向用户的面是背面的面。当然 OpenGL 提供了面剔除这个功能,OpenGL 能够检查所有面向观察者的面,并渲染它们,而丢弃那些背向的面。
我们需要让 OpenGL 能够区分哪个面是正面,哪个面是背面。我们已经知道那些复杂模型都是由一个个三角形组成,我们要区分面的正面和背面,也就是区分这一个个三角形的正面和背面。
我们可以通过三角形各个顶点的顺序来搞告诉 OpenGL 哪个是正向三角形哪个是背向三角形。

有两种方式描述三角形三个顶点的顺序,分别是顺时针和逆时针。默认情况 OpenGL 将逆时针顺序的三角形处理为正向三角形。我们同样可以伸出右手握拳,然后伸出大拇指,和坐标系中一样,手指的弯曲和三角形的顶点的顺序一样,如果大拇指指向你说明这个面是正面会被渲染,否则是背面会被剔除。
默认情况面剔除是关闭的,我们需要启用它。
gl.enable(gl.CULL_FACE)我们还可以指定剔除哪个面,默认是 gl.BACK。
gl.cullFace(gl.FRONT)gl.cullFace(gl.BACK)gl.cullFace(gl.FRONT_AND_BACK)我们还能指定什么顺序表示的是正面,默认是 gl.CCW 表示逆时针。
gl.frontFace(gl.CW)gl.frontFace(gl.CCW)现在我们回过头,再来看上面的 createBox 函数,在 buildPlane 中第一个循环计算出了面的顶点位置,第二个循环中计算了面的索引,我们求出了每个小格子的 4 个顶点,分别是 a,b,c 和 d,如下图所示。

然后是 index.push(a, b, c, a, c, d),需要注意这个顺序非常重要,因为逆时针为正面,a、b 和 c 构造了一个逆时针顺序的三角形,a、c 和 d 构成了另一个逆时针三角形,两个三角形组成了一个正方形。
如果我们将 index.push(a, b, c, a, c, d) 变成 index.push(a, b, c, a, d, c) 将第二个三角形变成顺时针,那么渲染的立方体,将会是下面这样。
