之前总结过一版Unity渲染流水线的简介,这次从矩阵的空间变换角度重新梳理一下这个过程,会更加清晰。同时也是对我这段时间线性代数复习内容的一个实践。
矩阵的几何意义:变换
矩阵的几何意义并不只是变换,而且矩阵也有其代数意义,只不过对于游戏来讲,主要就是变换,如果对其他的意义有兴趣,可以看我的另外一篇博客:
什么是变换
变换,指的是把我们的一些数据,如点,方向矢量,甚至是颜色,通过某种方式进行转换的过程。
我们先看一个很常见的变换类型——线性变化。线性变换指的是那些可以保留矢量加法和标量乘法的变换,用数学公式来表示就是:
f(x)+f(y)=f(x+y),kf(x)=f(kx)
缩放是一种线性变换,比如f(x) = 2x,可以表示一个大小为2的统一缩放,即经过变换后的矢量x的模将被放大两倍。
可以发现,f(x) = 2x是满足上面两个条件的。
同样,旋转也是一种线性变换。对于线性变换来说,如果我们要对一个三维矢量进行变换,我们需要3阶矩阵来表示这个变换
因为对于三维矢量来说,缩放是三个坐标同时变化相同大小,而旋转则是不同的变换,所以需要三个方程组,变成矩阵就是n阶矩阵
线性变换出了包括旋转和缩放外,还包括错切,镜像,正交投影等。
但是有一个很基础的变换它不是线性的,那就是平移。我们考虑一个平移的的方程,f(x) = x + (1, 2, 3),这个变换它就不是一个线性变换,他不满足矢量加法。比如我们令x = (1, 1, 1)。
f(x)+f(x)=(4,6,8),f(x+x)=(3,4,5)
可见,两个运算的结果是不同的,我们就不能用3阶矩阵表示三维坐标的平移。
因此,我们提出了仿射变换,仿射变换就是线性变换加平移变换。做法就是升高一个维度,用4阶矩阵来表示
这里有两个问题:第一个,为什么要追求线性变换,第二个,为什么升高一个维度,变换之后再降维,就可以把平移变成线性变换
首先回答第一个问题,线性变换从几何上来看,就是将图像变换到另一个坐标系下,变换前后图形不会发生改变
至于为什么升高一个维度,就可以抹平这个影响,可以看一下:https://www.zhihu.com/question/20666664
齐次坐标
当我们把三维变换矩阵变成4维的时候,我们还需要把原本的三维矢量转换成4维矢量,也就是我们说的齐次坐标。
那么如何将一个三维矢量变成齐次坐标呢?对于一个点,从三维坐标变成齐次坐标是将其w分量设置为1,对于方向矢量而言,需要将其w分量设置为0。
这样设置,当用一个4阶矩阵对一个点进行变换的时候,平移旋转和缩放都会作用于该点。但是对于一个矢量,平移的效果就会被忽略
分解基础变换矩阵
我们仿射变换进行坐标转换时,用的是一个4阶矩阵,我们可以对这个矩阵进行拆解,分成4个部分
[M3∗301∗3t3∗11]
左上角的3阶矩阵表示旋转和缩放,右上角矩阵表示平移。
接下来,我们看看,如何用这个矩阵进行旋转缩放和平移:
平移矩阵
看一下我们对一个点进行平移操作
⎣⎢⎢⎢⎡100001000010txtytz1⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡xyz1⎦⎥⎥⎥⎤=⎣⎢⎢⎢⎡x+txy+tyz+tz1⎦⎥⎥⎥⎤
可以看到,结果我们成功的把点(x , y, z)变成了(x + tx, y + ty, z + tz)。
我们在看一下,对一个矢量进行变换:
⎣⎢⎢⎢⎡100001000010txtytz1⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡xyz0⎦⎥⎥⎥⎤=⎣⎢⎢⎢⎡xyz0⎦⎥⎥⎥⎤
平移并不会对矢量产生什么影响
平移矩阵的逆矩阵就是反向平移得到的,也就是:
⎣⎢⎢⎢⎡100001000010−tx−ty−tz1⎦⎥⎥⎥⎤
可见,平移矩阵不是正交矩阵
缩放矩阵
我们可以对一个点进行坐标的缩放,用矩阵表示就是:
⎣⎢⎢⎢⎡kx0000ky0000kz00001⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡xyz1⎦⎥⎥⎥⎤=⎣⎢⎢⎢⎡kxxkyykzz1⎦⎥⎥⎥⎤
对一个矢量进行缩放
⎣⎢⎢⎢⎡kx0000ky0000kz00001⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡xyz0⎦⎥⎥⎥⎤=⎣⎢⎢⎢⎡kxxkyykzz0⎦⎥⎥⎥⎤
上面的矩阵只适合沿着坐标轴进行缩放,如果我们希望在任意方向上进行缩放,需要用到复合变换。其中一种主要思想就是:先将缩放轴变换成标准坐标轴,然后应用上看的标准缩放,再用你变换得到原来的缩放轴方向。
这个过程中其实隐含着一个相似矩阵的内容,上面的复合变换是P{-1}AP的形式,如果P{-1}AP = B,那么就说A和B相似。相似的几何意义就是说在不同的坐标系下对同一个向量作出的操作结果是相通的。
旋转矩阵
旋转是三种矩阵中最复杂的。我们知道旋转操作需要指定一个旋转轴,这个轴不一定是坐标轴,但是我们暂时就讲沿着坐标轴进行旋转。
围绕x轴进行旋转的矩阵是:
Rx(θ)=⎣⎢⎢⎢⎡10000cosθsinθ00−sinθcosθ00001⎦⎥⎥⎥⎤
绕y轴旋转
Ry(θ)=⎣⎢⎢⎢⎡cosθ0−sinθ00100sinθ0cosθ00001⎦⎥⎥⎥⎤
绕z轴旋转:
Rz(θ)=⎣⎢⎢⎢⎡cosθsinθ00−sinθcosθ0000000001⎦⎥⎥⎥⎤
复合变换
我们可以把平移,旋转和缩放组合起来,形成一个复杂的变换。
我们使用列矩阵来表示一个点,依次进行缩放,旋转,平移(这个顺序不能改变),表示出来就是:
pnew=MtranslationMrotationMscalepold
之所以这个顺序不能改变,是因为只有这个顺序下,才能得到我们想要的结果,如果我们先平移,再缩放,可能导致变形
坐标空间
我们之前的文章说过,顶点着色器的功能就是将模型上的顶点坐标转换到齐次坐标下。而游戏渲染的过程,其实就是将模型上的点经过层层转换变成屏幕上的点的过程。
坐标空间的转换矩阵
定义一个坐标空间,我们必须指明其原点和三个坐标轴的位置。
而这些,其实都是相对于另一个坐标系而言的,也就是说,所有的坐标空间都是相对的,每个坐标空间都是另一个坐标空间的子空间。
假设我们现在有一个父坐标空间P和一个子坐标空间C,我们知道在P坐标空间下,C的原点和三个坐标轴的方向,我们一般有两种需求,一种是将C坐标空间下的点Ac变成P坐标空间下的Ap,第二种则是反过来。
我们可以用两个矩阵变换来表示这个过程:
Ap=Mc−>pAcAc=Mp−>cAp
上面的两个矩阵分别表示从C到P的变换和从P到C的变换,二者互为逆矩阵,那么我们应该如何求解这两个矩阵呢?
我们可以从几何的角度理解一下,一个点的坐标为(a, b, c),那说明这个点是从原点沿着x, y, z三个轴分别移动了a, b, c三个单位。
同理,如果Ac为(a, b, c),那就是从C的原点Oc沿着x, y, z三个轴分别移动了a, b, c三个单位。
那么如果从P的角度看, 则是这个点先从P的原点Op移动到了Oc,然后再从Oc移动到了Ac。
这个过程用公式来表示就是:
Ap=Oc+axc+byc+czc
而我们的变换矩阵就隐藏在这个方程里,用矩阵来表示:
Ap=Oc+[xcyczc]⎣⎢⎡abc⎦⎥⎤=[xocyoczoc]+⎣⎢⎡xxcyxczxcxycyyczycxzcyzczzc⎦⎥⎤⎣⎢⎡abc⎦⎥⎤
然后我们将其转化为齐次坐标表示:
Ap=[xocyoczoc1]+⎣⎢⎢⎢⎡xxcyxczxc0xycyyczyc0xzcyzczzc00001⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡abc1⎦⎥⎥⎥⎤=⎣⎢⎢⎢⎡100001000010xocyoczoc1⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡xxcyxczxc0xycyyczyc0xzcyzczzc00001⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡abc1⎦⎥⎥⎥⎤=⎣⎢⎢⎢⎡xxcyxczxc0xycyyczyc0xzcyzczzc0xocyoczoc1⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡abc1⎦⎥⎥⎥⎤
那么我们的转换矩阵的就是
⎣⎢⎢⎢⎡xxcyxczxc0xycyyczyc0xzcyzczzc0xocyoczoc1⎦⎥⎥⎥⎤=⎣⎢⎢⎢⎡∣xc∣0∣yc∣0∣zc∣00Oc01⎦⎥⎥⎥⎤
模型空间
模型空间,又叫局部空间或者对象空间,每个模型都有独立的坐标空间,当模型旋转时,坐标空间也一起旋转。
在模型空间中,我们经常使用的是上下左右前后这种自然方向。
由于Unity使用的是左手坐标系,因此在模型空间中,x, y, z的坐标轴的正方向分别是模型的右,上,前方向。
模型空间的原点和坐标轴是由美术人员在建模软件中设定好的。当导入Unity后,我们可以在顶点着色器中访问到模型的顶点信息,其中包含了每个顶点的坐标,这些坐标都是相对于模型空间的坐标空间的。
世界空间
这个空间是游戏的最大空间,这个所谓的最大是一个宏观的概念,指的是游戏所能到的最外围的空间。
世界空间可以用来描述绝对位置,当然,位置是相对的,但是我们这里就定义世界空间坐标系中的位置是绝对位置。
在Unity中,世界空间使用的也是左手系,不过它的坐标轴是固定不变的。
在Unity中,我们可以通过调整Transform属性来改变对象相对于父对象的位置,如果没有父对象,那么就是相对于世界空间的位置。
模型变换:从模型空间到世界空间
假设我们世界空间中有一个GameObject,它的Transform组件的三个属性,Position,Rotation和Scale分别是,(5, 0, 25),(0, 150, 0)以及(2, 2, 2),那么根据前面我们说的坐标空间的仿射变换矩阵就是:
⎣⎢⎢⎢⎡100001000010txtytz1⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡cosθ0−sinθ00100sinθ0cosθ00001⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡kx0000ky0000kz00001⎦⎥⎥⎥⎤=⎣⎢⎢⎢⎡10000100001050251⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡−0.8860−0.5001000.50−0.88600001⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡2000020000200001⎦⎥⎥⎥⎤=⎣⎢⎢⎢⎡−1.7320−10020010−1.732050251⎦⎥⎥⎥⎤
接下来,我们就可以对模型中的每个点的坐标做成这个仿射变换矩阵进行空间转换了。
这里可能会有疑惑,这个变换矩阵求解的思路和我们刚才讲的坐标空间的转换矩阵不一样啊,更像是一开始讲的基础变换矩阵。
其实我们思考一下,这两个的结果是相同的,按照坐标空间转换矩阵的思路,我们首先要根据Transform属性求出模型空间的坐标系在世界空间下的表示,然后再求出转换矩阵,这二者的结果是相同的,下面我们求解观察变换矩阵的方式就是这种。
观察空间
观察空间又叫做摄像机空间,观察空间可以认为是模型空间的一个特例,摄像机在所有模型中非常特殊的一个模型,它的模型空间值得我们单独拿出来讨论。
摄像机空间决定了我们渲染游戏使用的视角。在观察空间中,摄像机位于原点,其坐标轴也是任意的,在Untiy中,观察空间x轴指向右方,y轴指向上方,z轴指向的是摄像机的后方,这一点和模型空间以及世界空间不同,因为摄像机空间采取的是右手坐标系,这一点是和OpenGL的传统对齐的。
观察变换:从世界空间到观察空间
在上一步中,我们通过模型变换,得到了模型上的点在世界空间中的坐标,现在我们要再将这些坐标变换到观察空间中,为了获得我们这一步的变换矩阵,我们可以有两种做法:
- 构建出观察空间到世界空间的变换矩阵,然后求解其逆矩阵,就是世界空间到观察空间的变换矩阵。
- 想象平移整个观察空间,将其原点与世界空间的原点重合,坐标轴也分别重合。
第一种方法是我们之前的方法,我们现在试试第二种
假设摄像机的Transform属性表明,摄像机在世界坐标空间中先按(30, 0, 0)进行旋转,然后按(0, 10, -10)进行平移,那么为了把摄像机移动回初始状态,要做的就是先按照(0, -10, 10)平移回去,然后(-30,0,0)旋转回去,表示成变换矩阵就是:
⎣⎢⎢⎢⎡10000cosθsinθ00−sinθcosθ00001⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡100001000010txtytz1⎦⎥⎥⎥⎤=⎣⎢⎢⎢⎡100000.886−0.5000.50.88600001⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡1000010000000−10101⎦⎥⎥⎥⎤=⎣⎢⎢⎢⎡100000.886−0.5000.50.88600−3.6613.661⎦⎥⎥⎥⎤
然后由于我们的摄像机是右手系,所以对所有的z分量取反,那么我们最终的变换矩阵就是:
⎣⎢⎢⎢⎡100000.8860.5000.5−0.88600−3.66−13.661⎦⎥⎥⎥⎤
裁剪空间
裁剪空间,又叫座齐次裁剪空间,用于变换的矩阵叫做裁剪矩阵,又叫做投影矩阵。
裁剪空间的目标是为了方便地对渲染图元进行裁剪,完全位于空间的图元将被保留,完全位于空间外的将被剔除,而部分位于空间内的图元将被裁剪,那么这片空间的范围是如何规定的呢?答案是视锥体。
视锥体指的是空间中的一片区域,这块区域决定了摄像机可以看到的空间。视锥体由六个平面组成,这六个平面又叫做裁剪平面。
视锥体有两种类型,一种是正交投影,一种是透视投影。
六个裁剪平面中有有两个比较特殊,分别叫做近裁剪平面和远裁剪平面,它们决定了摄像机可以看到的空间深度
可以看出,透视投影的视锥体是一个金字塔形,侧面的四个裁剪平面在摄像机的位置相交。而正交投影的视锥体是一个长方体,我们希望根据视锥体的范围进行裁剪,但是,如果直接使用视锥体定义的空间进行裁剪,那么不同的视锥体就需要不同的处理过程,而且对于透视投影的视锥体来说,想要判断一个顶点是否处于一个金字塔内部比较麻烦的。因此我们想用一种更加通用,方便和整洁的方式来进行裁剪的工作,这种方式就是通过一个投影把顶点转换到一个裁剪空间。
投影矩阵有两个目的:
- 为投影做准备。这是个迷惑点,虽然叫投影矩阵,但是并没有进行真正的投影工作,它是在为投影做准备,真正的投影是后面的齐次除法,经过投影变换之后,w分量具有了特殊的意义。
- 对x,y,z进行缩放,我们上面讲过直接使用视锥体的六个裁剪平面进行裁剪比较麻烦,而经过投影矩阵之后,我们可以直接通过判断x,y,z分量是否在w分量的范围内来判断。
透视投影
我们可以通过FOV(Field of View)来改变视锥体的张开角度,Clipping Planes中的Near和Far属性来控制近裁剪瓶main和远裁剪平面的距离,这可以求出近裁剪平面和远裁剪平面的高度:
nearClipPlaneHeight=2∗Near∗tan2FOVfarClipPlaneHeight=2∗Far∗tan2FOV
现在我们还缺乏横向的信息,这个可以通过摄像机的纵横比得到,在Unity中,一个摄像机的纵横比由View Port中的W和H共同决定,假设我们现在的纵横比为Aspect,那么:
nearClipWidth=nearClipHeight∗AspectfarClipWidth=farClipHeight∗Aspect
那么我们可以得到透视投影矩阵:
⎣⎢⎢⎢⎢⎡Aspectcot2FOV0000cot2FOV0000−Far−NearFar+Near−100−Far−Near2∗Far∗Near0⎦⎥⎥⎥⎥⎤
我们利用这个矩阵对点进行空间变换之后:
⎣⎢⎢⎢⎢⎡Aspectcot2FOV0000cot2FOV0000−Far−NearFar+Near−100−Far−Near2∗Far∗Near0⎦⎥⎥⎥⎥⎤⎣⎢⎢⎢⎡xyz1⎦⎥⎥⎥⎤=⎣⎢⎢⎢⎢⎡xAspectcot2FOVycot2FOV−zFar−NearFar+Near−Far−Near2∗Far∗Near−z⎦⎥⎥⎥⎥⎤
这个矩阵是建立在Unity的观察空间是右手系的基础上,使用该矩阵右乘列矩阵,变换后的z分量将会在[-w, w]之间。
从结果可以看出,该矩阵就是对x,y分量进行了缩放,对z分量进行了缩放和平移。
此时我们如何判断一个该点是否处于视锥体内呢?
−w≤x≤w−w≤y≤w−w≤z≤w
还需要注意的一点是,经过裁剪矩阵,空间会从右手系变为左手系。
正交投影
正交投影视锥体是一个长方体,我们可以通过改变Size属性来改变视锥体竖直方向高度的一半,而Cliping Plane的Near和Far属性控制近裁平面和远裁剪平面的远近,也就是
farClipPlaneHeight=nearClipPlaneHeight=2∗Size
通过纵横比,可以得到视锥体的宽度
nearClipPlaneWidth=Aspect∗nearClipPlaneHeight
那么正交投影的投影矩阵(裁剪矩阵)就是:
⎣⎢⎢⎢⎡Aspect∗Size10000Size10000−Far−Near2000−Far−NearFar+Near1⎦⎥⎥⎥⎤
用该矩阵对点进行空间变换:
⎣⎢⎢⎢⎡Aspect∗Size10000Size10000−Far−Near2000−Far−NearFar+Near1⎦⎥⎥⎥⎤⎣⎢⎢⎢⎡xyz1⎦⎥⎥⎥⎤=⎣⎢⎢⎢⎡Aspect∗SizexSizey−Far−Near2z−Far−NearFar+Near1⎦⎥⎥⎥⎤
需要注意正交投影的透视矩阵,w分量仍然为1。
而判断一个点是否在正交投影的视锥体的方式和透视投影的相同,而这一点也是为什么我们要搞这个投影变换的原因
屏幕空间
经过投影矩阵变换之后,我们就可以进行裁剪了,裁剪工作完成之后,我们就可以开始真正的投影了,也就是把视锥体内的点投影到屏幕上,经过这一步,我们会得到真正的像素位置。
屏幕有一个二维空间,因此,我们必须把顶点从裁剪空间投影到屏幕空间中,来生成对应的2D坐标。
首先,我们要进行标准的齐次除法,也就是透视除法,也就是用x,y,z分别除以w分量,在OpenGL中,我们把这一步得到的坐标叫做归一化设备坐标(Normalized Device Coordinate,NDC)。
经过这一步,透视投影的裁剪空间会变到一个立方体内,按照OpenGL的传统,这个立方体的x,y,z分量的范围都是[-1, 1],DirectX则是[0,1],而正交投影的裁剪空间本身就是一个立方体了,而且其w分量是1,也不会对x,y,z产生什么影响。
现在我们要做的就是把这个NDC中的坐标投影到屏幕中,在Unity中,屏幕左下角的像素坐标是(0,0),右上角的坐标是(pixelWidth,pixelHeight),由于当前坐标的范围是[-1, 1],所以我们要进行缩放。
齐次除法和缩放的公式合起来是:
screenx=2∗clipwclipx∗pixelWidth+2pixelWidthscreeny=2∗clipwclipy∗pixelWidth+2pixelHeight
上面只说了x和y分量,因为屏幕是一个二维空间,那么z分量呢?通常情况下,z分量用于深度缓冲区
总结