# 一、相机

​ 想要观察 3 维的世界,首先需要确定在哪里观察,这个哪里指的是相机的位置也即视点,此外还需要确定相机往那个方向拍照,为此需要确定观察点,将观察点与视点连线即可确定拍照的方向。最后还需要确定相机的朝向,是正着拍还是倒着拍,绕着 z 轴转了多少度,描述相机的朝向即上方向。当有了视线和上方向就可以对相机建系,另一个轴用视线叉乘上方向获得,由此就可以对相机建系。

​ 当我们看一个物体时,这时我们向右移动相当于此时我们静止然后物体向左移动。如果有一个相机一开始在世界坐标系的原点,上方向为 y 轴,视线为 - z 轴,此时移动相机,假设这个移动包含平移和旋转,则可以使用一个四阶矩阵描述这次运动,假设这个矩阵是 A。如果有另一个相机没有动,只是 3d 世界上的物体发生了移动,而这个移动如果正好的 A 的逆矩阵的话,此时两个相机看到的画面将会是一样的。因此当我们求出 A 的逆矩阵,再对每一个物体的每一个顶点左乘 A 的逆矩阵,就相当于相机进行了矩阵 A 的运动。如果运动后的相机左乘这个 A 的逆矩阵,便会归回原点。所以我们需要求出一个相机归回原点的矩阵,然后再让空间的每一个物体每个顶点左乘这个矩阵。

​ 想让相机归回原点有两个步骤

  1. 将相机平移到坐标原点。

假设这个相机的视点是 (x0,y0,z0), 左乘以下的矩阵就能将相机平移至原点。

[100x0010y0001z00001]\left[ \begin{matrix} 1 & 0 & 0 & -x_0 \\ 0 & 1 & 0 & -y_0 \\ 0 & 0 & 1 & -z_0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]

  1. 将相机的上方向旋转至 (0,1,0), 将视线旋转至 (0,0,-1)。

假设上方向为 v2 (x2,y2,z2), 视线为 v3 (x3,y3,z3)。此时视线与上方向叉乘获得相机的 x 轴记作 v1,v1=v3xv2.

A[x1x2x30y1y2y30z1z2z300001]=[1000010000100001][x1x2x30y1y2y30z1z2z300001]=A1[1000010000100001]A \left[ \begin{matrix} x_1 & x_2 & x_3 & 0 \\ y_1 & y_2 & y_3 & 0 \\ z_1 & z_2 & z_3 & 0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]= \left[ \begin{matrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & -1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right] \left[ \begin{matrix} x_1 & x_2 & x_3 & 0 \\ y_1 & y_2 & y_3 & 0 \\ z_1 & z_2 & z_3 & 0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]= A^{-1} \left[ \begin{matrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & -1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]

[x1x2x30y1y2y30z1z2z300001]=A1[1000010000100001]\left[ \begin{matrix} x_1 & x_2 & x_3 & 0 \\ y_1 & y_2 & y_3 & 0 \\ z_1 & z_2 & z_3 & 0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]= -A^{-1} \left[ \begin{matrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]

A1=[x1x2x30y1y2y30z1z2z300001]A^{-1}=\left[ \begin{matrix} x_1 & x_2 & x_3 & 0 \\ y_1 & y_2 & y_3 & 0 \\ -z_1 & -z_2 & -z_3 & 0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]

A1=AτA=(A1)τ=[x1y1z10x2y2z20x3y3z300001]A^{-1}=A^τ,A=(A^{-1})^τ=\left[ \begin{matrix} x_1 & y_1 & -z_1 & 0 \\ x_2 & y_2 & -z_2 & 0 \\ x_3 & y_3 & -z_3 & 0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]

[x1y1z10x2y2z20x3y3z300001][100x0010y0001z00001]=[x1y1z1x0x2y2z2y0x3y3z3z00001]\left[ \begin{matrix} x_1 & y_1 & -z_1 & 0 \\ x_2 & y_2 & -z_2 & 0 \\ x_3 & y_3 & -z_3 & 0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right] \left[ \begin{matrix} 1 & 0 & 0 & -x_0 \\ 0 & 1 & 0 & -y_0 \\ 0 & 0 & 1 & -z_0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right] = \left[ \begin{matrix} x_1 & y_1 & -z_1 & -x_0 \\ x_2 & y_2 & -z_2 & -y_0 \\ x_3 & y_3 & -z_3 & -z_0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]

​ 由于旋转矩阵是正交矩阵所以 A 的逆矩阵就是 A 的转置。最后将上面的平移矩阵左乘这个 A 矩阵就是视图矩阵。空间内的顶点需要左乘这个视图矩阵。另外如果物体了移动,有个移动矩阵记作 B,就让顶点坐标先左乘移动矩阵 B 再左乘视图矩阵 A。当然矩阵有结合律,可以先将 AB 结合成一个新的矩阵记作模型视图矩阵,坐标左乘这个模型视图矩阵即可。

# 二、投影

​ 三维世界的物体最终需要在二维平面 (屏幕) 上显示,这时候需要经过投影将三维物体投影至二维平面上。投影的方式有两种,一种是正交投影,物体显示在平面上的大小不会因为相机的位置发生改变,适合观察各类模型。另一种是透视投影,物体显示在平面上的大小会因为相机的位置发生改变,呈现近大远小,符合人眼看世界。

# 1. 正交投影

​ 正交投影有点像将三维物体拍扁,当三维物体每一个点将 z 分量设置为零后,就相当于将物体拍扁在 xoy 平面上。在正交投影下,用户所能观察的区域是一个长方体。长方体分为近面和远面。因为在此之前已经对相机进行了归位,就是将顶点左乘了视图矩阵相当于将相机归位。此时相机在原点,面向 - z 轴,朝向为 y 轴,所以此时观察的区域近面和远面是垂直于 z 轴的。之后需要将可视区域从长方体压缩成边长为 2,中心在原点的立方体。之后会对这个立方体进行投影,当然这个过程不是我们开发者做的,之后再根据用户屏幕的显示进行缩放。

​ 假设近面距离原点为 n,远面距离原点的距离为 f,近面的上下左右的坐标分别为,t,b,l,r。注意这个长方体不一定是 yoz 和 xoz 平面对称的,也即 t 可能不等于 - b,l 可能不等于 - r,将这个长方体平移到原点然后压缩成立方体同样有两个步骤。首先平移到原点然后再压缩。

  1. 平移到原点

​ 这个长方体中间的位置为 (n+f)/2。对这个长方体的 x 分量移动 (r+l)/2,y 分量移动 (t+b)/2,z 分量移动 (n+f)/2,

[100(r+l)/2010(t+b)/2001(n+f)/20001]\left[ \begin{matrix} 1 & 0 & 0 & -(r+l)/2 \\ 0 & 1 & 0 & -(t+b)/2 \\ 0 & 0 & 1 & -(n+f)/2 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]

  1. 缩放成立方体

[2/(rl)00002/(tb)00002/(nf)00001]\left[ \begin{matrix} 2/(r-l) & 0 & 0 & 0 \\ 0 & 2/(t-b) & 0 & 0 \\ 0 & 0 & 2/(n-f) & 0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]

[2/(rl)00002/(tb)00002/(fn)00001][100(r+l)/2010(t+b)/2001(n+f)/20001]=[2/(rl)00(r+l)/(rl)02/(tb)0(t+b)/(tb)002/(fn)(n+f)/(fn)0001]\left[ \begin{matrix} 2/(r-l) & 0 & 0 & 0 \\ 0 & 2/(t-b) & 0 & 0 \\ 0 & 0 & 2/(f-n) & 0 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right] \left[ \begin{matrix} 1 & 0 & 0 & -(r+l)/2 \\ 0 & 1 & 0 & -(t+b)/2 \\ 0 & 0 & 1 & (n+f)/2 \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]= \left[ \begin{matrix} 2/(r-l) & 0 & 0 & -(r+l)/(r-l) \\ 0 & 2/(t-b) & 0 & -(t+b)/(t-b) \\ 0 & 0 & 2/(f-n) & (n+f)/(f-n) \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]

# 2. 透视投影

​ 透视投影与正交投影不同的是,在透视投影下物体会呈现近大远小的特点,可视区域为四棱台。需要先将四棱台压缩成长方体然后再左乘一个正价变换矩阵。

  1. 压缩成长方体

​ 分别在 zoy 和 zox 平面观察四棱台。可以发现 oan 和 obc 是相似三角形。也即 bc/an=oc/on。y/y1=z/n。y1=yn/z。其中 y1 为压缩后的 y。对于 x 分量也一样。x1=xn/z。也就是空间中有一点 (x,y,z) 在经过透视投影矩阵的变换后的坐标为 (nx/z,ny/z,z)。有一点比较讨厌的是,这个 z 是变量与顶点的坐标相关。之后这个投影矩阵还要右乘模型视图矩阵形成 MVP 矩阵。提前将矩阵相乘能够减少运算量,这样顶点就不必每次都乘三个矩阵,乘一个就够了,对于每个顶点来说就少了两次四维向量乘四阶矩阵的运算。对于同一个物体每个顶点来说 MVP 矩阵应该是相同的,MVP 矩阵应该要只与物体移动,相机,和投影相关与物体的顶点位置无关。所以这里要想办法将 z 去掉。这时就要用上齐次坐标。对于齐次坐标 (x,y,z,w) 而言,三维坐标为 (x/w,y/w,z/w)。可以发现如果我们将 w 变成 z,对于三维坐标不就相当于除于 z 了吗,所以需要找到一个矩阵使齐次坐标 (x,y,z,1) 变成 (nx,ny,z*z,z)。

[n0000n0000??0010]\left[ \begin{matrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & ? & ? \\ 0 & 0 & 1 & 0 \\ \end{matrix} \right]

​ 我们很容易写出第一,二,四行。变换后的 z 明显与 x,y 无关,所以第三行第一二列为零。接下来就搞定第三行的三四列。这个地方使用待定系数法。这里取两个点一个在近平面,另一个在远平面。近平面 (x,y,n,1),远平面 (x,y,f,1)。射第三行第三列为 a,第四列为 b。

an+b=n2,af+b=f2,a(fn)=(fn)(f+n),a=f+n,b=fnan+b=n^2,af+b=f^2,a(f-n)=(f-n)(f+n),a=f+n,b=-fn

[n0000n0000f+nfn0010]\left[ \begin{matrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & f+n & -fn \\ 0 & 0 & 1 & 0 \\ \end{matrix} \right]

​ 当然此时如果注意力够集中会发现一个问题就是,不对啊,(f+n)-fnz=z*z,(z-f)(z-n)=0。只有 z 是 n 或 f 才成立,或者说当点的 z 分量不是 n 或者 f 的话,投影后的 z 分量会发生变化。这时候就要讲 z 分量的意义了。z 最重要的是判别物体的远近。如果有两个物体 x 和 y 分量一致,z 分量小的会遮挡 z 分量大。换句话说我们在意 z 分量的具体大小,反正最后都会被投影到一块二维平面上。只要保证在经过投影矩阵变换后,z 分量在 n 到 f 之间,且两个点的 z 分量记作 z1 和 z2。投影前 z1<z2,投影后也要 z1<z2,就行。另外在透视投影下,z 分量还有一个作用就是确定缩放比例,z 的绝对值越大,缩放比例就越大。但是在完成投影矩阵变换后,此时 z 的大小就无所谓了。

  1. 左乘正交投影矩阵

​ 将四棱锥压缩成长方体之后再进行正交投影即可。

[2/(rl)00(r+l)/(rl)02/(tb)0(t+b)/(tb)002/(fn)(n+f)/(fn)0001][n0000n0000f+nfn0010]=[2n/(rl)0(r+l)/(rl)002n/(tb)(t+b)/(tb)000(n+f)(nf)2nf/(nf)0010]\left[ \begin{matrix} 2/(r-l) & 0 & 0 & -(r+l)/(r-l) \\ 0 & 2/(t-b) & 0 & -(t+b)/(t-b) \\ 0 & 0 & 2/(f-n) & (n+f)/(f-n) \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right] \left[ \begin{matrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & f+n & -fn \\ 0 & 0 & 1 & 0 \\ \end{matrix} \right] = \left[ \begin{matrix} 2n/(r-l) & 0 & -(r+l)/(r-l) & 0 \\ 0 & 2n/(t-b) & -(t+b)/(t-b) & 0 \\ 0 & 0 & (n+f)(n-f) & -2nf/(n-f) \\ 0 & 0 & 1 & 0 \\ \end{matrix} \right]

# 三、绘制立方体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
方法一、使用drawArrays方法绘制。但这样做要绘制24个顶点,而立方体一共8个顶点,每个顶点被三个面公用。
const vertexs = 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, //右
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 //左
-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 //下
])
gl.bufferData(gl.ARRAY_BUFFER,vertexs,gl.STATIC_DRAW)
gl.vertexAttribPointer(a_Position,3,gl.FLOAT,false,0,0)
gl.enableVertexAttribArray(a_Position)

gl.drawArrays(gl.TRIANGLE_STRIP,0,24)
方法二、使用drawElements绘制。
//1.创建八个顶点的坐标
const vertexs = 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
])
//2.定义绘制顺序
const index = new Uint16Array([
0,1,2 ,1,2,3, //前
1,3,4 ,3,4,5, //右
4,5,6 ,5,6,7, // 后
6,7,0 ,7,0,2, // 左
0,6,1 ,1,6,4, // 上
2,7,3 ,7,3,5, // 下
])
//3.需要额外创建一个缓冲区对象用于储存绘制顺序。
const vertexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, vertexs, gl.STATIC_DRAW)
const indexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, index, gl.STATIC_DRAW)
//4.将数据写入顶点
const a_Position = gl.getAttribLocation(program, 'a_Position')
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(a_Position)

gl.enable(gl.DEPTH_TEST) //开启深度测试

//绘制
gl.drawElements(gl.TRIANGLES, index.length, gl.UNSIGNED_SHORT, 0)