WebGL 之投影矩阵

760 阅读10分钟

之前的文章中我们陆续介绍了 MVP 矩阵中的模型矩阵(M)视图矩阵(V),本篇文章就来讲讲最后一个 P, 投影矩阵(projection matrix),它解决的是可视范围的问题。学完投影矩阵后,我们在屏幕上渲染出 3 维的场景时,所有的点就可以由公式 投影矩阵 * 视图矩阵 * 模型矩阵 * 顶点的初始点位 得到了。

有 2 种投影方式 —— 正交投影和透视投影,下面分别介绍。

正交投影

正交投影,也可以称为正射投影。如下我使用 c4d 绘制的示意图 1 所示,可见区域为一个长方体区域。

2024-05-21_142421.png

正交投影时,所有物体的投影线都会垂直于绘图表面,也就是屏幕,所以如果有 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,符合左手坐标系:

2024-05-21_142551.png

映射的过程可分为 2 步:

  1. 将图 1 的长方体可视空间的中心点位移到图 2 的正方体裁剪空间的中心点;
  2. 将图 1 的长方体缩放到图 2 的正方体的大小。

平移

先是平移,假设图 1 的长方体的世界坐标如下,x 轴方向左边为 l(left),右边为 r(right);y 轴方向上边为 t(top),下边为 b(bottom);z 轴方向靠近摄像机的为 n(near),远离摄像机的为 f (far):

2024-05-21_142712.png

在之前的文章《控制图形的形变》中,我们已经推导得到了平移矩阵,这里直接使用,平移的 x 就应该为 -(l+r)/2(l+r)/2 为长方体的 x 轴方向的中点,加上 - 号就是移动到原点。同理可得平移的 yz,继而得到平移矩阵如下:

[
    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)之比。同理可得 tytz,所以缩放矩阵如下:

[
    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 矩阵库,则可以通过 mat4ortho() 方法获取正交投影矩阵:

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 所示,蓝色四棱台内部即为透视投影的可视区域,靠近摄像机的一面为近平面,远离摄像机的一面为远平面:

2024-05-21_142814.png

推导透视投影矩阵

webgl 中透视投影矩阵的作用,就是将图 4 中的可视区域的四棱台内的坐标,映射到图 2 所示的 x (-1, 1)、y (-1, 1)、z (-1, 1) 的用于显示图像的裁剪空间内,其过程可大致分为 2 步:

  1. 先收缩远平面(或者放大近平面),将透视投影的四棱台映射为像正交投影那样的长方体。正是因为有了这个收缩的过程,才会在屏幕上产生近大远小的效果;
  2. 像正交投影矩阵做的那样,将长方体中心点位移到图 2 正方体的中心点并缩放到正方体的大小。

收缩远平面

收缩远平面时,我们只需要考虑怎么转换四棱台内的某个点的 x 和 y 的坐标,而 z 坐标是不受影响的。

2024-05-21_142956.png

如图 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 步,将四棱台转换为了长方体:

image.png

接着就是第 2 步将长方体平移和缩放了。

将长方体的点映射到正方体的裁剪空间

还是先只考虑 x 轴和 y 轴坐标的处理,处理的点是上一步得到的点 p2(x2, y2, z2),假设 p2 在正方体内有一个对应点为 p3(x3, y3, z3)。

2024-05-21_143058.png

因为长方体内的点需要一一对应到正方体内,所以在如图 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/-z1y2 = 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 方向的分量,就可以得到下面的式子:

2024-05-21_143148.png

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

2024-05-21_111635.png

获取 z 轴的坐标

点 p3 的 z4 等于 ix1 + jy1 + kz1 + l ,而投影矩阵做的都是线性变换,所以这个式子里的 ij 应该为 0( 与 x、y 分量无关),即 z4 = kz1 + l,当我们求出变量 kl 后,就能获取完整的透视投影矩阵了。求二元一次方程诸位在初中都学过,带入两组已知的 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)。现在我们就得到了透视投影矩阵如下:

2024-05-21_112046.png

通过视角和宽高比获取透视投影矩阵

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

2024-05-21_112143.png

那么可以根据三角形的正切公式得到:t = n * tan(α/2),注意,图 7 所示红色线段,其位于 y 轴正方向的最大的刻度,为图 6 所示的 tbt 的相反数,所以 b = -t;得到了高度后,就可以根据宽高比得到 r = t * aspect = n * tan(α/2) * aspectlr 的相反数,l = -r

现在,就可以将之前得到的透视投影矩阵中的 r-l2n * aspect * tan(α/2) 替换,t-b2n * tan(α/2) 替换,r+lt+b 则都为 0,替换后的矩阵如下:

2024-05-21_112739.png

现在,我们就可以定义获取透视投影矩阵的函数 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)

感谢.gif 点赞.png