学习Shader 所需的数学基础

计算机图形学之所以深奥难懂,很大原因是在于它是建立在虚拟世界上的数学模型。数学参透到图形学的方方面面,当然也包括 Shader。在学习 Shader 的过程中,我们最常使用的就是适量和矩阵(即数学的分支之一-----线性代数)。

很多读者认为图形学中的数学复杂难懂。的确,一些数学模型在初学者看来晦涩难懂。但很多情况下,我们需要打交道的只是一些基础的数学运算,而只要掌握了这些内容,就会发现很多事情可以迎刃而解。我们在研究和学习他人编写的 Shader 代码时,也不再会疑问:“他为什么要这么写”,e而是“哦,这里就是使用矩阵进行了一个变换而已。”

 现在,让我们一起走进数学的世界吧!

这篇有点长,需要耐心阅读,一起学习。

1、笛卡尔坐标系

在游戏制作中,我们使用数学绝大部分都是为了就算位置、距离和角度等变量。而这些计算大部分都是在笛卡尔坐标系(Cartesian Coordinate System)下进行的。这个名字来源于法国伟大的哲学家、物理学家、心理学家、数学家笛卡儿。

1.1、二维笛卡儿坐标系

下面显示了一个二维笛卡尔坐标系。它是不是很像一个棋盘呢?

一个二维的笛卡儿坐标系包含了两个部分的信息:

  • 一个特殊的位置,即原点,它是整个坐标系的中心。
  • 两条过原点的互相垂直的矢量,即 x 轴和 y 轴。这些坐标轴也被称为是该坐标系的基矢量。

1.2、三维笛卡儿坐标系

在三维笛卡儿坐标系中,我们需要定义3个坐标轴和一个原点。图4.6显示了一个三维笛卡儿坐标系。

这三个坐标系也被称为是该坐标系的基矢量(basis vector)。通常情况下,这三个坐标轴之间是湘湖垂直的,且长度为1,这样的基矢量被称为标准正交基(orthonornal basis),但这并不是必须的,在一些坐标系中坐标轴之间垂直但长度不为1,这样的基矢量被称为正交基(orthogonal basis)。

正交:可以理解成互相垂直的意思。

和二维笛卡儿坐标系类似,三维笛卡儿坐标系中的坐标轴方向也不是固定的,即不一定是像图 4.6 中那样的指向。但这种不同导致了两种不同种类的坐标系:左手坐标系(left-handedcoordinate space)和右手坐标系(right-handed coordinate space)。

 

 1.3、Unity 使用的坐标系

对于一个需要可视化虚拟的三维世界的应用(如 Uniity)来说,它的设计者就是进行一个选择。对于模型空间和世界空间,Unity 使用的是左手坐标系。这可以从 Scene 视图的坐标轴显示看出来,在模型空间中 ,一个物体的右侧(right)、上侧(up)和前侧(forward)分别对应了 x 轴、y 轴和 z 轴的正方向。

但对于观察空间来说,Unity 使用的是右手坐标系。观察空间,通俗来讲就是以摄像机为原点的坐标系 。在这个坐标系中,摄像机的前向是 z 轴的负方向,这与在模型空间和世界空间中的定义相反。也就是说,z 轴坐标的减少以为着场景深度的增加,如图 4.13 所示。

2、点和矢量

点(point)是 n 维空间(游戏中主要使用二维和三维空间)中的一个位置,它没有大小、宽度这类概念。在笛卡儿坐标系中,我们可以使用 2 个或 3 个实数来表示一个点的坐标,如 P=(Px,Py) 表示二维空间的点,P=(Px,Py,Pz)表示三维空间中的点。

  矢量(vector,也被称为向量)的定义则复杂一些。在数学家看来,矢量就是一串数字。你可能要问了,点的表达式不也是遗传数字吗?没错,但矢量存在的意义更多是为了和标量(scalar)区分开来。通常来讲,矢量是指 n 维空间中一种包含了模(magnitude)和方向(direction)的有向线段,我们通常讲到的速度(velocity)就是一种典型的矢量。例如,这辆车的速度是向南 80km/h(向南志明了矢量的方向,80km/h指明了矢量的模)。二标量只有模没有方向,生活中常常说到的距离(distance)就是一种标量。例如,我家离学校只有 200m(200m 就是一个标量)。

具体来讲。

  • 矢量的模指的是这个矢量的长度。一个矢量的长度可以是任意的非负数。
  • 矢量的方向则描述了这个矢量在空间中的指向。

矢量的表示方法和点类似。我们可以使用 v=(x,y)来表示二维矢量,用 v=(x,y,z)来表示三维矢量,用 v=(x,y,z,w)来表示四维矢量。

为了方便阐述,我们对不同类型的变量在书写和印刷上使用不同的样式。

  • 对于标量,我们使用小写字母来表示,如 a,b,x,y,z 等。
  • 对于矢量,我们使用小写的粗体字母来表示,如 a,b,u,v 等。
  • 对于后面要学习的矩阵,我们使用大写的粗体字母来表示,如 A,B,S,M,R 等。

矢量通常由一个箭头来表示。我们有时会讲到一个矢量的头(head)和尾(tail)。矢量的头指的是它的箭头所在的端点处,而尾指的是另一个端点处。

2.1、点和矢量的区别

点是一个没有大小之分的空间中的位置,而矢量是一个有模和方向但没有位置的量。从这里看,点和矢量具有不同的意义。但是,从表示方式上两者非常相似。

矢量通常用于描述偏移量,因此,它们可以用于描述相对位置,即相对于另一个点的位置,此时矢量的尾是一个位置,那么矢量的头就可以表示另一个位置了。而一个点可以用于指定空间中的位置(即相对于原点的位置)。如果我们把矢量的尾固定在坐标系原点,那么这个矢量的表示就和点的表示重合了。图 4.16 表示了两者之间的关系。

2.2、矢量运算

2.2.1矢量和标量的乘法/除法

 注意,对于乘法来说,矢量和标量的位置可以互换。但对于除法,只能是矢量被标量除,而不能是标量被矢量除,这是没有意义的。

从几何意义上看,把一个矢量 v 和一个标量 k 相乘,意味着对矢量 v 进行一个大小为 |k| 的缩放。例如,如果想要把一个矢量放大两倍,就可以乘以2。当 k<0 时,矢量的方向也会取反。

2.2.2矢量的加法和减法

我们可以对两个矢量进行相加或相减,其结果是体格相同维度的新矢量。

我们只需要把两个矢量的对应分量进行相加或相减即可。公式如下:

 需要注意的是,一个矢量不可以和一个标量相加或相减,或者是和不同维度的矢量进行运算。

从几何意义上来看,对于加法,我们可以把矢量a 的头连接到矢量 b 的尾,然后话一条从 a 的尾到 b 的头的矢量,来得到 a 和 b 相加后的矢量。也就是说,如果我们从一个起点开始进行了一个位置偏移 a,然后又进行了一个位置偏移 b,那么久等同于进行了一个 a+b 的位置偏移。这被称为矢量加法的三角形定则(triangle rule)。矢量的减法是类似的。

 

 读者需要时刻谨记,在图形学中矢量通常用于描述位置偏移(简称位移)。因此,我们可以利用矢量的加法和减法来计算一点相对于另一点的位移。

假设,空间内有两点 a 和 b。还记得吗,我们可以用矢量 a 和 b 来表示它们相对于原点的位移。如果我们想要计算点 b 相对于点 a 的位移 ,就可以通过把 b 和 a 相减得到,如图 4.19 所示。

2.2.3矢量的模

正如我们之前讲到的一样,矢量是有模和方向的。矢量的模是一个标量,可以理解为是矢量在空间中的长度。它的表示符号通常是在矢量两旁分别加上一条垂直线(有的文献中会使用两条垂直线)。三维矢量的模的计算公式如下:

       

 我们可以从几何意义来理解上述公示。对于二维矢量来说,我们可以对任意矢量构建一个三角形,如图 4.20 所示。

从图 4.20 可以看出,对于二维矢量,其实就是使用了勾股定理,矢量的两个分量的绝对值对应了三角形两个直角边的长度,而斜边的长度就是矢量的模。

 2.2.4单位矢量

在很多情况下,我们只关心矢量的方向而不是模。例如,在计算光照模型时,我们往往需要顶点的法线方向和光源方向,此时我们不关心这些矢量有多长。在这些情况下,我们就需要单位矢量(unit vector)。

单位矢量指的是那些模为1的矢量。单位矢量也被称为被归一化的矢量(noramlized vector)。对任何给定的非零矢量,把它转换成单位矢量的过程就被称为归一化(normalization)

零矢量(即矢量的每个分量值都为0,如 v=(0,0,0))是不可以被归一化的。这是因为做触发运算时分母不能为0。

2.2.5矢量的点积

矢量之间也可以进行乘法,但是和 标量之间的乘法有很大不同。矢量的乘法有两种在常用的种类:点积(dot product,也别称为内积,inner product)叉积(cross product,也被称为外积,outer product)

 矢量的点击满足交换律,即 a·b = b·a

点击的几何意义很重要,因为点击几乎应用到了图形学的各个方面。其中一个几何意义就是投影(projection)

点积具有一些很重要的性质,在 Shader 的计算中,我们会经常利用这些性质来帮助计算。

性质一:点击可结合标量乘法。
上面的 “结合”是说,点击的操作数之一可以是另一个运算的结果,即矢量和标量的结果。公式如下: (ka)·b = (kb)=k(a·b)

也就是说,对点积中其中一个矢量进行缩放的结果,相当于对最后的点积结果进行缩放。

性质二:点积可结合矢量加法和减法,和性质一类似。

这里的“结合”指得是,点积的操作数可以是矢量相加或相减后的结果。用公式表达就是:
a·(b+c) = b+a·c

把上面的 c 换成 -c 就可以得到减法的版本。

性质三:一个矢量和本身进行点积的结果,是该矢量的模的平方。

这点可以很容易从公示验证得到:

这意味着,我们可以直接利用点积来求矢量的模,而不需要使用模的计算公式。当然,我们需要对点积结果进行开平方的操作来得到真正的模。但很多情况下,我们只是想要比较两个矢量的长度大小,因此可以直接使用点积的结果。毕竟,开平方的运算需要消耗一定性能。

现在是时候来看点积的另一种表示方法了。这种方法是从三角代数的角度出发的,这种表示方法更具有几何意义,因为它可以明确地强调出两个矢量之间的角度。

我们先直接给出第二个公式。

公式二:a·b = |a||b|cosθ

 2.2.6矢量的叉积

另一个重要的矢量运算就是叉积(cross product),也被称为外积(outer product)。与点积不同的是,矢量叉积的结果仍是一个矢量,而非标量 。

和点积雷士,叉积的名称来源于它的符号:axb。同样,这个叉号也是不可省略的。两个矢量的叉积可以用如下公式计算:

上面的公式看起来很复杂,但其实是有一定规律的。 

需要注意的是,叉积不满足交换律,即 axb ≠ bxa

实际上,叉积是满足反交换律的,即  axb = -(bxa)

而且叉积又不满足结合律,即 (axb)xc ≠ -ax(bxc)

从叉积的几何意义出发,我们可以更加深入地理解它地用处。对两个矢量进行叉积地结果会得到一个同时垂直于这两个矢量地新矢量。我们已经知道, 矢量是由一个模和一个方向来定义地,那么这个新的矢量的模和方向是什么呢?

我们先来看它的模。 axb 的长度等于 a 和 b 的模的乘积再乘以它们之间夹角的正弦值。公式如下:|axb| = |a||b|sinθ

可能已经发现,上述公式和点积的计算公式很类似,不同的是,这里使用的是正弦值。如果对于中学数学还有记忆的 话,可能还会发现,这和平行四边形的面积计算公式是一样的。

我们知道,平行四边形的面积可以使用 |b|h 来得到,即底乘以高。而 h 又可以使用 |a| 和夹角 θ 来得到,即 A = |b|h = |b|(|a|sinθ) = |a||b|sinθ = |axb|

如果 a 和 b 平行(可以是方向完全相同,也可以是完全相反)怎么办,不久不能够平行四边形了吗?我们可以认为构建出来的平行四边形面积为0,那么 axb =0。注意,这里得到的是零向量,而不是标量0。

那么,叉积到底有什么用呢?最常用的一个应用就是计算垂直于一个平面、三角形的矢量。另外,还可以用于计算三角面片的朝向。

2.3、矩阵

2.3.1矩阵的定义

相信很多读者都见过矩阵的真容,例如像下面这个样子:

 从它的外观上来看,就是一个长方形的网格,每个格子里放量了一个数字。的确,矩阵就是这么简单:它是由 mxn 个标量组成的长方形数组。在上面的式子中,我们是用方括号来为主矩阵中的数字,而一些其他的资料可能会使用圆括号或者花括号来表示,这都是等价的。

既然是网格结构,就意味着矩阵由行(row)列(column)例如上面的例子就是一个3x4的矩阵,它有三行四列。据此,我们可以给出矩阵的一般表达式。以 3x3 的矩阵为例,它可以写成:

2.3.2和矩阵联系起来

前面说到,矢量其实就是一个数组,而矩阵也是一个数组。既然都是数组,那就是一家人了!我们很容易想到 ,我们可以用矩阵来表示矢量。实际上,矢量可以看成是 nx1 的列矩阵(column matrix)或 1xn 的行矩阵(row matrix),其中 n 对应了矢量的维度。例如,矢量 v=(3,8,6) 可以写成行矩阵 [3  8  6]
或列矩阵

为什么我们要把矢量和矩阵联系在一起呢?这是为了可以让矢量像矩阵一样一起参与矩阵运算。这在空间变换中将非常有用。

到现在 ,使用行矩阵还是列矩阵来表示矢量看起来是没什么分别的。的确,我们可以根据自己的喜好来选择表示方法,但是如果要和矩阵一起参与乘法运算时,这种选择会影响我们的书写顺序和结果。这正是我们下面要讲到的。

 2.3.3矩阵运算

矩阵这个家伙看起来 比矢量要庞大很多,那么它的运算是不是很复杂的?答案是肯定的。但是,幸运的是在写 Shader 的过程中,我们只需要和很简单的一部分运算打交道。

2.3.3.1矩阵和标量的乘法 

和矢量类似,矩阵也可以和标量相乘 ,它的结果仍然是一个相同维度的矩阵。它们之间的乘法非常简单,就是矩阵的每个元素和该标量相乘。以 3x3 的矩阵为例,其公式如下:

 2.3.3.2矩阵和矩阵的乘法

两个矩阵的乘法也很简单,它们的结果会是一个新的矩阵,并且这个矩阵的维度和两个原矩阵的维度都有关系。

一个 r×n 的矩阵 A 和一个 n×c 的矩阵 B 相乘,它们的结果 AB 将是一个 r×c 大小的矩阵。注意它们的行列关系,第一个矩阵的列数必须和第二个矩阵的行数相同,它们相乘得到的矩阵的行数是第一个矩阵的行数,而列数是第二个矩阵的列数。例如,如果矩阵 A 的维度是 4x3, 矩阵 B 的维度是 3x6 ,那么 AB 的维度就是 4x6。

如果两个矩阵的行列不满足上面的规定怎么办?那么很抱歉,这两个矩阵就不能相乘,因为它们之间的乘法是没有被定义的(当然,读者完全可以自己定义一种新的乘法,但是数学家们会不会买账就不一定了)。那么为什么会有上面的规定呢?等我们理解了矩阵乘法的操作过程自然就会明白。

我们先给出看起来很复杂难懂(当给出直观的表达式后读者会发现其实它没那么难懂 )的数学表达式:

 

  2.3.4特殊的矩阵

有些特殊的矩阵类型在 Shader 中经常见到 。这些特殊的矩阵往往具有一些重要的性质。

2.3.4.1方块矩阵

方块矩阵(square matrix),简称方阵,是指那些行和列数目相等的矩阵。在三维渲染里,最常使用的就是 3x3 和 4x4 的方阵。

方阵之所以值得单独拿出来讲,是因为矩阵的一些运算和性质是只有方阵才具有的。例如对角元素(diagonal elements)。方阵的对角元素指的是行号和列号相等的元素,例如m11、m22、m33等等。如果把方阵看成一个正方形的话,这些元素排列在正方形的对角线上,这也是它们名字的由来。如果一个矩阵除了对角元素外的所有元素都为0,那么这个矩阵就叫做对角矩阵(diagonal matrix)。例如,下面就是一个 4x4 的对角矩阵:

2.3.4.2单位矩阵

一个特殊的对角矩阵是单位矩阵(identity matrix)。一个3x3 的单位矩阵如下:

为什么要为这种矩阵单独起一个名字呢 ?这是因为,任何矩阵和它相乘的结果还是原来的矩阵。也就是说:MI = IM =M

这就跟标量中的数字1 一样!

2.3.4.3转置矩阵

转置矩阵(transposed matrix)实际是对原矩阵的一种运算,即转置运算。给定一个 r×c 的矩阵 M,它的转置可以表示成  M^{T},这是一个 c×r 的矩阵。转置矩阵的计算非常简单,我们只需要把原矩阵翻转一下即可,也就是说,原矩阵的第 i 行变成了第 i 列,而第 j 列变成了第 j 行。数学公式是:
M_{ij}^{T} = M_{ji}^{}

例如,

 对于行矩阵和列矩阵来说,我们可以使用转置操作来转换行列矩阵:

转置矩阵也有一些常用的性质。

性质一:矩阵转置的转置 等于原矩阵。

很容易理解,我们把一个矩阵翻转一下后再翻转一下,等于没有对矩阵做任何操作。即

(M^{T})^{T} = M

性质二:矩阵串接的转置,等于反向串接各个矩阵的转置。 

用公式表示就是:

(AB)^{T} = B^{T}A^{T}

该性质同样可以扩展到更多矩阵相乘的情况。

2.3.4.4逆矩阵

逆矩阵(inverse matrix)大概是本章讲到关于矩阵最复杂的一种操作了。不是所有的矩阵都有逆矩阵,第一个前提就是,该矩阵必须是一个方阵。

给定一个方阵M,它的逆矩阵用 M^{-1} 来表示。逆矩阵最重要的性质就是,如果我们把 M^{} 和 M^{-1} 相乘,那么它们的结果将会是一个单位矩阵。也就是说,

MM^{-1} = M^{-1}M = I

前面说了,并非所有的方阵都有对应的逆矩阵。一个明显的例子就是一个所有元素都为 0 的矩阵,很显然,任何矩阵和它相乘都会得到一个零矩阵,即所有的元素仍然都是 0。如果一个矩阵有对应的逆矩阵,我们就说这个矩阵是可逆的(invertible) 或者说是非奇异的(nonsingular);相反的,如果一个矩阵没有对应的逆矩阵,我们就说它是不可逆的(noninvertible)或者说是奇异的(singular)

那么如何判断一个矩阵是否是可逆的呢?简单来说,如果一个矩阵的行列式(determinant)不为0,那么它就是可逆的。

逆矩阵有很多非常重要的性质。

性质一:逆矩阵的逆矩阵是原矩阵本身。

假设矩阵 M 是可逆的,那么 (M^{-1})^{-1} = M

性质二:单位矩阵的逆矩阵是它本身。

即 I^{-1} = I

性质三:转置矩阵的逆矩阵是逆矩阵的转置。

即 (M^{T})^{-1} = (M^{-1})^{T}

性质四:矩阵串接相乘后的逆矩阵等于反向串接各个矩阵的逆矩阵。

(AB)^{-1} = B^{-1} A^{-1}

这个性质也可以扩展到更多矩阵的连乘,如:(ABCD)^{-1} = D^{-1} C^{-1}B^{-1} A^{-1}

逆矩阵是具有几何意义的。我们知道一个矩阵可以表示一个变换 ,而逆矩阵允许我们还原这个变换,或者说是计算这个变换的方向变换。因此,如果我们使用变换矩阵 M 对矢量 v 进行了一次变换,然后再使用它的逆矩阵 M^{-1} 进行另一次变换,那么我们会得到原来的矢量。这个性质可以使用矩阵乘法的结合律很容易地进行证明:

M^{-1}(Mv) = (M^{-1}M)v =Iv = v

2.3.4.5正交矩阵

另一个特殊地方阵是正交矩阵(orthogonal matrix)。正交是矩阵地一种属性。如果一个方M 和它地转置矩阵地乘积是单位矩阵地话,我们就说这个矩阵是正交地(orthogonal)。反过来也是成立地。也就是说,矩阵M 是正交地等价于:

MM^{T} = M^{T} M = I

从上可以看出,上式和我们的逆矩阵遇到的公式很像。把这两个公式结合起来,我们就可以得到一个重要的性质,即如果一个矩阵式正交的,那么它的转置矩阵和逆矩阵是一样的。也就是说,矩阵M是正交的等价于:M^{T} = M^{-1}

这个式子非常好用,因为再三维变换中我们经常会需要使用逆矩阵来求解反向的变换。而逆矩阵的求解往往计算量很大,但转置矩阵却非常容易求解;我们只需要把矩阵翻转一下就可以了。那么,我们如何提前判断一个矩阵是否是正交矩阵呢?可能会说,判断MM^{T} = I 是否成立 就可以了 嘛!但是,求解这样一个表达式无疑是需要一定计算量的,这些计算量可能呵呵直接求解逆矩阵无异。而且,如果我们判断出这不是一个正交矩阵 ,那么这些花在验证是否是正交矩阵上的计算就浪费了。因此,我们更想不需要计算,而仅仅根据一个矩阵的够找过程来判断这个矩阵是否是正交矩阵。因此,我们需要来了解正交矩阵的几何意义。

 M^{T}M= \begin{bmatrix} - & c_{1} & -\\ - & c_{2} & - \\ - & c_{3} & - \end{bmatrix}\begin{bmatrix} | & | & |\\ c_{1} & c_{2} &c_{3} \\ | & | & | \end{bmatrix} =\begin{bmatrix} c_{1}\cdot c_{1} & c_{1}\cdot c_{2} & c_{1}\cdot c_{3}\\ c_{2}\cdot c_{1} & c_{2}\cdot c_{2} & c_{2}\cdot c_{3} \\ c_{3}\cdot c_{1} & c_{3}\cdot c_{2} & c_{3}\cdot c_{3} \end{bmatrix} = \begin{bmatrix} 1 & 0 & 0\\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{bmatrix} = I

 这样我们就有了9个等式:

\\c_{1}\cdot c_{1} =1 , c_{1}\cdot c_{2} =0 , c_{1}\cdot c_{3} =0\\ c_{2}\cdot c_{1} =0 , c_{2}\cdot c_{2} =1 , c_{2}\cdot c_{3} =0\\ c_{3}\cdot c_{1} =0 , c_{3}\cdot c_{2} =0 , c_{3}\cdot c_{3} =1

 我们可以得到一下结论:

  • 矩阵的每一行,即 c1、c2、和 c3 是单位矢量,因为只有这样它们与自己的点积才能是 1;
  • 矩阵的每一行,即 c1、c2、和 c3 之间互相垂直,因为只有这样它们之间的点积才能是 0。
  • 上诉两条结论对矩阵的每一列同样适用,因为如果M是正交矩阵的话,M^{T}也会是正交矩阵。

也就是说,如果一个矩阵满足上面的条件,那么他就是一个正交矩阵。

 2.3.5行距正还是列矩阵

在前面的内容中,可以把一个矢量转换成一个行矩阵或是列矩阵。它们本身是没有区别的,但是,当我们需要把它和另一个矩阵相乘时,就会出现一些差异。

假设有一个矢量 v=(x,y,z),我们可以把它转换成列矩阵 v=(x,y,z) 或列矩阵 v =(x,y,z)^{T} (这里使用了转置符号来避免列矩阵在我们的这一行中显得太高)。现在,有另一个矩阵M:

M =\begin{bmatrix} m_{11} &m_{12} &m_{13} \\ m_{21} & m_{22} &m_{23} \\ m_{31}&m_{32} &m_{33} \end{bmatrix}

那么M分别和行矩阵以及列矩阵相乘后会是什么结果呢?我们先来看看M和行矩阵的相乘。由矩阵乘法的定义可知,我们需要把行矩阵放在M的左边(还记得吗,矩阵乘法要求两个矩阵的行列数满足一定条件),即

        vM = [xm_{11}+ym_{21}+zm_{31} \ xm_{12}+ym_{22}+zm_{32} \ xm_{13}+ym_{23}+zm_{33}]

 而如果和列矩阵相乘的话,结果是:

        Mv =\begin{bmatrix} xm_{11} &ym_{12} &zm_{13} \\ xm_{21} & ym_{22} &zm_{23} \\ xm_{31}&ym_{32} &zm_{33} \end{bmatrix}

对此就会发现,结果矩阵除了行列矩阵的区别外,里面的元素也是不一样的。这就意味着,在和矩阵相乘时选择行矩阵还是列矩阵来表示矢量是非常重要的,因为这决定了矩阵乘法的书写次序和结果值。

在 Unity 中,常规做法是把矢量放在矩阵的右侧,即把矢量转换成列矩阵来进行运算。因此,在后面内容中,如无特殊情况,我们都将使用列矩阵。这意味着,我们的矩阵乘法通常都是右乘,例如:

CBAv = (C(B(Av)))

使用列向量的结果是 ,我们的阅读顺序是从右到左,即先对 v 使用 A 进行变换,再使用 B 进行变换,最后使用 C 进行变换。

上面的计算等价于下面的行矩阵运算:

vA^{T}B^{T}C^{T} = (((vA^{T})B^{T})C^{T})

2.4矩阵的几何意义:变换

2.4.1什么是变换

变换(transform),指的是我们把一些数据,如点、方向矢量甚至是颜色等,通过某种方式进行转换的过程。在计算机图形学领域,变换非常重要。尽管通过变换我们能够进行的操作是有限的,但这些操作已经足够奠定变换在图形学领域举足轻重的地位了。

我们先来看一个非常常见的变换类型----线性变换(linear transform)。线性变换指的是那些可以保留矢量加和标量乘的变换。用数学公式来表示着两个条件就是:

f(x) +f(y) = f(x+y)

kf(x) = f(kx)

上面的式子看起来很抽象。缩放(scale)就是一种线性变换。例如,f(x) =2x,可以表示一个大小为2的同意缩放,即经过变换后矢量 x 的模将被放大两倍。可以发现,f(x) =2x

是满足上面的两个条件的。同样,旋转(rotation)也是一种线性变换。对于线性变换来说,如果我们要对一个三维的矢量进行变换,那么仅仅使用 3x3 的矩阵就可以表示所有的线性变换。

线性变换除了包括旋转和缩放外,还包括错切(shear)、镜像(mirroring,也被称为 reflection)、正交投影(orthographic projection)等,但我们着重讲述旋转和缩放变换。

但是,仅有线性变换是不够的。我们来考虑平移变换,例如f(x) = x+(1,2,3)。这个变换就不是一个线性变换,它满足标量乘法,但不满足矢量加法。如果我们令x =(1,1,1),那么:

f(x)+f(x) = (4,6,8)

f(x+x) = (3,4,5)

可见,两个运算得到的结果是不一样的。因此,我们不能用一个 3x3 的矩阵来表示一个平移变换。这是我们不希望看到的,毕竟平移变换是非常常见的一种变换。

这样,就有了仿射变换(affine transform)。仿射变换就是合并线性变换和平移变换的变换类型。仿射变换可以使用一个 4x4 的矩阵来表示,为此,我们需要把矢量扩展到四维空间下,这就是齐次坐标空间(homogeneous sapce)。

表 4.1 给出了图形学中常见变换矩阵的名称和它们的特性。

 2.4.2齐次坐标

我们知道,由于 3x3 矩阵不能表示平移操作,我们就把其扩展到了 4x4 的矩阵(是的,只要多一个维度就可以实现对平移的表示)。为此,我们还需要把原来的三维矢量转换成四维矢量,也就是我们所说的齐次坐标(jomogeneous coordinate)(事实上齐次坐标的维度可以超过四维)。我们可以发现,齐次坐标并没有神秘的地方,它只是为了方便计算而使用的一种表示方式而已。

如上所说,齐次坐标是一个四维矢量。那么,我们如何把三维矢量转换成齐次坐标呢?对于一个点,从三维坐标转换成齐次坐标是把其 w 分量设为 1,而对于方向矢量来说,需要把其 w 分量设为 0。这样的设置会导致,当用一个 4x4 矩阵对一个点进行变换时,平移、旋转、缩放都会施加于该点。但是如果时用变换一个方向矢量 ,平移的效果就会被忽略。我们可以从下面的内容中理解这些差异的原因。

2.4.3分解基础变换矩阵

我们已经知道,可以使用一个 4x4 的矩阵来表示平移、旋转和缩放。我们把表示纯平移、纯旋转和纯缩放的变换矩阵叫做基础变换矩阵。这些矩阵具有一些共同点,我们可以把一个基础变换矩阵分解成 4 个组成部分:

\begin{bmatrix} M_{3x3} & t_{3x1} \\ O_{1X3}&1 \end{bmatrix}

其中,左上角的矩阵 M_{3x3} 用于表示旋转和缩放,t_{3x1}用于表示平移,O_{1x3}是零矩阵,即O_{1X3} = \begin{bmatrix} 0&0 & 0 \end{bmatrix},右下角的元素就是标量1。

2.4.4平移矩阵

我们可以使用矩阵乘法来表示对一个点进行平移变换:

\begin{bmatrix} 1 &0 & 0 & t_{x}\\ 0 & 1 & 0 & t_{y}\\ 0& 0 & 1& t_{z}\\ 0& 0 & 0&1 \end{bmatrix}\begin{bmatrix} x\\ y\\ z\\ 1 \end{bmatrix} = \begin{bmatrix} x+t_{x}\\ y+t_{y}\\ z+t_{z}\\ 1 \end{bmatrix}

从结果来看我们可以很容易看出为什么这个矩阵有平移的效果:点的 x、y、z 分别增加了一个位置偏移。在 3D 中的可视化效果是,把点(x,y,z)在空间中平移了(t_{x},t_{y},t_{z}) 个单位。

有趣的是,如果我们对一个方向矢量进行平移变换,结果如下:

\begin{bmatrix} 1 & 0 &0 &t_{x} \\ 0 & 1& 0 &t_{y}\\ 0& 0 & 1& t_{z}\\ 0 & 0 & 0 & 1 \end{bmatrix}\begin{bmatrix} x\\ y\\ z\\ 0 \end{bmatrix}=\begin{bmatrix} x\\ y\\ z\\ 0 \end{bmatrix}

可以发现,平移变换不会对方向矢量产生任何影响。这点很容易理解,我们在学习矢量的时候就说过了,矢量没有位置属性,也就是说它可以位于空间中的任意一点,因此对位置的改变(即平移)不应该对四维矢量产生影响。

现在 ,从上可知当给定一个平移操作时如何让构建一个平移矩阵:基础变换矩阵中的 t_{3x1} 矢量对应了平移矢量,左上角的矩阵 M_{3X3}为单位矩阵 I_{3} 。

平移矩阵的逆矩阵就是反向平移得到的矩阵,即

\begin{bmatrix} 1 & 0 &0 &-t_{x} \\ 0 & 1& 0 &-t_{y}\\ 0& 0 & 1& -t_{z}\\ 0 & 0 & 0 & 1 \end{bmatrix}

可以看出,平移矩阵并不是一个正交矩阵。

2.4.5缩放矩阵

我们可以对一个模型沿空间的 x 轴、 y 轴和 z 轴进行缩放。同样,我们可以使用矩阵乘法来表示一个缩放变换:

\begin{bmatrix} k_{x} & 0 &0 &-t_{x} \\ 0 & k_{y}& 0 &-t_{y}\\ 0& 0 & k_{z}& -t_{z}\\ 0 & 0 & 0 & 1 \end{bmatrix}\begin{bmatrix} x\\ y\\ z\\ 0 \end{bmatrix} = \begin{bmatrix} k_{x}x\\ k_{y}y\\ k_{z}z\\ 0 \end{bmatrix}

如果缩放系数 k_{x} = k_{y} = k_{z},我们把这样的缩放称为统一缩放(uniform sacle),否则称为非统一缩放(nonuniform scale)。从外观上看,统一缩放时扩大整个模型,而非统一缩放会拉伸或挤压模型。更重要的是,统一缩放不会改变角度和比例信息,而非统一缩放会改变与模型相关的角度和比例。例如在对发现进行变换时,如果存在非统一缩放,直接使用用于变换顶点的变换矩阵的话,就会得到错误的结果。

缩放矩阵的逆矩阵时使用原缩放系数的倒数来对点或方向矢量进行缩放,即

\begin{bmatrix} \frac{1}{k_{x}} &0 &0 &0 \\ 0& \frac{1}{k_{y}}& 0 & 0\\ 0 &0 &\frac{1}{k_{z}} & 0\\ 0& 0& 0& 1 \end{bmatrix}

缩放矩阵一般不是正交矩阵。

上面的矩阵只适用于沿坐标轴方向进行缩放 。如果我们希望在任意方向上进行缩放,就需要使用一个复合变换。其中一种方法的主要思想就是,先将缩放轴变换成标准坐标轴,然后进行沿坐标轴的缩放,再使用逆变换得到原来的缩放轴朝向。

2.4.6旋转矩阵

旋转式三种常见的变换矩阵中最复杂的一种。我们知道,旋转操作需要指定一个旋转轴,这个旋转轴不一定式空间中的坐标轴,但我们所讲的旋转就是指绕着空间中的 x 轴、 y 轴或 z 轴进行旋转。

如果我们需要把点绕着 x 轴旋转 θ 度,可以使用下面的矩阵:

 绕 y 轴的旋转也是雷士的:

最后,是绕 z 轴的旋转:

旋转矩阵的逆矩阵是旋转相反角度得到的变换矩阵。旋转矩阵是正交矩阵,而且多个旋转矩阵之间的串联同样是正交的。

 2.4.7复合变换

我们可以把平移、旋转和缩放组和起来,来形成一个复杂的变换过程。例如,可以对一个模型先进行大小为(2,2,2)的缩放,再绕 y 轴旋转 30°,最后向 z 轴平移 4 个单位。复合变换可以通过矩阵的串联来实现。上面的变换过程可以通过下面的公式来计算:

 由于上面我们使用的是列矩阵,因此阅读顺序是从右到左,即先进行缩放变换,在进行旋转变换,最后进行平移变换。需要注意的是,变换的结果是依赖于变换顺序的,由于矩阵乘法不满足交换律,因此矩阵的乘法顺序很重要。也就是说,不同的变换顺序得到的结果可能是一样的。想象一下 ,如果让读者向前一步然后左转,记住此时的位置。然后回到原位,这次先左转再向前走一步,得到的位置和上一次是不一样的。究其本质,是因为矩阵的乘法不满足交换律,因此不同的乘法顺序得到的结果是不一样的。

在绝大多数情况下,我们约定变换的顺序就是先缩放,再旋转,最后平移。

为了从数学公式上理解变换顺序的本质,我们可以对比不同变换顺序产生的变换矩阵的表达式。如果我们只考虑对 y 轴的旋转的话,按先缩放、再旋转、最后 平移这样的顺序组合3种变换得到的变换矩阵是:

 

而如果我们使用了其他变换顺序,例如先平移,再缩放,最后旋转,那么得到的变换矩阵是:

 从两个结果可以看出,得到的变换矩阵是不一样的。

除了需要注意不同类型的变换顺序外,我们有时还需要小心旋转的变换顺序。

 2.5坐标空间

我们已经学会了如何使用矩阵来表示基本的变换,如平移、旋转和缩放。我们接下来将关注如何使用这些变换来对坐标空间进行变换。

在学习顶点着色器流水线阶段时,我们说过,顶点着色器最基本的功能就是把模型的顶点坐标从模型空间转换到齐次裁剪坐标空间中。

渲染游戏的过程可以理解成是把一个个顶点经过层层处理最终转化到屏幕上的过程,那么本节我们就将学习这个转换的过程时如何实现的。更具体来说 ,顶点是经过了哪些坐标空间后,最后被华仔了我们的屏幕上。

2.5.1坐标空间的变换

我们先要为后面的内容做些数学铺垫。在渲染流水线中,我们往往需要 把一个点或方向矢量从一个坐标空间转换到另一个坐标空间。这个过程到底是怎么实现的呢?

我们把问题一般化。我们知道,要想定义一个坐标空间,必须指明其原点位置和3个坐标轴的方向。而这些数值实际上是相对于另一个坐标空间的。也就是说,坐标空间会形成一个层次结构---每个坐标空间都是另一个坐标空间的子空间,反过来说,每个空间都有一个父(parent)坐标空间。对坐标空间的变换实际上就是在父空间和子空间之间对点和矢量进行变换。

假设,现在有父坐标空间 P 以及一个子坐标空间 C 。我们知道在父坐标空间中子坐标空间的原点位置以及 3 个单位坐标轴。我们一般会有两种需求:一种需求是把子坐标空间下表示的点或矢量A_{C} 转换到父坐标空间下的表示 A_{P} ,另一个需求是反过来,即把父坐标下表示的点或矢量 B_{P} 转换到子坐标空间下的表示 B_{C}。我们可以使用下面的公式来表示这两种需求:

A_{P} = M_{C\rightarrow P}A_{C}

B_{C} = M_{P\rightarrow C}B_{P}

其中,M_{C\rightarrow P } 表示的是从子坐标空间变换到父坐标空间的变换矩阵,而 M_{P\rightarrow C} 是其逆矩阵(即反向变换)。那么,现在的问题就是,如果求解这些变换矩阵?事实上,我们只需要解出两者之一即可,另一个矩阵可以通过求矩阵的方式来得到。

下面,我们就来回顾一个看似很简单的问题:当给定一个坐标空间以及其中一点(a,b,c)时,我们是如何知道该点的位置呢?我们可以通过4个步骤来确定它的位置:

        (1)从坐标空间的原点开始

        (2)向 x 轴方向移动 a 个单位。

        (3)向 y 轴方向移动 b 个单位。

        (4)向 z 轴方向移动 c 个单位。

需要说明的是,上面的步骤只是我们的想象,这个点实际上并没有发生移动。上面的步骤看起来再简单不过了,坐标空间的变换就蕴含在上面的 4 个步骤中。现在,我们已知子坐标空间 C 的 3 个坐标轴在父坐标空间 P 下的表示 X_{C}Y_{C}Z_{C},以及其原点位置 O_{C}。当给定一个子坐标空间中的一点 A_{c} = (a,b,c),我们同样可以依照上面 4 个步骤来确定其在父坐标空间下的位置A_{p}:

        1. 从坐标空间得原点开始

        这很简单,我们已经知道了子坐标空间的原点位置 O_{C}

        2. 向 x 轴方向移动 x 个单位

        任然很简单,因为我们已经知道了 x 轴得矢量表示,因此可以得到 O_{c} + ax_c

        3. 向 y 轴方向移动 y 个单位

        同样得道理,这一步就是: O_c+ax_c+by_c

        4. 向 z 轴方向移动 z 个单位

        最后,就可以得到  O_c+ax_c+by_c+cz_c

现在,我们已经求出了 M_{C\rightarrow P }! 什么?你没有看出来吗?我们再来坎一下最后得到得式子:

A_P = O_c+ax_c+by_c+cz_c

 可能会问,这个式子里根本没有矩阵啊!其实我们只要稍微使用一点魔法,矩阵就回出现再上面的式子中:

 \\A_P=O_C+ax_c+by_c+cz_c\\ =(x_{oc},y_{oc},z_{oc})+a(x_{xc},y_{xc},z_{xc})+b(x_{yc},y_{yc},z_{yc})+c(x_{zc},y_{zc},z_{zc})\\ =(x_{oc},y_{oc},z_{oc})+\begin{bmatrix} x_{xc} & x_{yc} &x_{zc} \\ y_{xc} & y_{yc} &y_{zc} \\ z_{xc} & z_{yc} & z_{zc} \end{bmatrix}\begin{bmatrix} a\\ b\\ c \end{bmatrix}\\= (x_{oc},y_{oc},z_{oc})+\begin{bmatrix} | & | &| \\ x_c& y_c &z_c \\ | & | & | \end{bmatrix}\begin{bmatrix} a\\ b\\ c \end{bmatrix}

 其中 “|” 符号表示按列展开的。上面的式子实际上就是使用了我们之前所学的公式而已。但这个最后的表达式还不是很漂亮,因为还存在加法表达式,即平移变换。我们已经知道 3x3 的矩阵无法表示平移变换,因此为了得到一个更漂亮的结果,我们把上面的式子扩展到其次坐标空间中,得:

\\A_P=(X_{OC},Y_{OC},Z_{OC},1)+\begin{bmatrix} | & | &| &0 \\ X_C& Y_C& Z_C & 0\\ | &| &| & 0\\ 0& 0& 0& 1 \end{bmatrix}\begin{bmatrix} a\\ b\\ c\\ 1 \end{bmatrix}\\ =\begin{bmatrix} 1 &0 & 0 & X_{OC}\\ 0& 1& 0 & Y_{OC}\\ 0 & 0 & 1 & Z_{OC}\\ 0& 0& 0& 1 \end{bmatrix}\begin{bmatrix} | & | &| &0 \\ X_C& Y_C& Z_C & 0\\ | &| &| & 0\\ 0& 0& 0& 1 \end{bmatrix}\begin{bmatrix} a\\ b\\ c\\ 1 \end{bmatrix}\\= \begin{bmatrix} | & | &| &X_{OC}\\ X_C& Y_C& Z_C & Y_{OC}\\ | &| &| & Z_{OC}\\ 0& 0& 0& 1 \end{bmatrix}\begin{bmatrix} a\\ b\\ c\\ 1 \end{bmatrix}\\ =\begin{bmatrix} | & | &| &| \\ X_C& Y_C& Z_C & O_C\\ | &| &| & |\\ 0& 0& 0& 1 \end{bmatrix}\begin{bmatrix} a\\ b\\ c\\ 1 \end{bmatrix}\\

那么现在,你看到M_{C\rightarrow P} 在哪里了吧?没错,

M_{C \rightarrow P} = \begin{bmatrix} | &| & |& |\\ X_C &Y_C & Z_C &O_C \\ | & | & | & |\\ 0& 0 &0 &1 \end{bmatrix}

只是运用了一些基础的矢量和矩阵运算,一旦当你真正理解了这些运算就会发现上面的过程只是简单地推导了一下而已。

一旦求出来 M_{C \to P}M_{C \to P}就可以通过求逆矩阵地方式求出来,因为从坐标空间 C 变换到坐标空间 P 与坐标空间 P 变换到坐标空间 C 是互逆的两个过程。

可以看出来,变换矩阵M_{C \to P} 实际上可以通过坐标空间 C 在坐标空间 P 中的原点和坐标轴的矢量表示来构建出来:把 3 个坐标轴依次放入矩阵的前 3 列,把原点矢量放到最后一列,再用 0 和 1 填充最后一行即可。

需要注意的是,这里我们并没有要求 3 个坐标轴 X_CY_C 和 Z_C 是单位矢量,事实上,如果存在缩放的话,这 3 个矢量值很可能不是单位矢量。

更加令人振奋的是,我们可以利用反向思维,从这个变换矩阵反推来获取子坐标空间的原点和坐标轴方向!例如,当我们已知从模型空间到世界空间的一个 4x4 的变换矩阵,可以提取它的第一列再进行归一化后(为了消除缩放的影响)来得到模型空间的 x 轴在世界空间下的单位矢量表示。同样的方法可以提取 y 轴和 z 轴。我们可以从另一个角度来理解这个提取过程。因为矩阵M_{C \to P} 可以把一个单位矢量从坐标空间 C 变换到坐标空间 P 中,那么,我们只需要用它来变换坐标空间 C 中的 x 轴(1,0,0,0),即使用矩阵乘法 M_{C \to P} [1 \0 \0 \0 ]^{T} ,得到的结果正是 M_{C \to P} 的第一列。

另一个有趣的情况是,对方向矢量的坐标空间变换。我们知道,矢量是没有位置的,因此坐标空间的原点交换是可以忽略的。也就是说,我们仅仅平移坐标系的原点是不会对矢量造成任何影响的。那么,对矢量的坐标空间变换就可以使用 3x3 的矩阵来表示,因为我们不需要表示平移变换。那么变换矩阵就是:

M_{C \to P} = \begin{bmatrix} | &| &| \\ X_c &Y_c &Z_c \\ |& |& | \end{bmatrix}

在 Shader 中,我们常常会看到截取变换矩阵的前 3 行前 3 列来对法线方向、光照方向来进行空间变换,这正式原因所在。

现在,我们再来关注 M_{P \to C}。我们前面讲到,可以通过求 M_{C \to P}的逆矩阵的方式求解出反向变换 M_{P \to C} 。但有一种情况我们不需要求解逆矩阵就可以得到 M_{P \to C} ,这种情况就是M_{C \to P} 是一个正交矩阵。如果它是一个正交矩阵的话,那么M_{C \to P}的逆矩阵就等于它的转置矩阵。这意味着我们不需要进行复杂的求逆操作就可以得到反向变换。也就是说,

\\M_{P \to C} = \begin{bmatrix} | &| & |\\ X_P &Y_P] &Z_P \\ |& |& | \end{bmatrix}=M_{C \to p}^{-1} = M_{C \to p}^{T} \\ =\begin{bmatrix} -& X_C&- \\ -& Y_C& -\\ -& Z_C& - \end{bmatrix}

 而现在,我们不仅可以根据变换矩阵 M_{C \to P} 反推出子坐标空间的坐标轴方向在父坐标空间中的表示  X_CY_C 和 Z_C ,还可以反推出父坐标空间的坐标轴方向在子坐标空间的表示   X_PY_P 和 Z_P ,这些坐标轴对应的就是 M_{C \to P} 的每一行!也就是说,如果我们知道坐标空间变换矩阵 M_{A \to B} 是一个正交矩阵,那么我们可以提取它的第一列来得到坐标空间 A 的 x 轴在坐标空间 B 下的表示,还可以提取它的第一行来得到坐标空间 B 的 x 轴在坐标空间 A 下的表示。反过来,如果我们知道坐标空间 B 的 x 轴、y 轴和 z 轴(必须是单位矢量,否则构建出来的就不是正交矩阵了)在坐标空间 A 下的表示,就可以把它们依次放在矩阵的每一行就可以得到从 A 到 B 的变换矩阵了。

2.5.2模型空间

模型空间(model space),如它的名字所暗示的那样,是和某个模型或者说是对象有关的。有时模型空间也被称为对象空间(object space)或局部空间(loacl space)。每个模型都有自己独立的坐标空间,当它移动或旋转的时候,模型空间也会跟着它移动和旋转。把我们自己当成游戏中的模型的话,当我们在办公室里移动时,我们的模型空间也在跟着移动,当我们转身时,我们本身的前后左右方向也在跟着改变。

在模型空间中,我们经常使用一些方向概念,例如“前(forward)”“后(back)”“左(left)”“右(right)”“上(up)”“下(down)”。在本书中,我们把这些方向称为自然方向。模型空间中的坐标轴通然会使用这些自然方向。之前讲过,Unity 在模型空间中使用的是左手坐标系,因此在模型空间中,+x 轴、+y 轴、+z 轴分别对应的是模型的右、上和前向。需要注意的是,模型坐标空间中的 x 轴、y 轴、z 轴和自然方向的对应不一定是上述这种关系,但由于Unity使用的是这样的约定,因此我们也使用这种方式。

2.5.3世界空间

世界空间是一个特殊的坐标系,因为它简历了我们所关心的最大空间。空间可以是无限大的,怎么会有“最大”这一说呢?这里说的最大指的是一个宏观的概念 ,也就是说它是我们所关心的最外层的坐标空间。

世界空间可以被用于描述绝对位置。我们这里讲的绝对位置指的是在世界坐标系中的位置。通常,我们会把世界空间的原点放置在游戏空间的中心 。

在 Uniity 中,世界空间同样使用了左手坐标系后。但它的 x 轴、y 轴、z 轴式固定不变的。在 Unity 中,我们可以通过调整 Transform 组件中的 Postion 属性来改变模型的位置,这里的位置值是相对于这个 Transform 的父节点(parent)的模型坐标空间中的原点定义的。如果一个 Transform 没有任何父节点,那么这个位置就是在世界坐标系中的位置。我们可以想象成还有一个虚拟的根模型,这个根模型的模型空间就是世界空间,所有的游戏对象都附属于这个根模型。同样,Transform 中的 Rotation 和 Scale 也是同样的道理。

顶点变换的第一步,就是将顶点坐标从模型空间变换到世界空间中。这个变换通常叫做模型变换(model transform)。

2.5.4观察空间

观察空间(view space)也被称为摄像机空间(camera space)。观察空间可以认为是模型空间的一个特例---在所有的模型中有一个非常特殊的模型,即摄像机(虽然通常来说摄像机本身是不可见的),它的模型空间值得我们单独出来讨论,也就是观察空间。

摄像机决定了我们渲染游戏所使用的视角。在观察空间中,摄像机位于原点,同样,其坐标轴的选择可以是任意的,Unity 中观察空间的坐标轴选择是:+x 轴指向右方,+y 轴指向上方,而+z 轴指向的是摄像机的后方。我们之前讨论的模型空间喝世界空间中 +z 轴指的都是物体的前方,这里不一样是因为,unity 在模型空间和世界空间中选用的都是左手坐标系,而在观察空间中使用的是右手坐标系。这是符合 OpenGL 传统的,在这样的观察空间中,摄像机的正前方指向的是 -z 轴方向。

这种左右手坐标系之间的改变很少会对我们在 Unity 中的编程产生影响,因为 Unity 为我们做了很多渲染的底层工作,包括很多坐标空间的转换。但是如果需要调用类似 Camera.cameraToWorldMatrix、Camera.worldToCameraMatrix 等接口自行计算某模型在观察空间中的位置,就要小心这样的差异。

最后要提醒一点的是,观察空间和屏幕空间是不同的。观察空间是一个三维空间,而屏幕空间是一个二维空间。从观察空间到屏幕空间的转换需要经过一个操作,那就是投影(progection)。

顶点变换的第二步,就是将顶点坐标从世界空间变换到观察空间中。这个变换通常叫做观察变换(view transform)。

为了得到顶点在观察空间中的位置,我们可以有两种方法。一种方法是计算观察空间的 3 个坐标轴在世界空间下的表示,然后根据2.5.1节中讲到的方法,构建出从观察空间变换到世界空间的变换矩阵,再对该矩阵求逆来得到从世界空间变换到观察空间的变换矩阵。我们还可以使用另一种方法,即想象平移整个观察空间,让摄像机原点位于世界坐标的原点,坐标轴与世界空间中的坐标轴重合即可。这两种方法得到的变换矩阵都是一样的,不同的只是我们思考的方式。

这里我们使用第二种方法。由 Transform 组件可以知道 ,摄像机在世界空间中的变换是先按(30,0,0) 进行旋转,然后按 (0,10,-10) 进行了平移。那么为了把摄像机重新移动到初始状态(这里指摄像机原点位于世界坐标的原点、坐标轴与世界空间中的坐标轴重合),我们需要进行逆向变换,即先按(0,-10,10) 平移,以便将摄像机移回到原点,再按(-30,0,0) 进行旋转,以便让坐标轴重合。因此,变换矩阵就是:

但是,由于观察空间使用的是右手坐标系,因此需要对 z 分量进行反操作。我们可以通过乘以另一个特殊的矩阵来得到最终的观察变换矩阵:

现在我们可以进行顶点变换了:

 这样我们就得到了观察空间中的位置 ---(9,8.84,-27.31)。

2.5.5裁剪空间

顶点接下来要从观察空间转换到裁剪空间(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 分量将会具有特殊的意义。
    投影到底是什么呢?我们可以理解成是一个空间的降维,例如从四维空间投影到三维空间中。而投影矩阵实际上并不会真的进行这个步骤,它会为真正的投影做准备工作。真正的投影会在屏幕映射时发生,通过齐次除法来得到二维坐标。
  • 齐次是对 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,我们定义:

现在,我们可以i根据已知的Near、Far、FOV 和Aspect 的值来确定透视投影的投影矩阵。如下:

 

需要注意的是,这里的投影矩阵是建立在 Unity 对坐标系的假定上面的,也就是说,我们针对的是观察空间为右手坐标系,使用列矩阵在矩阵右侧进行相乘,且变换后 z 分量范围将在 [-w ,w]之间的情况。而在类似 DirectX 这样的图形接口中,它们希望变换 后 z 分量范围将在[0,w]之间,因此就需要对上面的透视矩阵进行一些更改。而一个顶点和上述投影矩阵相乘后,可以由观察空间变换到裁剪空间中,结果如下:

从结果可以看出,这个投影矩阵本质就是对 x、y 和 z 分量进行了不同程度的缩放(当然,z 分量还做了一个平移),缩放的目的是为了方便裁剪。我们可以注意到,此时顶点的w 分量不再是 1,而是原先 z 分量的取反结果。现在,我们就可以按如下不等式来判断一个变换后的顶点是否位于视锥体内。如果一个顶点再视锥体内,那么它变换后的坐标必须满足:

\\-w\leqslant x \leqslant w\\ -w\leqslant y \leqslant w\\ -w\leqslant z \leqslant w

任何不满足上述条件的图元都需要被剔除或者裁剪。图 4.39 显示了经过上述投影矩阵后,视锥体的变化。

 

从图 4.39 还可以注意到,裁剪矩阵会改变空间的旋向性:空间从右手坐标系变换到了左手坐标系。这意味着,离摄像机越远,z 值将越大。

2.正交投影

首先,我们还是看一下正交投影钟的 6 哥裁剪平面是如何定义的。和透视投影类似,在 Unity 中,它们也是由 Camera 组件中的参数和 Game 视图的横纵比共同决定,如图4.40 所示。

正交投影的视锥体是一个长方体,因此计算上相比透视投影来说更加简单。由图可以看出,我们可以通过 Camera 组件的 Size 属性来改变视锥体竖直方向上高度的一半,而 Calipping Planes 中的 Near 和 Far 参数可以控制视锥体的近裁剪平面和远裁剪平面距离摄像机的远近。这样,我们可以求出视锥体近裁剪平面和远裁剪平面的高度,也就是:

nearClipPlaneHeight = 2·Size

farClipPlaneHeight = nearClipPlaneHeight 

现在我们还缺乏横向的信息。同样,我们可以通过摄像机的横纵比得到。假设,当前摄像机的横纵比为 Aspect,那么:

  nearClipPlaneWidth = Aspect·nearClipPlaneHeight

farClipPlaneWidth = nearClipPlaneWidth

现在,我们可以根据已知的 Near、Far、Size 和 Aspect 的值来确定正交投影的裁剪矩阵。如下:

 同样,这里的投影矩阵是建立在 Unity 对坐标系的假定上面的。一个顶点和上述投影矩阵相乘后的结果如下:

注意到,和透视投影不同的是,使用正交投影的投影矩阵对顶点进行变换后,其 w 分量仍然为1。本质是因为投影矩阵最后一行的不同,透视投影的投影矩阵的最后一行是[0 0 -1 0],而正交投影的投影矩阵的最后一行是[0 0 0 1]。这样的选择是由原因的,是为了为齐次除法做准备。

判断一个变换后的顶点是否位于视锥体内使用的不等式和透视投影中的一样,这种通用性也是为什么要使用投影矩阵的原因之一。图 4.41 显示了经过上述投影矩阵后,正交投影的视锥体的变化。

同样,裁剪矩阵改变了空间的旋向性。可以注意到,经过正交投影变换后的顶点实际已经位于一个立方体内了。

2.5.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 这u昂的齐次裁剪空间,如图 4.43 所示。

而对于正交投影来说,它的裁剪空间实际已经是一个立方体了,而且由于经过正交投影矩阵变换后的顶点的 w 分量是 1 ,因此齐次除法并不会对顶点的 x、y、z 坐标产生影响,如图 4.44 所示。

经过齐次除法后,透视投影和正交投影的视锥体都变换到一个相同的立方体内。现在,我们可以根据变换后的 x 和 y 坐标来映射输出窗口的对应像素坐标。

在Unity 中,屏幕空间左下角的像素坐标是(0,0),右上角的像素坐标是(pixelWidth,pixelHeight )。由于当前 x 和 y 坐标都是[-1,1],因此这个映射的过程就是一个缩放的过程。

齐次除法和屏幕映射的过程剋使用下面的公式来总结:

 上面的式子对 x 和 y 分量都进行了处理,那么 z 分量呢?通常,z 分量会被用于深度缓冲。一个传统的方式是把\frac{clip_z}{clip_w} 的值直接存进深度缓冲,但这并不是必须的。通常驱动生产商会根据硬件来选择最好的存储格式。此时 clipw 也并不会被抛弃,虽然它已经完成了它的主要工作---在齐次除法中作为分母来得到 NDC,但它仍然会在后续的一些工作中起到重要的作用,例如进行透视校正插值。

在 Unity 中,从裁剪空间到屏幕空间的转换是由 Unity 帮我们完成的。我们的顶点着色器只需要把顶点转换到裁剪空间即可。

2.5.7总结

以上就是一个顶点如何从模型空间变换到屏幕坐标的过程。图4.45 总结了这下空间和用于变换的矩阵。

顶点着色器的最基本的任务就是把顶点坐标从模型空间中。这对应了图 4.45  中的前三个顶点变换过程。而在片元着色器中,我们通常也可以得到该片元在屏幕空间的像素位置。

在 Unity 中,坐标系的旋向性也随着变换发生了改变。图 4.46  总结了 Unity 中各个空间使用的坐标系旋向性。

从图 4.46 中可以发现,只有在观察空间中 Unity 使用了右手坐标系。

需要注意的是,这里仅仅给出的是一些最终能够要的坐标空间。还有一些空间在实际开发中也会遇到,例如切线空间(tangent space)。切线空间通常用于法线映射。

 2.6法线变换

最后,我们来看一种特殊的变换:法线变换。

法线(normal),也被称为 法矢量(normal vector)。在上面我们已经看到如何使用变换矩阵来变换一个顶点或者一个方向矢量,但法线是需要我们特殊处理的一种方向矢量。在游戏中,模型的一个顶点往往会携带额外的信息,而顶点法线就是其中一种信息。当我们变换一个模型的时候,不仅需要变换它的顶点,还需要变换顶点法线,以便在后续处理(如片元着色器)中计算光照等。

一般来说,点和绝大部分方向矢量都可以使用同一个 4x4 或 3x3 的变换矩阵 M_{A \to B} 把其从坐标空间 A 变换到坐标空间 B 中。但在变换法线的时候,如果使用同一个变换矩阵,可能就无法确保维持法线的垂直性。下面就来了解下为什么会出现这样的问题。

我们先来了解一下另一种方向矢量 ---切线(tangent),也被称为 切矢量(tangent vector)。于法线类似,切线往往也是模型顶点携带的一种信息。它通常与纹理空间对齐,而且与法线方向垂直,如图 4.47 所示。

由于切线是由两个顶点之间的差值计算得到的,因此我们可以直接使用用于变换顶点的变换矩阵来变换切线。假设,我们使用 3x3 的变换矩阵 M_{A \to B} 来变换顶点(注意,这里涉及的变换矩阵都是 3x3 的矩阵,不考虑平移变换。这是因为切线和法线都是方向矢量,不会受平移的影响),可以由下面的式子直接得到变换后的切线:

T_B = M_{A \to B} T_A

其中 T_A 和 T_B 分别表示在坐标空间 A 下和坐标空间 B 下的切线方向。但如果直接使用M_{A \to B}来变换法线,得到的新的法线方向可能就不会与表面垂直了。图 4.48 给出了这样的一个例子。

那么,应该使用哪个矩阵来变换法线呢?我们可以由数学约束条件来推出这个矩阵。我们知道同一个顶点的切线  T_A 和法线 N_A 必须满足垂直条件,即 T_A\cdot N_A =0。给定变换矩阵 M_{A \to B},我们已经知道 T_B = M_{A \to B} T_A。我们现在想要找到一个矩阵 G 来变换法线 N_A ,使得变换后的法线仍然与切线垂直。即

 T_B\cdot N_B = (M_{A \to B})\cdot (GN_A) =0

对上式进行一些推导后可得:

  2.7 Unity shader 的内置变量(数学篇)

使用 Unity 写 Shader 的一个好处在于,它提供了很多内置的参数,这使得我们不再需要自己手动计算一些值。本节将给出 Unity 内置的用于空间变换和摄像机以及屏幕参数的内置变量。这些内置变量可以在 Unity Shader Variables.cginc 文件中找到定义和说明。

2.7.1变换矩阵

首先是用于坐标空间变换的矩阵。表 4.2 给出了 Unity 5.2 版本提供的所有内置变换矩阵。下面所有的矩阵都是 float 4x4类型的。

 

 表 4.2 给出了这些矩阵的常见用法。但哦我们可以根据需求来达到不同的目的,例如我们可以提取坐标空间的坐标轴。

其中有一个矩阵比较特殊,即 UNITY_MATRIX_T_MV 矩阵。很多对数学不了解的读者不理解这个矩阵有什么用处。如果读者认真看过矩阵一节的知识,应该还会记得一种非常吸引人的矩阵类型---正交矩阵。对于正交矩阵来说,它的逆矩阵就是转置矩阵。因此,如果 UNITY_MATRIX_MV 是一个正交矩阵的话,那么 UNITY_MATRIX_T_MV 就是它的逆矩阵,也就是说,我们可以使用 UNITY_MATRIX_T_MV 把顶点和方向矢量从观察空间变换到模型空间。

总结一下,如果我们值考虑旋转、平移和缩放这3周昂变换的话,如果一个模型的变换只包括旋转,那么 UNITY_MATRIX_MV 就是一个正交矩阵。这个条件似乎有些苛刻,我们可以把条件再放宽一些,如果只包括旋转和统一缩放(假设缩放系数是k ),那么 UNITY_MATRIX_MV 就几乎是一个正交矩阵了。为什么是几乎呢?因为统一缩放可能会导致每一行或者每一列的矢量长度不为1,而是k ,这不符合正交矩阵的特性,但我们可以通过除以这个统一缩放系数,来把它变成正交矩阵。在这种情况下,UNITY_MATRIX_MV 的逆矩阵就是\frac{1}{K} UNITY\_MATRIX\_T\_MV。而且,如果我们知识对方向矢量进行变换的话,条件可以放的更宽,即不考虑有没有平移变换,因为平移对方向矢量没有影响。因此,我么可以截取UNITY_MATRIX_T_MV 的前三行前三列来把方向矢量从观察空间变换到模型空间(前提是只存在旋转变换和统一缩放)。对于方向矢量,我们可以在使用前对它们进行归一化处理,来消除统一缩放的影响。

 还有一个矩阵需要说明一下,那就是 UNITY_MATRIX_IT_MV 矩阵。法线的变换需要使用远变换矩阵的逆转置矩阵。因此 UNITY_MATRIX_IT_MV 可以把法线从模型空间变换到观察空间。但只要我们做一点手脚,它也可以用于直接得到 UNITY_MATRIX_MV 的逆矩阵----我们只需要对它进行转置就可以了。因此,为了把顶点或方向矢量从观察空间变换到模型空间,我们可以使用类似下面的代码:

// 方法一:使用 transpose 函数对 UNITY_MATRIX_IT_MV 进行转置,
// 得到 UNITY_MATRIX_MV 的逆矩阵,然后进行列矩阵乘法,
//把观察空间中的点或方向矢量变换到模型空间中
float4 modelPos = mul(transpose(UNITY_MATRIX_IT_MV), viewPos);

//方法二:不能直接使用转置函数 transpose ,而是交换  mul 参数的位置,使用行矩阵乘法 
// 本质和方法一是完全一样的
float4 modelLPos = mul(viewPos , UNITY_MATRIX_IT_MV)

2.7.2摄像机和屏幕参数

Unity 提供了一些内置变量来让我们访问正在渲染的摄像机的参数信息。这些参数对应了摄像机上的 Camera 组件中的属性值。表 4.3  给出了 Unity5.2 版本提供的这些变量。

 ​

 2.8答疑解惑

恭喜你已经完成了本书所有的数学训练!我们希望你能从上面的内容中得到很多收获和启发。但是,我们也相信在读完上面的内容后你可能对某些概念仍然感到迷惑。

2.8.1 使用 3x3 还是 4x4 的变换矩阵

对于线性变换(例如旋转和缩放)来说,仅使用 3x3 的矩阵就足够表示所有的变换了。但如果存在平移变换,我们就需要使用 4x4 的矩阵。因此,在对顶点的变换中,我们通常使用 4x4 的变换矩阵。当然,在变换前我们需要把点坐标转化成齐次坐标的表示,即把顶点的 w 分量设为 1。而在对方方向矢量的变换中,我们通常使用 3x3 的矩阵就足够了,这是因为平移变换对方向矢量是没有影响的。

2.8.2 CG中的矢量和矩阵类型

我们通常在 Unity Shader 中使用 CG 作为着色器编程语言。在 CG 中变量类型有很多种,但在本节我们是想解释如何让使用这些类型进行数学运算。因此,我们只以 float 家族的变量来做说明。

在CG中,矩阵类型是由 float3x3、float4x4 等关键词进行声明和定义的。而对于 float3、float4等类型的变量,我们既可以把它当成一个矢量,也可以把它当成是一个 1xN 的行矩阵或者一个 Nx1 的列矩阵。这取决于运算的种类和它们在运算中的位置。例如,当我们进行点积操作时,两个操作数就被当成矢量类型,如下:

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 函数实现的。例如:

float4 v = float4(1.0,2.0,3.0,4.0);
float4x4 M = float4x4(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);
//注意:row_mul_result 不等于 column_mul_result ,而是:
//mul(M,v) == nul(v,tranpose(M))
//mul(v,M) == nul(tranpose(M),v)

因此,参数的位置会直接影响结果值。通常在变换顶点时,我们都是使用右乘的方式来按列矩阵进行乘法。这是因为,Unity 提供的内置矩阵(如 UNITY_MTRIX_MVP等)都是按列存储的。但有时,我们也会使用左乘的方式,这是因为可以省去矩阵转置的操作。

需要注意的一点是,CG 对矩阵类型中原色的初始化和访问顺序。在CG 中,对 float4x4 等类型的变量是按行优先的方式进行填充的。什么意思呢?我们知道 ,想要填充一个矩阵需要给定一串数字,例如,如果需要声明一个 3x4 的矩阵,我们需要提供 12 个数字。那么,这串数字是一行一行地填充矩阵还是一列一列地填充矩阵呢?这两种方式得到地矩阵是不同的。例如,我们使用(1,2,3,4,5,6,7,8,9)去填充一个 3x3 的矩阵,如果是按照行优先的方式,得到的矩阵是:\begin{bmatrix} 1 &2 &3 \\ 4& 5& 6\\ 7 &8 &9 \end{bmatrix}

如果是按照列优先的方式,得到的矩阵是:

\begin{bmatrix} 1 & 4& 7\\ 2 & 5& 8\\ 3&6 & 9 \end{bmatrix}

CG使用的是行优先的方法,即是一行一行的填充矩阵的。因此,如果读者需要自己定义一个矩阵时(例如,自己构建用于空间变换的矩阵),就要注意这里的初始化方式。

类似地,当我们在CG 中访问一个矩阵中地元素时,也是按行来索引地 。例如:

// 按行优先地方式初始化矩阵 M
float3x3 M = float3x3(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 的第二行第一列的元素,即 4.0
float ele = M[1][0];

之所以 Unity Shader 中的矩阵类型满足上述规则,是因为使用的时 CG 语言。换句话说,上面的特性都是CG的规定。

如果读者熟悉 Unity 的 API,可能知道 Unity 在脚本中提供了一种矩阵类型 ---Matrix4x4。脚本中的这个矩阵类型则是采用列优先的方式。这与 Unity Shader 中的规定不一样,希望读者在遇到时不会感到困惑。

2.8.3 Unity 中的屏幕坐标:ComputeScreenPos/VPOS/WPOS

我们前面讲了屏幕空间的转换细节。在写 Shader 的过程中,我们有时候希望能够获得片元在屏幕上的像素位置。

在顶点/片元着色器中,有两种方式来获得片元的屏幕坐标。

一种是在片元着色器的输入中声明VPOS或WPOS语义。VPOS是HLSL中 对屏幕坐标的语义,而  WPOS是CG 中对屏幕坐标的语义。两者在 Unity Shader 中是等价的。我们可以在 HLSL/CG 中通过语义的方式来定义顶点/片元着色器的默认输入,而不需要自己定义输入输出夫人数据结构。使用这种方法,可以在片元着色器中这样写:

fixed4 frag(float4 sp:VPOS):SV_Taregt {
    // 用屏幕坐标除以屏幕分辨率 _ScreenParams.xy,得到视口空间中的坐标
    return fixed4(sp.xy/_ScreenParams.xy,0.0,1.0);
}

VPOS/WPOS 语义定义的输入是一个float4 类型的变量。我们已经知道它的xy 值代表了在屏幕空间中的像素坐标。如果屏幕分辨率为 400x300,那么 x 的范围就是[0.5,400.5],y的范围是[0.5,300.5]。注意,这里的像素坐标并不是整数值,这是因为 OpenGL 和 DirectX 10以后的版本认为像素中心对应的浮点值中的0.5。那么,它的 zw 分量是什么呢?在 Unity 中,VPOS/WPOS 的z 分量范围是[0,1],在摄像机的近裁剪平面处,z 值为0,在远裁剪平面处,z 值为1.对于w 分量,我们需要考虑摄像机的投影类型。如果使用的是透视投影,那么 w 分量的范围是\begin{bmatrix} \frac{1}{Near}, & \frac{1}{Far} \end{bmatrix},Near 和 Far 对应了在Camera 组件中设置的近裁剪平面和远裁剪平面距离摄像机的远近;如果使用的是正交投影,那么 w 分量的值恒为 1。这些值是通过对经过投影矩阵变换后的 w 分量取倒数后得到的。在代码的最后,我们把屏幕空间除以屏幕分辨率来得到视口空间(viewport space)中的坐标。视口坐标很简单,就是把屏幕坐标归一化,这样屏幕左下角就是(0,0),右上角就是(1,1).如果已知屏幕坐标的话,我们只需要把xy 值除以屏幕分辨率即可。

另一种方式是通过 Unity 提供的 ComputeScreenPos 函数。这个函数在 UnityCG.cginc 里被定义。通常的用法需要两个步骤,首先在顶点着色器中将 ComputeScreenPos的结果保存在输出结构体中,然后在片元着色器中进行一个齐次除法运算后得到视口空间下的坐标,例如:

struct vertOut{
    float4 pos :SV_POSITION;
    float4 scrPos :TEXCOORDO;
};

vertOut vert(appdata_base v){
    vertOut o;
    o.pos = mul(UNITY_MATRIX_MVP,v.vertex);
    //第一步;把 ComputeScreenPos 的结果保存到 scrPos 中
    o.scrPos = ComputeScreenPos(o.pos);
    return o;
}

flxed4 frag(vertOut i):SV_Target{
    // 第二步:用 scrPos.xy 除以 scrPos.w 得到视口空间中的坐标
    float2 wcoord = (i.scrPos.xy/i.scrPos.w);
    return fixed4(wcoordl,0.0,1.0);
}

上面两种效果一样。我们现在来看一下这种方式的实现细节。这种方法实际上是手动实现了屏幕映射的过程,而且它得到的坐标直接就是视口空间中的坐标。我们在xxx节中已经看到了如何将裁剪坐标空间中的点映射到屏幕坐标来。据此,我们可以得到视口空间中的坐标,公式如下:

 上面公式的思想就是,首先对裁剪空间下的坐标进行齐次除法,得到范围在[-1,1]的 NDC ,然后再将其映射到范围在[0,1]的视口空间下的坐标。那么 ComputeScreenPos 究竟是如何做到呢?我们可以在 UnityCG.cginc 文件 中找到ComputeScreenPos 函数的定义。如下:

inline float4 ComputeScreenPos (float4 pos){
    float4 o = pos* 0.5f;
    #if defined(UNITY_HALF_TEXEL_OFFSET)
    o.xy = float2(0.x,o.y*_ProjectionParams.x)+ o.w * _ScreenParams.zw;
    #else
    o.xy = float2(o.x,o.y*_ProjectionParams.x)+o.w;
    #endif

    o.zw = pos.zw;
    return o;
}

ComputeScreenPos 的输入参数pos 是经过 MVP 矩阵变换后在裁剪空间中的顶点坐标 。UNITY_HALF_TEXEL_OFFSET  是 Unity 在某些DirectX 平台上使用的宏,在这里我们可以忽略它。这样,我们可以只关注 #else 的 部分。_ProjecionParans.x 在默认情况下是1(如果我们使用了一个翻转的投影矩阵的话就是 -1,但这种情况很少见)。那么上述代码的过程实际是输出了:

 可以看出,这里的 xy 并不是真正的视口空间下的坐标。因此,我们在片元着色器中再进行一步处理,即除以裁剪坐标的 w 分量。至此,完成整个映射的过程。因此,虽然 ComputeScreenPos 的函数名字似乎意味着会直接得到屏幕空间中的位置,但并不是这样的,我们仍然在片元着色器中除以它的 w 分量来得到真正的视口空间中的位置。那么,为什么 Unity 不直接在 ComputeScreenPos 中为我们进行除以 w 分量这个步骤呢?为什么还需要我们进行这个除法?这是因为,如果  Unity 在顶点着色器这么做的话,就会破坏插值的结果。我们知道,从顶点着色器到片元着色器的过程实际会有一个插值的过程。如果不在顶点着色器中进行这个除法,保留x 、y和  w 分量,那么它们在插值后再进行这个除法,得到的 \frac{x}{w}和 \frac{y}{w}就是正确的(我们可以认为是除法抵消了插值的影响)。但如果我们直接在顶点着色器中进行这个除法,那么就需要对 \frac{x}{w}和 \frac{y}{w}直接进行插值,这样得到的插值结果就会不准确。原因是,我们不可以在投影空间中进行插值,因为这并不是一个线性空间,而插值往往是线性的。

经过除法操作后,我们就可以夫人发哦该片元在视口空间中的坐标了,也就是一个 xy 范围都在[0,1]之间的值。那么它的 zw 值是什么呢?可以看出,我们在顶点着色器中直接把裁剪空间的 zw 值存进了输出结构体中,因此片元着色器输入的就是这些插值后的裁剪空间中的 zw 值。这意味着,如果使用的是透视投影,那么 z 值得范围是[-Near,Far],w 值得范围是[Near,Far];如果使用得是正交投影,那么 z 值范围是[-1,1],而 w 值恒为1。

参考书籍《Unity Shader入门精要》

作者:冯乐乐

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值