WebGL 手把手入门指南(五)绘制立方体

avatar

通过上面几章的内容,你可以知道怎么绘制二维图形,看起来这些Canvas也能轻松实现,之前介绍WebGL的坐标是三维的,这一章我们应用z轴来进入三维世界

一、视图矩阵

在三维空间绘制的图形最终还是需要映射到二维的屏幕上,我们需要引入一台摄像机把看到的三维空间景象转换到屏幕上。

对于摄像机的摆放我们需要确定三个点

  • 视点:摄像机所在的三维空间的位置。默认值为(0,0,0)。
  • 观察目标点:摄像机观察目标点的位置,视点对目标点的矢量其实就是我们理解的视线方向。默认值为(0,0,-1)。
  • 上方向:上面两个点确定了视线,但是我们的摄像机还是可以原地偏移的,为了防止摄像机偏移,我们还需要确定上方向,这样才能固定产出一个画面。默认值为(0,1,0)

在WebGL中我们通常把这三个矢量创建成矩阵叫做视图矩阵(view matrix),生成视图矩阵很简单,可以通过js库来帮我们生成。每一个点都需要被视图矩阵相乘,才能得到最后展示的画面。code

// 顶点着色器
const VSHADER_SOURCE = `
  attribute vec4 a_Position;
  uniform mat4 u_ViewMatrix;
  void main() {
    gl_Position = u_ViewMatrix * a_Position;
  }
`
// 通过库函数创建矩阵对象
const viewMatrix = new Matrix4()
// 设置视图矩阵,视点,目标点,上方向
viewMatrix.setLookAt(0.5,1,0.5, 0,0,-1, 0,1,0)

const u_ViewMatrix = gl.getUniformLocation(gl.program, 'u_ViewMatrix')
gl.uniformMatrix4fv(u_ViewMatrix, false, viewMatrix.elements)

二、投影矩阵

上面的例子我们会发现,在设置一些视图矩阵的时候,图形不能完整的展示在画布上,比如上面的三角形缺了一角。原因是,虽然你可以把物体放在三维空间的任意位置,但是只有在可视空间内WebGL才会绘制它,这也是为了性能着想。

常用的可视空间有两种:

  • 正射投影(orthographic projection)是一个长方体的空间物体的远近不会影响大小。
  • 透视投影(perspective projection)是一个梯形体的可视空间能够展现出物体的近大远小的效果。

两种投影方式都可以想象是一个盒子,盒子里面展示,盒子外面隐藏,盒子靠近视角的一个面叫做近裁剪面(near clipping plane)和远裁剪面(far clipping plane)。

2.1 正射投影

正射投影通过六个数值来固定,观察的视线是确定的,左距离、右距离、上距离、下距离可以确定一个垂直视线的面,然后定义近裁剪面和远裁剪面到视点的距离,就可以确定唯一的一个长方体了,这就是正射投影。

使用正射投影矩阵也很简单,正射投影矩阵x顶点坐标,下面的demo可以通过上下左右按键来控制近远截面的距离来展示三角形。code

// 通过库函数创建矩阵对象
const projMatrix = new Matrix4();
let near = 0.1;
let far = 0.9;
// 设置投影矩阵 左右下上近远
projMatrix.setOrtho(-1, 1, -1, 1, near, far);

const u_ProjMatrix = gl.getUniformLocation(gl.program, "u_ProjMatrix");

document.addEventListener("keydown", (e) => {
	// 左
	if (e.keyCode === 37) near -= 0.1;
	// 右
	if (e.keyCode === 39) near += 0.1;
	// 上
	if (e.keyCode === 38) far += 0.1;
	// 下
	if (e.keyCode === 40) far -= 0.1;
	draw();
});
draw();
function draw() {
  // 设置新的投影
	projMatrix.setOrtho(-1, 1, -1, 1, near, far);
	console.log("near:", +near.toFixed(1), ",far:", +far.toFixed(1));
	gl.uniformMatrix4fv(u_ProjMatrix, false, projMatrix.elements);
	gl.clear(gl.COLOR_BUFFER_BIT);
	// 绘制方式; 开始顶点; 总顶点数;
	gl.drawArrays(gl.TRIANGLES, 0, 3);
}

2.2 透视投影

正射投影你可能看不出立体感,透视投影的近大远小的特性就能让物体有一定的立体感。首先看看怎么设置透视投影。

先确定一个可视空间上下的夹角,然后确定远近截面的矩形的宽高比其实就是Canvas的画布比,最后确定远近截面的距离就可以确定出一个透视投影了。

下面的例子中,在y轴两侧各放三个一样大且z轴不等的三角形,在透视投影我们会看到如下近大远小的效果。code

// 开启隐藏面消除功能(后面再做说明)
// 能够隐藏被遮盖的部分
gl.enable(gl.DEPTH_TEST);
// 清除深度缓存区
gl.clear(gl.DEPTH_BUFFER_BIT);

// 设置透视投影矩阵
viewMatrix
	// 角度\宽高比\近裁面\远裁面
	.setPerspective(30, 1, 1, 100)
	.lookAt(0, 0, 5, 0, 0, -100, 0, 1, 0);

可以看到,虽然三角形都是一样大,但是距离屏幕越近的会看上去越大,同时两边的三角形都在一条直线上,在视觉上会往中间靠近,能够看到相同位置后面的三角形。我们可以通过viewMatrix.elements打印出投影矩阵看看,如下图,可以看到,其实就是对三角形进行了位移和缩放

2.3 投影的画布比

投影矩阵的画布比是按照canvas的画布比给出,如果不按照canvas的画布比的话会是怎么样的呢,我们可以调整投影矩阵的画布比为2,canvas是正方形,setPerspective(30, 2, 1, 100)可以看出图片被压缩了,x轴压缩了一倍。所以我们绘制图形是需要和canvas保持一致的画布比

我在学投影矩阵的时候就有个疑问,为什么正射投影的传参是上下左右距离,而透视投影传参是用比例呢?

我理解是,在正射投影时,如果设置左边大于右边的话视线会在画布偏右边的位置,作为矩形体还是很好计算的。

对应透视投影,如果锥形体画布中心不是视角中心的话,计算难度会大大增加,最终得到的效果也很难直接想象出来,所以就采取了简单的比例,并不是webgl不能支持。其实在webgl中并没有设置投影的方法,他只认坐标和矩阵,这两种投影只是人们常用就总结出来了。

2.4 正确处理物体前后关系

在上面的投影设置时,开启了隐藏面消除功能,可以先试试如果不开启会是什么样的,如下图:

为什么会是上面的样子呢,在设置点的时候远处的点是后设置的,webgl为了加速绘图操作,只会根据渲染顺序来确定前后关系,所以远处的三角形会在上面。

对于一些静止的画面我们可以控制绘制的顺序,如果画面物体层叠而且运动我们就不好提前计算了,为此webgl提供了隐藏面消除功能,开启隐藏面消除需要两个步骤:

  • 开启隐藏面消除功能gl.enable(gl.DEPTH_TEST);
  • 清除深度缓存区gl.clear(gl.DEPTH_BUFFER_BIT); webgl想要进行隐藏面消除,就需要知道每个图形的深度信息,这个信息就储存在深度缓冲区,然后才能把几何图形绘制在颜色缓冲区最后显示在Canvas上,在每一帧前都需要清除上一帧储存的颜色和深度缓冲区,这样才不会出现错误。

三、绘制三维立方体

三角形还只是一个平面图形,下面来讲讲怎么做出一个立方体。其实上面的知识能够让你做出一个立方体了,不过立方体六个面,画三角形你需要定义32个顶点,但是在我们认知中三角形就只有8个顶点,显然webgl考虑到了这一点,提供了gl.drawElements来绘制图形,它能重复利用顶点,大大减小数据量和复杂度

gl.drawElements是通过顶点+索引值的方式来绘制图形,我们把立方体分为六个面,每个面通过两个三角形构成,两个三角形需要六个顶点的索引值,下图就是组织顶点和索引数据的过程。

通过正常方式使用gl.ARRAY_BUFFER传递顶点和颜色值,特殊的是需要在gl.ELEMENT_ARRAY_BUFFER缓冲区中写入索引值。然后drawElements的参数分别是:绘制方式、顶点数量、索引数据类型、开始绘制位置code

// 从gl.ELEMENT_ARRAY_BUFFER的缓冲区冲拿到索引值, 通过索引值去gl.ARRAY_BUFFER的缓冲区获取顶点坐标
// 绘制方式\顶点数量\索引数据类型\开始绘制位置
gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0)

function initVertexBuffers(gl) {
  const verticesColor = new Float32Array([
    1, 1, 1, 1, 1, 1, //顶点 + 颜色
    -1, 1, 1, 1, 0, 1,
    -1, -1, 1, 1, 0, 0,
    1, -1, 1, 1, 1, 0,
    1, -1, -1, 0, 1, 0,
    1, 1, -1, 0, 1, 1,
    -1, 1, -1, 0, 0, 1,
    -1, -1, -1, 0, 0, 0
  ])

  // 顶点索引
  const 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, // 后
  ])

  // 省略创建缓冲区传递给变量a_Position和a_Color

  // 把顶点索引数据写入缓冲区对象
  const indexBuffer = gl.createBuffer()
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW)

  return indices.length
}

可以看到结果是一个非常绚丽的立方体,大家可以思考思考,现在是一个顶点一个颜色,怎么样能做到每个面都是一个颜色?