版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
4.6 坐标空间
我们己经学会了如何使用矩阵来表示基本的变换,如平移、旋转和缩放。而在本节中,我们将关注如何使用这些变换来对坐标空间进行变换。
我们在第2 章渲染流水线中就接触了坐标空间的变换。例如,在学习顶点着色器流水线阶段时,我们说过, 顶点着色器最基本的功能就是把模型的顶点坐标从模型空间转换到齐次裁剪坐标空间中。
渲染游戏的过程可以理解成是把一个个顶点经过层层处理最终转化到屏幕上的过程,那么本节我们就将学习这个转换的过程是如何实现的。更具体来说,顶点是经过了哪些坐标空间后, 最后被画在了我们的屏幕上。
事情看起来虽然是这样一一在只有一个坐标空间的世界里, Shader 的开发者会生活得更加美好。但事实是, 一旦你真的这么做了,就会发现理想和现实之间的差距:我们不可以也不愿意抛弃这些不同的坐标空间。
我们在第2 章渲染流水线中就接触了坐标空间的变换。例如,在学习顶点着色器流水线阶段时,我们说过, 顶点着色器最基本的功能就是把模型的顶点坐标从模型空间转换到齐次裁剪坐标空间中。
渲染游戏的过程可以理解成是把一个个顶点经过层层处理最终转化到屏幕上的过程,那么本节我们就将学习这个转换的过程是如何实现的。更具体来说,顶点是经过了哪些坐标空间后, 最后被画在了我们的屏幕上。
4.6.1 为什么要使用这么多不同的坐标空间
我们先要回答读者的一个疑问。在编写Shader 的过程中,很多看起来很难理解和复杂的数学运算都是为了在不同坐标空间之间转换点和矢量。看起来,这么多的坐标空间就是“万恶之源”啊! 很多人都有这样的疑问: “为什么我们不能只使用一个坐标空间来做所有的事情呢?这样一来我们不就不用学习这些烦人的数学公式了吗?这样世界将变得多美好啊!”事情看起来虽然是这样一一在只有一个坐标空间的世界里, Shader 的开发者会生活得更加美好。但事实是, 一旦你真的这么做了,就会发现理想和现实之间的差距:我们不可以也不愿意抛弃这些不同的坐标空间。
事实上,在我们的生活中,我们也总是使用不同的坐标空间来交流。现在正在读这本书的你,很可能正坐在办公室或书房中。如果问你: “办公室的饮水机在哪里?”你大概会回答: “在办公室门的左方3 米处。”这里,你很自然地使用了以门为原点的坐标空间。现在,公司的前台小姐走进门来,你非常惊讶地看到她脸上还残留有中午吃饭的米粒!我们假设正在读这本书的你是一个好心而且不喜欢看别人笑话的人,这时你可能会提醒她:“嘿,你左脸上面有些东西没有擦掉!”
此时,你又使用了以前台小姐的嘴巴为原点的坐标空间。如果只有一个坐标系会怎么样呢?你可以尝试一下使用以你的办公室的门为原点的坐标空间来描述前台小姐脸上的一粒饭粒。
再比如,我们每个人所生活的城市可以看成是一个世界坐标系(三维渲染里的世界坐标系将在4.6.5 节中讲到〉,这个坐标系的坐标轴可以认为是由东南西北这些定义的方向轴。如果一个陌生人向你问路,你很有可能会说: “向东走800 米上桥,然后再向南走50 米就到了”。但是我们知道, 现实生活中有很多人是分不清东南西北的(在作者小时候,经常使用“上北下南左西右东”来傻傻地判断东南西北, 因此总是得到错误方位〉。如果现在有一个饥肠辘辘又分不清东南西北的路人来问你最近的餐厅怎么走,你可能会说: “你先往前走50 米,到了路口向左拐100 米就有一家非常好吃的烤鸭店。”此时,你使用的是以这个路人为原点的坐标空间。想象一下, 如果在这个世界上我们只能使用东南西北来描述所有东西的话,该会有多少人会被饿死。
由此可见,我们需要在不同的情况下使用不同的坐标空间, 因为一些概念只有在特定的坐标空间下才有意义, 才更容易理解。这也是为什么在渲染中我们要使用这么多坐标空间。在开始介绍一些不同的坐标空间之前,读者需要注意, 所有的坐标空间在理论上都是平等的,没有谁优谁劣之分,不会因为我们从一个坐标空间转换到另一个坐标空间计算就出错了。但是,在特定的情况下, 一些坐标空间的确比另一些坐标空间更加吸引人。
此时,你又使用了以前台小姐的嘴巴为原点的坐标空间。如果只有一个坐标系会怎么样呢?你可以尝试一下使用以你的办公室的门为原点的坐标空间来描述前台小姐脸上的一粒饭粒。
再比如,我们每个人所生活的城市可以看成是一个世界坐标系(三维渲染里的世界坐标系将在4.6.5 节中讲到〉,这个坐标系的坐标轴可以认为是由东南西北这些定义的方向轴。如果一个陌生人向你问路,你很有可能会说: “向东走800 米上桥,然后再向南走50 米就到了”。但是我们知道, 现实生活中有很多人是分不清东南西北的(在作者小时候,经常使用“上北下南左西右东”来傻傻地判断东南西北, 因此总是得到错误方位〉。如果现在有一个饥肠辘辘又分不清东南西北的路人来问你最近的餐厅怎么走,你可能会说: “你先往前走50 米,到了路口向左拐100 米就有一家非常好吃的烤鸭店。”此时,你使用的是以这个路人为原点的坐标空间。想象一下, 如果在这个世界上我们只能使用东南西北来描述所有东西的话,该会有多少人会被饿死。
由此可见,我们需要在不同的情况下使用不同的坐标空间, 因为一些概念只有在特定的坐标空间下才有意义, 才更容易理解。这也是为什么在渲染中我们要使用这么多坐标空间。在开始介绍一些不同的坐标空间之前,读者需要注意, 所有的坐标空间在理论上都是平等的,没有谁优谁劣之分,不会因为我们从一个坐标空间转换到另一个坐标空间计算就出错了。但是,在特定的情况下, 一些坐标空间的确比另一些坐标空间更加吸引人。
现在, 就让我们来看一下在游戏渲染流水线中, 一个顶点到底经过了怎样的空间变换。
我们把问题一般化。我们知道,要想定义一个坐标空间, 必须指明其原点位置和3 个坐标轴的方向。而这些数值实际上是相对于另一个坐标空间的(读者需要记住,所有的都是相对的〉。也就是说,坐标空间会形成一个层次结构一一每个坐标空间都是另一个坐标空间的子空间,反过来说,每个空间都有一个父( parent)坐标空间。对坐标空间的变换实际上就是在父空间和子空间之间对点和矢量进行变换。
假设,现在有父坐标空间P 以及一个子坐标空间C。我们知道在父坐标空间中子坐标空间的原点位置以及3 个单位坐标轴。我们一般会有两种需求:一种需求是把子坐标空间下表示的点或矢量Ac 转换到父坐标空间下的表示Ap ,另一个需求是反过来,即把父坐标空间下表示的点或矢量Bp 转换到子坐标空间下的表示Bc。我们可以使用下面的公式来表示这两种需求:
4.6.2 坐标空间的变换
我们先要为后面的内容做些数学铺垫。在渲染流水线中,我们往往需要把一个点或方向矢量从一个坐标空间转换到另一个坐标空间。这个过程到底是怎么实现的呢?我们把问题一般化。我们知道,要想定义一个坐标空间, 必须指明其原点位置和3 个坐标轴的方向。而这些数值实际上是相对于另一个坐标空间的(读者需要记住,所有的都是相对的〉。也就是说,坐标空间会形成一个层次结构一一每个坐标空间都是另一个坐标空间的子空间,反过来说,每个空间都有一个父( parent)坐标空间。对坐标空间的变换实际上就是在父空间和子空间之间对点和矢量进行变换。
假设,现在有父坐标空间P 以及一个子坐标空间C。我们知道在父坐标空间中子坐标空间的原点位置以及3 个单位坐标轴。我们一般会有两种需求:一种需求是把子坐标空间下表示的点或矢量Ac 转换到父坐标空间下的表示Ap ,另一个需求是反过来,即把父坐标空间下表示的点或矢量Bp 转换到子坐标空间下的表示Bc。我们可以使用下面的公式来表示这两种需求:
![](https://i-blog.csdnimg.cn/blog_migrate/c8689d3a533f2a95e5cb77ab9d519d6e.png)
其中, Mc→p 表示的是从子坐标空间变换到父坐标空间的变换矩阵,而Mp→c是其逆矩阵(即反向变换〉。那么,现在的问题就是, 如果求解这些变换矩阵?事实上,我们只需要解出两者之一即可,另一个矩阵可以通过求逆矩阵的方式来得到。
下面,我们就来讲解如何求出从子坐标空间到父坐标空间的变换矩阵Mc→p 。
首先,我们来回顾一个看似很简单的问题: 当给定一个坐标空间以及其中一点(a,b,c)时,我们是如何知道该点的位置的呢?我们可以通过4 个步骤来确定它的位置:
(1)从坐标空间的原点开始:
(2)向x 轴方向移动a 个单位。
(3)向y 轴方向移动b 个单位。
(4)向z 轴方向移动c 个单位。
下面,我们就来讲解如何求出从子坐标空间到父坐标空间的变换矩阵Mc→p 。
首先,我们来回顾一个看似很简单的问题: 当给定一个坐标空间以及其中一点(a,b,c)时,我们是如何知道该点的位置的呢?我们可以通过4 个步骤来确定它的位置:
(1)从坐标空间的原点开始:
(2)向x 轴方向移动a 个单位。
(3)向y 轴方向移动b 个单位。
(4)向z 轴方向移动c 个单位。
需要说明的是,上面的步骤只是我们的想象,这个点实际上并没有发生移动。上面的步骤看起来再简单不过了,坐标空间的变换就蕴含在上面的4 个步骤中。现在,我们已知子坐标空间C的3 个坐标轴在父坐标空间P 下的表示Xc、Yc、Zc,以及其原点位置O。当给定一个子坐标空间中的一点Ac = (a,b,c) ,我们同样可以依照上面4 个步骤来确定其在父坐标空间下的位置Ap :
![](https://i-blog.csdnimg.cn/blog_migrate/a7df0b11eb2bbe235deeeb1711dd2246.png)
读者可能会问,这个式子里根本没有矩阵啊!其实我们只要稍稍使用一点“魔法”,矩阵就会出现在上面的式子中:
![](https://i-blog.csdnimg.cn/blog_migrate/0ddb8005c5ba41b49835357306482d13.png)
其中“|”符号表示是按列展开的。上面的式子实际上就是使用了我们之前所学的公式而已。但这
个最后的表达式还不是很漂亮,因为还存在加法表达式,即平移变换。我们已经知道3 × 3 的矩阵无
法表示平移变换,因此为了得到一个更漂亮的结果,我们把上面的式子扩展到齐次坐标空间中,得
![](https://i-blog.csdnimg.cn/blog_migrate/bb7825f1f494dcaf30cf8e11830bf58d.png)
读者:这个看起来太神奇了!怎么就变着变着就出现了矩阵呢?
我们:上面只是运用了一些基础的矢量和矩阵运算, 一旦当你真正理解了这些运算就会发现上面的过程只是简单地推导了一下而已。
一旦求出来Mc→p, Mp→c 就可以通过求逆矩阵的方式求出来,因为从坐标空间C 变换到坐标空间P 与从坐标空间P 变换到坐标空间C 是互逆的两个过程。
可以看出来,变换矩阵Mc→p 实际上可以通过坐标空间C 在坐标空间P 中的原点和坐标轴的矢量表示来构建出来:把3 个坐标轴依次放入矩阵的前3 列,把原点矢量放到最后一列,再用0和1 填充最后一行即可。
我们:上面只是运用了一些基础的矢量和矩阵运算, 一旦当你真正理解了这些运算就会发现上面的过程只是简单地推导了一下而已。
一旦求出来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),即使用矩阵乘法
更加令人振奋的是,我们可以利用反向思维,从这个变换矩阵反推来获取子坐标空间的原点和坐标轴方向!例如,当我们己知从模型空间到世界空间的一个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 的矩阵来表示,因为我们不需要表示平移变换。那么变换矩阵就是:
另一个有趣的情况是,对方向矢量的坐标空间变换。我们知道,矢量是没有位置的, 因此坐标空间的原点变换是可以忽略的。也就是说,我们仅仅平移坐标系的原点是不会对矢量造成任何影响的。那么,对矢量的坐标空间变换就可以使用3 × 3 的矩阵来表示,因为我们不需要表示平移变换。那么变换矩阵就是:
![](https://i-blog.csdnimg.cn/blog_migrate/85a87954c59292ed467489b8253aa83e.png)
在Shader 中,我们常常会看到截取变换矩阵的前3 行前3 列来对法线方向、光照方向来进行空间变换,这正是原因所在。
现在,我们再来关注Mp→c。我们前面讲到,可以通过求Mc→p 的逆矩阵的方式求解出来反向变换Mp→c。但有一种情况我们不需要求解逆矩阵就可以得到Mp→c,这种情况就是Mc→p 是一个正交矩阵。如果它是一个正交矩阵的话, Mc→p 的逆矩阵就等于它的转置矩阵。这意味着我们不需要进行复杂的求逆操作就可以得到反向变换。也就是说,
现在,我们再来关注Mp→c。我们前面讲到,可以通过求Mc→p 的逆矩阵的方式求解出来反向变换Mp→c。但有一种情况我们不需要求解逆矩阵就可以得到Mp→c,这种情况就是Mc→p 是一个正交矩阵。如果它是一个正交矩阵的话, Mc→p 的逆矩阵就等于它的转置矩阵。这意味着我们不需要进行复杂的求逆操作就可以得到反向变换。也就是说,
![](https://i-blog.csdnimg.cn/blog_migrate/e0bb570e28a83c3d48d89bd9f945b758.png)
而现在,我们不仅可以根据变换矩阵Mc→p 反推出子坐标空间的坐标轴方向在父坐标空间中的表示Xc、Yc 和Zc,还可以反推出父坐标空间的坐标轴方向在子坐标空间中的表示Xp、Yp 和Zp , 这些坐标轴对应的就是Mc→p 的每一行!也就是说,如果我们知道坐标空间变换矩阵M
A→B
才是一个正交矩阵,那么我们可以提取它的第一列来得到坐标空间A 的x 轴在坐标空间B 下的表示,还可以
提取它的第一行来得到坐标空间B 的x 轴在坐标空间A 下的表示。反过来,如果我们知道坐标空间B 的x 轴、y 轴和z 轴(必须是单位矢量,否则构建出来的就不是正交矩阵了)在坐标空间A下的表示,就可以把它们依次放在矩阵的每一行就可以得到从A 到B 的变换矩阵了。
读者: 天呐,我的脑子已经完全乱掉了,一会从P 到C , 一会又从C 到P,一会是行,一会又是列,我自己写的时候一定会搞不清楚!
提取它的第一行来得到坐标空间B 的x 轴在坐标空间A 下的表示。反过来,如果我们知道坐标空间B 的x 轴、y 轴和z 轴(必须是单位矢量,否则构建出来的就不是正交矩阵了)在坐标空间A下的表示,就可以把它们依次放在矩阵的每一行就可以得到从A 到B 的变换矩阵了。
读者: 天呐,我的脑子已经完全乱掉了,一会从P 到C , 一会又从C 到P,一会是行,一会又是列,我自己写的时候一定会搞不清楚!
我们: 我们知道这个过程很容易造成思维的混乱,因此才要花费大量的篇幅来解释背后的数学原理。只有知道了这些原理,遇到疑问时你才知道怎样去验证结果的正确性。例如像下面这样。
当你不知道把坐标轴的表示是按行放还是按列放的时候,不妨先选择一种摆放方式来得到变换矩阵。例如,现在我们想把一个矢量从坐标空间A 变换到坐标空间B,而且我们已经知道坐标空间B 的x 轴、y 轴、z 轴在空间A 下的表示,即X B 、Y B 和Z B 。那么想要得到从A 到B 的变换矩阵M A→B ,我们是把它们按列放呢还是按行放呢?如果读者实在想不起来正确答案,我们不妨先随便选择一种方式,例如按列摆放。那么,
当你不知道把坐标轴的表示是按行放还是按列放的时候,不妨先选择一种摆放方式来得到变换矩阵。例如,现在我们想把一个矢量从坐标空间A 变换到坐标空间B,而且我们已经知道坐标空间B 的x 轴、y 轴、z 轴在空间A 下的表示,即X B 、Y B 和Z B 。那么想要得到从A 到B 的变换矩阵M A→B ,我们是把它们按列放呢还是按行放呢?如果读者实在想不起来正确答案,我们不妨先随便选择一种方式,例如按列摆放。那么,
![](https://i-blog.csdnimg.cn/blog_migrate/2cc8668e03f373ed24b20f6cea6a9c55.png)
这次结果就和我们预期的一样了。
理解上面的原理和过程非常重要。我们在本书的后面也会经常遇到坐标空间的变换。
理解上面的原理和过程非常重要。我们在本书的后面也会经常遇到坐标空间的变换。
4.6.3 顶点的坐标空间变换过程
我们知道, 在渲染流水线中, 一个顶点要经过多个坐标空间的变换才能最终被画在屏幕上。一个顶点最开始是在模型空间(见4.6.4 节)中定义的,最后它将会变换到屏幕空间中, 得到真正的屏幕像素坐标。因此, 接下来的内容我们将解释顶点要进行的各种空间变换的过程。为了帮助读者理解这个过程,我们将建立在农场游戏的实例背景下,每讲到一种空间变换,我们会解释如何应用到这个案例中。
在我们的农场游戏中, 妞妞很好奇自己是如何被渲染到屏幕上的。它只知道自己和一群小伙伴在农场里快乐地吃草, 而前面有一个摄像机一直在观察它们, 如图4.31 所示。妞妞特别喜欢自己的鼻子,它想知道鼻子是怎么被画到屏幕上的?
![](https://i-blog.csdnimg.cn/blog_migrate/58ff20b2843637594264bdb0f2c9cc29.png)
在下面的内容中, 我们将了解姐姐的鼻子是如何一步步画到屏幕上的。
4.6.4 模型空间
模型空间(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 所示:
![](https://i-blog.csdnimg.cn/blog_migrate/928933a9ceb94bd4d3bd256702e63dfa.png)
4.6.5 世界空间
世界空间(world space )是一个特殊的坐标系,因为它建立了我们所关心的最大的空间。一些读者可能会指出, 空间可以是无限大的,怎么会有“最大”这一说呢?这里说的最大指的是一个宏观的概念,也就是说它是我们所关心的最外层的坐标空间。以我们的农场游戏为例,在这个游戏里世界空间指的就是农场,我们不关心这个农场是在什么地方,在这个虚拟的游戏世界里,农场就是最大的空间概念。
世界空间可以被用于描述绝对位置(较真的读者可能会再一次提醒我,没有绝对的位置。没错,但我相信读者可以明白这里绝对的意思)。在本书中,绝对位置指的就是在世界坐标系中的位置。通常,我们会把世界空间的原点放置在游戏空间的中心。
世界空间可以被用于描述绝对位置(较真的读者可能会再一次提醒我,没有绝对的位置。没错,但我相信读者可以明白这里绝对的意思)。在本书中,绝对位置指的就是在世界坐标系中的位置。通常,我们会把世界空间的原点放置在游戏空间的中心。
在Unity 中,世界空间同样使用了左手坐标系。但它的x 轴、y 轴、z 轴是固定不变的。在Unity 中,我们可以通过调整Transform 组件中的Position 属性来改变模型的位置,这里的位置值是相对于这个Transform 的父节点( parent )的模型坐标空间中的原点定义的。如果一个Transform没有任何父节点,那么这个位置就是在世界坐标系中的位置,如图4.33 所示。我们可以想象成还有一个虚拟的根模型,这个根模型的模型空间就是世界空间,所有的游戏对象都附属于这个根模型。同样, Transform 中的Rotation 和Scale 也是同样的道理。
![](https://i-blog.csdnimg.cn/blog_migrate/83bc64a9dec24d8ff074667e91aba261.png)
顶点变换的第一步,就是将顶点坐标从模型空间变换到世界空间中。这个变换通常叫做模型变换( model transform).
现在,我们来对妞妞的鼻子进行模型变换。为此,我们首先需要知道妞妞在世界坐标系中进行了哪些变换,这可以通过面板中的Transform 组件来得到相关的变换信息, 如图4.34 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/fd39229b77253ef3e0b39e77783a1eb3.png)
根据Transform 组件上的信息,我们知道在世界空间中,妞妞进行了(2, 2, 2)的缩放,又进行
了(0, 150, 0)的旋转以及
(5, 0, 25)的平移。注意这里的变换顺序是不能互换的,即先进行缩放,再
进行旋转,最后是平移。据此我们可以构建出模型变换的变换矩阵:
![](https://i-blog.csdnimg.cn/blog_migrate/14a36bb035f25fe487d786f8e1ea6e99.png)
也就是说,在世界空间下,妞妞鼻子的位置是(9, 4, 18.072)。注意,这里的浮点数都是近似值,这里近似到小数点后3 位。实际数值和Unity 采用的浮点值精度有关。
4.6.6 观察空间
观察空间(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 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/4c3a9c6f2f4d449acc56671a3ed178eb.png)
为了得到顶点在观察空间中的位置,我们可以有两种方法。一种方法是计算观察空间的3 个坐标轴在世界空间下的表示,然后根据4.6.2 节中讲到的方法,构建出从观察空间变换到世界空间的变换矩阵,再对该矩阵求逆来得到从世界空间变换到观察空间的变换矩阵。我们还可以使用另一种方法,即想象平移整个观察空间,让摄像机原点位于世界坐标的原点,坐标轴与世界空间中的坐标轴重合即可。这两种方法得到的变换矩阵都是一样的,不同的只是我们思考的方式。
这里我们使用第二种方法。由Transform 组件可以知道,摄像机在世界空间中的变换是先按(30, 0, 0)进行旋转, 然后按
这里我们使用第二种方法。由Transform 组件可以知道,摄像机在世界空间中的变换是先按(30, 0, 0)进行旋转, 然后按
(0, 10, - 10)进行了平移。那么,为了把摄像机重新移回到初始状态(这里指摄像机原点位于世界坐标的原点、坐标轴与世界空间中的坐标轴重合〉, 我们需要进行逆向变换,即先按(0, -10, 10)平移, 以便将摄像机移回到原点,再按(-30, 0, 0)进行旋转, 以便让坐标轴重合。
因此, 变换矩阵就是:
![](https://i-blog.csdnimg.cn/blog_migrate/8d49435b15ec671c2e86af5a68195843.png)
现在我们可以用它来对妞妞的鼻子进行顶点变换了:
![](https://i-blog.csdnimg.cn/blog_migrate/da5e78812281a1afcd684e96b91cfe0f.png)
这样,我们就得到了观察空间中妞妞鼻子的位置一一(9,8.84,-27.31 )。
4.6.7 裁剪空间
顶点接下来要从观察空间转换到裁剪空间( clip space,也被称为齐次裁剪空间〉中, 这个用于变换的矩阵叫做裁剪矩阵( clip matrix ),也被称为投影矩阵( projection matrix)。
裁剪空间的目标是能够方便地对渲染图元进行裁剪: 完全位于这块空间内部的图元将会被保留,完全位于这块空间外部的图元将会被剔除,而与这块空间边界相交的图元就会被裁剪。那么,这块空间是如何决定的呢?答案是由视锥体( view frustum ) 来决定。
视锥体指的是空间中的一块区域,这块区域决定了摄像机可以看到的空间。视锥体由六个平面包围而成,这些平面也被称为裁剪平面( clip planes ) 。视锥体有两种类型,这涉及两种投影类型: 一种是正交投影( orthographic projection ) , 一种是透视投影( perspective projection ) 。
图4.36 显示了从同一位置、同一角度渲染同一个场景的两种摄像机的渲染结果。
裁剪空间的目标是能够方便地对渲染图元进行裁剪: 完全位于这块空间内部的图元将会被保留,完全位于这块空间外部的图元将会被剔除,而与这块空间边界相交的图元就会被裁剪。那么,这块空间是如何决定的呢?答案是由视锥体( view frustum ) 来决定。
视锥体指的是空间中的一块区域,这块区域决定了摄像机可以看到的空间。视锥体由六个平面包围而成,这些平面也被称为裁剪平面( clip planes ) 。视锥体有两种类型,这涉及两种投影类型: 一种是正交投影( orthographic projection ) , 一种是透视投影( perspective projection ) 。
图4.36 显示了从同一位置、同一角度渲染同一个场景的两种摄像机的渲染结果。
![](https://i-blog.csdnimg.cn/blog_migrate/f3dcd0426575ad90825d899943aca9f5.png)
从图中可以发现,在透视投影中,地板上的平行线并不会保持平行,离摄像机越近网格越大,离摄像机越远网格越小。而在正交投影中,所有的网格大小都一样, 而且平行线会一直保持平行。可以注意到, 透视投影模拟了人眼看世界的方式,而正交投影则完全保留了物体的距离和角度。因此,在追求真实感的3D 游戏中我们往往会使用透视投影,而在一些2D 游戏或渲染小地图等其他HUD 元素时,我们会使用正交投影。
在视锥体的6 块裁剪平面中, 有两块裁剪平面比较特殊,它们分别被称为近剪裁平面( near clipplane ) 和远剪裁平面(far clip plane )。它们决定了摄像机可以看到的深度范围。正交投影和透视投影的视锥体如图4.37 所示。
在视锥体的6 块裁剪平面中, 有两块裁剪平面比较特殊,它们分别被称为近剪裁平面( near clipplane ) 和远剪裁平面(far clip plane )。它们决定了摄像机可以看到的深度范围。正交投影和透视投影的视锥体如图4.37 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/32d961bdb30b5f70e9c3ac5c887b8d20.png)
由图4.37 可以看出,透视投影的视锥体是一个金字塔形,侧面的4 个裁剪平面将会在摄像机处相交。它更符合视锥体这个词语。正交投影的视锥体是一个长方体。前面讲到,我们希望根据视锥体围成的区域对图元进行裁剪,但是,如果直接使用视锥体定义的空间来进行裁剪,那么不同的视锥体就需要不同的处理过程,而且对于透视投影的视锥体来说,想要判断一个顶点是否处于一个金字塔内部是比较麻烦的。因此,我们想用一种更加通用、方便和整洁的方式来进行裁剪的工作,这种方式就是通过一个投影矩阵把顶点转换到一个裁剪空间中。
投影矩阵有两个目的。
投影矩阵有两个目的。
- 首先是为投影做准备。这是个迷惑点,虽然投影矩阵的名称包含了投影二字,但是它并没有进行真正的投影工作,而是在为投影做准备。真正的投影发生在后面的齐次除法(homogeneous division )过程中。而经过投影矩阵的变换后,顶点的w 分量将会具有特殊的意义。
我们:可以理解成是一个空间的降维,例如从四维空间投影到三维空间中。而投影矩阵实际上并不会真的进行这个步骤,它会为真正的投影做准备工作。真正的投影会在屏幕映射时发生,通过齐次除法来得到二维坐标。具体会在4.6.8 节中讲到。
- 其次是对x、y、z 分量进行缩放。我们上面讲过直接使用视锥体的6个裁剪平面来进行裁剪会比较麻烦。而经过投影矩阵的缩放后,我们可以直接使用w 分量作为一个范围值,如果x、y、z 分量都位于这个范围内,就说明该顶点位于裁剪空间内。
1 透视投影
视锥体的意义在于定义了场景中的一块三维空间。所有位于这块空间内的物体将会被渲染,否则就会被剔除或裁剪。我们已经知道,这块区域由6 个裁剪平面定义,那么这6 个裁剪平面又是怎么决定的呢?在Unity 中,它们由Camera 组件中的参数和Game 视图的横纵比共同决定,如图4.38 所示。
视锥体的意义在于定义了场景中的一块三维空间。所有位于这块空间内的物体将会被渲染,否则就会被剔除或裁剪。我们已经知道,这块区域由6 个裁剪平面定义,那么这6 个裁剪平面又是怎么决定的呢?在Unity 中,它们由Camera 组件中的参数和Game 视图的横纵比共同决定,如图4.38 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/cfe3a36955ecf824a61d3d5c81f70481.png)
![](https://i-blog.csdnimg.cn/blog_migrate/787327adc03f5d080273f6eaf96a298c.png)
现在我们还缺乏横向的信息。这可以通过摄像机的横纵比得到。在Unity 中, 一个摄像机的横纵比由Game 视图的横纵比和
Viewport Rect 中的W 和H 属性共同决定(实际上, Unity 允讲我们在脚本里通过Camera.aspect 进行更改,但这里不做讨论〉。假设,当前摄像机的横纵比为Aspect,我们定义:
![](https://i-blog.csdnimg.cn/blog_migrate/eb6867e25eb0114b3df9957861cafefa.png)
上面公式的推导部分可以参见本章的扩展阅读部分。需要注意的是,这里的投影矩阵是建立在Unity 对坐标系的假定上面的,也就是说,我们针对的是观察空间为右手坐标系,使用列矩阵在矩阵右侧进行相乘,且变换后z 分量范围将在[ -w, w]之间的情况。而在类似DirectX 这样的图形接口中,它们希望变换后z 分量范围将在[0, w]之间,因此就需要对上面的透视矩阵进行一些更改。这不在本书的讨论范围内。
而一个顶点和上述投影矩阵相乘后,可以由观察空间变换到裁剪空间中,结果如下:
而一个顶点和上述投影矩阵相乘后,可以由观察空间变换到裁剪空间中,结果如下:
![](https://i-blog.csdnimg.cn/blog_migrate/e573ca21acc529471ff96142135420a2.png)
从结果可以看出,这个投影矩阵本质就是对x、y 和z 分量进行了不同程度的缩放(当然, z分量还做了一个平移),缩放的目的是为了方便裁剪。我们可以注意到,此时顶点的w 分量不再是1 ,而是原先z 分量的取反结果。现在,我们就可以按如下不等式来判断一个变换后的顶点是否位于视锥体内。如果一个顶点在视锥体内,那么它变换后的坐标必须满足:
![](https://i-blog.csdnimg.cn/blog_migrate/a82590412b62c17fd3422634d4a0a5b7.png)
任何不满足上述条件的图元都需要被剔除或者裁剪。图4.39 显示了经过上述投影矩阵后,视锥体的变化。
![](https://i-blog.csdnimg.cn/blog_migrate/e9b79473521374125024890e3d41432b.png)
从图4.39 还可以注意到,裁剪矩阵会改变空间的旋向性:空间从右手坐标系变换到了左手坐标系。这意味着,离摄像机越远, z 值将越大。
2. 正交投影
首先,我们还是看一下正交投影中的6 个裁剪平面是如何定义的。和透视投影类似,在Unity中,它们也是由Camera 组件中的参数和Game 视图的横纵比共同决定,如图4.40 所示。
2. 正交投影
首先,我们还是看一下正交投影中的6 个裁剪平面是如何定义的。和透视投影类似,在Unity中,它们也是由Camera 组件中的参数和Game 视图的横纵比共同决定,如图4.40 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/214a6e77c55d4dc7f9936c9dcdf14a00.png)
![](https://i-blog.csdnimg.cn/blog_migrate/5b527aecc4c62f7cb9b9a1939e51cc22.png)
![](https://i-blog.csdnimg.cn/blog_migrate/f2e4de24d7b4d90b76fa6c5139bf6eac.png)
注意到,和透视投影不同的是,使用正交投影的投影矩阵对顶点进行变换后,其w 分量仍然为1 。本质是因为投影矩阵最后一行的不同,透视投影的投影矩阵的最后一行是[0 0 -1 0] ,而正交投影的投影矩阵的最后一行是[0 0 0 1] 。这样的选择是有原因的,是为了为齐次除法做准备。具体会在下一节中讲到。
判断一个变换后的顶点是否位于视锥体内使用的不等式和透视投影中的一样,这种通用性也是为什么要使用投影矩阵的原因之一。图4.41 显示了经过上述投影矩阵后,正交投影的视锥体的变化。
判断一个变换后的顶点是否位于视锥体内使用的不等式和透视投影中的一样,这种通用性也是为什么要使用投影矩阵的原因之一。图4.41 显示了经过上述投影矩阵后,正交投影的视锥体的变化。
![](https://i-blog.csdnimg.cn/blog_migrate/1003037f4f61040da74083cdf3ca27fc.png)
希望看到这里读者的脑袋还没有爆炸。现在,我们继续来看我们的农场游戏。在4.6.6 节的最后,我们已经帮助妞妞确定了它的鼻子在观察空间中的位置一一(9, 8.84, -27.31 )。现在,我们要计算它在裁剪空间中的位置。
首先,我们需要知道农场游戏中使用的摄像机类型。由于农场游戏是一个3D 游戏,因此这里我们使用了透视摄像机。摄像机参数和Game 视图的横纵比如图4.42 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/498dcb3396fe0059f06397cf1f494aba.png)
![](https://i-blog.csdnimg.cn/blog_migrate/0ca20a51fd2ea8b61f70c9f1f93ea84f.png)
![](https://i-blog.csdnimg.cn/blog_migrate/b01968bcf4fedd986c5b1ec33ad3f6ef.png)
由此,我们可以判断,妞妞的鼻子位于视锥体内,不需要被裁剪。
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 所示。
屏幕空间是一个二维空间, 因此,我们必须把顶点从裁剪空间投影到屏幕空间中, 来生成对应的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 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/f1effe97cdb8e342a4ed12bc4a87a07e.png)
而对于正交投影来说,它的裁剪空间实际已经是一个立方体了,而且由于经过正交投影矩
阵变换后的顶点的w 分量是1 ,因此齐次除法并不会对顶点的x 、y 、z 坐标产生影响,如图4.44
所示。
![](https://i-blog.csdnimg.cn/blog_migrate/9c3aa3513f3f1869fb04ca669494df4e.png)
经过齐次除法后,透视投影和正交投影的视锥体都变换到一个相同的立方体内。现在,我们可以根据变换后的x 和y 坐标来映射输出窗口的对应像素坐标。
在Unity 中, 屏幕空间左下角的像素坐标是(0, 0), 右上角的像素坐标是(pixe!Width, pixel Height)。由于当前x 和y 坐标都是[-1, 1 ], 因此这个映射的过程就是一个缩放的过程。
齐次除法和屏幕映射的过程可以使用下面的公式来总结:
在Unity 中, 屏幕空间左下角的像素坐标是(0, 0), 右上角的像素坐标是(pixe!Width, pixel Height)。由于当前x 和y 坐标都是[-1, 1 ], 因此这个映射的过程就是一个缩放的过程。
齐次除法和屏幕映射的过程可以使用下面的公式来总结:
![](https://i-blog.csdnimg.cn/blog_migrate/be3836f71fce6f5ba08495cfa9969878.png)
上面的式子对x 和y 分量都进行了处理,那么z 分量呢?通常, z 分量会被用于深度缓冲。一个传统的方式是把clip
z
/clip
w
的值直接存进深度缓冲中,但这并不是必须的。通常驱动生产商会根据硬件来选择最好的存储格式。此时clip
w
也并不会被抛弃,虽然它已经完成了它的主要工作一一在齐次除法中作为分母来得到NDC , 但它仍然会在后续的一些工作中起到重要的作用,例如进行透视校正插值。
在Unity 中,从裁剪空间到屏幕空间的转换是由Unity 帮我们完成的。我们的顶点着色器只需要把顶点转换到裁剪空间即可。
在上一步中,我们知道了裁剪空间中妞妞鼻子的位置一一(11.691, 15.311, 23.692, 27.31)。现在,我们终于可以确定妞妞的鼻子在屏幕上的像素位置。假设,当前屏幕的像素宽度为400 ,高度为300 。首先,我们需要进行齐次除法,把裁剪空间的坐标投影到NDC 中。然后,再映射到屏幕空间中。这个过程如下:
在Unity 中,从裁剪空间到屏幕空间的转换是由Unity 帮我们完成的。我们的顶点着色器只需要把顶点转换到裁剪空间即可。
在上一步中,我们知道了裁剪空间中妞妞鼻子的位置一一(11.691, 15.311, 23.692, 27.31)。现在,我们终于可以确定妞妞的鼻子在屏幕上的像素位置。假设,当前屏幕的像素宽度为400 ,高度为300 。首先,我们需要进行齐次除法,把裁剪空间的坐标投影到NDC 中。然后,再映射到屏幕空间中。这个过程如下:
![](https://i-blog.csdnimg.cn/blog_migrate/3649d1e130e5e5cf5a364947645d98c6.png)
由此,我们知道了妞妞鼻子在屏幕上的位置一一(285.617, 234.096) 。
4.6.9 总结
以上就是一个顶点如何从模型空间变换到屏幕坐标的过程。图4.45 总结了这些空间和用于变换的矩阵。
顶点着色器的最基本的任务就是把顶点坐标从模型空间转换到裁剪空间中。这对应了图4.45中的前三个顶点变换过程。而在片元着色器中,我们通常也可以得到该片元在屏幕空间的像素位置。我们会在4.9.3 节中看到如何得到这些像素位置。
在Unity 中,坐标系的旋向性也随着变换发生了改变。图4.46 总结了Unity 中各个空间使用的坐标系旋向性。
顶点着色器的最基本的任务就是把顶点坐标从模型空间转换到裁剪空间中。这对应了图4.45中的前三个顶点变换过程。而在片元着色器中,我们通常也可以得到该片元在屏幕空间的像素位置。我们会在4.9.3 节中看到如何得到这些像素位置。
在Unity 中,坐标系的旋向性也随着变换发生了改变。图4.46 总结了Unity 中各个空间使用的坐标系旋向性。
![](https://i-blog.csdnimg.cn/blog_migrate/f37acaf182c598fa37bf5234622367de.png)
![](https://i-blog.csdnimg.cn/blog_migrate/caf21ea12f0cb35c7ed19395900c7d6b.png)
从图4.46 中可以发现,只有在观察空间中Unity 使用了右手坐标系。需要注意的是,这里仅仅给出的是一些最重要的坐标空间。还有一些空间在实际开发中也会遇到,例如切线空间( tangent space )。切线空间通常用于法线映射,在后面的4.7 节中我们会讲到。
4.7 法线变换
在本章的最后, 我们来看一种特殊的变换:法线变换。
法线(normal) ,也被称为法矢量( normal vector )。在上面我们已经看到如何使用变换矩阵来变换一个顶点或一个方向矢量,但法线是需要我们特殊处理的一种方向矢量。在游戏中,模型的一个顶点往往会携带额外的信息,而顶点法线就是其中一种信息。当我们变换一个模型的时候,不仅需要变换它的顶点,还需要变换顶点法线,以便在后续处理〈如片元着色器〉中计算光照等。
一般来说,点和绝大部分方向矢量都可以使用同一个4 × 4 或3 × 3 的变换矩阵M A→B 把其从坐标空间A 变换到坐标空间B 中。但在变换法线的时候,如果使用同一个变换矩阵,可能就无法确保维持法线的垂直性。下面就来了解一下为什么会出现这样的问题。
我们先来了解一下另一种方向矢量一一切线(tangent), 也被称为切矢量(tangent vector) 。与法线类似,切线往往也是模型顶点携带的一种信息。它通常与纹理空间对齐,而且与法线方向垂直,如图4.47 所示。
法线(normal) ,也被称为法矢量( normal vector )。在上面我们已经看到如何使用变换矩阵来变换一个顶点或一个方向矢量,但法线是需要我们特殊处理的一种方向矢量。在游戏中,模型的一个顶点往往会携带额外的信息,而顶点法线就是其中一种信息。当我们变换一个模型的时候,不仅需要变换它的顶点,还需要变换顶点法线,以便在后续处理〈如片元着色器〉中计算光照等。
一般来说,点和绝大部分方向矢量都可以使用同一个4 × 4 或3 × 3 的变换矩阵M A→B 把其从坐标空间A 变换到坐标空间B 中。但在变换法线的时候,如果使用同一个变换矩阵,可能就无法确保维持法线的垂直性。下面就来了解一下为什么会出现这样的问题。
我们先来了解一下另一种方向矢量一一切线(tangent), 也被称为切矢量(tangent vector) 。与法线类似,切线往往也是模型顶点携带的一种信息。它通常与纹理空间对齐,而且与法线方向垂直,如图4.47 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/6902b7a789812add8762fd87e91d7a9c.png)
T
B
=M
A→B
T
A
其中T
A
和T
B
分别表示在坐标空间A 下和坐标空间B 下的切线方向。但如果直接使用M
A→B
来变换法线, 得到的新的法线方向可能就不会与表面垂直了。图4.48 给出了这样的一个例子。
![](https://i-blog.csdnimg.cn/blog_migrate/a3ae4e1fd4d70aafe7b06e32235aebf5.png)
![](https://i-blog.csdnimg.cn/blog_migrate/6cd27f1b08a31fdac49ef646ab525b08.png)
![](https://i-blog.csdnimg.cn/blog_migrate/ee9a62f68f206dc79dcc90ec8e3aaad0.png)