Skip to content

上节介绍了世界空间到观察空间(相机空间)的视图变换,本节介绍下一个转换步骤:观察空间到裁剪空间的投影变换。

观察空间也称为相机空间。

投影变换,顾名思义,就是将 3D 坐标投影到 2D 平面的过程。上节我们讲到,观察空间也沿用了右手坐标系,即 Z轴正向朝向屏幕外侧,但是裁剪坐标系是左手坐标系,即 Z 轴正向朝向屏幕内侧,那么在投影变换阶段,我们除了要将 3D 坐标投影到 2D 平面,还要将右手坐标系变换成左手坐标系。

投影分类

业界有两种常用的投影方式:正交投影透视投影

正交投影

正交投影,又名平行投影,常用在机械制图、施工图纸领域,投影后的比例和投影前的比例一致。

透视投影

透视投影多用在成像领域,比如人看世界、相机拍照等场景,这个场景有一个特点就是投影后能够实现近大远小的效果。

投影原理

正交投影和透视投影的原理大体相同,基本过程如下:

  • 首先指定可视范围,即什么范围内的物体能投影。此过程通过指定近平面和远平面来圈定范围。
  • 将可视范围内的所有物体坐标投影到近平面上,投影后的坐标根据相似三角形原理求得,比较简单。

不同之处:

  • 正交投影的投影线是平行线,可视范围是一个立方体盒子。

  • 透视投影的投影线是相交线,可视范围是一个棱锥体盒子,这样经过投影后才能达到近大远小的效果。

如下图所示:

上面两种投影坐标系原点均位于投影盒正中心位置,因为是左手坐标系,所以,Z轴朝向屏幕内侧。

投影算法推导

接下来,我们推导一下投影变换算法。假设模型中有一点 P,且P的坐标为 (x,y,z),那么投影到近平面后的坐标P' = (x',y',z')。那么 P'和P 之间的关系是什么呢?

我们已知的条件有以下几个:

  • 近平面的 z 值 zNear。
  • 近平面的宽度 width 和高度 height。
  • 远平面的 z 值 zFar。

zFar 和 zNear 是相机坐标系中的Z轴坐标,由于相机坐标系是右手坐标系,Z轴朝向屏幕外侧,所以 zNear 是大于 zFar 的,这点在做坐标转换时需要注意。

接下来分析一下如何根据这些已知条件推导出 P'坐标,当推导出 P' 坐标之后,也就有了相应地投影矩阵。

正交投影

通过上面的示意图,我们能看到,正交投影盒中的一点 P(x, y, z),其中:

  • x坐标在【-width/2, width / 2】之间,通常我们不指定width,而是指定相机坐标系下的投影盒的左右两侧坐标,left和right。
  • y坐标在【-height/2, height/2】之间,通常我们不指定height,而是指定相机坐标系下的投影盒的左右两侧坐标,top和bottom。
  • z坐标在【zNear,zFar】之间。

投影后的点 P'(x',y',z'),其中:

  • x' 在【-1,1】之间
  • y' 在【-1,1】之间
  • z' 在【-1,1】之间

我们最终需要一个矩阵M,使用该矩阵左乘顶点向量 P,即可得到P'。

按照惯例,我们还是找出P'和P之间的关系:

x' 和 x 的关系:

$ \begin{aligned} x'&= \frac{x - (left + right)/2} {(right - left)/2} \
&= \frac{2x}{right - left} - \frac{right + left}{right - left} \end{aligned} $

y'和y的关系

$ \begin{aligned} y'&= \frac{y - (top + bottom)/2} {(top - bottom)/2} \
&= \frac{2y}{top - bottom} - \frac{top + bottom}{top - bottom} \end{aligned} $

z' 和 z 的关系

$ \begin{aligned} z'&= \frac{z - (zFar + zNear)/2} {(zNear - zFar)/2} \
&= \frac{2z}{zNear - zFar} - \frac{zFar + zNear}{zNear - zFar} \end{aligned} $

找出 P'和P之间的关系之后,我们就能够将这个关系用矩阵表示出来,还记得我们的矩阵生成公式吗?

$ \begin{aligned} M = \begin{pmatrix} x'_x & y'_x & z'_x & t_x' \
x'_y & y'_y & z'_y & t_y' \
x'_z & y'_z & z'_z & t_z' \
0 & 0 & 0 & 1 \end{pmatrix} \end{aligned} $

将上面的 x'、y'、z'代入上式,即可求出正交投影矩阵:

$ \begin{aligned} M = \begin{pmatrix} \frac{2}{right - left} & 0 & 0 & \frac{right + left}{left - right} \
0 & \frac{2}{top - bottom} & 0 & \frac{top + bottom}{bottom - top} \
0 & 0 & \frac{2}{zNear - zFar} & \frac{zFar + zNear}{zNear - zFar} \
0 & 0 & 0 & 1 \
\end{pmatrix} \end{aligned} $

以上就是正交投影变换矩阵的推导过程,很简单。

那么,有了矩阵表示方式,正交投影算法就能够实现了。

javascript
function ortho(left, right, bottom, top, near, far, target){
  target = target || new Float32Array(16);

  target[0] = 2 / (right - left);
  target[1] = 0;
  target[2] = 0;
  target[3] = 0;
  target[4] = 0;
  target[5] = 2 / (top - bottom);
  target[6] = 0;
  target[7] = 0;
  target[8] = 0;
  target[9] = 0;
  target[10] = 2 / (near - far);
  target[11] = 0;
  target[12] = (left + right) / (left - right);
  target[13] = (bottom + top) / (bottom - top);
  target[14] = (near + far) / (near - far);
  target[15] = 1;

  return target;
}

实践

我们用一个立方体来演示一下,你会发现,无论远平面距离多远,立方体的大小是不会变的。

我们将摄像机放在 Z 轴正向 20 个单位处,Y轴正向为上方,看向坐标系原点,同时将 投影盒设置为一个上下左右边界坐标在【 -5,5】之间,近平面坐标为 20, 远平面坐标为 -20 的立方体。

javascript
var cube = createCube(3, 3, 3);
var cameraPosition = {x: 0, y: 0, z: 20};
var target ={x: 0, y: 0, z: 0};
var up = {x: 0, y: 1, z: 0};
var cameraMatrix = matrix.lookAt(cameraPosition, target, up);
var viewMatrix = matrix.inverse(cameraMatrix);
var projectionMatrix = matrix.ortho(-5, 5, -5, 5, -2020);
...

可以看到,无论我们将镜头移到多远,物体投影后的大小始终不变。

那么什么会影响物体投影后的大小呢?有的同学已经猜到了,投影盒的宽度和高度。

我们试一下:

可以看到,将宽度和高度增大之后,物体投影后变小了。

透视投影

透视投影是我们比较常用的投影方式,它能够实现现实生活中人眼看向世界产生的近大远小的效果。接下来我们推导一下透视算法。

和正交投影类似,透视投影也接收近平面和远平面参数,不同的是,透视投影的投影盒是一个棱锥体。正因为如此,透视投影才可以实现近大远小的效果。

通过上面透视投影示例图,根据相似三角形原理,我们可以知道如下关系:

$ \frac{zNear}{z} = \frac{y_1}{y} = \frac{x_1}{x} $

所以有 $ \begin{aligned} x_1 = \frac{zNear * x}{z} \
y_1 = \frac{zNear * y}{z} \end{aligned} $

其中 $x_1$和$y_1$是相机坐标系坐标经过视线看向物体后在近平面上的交点坐标,此时 $x_1$和$y_1$还是相机坐标系下坐标,并没有变换到裁剪坐标系,我们还要将$x_1$和$y_1$变换到【-1,1】之间。

$ \begin{aligned} x' = \frac{x_1} {z * width /2} = \frac{zNear * x} {z * width /2} \
\
y' = \frac{y_1} {z * height /2} = \frac{zNear * y} {z * height /2} \
\end{aligned} $

又由于投影坐标系和相机坐标系 Z 轴相反,所以需要对Z轴坐标取反。

$ \begin{aligned} x' = - \frac{zNear * x} {z * width /2} \
\
y' = - \frac{zNear * y} {z * height /2} \
\end{aligned} $ 看到这里,你会发现,x' 和 y' 不仅和投影面的宽度和高度有关系,还和 z 轴坐标有关系,z 轴坐标越大,x'和 y' 越小,也就产生了近大远小的效果,我们将齐次坐标 w 分量设置为 z,此时 x' 和 y' 的值为

$ \begin{aligned} x' = - \frac{zNear * x} {width /2} \
\
y' = - \frac{zNear * y} {height /2} \
\end{aligned} $

接下来,我们看下 z' 和 z 之间的关系,因为是线性关系,所以: $ z' = az + b; $

又因为齐次坐标w分量为 z,所以有 $ z' = a + b / z $ 其中 a和b 是常量,待求。

我们还知道当 z 为 zNear 时,裁剪空间 z 轴坐标为 -1,当 z 为 zFar 时,z轴坐标为 1,所以有如下两个等式: $ a + \frac {b} { zNear} = -1 $

$ a + \frac {b} {zFar} = 1 $

解这两个方程,可以求出 a 和 b 的值:

$ a = \frac{zFar + zNear}{zFar - zNear} $

$ b = \frac{2 \times zFar \times zNear}{zFar - zNear} $

依然按照正交投影的参数,width 由 left和right决定,height 由top和bottom决定, 根据上述推导过程,我们可以得出透视投影的变换矩阵 M

$ \begin{aligned} M = \begin{pmatrix} \frac{2 \times zNear}{right - left} & 0 & 0 & \frac{right + left}{left - right} \
0 & \frac{2 \times zNear}{top - bottom} & 0 & \frac{top + bottom}{bottom - top} \
0 & 0 & \frac{2(zFar + zNear)}{zNear - zFar} & \frac{2 \times zFar \times zNear}{zNear - zFar} \
0 & 0 & -1 & 0 \end{pmatrix} \end{aligned} $

有了推导公式,算法的实现水到渠成:

javascript
function makePerspective(left, right, top, bottom, zNear, zFar, target){
  target = target || new Float32Array(16);
  var a = (2 * near) / (right - left);
  var b = (2 * near) / (top - bottom);

  var c = (right + left) / (right - left);
  var d = (top + bottom) / (top - bottom);
  var e = (zFar + zNear) / (zNear - zFar);
  var f = (2 * zFar * zNear) / (zNear - zFar);

  target[0] = a;
  target[1] = 0;
  target[2] = 0;
  target[3] = 0;
  
  target[4] = 0;
  target[5] = b;
  target[6] = 0;
  target[7] = 0;
  
  target[8] = c;
  target[9] = d;
  target[10] = e;
  target[11] = -1;
  
  target[12] = 0;
  target[13] = 0;
  target[14] = f;
  target[15] = 0;

  return target;
}

除了传递以left 、right、top、bottom 方式传递近平面参数以外,为了方便,业界往往用视角fovy 和宽高比 aspect 的方式代替它们,推导过程和上面几乎一样,只不过我们以aspect 和 fovy角度 θ 来代替 x' 和 y'

$ \begin{aligned} x' &= \frac {2 \times zNear} {width}\
&=\frac{1}{aspect \times tan(\theta/2)} \end{aligned} $

$ \begin{aligned} y' &= \frac {2 \times zNear} {height}\
&=\frac{1}{tan(\theta/2)} \end{aligned} $

所以这种方式下的透视投影矩阵为:

$ \begin{aligned} M = \begin{pmatrix} \frac{2 \times zNear}{right - left} & 0 & 0 & \frac{right + left}{left - right} \
0 & \frac{2 \times zNear}{top - bottom} & 0 & \frac{top + bottom}{bottom - top} \
0 & 0 & \frac{2(zFar + zNear)}{zNear - zFar} & \frac{2 \times zFar \times zNear}{zNear - zFar} \
0 & 0 & -1 & 0 \
\end{pmatrix} \end{aligned} $

算法实现:

javascript
function perspective(fovy, aspect, zNear, zFar, target){
  var top = zNear * Math.tan((Math.PI / 180) * 0.5 * fovy),
    height = 2 * top,
    width = aspect * height,
    left = -0.5 * width;
  return perspective2(left, left + width, top, top - height, zNear, zFar, target);
}

实战演练

接下来,我们试试写的算法能不能正常工作。

首先,将摄像机放在 z 轴正向 20 单位处,然后采用透视投影,视角为 60 度,宽高比设置为屏幕宽高比。

javascript
var aspect = canvas.width / canvas.height;
var projectionMatrix = matrix.perspective(60, aspect, 1, 1000);

大家可以点击这里查看演示。

可以看到,影响投影的因素有如下几个:

  • 摄像机所在位置,距离越远,投影越小,反之,投影越大。
  • 投影盒宽高比会影响显示比例。
  • 视角会影响显示大小,视角越大,投影越小,反之,投影越大。

CSS3 中的 perspective

不知道大家有没有发现,perspective 这个名词在 CSS3 中出现过,perspective 代表摄像机距离近平面的距离,增加了此属性,就能实现近大远小的透视效果。

其实它们的底层实现大体也是基于上面的算法。

回顾

以上就是投影矩阵的推导过程以及算法实现,建议大家拿笔在纸上推导一下,做到真正掌握,以后碰到一些坐标变换场合就能做到灵活运用。