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