上节介绍了世界空间到观察空间(相机空间)的视图变换,本节介绍下一个转换步骤:观察空间到裁剪空间的投影变换。
观察空间也称为相机空间。
投影变换,顾名思义,就是将 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} $
以上就是正交投影变换矩阵的推导过程,很简单。
那么,有了矩阵表示方式,正交投影算法就能够实现了。
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 的立方体。
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, -20,20);
...略
可以看到,无论我们将镜头移到多远,物体投影后的大小始终不变。
那么什么会影响物体投影后的大小呢?有的同学已经猜到了,投影盒的宽度和高度。
我们试一下:
可以看到,将宽度和高度增大之后,物体投影后变小了。
透视投影
透视投影是我们比较常用的投影方式,它能够实现现实生活中人眼看向世界产生的近大远小的效果。接下来我们推导一下透视算法。
和正交投影类似,透视投影也接收近平面和远平面参数,不同的是,透视投影的投影盒是一个棱锥体。正因为如此,透视投影才可以实现近大远小的效果。
通过上面透视投影示例图,根据相似三角形原理,我们可以知道如下关系:
$ \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} $
有了推导公式,算法的实现水到渠成:
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} $
算法实现:
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 度,宽高比设置为屏幕宽高比。
var aspect = canvas.width / canvas.height;
var projectionMatrix = matrix.perspective(60, aspect, 1, 1000);
大家可以点击这里查看演示。
可以看到,影响投影的因素有如下几个:
- 摄像机所在位置,距离越远,投影越小,反之,投影越大。
- 投影盒宽高比会影响显示比例。
- 视角会影响显示大小,视角越大,投影越小,反之,投影越大。
CSS3 中的 perspective
不知道大家有没有发现,perspective
这个名词在 CSS3 中出现过,perspective 代表摄像机距离近平面的距离,增加了此属性,就能实现近大远小的透视效果。
其实它们的底层实现大体也是基于上面的算法。
回顾
以上就是投影矩阵的推导过程以及算法实现,建议大家拿笔在纸上推导一下,做到真正掌握,以后碰到一些坐标变换场合就能做到灵活运用。