之前的文章中我们陆续介绍了 MVP 矩阵中的模型矩阵(M)和视图矩阵(V),本篇文章就来讲讲最后一个 P, 投影矩阵(projection matrix),它解决的是可视范围的问题。学完投影矩阵后,我们在屏幕上渲染出 3 维的场景时,所有的点就可以由公式 投影矩阵 * 视图矩阵 * 模型矩阵 * 顶点的初始点位 得到了。
有 2 种投影方式 —— 正交投影和透视投影,下面分别介绍。
正交投影
正交投影,也可以称为正射投影。如下我使用 c4d 绘制的示意图 1 所示,可见区域为一个长方体区域。

正交投影时,所有物体的投影线都会垂直于绘图表面,也就是屏幕,所以如果有 2 个点,a 点坐标为 (1, 2, 0),b 点坐标为 (1, 2, 10),这 2 个点经过正交投影后在屏幕上只会看到一个点,而不会有近大远小的效果。
推导正交投影矩阵
webgl 中正交投影矩阵的作用,就是将图 1 中的这个作为可视区域的长方体内的坐标,映射到图 2 所示的 x (-1, 1)、y (-1, 1)、z (-1, 1) 的用于显示图像的裁剪空间内,需要注意的是裁剪空间的 z 轴是指向屏幕内的,也就是说靠近我们的方向是 -1,屏幕里是 1,符合左手坐标系:

映射的过程可分为 2 步:
- 将图 1 的长方体可视空间的中心点位移到图 2 的正方体裁剪空间的中心点;
- 将图 1 的长方体缩放到图 2 的正方体的大小。
平移
先是平移,假设图 1 的长方体的世界坐标如下,x 轴方向左边为 l(left),右边为 r(right);y 轴方向上边为 t(top),下边为 b(bottom);z 轴方向靠近摄像机的为 n(near),远离摄像机的为 f (far):

在之前的文章《控制图形的形变》中,我们已经推导得到了平移矩阵,这里直接使用,平移的 x 就应该为 -(l+r)/2,(l+r)/2 为长方体的 x 轴方向的中点,加上 - 号就是移动到原点。同理可得平移的 y 和 z,继而得到平移矩阵如下:
[
1, 0, 0, -(r+l)/2,
0, 1, 0, -(t+b)/2,
0, 0, 1, -(f+n)/2,
0, 0, 0, 1
]
缩放
在《控制图形的形变》中也推导得到了缩放矩阵,而需要的缩放系数 tx 就应该为图 2 的正方体 x 轴方向上的长(为 2)与图 1 的长方体的 x 轴方向上的长(为 r-l)之比。同理可得 ty 与 tz,所以缩放矩阵如下:
[
2/(r-l), 0, 0, 0,
0, 2/(t-b), 0, 0,
0, 0, 2/(n-f), 0,
0, 0, 0, 1
]
结果
将缩放矩阵 * 平移矩阵,即可得到正交投影矩阵如下:
[
2/(r-l), 0, 0, -(r+l)/(r-l),
0, 2/(t-b), 0, -(t+b)/(t-b),
0, 0, -2/(f-n), -(f+n)/(f-n),
0, 0, 0, 1
]
现在,我们以之前《绘制彩色正方体》中使用顶点法绘制的正方体为基础,给顶点坐标 gl_Position 乘上正交投影矩阵,效果如下:
如果使用的是 gl-matrix 矩阵库,则可以通过 mat4 的 ortho() 方法获取正交投影矩阵:
const projectionMatrix = mat4.create()
mat4.ortho(projectionMatrix, -1, 1, -1, 1, 0, 2)
透视投影
在生活中我们眼睛看到的场景就符合透视投影的规则,比如一条通向远处的马路,它的平行的两条边看起来有汇聚到一点的趋势。假设还是正交投影开头提到的那 2 个点,a 点坐标为 (1, 2, 0),b 点坐标为 (1, 2, 10),这 2 个点经过透视投影后在屏幕上就会看到 2 个点,有近大远小的效果。如下图 4 所示,蓝色四棱台内部即为透视投影的可视区域,靠近摄像机的一面为近平面,远离摄像机的一面为远平面:

推导透视投影矩阵
webgl 中透视投影矩阵的作用,就是将图 4 中的可视区域的四棱台内的坐标,映射到图 2 所示的 x (-1, 1)、y (-1, 1)、z (-1, 1) 的用于显示图像的裁剪空间内,其过程可大致分为 2 步:
- 先收缩远平面(或者放大近平面),将透视投影的四棱台映射为像正交投影那样的长方体。正是因为有了这个收缩的过程,才会在屏幕上产生近大远小的效果;
- 像正交投影矩阵做的那样,将长方体中心点位移到图 2 正方体的中心点并缩放到正方体的大小。
收缩远平面
收缩远平面时,我们只需要考虑怎么转换四棱台内的某个点的 x 和 y 的坐标,而 z 坐标是不受影响的。

如图 5 所示,以 zy 平面为例(zx 平面同理),假设我们已知视角为 α,近平面和远平面的 z 轴坐标分别为 -n 和 -f,如果我们要收缩远平面,则四棱台内的一点 p1 的 x 和 y 坐标(x1, y1),就需要转换到与其对应的在近平面上的 p2 点相同(x2, y2),所以现在的问题就成了如何由 p1 获取 p2 的 x2 和 y2。
根据相似三角形的性质,可以得到 x1/x2 = y1/y2 = z1/z2,又因为 z2 = -n,所以 p2 点的 x2 和 y2 坐标应该为:
x2 = nx1/-z1
y2 = ny1/-z1
当我们把四棱台内的点的 x 和 y 轴的坐标都替换成了其在近平面的对应点,也就完成了前面提到的第 1 步,将四棱台转换为了长方体:

接着就是第 2 步将长方体平移和缩放了。
将长方体的点映射到正方体的裁剪空间
还是先只考虑 x 轴和 y 轴坐标的处理,处理的点是上一步得到的点 p2(x2, y2, z2),假设 p2 在正方体内有一个对应点为 p3(x3, y3, z3)。

因为长方体内的点需要一一对应到正方体内,所以在如图 6 所示的长方体和正方体各自的 xy 截面上,就应该存在比例上的等式(比如 p2 点在 x 轴方向上距左边的距离占总长度的 1/4,那么映射到正方体后的 p3 点距左边的距离占总长度的比例也应该为 1/4):
- x 轴方向:
(x2-l)/(r-l) = (x3-(-1))/(1-(-1)),转换可得 x3 = 2x2/(r-l)-(r+l)/(r-l);
- y 轴方向:
(y2-b)/(t-b) = (y3-(-1))/(1-(-1)),转换可得 y3 = 2y2/(t-b) -(t+b)/(t-b)。
因为我们的初心是要推导将四棱台内的点 p1 映射到正方体裁切面内的点 p3 的转换矩阵,所以我们将 x2 = nx1/-z1 与 y2 = ny1/-z1 代入上面的等式,最终得到的等式如下:
x3 = (2n/(r-l))x1/-z1 - (r+l)/(r-l);
y3 = (2n/(t-b))y1/-z1 - (t+b)/(t-b)。
点的齐次坐标
这里补充一个数学知识,在 3D 数学中,如果有一个坐标为 (x, y, z),我们无法确认其为一个点还是一个向量。所以就有了齐次坐标 (x, y, z, w) 的概念,当 w 为 0 的时候,表示的就是某方向无穷远的一个点,也就是无限长的一个向量;如果坐标为 (x, y, z, 1) 时,则表示是一个点。 传统坐标 (x', y', z') 和齐次坐标 (x, y, z, w) 有关系式如下:
x' = x/w;
y' = y/w;
z' = z/w;
也就是说,齐次坐标 (x3, y3, z3, 1) 和 (wx3, wy3, wz3, w) 表示的都是 3 维空间内的点 p3,那么我们就可以将之前得到的 x3 和 y3,都乘上 -z1,得到新的齐次坐标 (x4, y4, z4, w),其中已知:
x4 = (2n/(r-l))x1 + ((r+l)/(r-l))z1;
y4 = (2n/(t-b))y1 + ((t+b)/(t-b))z1;
w= -z1。
还剩个 z4 未知,但已经知道了将图 4 所示的四棱台内的点 p1 投射到图 2 所示的裁剪空间后的坐标的 x 和 y 方向的分量,就可以得到下面的式子:

于是可以获取透视投影矩阵(行主序)如下:

获取 z 轴的坐标
点 p3 的 z4 等于 ix1 + jy1 + kz1 + l ,而投影矩阵做的都是线性变换,所以这个式子里的 i 和 j 应该为 0( 与 x、y 分量无关),即 z4 = kz1 + l,当我们求出变量 k 和 l 后,就能获取完整的透视投影矩阵了。求二元一次方程诸位在初中都学过,带入两组已知的 z1 和 z4 即可。
先假设 P1 是一个位于图 4 近平面上的点,那么 z1 就为 -n,映射到裁剪空间时的 z3 就为 -1,则 z4 = z3 * -z1 = -1 * -z1 = z1 = -n,于是得到第一个等式 -n = -nk + l;
再假设 P1 是一个位于图 4 远平面上的点,那么 z1 就为 -f,映射到裁剪空间时的 z3 就为 1,则 z4 = z3 * -z1 = 1 * -z1 = -z1 = f,于是得到第二个等式 f = -fk + l。
两个等式一联立,就可以得到 k = (f+n)/(-f+n) = -(f+n)/(f-n);l = -2fn/(f-n)。现在我们就得到了透视投影矩阵如下:

通过视角和宽高比获取透视投影矩阵
目前我们得到的透视投影矩阵,使用的时候不够友好,我们可以进行些转换。利用视角 α 和近平面的宽高比 aspect 来获取矩阵:

那么可以根据三角形的正切公式得到:t = n * tan(α/2),注意,图 7 所示红色线段,其位于 y 轴正方向的最大的刻度,为图 6 所示的 t。b 为 t 的相反数,所以 b = -t;得到了高度后,就可以根据宽高比得到
r = t * aspect = n * tan(α/2) * aspect;l 为 r 的相反数,l = -r。
现在,就可以将之前得到的透视投影矩阵中的 r-l 用 2n * aspect * tan(α/2) 替换,t-b 用 2n * tan(α/2) 替换,r+l 和 t+b 则都为 0,替换后的矩阵如下:

现在,我们就可以定义获取透视投影矩阵的函数 getPerspectiveMatrix() 如下,其传入的参数依次为视角 fov,宽高比 aspect,和视点距离近远平面的距离:
function getPerspectiveMatrix(fov, aspect, near, far) {
// 角度转换成弧度
fov = fov * (Math.PI / 180)
return new Float32Array([
1 / (aspect * Math.tan(fov / 2)), 0, 0, 0,
0, 1 / Math.tan(fov / 2), 0, 0,
0, 0, -(far + near) / (far - near), -1,
0, 0, -(2 * far * near) / (far - near), 0
])
}
注意,因为 getPerspectiveMatrix() 返回的矩阵是要传递给 gl.uniformMatrix4fv() 的,所以改为列主序。
将透视投影矩阵应用于《视图矩阵》绘制的正方体,可以看到已经有了近大远小的透视效果,诸君还可以点击运行后按着键盘上下左右方向键调整不同视角观察:
如果使用 gl-matrix 矩阵库,则是使用 mat4.perspective() 方法,传递的参数中,第 2 个到最后一个参数也是 fov, aspect, near, far,只不过 fov 需要是弧度值而不是角度值:
const { mat4 } = glMatrix
const perspectiveMatrix = mat4.create()
mat4.perspective(
perspectiveMatrix,
(30 * Math.PI) / 180,
canvas.width / canvas.height,
0.1,
10
)
gl.uniformMatrix4fv(uProjectionMatrix, false, perspectiveMatrix)
