Shader学习07【顶点的坐标空间变换过程】
我们知道,在渲染流水线中,一个顶点要经过多个坐标空间的变换才能最终被画在屏幕上,一个顶点最开始是在模型空间中定义的,最后它将会被变换到屏幕空间中,得到真正的屏幕像素坐标,因此,接下来的内容我们将解释顶点要进行的各种空间变换过程
为了使读者理解这个过程,我们将建立在农场游戏的实例背景下,每讲到一种空间变换,我们会解释如何应用到这个案例中
在我们的农场游戏中,妞妞很好奇自己是如何被渲染到屏幕上的,它只知道自己和一群小伙伴在农场里快乐的吃草,而前面有一个摄像机一直观察他们,如下图所示,妞妞想知道自己的鼻子是怎么被画到屏幕上的?
1:模型空间
模型空间(model Space)如他的名字所暗示一样,是和某个模型或者说是对象有关的,有时模型空间也被称为对象空间(object space)或局部空间(local space)。每个模型都有自己独立的坐标空间,当它移动或旋转的时,模型空间也会跟着它移动和旋转,把我们自己当成游戏中的模型的话,当我们在办公室移动时,我们的模型空间也在跟着移动,当我们转身时,我们本身的前后左右方向也在跟着改变
在模型空间中,我们经常使用一些方向概念,例如:forward,back,left,right,up,down。在这里,我们把这些方向称之为自然方向,模型空间中的坐标轴通常会使用这些自然方向,Unity在模型空间中使用的是左手坐标系,因此在模型空间中,+x轴,+y轴,+z轴分别对应的是模型的右,上,前方向。需要注意的是,模型坐标空间中的X轴,Y轴,Z轴和自然方向的对应不一定是上述这种关系,但由于Unity使用的是这样的约定,因此,我们暂且使用这种方式,我们可以在Hierachy视图中单击任意对象就可以看见它们对应的模型空间中的3个坐标轴
模型空间的原点和坐标轴通常是由美术人员在建模软件里确定好的,当导入到Unity中后,我们可以在顶点着色器中访问到模型的顶点信息,其中包含了每个顶点的坐标,这些坐标都是相对于模型空间中的原点(通常位于模型的重心)定义的
当我们把妞妞放到场景中时,就会有一个模型坐标空间时刻跟随着它,扭扭鼻子的位置可以通过访问顶点属性来得到,假设这个位置是(0,2,4),由于顶点变换中往往包含了平移变换,因此需要把其扩展到其次坐标系下,得到顶点坐标是(0,2,4,1)
2:世界空间
世界空间(world space)是一个特殊的坐标系,因为它建立里我们所关心的最大的空间,一些读者可能会指出,空间可以是无限大的,怎么会有”最大“这一说呢?这里说的最大指的是一个宏观的概念,也就是说它是我们所关心的最外层的坐标空间,以我们的农场游戏为例,在这个游戏里世界空间指的就是农场,我们不关心这个农场在什么地方,在这个虚拟的游戏世界里,农场就是最大的空间概念
世界空间用于描述绝对位置。在这里,绝对位置指的就是在世界坐标系中的位置。通常,我们会把世界空间的原点放置在游戏空间的中心
在Unity中,世界空间同样使用了左手坐标系,但它的X轴,Y轴,Z轴是固定不变的,在Unity中,我们可以通过调整Transform组件中的Position属性来改变模型的位置,这里的位置指的是相对于这个Transform的父节点(parent)的模型坐标空间中的原点定义的。如果一个Transform没有任何父节点,那么这个位置就是在世界坐标系中的位置。我们可以想象成还有一个虚拟的根模型,这个根模型的模型空间就是世界空间,所有的游戏对象都附属于这个根模型,同样,transform中的Rotation和Scale也是同样的道理
顶点变换的第一步,就是将顶点坐标从模型空间变换到世界空间中,这个变换通常叫做模型变换(model transform),现在,我们先对妞妞的鼻子进行模型变换,为此,我们首先需要知道妞妞在四届坐标系中进行了哪些变换,这可以通过面板中的Transform组件来得到相关的变换信息,
根据Transform组件信息,我i们知道在世界空间中,妞妞进行了(2,2,2)的缩放,又进行了(0,150,0)的旋转,以及(5,0,25)的平移,注意这里的变换顺序是不能互换的,即先进行缩放,再进行旋转,最后是平移,这里,我想详细解释一下为什么矩阵变换顺序不能随意改变(之前的解释可能让读者迷惑)
首先我们已经了解了矩阵是用来干什么的,当我们把一个物体从一个坐标系放到另一个坐标系中,这两个坐标系可以视为是完全重合的(但是这个时候所有的顶点的坐标都是新坐标系下的,只不过在没有进行变换的时候,这些坐标的XYZ是和原坐标系下相同的,做过变换之后就不同了,但是不要混淆了这一点,到了新坐标系下,所有顶点坐标都是基于新坐标系的),我们要做的,就是把物体放置到一个合适的位置(平移变换),摆放到一个合适的角度(旋转变换),调整到一个合适的大小(缩放变换)然后再重新计算在新坐标系下顶点坐标,那如何计算呢,我们必须先知道如何计算,才知道为什么变换顺序不能被打乱。
先以缩放变换举例,一个坐标(x,y,z)放大到2倍,通过矩阵的计算公式,它的坐标会变化为(2x,2y,2z),这个时候就必须要明确一点,这个缩放的原点以及缩放的方向是什么,对于x,它缩放的原点就是坐标系的原点,缩放的方向就是(1,0,0),缩放的倍数是2,记住,它的缩放是以这组数据为参数的,这个参数的数据只和矩阵里的那四行四列也就是你设置的数字有关,与其他坐标系什么的都没有关系,甚至于要明白,缩放矩阵只是为了设置缩放的大小,理论上对于所有的旋转矩阵来说,它的缩放方向和缩放的参考点都还是原来的那些参数。
那如果我们先旋转在缩放会出现什么情况呢?我们可以在世界变换中分析一下这个问题,其他类型的变换都是类似的。
先旋转,旋转之后再缩放,我们希望的效果是这个物体依然能够按照模型坐标系来进行缩放,可事实却是它的缩放变换矩阵还是那个矩阵,也就是意味着对于x来说,它缩放的方向仍然是(1,0,0)但是这是世界坐标系下的(1,0,0)了,而不是模型坐标系下了,所以理所当然会出现问题
现在我们可以用它来对妞妞的鼻子进行模型变换了
也就是说,在世界空间下,妞妞鼻子的位置是(9,4,18.072)注意,这里的浮点数都是近似值,这里近似到小数点后3位,实际数值和Unity采用的浮点值精度有关
3:观察空间
观察空间(view space)也被称为摄像机空间(camera space),观察空间可以认为是模型空间的一个特例——在所有的模型中有一个非常特殊的模型,即摄像机(虽然通常来说摄像机本身是不可见的),它的模型空间值得我们单独拿出来讨论,也就是观察空间。
摄像机决定了我们渲染游戏所使用的视角。在观察空间中,摄像机位于原点,同样,其坐标轴的选择可以是任意的,但由于本书讨论的是以Unity为主,而Unity中观察空间的坐标轴选择是:+x指向右方,+y轴指向上方,+z轴指向的是摄像机的后方。读者在这里可能觉得很奇怪,我们之前讨论的模型空间和世界空间使用的都是左右坐标系,为什么观察空间的坐标系不一样?
这是因为,Unity在模型空间和世界空间中选用的都是左手坐标系,而在观察空间中使用的是右手坐标系,这是符合OpenGL传统的,在这样的观察空间中,摄像机的正前方只想的-z轴方向
这种左右手坐标系之间的改变很少会对我们在Unity中的编程产生影响,因为Unity为我们做了很多渲染的底层工作,包括很多坐标空间的转换,但是如果读者需要调用类似Camera,cameraToWorldMatrix,Camera,worldToCamraMatrix等接口自行计算某模型在观察空间中的位置,就要小心这样的差异
在这里特意提醒读者,观察空间和屏幕空间是不同的,观察空间是一个三维空间,而屏幕空间是一个二维空间,从观察空间到屏幕空间的转换需要经过一个操作,那就是投影(projection),后面会说到。
顶点变换的第二步,就是将顶点坐标从世界空间变换到观察空间中,这个变换通常叫做观察变换(view transform)
回到我们的农场游戏,现在我们需要把妞妞的鼻子从世界空间变换到观察空间中,为此,我们需要知道世界坐标系下摄像机的变换信息,这同样可以通过摄像机面板中Transform组件得到,如图所示:
为了得到顶点在观察空间的位置,我们可以有两种方法,一种方式是计算观察空间的3个坐标轴在世界空间下的表示,然后,根据前面所述方法,构造出从观察空间到世界空间的变换矩阵,然后再对该矩阵求逆来得到从世界空间变换到观察空间的变换矩阵,我们还可以使用另一种方法,即想象平移整个观察空间,让摄像机原点位于世界坐标的原点,坐标轴与世界空间中的坐标轴重合即可,这两种方法得到的变换矩阵都是一样的,不同的只是我们思考的方式
这里,我们使用第二种思考方法,由Transform组件可以知道,摄像机在世界空间中的变换是先按(30,0,0)进行旋转,然后按(0,10,-10)进行了平移,那么,为了把摄像机重新移回到初始状态(这里指摄像机原点位于世界坐标的原点,坐标轴与世界空间中的坐标轴重合),我们需要进行逆向变换,即先按(0,-10,10)平移,以便将摄像机移回到原点,再按(-30,0,0)进行旋转,以便让坐标轴重合,因此,变换矩阵就是:
但是,由于观察空间使用的是右手坐标系,因此需要对z分量进行取反操作,我们可以通过乘以另一个特殊的矩阵来得到最终的观察变换矩阵:
这样,我们就得到了观察空间中妞妞鼻子的位置——(9,8.84,-27.31)
4:裁剪空间
顶点接下来要从观察空间转换到裁剪空间(clip space,也被称为齐次裁剪空间)中,这个用于变换的矩阵叫做裁剪矩阵(clip matrix),也被称为投影矩阵(projection matrix)
裁剪空间的目标是能够方便地对渲染图元进行裁剪:完全位于这块空间内部的图元将会被保留,完全位于这块空间外部的图元将会被剔除,而与这块空间边界相交的图元就会被裁剪,那么,这块空间是如何确定下来的呢?答案是由视锥体(view frustum)来决定
视锥体指的是空间中的一块区域,这块区域决定了摄像机可以看到的空间,视锥体有六个平面包围而成,这些平面也被称为裁剪平面(clip planes)。视锥体有两种类型,这涉及到两种投影类型:一种是正交投影(orthographic projection),一种是透视投影(perspective projection)下图显示了从同一位置,同意角度渲染同一个场景的两种摄像机的渲染结果。
从图中可以发现,在透视投影中,地板上的平行线并不会保持平行,离摄像机越近网格越大,离摄像机越远网格越小,而在正交投影中,所有的网格大小都一样,而且平行线会一直保持平行,可以注意到,透视投影模拟了人眼看世界的方式,而正交投影则完全保留了物体的距离和角度。因此,在追求真实感的3D游戏中我们往往会使用透视投影,而在一些2D游戏或渲染小地图等其他HUD元素时,我们会使用正交投影
在视锥体的6块裁剪平面中,有两块裁剪平面比较特殊,它们分别成为近裁剪平面(near clip plane)和远裁剪平面(far clip plane)。它们决定了摄像机可以看到的深度范围,正交投影和透视投影的视锥体如图所示
从上图可以看出,视锥体是一个金字塔形,侧面的4个裁剪平面将会在摄像机处相交,它更符合视锥体这个词语,正交投影的视锥体是一个长方体。前面讲到,我们希望根据视锥体围成的区域对图元进行裁剪,但是,如果直接使用视锥体定义的空间来进行裁剪,那么不同的视锥体就需要不同的处理过程,而且对于透视投影的视锥体来说,想要判断一个顶点是否处于金字塔内部是比较麻烦的,因此,我们想用一种更加通用,方便,整洁的方式进行裁剪的工作,这种方式就是通过一个投影矩阵把顶点转换到一个裁剪空间中。
投影矩阵有两个目的。
@1首先是为投影做准备。这是个迷惑点,虽然投影矩阵的名称包含了投影二字,但是它并没有进行真正的投影工作,而是在为投影做准备,真正的投影发生在后面的齐次除法(homogeneous division)过程中。而经过投影矩阵的变换后,顶点的w分量将会具有特殊的意义
@2其次是对x,y,z分量进行缩放,上面讲过直接使用视锥体的6个裁剪平面来进行裁剪会比较麻烦,而经过投影矩阵的缩放后,我们可以直接使用w分量作为一个范围值,如果x,y,z分量都位于这个范围内,就说明该点位于裁剪空间内
在裁剪空间之前,虽然我们使用了齐次坐标来表示点和矢量,但它们的第四个分量都是固定的:点的w分量是1,方向矢量的w分量是0,经过投影矩阵变换后,我们我们就会赋予其次坐标的第4个坐标更加丰富的含义,下面会解释
所以投影到底是什么意思
anwser:可以理解成是一个空间的降维,例如从四维空间投影到三维空间中,而投影矩阵实际上并不会真的进行这个步骤,它会为真正的投影做准备工作,真正的投影会在屏幕映射时发生,通过齐次除法来得到二维坐标,具体后续会详细讲解正交投影和透视投影下分别使用的是何种裁剪矩阵:
@1:透视投影
视锥体的意义在于定义了场景中的一块三维空间,所有位于这块空间内的物体将会被渲染,否则就会被剔除或裁剪,我们已经知道,这块区域由6个裁剪平面定义,那么这6个裁剪平面又是怎么决定的呢?在Unity中,它们由Camera组件中的参数和Game视图的横纵比来决定
由上图可知,在Unity中,可以通过Camera中的组件Field of View(简称FOV)属性来改变视锥体竖直方向张开角度,而Clipping Planes中的Near和Far参数可以控制视锥体的近裁剪平面和远裁剪平面距离摄像机的远近,这样,我们可以求出视锥体近裁剪平面和远裁剪平面的高度,也就是
现在我们还缺乏横向信息,还可以通过摄像机的横纵比来得到,在Unity中,一个摄像机的横纵比由Game试图的横纵比和ViewPort Rect中的W和H属性共同决定,(实际上,Unity允许我们在脚本里通过Camera.aspect进行更改,但这里不做讨论)。假设,当前摄像机的横纵比为Aspect,我们定义:
现在,我们就可以根据已知的Near,Far,FOV,Aspect的值来确定透视投影的投影矩阵,如下
上面公式的推到会在本博客的文章中列出,需要注意的是,这里的投影矩阵是建立在Unity对坐标系的假定上面的,也就是说,我们针对的是观察空间为右手坐标系,使用列矩阵在矩阵右侧进行相乘,且变换后的Z分量范围将在【-w,w】之间的情况,而在类似DirectX这样的图形接口中,他们希望变换后Z分量范围将在【0,w】之间,因此就需要对上面的透视矩阵进行一些更改,这不在本书的讨论范围内。
而一个顶点和上述投影矩阵相乘后,可以由观察空间变换到裁剪空间中,结果如下:
从结果来看,这个投影矩阵本质就是对x,y,z和z分量进行了不同程度的缩放(当然,z分量还做了一个平移),缩放的目的是为了方便裁剪,我们可以注意到,此时顶点的w分量不再是1,而是原先z分量的取反结果。现在,我们就可以按如下不等式来判断一个变换后的顶点是否位于视锥体内。如果一个顶点在视锥体内,那么它变换后的坐标必须满足:
-w≤x≤w -w≤y≤w -w≤z≤w
任何不满足上述条件的图元都需要被剔除或裁剪,图4.39显示了经过上述投影矩阵后,视锥体的变化
从上图你可以注意到,裁剪矩阵会改变空间的旋向性:空间从右手坐标系变换到了左手坐标系,这意味着,离摄像机越远,z值将越大
@2:正交投影
首先,我们还是看一下正交投影钟的6个裁剪平面是如何定义的。和透视投影类似,在Unity中,它们也是由Camera组件中的参数和Game视图的横纵比共同决定
正交投影的视锥体是一个长方体,因此计算上相比透视投影来说更加简单,由图可以看出,我们可以通过Camera组件的size属性来改变视锥体竖直方向上高度的一半,而Clipping Planes中的Near和Far参数可以控制视锥体的近裁剪平面和远裁剪平面距离摄像机的远近,这样,我们可以求出视锥体近裁剪平面和远裁剪平面的高度,也就是:
现在我们还缺乏横向的信息,同样,我们可以通过摄像机的横纵比得到,假设,当前摄像机的横纵比为Aspect,那么:
现在,我们可以根据已知的Near,Far,Size和Aspect的值来确定正交投影的裁剪矩阵,如下:
同样,这里的投影矩阵是建立在Unity对坐标系的假定上面的,一个顶点和上述投影矩阵相乘后的结果如下:
注意到,和透视投影不同的是,使用正交投影的投影矩阵对顶点进行变换后,其w分量仍然为1,本质是因为投影矩阵最后一行的不同,透视投影的投影矩阵最后一行是【0,0,-1,0】,而正交投影的投影矩阵的最后一行是【0,0,0,1】这样选择是有原因的,是为了齐次除法做准备。
判断一个变换后的顶点是否位于视锥体内使用的不等式和透视投影中的一样,这样通用性也是为什么要使用投影矩阵的原因之一,下图显示了经过上述投影矩阵后,正交投影的视锥体的变化
据此,我们可以知道透视投影的参数:FOV为60,Near为5,Far为40,Aspect为4/3=1.333,那么对应的投影矩阵就是
然后,我们就可以用这个投影矩阵把妞妞的鼻子从观察空间转换到裁剪空间中,如下:
这样。我们就直接求出了妞妞的鼻子在裁剪空间中的位置——(11.691,15.311,23.692,27.31)。接下来Unity会判断妞妞的鼻子是否需要裁剪,通过比较得到,妞妞的鼻子满足下面的不等式
由此,我们可以判断,妞妞的鼻子位于视锥体内,不需要被裁剪
5:屏幕空间
经过投影矩阵的变换后,我们可以进行裁剪操作,当完成了所有的裁剪工作后,就需要进行真正的投影了,也就是说,我么需要把视锥体投影到屏幕空间(screen space)中,经过这一步变换,我们会得到真正的像素位置,而不是虚拟的三维坐标
屏幕空间是一个二维空间,因此,我们必须把顶点从裁剪空间投影到屏幕空间中,来生成对应的2D坐标,这个过程可以理解成有两个步骤。
首先,我们需要进行标准齐次除法(homogeneous divsion),也被称为透视除法(perspective division),虽然这个步骤听起来很陌生,但是它实际上非常简单,就是用齐次坐标系的w分量去除以x,y,z分量,在OpenGL中,我们把这一步得到的坐标叫做归一化的设备坐标(Normalized Device Coordinates,NDC)。经过这一步,我们可以把坐标从齐次裁剪坐标空间转换到NDC中。经过透视投影变换后的裁剪空间,经过齐次除法后会变换到一个立方体内,按照OpenGL的传统,这个立方体的x,y,z分量的范围都是[-1,1]。但在DirectX这样的API中,z分量的范围会是[0,1]。而Unity选择了OpenGL这样的齐次裁剪空间,如图所示:
而对于正交投影来说,它的裁剪空间实际已经是一个立方体了,而且由于经过正交投影矩阵变换后的顶点的w分量是1,因此齐次除法并不会对顶点的x,y,z坐标产生影响,如图4。44所示
经过齐次除法后,透视投影和正交投影的视锥体都变换到一个相同的立方体内,现在,我们可以根据变换后的x,y坐标来映射输出窗口的对应像素坐标。
在Unity中,屏幕空间左下角的像素坐标是(0,0),右上角的像素坐标是(pixelWidth,pixelHeight)。由于当前x,y坐标都是【-1,1】,因此这个映射的过程就是一个缩放的过程。
齐次除法和屏幕映射的过程可以使用下面的公式来总结:
上面的式子对x和y分量都进行了处理,那么z分量呢?通常,z分量会被用于深度缓冲,一个传统方式是把的值直接存进深度缓冲中,但这并不是必须的,通常驱动生产商会根据硬件来选择最好的存储格式,此时clipw也并不会被抛弃,虽然它已经完成了它的主要工作——在齐次除法中作为分母来得到NDC,但它仍然会在后续的一些工作中起到重要的作用,例如进行透视校正插值
在unity中,从裁剪空间到屏幕空间的转换是由Unity帮我们完成的,我们的顶点着色器只需要把顶点转换到裁剪空间即可。
从上一步中,我们知道了裁剪空间中妞妞鼻子的位置——(11.691,15.311,23.692,27.31)。现在,我们终于可以确定妞妞的鼻子在屏幕上的像素位置,假设,当前屏幕的像素宽度为400,高度为300,首先,我们需要进行齐次除法,把裁剪空间的坐标投影到NDC中,然后再映射到屏幕空间中,这个过程如下:
总结:
以上就是一个顶点如何从模型空间变换到屏幕坐标的过程,下图总结了这些空间和用于变换的矩阵
顶点着色器最基本的任务就是把顶点坐标从模型空间转换到裁剪空间中,而在片元着色器中,我们通常也可以得到该片元在屏幕空间的像素位置,我们会在后续内容中了解如何得到这些像素位置
在Unity中,坐标系的旋向性也随着变换发生了改变,总结了Unity中各个空间使用的坐标系旋向性。
上图中可以知道,只有在观察空间中Unity使用了右手坐标系,需要注意的是,这里仅仅给出的是一些最重要的坐标空间,还有一些空间在实际开发中也会遇到,例如——切线空间(tangent space)切线空间通常用于法线映射。