这一篇我们来聊一聊坐标变换。坐标变换比较偏理论,这里我们以Unity为参照,介绍一下顶点坐标变换的流程,详细的推导可以参考《Real time rendering》或者《Unity Shader入门精要》。
齐次坐标(Homogeneous Coordinates)
所谓齐次坐标,就是用N+1维来表示N维。
那么多一个维度有什么好处呢?首先,可以通过这个维度来区分向量和点。以三维空间为例,(1,0,0,0)表示的是一个向量,而(1,0,0,1)表示的是一个点。另外,齐次坐标可以很方便地处理空间变换。依然以三维空间为例,旋转和缩放的变换矩阵只需要3列,但是平移的变换矩阵需要4列,因此,4维齐次坐标可以处理平移变换。
左手坐标系与右手坐标系
在三维坐标系中,当我们确定了x轴和y轴的方向,这两个轴可以决定一个平面。那么显然,z轴是垂直于这个平面的。想象一下,假如xy平面是水平的,那么z轴的方向就有两种选择:一种是向上,另一种是向下。由此,这两个方向的区别,正是左手坐标系和右手坐标系的区别。
在上图中,左侧是Unity坐标系,右侧是Blender坐标系,可以看出这两个坐标系无论如何旋转,都无法重合。因为Unity用的是左手坐标系,而Blender用的是右手坐标系。
有一点需要注意的是,虽然Unity中的y轴代表向上,Blender中的z轴代表向上,但并不意味着y轴向上的坐标系就是左手坐标系。坐标系的旋向性是通过向上、向右和向前这三个方向的关系来定义的,并不是通过坐标轴的关系来定义的。
模型空间
模型空间是指模型自己的坐标空间,每个模型都有自己的模型空间。模型空间的原点和坐标是在建模软件中确定的,在顶点着色器中获得的坐标就是模型空间下的坐标。
Unity中的模型空间使用的是左手坐标系,在绝大部分情况下,我们并不需要关心这件事。在处理某些特殊的需求时,比如在建模软件中把顶点的坐标保存在贴图里,再在Unity中读取时,这时候需要小心左手坐标系和右手坐标系之间的转换。
世界空间
世界空间是所有模型统一的参考空间。坐标变换的第一步就是把顶点坐标从模型空间变换到世界空间中,这就是所谓的模型变换(model transform)。从Unity的角度来理解,模型变换和GameObject上的Transform有关,这里我们只考虑场景中的根物体,模型变换的矩阵可以如下表示:
相乘的三个矩阵分别表示平移、旋转和缩放,注意这里的顺序是不能改变的,因为这里是用矩阵左乘向量,所以相当于变换的顺序是先缩放,再旋转,后平移。这三个矩阵的表示分别如下:
观察空间
观察空间是以相机为原点的坐标空间,坐标变换的第二步就是从世界空间变换到观察空间。Unity的观察空间是以x轴为右,y轴为上,-z为前。这里比较特别的是,Unity的观察空间是右手坐标系,所以相机的正前方是-z方向。这就是所谓的观察变换(view transform)。
观察变换最左边有一个特殊矩阵,这是为了相机能够指向-z。随后的两个矩阵分别是相机旋转变换的逆矩阵以及相机平移变换的逆矩阵。这可以从直觉上去理解,就是说,相机在世界空间中已经有确定的位移和旋转了,那么我要把世界空间中的另一组坐标变换到观察空间中的话,就需要把世界空间中的原点“移动”到相机的位置上,从矩阵上表示就是乘上相机本身模型变换的逆变换。
裁剪空间
坐标变换的下一步是从观察空间变换到裁剪空间中,这一步对应的就是投影变换(projection transform)。投影变换有两种,一种称为透视投影,另一种成为正交投影。透视投影可以产生近小远大的效果,这也是人眼看到真实世界的效果,常用于3D游戏中。正交投影不会产生近小远大的效果,我们在导航地图中看到的就是正交投影的效果,常用于2D游戏和UI渲染。裁剪空间是左手坐标系。
先给出透视投影矩阵:
再给出正交投影矩阵:
投影矩阵比较复杂,这里不给出详细的推导了。我们就从直觉上去理解一下。投影变换的结果就是把视锥体里的xyz坐标都变换到[-w, w]的区间里。从矩阵里来看,x和y坐标比较好理解,就是乘上一个系数缩放到对应的区间。可是为什么z坐标这么复杂呢?因为z坐标的变换不仅有缩放,还有平移,对于透视投影来说,z坐标的范围是从[-n, -f]变换到了[-n, f],对于正交投影来说,z坐标的范围是从[-n, -f]变换到了[-1, 1],这样看起来就一目了然了。需要提到的是,这里只针对Unity里的情况,不同的图形API的处理方式并不相同。
屏幕空间
经过投影变换后,就可以进行裁剪操作,而裁剪完成后,就可以把坐标变换到屏幕空间了。这时候,需要先进行透视除法,这一步很简单,只需要把每个齐次坐标的xyz分量除以w分量,经过这一步后,裁剪空间里的所有坐标都会变换到一个立方体内,立方体的xyz分量都在[-1, 1]之间。这个立方体被称为NDC(Normalized Device Coordinates)。
现在,我们只需做最后一步,把NDC里的坐标变换到屏幕上。屏幕空间是一个2D的空间,左下角是(0, 0),左上角是(pixelWidth, pixelHeight),而NDC里的x和y坐标都在[-1 ,1]之间,所以,做一个简单的缩放就可以了。
总结
最后,以《Unity Shader入门精要》的插图作为总结,可以清晰地看到一个顶点如何通过漫长的旅途最终显示到屏幕上。