我们已经学会了如何使用矩阵来表示基本的变换,如平移、旋转和缩放。而在本节中,我们
将关注如何使用这些变换来对坐标空间进行变换。
我们在第2 章渲染流水线中就接触了坐标空间的变换。例如,在学习顶点着色器流水线阶段
时,我们说过,顶点着色器最基本的功能就是把模型的顶点坐标从模型空间转换到齐次裁剪坐标
空间中。
渲染游戏的过程可以理解成是把一个个顶点经过层层处理最终转化到屏幕上的过程,那么本
节我们就将学习这个转换的过程是如何实现的。更具体来说,顶点是经过了哪些坐标空间后,最
后被画在了我们的屏幕上。
我们先要回答读者的一个疑问。在编写Shader 的过程中,很多看起来很难理解和复杂的数学
运算都是为了在不同坐标空间之间转换点和矢量。看起来,这么多的坐标空间就是“万恶之源”
啊!很多人都有这样的疑问:“为什么我们不能只使用一个坐标空间来做所有的事情呢?这样一来
我们不就不用学习这些烦人的数学公式了吗?这样世界将变得多美好啊!”
事情看起来虽然是这样—在只有一个坐标空间的世界里,Shader 的开发者会生活得更加美
好。但事实是,一旦你真的这么做了,就会发现理想和现实之间的差距:我们不可以也不愿意抛
弃这些不同的坐标空间。
事实上,在我们的生活中,我们也总是使用不同的坐标空间来交流。现在正在读这本书的你,
很可能正坐在办公室或书房中。如果问你:“办公室的饮水机在哪里?”你大概会回答:“在办公
室门的左方3 米处。”这里,你很自然地使用了以门为原点的坐标空间。现在,公司的前台小姐走
进门来,你非常惊讶地看到她脸上还残留有中午吃饭的米粒!我们假设正在读这本书的你是一个
好心而且不喜欢看别人笑话的人,这时你可能会提醒她:“嘿,你左脸上面有些东西没有擦掉!”
此时,你又使用了以前台小姐的嘴巴为原点的坐标空间。如果只有一个坐标系会怎么样呢?你可
以尝试一下使用以你的办公室的门为原点的坐标空间来描述前台小姐脸上的一粒饭粒。
再比如,我们每个人所生活的城市可以看成是一个世界坐标系(三维渲染里的世界坐标系将
在4.6.5 节中讲到),这个坐标系的坐标轴可以认为是由东南西北这些定义的方向轴。如果一个陌
生人向你问路,你很有可能会说:“向东走800 米上桥,然后再向南走50 米就到了”。但是我们知
道,现实生活中有很多人是分不清东南西北的(在作者小时候,经常使用“上北下南左西右东”
来傻傻地判断东南西北,因此总是得到错误方位)。如果现在有一个饥肠辘辘又分不清东南西北的
路人来问你最近的餐厅怎么走,你可能会说:“你先往前走50 米,到了路口向左拐100 米就有一
家非常好吃的烤鸭店。”此时,你使用的是以这个路人为原点的坐标空间。想象一下,如果在这个
世界上我们只能使用东南西北来描述所有东西的话,该会有多少人会被饿死。
由此可见,我们需要在不同的情况下使用不同的坐标空间,因为一些概念只有在特定的坐标
空间下才有意义,才更容易理解。这也是为什么在渲染中我们要使用这么多坐标空间。
在开始介绍一些不同的坐标空间之前,读者需要注意,所有的坐标空间在理论上都是平等的,
没有谁优谁劣之分,不会因为我们从一个坐标空间转换到另一个坐标空间计算就出错了。但是,
在特定的情况下,一些坐标空间的确比另一些坐标空间更加吸引人。
现在,就让我们来看一下在游戏渲染流水线中,一个顶点到底经过了怎样的空间变换。
4.6.2 坐标空间的变换
我们先要为后面的内容做些数学铺垫。在渲染流水线中,我们往往需要把一个点或方向矢量
从一个坐标空间转换到另一个坐标空间。这个过程到底是怎么实现的呢?
我们把问题一般化。我们知道,要想定义一个坐标空间,必须指明其原点位置和3 个坐标轴
的方向。而这些数值实际上是相对于另一个坐标空间的(读者需要记住,所有的都是相对的)。也
就是说,坐标空间会形成一个层次结构—每个坐标空间都是另一个坐标空间的子空间,反过来
说,每个空间都有一个父(parent)坐标空间。对坐标空间的变换实际上就是在父空间和子空间之
间对点和矢量进行变换。
假设,现在有父坐标空间P 以及一个子坐标空间C。我们知道在父坐标空间中子坐标空间的
原点位置以及3 个单位坐标轴。我们一般会有两种需求:一种需求是把子坐标空间下表示的点或
矢量Ac 转换到父坐标空间下的表示Ap,另一个需求是反过来,即把父坐标空间下表示的点或矢
量Bp 转换到子坐标空间下的表示Bc。我们可以使用下面的公式来表示这两种需求:
其中,Mc→p 表示的是从子坐标空间变换到父坐标空间的变换矩阵,而Mp→c 是其逆矩阵(即
反向变换)。那么,现在的问题就是,如何求解这些变换矩阵?事实上,我们只需要解出两者之一
即可,另一个矩阵可以通过求逆矩阵的方式来得到。
下面,我们就来讲解如何求出从子坐标空间到父坐标空间的变换矩阵Mc→p。
首先,我们来回顾一个看似很简单的问题:当给定一个坐标空间以及其中一点(a,b,c)时,我
们是如何知道该点的位置的呢?我们可以通过4 个步骤来确定它的位置:
(1)从坐标空间的原点开始;
(2)向x 轴方向移动a 个单位;
(3)向y 轴方向移动b 个单位;
(4)向z 轴方向移动c 个单位。
需要说明的是,上面的步骤只是我们的想象,这个点实际上并没有发生移动。上面的步骤看
起来再简单不过了,坐标空间的变换就蕴含在上面的4 个步骤中。现在,我们已知子坐标空间C
的3 个坐标轴在父坐标空间P 下的表示xc、yc、zc,以及其原点位置Oc。当给定一个子坐标空间
中的一点Ac (a,b,c),我们同样可以依照上面 4 个步骤来确定其在父坐标空间下的位置 p A :
1.从坐标空间的原点开始
这很简单,我们已经知道了子坐标空间的原点位置c O 。
2.向x 轴方向移动a 个单位
仍然很简单,因为我们已经知道了x 轴的矢量表示,因此可以得到
c c O ax
3.向y 轴方向移动b 个单位
同样的道理,这一步就是:
c c c O ax by
4.向z 轴方向移动c 个单位
最后,就可以得到
c c c c O ax by cz
现在,我们已经求出了Mc→p!什么?你没看出来吗?我们再来看一下最后得到的式子:
p c c c c A O ax by cz
读者可能会问,这个式子里根本没有矩阵啊!其实我们只要稍稍使用一点“魔法”,矩阵就会
出现在上面的式子中:
其中“|”符号表示是按列展开的。上面的式子实际上就是使用了我们之前所学的公式而已。
但这个最后的表达式还不是很漂亮,因为还存在加法表达式,即平移变换。我们已经知道3×3
的矩阵无法表示平移变换,因此为了得到一个更漂亮的结果,我们把上面的式子扩展到齐次坐标
空间中,得
那么现在,你看到Mc→p 在哪里了吧?没错,
读者:这个看起来太神奇了!怎么就变着变着就出现了矩阵呢?
我们:上面只是运用了一些基础的矢量和矩阵运算,一旦当你真正理解了这些运算就会发现
上面的过程只是简单地推导了一下而已。
一旦求出来Mc→p,Mp→c 就可以通过求逆矩阵的方式求出来,因为从坐标空间C 变换到坐标
空间P 与从坐标空间P 变换到坐标空间C 是互逆的两个过程。
可以看出来,变换矩阵Mc→p 实际上可以通过坐标空间C 在坐标空间P 中的原点和坐标轴的
矢量表示来构建出来:把3 个坐标轴依次放入矩阵的前3 列,把原点矢量放到最后一列,再用0
和1 填充最后一行即可。
需要注意的是,这里我们并没有要求3 个坐标轴xc、yc 和zc 是单位矢量,事实上,如果存在
缩放的话,这3 个矢量值很可能不是单位矢量。
更加令人振奋的是,我们可以利用反向思维,从这个变换矩阵反推来获取子坐标空间的原点
和坐标轴方向!例如,当我们已知从模型空间到世界空间的一个4×4 的变换矩阵,可以提取它的
第一列再进行归一化后(为了消除缩放的影响)来得到模型空间的x 轴在世界空间下的单位矢量
表示。同样的方法可以提取y 轴和z 轴。我们可以从另一个角度来理解这个提取过程。因为矩阵
Mc→p 可以把一个方向矢量从坐标空间C 变换到坐标空间P 中,那么,我们只需要用它来变换坐
标空间C 中的x 轴(1,0,0,0),即使用矩阵乘法Mc→p[1 0 0 0]T,得到的结果正是Mc→p 的第一列。
另一个有趣的情况是,对方向矢量的坐标空间变换。我们知道,矢量是没有位置的,因此坐
标空间的原点变换是可以忽略的。也就是说,我们仅仅平移坐标系的原点是不会对矢量造成任何
影响的。那么,对矢量的坐标空间变换就可以使用3×3 的矩阵来表示,因为我们不需要表示平移
变换。那么变换矩阵就是:
在Shader 中,我们常常会看到截取变换矩阵的前3 行前3 列来对法线方向、光照方向来进行
空间变换,这正是原因所在。
现在,我们再来关注Mp→c。我们前面讲到,可以通过求Mc→p 的逆矩阵的方式求解出来反向
变换Mp→c。但有一种情况我们不需要求解逆矩阵就可以得到Mp→c,这种情况就是Mc→p 是一个正
交矩阵。如果它是一个正交矩阵的话,Mc→p 的逆矩阵就等于它的转置矩阵。这意味着我们不需要
进行复杂的求逆操作就可以得到反向变换。也就是说,
而现在,我们不仅可以根据变换矩阵Mc→p 反推出子坐标空间的坐标轴方向在父坐标空间中的
表示xc、yc 和zc,还可以反推出父坐标空间的坐标轴方向在子坐标空间中的表示xp、yp 和zp,这
些坐标轴对应的就是Mc→p 的每一行!也就是说,如果我们知道坐标空间变换矩阵MA→B 是一个正
交矩阵,那么我们可以提取它的第一列来得到坐标空间A 的x 轴在坐标空间B 下的表示,还可以
提取它的第一行来得到坐标空间B 的x 轴在坐标空间A 下的表示。反过来,如果我们知道坐标空
间B 的x 轴、y 轴和z 轴(必须是单位矢量,否则构建出来的就不是正交矩阵了)在坐标空间A
下的表示,就可以把它们依次放在矩阵的每一行就可以得到从A 到B 的变换矩阵了。
读者:天呐,我的脑子已经完全乱掉了,一会儿从P 到C,一会儿又从C 到P,一会儿是行,
一会儿又是列,我自己写的时候一定会搞不清楚!
我们:我们知道这个过程很容易造成思维的混乱,因此才要花费大量的篇幅来解释背后的数
学原理。只有知道了这些原理,遇到疑问时你才知道怎样去验证结果的正确性。例如像下面这样。
当你不知道把坐标轴的表示是按行放还是按列放的时候,不妨先选择一种摆放方式来得到变
换矩阵。例如,现在我们想把一个矢量从坐标空间A 变换到坐标空间B,而且我们已经知道坐标
空间B 的x 轴、y 轴、z 轴在空间A 下的表示,即xB、yB 和zB。那么想要得到从A 到B 的变换矩
阵MA→B,我们是把它们按列放呢还是按行放呢?如果读者实在想不起来正确答案,我们不妨先随
便选择一种方式,例如按列摆放。那么,
现在,我们可以非常快速地来验证它是否是正确的。方法就是,用MA→B 来变换xB。在计算前
我们先想一下这个结果,如果我们用变换矩阵来变换B 的x 轴的话,那么结果应该是(1,0,0)才对。
因为当变换到空间B 中时,x 轴的指向就是(1,0,0)。好了,我们可以来进行真正的计算来验证它了:
读者看到这里会有疑问,“我不知道这个结果是什么啊”。没错,这不是你的计算有问题,而
是上式的计算结果的确不可知。这种时候你就会发现我们的摆放方式选择错了。现在,我们使用
正确的摆放方式,即按行来摆放,那么就有:
这次结果就和我们预期的一样了。
理解上面的原理和过程非常重要。我们在本书的后面也会经常遇到坐标空间的变换。
我们知道,在渲染流水线中,一个顶点要经过多个坐标空间的变换才能最终被画在屏幕上。一
个顶点最开始是在模型空间(见4.6.4 节)中定义的,最后它将会变换到屏幕空间(见4.6.8 节)中,
得到真正的屏幕像素坐标。因此,接下来的内容我们将解释顶点要进行的各种空间变换的过程。
为了帮助读者理解这个过程,我们将建立在农场游戏的实例背景下,每讲到一种空间变换,
我们会解释如何应用到这个案例中。
在我们的农场游戏中,妞妞很好奇自己是如何被渲染到屏幕上的。它只知道自己和一群小伙
伴在农场里快乐地吃草,而前面有一个摄像机一直在观察它们,如图4.31 所示。妞妞特别喜欢自
己的鼻子,它想知道鼻子是怎么被画到屏幕上的?
在下面的内容中,我们将了解妞妞的鼻子是如何一步步画到屏幕上的。
`模型空间(model space),如它的名字所暗示的那样,是和某个模型或者说是对象有关的。
有时模型空间也被称为对象空间(object space)或局部空间(local space)。每个模型都有自己独
立的坐标空间,当它移动或旋转的时候,模型空间也会跟着它移动和旋转。把我们自己当成游戏
中的模型的话,当我们在办公室里移动时,我们的模型空间也在跟着移动,当我们转身时,我们
本身的前后左右方向也在跟着改变。
在模型空间中,我们经常使用一些方向概念,例如“前(forward)”“后(back)”“左(left)”、
“右(right)”、“上(up)”、“下(down)”。在本书中,我们把这些方向称为自然方向。模型空间
中的坐标轴通常会使用这些自然方向。在4.2.4 节中我们讲过,Unity 在模型空间中使用的是左手
坐标系,因此在模型空间中,+x 轴、+y 轴、+z 轴分别对应的是模型的右、上和前向。需要注意
的是,模型坐标空间中的x 轴、y 轴、z 轴和自然方向的对应不一定是上述这种关系,但由于Unity
使用的是这样的约定,因此本书将使用这种方式。我们可以在Hierarchy 视图中单击任意对象就可
以看见它们对应的模型空间的3 个坐标轴。
模型空间的原点和坐标轴通常是由美术人员在建模软件里确定好的。当导入到Unity 中后,
我们可以在顶点着色器中访问到模型的顶点信
息,其中包含了每个顶点的坐标。这些坐标都是
相对于模型空间中的原点(通常位于模型的重心)
定义的。
当我们把妞妞放到场景中时,就会有一个模
型坐标空间时刻跟随着它。妞妞鼻子的位置可以
通过访问顶点属性来得到。假设这个位置是(0, 2,
4),由于顶点变换中往往包含了平移变换,因此
需要把其扩展到齐次坐标系下,得到顶点坐标是
(0, 2, 4, 1),如图4.32 所示。
4.6.5 世界空间
世界空间(world space)是一个特殊的坐标系,因为它建立了我们所关心的最大的空间。一
些读者可能会指出,空间可以是无限大的,怎么会有“最大”这一说呢?这里说的最大指的是一
个宏观的概念,也就是说它是我们所关心的最外层的坐标空间。以我们的农场游戏为例,在这个
游戏里世界空间指的就是农场,我们不关心这个农场是在什么地方,在这个虚拟的游戏世界里,
农场就是最大的空间概念。
世界空间可以被用于描述绝对位置(较真的读者可能会再一次提醒我,没有绝对的位置。没
错,但我相信读者可以明白这里绝对的意思)。在本书中,绝对位置指的就是在世界坐标系中的位
置。通常,我们会把世界空间的原点放置在游戏空间的中心。
在Unity 中,世界空间同样使用了左手坐标系。但它的x 轴、y 轴、z 轴是固定不变的。在
Unity 中,我们可以通过调整Transform 组件中的Position 属性来改变模型的位置,这里的位置指
的是相对于这个Transform 的父节点(parent)的模型坐标空间中的原点定义的。如果一个
Transform 没有任何父节点,那么这个位置就是在世界坐标系中的位置,如图4.33 所示。我们可
以想象成还有一个虚拟的根模型,这个根模型的模型空间就是世界空间,所有的游戏对象都附属
于这个根模型。同样,Transform 中的Rotation 和Scale 也是同样的道理。
顶点变换的第一步,就是将顶点坐标从模型空间变换到世界空间中。这个变换通常叫做模型
变换(model transform)。
现在,我们来对妞妞的鼻子进行模型变换。为此,我们首先需要知道妞妞在世界坐标系中进
行了哪些变换,这可以通过面板中的Transform 组件来得到相关的变换信息,如图4.34 所示。
根据Transform 组件上的信息,我们知道在世界空间中,妞妞进行了(2, 2, 2)的缩放,又进行
了(0, 150, 0)的旋转以及(5, 0, 25)的平移。注意这里的变换顺序是不能互换的,即先进行缩放,再
进行旋转,最后是平移。据此我们可以构建出模型变换的变换矩阵:
现在我们可以用它来对妞妞的鼻子进行模型变换了:
也就是说,在世界空间下,妞妞鼻子的位置是(9, 4, 18.072)。注意,这里的浮点数都是近似值,
这里近似到小数点后3 位。实际数值和Unity 采用的浮点值精度有关。
观察空间(view space)也被称为摄像机空间(camera space)。观察空间可以认为是模型空
间的一个特例—在所有的模型中有一个非常特殊的模型,即摄像机(虽然通常来说摄像机本身
是不可见的),它的模型空间值得我们单独拿出来讨论,也就是观察空间。
摄像机决定了我们渲染游戏所使用的视角。在观察空间中,摄像机位于原点,同样,其坐标
轴的选择可以是任意的,但由于本书讨论的是以Unity 为主,而Unity 中观察空间的坐标轴选择
是:+x 轴指向右方,+y 轴指向上方,而+z 轴指向的是摄像机的后方。读者在这里可能觉得很奇
怪,我们之前讨论的模型空间和世界空间中+z 轴指的都是物体的前方,为什么这里不一样了呢?
这是因为,Unity 在模型空间和世界空间中选用的都是左手坐标系,而在观察空间中使用的是右
手坐标系。这是符合OpenGL 传统的,在这样的观察空间中,摄像机的正前方指向的是-z 轴方向。
这种左右手坐标系之间的改变很少会对我们在Unity 中的编程产生影响,因为Unity 为我们
做了很多渲染的底层工作,包括很多坐标空间的转换。但是,如果读者需要调用类似
Camera.cameraToWorldMatrix、Camera.worldToCameraMatrix 等接口自行计算某模型在观察空间中
的位置,就要小心这样的差异。
最后要提醒读者的一点是,观察空间和屏幕空间(详见4.6.8 节)是不同的。观察空间是一个
三维空间,而屏幕空间是一个二维空间。从观察空间到屏幕空间的转换需要经过一个操作,那就
是投影(projection)。我们后面就会讲到。
顶点变换的第二步,就是将顶点坐标从世界空间变换到观察空间中。这个变换通常叫做观察
变换(view transform)。
回到我们的农场游戏。现在我们需要把妞妞的鼻子从世界空间变换到观察空间中。为此,我
们需要知道世界坐标系下摄像机的变换信息。这同样可以通过摄像机面板中的Transform 组件得
到,如图4.35 所示。
为了得到顶点在观察空间中的位置,我们可以有两种方法。一种方法是计算观察空间的三个
坐标轴在世界空间下的表示,然后根据4.6.2 节中讲到的方法,构建出从观察空间变换到世界空间
的变换矩阵,再对该矩阵求逆来得到从世界空间变换到观察空间的变换矩阵。我们还可以使用另
一种方法,即想象平移整个观察空间,让摄像机原点位于世界坐标的原点,坐标轴与世界空间中
的坐标轴重合即可。这两种方法得到的变换矩阵都是一样的,不同的只是我们思考的方式。
这里我们使用第二种方法。由Transform 组件可以知道,摄像机在世界空间中的变换是先按(30,
0, 0)进行旋转,然后按(0, 10, −10)进行了平移。那么,为了把摄像机重新移回到初始状态(这里
指摄像机原点位于世界坐标的原点、坐标轴与世界空间中的坐标轴重合),我们需要进行逆向变换,
即先按(0, −10, 10)平移,以便将摄像机移回到原点,再按(−30, 0, 0)进行旋转,以便让坐标轴重合。
因此,变换矩阵就是:
但是,由于观察空间使用的是右手坐标系,因此需要对z 分量进行取反操作。我们可以通过
乘以另一个特殊的矩阵来得到最终的观察变换矩阵:
现在我们可以用它来对妞妞的鼻子进行顶点变换了:
这样,我们就得到了观察空间中妞妞鼻子的位置— (9, 8.84,−27.31)。
顶点接下来要从观察空间转换到裁剪空间(clip space,也被称为齐次裁剪空间)中,这个用
于变换的矩阵叫做裁剪矩阵(clip matrix),也被称为投影矩阵(projection matrix)。
裁剪空间的目标是能够方便地对渲染图元进行裁剪:完全位于这块空间内部的图元将会被保
留,完全位于这块空间外部的图元将会被剔除,而与这块空间边界相交的图元就会被裁剪。那么,
这块空间是如何决定的呢?答案是由视锥体(view frustum)来决定。
视锥体指的是空间中的一块区域,这块区域决定了摄像机可以看到的空间。视锥体由六个平
面包围而成,这些平面也被称为裁剪平面(clip planes)。视锥体有两种类型,这涉及两种投影
类型:一种是正交投影(orthographic projection),一种是透视投影(perspective projection)。
图4.36 显示了从同一位置、同一角度渲染同一个场景的两种摄像机的渲染结果。
从图中可以发现,在透视投影中,地板上的平行线并不会保持平行,离摄像机越近网格越大,
离摄像机越远网格越小。而在正交投影中,所有的网格大小都一样,而且平行线会一直保持平行。
可以注意到,透视投影模拟了人眼看世界的方式,而正交投影则完全保留了物体的距离和角度。
因此,在追求真实感的3D 游戏中我们往往会使用透视投影,而在一些2D 游戏或渲染小地图等其
他HUD 元素时,我们会使用正交投影。
在视锥体的6 块裁剪平面中,有两块裁剪平面比较特殊,它们分别被称为近剪裁平面(near clip
plane)和远剪裁平面(far clip plane)。它们决定了摄像机可以看到的深度范围。正交投影和透视
投影的视锥体如图4.37 所示。
由图4.37 可以看出,透视投影的视锥体是一个金字塔形,侧面的4 个裁剪平面将会在摄像机
处相交。它更符合视锥体这个词语。正交投影的视锥体是一个长方体。前面讲到,我们希望根据
视锥体围成的区域对图元进行裁剪,但是,如果直接使用视锥体定义的空间来进行裁剪,那么不
同的视锥体就需要不同的处理过程,而且对于透视投影的视锥体来说,想要判断一个顶点是否处
于一个金字塔内部是比较麻烦的。因此,我们想用一种更加通用、方便和整洁的方式来进行裁剪
的工作,这种方式就是通过一个投影矩阵把顶点转换到一个裁剪空间中。
投影矩阵有两个目的:
首先是为投影做准备。这是个迷惑点,虽然投影矩阵的名称包含了投影二字,但是它并没有
进行真正的投影工作,而是在为投影做准备。真正的投影发生在后面的齐次除法
(homogeneous division)过程中。而经过投影矩阵的变换后,顶点的w 分量将会具有特殊的
意义。
读者:投影到底是什么意思呢?
我们:可以理解成是一个空间的降维,例如从四维空间投影到三维空间中。而投影矩阵实际
上并不会真的进行这个步骤,它会为真正的投影做准备工作。真正的投影会在屏幕映射时发生,
通过齐次除法来得到二维坐标。具体会在4.6.8 节中讲到。
其次是对x、y、z 分量进行缩放。我们上面讲过直接使用视锥体的6 个裁剪平面来进行裁
剪会比较麻烦。而经过投影矩阵的缩放后,我们可以直接使用w 分量作为一个范围值,
如果x、y、z 分量都位于这个范围内,就说明该顶点位于裁剪空间内。
在裁剪空间之前,虽然我们使用了齐次坐标来表示点和矢量,但它们的第四个分量都是固定
的:点的w 分量是1,方向矢量的w 分量是0。经过投影矩阵的变换后,我们就会赋予齐次坐标
的第4 个坐标更加丰富的含义。下面,我们来看
一下两种投影类型使用的投影矩阵具体是什么。
1.透视投影
视锥体的意义在于定义了场景中的一块三
维空间。所有位于这块空间内的物体将会被渲
染,否则就会被剔除或裁剪。我们已经知道,这
块区域由6 个裁剪平面定义,那么这6 个裁剪平
面又是怎么决定的呢?在Unity 中,它们由
Camera 组件中的参数和Game 视图的横纵比共
同决定,如图4.38 所示。
由图4.38 可以看出,我们可以通过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 分量的取反结果。现在,我们就可以按如下不等式来判断一个变换后的顶点是否
位于视锥体内。如果一个顶点在视锥体内,那么它变换后的坐标必须满足:
任何不满足上述条件的图元都需要被剔除或者裁剪。图4.39 显示了经过上述投影矩阵后,视
锥体的变化。
从图4.39 还可以注意到,裁剪矩阵会改变空间的旋向性:空间从右手坐标系变换到了左手坐
标系。这意味着,离摄像机越远,z 值将越大。
2.正交投影
首先,我们还是看一下正交投影中的6 个裁
剪平面是如何定义的。和透视投影类似,在Unity
中,它们也是由Camera 组件中的参数和Game 视
图的横纵比共同决定,如图4.40 所示。
正交投影的视锥体是一个长方体,因此计算
上相比透视投影来说更加简单。由图可以看出,
我们可以通过Camera 组件的Size 属性来改变视锥
体竖直方向上高度的一半,而Clipping Planes 中的
Near 和Far 参数可以控制视锥体的近裁剪平面和
远裁剪平面距离摄像机的远近。这样,我们可以求出视锥体近裁剪平面和远裁剪平面的高度,也
就是:
nearClipPlaneHeight=2·Size
farClipPlaneHeight=nearClipPlaneHeight
现在我们还缺乏横向的信息。同样,我们可以通过摄像机的横纵比得到。假设,当前摄像机
的横纵比为Aspect,那么:
现在,我们可以根据已知的Near、Far、Size 和Aspect 的值来确定正交投影的裁剪矩阵。如下:
上面公式的推导部分可以参见本章的扩展阅读部分。同样,这里的投影矩阵是建立在Unity
对坐标系的假定上面的。
一个顶点和上述投影矩阵相乘后的结果如下:
注意到,和透视投影不同的是,使用正交投影的投影矩阵对顶点进行变换后,其w 分量仍然
为1。本质是因为投影矩阵最后一行的不同,透视投影的投影矩阵的最后一行是[0 0 −1 0],而
正交投影的投影矩阵的最后一行是[0 0 0 1]。这样的选择是有原因的,是为了为齐次除法做
准备。具体会在下一节中讲到。
判断一个变换后的顶点是否位于视锥体内使用的不等式和透视投影中的一样,这种通用性也
是为什么要使用投影矩阵的原因之一。图4.41 显示了经过上述投影矩阵后,正交投影的视锥体的
变化。
同样,裁剪矩阵改变了空间的旋向性。可以注意到,经过正交投影变换后的顶点实际已经位
于一个立方体内了。
希望看到这里读者的脑袋还没有爆炸。现在,我们继续来看我们的农场游戏。在4.6.6 节的最
后,我们已经帮助妞妞确定了它的鼻子在观察空间中的位置—(9, 8.84, -27.31)。现在,我们要计
算它在裁剪空间中的位置。
首先,我们需要知道农场游戏中使用的摄像机类型。由于农场游戏是一个3D 游戏,因此这
里我们使用了透视摄像机。摄像机参数和Game 视图的横纵比如图4.42 所示。
据此,我们可以知道透视投影的参数:FOV 为60°,Near 为5,Far 为40,Aspect 为4/3 = 1.333。
那么,对应的投影矩阵就是:
然后,我们用这个投影矩阵来把妞妞的鼻子从观察空间转换到裁剪空间中。如下:
这样,我们就求出了妞妞的鼻子在裁剪空间中的位置— (11.691, 15.311, 23.692, 27.31)。接
下来,Unity 会判断妞妞的鼻子是否需要裁剪。通过比较得到,妞妞的鼻子满足下面的不等式:
由此,我们可以判断,妞妞的鼻子位于视锥体内,不需要被裁剪。
4.6.8 屏幕空间
经过投影矩阵的变换后,我们可以进行裁剪操作。当完成了所有的裁剪工作后,就需要进行
真正的投影了,也就是说,我们需要把视锥体投影到屏幕空间(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 这样的齐次裁剪空间。如图4.43 所示。
而对于正交投影来说,它的裁剪空间实际已经是一个立方体了,而且由于经过正交投影矩
阵变换后的顶点的w 分量是1,因此齐次除法并不会对顶点的x、y、z 坐标产生影响。如图4.44
所示。
经过齐次除法后,透视投影和正交投影的视锥体都变换到一个相同的立方体内。现在,我们
可以根据变换后的x 和y 坐标来映射输出窗口的对应像素坐标。
在Unity 中,屏幕空间左下角的像素坐标是(0, 0),右上角的像素坐标是(pixelWidth, pixelHeight)。
由于当前x 和y 坐标都是[−1, 1],因此这个映射的过程就是一个缩放的过程。
齐次除法和屏幕映射的过程可以使用下面的公式来总结:
上面的式子对x 和y 分量都进行了处理,那么z 分量呢?通常,z 分量会被用于深度缓冲。一
个传统的方式是把z
w
clip
clip
的值直接存进深度缓冲中,但这并不是必须的。通常驱动生产商会根据硬
件来选择最好的存储格式。此时clipw 也并不会被抛弃,虽然它已经完成了它的主要工作—在齐
次除法中作为分母来得到NDC,但它仍然会在后续的一些工作中起到重要的作用,例如进行透视
校正插值。
在Unity 中,从裁剪空间到屏幕空间的转换是由底层帮我们完成的。我们的顶点着色器只需
要把顶点转换到裁剪空间即可。
在上一步中,我们知道了裁剪空间中妞妞鼻子的位置—(11.691, 15.311, 23.692, 27.31)。现
在,我们终于可以确定妞妞的鼻子在屏幕上的像素位置。假设,当前屏幕的像素宽度为400,高
度为300。首先,我们需要进行齐次除法,把裁剪空间的坐标投影到NDC 中。然后,再映射到屏
幕空间中。这个过程如下:
以上就是一个顶点如何从模型空间变换到屏幕坐标的过程。图4.45 总结了这些空间和用于变
换的矩阵。
顶点着色器的最基本的任务就是把顶点坐标从模型空间转换到裁剪空间中。这对应了图4.45
中的前3 个顶点变换过程。而在片元着色器中,我们通常也可以得到该片元在屏幕空间的像素位
置。我们会在4.9.3 节中看到如何得到这些像素位置。
在Unity 中,坐标系的旋向性也随着变换发生了改变。图4.46 总结了Unity 中各个空间使用
的坐标系旋向性。
从图4.46 中可以发现,只有在观察空间中Unity 使用了右手坐标系。
需要注意的是,这里仅仅给出的是一些最重要的坐标空间。还有一些空间在实际开发中也
会遇到,例如切线空间(tangent space)。切线空间通常用于法线映射,在后面的4.7 节中我们
会讲到。
在本章的最后,我们来看一种特殊的变换:法线变换。
法线(normal),也被称为法矢量(normal vector)。在上面我们已经看到如何使用变换矩阵
来变换一个顶点或一个方向矢量,但法线是需要我们特殊处理的一种方向矢量。在游戏中,模型
的一个顶点往往会携带额外的信息,而顶点法线就是其中一种信息。当我们变换一个模型的时候,
不仅需要变换它的顶点,还需要变换顶点法线,以便在后续处理(如片元着色器)中计算光照等。
一般来说,点和绝大部分方向矢量都可以使用同一个4×4 或3×3 的变换矩阵MA→B 把其从
坐标空间A 变换到坐标空间B 中。但在变换法线的
时候,如果使用同一个变换矩阵,可能就无法确保
维持法线的垂直性。下面就来了解一下为什么会出
现这样的问题。
我们先来了解一下另一种方向矢量—切线
(tangent),也被称为切矢量(tangent vector)。与
法线类似,切线往往也是模型顶点携带的一种信
息。它通常与纹理空间对齐,而且与法线方向垂直,
如图4.47 所示。
由于切线是由两个顶点之间的差值计算得到
的,因此我们可以直接使用用于变换顶点的变换矩
阵来变换切线。假设,我们使用3×3 的变换矩阵
MA→B 来变换顶点(注意,这里涉及的变换矩阵都是3×3 的矩阵,不考虑平移变换。这是因为切
线和法线都是方向矢量,不会受平移的影响),可以由下面的式子直接得到变换后的切线:
TB=MA→BTA
其中TA 和TB 分别表示在坐标空间A 下和坐标空间B 下的切线方向。但如果直接使用MA→B
来变换法线,得到的新的法线方向可能就不会与表面垂直了。图4.48 给出了这样的一个例子。
那么,应该使用哪个矩阵来变换法线呢?我们可以由数学约束条件来推出这个矩阵。我们知
道同一个顶点的切线TA 和法线NA 必须满足垂直条件,即TA·NA=0。给定变换矩阵MA→B,我们
已经知道TB=MA→B TA。我们现在想要找到一个矩阵G 来变换法线NA,使得变换后的法线仍然与
切线垂直。即
对上式进行一些推导后可得
( ) ( ) ( )T ( ) T T T ( T ) 0
AB A A AB A A A AB A A AB A M T GN M T GN T M GN T M G N
由于TA · NA=0 , 因此如果T
AB M G=I, 那么上式即可成立。也就是说, 如果
= ( T ) 1 = ( 1 )T
A B A B
G M M ,即使用原变换矩阵的逆转置矩阵来变换法线就可以得到正确的结果。
值得注意的是,如果变换矩阵MA→B 是正交矩阵,那么1 T
A B A B
M M ,因此( T ) 1
A B A B
M M ,
也就是说我们可以使用用于变换顶点的变换矩阵来直接变换法线。如果变换只包括旋转变换,那
么这个变换矩阵就是正交矩阵。而如果变换只包含旋转和统一缩放,而不包含非统一缩放,我们
利用统一缩放系数 k来得到变换矩阵 MA→B的逆转置矩阵( T ) 1 1
A B k A B
M M 。这样就可以避免计
算逆矩阵的过程。如果变换中包含了非统一变换,那么我们就必须要求解逆矩阵来得到变换法线
的矩阵。
Unity Shader入门精要
作者:冯乐乐