渲染游戏的过程可以理解成是把一个个顶点经过层层处理最终转化到屏幕上的过程,本文就旨在说明,顶点是经过了哪些坐标空间后,最终被画在了我们的屏幕上。
空间变换的原理
首先,我们来看一个简单的问题:当给定一个坐标空间以及其中一点(a, b, c)时,我们是如何知道该点的位置的呢?
- 从坐标空间的原点开始
- 向x轴方向移动a个单位
- 向y轴方向移动b个单位
- 向z轴方向移动c个单位
坐标空间的变换就蕴含在上面的4个步骤中。现在,我们已知坐标空间C的3个坐标轴在坐标空间P下的表示Xc, Yc, Zc,以及其原点位置Oc。当给定坐标空间C中的一点Ac = (a, b, c),我们同样可以依照上面4个步骤来确定其在坐标空间P下的位置Ap
- 从坐标空间的原点开始,即 Oc
- 向x轴方向移动a个单位,即 Oc + aXc
- 向y轴方向移动b个单位,即 Oc + aXc + bYc
- 向z轴方向移动c个单位,即 Oc + aXc + bYc + cZc
对得到的表达式做如下变换,其中“|”符号表示按列展开
可以看出Mc->p实际上是通过坐标空间C在坐标空间P中的原点和坐标轴的矢量表示构建出来的:把3个坐标轴依次放入矩阵的前3列,把原点矢量放到最后一列,再用0和1填充最后一行即可。
我们可以利用反向思维,从这个变换矩阵中提取出坐标空间C的原点和坐标轴在坐标空间P的表示。例如,当我们已知从模型空间到世界空间的4×4变换矩阵,我们可以提取出它的第一列,再进行归一化(为了消除缩放的影响)来得到模型空间的x轴在世界空间下的单位矢量表示。同样的方法可以提取y轴和z轴。
当对方向矢量进行坐标空间变换时,由于矢量是没有位置的,因此坐标空间的原点变换是可以忽略的。那么对方向矢量的坐标空间变换就可以使用3×3的矩阵来表示,即
在Shader中,我们常常看到截取变换矩阵的前3行前3列来对法线方向,光照方向进行空间变化,这正是原因所在。
一旦求出来Mc->p,Mp->c就可以通过求逆矩阵的方式求出来,因为从坐标空间C变换到坐标空间P与从坐标空间P变换到坐标空间C是互逆的两个过程。当Mc->p是一个正交矩阵时,Mc->p的逆矩阵就等于它的转置矩阵,即
此时,我们还可以通过Mc->p反推出坐标空间P的坐标轴在坐标空间C中的表示Xp, Yp, Zp,这些坐标轴对应的就是Mc->p的每一行。
模型空间
模型空间,是和某个模型或者说是对象有关的,模型空间也被称为对象空间或局部空间。
每个模型都有自己独立的坐标空间,当它移动或旋转的时候,模型空间也会跟着移动和旋转。
Unity在模型空间中使用的是左手坐标系,因此在模型空间中,+x轴,+y轴,+z轴分别对应的是模型的右,上,前向。
模型空间的原点和坐标轴通常是由美术人员在建模软件里确定好的。当导入到Unity中后,我们可以在顶点着色器中访问到模型的顶点信息,其中就包含了每个顶点的坐标。这些坐标都是相对于模型空间中的原点(通常位于模型的重心)定义的。
世界空间
世界空间是一个特殊的坐标系,因为它建立了我们所关心的最大空间,即整个游戏空间
在Unity中,世界空间同样使用了左手坐标系。它的x轴,y轴,z轴是固定不变的。
顶点变换的第一步,就是将顶点坐标从模型空间转换到世界空间中,这个变换通常叫做模型变换
在Unity中,我们可以通过Transform组件中的值得知模型做了哪些变换。这个值是根据Transform的父节点的模型坐标空间中的原点定义的,如果这个Transform没有任何父节点,那么这个值就是相对于世界坐标空间定义的。
要将模型空间中的一点转换到其父空间中,需要获取M子->父,这个矩阵可以通过模型的Transform值得到。Transform中包含了旋转,缩放和平移值,则M子->父 = Mtranslation Mrotate Mscale。而从模型空间转换到世界空间的变换矩阵M模型->世界可以通过子空间到父空间变换矩阵,父空间到爷爷空间变换矩阵,连乘,直到世界空间为止得到。
观察空间
观察空间也被称为摄像机空间,可以认为是模型空间的一个特例,即摄像机的模型空间。
在Unity中,观察空间使用的是右手坐标系,即+x轴指向右方,+y轴指向上方,+z轴指向摄像机后方
顶点变换的第二步就是将顶点坐标从世界空间变换到观察空间中。这个变换通常叫做观察变换。
从观察空间到世界空间的变换矩阵我们同样可以通过Transform中的值得到,再对该矩阵求逆得到从世界空间到观察空间的变换矩阵。我们还可以使用另一种方法,对Transform组件中的值直接取反(做逆向变换),然后得到从世界空间到观察空间的变换矩阵。注意,由于观察空间使用的是右手坐标系,因此还需要对变换矩阵的z分量进行取反操作。
裁剪空间
顶点接下来要从观察空间转换到裁剪空间中,这个变换可以被称为投影变换。这个用于变换的矩阵叫做裁剪矩阵或是投影矩阵
裁剪空间的目的是能够方便地对渲染图元进行裁剪:完全位于这块空间内部的图元将会被保留,完全位于这块空间外部的图元将会被剔除,与这块空间边界相交的图元就会被裁剪。而这块空间就是由视椎体来决定的。
视椎体有两种类型,分别对应两种投影类型:透视投影(下图左)和正交投影(下图右)。透视投影模拟了人眼看世界的方式,而正交投影则完全保留了物体的距离和角度。
投影矩阵虽然叫做投影矩阵,但并没有真正进行投影,而是为投影做准备。目的是对x,y,z分量进行缩放,经过投影矩阵的缩放后,我们可以直接使用w分量作为范围值,只有x,y,z分量都位于这个范围内的顶点才认为是在裁剪空间内。并且w分量在真正的投影时也会用到。
透视投影和正交投影分别对应了不同的投影矩阵。还需要注意的是投影矩阵会改变空间的旋向性:空间从右手坐标系变换到了左手坐标系
透视投影
此时我们就可以按如下不等式来判断一个变换后的顶点是否位于视椎体内
正交投影
判断一个变换后的顶点是否位于视椎体内使用的不等式和透视投影中的一样,这种通用性也是为什么要使用投影矩阵的原因之一。
屏幕空间
当完成了所有的裁剪工作后,就需要进行真正的投影了,即把视椎体投影到屏幕空间中,这个过程可以被称为屏幕映射。经过这一步变换,我们会得到真正的像素位置,对应的2D坐标,而不是虚拟的三维坐标。这个过程可以理解成有两步:
- 进行标准齐次除法,也被称为透视除法。就是把齐次坐标系的x,y,z分量都除以w分量。在OpenGL中把这一步得到的坐标叫做归一化的设备坐标(NDC)。经过透视投影变换后的裁剪空间会变换到一个立方体内,而正交投影的裁剪空间本身就是一个立方体(它的w分量是1,齐次除法不会对它产生影响)。在Unity中这个立方体的x,y,z分量的范围都是[-1, 1],和OpenGL保持一致。
- 经过齐次除法后,透视投影和正交投影的视椎体都变换到一个相同的立方体内。现在,我们可以根据变换后的x,y坐标来映射输出窗口对应的像素坐标。
在Unity中,屏幕空间左下角的像素是(0, 0),右上角的像素坐标是(pixelWidth, pixelHeight)。齐次除法和屏幕映射的过程可以使用下面的公式来表示
$$screen_x = \frac{clip_x * pixelWidth}{2 * clip_w} + \frac{pixelWidth}{2}$$
$$screen_y = \frac{clip_y * pixelHeight}{2 * clip_w} + \frac{pixelHeight}{2}$$
在Unity中,从裁剪空间到屏幕空间的转换是由底层帮我们完成的。我们的顶点着色器只需要把顶点转换到裁剪空间即可(模型空间-世界空间-观察空间-裁剪空间,对应的矩阵通常会串联成一个MVP矩阵)。
法线变换
最后,我们再来看一种特殊的变换:法线变换。在游戏中,模型的一个顶点往往会携带额外的信息,而顶点法线和切线就是其中的两种信息,切线和法线是互相垂直的。
由于切线是由两个顶点之间的差值计算得到的,因此我们可以直接使用变换顶点的矩阵MA->B来变换切线。但如果直接使用MA->B来变换法线,得到的新法线可能就不会和切线垂直了。例如下图所示.
视口空间
视口空间中的坐标被称为视口坐标,就是把屏幕归一化,这样屏幕左下角就是(0, 0),右上角就是(1, 1)。如果已知屏幕坐标的话,我们只需要把x,y分量除以屏幕分辨率即可得到视口坐标。如果已知裁剪空间中的坐标,可以通过以下公式得到视口坐标