摘录自 冯乐乐的《Unity Shader入门精要》
笛卡尔坐标系
1)二维笛卡尔坐标系
在游戏制作中,我们使用的数学绝大部分都是计算位置、距离、角度等变量。而这些计算大部分都是在笛卡尔坐标系下进行的。
一个二维的笛卡尔坐标系包含了两个部分的信息:
一个特殊的位置,即原点,它是整个坐标系的中心。
两条过原点的互相垂直的矢量,即X轴和Y轴。这些坐标轴也被称为是该坐标的矢量。
OpenGL 和 DirectX 使用了不同的二维笛卡尔坐标系。如下图所示:
2)三维笛卡尔坐标系
在三维笛卡尔坐标系中,我们需要定义三个坐标轴和一个原点。如下图所示:
这三个坐标轴也被称为是该坐标轴的基矢量。通常情况下,这个三个坐标轴之间是相互垂直的,且长度为1,这样的的基矢量被称为标准正交基,但这并不是必须的。例如,在一些坐标系中坐标轴之间相互垂直但长度不为1,这样的基矢量被称为正交基。如非特殊说明,后续默认使用的都是标准正交基。
正交的意思是相互垂直。
三维坐标系可以大致分为左手坐标系和右手坐标系。
三维坐标系并不都是等价的。因为就出现了不同的三维坐标系:左手坐标系和右手坐标系。如果两个坐标系具有相同的旋向性,那么我们就可以通过旋转的方法来让它们的坐标轴指向重合。但是如果它们具有不同的旋向性,那么就无法达到重合的目的。下图分别为左手坐标系和右手坐标系。
对于一个需要可视化虚拟的三维世界的应用(如Unity)来说,它的设计者就要进行一个选择。对于模型空间和世界空间,Unity使用的是左手坐标系。
但对于观察空间来说,Unity使用的是右手坐标系。观察空间,通俗来讲就是以摄像机为原点的坐标系。在这个坐标系中,摄像机的前向是z轴的负方向,这与在模型空间和世界空间中的定义相反。也就是说,z轴坐标的减少意味着场景深度的增加。
点和矢量
点是n维空间中的一个位置,它没有大小和宽度这类概念。在笛卡尔坐标系中,我们可以使用2个或3个实数来表示一个点的坐标。
矢量的定义则复杂一些。矢量存在的意义更多是为了和标量区分开来。通常的讲,矢量是指n维空间中一种包含了模和方向的有向线段,我们通常讲到的速度就是一种典型的矢量。
具体来讲。
矢量的模指的是这个矢量的长度,一个矢量的长度可以是任意的非负数。
矢量的方向则描述了这个矢量在空间中的指向。
下图简单描述了点和矢量之间的 关系。
1)矢量和标量的乘法/除法
只能是矢量被标量除,而不能是标量被矢量除。
2)
矢量的加减法
需要注意的是,一个矢量不能和一个标量相加或相减。矢量的加减法遵守三角法则。
3)矢量的模
4)单位矢量
单位矢量指的是那些模为1 的矢量。也被称为归一化矢量。
5)矢量的点积
矢量的乘法有两种最常用的种类:点积(内积)和叉积(外积)
公式一
点积满足交换律。
性质一:点积可结合标量乘法
性质二:点积可结合矢量加法和减法,和性质一类似。
性质三:一个矢量和本身进行点积的结果,是该矢量的模的平方。
公式二
6)矢量的叉积
叉积不满足交换律,也不满足结合律。
矩阵
1)基础概念
矩阵是由m*n个标量组成的长方形数组。
矩阵由行列之分。如下是一个3*4矩阵。
M(i,j) 表明了这个元素在矩阵M的第i行,第j列。
矢量可以看成n*1的列矩阵或1*n的行矩阵
2)基础运算
矩阵与标量的乘法。
矩阵与矩阵的乘法,它们的结果会是一个新的矩阵,并且这个矩阵的维度和两个原矩阵的维度都有关系。
一个 r*n 的矩阵A和一个n*c 的矩阵B相乘,它们的结果AB将会是一个 r*c 大小的矩阵。
第一个矩阵的 列数必须和第二个矩阵的行数相同,它们相乘得到的矩阵行数是第一个矩阵的行数,而列数是第二个矩阵的列数。
如果不满足规定,就不能相乘。
性质一:矩阵乘法并不满足交换律。
性质二:矩阵乘法满足结合律
3)特殊矩阵。
方块矩阵(方阵),是指行和列数目相等的矩阵。
如果一个方阵除了对角元素之外的所有元素都为0,那么这个矩阵就叫做对角矩阵。如下图所示:
单位矩阵:对角矩阵中对角元素值都为1。任何矩阵和它相乘结果还是原来的矩阵。
转置矩阵:实际上是对原矩阵的一种运算,即转置运算。转置矩阵的计算非常简单,只需要将原矩阵翻转一下即可。原矩阵的第i行变成了第i列。而第j列变成了第j行。
如下所示。
性质一:矩阵转置的转置等于原矩阵。
性质二:矩阵串联的转置,等于反向串接各个矩阵的转置。
逆矩阵
并不是所有的矩阵都有逆矩阵,第一个前提改矩阵是一个方阵。
逆矩阵的性质:该矩阵和逆矩阵相乘,得到一个单位矩阵。即
如果一个矩阵由对应的逆矩阵,我们就说这个矩阵是可逆的,或者说是非奇异的。
如何判断一个矩阵是否可逆呢?简单来说,如果一个矩阵的行列式不为0,那么它就是可逆的。
性质一:逆矩阵的逆矩阵是原矩阵本身。即
性质二:单位矩阵的逆矩阵是它本身。即
性质三:转置矩阵的逆矩阵是逆矩阵的转置,即
性质四:矩阵串接相乘后的逆矩阵等于反向串接各个矩阵的逆矩阵,即
如果一个方阵和它的转置矩阵的乘积是单位矩阵的话,我们就说这个矩阵是正交的。反过来也成立。
在Unity中,常规做法是把矢量放在矩阵的右侧,即把矢量转换为列矩阵来进行运算。
变换
变换指的是我们把一些数据,如点,方向矢量甚至是颜色等,通过某种方式进行转换的过程。
最常见的是线性变换。线性变换指的是那些可以保留矢量加和标量乘的变换。如下:
类似缩放和旋转都是一个线性变换。还有错切,镜像,正交投影等,都是线性变换。
平移变换满足标量相乘,但是不满足矢量加法。
仿射变换是合并线性变换和平移变换的变换类型。仿射变换可以使用一个4*4的矩阵来表示,这就是齐次坐标空间。
下表给出了图形学常见变换矩阵的名称和它们的特性。
我们知道,由于3*3矩阵不能表示平移操作,我们就把其扩展到了4*4的矩阵。为此,我们还需要把原来的三维矢量转换成四维矢量,也就是齐次坐标。
对于一个点,转换为齐次坐标就是把其w分量设为1.对于方向矢量来说,需要把其w分量设为0.这样的设置就会导致,当用一个4*4矩阵对一点进行变换时,平移、旋转、缩放都会施加于该点。但是如果是用于变换一个方向矢量,平移的效果就会被忽略。
我们已经知道,可以使用一个4*4的矩阵来表示平移、旋转和缩放。我们把表示纯平移、纯旋转、纯缩放的变换矩阵叫做基础变换矩阵、这些矩阵具有一些共同点,我们可以把一个基础变换矩阵分解成4个组成部分:
其中左上角的矩阵M(3*3)用于表示旋转和缩放,右上角的t(3*1)表示平移,左下角的 0(1*3) 是零矩阵,右下角的元素是标量1。
我们可以使用矩阵乘法来表示对一个点进行平移变换:
从结果来看我们可以很容易看出为什么这个矩阵有平移效果,点的x,y,z分量分别增加了一个位置平移。
有趣的是,如果我们队一个方向矢量进行平移变换,结果如下:
可以发现,平移变换不会对方向矢量产生任何影响。
平移矩阵的逆矩阵就是反向平移得到的矩阵,即
可以看出,平移矩阵并不是一个正交矩阵。
我们可以对一个模型沿空间的x轴、y轴和z轴进行缩放。同样,我们可以使用矩阵乘法来表示一个缩放变换。
对方向矢量可以使用同样的矩阵进行缩放。
如果三个缩放系数相等,我们把这样的缩放称为统一缩放,否则为非统一缩放。
缩放矩阵的逆矩阵是使用原缩放系数的倒数来对点或方向矢量进行缩放。即
缩放矩阵一般不是正交矩阵。
旋转是三种常见的变换矩阵中最复杂的一种。
如果我们需要把点绕着x轴旋转θ度,可以使用下面的矩阵:
y轴的可以使用如下矩阵:
z轴的:
旋转矩阵的逆矩阵是旋转相反角度得到的交换矩阵。旋转矩阵是正交矩阵,而且多个旋转矩阵之间的串联同样是正交的。
我们可以把平移、旋转和缩放组合起来,来形成一个复杂的变换过程。例如,可以对一个模型先进行大小为(2,2,2)的缩放,再绕y轴旋转30度,最后向z轴平移4个单位,复合变换可以通过矩阵的串联来实现。上面的变换可以使用下面的公式进行计算。
在绝大多数情况下,我们约定的变换的顺序就是先缩放,再旋转,最后平移。
坐标空间
我们知道,要想定义一个坐标空间,必须指明其原点位置和3个坐标轴的方向。而这些数值实际上是相对于另一个坐标空间的。也就是说,坐标空间会形成一个层次结构——每个坐标空间都是另一个坐标空间的子空间,反过来说,每个空间都有一个父坐标空间。对坐标空间的变换实际上就是在父空间和子空间之间对点和矢量进行变换。
假设。现有父坐标空间P以及一个子坐标空间C。我们知道在父坐标空间中子坐标空间的原点位置以及3个单位坐标轴。我们一般会有两种需求:一种需求是把子坐标空间下表示的点或矢量转换到父坐标空间下。另一个修是反过来,即把福坐标空间下表示的点或矢量转换到子坐标空间下。我们可以使用下面的公式来表示这两种需求。
其中,表示的是从子坐标空间变换到父坐标空间的变换矩阵,而是其逆矩阵。式子如下:
变换为矩阵得到:
其中“|”符号表示是按列展开的。上面的式子实际上就是使用了我们之前所学的公式。但这个最后的表达式还不够漂亮,因为还存在加法表达式,即平移变换,我们把上面的式子扩展到齐次坐标空间中,得
所以的矩阵就是
一旦求出来,就可以通过求逆矩阵的方式求出来,因为从坐标空间C变换到坐标空间P 与 从坐标空间P变换到坐标空间C是互逆的两个过程。
可以看出来,变换矩阵实际上可以通过坐标空间C在坐标空间P的原点和坐标轴的矢量表示来构建出来:把3ge坐标轴一次放入矩阵的前3列,把原点矢量放到最后一列,再用0和1填充最后一行即可。
我们可以利用反向思维,从这个变换矩阵反推来获取子坐标空间的元点和坐标轴方向!例如,我们已知从模型空间到世界空间的一个4*4的变换矩阵,可以提取它的第一列再进行归一化后来得到模型空间的x轴在世界空间下的单位矢量表示。同样的方法可以提取y轴和z轴。
另一个有趣的是,对方向矢量的坐标空间变换。我们知道,矢量是没有位置的,因此坐标空间的原点变换是可以忽略的。也就是说,我们仅仅平移坐标系的原点是不会对矢量造成任何影响的。
在Shader中,我们常常会看到截取变换矩阵的前3行前3列来对法线方向、光照方向来进行空间变换,这正是原因所在。
前面说到,可以通过求的逆矩阵的方式求解出来反向变换。但有一种情况我们不需求求解逆矩阵就可以得到,这种情况就是是一个正交矩阵。如果它是一个正交矩阵的话,的逆矩阵就是等于它的转置矩阵。这意味着我们不需要进行复杂的求逆操作就可以得到反向变换。也就是说:
而现在,我们不仅可以根据变换矩阵反推出子坐标空间的坐标轴方向在父坐标空间中的表示,还可以反推出父坐标空间的坐标轴方向在子坐标空间的表示,这些坐标轴对应的就是的每一行!也就是说,如果我们只打坐标空间变换矩阵是一个正交矩阵,那么我们可以提取它的第一列来得到坐标空间A的x轴在坐标空间B下的表示,还可以提取它的第一行来得到坐标空间B的x轴在坐标空间A下的表示。反过来,如果我们知道坐标空间B的x轴、y轴和z轴在坐标空间A下的表示,就可以把它们依次放在矩阵的每一行就可以得到从A到B的变换矩阵了。
模型空间,如它的名字一样,是和某个模型或者说是对象有关的。有时候模型空间也被称为对象空间或局部空间。每个模型都有自己独立的坐标空间,当它移动或旋转的时候,模型空间也会跟着它移动和旋转。把我们自己家当成游戏中的模型的话,当我们在办公室里移动时,我们的模型空间也在跟着移动,当我们转身时,我们本身的前后左右方向也在跟着改变。
模型空间的原点和坐标轴通常是由美术人员在建模软件里确定好的。当导入到Unity中后,我们可以在顶点着色器中访问到模型的顶点信息,其中包含了每个顶点的坐标。这些坐标都是相对于模型空间中的原点定义的。
世界空间是一个特殊的坐标系,因为它建立了我们所关心的最大的空间。时间空间可以被用于描述绝对位置。
在Unity中,世界空间同样使用了左手坐标系。但它的x轴,y轴,z轴是固定不变的。在Unity中,我们可以通过调整Transform组件中的Position属性来改变模型的位置,这里的位置值是相对于这个Transform的父节点的模型坐标空间中的原点定义的。如果一个Transform没有任何父节点,那么这个位置就是在世界坐标系中的位置。
顶点变换的第一步,就是将顶点坐标从模型空间变换到世界空间中。这个变换通常叫做模型变换。
我们可以对妞妞的鼻子进行模型变换。如下图
根据Transform 组件上的信息,我们知道在世界空间中,妞妞进行了(2,2,2)的缩放,又进行了(0,150,0)的旋转,以及(5,0,25)的平移。注意这里的变换顺序是不能互换的,即先进行缩放,再进行旋转,最后是平移。据此我们可以构建出模型变换的变换矩阵:
现在我们可以用它来对妞妞的鼻子进行模型变换了:
也就是说,在世界空间下,妞妞鼻子的位置是(9,4,18.072).注意,这个的浮点数都是近似值。实际数值和Unity采用的浮点值精度有关。
观察空间也被称为摄像机空间。观察空间可以认为是模型空间的一个特例。在所有的模型中有一个非常特殊的模型,就是摄像机。这个模型空间就是观察空间。
摄像机决定了我们渲染游戏所使用的视角。在观察空间中,摄像机位于原点,同样,其坐标轴的选择可以是任意的,但本文以Unity为主,而Unity中观察空间的坐标轴选择是:+x轴指向右方,+y轴指向上方,而+z轴指向的是摄像机的后方。Unity在模型空间和世界空间中选用的都是左手坐标系,而在观察空间中使用的是右手坐标系。
这种左右手坐标系之间的改变很少会对我们再Unity中的编程产生影响,因为Unity为我们做了很多渲染的底层工作,包括很多坐标空间的转换。但是,如果我们需要调用类似Camera.cameraToWorldMatrix、Camera.worldToCameraMatrix等接口自行计算某模型在观察空间中的位置,就哟啊小心这样的差异。
观察空间和屏幕空间是不同的,观察空间是三维的,而屏幕空间是二维的。
顶点变换的第二步,就是将顶点坐标从世界空间变换到观察空间中,这个变换通常叫做观察变换。
现在我们需要把妞妞的鼻子从世界空间变换到观察空间中。为此,我们需要知道世界坐标系下摄像机的变换信息。这同样可以通过摄像机面板的Transform 组件得到。如下图。
为了得到定在在观察空间中的位置,有两种方法。一种是计算观察空间的3个坐标轴在世界空间下的表示,构建出从观察空间变换到世界空间的变换矩阵,再对该矩阵求逆来得到从世界空间变换到观察空间的变换矩阵。我们还可以使用另一种方法,即想象平移整个观察空间,让摄像机原点位于世界坐标的原点,坐标轴与世界空间中的坐标轴重合即可。这两种方法的变换矩阵都是一样的。
这里我们使用第二种方法:由Transform 组件可以知道,摄像机在世界空间中的变换是先按(30,0,0)进行旋转,然后按(0,10,-10)进行了平移。那么,为了把摄像机重新移回到初始状态,我们需要进行逆向变换,即先按(0,-10,10)平移,以便摄像机回到原点,再按(-30,0,0)进行旋转,以便让坐标轴重合。因此,变换矩阵就是:
但是,由于观察空间使用的是右手坐标系,因此需要对z分量进行取反操作。我们可以通过乘以另一个特殊的矩阵来得到最终的观察变换矩阵:
现在我们可以用它来对妞妞的鼻子进行顶点变换了:
这样我们就得到了观察空间中妞妞鼻子的位置——(9,8.84,-27.31)。
顶点接下来要从观察空间转换到裁剪空间(也称为齐次裁剪空间)中,这个用于变换的矩阵叫做裁剪矩阵,也被称为投影矩阵。
裁剪空间的目标是能够方便地对渲染图元进行裁剪:完全位于这块空间内部的图元将会被保留,完全位于这个空间外部的图元将会被剔除,而与这块空间边界相交的图元就会被裁剪。这块空间由视锥体来决定。
视锥体指的是空间中的一块区域,这块区域决定了摄像机可以看到的空间。视锥体由留个平面包围而成,这些平面也被称为裁剪平面。视锥体有两种类型,这涉及两种头像类型:一种是正交投影,一种是透视投影。
下图显示了从同一个位置、同一角度渲染同一个场景的两种摄像机的渲染结果。(左图为透视投影,右图为正交投影)
从图中可以发现,在透视投影中,地板的平行线并不会保持平行,离摄像机越近网格越大,离摄像机越远网格越小。而在正交投影中,所有的网格大小都一样,而且平行线会一直保持平行。可以注意到,透视投影模拟了人眼看世界的方式,而正交投影则完全保留了物体的距离和角度。因此在追求真实感的3D游戏中我们往往会使用透视投影,而在一些2D游戏或渲染小地图等其他HUD元素时,我们会使用正交投影。
在视锥体的6块裁剪平面中,有两块裁剪平面比较特殊,它们分别称为近裁剪平面和远裁剪平面。它们决定了摄像机可以看到的深度范围。正交投影和透视投影的视锥体如下图所示。
由上图可以看出,透视投影的视锥体是一个金字塔形,侧面的4个裁剪平面将会在摄像机处相交。它更符合视锥体这个词语。正交投影的视锥体是一个长方体。前面讲到,我们希望根据视锥体围城的区域对图元进行裁剪,但是,如果直接使用视锥体定义的空间来进行裁剪,那么不同的视锥体就需要不同的处理过程,而且对于透视投影的视锥体来说,想要判断一个顶点是否处于一个金字塔内部是比较麻烦的,因此,我们相拥一种更加通用、方便、整洁的方式来进行裁剪的工作,这种方式就是通过一个投影矩阵把顶点转换到一个裁剪空间中。
投影矩阵有两个目的。
首先是为投影做准备。这是个迷惑点,虽然投影矩阵的名称包含了投影二字,但是它并没有记性真正的投影工作,而是在为投影做准备。真正的投影发生在后面的齐次除法过程中。而经过投影矩阵的变换后,顶点的w分量将会具有特殊的意义。
齐次是对x、y、z分量进行缩放。我们上面讲到直接使用视锥体的6个裁剪平面来进行裁剪会比较麻烦。而经过投影矩阵的缩放后,我们可以直接使用w分量作为一个范围值,如果x、y、z都在这个范围内,就说明该顶点位于裁剪空间内。
在裁剪空间之前,虽然我们使用了齐次坐标来表示点和矢量,但它们的第四个分量都是固定的:点的w分量为1,方向矢量的w分量是0。经过投影矩阵的变换后,我们就会赋予齐次坐标的第4个坐标更丰富的含义。下面,我们看下两种投影类型使用的投影矩阵具体是什么。
透视投影
视锥体的意义在于定义了场景中的一块三维空间。所有位于这块空间内的物体将会被渲染,否则就会被剔除或裁剪。我们已经知道,这块区域由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值将越大。
正交投影
首先,我们看下正交投影中的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]。这样的选择是由原因的,是为了为其次除法做准备。
判断一个变换后的顶点是否位于视锥体内使用的不等式和透视投影中的一样,这种通用性也是为什么要使用投影矩阵的原因之一。下图显示了经过上述投影矩阵后,正交投影的视锥体的变化。
同样,裁剪矩阵改变了空间的旋向性。可以注意到,经过正交投影变换后的顶点实际已经位于一个立方体内了。
现在,我们要计算妞妞鼻子在裁剪空间的位置。
我们使用了透视摄像机,摄像机参数和Game视图的横纵比如下图所示:
据此,我们可以知道透视投影的参数:FOV为60°,Near 为 5,Far 40,Aspect 为4/3 =1.333。
那么,对应的投影矩阵就是:
然后,我们用这个投影矩阵来把妞妞的鼻子从观察空间转换到裁剪空间中。如下:
我们就求出了妞妞鼻子在裁剪空间中的位置(11.691,15.311,23.692,27.31)。接来下,Unity会判断妞妞的鼻子是否需要裁剪,通过比较得到,妞妞的鼻子满足下面的不等式:
由此,我们判断出,妞妞的鼻子位于视锥体内,不需要被裁剪。
屏幕空间
经过投影矩阵的变换后,我们可以进行裁剪操作。当完成了所有的裁剪工作后,就需要进行真正的投影了,也就是说,我们需要把视锥体投影到屏幕空间中。经过这一步变换,我们会得到真正的像素位置,而不是虚拟的三维坐标。
屏幕空间是一个二维空间,因此,我们必须把顶点从裁剪空间投影到屏幕空间中,来生成对应的2D坐标。这个过程可以理解成两个步骤。
首先,我们需要进行标准齐次除法,也被称为透视除法。虽然这个步骤听起来很陌生,但是它实际上非常简单,就是用齐次坐标的w分量去除以x、y、z分量。在OpenGL中,我们把这一步得到的坐标叫做归一化的设备坐标。经过这一步,我们可以把坐标从齐次裁剪坐标空间转到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分量被用于深度缓冲。一个传统的方式是把的值直接存进深度缓冲中,但这并不是必须的。通常驱动生产商会根据硬件来选择最好的存储格式。此时clipw也并不会被抛弃,虽然它已经完成了它的主要工作——在齐次除法中作为分母来得到NDC,但它仍然会在后续的一些工作中起到重要的工作,例如进行透视校正插值。
在Unity中,从裁剪空间到屏幕空间的转换是由Unity帮我们完成的。我们的顶点着色器只需要把顶点转换到裁剪空间即可。
现在我们可以确定妞妞的鼻子在屏幕上的像素位置了,假设屏幕像素宽度为400,高度为300。十一选不我们需要进行齐次除法,把裁剪空间的坐标投影到NDC中。然后,再映射到屏幕空间中。这个过程如下:
由此,我们就知道了妞妞鼻子在屏幕上的位置——(285.617,234.096).
法线变换
法线,也被称为法矢量。在游戏中,模型的一个顶点往往会携带额外的信息,而顶点法线就是其中的一种信息。当我们变换一个模型的时候,不仅需要变换它的顶点,还需要变换顶点法线,以便在后续处理中计算光照。
一般来说,点和绝大部分方向矢量都可以使用同一个4*4或3*3的变换矩阵把其从坐标空间A变换到坐标空间B中。但在变换发现的 时候,如果使用同一个变换矩阵,可能就无法确保维持法线的垂直线。
切线,也被称为切矢量与法线类似,切线往往也是模型顶点携带的一种信息,它通常与纹理空间对其,而且与法线方向垂直。如下图:
由于切线是两个顶点之间的差值计算得到的,因此我们可以直接使用用于变换顶点的变换矩阵来变换切线。假设,我们使用3*3的变换矩阵来变换顶点,可以由下面的式子直接得到变换后的切线。
其中T(a)和T(b)分别表示在坐标看空间A下和坐标空间B下的切线方向。但如果直接使用来变换法线,得到的新的法线方向可能就不会与表面垂直了。下图给出了一个例子:
我们知道同一个顶点的切线T(a)和法线N(a)必须满足垂直条件,T(a)·N(a) = 0.给定变换矩阵,我们已经知道了。现在我们想要找到一个矩阵G来变换法线N(a),使得变换后的法线仍然与切线垂直。即
对上式进行一些推导可以得到:
由于,因此如果,那么上式即可成立。也就是说,如果,即使用原变换矩阵的逆转置矩阵来变换法线就可以得到正确的结果。
Unity Shader 的内置变量
使用Unity 写 Shader 的一个好处在于,它提供了很多内置的参数,这使得我们不再需要自己手动计算一些值。本节将给出Unity内置的用于空间变换和摄像机以及屏幕参数的内置变量。这些内置变量可以在UnityShaderVariables.chnic文件中找到定义和说明。
下表给出了Unity5.2 版本提供的所有内置变换矩阵,下面所有的矩阵都是float4×4类型的。
其中有一个矩阵比较特殊,即UNITY_MATRIX_T_MV矩阵。
下表给出了Unity5.2版本提供的摄像机和屏幕参数信息
对于线性变换来说(例如旋转和缩放),仅适用3×3的矩阵就足够表示所有的变换了。但如果存在平移变换,我们就需要使用4×4的矩阵,因此,在对顶点的变换中个,我们通常使用4×4的变换矩阵。当然,在变换前我们需要把点坐标转换成齐次坐标的表示会,即把顶点的w分量设为1。而在对方向矢量的变换中,我们通常使用3×3的矩阵就足够了,这是因为平移变换对方向矢量是没有影响的。
我们通常在Unity Shader中使用CG作为着色器编程语言。在CG中变量类型有很多种。
在CG中,矩阵类型是由float3×3、float4×4等关键词进行声明和定义的。而对于float3、float4等类型的变量,我们即可以把它当成一个矢量,也可以把它当成是一个1×n的行矩阵或者一个n×1的列矩阵。这取决于运算的 种类和它们在运算中的位置。例如,当我们进行点积操作时,两个操作数就被当成矢量类型,如下:
[plain] view plain copy
- float4 a = float4(1.0,2.0,3.0,4.0);
- float4 b = float4(1.0,2.0,3.0,4.0);
- //对两个矢量进行点积操作
- float result = dot(a, b);
但在进行矩阵相乘时,参数的位置将决定是按列矩阵还是行矩阵进行乘法。在CG中,矩阵乘法是通过mul函数实现的。例如:
[plain] view plain copy
- float4 v = float4(1.0, 2.0, 3.0, 4.0);
- float4×4 M = float4×4(1.0, 0.0, 0.0, 0.0,
- 0.0, 1.0, 0.0, 0.0,
- 0.0, 0.0, 1.0, 0.0,
- 0.0, 0.0, 0.0, 1.0);
- //把v当成列矩阵和矩阵M进行右乘
- float4 column_mul_result = mul(M, v);
- //把v当成行矩阵和矩阵M进行左乘
- float4 row_mul_result = mul(v, M);
因此,参数的位置会直接影响结果值。通常在变换顶点时,我们都是使用右乘的方式来按列矩阵进行乘法。这是因为,Unity提供的内置矩阵(如UNITY_MATRIX_MVP等)都是按列存储的。但有时,我们也会使用左乘的方式,这是因为可以省去对矩阵的转置的操作。
需要注意的一点是,CG对矩阵类型中元素的初始化和访问顺序。在CG中,对float4×4等类型的变量是按行优先进行填充的。假设我们使用数字(1,2,3,4,5,6,7,8,9)去填充一个3×3的矩阵,如果是按照行优先的方式,得到的矩阵是:
如果是按列优先的话,得到的矩阵是:
CG使用的是行优先的方法,即使一行一行地填充矩阵的
类似地,当我们再CG中访问一个矩阵中的元素时,也是按行来索引的。例如:
[plain] view plain copy
- //按行优先的方式初始化矩阵M
- float3×3 M = float3×3(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0);
- //得到M的第一行,即(1.0, 2.0, 3.0)
- float3 row = M[0];
- //得到M的第2行第一列的元素,即4.0
- float ele = M[1][0]
之所以Unity Shader中的矩阵类型满足上述规则,是因为使用的是CG语言,换句话说,上面的特性都是CG的规定。
在顶点/片元着色器中,有两种方式来获得片元的屏幕坐标。
一种是在片元着色器的输入中声明VPOS或WPOS语义。VPOS是HLSL中对屏幕坐标的语义,而WPOS是CG中对屏幕坐标的语义,两者在Unity Shader都是等价的。我们可以在HLSL/CG中通过语义的方式来定义顶点/片元着色器的默认输入,而不需要自己定义输入输出的数据结构。使用这种写法,可以在片元着色器中这样写:
[plain] view plain copy
- fixed4 frag(float4 sp : VPOS) : SV_TARGET
- {
- //用屏幕坐标除以屏幕分辨率_ScreenParams.xy,得到视口空间中的坐标
- return fixed4(sp.xy/_ScreenParams.xy, 0.0, 1.0);
- }
得到的效果如下图所示:
VPOS/WPOS语义定义的输入是一个float4类型的变量。我们已经知道它的xy值代表了在屏幕空间中的像素坐标。如果屏幕的分辨率为400×300,那么x的范围就是[0.5,400.5],y的范围就是[0.5,300.5]。注意,这里的像素坐标并不是整数值,这是因为openg 和DirectX 10 以后的版本认为像素中心对应的是浮点值中的0.5。在Unity中,VPOS/WPOS的z分量范围是[0,1],在摄像机的近裁剪平面处,z值为0,在远裁剪平面处,z值为1.对于w分量,我们需要考虑摄像机的投影类型。如果是透视投影, 那么w分量的范围是
,Near和Far对应了Camera组件中设置的近裁剪平面和远裁剪平面矩阵摄像机的远近;如果使用的是正交投影,那么w分量的值恒为1.这些值是通过对经过投影矩阵变换后的w分量取倒数后得到的。在代码的最后,我们把屏幕空间除以屏幕分辨率来得到的视口空间中的坐标。视口坐标很简单,就是把屏幕坐标归一化,这样屏幕左下角就是(0,0),右上角就是(1,1)。如果已知屏幕坐标的话,我们只需要把xy值除以屏幕分辨率即可。
另一种方式是通过Unity提供的ComputeScreenPos函数。这个函数在UnityCGcginc里被定义。通常的用法需要两个步骤,首先在顶点着色器中将ComputeScreenPos的结果保存在输出结构体中,然后在片元着色器中进行一个齐次除法运算后得到视口空间下的坐标。例如:
[plain] view plain copy
- struct vertOut
- {
- float4 pos : SV_POSITION;
- float4 scrPos : TEXCOORD0;
- }
- vertOut vert(appdata_base v)
- {
- vertOut o;
- o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
- //第一步:把ComputeScreenPos的结果保存到scrPos中
- o.scrPos = ComputeScreenPos(o.pos)
- return 0;
- }
- fixed4 frag(vertOut i) : SV_Target
- {
- //第二步,用scrPos.xy除以scrPos.w得到视口空间中的坐标
- float2 wcoord = (i.scrPos.xy / i.scrPos.w);
- return fixed4(wcoord, 0.0, 1.0);
- }
上面代码的实现效果和上面的代码一样。这种方法实际上是手动实现了屏幕映射的过程,而且它得到的坐标直接就是视口空间中的坐标。我们已经知道了如何将裁剪坐标空间中的点映射到屏幕坐标中。据此,我们可以得到视口空间中的坐标,公式如下:
上面公式的思想就是,首先对裁剪空间下的坐标进行齐次除法,得到阀内在[-1,1]的NDC,然后再将其映射到范围在[0,1]的视口空间下的坐标。