本文重点内容:
1、创建一个立方体构建的Grid网格
2、支持缩放、位移、旋转
3、变换矩阵
4、创建简单的相机投影
译注:从原创作者博客转为公众号文章非常复杂,我需要先将原文翻译一遍,然后在公众号再排版一遍。公众号编辑十分不方便,尤其是原作者的代码风格、图片格式、数学公式、动图、视频、引用Tips等等都需要二次导入和格式转换。加上原作者每篇的内容非常长,编辑起来非常耗时,非常累。 另外,我对比了一下使用源码引用和截图在公众号的阅读体验,觉得截图的体验要好于源码引用。截图既能保留原作者源码风格,又能在手机上有良好的阅读体验。 代码的黄色部分,是指在原有代码基础上变化的部分,完整源码会在后台通过回复关键字获取。
这是基础渲染课程系列的第一部分,主要涵盖变换矩阵相关的内容。如果你还不清楚Mesh是什么或者怎么工作的,可以转到Mesh Basics 相关的章节去了解(译注:Mesh Basics系列皆已经翻译完毕,但与本系列主题关联不大,讲完4个渲染系列之后,再放出来)。这个系列会讲,这些Mesh是如何最终变成一个像素呈现在显示器上的。
该示例使用Unity5.3.1(译注:实测2018.4版本没有问题)。
1 空间可视化
你已经知道什么是Mesh网格以及如何在场景中对其进行定位了。但是这种定位实际上是如何完成的呢?着色器如何知道在哪里绘制?当然,我们可以仅依靠Unity的transform组件和着色器来完成所有工作,但是如果你想获得完全控制权,那么了解实际发生的底层原理则至关重要。
为了完全理解此过程,最好创建自己的实现。移动,旋转和缩放网格是通过操纵其顶点的位置来完成的。这属于空间上的变换,因此要在实际中看到它,我们必须使空间可见。可以通过创建用“点”组成的3D网格来实现。点可以是任何预制件。
创建一个点,实际上就是实例化预制件,确定其坐标并为其赋予独特的颜色。
网格最明显的形状是一个立方体,所以让我们开始吧。我们将其以原点为中心,因此变换(尤其是旋转和缩放)相对于网格立方体的中点。
我将使用默认的立方体作为预制对象,将其缩放为一半大小,以便在它们之间留出空间。
(缩小立方体预置)
创建一个网格对象,添加我们的组件,并连接预制件。进入播放模式时,将会以我们对象的本地原点为中心出现方格。
(Transformations Grid)
2 Transformations
理想情况下,我们应该能够对Grid应用任意数量的转换。 以及各种不同类型的转换,但为了和Unity的理解一致,将只限制在位置,旋转和缩放上。
如果我们为每个Transform创建一个组件类型,就可以按照所需的任何顺序和数量将它们添加到Grid对象中。 而且,尽管每个Transform的细节都不同,但它们都需要一种方法将自己应用于空间点。
让我们为所有的Transform组件创建一个可以继承的基类。 它是一个抽象类,这意味着它不能直接使用。 给它一个抽象的Apply方法,具体的转换组件将使用它来完成其工作。
将此类组件添加到网格对象后,就必须以某种方式检索它们,以便将其应用于所有网格点。我们将使用通用List来存储对这些组件的引用。
现在我们可以添加一个Update方法来检索Transform,然后遍历整个网格并转换所有点。
为什么要在Update获取组件? 这样就可以在保持播放模式的同时使用Transform组件,并立即看到结果。
为什么使用List而不是数组? GetComponents方法的最直接的版本只是返回一个包含请求类型的所有组件的数组。 这意味着每次调用都会创建一个新数组,在本例中是每次Update。 替代版本具有列表参数。 这样做的好处是它将把组件放到列表中,而不是创建一个新的数组。 但在我看来,这不是一个关键的优化,但是当你需要经常获取组件时,使用list是个好习惯。
通过获取原始坐标,然后应用每个变换来完成每个点的变换。 但不能依靠每个点的实际位置,因为已经对它们进行了变换,并且我们不想在每个帧上累积变换。
2.1 转换
我们的第一个具体组成部分是Transform,这是最简单的。因此,创建一个扩展了Transformation的新组件,并将其位置用作局部偏移。
现在,编译器将报错说没有提供Apply的具体版本,所以我们给它一个吧。只需将所需位置添加到原始点即可。
现在,你可以将位置转换组件添加到我们的网格对象中。这让我们可以移动“点”,而无需移动实际的网格对象。我们所有的转换都发生在对象的局部空间中。
(变换位置)
2.2 缩放
接下来是缩放转换。它与位置处理方式几乎相同,只是比例分量被乘而不是被添加到原始点。
也把该组件添加到我们的网格对象中。现在我们也可以缩放网格。请注意,我们仅调整网格点的位置,因此缩放不会更改其可视化效果的大小。
(调整缩放)
一次操作中尝试执行定位和缩放。 你会发现比例尺也会影响位置。 发生这种情况是因为我们首先重新定位空间,然后对其进行缩放。但Unity的transform组件是反过来实现的,所以,我们也应该调整下脚本执行的顺序,这可以通过重新排序组件来完成。 通过每个组件右上角齿轮图标下的弹出菜单移动它们。
(修改组件顺序)
2.3 旋转
第三种变换类型是旋转。比前两个要困难一些。我们从一个新组件开始,该组件将返回没有变化的点。
那么旋转该如何实现呢? 它需要限制自己绕单个轴(Z轴)旋转。 围绕该轴旋转点就像旋转一个轮子。 由于Unity使用左手坐标系,因此在Z轴正方向观看时,正向旋转会使车轮逆时针旋转。
(绕着Z轴的2D旋转)
一个点旋转时会发生什么变化呢? 最简单的考虑点位于半径为一个单位的圆(单位圆)上的点。 最直接的点对应于X和Y轴。 如果将这些点旋转90°,则总是以0、1或-1结束。
(将(1,0)和(0,1)分别旋转90和180度)
第一步之后,点(1,0)变为(0,1)。 下一步将其设置为(−1,0)。 然后是(0,-1),最后回到(1,0)。
如果我们从点(0,1)开始,则与之前的序列相比,我们仅领先一步。
我们从(0,1)到(−1,0)到(0,−1)到(1,0)再返回。 因此,我们的点的坐标经历了循环0、1、0,-1。 他们只是有不同的起点而已。
如果改为以45°增量旋转怎么办? 这将产生位于XY平面对角线上的点。 由于到原点的距离没有变化,因此我们必须以(±√½,±√½)形式的坐标结束。 这将我们的周期扩展为0,√½,1,√½,0,-√½,-1,-√½。 如果不断减小步长,则最终会出现正弦波。
(正弦和余弦)
在我们例子里,从(1,0)开始,正弦波与y坐标匹配。 余弦与x坐标匹配。 这意味着我们可以将(1,0)重新定义为(cos z,sin z)(cosz,sinz)。 同样,我们可以将(0,1)替换为(-sin z,cos z)(-sinz,cosz)。
因此,我们首先计算围绕Z轴所需旋转的正弦和余弦。提供以度为单位的角度,但是正弦和余弦使用弧度,因此必须进行转换。
什么是弧度? 像度数一样,它们可以用作旋转的量度。 使用单位圆时,弧度与您沿其圆周行进的距离匹配。 由于圆周的长度等于圆半径的2π倍,因此1个弧度等于π/ 180度。 在这里你还可以看到π的定义。 它是圆的周长与其直径之比。
很高兴我们找到了一种旋转(1,0)和(0,1)的方法,但是旋转任意点呢? 好吧,这两点定义了X和Y轴。 我们可以将任何2D点(x,y)分解为 xX yY。 没有任何旋转,它等于x(1,0) y(0,1),实际上的确是(x,y)。 但是当旋转时,我们现在可以使用x(cos Z,sin Z) y(-sin Z,cos Z)并得到正确旋转的点。 你可以将其视为缩放点,使其落在单位圆上,旋转然后再缩小。 压缩成一个坐标对,它变成(xcosZ-ysinZ,xsinZ ycosZ)。
将旋转组件添加到网格,并将其作为中间转换。 这意味着我们首先缩放,然后旋转,最后重新定位,这也是Unity的Transform组件所做的。 当然,目前仅支持围绕Z旋转。 稍后我们将处理其他两个轴。
(所有的三个转换效果)
3 完全体的旋转
现在,我们只能绕Z轴旋转。 为了提供与Unity变换组件相同的旋转支持,我们还必须启用围绕X和Y轴的旋转。 孤立地绕这些轴旋转的实现就类似于绕Z旋转,但同时绕多个轴旋转则变得更加复杂。 为了解决这个问题,我们可以使用更好的方法来写下旋转数学。
3.1 矩阵
从现在开始,我们将垂直而不是水平地写入点的坐标。用
的写法代替(x,y)。同样的使用
代替(xcosZ−ysinZ,xsinZ ycosZ)。这样阅读更加容易一些。请注意,x和y因子最终排列在垂直列中,表示一个2D乘法。 实际上,我们执行的乘法是
,
这是矩阵乘法。2 x 2矩阵的第一列表示X轴,第二列表示Y轴。
(用2D的矩阵定义X和Y轴)
通常,将两个矩阵相乘时,在第一个矩阵中逐行,在第二个矩阵中逐列。 结果矩阵中的每个项是一行的项总和乘以一列的相应项之和。 这意味着第一矩阵的行和第二矩阵的列必须具有相同数量的元素。
(2个2X2的矩阵相乘)
结果矩阵的第一行包含行1×列1,行1×列2,依此类推。 第二行包含第2行×第1列,第2行×第2列,依此类推。 因此,它具有与第一矩阵相同的行数和与第二矩阵相同的列数。
3.2 3D旋转矩阵
到目前为止,我们有一个2 x 2矩阵,可用于绕Z轴旋转2D点。
但我们实际上使用的是3D点。所以我们尝试乘法
,
因为矩阵的行和列长度不匹配。所以我们必须把我们的旋转矩阵增加到3乘3,以包含第三维空间。如果我们用零来填充它会发生什么?
结果的X和Y分量是正常的,但Z分量始终为零。 那是不对的。 为了保持Z不变,我们必须在旋转矩阵的右下角插入1。 这么做才是对的,因为第三列表示Z轴,即
。
如果我们一次对所有三个维度都使用此技巧,那么最终将得到一个矩阵,其对角线为1,其他任何地方为0。 这被称为单位矩阵,因为它不会改变与之相乘的关系。 它就像一个过滤器,使所有内容保持不变。
3.3 为X和Y做矩阵旋转
使用我们找到的绕Z轴旋转的相同方式,我们可以得出绕Y轴旋转的矩阵。首先,X轴从
开始,逆时针旋转90°后,变为
。
这意味着旋转的X轴可以用
来表示。Z轴在其后方相距90°,因此为
。
Y轴保持不变,从而完成了旋转矩阵。
最后旋转矩阵使X保持不变,并以类似方式调整Y和Z。
3.4 统一旋转矩阵
我们的三个旋转矩阵每个绕单个轴旋转。 为了将它们结合起来,我们必须一个接一个地应用。 让我们先绕Z旋转,然后绕Y旋转,最后绕X旋转。但其实我们可以这样做:首先将Z旋转应用于我们的点,然后将Y旋转应用于结果,然后将X旋转应用于该结果。
同样我们也可以将旋转矩阵彼此相乘。这将产生一个新的旋转矩阵,该矩阵将立即应用所有三个旋转。让我们展示下Y×Z。
结果矩阵的第一项是
。
整个矩阵需要大量的乘法运算,但是许多部分最终都为0,可以丢弃。
现在再来展示X × (Y × Z) ,这会得到我们最终要的矩阵。
乘法顺序重要吗? X乘以 X×(Y×Z)=(X×Y)×Z的顺序无关紧要。 你最终得到一个不同的中间步骤,但最终结果却相同。 但是,在此方程式中对矩阵重新排序确实会改变旋转顺序,会产生不同的结果。 因此X×Y×Z≠Z×Y×X 在这方面,矩阵乘法不同于单数乘法。 Unity的实际轮换顺序为ZXY。
现在我们有了这个矩阵,可以看到如何构建旋转结果的X,Y和Z轴。
(3个轴任意旋转)
4 矩阵转换
如果我们可以能够将三个旋转方向组合到一个矩阵中,是否还可以将缩放,旋转和重新定位也组合到一个矩阵中?如果我们可以将缩放和重新定位表示为矩阵乘法,那么答案是肯定的。
缩放矩阵很容易构造。取单位矩阵并缩放其分量。
但是我们如何支持重新定位呢? 这不是对三个轴的重新定义,而是一个偏移量。 因此,我们无法用现在拥有的3 x 3矩阵表示它。 我们需要另外一列来包含偏移量。
但是,这是无效的,因为矩阵的行长已变为4。因此,我们需要在点上添加第四个组件。 当此分量与偏移量相乘时,它应该为1。我们想要保留该1值,因此可以在进一步的矩阵乘法中使用它。 这会导致一个4×4矩阵和一个4D点。
因此,我们必须使用4 x 4转换矩阵。 这意味着缩放和旋转矩阵会获得额外的行和列,其中右下角的数字为0,而数字为1。 我们所有的点都得到第四坐标,该坐标始终为1。
4.1 齐次坐标
我们可以理解第四个坐标吗?它代表什么有用的东西呢?我们现在知道给它赋予值1可以实现点的重新定位。如果其值为0,则偏移量将被忽略,但缩放和旋转仍会发生。
可以缩放和旋转但不能移动的东西。那不是点,而是向量,代表一个方向。
所以
代表一个点,而
表示向量。这概念很有用,因为这意味着我们可以使用相同的矩阵来变换位置,法线和切线。
如果当第四个坐标得到的值不是0或1时会发生什么呢? 好吧,不应该有这种情况发生。 或实际上,它没有区别。 我们现在正在使用齐次坐标。 这个想法是,空间中的每个点都可以用无限数量的坐标集表示。 最直接的形式使用1作为第四坐标。 通过将整个集合乘以任意数字,可以找到所有其他选择。
因此,要获得欧几里得点(实际的3D点),请将每个坐标除以第四个坐标,然后将其丢弃。
当然,当第四个坐标为0时,这是行不通的。这些点被定义为无限远。这就是为什么它是表现为方向的。
4.2 使用矩阵
我们可以使用Unity的Matrix4x4结构执行矩阵乘法。从现在开始,我们将使用它来执行转换,而不是之前的方法。
将一个抽象的只读属性添加到Transformation中以检索转换矩阵。
它的Apply方法不再需要抽象。将仅获取矩阵并执行乘法。
请注意,Matrix4x4.MultiplyPoint具有3D矢量参数。 假定缺少的第四坐标为1。它还负责从齐次坐标转换回欧几里得坐标的工作。 如果是要乘以一个方向而不是一个点,则可以使用Matrix4x4.MultiplyVector。
现在,具体的转换类必须将其Apply方法更改为Matrix属性。 首先是PositionTransformation。Matrix4x4.SetRow方法提供了一种方便的方式来填充矩阵。
接下来是ScaleTransformation。
对于RotationTransformation,逐列设置矩阵会更方便,因为这与我们现有的代码匹配。
4.3 组合矩阵
现在,让我们将这些Transform矩阵合并为一个矩阵。将一个Transform矩阵字段添加到TransformationGrid。
我们将在每次Update时更新此转换矩阵。这需要先获取第一个矩阵,然后将其与所有其他矩阵相乘。确保它们以正确的顺序相乘。
现在,网格不再调用Apply,而是自己执行矩阵乘法。
这种新方法效率更高,因为我们曾经分别为每个点创建每个Transform矩阵,然后分别应用它们。 现在,我们一次创建一个统一的转换矩阵,并将其重新用于每个点。Unity使用相同的技巧把每个对象层次结构简化为一个Transform矩阵。
对我们而言,我们可以使其变得更加高效。 所有变换矩阵都具有相同的底行[0 0 0 1]。 知道了这一点,我们就可以忽略该行,而跳过0的计算和最后的转换除法。Matrix4x4.MultiplyPoint4x3方法就是这么做的。 但是,我们不会使用该方法,因为有一些有用的转换会改变底部的行。
5 投影矩阵
到目前为止,我们一直在将点从3D中的一个位置转换为3D空间中的另一个位置。但是这些点最终如何在2D显示器上绘制呢?这需要从3D空间转换为2D空间。我们可以为此创建一个Transform矩阵!
对相机投影进行新的具体转换。从单位矩阵开始。
将其添加为最终转换。
(相机投影最终结果)
5.1 正交相机
从3D到2D的最直接方法是简单地放弃一个维度。这会将3D空间折叠成一个平面。该平面就像画布一样,用于渲染场景。让我们放弃Z维度试试,看看会发生什么。
(正交投影)
实际上,网格变为2D了。但你仍然可以缩放,旋转和重新放置所有内容,之后会将其投影到XY平面上。这是基本的正交摄影机投影。
我们的原始相机位于原点,并朝正Z方向看。 那我们可以移动它并旋转它吗? 是的,事实上我们已经可以做到了这一点。 移动相机与向相反方向移动世界具有相同的视觉效果。 旋转和缩放也是如此。 因此,尽管有点尴尬,但我们可以使用现有的转换来移动相机。Unity使用矩阵求逆来做同样的事情。
5.2 透视摄像机
正交摄影机很好,但不能像我们看到的那样显示世界。 为此,我们需要一个透视相机。 由于视角的原因,距离较远的事物对我们来说显得较小。 我们可以根据点与相机的距离缩放比例来重现此效果。
将所有内容除以Z坐标。 我们可以用矩阵乘法吗? 是的,通过将单位矩阵的底部行更改为[0,0,1,0]。 这将使结果的第四个坐标等于原始Z坐标。 从齐次坐标转换为欧几里得坐标,然后进行所需的划分。
正交投影的最大区别是点不会直接向下移动到投影平面。 相反,它们会朝着相机的位置(原点)移动,直到撞到切面。 当然,这仅适用于摄像机前面的点。 相机后面的点会被错误地投影。 由于现在我们不会丢弃这些点,因此先通过重新定位确保所有内容都位于相机的前面。 如果不缩放或旋转网格,则5的距离就足够了,否则你可能需要更多。
(透视投影)
原点和投影平面之间的距离也会影响投影。 它的作用就像照相机的焦距。 焦距的越大,视野就越小。 现在,我们使用的焦距为1,可产生90°的视野。 让它可以配置。
(焦距)
由于更大的焦距意味着我们正在放大,有效地增加了终点的比例,因此我们可以采用这种方式进行支持。当我们折叠Z尺寸时,不需要缩放该尺寸。
我们现在有一个非常简单的透视相机。 如果要完全模仿Unity的相机投影,我们还必须处理近距和远距平面。 这将需要投影到立方体而不是平面中,因此深度信息需要保留下来。 再有就是要关心视图纵横比。 另外,Unity的相机朝负Z方向看,还需要取反一些数字。 你可以将所有内容合并到投影矩阵中。 大家可以自己尝试构建。
那么,这一章节的意义何在? 我们很少需要自己构造矩阵,并且绝对不需要构造投影矩阵。 其实最主要是你已经能了解它们的背后发生了什么。 矩阵并不可怕,它们只是将点和向量从一个空间转换到另一个空间。 而且你现在也已经知道了,这就很好了,因为一旦我们开始编写自己的着色器时,你会再次遇到矩阵。
我们将在第2部分“着色器基础知识”中进行此操作。