前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Unity基础教程系列(新)(六)——Jobs(Animating a Fractal)

Unity基础教程系列(新)(六)——Jobs(Animating a Fractal)

作者头像
放牛的星星
发布2021-03-10 15:09:46
3.3K0
发布2021-03-10 15:09:46
举报
文章被收录于专栏:壹种念头壹种念头

目录

 修改

 1 分形

 1.1 创建分形

 1.2 多子节点

 1.3 重定位

 1.4 完成分形

 1.5 动画

 1.6 性能

 2 扁平化层次结构

 2.1 清理

 2.2 创建部件

 2.3 存储信息

 2.4 创建所有的部件

 2.5 重建分形

 2.6 再次添加动画

 2.7 再一次关注性能

 3 程序绘制

 3.1 移除GameObject

 4.2 变换矩阵

 3.3 Compute Buffers

 3.4 着色器

 3.5 绘制

 3.6 性能

 3.7 使游戏对象移动

 4 Job System

 4.1 Burst 包

 4.2 Native 数组

 4.3 Job 结构

 4.4 执行Jobs

 4.5 调度

 4.6 Burst 编译器

 4.7 Burst 检视器

 4.8 Mathematics 库

 4.9 发送更少的数据

 4.10 使用多核

 4.11 最后的性能

本文重点内容: 1、使用对象层次构建分形 2、扁平化层次 3、摆脱GameObject使用程序化生成 4、使用Jobs来更新分形 5、并行更新分形的不同部分

这是关于学习使用Unity的基础知识的系列教程中的第六篇。这次我们将创建一个动画分形。我们从常规的游戏对象层次结构开始,然后慢慢过渡到Jobs系统,并一直伴随着评估性能。

本教程是CatLikeCoding系列的一部分,原文地址见文章底部。

本教程使用Unity 2019.4.16f1制作。

(由97656个球构建的分形)

修改

我修改了上一篇教程,因此我们现在假设统一缩放,因此无需设置world-to-object矩阵。URP和DRP的实例化选项编译指示均已更改:

现在我们仅在ConfigureProcedural中构造unity_ObjectToWorld,其他矩阵代码已被删除。负比例也不再传递给着色器,因为不再需要它。

1 分形

通常,分形是具有自相似性的物体,简单来说,它意味着较小的部分看起来与较大的部分相似。例如海岸线和大量植物。例如,一棵树的树枝看起来像树的树干,只是比较小。同样,它的树枝看起来像树枝的较小版本。还有数学和几何分形。一些示例是Mandelbrot和Julia集,Koch雪花,Menger海绵和Sierpiński三角形等等。

可以通过从初始形状开始然后将其较小的副本附加到自身上来构造几何分形,然后生成自己的较小版本,依此类推。从理论上讲,这可以永远持续下去,创建无限数量的形状,并且仍占据有限的空间。我们可以在Unity中创建类似的内容,但是在性能降低太多之前,只能创建几个层次。

我们将在与上一个教程相同的项目中创建分形,只是没有视图。

1.1 创建分形

首先创建一个分形组件类型来表示我们的分形。给它一个可配置的深度整数,以控制分形的最大深度。最小深度为1,只包含初始形状。我们将最大使用8,这已经是很高的值了,最好不要太大,以免意外使你的计算机无响应。4是比较合理的默认值。

我们将使用球形作为初始形状,可以通过GameObject / 3D Object / Sphere 创建球形。将其放置在世界原点上,将我们的分形分量附加到其上,并为其提供简单的材质。最初使用URP,我将其设为黄色。从中删除SphereCollider组件,以使游戏对象尽可能简单。

(分形检视器)

为了将球体变成分形,我们需要产生它的克隆。这可以通过调用静态Object.Instantiate方法(以自身作为参数)来完成。因为MonoBehaviour继承自Object,因此我们可以直接调用Instantiate方法而无需类型限定符。为了传递对Fractal实例本身的引用,我们可以使用this关键字,因此我们将调用Instantiate(this),在进入播放模式后将执行此操作以生成分形。

我们可以使用Awake方法克隆分形,但是随后克隆的Awake方法也将立即被调用,并立即创建另一个实例,依此类推。这将一直持续到Unity崩溃,因为它递归地调用了太多的方法,崩溃将很快发生。

为了避免立即递归,我们可以改为添加Start方法并在其中调用Instantiate。Start是另一个Unity事件方法,与Awake一样,创建组件后也会调用一次。不同之处在于Start不会立即被调用,而是在组件有或没有第一次在组件上调用Update方法之前立即调用。此时创建的新组件将在下一帧进行首次更新。这意味着实例化每个帧只会发生一次。

如果现在进入播放模式,你会看到每帧都会创建一个新的克隆。首先是原始分形的克隆,然后是第一个克隆的克隆,然后是第二个克隆的克隆,依此类推。仅当计算机内存不足时,此过程才会停止,因此,在此之前你应该退出播放模式。

(创建无限的克隆)

一旦达到最大深度,我们将不得不中止实例化。为了达到最大深度,最简单的方法是减少生成的子分形的配置深度。

然后我们可以在Start的开头检查深度是否为1或更小。如果是这样,我们就不应该再从方法中返回任何内容,从而中止了。

为了容易看到配置的深度确实对于每个新的子分形都减小了,我们将其name属性设置为Fractal,然后设置一个空格和其深度。文本部分写在双引号之间,并且深度整数可以使用加法运算符连接到该文本字符串。

(四个分形级别随深度减小)

确实,每个级别的深度都会减少,一旦我们创建了正确数量的克隆体,该过程就会停止。为了使新的分形成为其直接父分形的真正子代,我们需要配置其转换层次结构。这是通过在子项的transform属性上调用SetParent并将当前分形的变换作为第一个参数来实现的。第二个参数控制Unity是否应调整孩子的transform,以保持其当前的世界位置。我们不在乎,因此将其传递给false。

(分形层次)

这为我们提供了一个简单的游戏对象层次结构,但是由于它们全部重叠,因此看起来仍然像一个球体。要更改此设置,请将子节点的transform的本地位置设置为Vector3.right。这将其定位在其父代的右侧一个单位,因此我们所有的球体最终都沿X轴连续接触。

(球体排成一排)

自相似的想法是,较小的部分看起来像较大的部分,因此每个子项都应小于其父项。通过将其局部比例尺统一设置为0.5,我们将其尺寸减半。由于比例尺也适用于子节点,这意味着每降低一级,尺寸就会减半。

(逐渐减小的球)

为了使球体再次接触在一起,我们需要减小其偏移量。父级和子级的局部半径以前都是0.5,因此偏移1会使它们接触。由于子节点的大小已减半,因此其局部半径现在为0.25,因此偏移量应减小为0.75。

(球体接触)

1.2 多子节点

每个关卡仅产生一个孩子会产生一系列球体,且球体的大小逐渐减小,这并不是一个有趣的分形。因此,我们通过复制创建子代的代码,重用child变量,在每个步骤中添加第二个子节点。唯一的区别是,我们将对额外的子代使用Vector3.up,它将其子节点置于父节点之上,而不是在右边。

我们的期望是,每个分形部分现在都将有两个子节点,最多四层深度。

(球和多个子节点,不正确)

事实似乎并非如此。我们最终在分形的顶部得到了太多的层次。这是因为当我们克隆一个分形去创造它的第二个子代时,我们已经给了它第一个子代了。这个子对象现在也被克隆了,因为Instantiate复制了传递给它的整个游戏对象层次结构。

解决方案是仅在创建两个孩子之后再建立父子关系。为了使此操作更容易,我们将子创建代码移动到一个单独的CreateChild方法中,该方法返回子分形。除了不设置父对象并且偏移方向成为参数之外,它的所有操作均相同。

从Start中删除创建子代码的代码,而是使用up和right向量作为参数两次调用CreateChild。通过变量追踪子项,然后使用它们设置父项。

(球和多个子节点,正确)

1.3 重定位

现在,我们得到了一个分形,每个部件正好有两个子节点,但要除了最大深度的最小部件。这些子项始终以相同的方式放置:一个在顶部,另一个在右侧。但是,分形子代会依附于其父代,并且可以认为是从子代成长出来的。因此,它们的方向也相对于其父对象是有意义的。对于孩子来说,其父对象是地面,这使得其偏移方向等于其局部的上轴。

我们可以通过向CreateChild添加旋转参数来支持每个部件的不同方向。孤立的旋转可以用四元数表示,它是一个四分量矢量。为此,Unity具有四元数结构类型,我们可以通过将其分配给子级局部旋转来应用于子级。

在Start中,第一个孩子位于其父对象上方,因此其方向不会改变。我们可以用Quaternion.identity来表示,这是不旋转的恒等四元数。第二个孩子在右边,因此我们需要将其绕Z轴顺时针旋转90°。我们可以通过静态Quaternion.Euler方法来执行此操作,该方法在给定的Euler角度沿X,Y和Z轴的情况下创建旋转。因为Unity使用左手坐标系,所以对于前两个轴将其传递为零,对于Z轴将其传递为-90°。

(重定向 分形子代)

1.4 完成分形

让我们继续添加第三个子对象来增加分形,这次是向左偏移,绕Z轴旋转90°。这完成了我们在XY平面上的分形。

(2D 分形)

我们还可以添加一个向下偏移的子节点吗? 是的,但这仅对分形的根部分有意义,因为在所有其他情况下,子节点最终都将隐藏在其父母的内部。为简单起见,我不会专门给根部分多创建一个子节点。

然后,通过添加两个具有正向和反向偏移的子级以及绕X轴旋转90°和-90°的旋转,将分形带入三维。

(3D分形)

一旦确定分形正确,就可以尝试配置更大的深度,例如6。

(深度为6)

在此深度处,你会注意到,由分形描述的金字塔侧面显示了Sierpiński三角形的图案。使用正交投影时比较容易看到。

(Sierpiński 三角形)

1.5 动画

通过让分形产生动画,可以使分形栩栩如生。创建无限运动的最简单方法是使用新的Update方法沿其局部上轴旋转每个部件。这可以通过在分形的Transform组件上调用Rotate来完成。这将随着时间施加累积旋转。如果我们对第二个参数使用Time.deltaTime,对其他两个参数使用零,那么最终的旋转速度为每秒一度。让我们将其扩展到每秒22.5,以便在16秒内实现完整的360°旋转。由于分形的四边对称性,该动画似乎每四秒钟循环一次。

(分形动画)

分形的每个部分都以完全相同的方式旋转,但是由于整个分形的递归性质,分形越深,运动越复杂。

1.6 性能

让分形产生动画是一个不错的主意,但它也应该足够快地运行。分形深度小于六分应该没问题,但是分形深度高可能会成为问题。因此,我分析了一些构建。

(使用URP分析构建,分形深度为6)

我针对深度分别为6、7和8的分形剖析了单独的构建。我大致估算出每帧调用Update方法花费的平均时间(以毫秒为单位),以及URP和DRP每秒的平均帧数。我关闭了VSync,以最好地掌握它在计算机上的运行速度。

事实证明,深度6没问题,但是我的机器在深度为7的时候开始挣扎,而深度8却是灾难。52ms中,太多时间是用来调用Update方法的。仅此一项就可以将帧速率限制为最多19FPS,但是对于URP而言它最终会变得更糟,对于DRP而言则为3。

Unity的默认球体有很多顶点,因此尝试进行相同的实验是有意义的,但是将分形的网格替换为立方体,渲染起来便便宜得多。这样做之后,我得到了相同的结果,这表明瓶颈是CPU,而不是GPU。

(深度为6时,用立方体代替球体)

请注意,使用立方体时,分形会自相交,因为立方体比球体突出得更远。深度4处的某些部件最终会碰到1级的根节点。因此,这些部分的向上子级最终会穿透根部件,而该级别的其他一些子级则触及2级部分,依此类推。

2 扁平化层次结构

分形及其所有独立移动部分的递归层次结构是Unity努力解决的问题。它必须独立地更新部件,计算它们的对象到世界的转换矩阵,然后剔除它们,最后使用GPU实例化或SRP批处理器对其进行渲染。我们确切地知道了分形的工作方式,因此我们可以使用比Unity通用方法更有效的策略。我们可以通过简化层次结构,摆脱其递归性质来提高性能。

2.1 清理

重组分形层次结构的第一步是删除当前方法。删除Start,CreateChild和Update方法。

与其复制根游戏对象,不如将其用作所有分形部件的根容器。因此,从我们的分形游戏对象中删除MeshFilter和MeshRenderer组件。然后将网格和材质的配置字段添加到分形。通过检查器将它们设置为我们先前使用的球体和材质。

(调整分形的GameObject)

我们将对分形部分使用相同的方向和旋转。这次我们将它们存储在静态数组中,以方便以后访问。

2.2 创建部件

现在,我们将重新讨论如何创建零件。为此添加一个新的CreatePart方法,最初是一个没有参数的void方法。

在Awake方法中调用它。这次我们不需要担心无限递归,所以不需要使用Start。

我们将在CreatePart中手动构造一个新的游戏对象。这是通过调用GameObject构造函数方法完成的。通过提供该字符串作为参数来为其赋予分形部分名称。用变量跟踪它,然后使分形根为其父代。

(第一个分形部件)

这为我们提供了一个仅具有Transform组件而没有其他组件的游戏对象。为了使其可见,我们需要通过在游戏对象上调用AddComponent来添加更多组件。做一次。

GetComponent是一种通用方法,可以添加任何种类的组件。就像方法的模板一样,每种所需的组件类型都有特定的版本。通过在尖括号中将其附加到方法的名称中,可以指定所需的类型。对MeshFilter执行此操作。

这会将MeshFilter添加到游戏对象,该对象也会返回。我们需要将网格分配给它的mesh属性,我们可以直接在方法调用的结果上执行此操作。

对MeshRenderer组件执行相同的操作,设置其材质。

现在,我们的分形部分已被渲染,进入播放模式后将出现一个球体。

2.3 存储信息

比起让每个部件更新自己,从具有分形组件的单个根对象控制整个分形更加有效。对于Unity来说也更容易,因为它只需要管理一个更新的游戏对象,而不是潜在的数千个。但要做到这一点,我们需要在一个单一的分形组件中跟踪所有部件的数据。

至少我们需要知道部件的方向和旋转。我们可以通过将它们存储在数组中来追踪它们。但是,我们不使用矢量和四元数的单独数组,而是通过创建新的FractalPart结构类型将它们分组在一起。就像定义一个类一样,但是使用struct关键字而不是class来完成。因为我们只需要在Fractal内部定义此类型,并在该类及其字段中对其进行定义即可。出于同样的原因,不要将其设置为Public。

此类型将充当数据的简单容器,这些数据被捆绑在一起并被视为单个值,而不是对象。为了使Fractal中的其他代码可以访问此嵌套类型内的字段,需要将它们公开。请注意,这仅显示Fractal内部的字段,因为struct本身在Fractal内部是私有的。

为了正确定位,旋转和缩放分形部件,我们需要访问其Transform组件,因此还需要为该结构添加一个引用字段。

现在,我们可以为分形内部的分形部件数组定义一个字段。

我们可以将所有部件放置在一个大数组中,也可以为同一级别的所有部件提供自己的数组。后者可以让之后使用层次结构更容易。我们通过将部件字段转换为数组来跟踪所有这些数组。这样的数组的元素类型是FractalPart [],因此它自己的类型定义为后跟一对空的方括号,就像其他数组一样。

在Awake的开头创建此新的顶级数组,其大小等于分形深度。在这种情况下,尺寸声明在第一对方括号内,第二对方括号应留空。

每个级别都有自己的数组,分形的根级别也只有一个部件。因此,首先为单个元素创建一个新的FractalPart数组,并将其分配给第一级。

之后,我们需要为其他级别创建一个数组。每一个都是上一个级别的五倍,因为我们给了每个部件五个孩子。我们可以这样做,将级别数组的创建变成一个循环,追踪数组的大小,并在每次迭代结束时将其乘以5。

因为大小是整数,并且只在循环内使用它,所以我们可以将其合并到for语句中,将初始化器和调整器部分转换为逗号分隔的列表。

2.4 创建所有的部件

要检查我们是否正确创建了部件,请将层索引的参数添加到CreatePart并将其附加到部件的名称。请注意,级别索引从零开始并增加,而在先前方法中我们减小了子级的已配置深度。

第一个部件的级别索引是0。然后在所有级别上执行一个循环,同样从索引1开始,因为我们显式地首先执行了顶层的单个部件。当我们要嵌套循环时,为level迭代器变量使用一个更具体的名称,比如li。

每个级别的迭代都从存储对该级别的parts数组的引用开始。然后循环遍历该级别的所有部分并创建它们,这次使用类似fpi的名称作为分形部分迭代器变量。

(所有的分形部件 逐级创建)

由于子节点的方向和旋转方式各不相同,我们需要对其进行区分。为此,我们向CreatePart添加子索引,也可以将其添加到游戏对象的名称中。

根部件不是任何部件的子部件,因此我们使用索引零,因为它可以被视为基于地面的子部件。

在每个级别的循环内,我们需要循环浏览五个子索引。可以通过在每次迭代中增加子索引并将其在适当的时候重置为零来做到这一点。或者,我们可以在另一个嵌套循环中显式创建五个子代。这就要求我们在每次迭代中将分形部分索引增加5,而不仅仅是增加它。

(级别和索引同时显示)

我们还需要确保部件尺寸正确。同一级别的所有部分都具有相同的比例尺,不会改变。因此,我们在创建每个部件时只需要设置一次。在CreatePart中为其添加一个参数,并使用它来设置统一比例。

根部分的比例为1。之后,比例将每个级别减半。

2.5 重建分形

为了重建分形的结构,我们需要直接放置所有零件,这次是直接放置在世界空间中。由于我们不使用转换层次结构,因此位置会随着分形动画的变化而改变,因此我们将继续在Update中而不是在Awake中进行设置。但是首先我们需要存储部件的数据。

首先更改CreatePart,以便它返回新的FractalPart结构值。

然后使用其子索引和静态数组以及对该游戏对象的Transform组件的引用来设置该部件的方向和旋转。我们可以通过将新部件存储在变量中,设置其字段然后返回它来实现。另一种执行此操作的方法是使用对象或结构初始化程序。这是大括号内的列表,在构造函数调用的参数列表之后。

如果构造函数方法调用没有参数,则在包含初始化程序的情况下,我们可以跳过空参数列表。

将返回的部分复制到Awake中的正确数组元素。那是根部分第一个数组的第一个元素。对于其他部分,它是当前级别数组的元素,其索引等于分形部分的索引。当我们以5的步长增加该索引时,也需要向其中添加子索引。

接下来,创建一个新的Update方法,该方法遍历所有级别及其所有部分,并将相关的分形部分数据存储在变量中。我们再次从第二个级别开始循环,因为根部分不会移动并且始终位于原点。

要相对于其父级放置部件,我们还需要访问父级的Transform组件。为此,还要追踪父部件数组。父级是该数组中的元素,其索引等于当前部分的索引除以五。之所以有效,是因为我们执行整数除法,因此没有余数。因此,索引为0–4的部分将获得父索引0,索引为5–9的部分将获得父索引1,依此类推。

现在我们可以设置部件相对于其指定父级的位置。首先使其局部位置等于其父级的位置,再加上部件的方向乘以其局部比例。由于比例尺是统一的,因此可以满足比例尺的X分量。

(部件离彼此太近)

这使部件太靠近其父部件,因为我们正在按零件自己的比例缩放距离。当比例缩小一半时,我们必须将最终偏移量增加到150%。

(部件在正确的距离)

我们还需要应用零件的旋转。这是通过将其分配给其对象的局部旋转来完成的。让我们在设置其位置之前执行此操作。

但是,我们还需要传递父级的旋转。旋转可以通过四元数的乘积来堆叠。与常规的数字乘法不同,在这种情况下顺序很重要。生成的四元数表示通过执行第二四元数的旋转,然后应用第一四元数的旋转而获得的旋转。因此,在转换层次结构中,首先执行子节点的旋转,然后执行父级的旋转。因此,正确的四元数乘法顺序是parent-child。

最后,父母的旋转也会影响其偏移的方向。通过执行quaternion–vector乘法,我们可以将四元数旋转应用于矢量。

(恢复分形)

2.6 再次添加动画

为了再次使分形产生动画,我们需要重新引入另一个旋转。这次,我们将创建一个四元数来表示当前增量时间的旋转,并且角速度与以前相同。在Update开始时执行此操作。

让我们从根部件开始。在循环之前检索它,并将其旋转乘以增量旋转。

FractalPart是一个结构,它是一个值类型,因此更改其局部变量不会更改任何其他内容。我们需要将其复制回其数组元素(替换旧数据),以便记住其旋转方式已更改。

而且,我们还必须调整根的Transform组件的旋转。这将使分形再次旋转,但仅绕其根旋转。

要旋转所有其他部件,我们还需要将相同的增量旋转也计入其旋转。当所有事物都围绕其局部上轴旋转时,增量旋转是最右边的操作数。在应用部件的游戏对象的最终旋转之前,请执行此操作。最后将调整后的部件数据复制回数组。

2.7 再一次关注性能

现在,我们的分形像以前一样出现和设置动画,但是具有新的平面对象层次结构和负责更新整个事物的单个组件。让我们再次进行分析,以对比使用相同的构建设置是否可以使这种新方法更好地执行。

(分析一次构建,URP和分形深度为6)

事实证明,现在更新过程要快得多,在所有情况下,递归方法都减少了约80%。每个地方的平均帧频也有所增加。URP的深度7已超过30FPS。深度8的效果也更好,但结果仍然不可接受。对于我来说,奇怪的是,对于深度为6的DRP,帧速率有所下降,使用立方体代替球进行测试时,帧速率要好得多达到了140FPS。除此之外,球体和立方体的结果是相同的。

我们可以得出结论,我们的新方法绝对是一种改进,但仅靠其本身仍不足以支撑深度7或8的分形。

3 程序绘制

由于我们的分形目前具有扁平的对象层次结构,因此它的结构设计与我们之前的教程的视图相同:单个对象具有许多几乎相同的子对象。通过按程序绘制图形的点,而不是每个点使用单独的游戏对象,我们显着提高了其性能。这表明我们可以对分形应用相同的方法。

虽然对象层次是扁平的,分形部分仍然具有递归层次关系。这使得它与具有独立点的视图在根本上不同。这种分层依赖性使其不适合迁移到计算着色器。但是仍然可以通过单个过程命令绘制同一级别的所有部分,从而避免了成千上万个游戏对象的开销。

可以使用计算着色器更新分形吗? 是的,但是这很不方便,因为必须先更新父部件,然后再更新子部件。这种依赖性要求将工作分成多个连续的阶段,就像我们一次又一次地在各个级别上进行迭代一样。从GPU的角度来看,由于大多数级别没有很多部件,因此无法有效利用其并行处理能力。 可以采用一种混合方法:将CPU用于除最后一个级别以外的所有级别,然后将GPU用于最后一个级别。但是本教程的重点是CPU,最后我们会发现GPU将成为瓶颈,而不是CPU。

3.1 移除GameObject

我们首先删除游戏对象。这也意味着我们不再具有用于存储世界位置和旋转的Transform组件。而是将它们存储在FractalPart的其他字段中。

从CreatePart中删除所有游戏对象代码。我们仅需保留其子索引参数,因为其他子索引参数仅在创建游戏对象时使用。

相应地调整Awake中的代码。从现在开始,我们不再在这里处理缩放问题。

在Update中,我们现在必须将根的旋转指定为其世界旋转字段,而不是Transform组件旋转。

所有其他部件的旋转和位置都需要进行相同的调整。我们还重新处理了缩放递减的情况。

3.2 变换矩阵

变换组件提供用于渲染的变换矩阵。由于我们的部件不再具有这些组件,因此我们需要自己创建矩阵。将它们存储在每个级别的数组中,就像我们存储部件一样。为此添加一个Matrix4x4 [] []字段,并在Awake中与其他数组一起创建其所有数组。

创建转换矩阵的最简单方法是调用静态Matrix4x4.TRS方法,并将位置,旋转和比例作为参数。它返回一个Matrix4x4结构,我们可以将其复制到数组中。第一个是Udpate中的根矩阵,它是根据其世界位置,世界旋转和小数位数创建的。

TSR是什么意思? 它代表平移-旋转-缩放(translation-rotation-scale)。在此上下文中的平移意味着定位或偏移。

在循环中以相同的方式创建所有其他矩阵,这次使用可变比例。

此时进入播放模式不会向我们显示分形,因为我们尚未可视化这些部件。但是我们确实计算了它们的变换矩阵。如果我们让播放模式以深度6或更大的分数运行一段时间,则Unity有时会开始记录错误。该错误告诉使用四元数到矩阵的转换失败,因为输入四元数无效。

由于浮点精度限制,转换失败。随着我们不断将四元数彼此相乘,连续的微小误差变得越来越复杂,直到结果不再被视为有效的旋转为止。这是由我们每次更新累积的非常小的旋转引起的。

解决方案是从每次更新时使用新的四元数开始。为此,我们可以将旋转角存储为FractalPart中的单独浮点字段,而不用调整其局部旋转(local rotation)。

在Update中,我们恢复为使用旋转增量角的旧方法,然后将其添加到根的旋转角中。根的世界旋转等于其配置的旋转,该旋转应用于围绕Y轴的新旋转(等于其当前旋转角)。

其他所有部件也是如此,其父级的世界旋转应用于顶部。

3.3 Compute Buffers

要渲染部件,我们需要将矩阵发送到GPU。我们将为此使用计算缓冲区(Compute Buffers),就像我们对视图所做的那样。区别在于,这次CPU将填充缓冲区,而不是GPU。这次我们为每个级别使用一个单独的缓冲区。为缓冲区数组添加一个字段,然后在Awake中创建它们。4×4矩阵具有16个浮点值,因此缓冲区的步幅是16个乘以4个字节。

我们还必须使用新的OnDisable方法释放缓冲区。为了使之与热重载一起工作,请将Awake也更改为OnEnable。

为了使内容整洁,还请在OnDisable的末尾删除所有数组引用。无论如何,我们都会在OnEnable中创建新的。

通过添加一个OnValidate方法,该方法可以简单地互相调用OnDisable和OnEnable,然后重设分形,这也使得在播放模式下通过检查器轻松支持更改分形深度。通过检查器或撤消/重做操作对组件进行更改后,将调用OnValidate方法。

但是,这仅在我们处于播放模式并且分形当前处于活动状态时才有效。我们可以通过检查数组之一是否不为空来验证这一点。

除此之外,如果我们通过检查器禁用组件,也会调用OnValidate。这将触发分形的重置,然后再次被禁用。我们还可以通过检查Fractal组件的enabled属性来避免这种情况的发生。仅当两个条件都成立时,我们才重置分形。我们将检查与布尔&& AND运算符组合在一起以形成单个条件表达式。

最后,要将矩阵上载到GPU,请在Update结束时在所有缓冲区上调用SetData,并使用相应的矩阵数组作为参数。

我们是否应该避免将数据发送到GPU? 是的,最大限度的避免。但现在,我们别无选择,我们需要以某种方式将矩阵发送到GPU,这是最有效的方法。

3.4 着色器

现在,我们需要再次创建支持程序绘制的着色器。要设置对象到世界的矩阵,我们可以从图形的PartGPU.hlsl中获取代码,将其复制到新的FractalGPU.hlsl文件中,并使其适应我们的分形。这意味着代替float3位置缓冲区,它使用float4x4矩阵缓冲区。而且我们可以直接复制矩阵,而不必在着色器中构造它。

分形的URP着色器图也是Point URP GPU视图的简化副本。顶点位置节点完全相同,只是我们现在必须依赖FractalGPU HLSL文件。而不是根据世界位置进行着色,反照率就可以使用单一的颜色属性。

(分形着色器视图)

DRP表面着色器也比等效的视图更简单。它需要一个不同的名称,包括正确的文件和反照率的新颜色属性。color属性的工作原理类似于平滑度,只是使用Color而不是范围和四分量默认值。即使不再需要它,我也将世界位置保留在Input结构中,因为不能编译空结构。

3.5 绘制

最后,要再次绘制分形,我们必须追踪Fractal中矩阵缓冲区的标识符。

然后在Update结束时,使用正确的缓冲区为每个级别调用一次Graphics.DrawMeshInstancedProcedural。我们将对所有级别简单地使用相同的边界:边长为3的立方体。

为什么使用3作为边界大小?

(只有最深的一级)

我们的分形再次出现,但看起来只渲染了最深的层次。但帧调试器将显示确实渲染了所有级别,但它们均错误地使用了上一级的矩阵。发生这种情况是因为draw命令排队等待稍后执行。因此,我们最后设置的缓冲区是被所有缓冲区使用的缓冲区。

解决方案是将每个缓冲区链接到特定的绘制命令。我们可以通过MaterialPropertyBlock对象来实现。如果尚不存在,请为其添加一个静态字段并在OnEnable中创建它的新实例。

在Update中,将缓冲区设置在属性块上,而不是直接在材质上。然后将该块作为附加参数传递给Graphics.DrawMeshInstancedProcedural。这将使Unity复制当时块所具有的配置,并将其用于该特定的draw命令,从而覆盖材质设置的内容。

为什么分形在场景窗口中闪烁? 这可能会在场景窗口中发生-至少在Mac上如此-但在游戏窗口或内部版本中不会发生。根据游戏编辑器的布局,为游戏窗口打开VSync可能会变得更好或更糟。这是与计时有关的编辑器错误,但我不知道确切原因。

3.6 性能

现在,我们的分形再次完成,让我们在渲染球体时再次测量其性能。

(分析一次URP构建,分形深度为6)

除了深度8外,Update持续时间有所增加,这是有道理的,因为这现在还包括将数据上传到GPU所花费的时间。但是帧速率有所提高。URP深度7几乎达到60FPS,尽管DRP仅超过30FPS。DRP在深度6处也再次表现较差。但是,当我们尝试使用立方体时,我们看到了显着的改进。

帧速率有了巨大的提高,RP均达到深度7的140FPS,深度8也均达到30FPS。更新时间也减少了。这可能是因为在渲染球体时设置缓冲区数据更加耗时,因为CPU被迫等待,直到GPU从缓冲区中读取完成。

3.7 使游戏对象移动

创建我们自己的转换矩阵的副作用是,我们的分形现在忽略了其游戏对象的转换。我们可以通过将游戏对象的旋转和位置合并到Update中的根对象矩阵中来解决此问题。

我们还可以应用游戏对象的比例。但是,如果游戏对象是包含不均匀缩放比例和旋转的复杂层次结构的一部分,则可能会受到非仿射变换的影响,导致其剪切。在这种情况下,它没有明确定义的比例尺。因此,Transform组件不具有简单的世界空间比例属性。相反,它们具有lossyScale属性,以指示它可能不是精确的仿射尺度。我们将简单地使用该比例的X分量,而忽略任何不均匀的比例。

同时将调整后的世界位置和比例应用于边界。

4 Job System

此时,我们的C#代码已经是它能达到的最快的了。对于深度8分形来说,这仍然还是问题,因为31毫秒的更新持续时间使其无法实现高帧速率。最大值约为32FPS,因此CPU是渲染立方体时的瓶颈。幸运的是,我们可以采用其他方法,即Unity的事务系统(Jobs System)。

Jobs System的思想是利用CPU的多核和特殊的SIMD指令(代表单指令多数据)来尽可能有效地利用CPU的并行处理能力。这是通过将工作定义为单独的片来实现的。这些Job的编写方式与常规C#代码类似,但是随后通过Unity的Burst编译器进行编译,该编译器通过执行常规C#所没有的一些结构性约束而实现了积极的优化和并行化。

4.1 Burst 包

Burst是作为单独的软件包存在的,因此请通过软件包管理器为Unity版本安装最新版本。就我而言,是Burst版本1.4.3。它依赖于Mathematics程序包,在我的案例中,该程序包也自动导入(版本1.2.1)。

要为Fractal创建Job,我们需要使用Unity.Burst,Unity.Collections和Unity.Jobs命名空间中的代码。

4.2 Native 数组

Job无法与对象一起使用,仅允许使用简单值和结构类型。它仍然可以使用数组,但是我们必须将它们转换为通用NativeArray类型。这是一个结构,它包含一个指向Native内存的指针,该指针位于我们的C#代码使用的常规托管内存堆之外。因此,它避免了默认的内存管理开销。

要创建分形部件的Native数组,我们需要使用NativeArray类型。当我们使用多个这样的数组时,我们真正需要的是数组。矩阵的多个数组也是如此。

现在,我们必须在OnEnable的开头创建Native数组的新数组。

并使用适当的NativeArray类型的构造方法(需要两个参数)为每个级别创建新的本机数组。第一个参数是数组的大小。第二个参数指示本机数组预期存在多长时间。由于我们每帧都使用相同的数组,因此我们必须使用Allocator.Persistent。

我们还必须在部件创建循环中更改变量类型以进行匹配。

并且在Update内部的循环中也是如此。

最后,就像compute buffers一样,在完成处理后,我们需要在OnDisable中显式释放其内存。我们通过在NativeArray上调用Dispose来实现。

此时分形仍然起作用。唯一的区别是我们现在使用的是NativeArray而不是托管C#数组。这可能会更糟,因为从托管C#代码访问本机数组会产生一些额外的开销。不过没关系,一旦使用Burst编译的Job,该开销将不存在。

4.3 Job 结构

要定义Job,我们需要创建一个实现Job接口的结构类型。实现一个接口就像继承一个类,但接口不继承现有功能,而是要求你自己包括特定功能。我们将在Fractal内部创建一个UpdateFractalLevelJob结构,该结构实现IJobFor,这是最灵活的作业接口类型。

为什么将接口命名为IJobFor? 约定是在所有接口类型前面加上一个I来表示接口,因此该接口名为JobFor并带有一个I前缀。这是一个Job接口,特别是用于在循环内部运行的功能的接口。

IJobFor接口要求我们添加一个具有整数参数且不返回任何内容的Execute方法。该参数表示for循环的迭代器变量。接口强制执行的所有操作都必须是公共的,因此此方法必须是公共的。

这个想法是Execute方法替换了我们Update方法的最内层循环的代码。为了使这项工作有效,需要将该代码所需的所有变量作为字段添加到UpdateFractalLevelJob。将它们公开,以便我们稍后进行设置。

我们可以更进一步,并使用ReadOnly和WriteOnly属性来指示我们只需要部分访问某些本机数组。最内层的循环仅从parents数组读取,而仅写入matrices数组。它既读取也不写入parts数组,这是默认假设,因此没有相应的属性。

如果多个进程并行修改同一数据,那么它将首先执行任意操作。如果两个进程设置相同的数组元素,则最后一个赢。如果一个进程获得与另一个进程相同的元素,则它将获得旧值或新值。最终结果取决于我们无法控制的确切时间,这可能导致行为不一致,很难检测和修复。这些现象称为竞态条件。ReadOnly属性表示该数据在作业执行期间保持不变,这意味着进程可以并行安全地从中读取数据,因为结果始终相同。

编译器强制该Job不写入ReadOnly数据,也不从WriteOnly数据读取。如果我们不小心这样做了,编译器将让我们知道我们犯了语义错误。

4.4 执行Jobs

Execute方法将替换我们Update方法的最内层循环。将相关代码复制到该方法中,并在需要时进行调整,以便它使用作业的字段和参数。

更改Update,以便我们创建一个新的UpdateFractalLevelJob值,并在级别循环中设置其所有字段。然后更改最里面的循环,以便它调用作业的Execute方法。这样,我们保留了完全相同的功能,但是代码已迁移到Job中。

但是我们不必每次迭代都显式调用Execute方法。我们可以安排Job,以便它自己执行循环。这是通过调用带有两个参数的Schedule来完成的。第一个是我们想要的迭代次数,它等于我们正在处理的parts数组的长度。第二个是JobHandle结构值,用于强制作业之间的顺序依赖性。最初,我们将使用默认值,该默认值不强制执行任何约束。

Schedule不会立即运行该作业,而只是安排它以供以后处理。它返回一个JobHandle值,该值可用于跟踪作业的进度。我们可以通过在句柄上调用Complete来延迟代码的进一步执行,直到作业完成为止。

4.5 调度

此时,我们正在计划并立即等待,直到完成每个级别的Job。结果是,即使我们已经切换到使用Job,我们的分形仍然像以前一样以顺序方式进行更新。我们可以通过延迟完成直到计划完所有Job之后再来执行。为此,我们使工作相互依赖,在计划时将最后一个工作句柄传递给下一个工作句柄。然后,我们在完成循环后调用Complete,这将触发整个作业序列的执行。

此时,我们不再需要将单个Job存储在变量中,只需要追踪最后一个句柄即可。

分析器将向我们展示Job最终可以在工作线程而不是主线程上运行。但它们也可能在主线程上运行,因为主线程需要等待Job完成,所以此时在哪里运行Job没有什么区别。

(分析构建,URP并且分形深度为8 主线程在等待工作线程结束)

将所有作业捆绑在一起以仅等待最后一个作业的完成,这样做的好处是可以延迟等待完成。一个常见的示例是在LateUpdate方法中安排Update中的所有作业,执行其他操作并延迟调用Complete,这是在所有常规Update方法完成后调用的。也可以将完成延迟到下一帧甚至更晚。但是我们不会那样做,因为我们需要完成每帧的工作,除了随后将矩阵上传到GPU外,别无其他事情。

4.6 Burst 编译器

经过所有这些更改之后,我们还没有看到任何性能上的改进。那是因为我们当前不使用Burst编译器。通过将BurstCompile属性附加到Unity,我们必须明确指示Unity使用Burst编译我们的Job结构。

(使用Burst编译器)

我们的Burst编译Job平均在9.56毫秒内完成深度8分形的工作。整个更新大约需要14毫秒。因此,仅通过启用Burst编译,我们的更新速度就提高了一倍以上。我们仍然仅使用单个CPU内核,因此加速完全是由于Burst应用的优化。

您可能会注意到,刚进入播放模式后,性能会差很多。之所以发生这种情况,是因为在编辑器中需要按顺序进行Burst编译,就像着色器编译一样。第一次运行作业时,它将由Burst编译,同时使用常规的C#编译版本运行该作业。Burst编译完成后,编辑器将切换到运行Burst版本。通过将BurstCompile属性的CompileSynchonously属性设置为true,我们可以强制编辑器在需要时立即编译作业的Burst版本(安装Unity直到完成编译)。可以通过在参数列表中包括它们的分配来设置属性的属性。

就像着色器编译一样,这不会影响构建,因为所有内容都是在构建过程中进行编译的。

4.7 Burst 检视器

你可以通过Burs Inspector窗口检查Burst生成的汇编代码,该窗口通过Jobs / Burst / Open Inspector ...打开。这向你显示了Burst为项目中所有作业生成的底层指令。我们的工作将作为Fractal.UpdateFractalLevelJob-(IForJob)包含在 Compile Targets 列表中。

我不会详细分析生成的代码,性能的提高已经说明一切。但是,切换到最右边的显示模式(.LVM IR优化诊断)有助于了解Burst的功能,这很有用。它当前包含以下对我有用的信息:

首先,这意味着Burst无法重写代码,因此无法使用SIMD指令合并多个迭代。最简单的示例是执行类似于data [i] = 2f * data [i]的工作。使用SIMD指令,Burst可以更改,以便可以一次对多个索引执行此操作,一次最多可以执行八个。这种方式的合并操作称为矢量化,因为单个值上的指令已替换为矢量上的指令。

当Burst指示不了解控制流时,表示存在复杂的条件块。我们没有这些,但是默认情况下启用了Burst安全检查,该检查会强制执行读/写属性并检测作业之间的其他依赖关系问题,例如尝试并行运行两个写入同一数组的问题。这些检查用于开发,会从构建中删除。我们也可以通过禁用Safety Checks开关来禁用它们,以使Burst检查器看到最终结果。你也可以通过 Jobs / Safety Checks 菜单针对每个作业或整个项目禁用它们。通常,除非要最大化编辑器性能,否则通常在编辑器中启用安全检查并在构建中测试性能。

如果不进行安全检查,Burst仍然无法向量化循环,这一次是因为调用指令阻碍了循环。这意味着存在Burst无法优化的方法调用,该方法调用永远无法向量化。

第二点则说明Burst找到了一种将多个独立操作向量化为单个SIMD指令的方法。例如,独立值的多个加法合并为单个向量加法。代价-3表示这有效地消除了三个指令。

SLP是什么意思? 它是超字级(superword-level)并行性的简写。

4.8 Mathematics 库

我们当前使用的代码并未针对Burst优化。Burst无法优化的调用指令对应于我们调用的静态Quaternion方法。Burst经过专门优化,可与Unity的数学库配合使用,该库在设计时考虑了矢量化。

数学库代码包含在Unity.Mathematics命名空间中。

这个库被设计成类似于着色器数学代码。目的是静态地使用Unity.Mathematics.math 类型,就像我们静态使用UnityEngine一样。视图的函数库中的Mathf。

但是,当尝试对float4x4和四元数类型调用某些方法时,这将导致冲突,因为数学中的方法与这些类型具有完全相同的名称。这将使编译器抱怨我们试图在方法上调用方法,这是不可以的。为了避免添加using语句来指示在我们写这些单词时,默认情况下应将它们解释为类型。这是通过使用,标签,赋值和完全限定的类型编写的。我们可以简单地使用标签的类型名称,当然也可以使用其他标签。

现在将Vector3的所有用法替换为float3,除了用于缩放Update中边界的矢量。我不会列出所有这些更改。然后还将所有Quaternion用法替换为quaternion。请注意,唯一的区别是数学类型不大写。之后,将所有Matrix4x4的用法替换为float4x4。

完成之后,用数学中的相应方法替换directions数组的vector direction属性。

我们还需要调整rotations数组的初始化。数学库使用弧度而不是度数,因此用0.5f * PI更改所有90f实例。除此之外,四元数还具有用于绕X,Y或Z轴创建旋转的单独方法,这些方法比通用的Euler方法更有效。

我们还必须将Update中的旋转角度增量也转换为弧度。

下一步是调整UpdateFractalLevelJob.Execute。首先用更快的RotateY变量替换Euler方法调用。然后将所有涉及四元数的乘法替换为mul方法的调用。最后,我们可以通过将scale作为单个参数调用math.float3方法来创建统一的比例向量。

以相同的方式在Update中调整根部件的更新代码,因此我们保持一致。

变换位置和旋转类型不正确吗? 确实,但是Vector3和float3类型之间以及四元数和四元数类型之间存在隐式转换。

此时,Burst检查器将不再有编译警告。但它仍然不能向量化循环,因为不能向量化返回类型。之所以如此,是因为我们的数据太大,无法向量化循环多次迭代。这一项不大,虽然我们使用数学库,但Burst仍可以向量化单个迭代中的许多操作,但Burst检查器没有提及这一点。

此时,对于一个深度为8的分形,更新现在平均每次构建需要5.5毫秒。因此,再次切换到数学会使更新速度提高一倍。通过将两个参数传递给BurstCompile构造函数方法,可以启用更多的Burst优化,从而使速度更快。这些是常规参数,因此必须在属性分配之前。

我们将对第一个参数使用FloatPrecision.Standard,对第二个参数使用FloatMode.Fast。快速模式允许Burst重新排序数学运算,例如将a + b c重写为b c + a。这可以提高性能,因为存在madd(乘加)指令比使用单独的add指令(后跟乘法)的速度更快。着色器编译器默认情况下会执行此操作。通常,重新排序操作在逻辑上没有什么区别,但是由于浮点数的限制,更改顺序会产生稍微不同的结果。你可以假设这些差异无关紧要,因此,除非有充分的理由,否则请始终启用此优化。

结果是更新持续时间进一步减少,平均降低到4.8ms。

FloatPrecision呢? FloatPrecision参数控制sin和cos方法的精度。我们不直接使用它们,而是在创建四元数时使用它们。降低三角精度可以加快速度,但就我的例子而言,并没有明显的区别。

4.9 发送更少的数据

我们的转换矩阵的最底行始终包含相同的向量:(0,0,0,1)。由于总是一样,我们可以将其丢弃,从而将矩阵数据的大小减少25%。这意味着更少的内存使用以及更少的数据从CPU到GPU的传输。

首先将所有对float4x4的用法替换为float3x4。然后在OnEnable中将计算缓冲区的步幅从16个浮点减小到12个浮点。

对于float3x4没有TRS方法,我们需要在Execute中自行组装矩阵。为此,首先创建一个用于旋转和缩放的3×3矩阵,然后通过旋转调用float3x3,然后将缩放因子分解为矩阵。通过调用带有四个列向量的float3x4来创建最终矩阵,四列向量是3×3矩阵的三列(存储在其c0,c1和c2字段中),然后是零件的位置。

对Update中的根部件执行相同的操作。

由于我们没有在float3x4类型上调用方法,因此与math.float3x4方法没有冲突,因此我们不需要使用using语句,也不需要float4x4。

最后,调整ConfigureProcedural,以便我们逐行复制矩阵,并添加缺少的矩阵。

进行此更改后,我的平均更新持续时间降至4.5毫秒。因此,我仅通过存储和传输较少的数据就获得了毫秒的收益。

4.10 使用多核

我们已经达到了单个CPU内核的优化终点,但是我们可以走得更远。在更新视图时,需要先更新所有父部件,然后再更新其子部件,因此我们无法摆脱工作之间的顺序依赖性。但是同一级别的所有部分都是独立的,可以以任何顺序更新,甚至可以并行更新。这意味着我们可以将单个作业的工作分散到多个CPU内核上。这是通过在Job而不是Scedule上调用ScheduleParallel来完成的。此方法需要一个新的第二个参数,该参数指示批次计数。首先将其设置为1,看看会发生什么。

(在多线程上运行)

现在,我们的工作分解了,并在多个CPU内核上运行,这些内核并行更新了我们的分形部分。就我而言,这将平均更新时间平均缩短为2ms。减少的数量取决于可用的CPU内核数,这受硬件限制以及有多少其他进程已声明线程。

批次计数控制如何将迭代分配给线程。每个线程循环执行一个批处理,执行一些记账,然后循环执行另一个批处理,直到完成工作。经验法则是,当Execute做很少的工作时,你应该尝试大量批处理;当Execute做很多工作时,你应该尝试少量批处理。在我们的情况下,Execute会做很多工作,因此批处理计数为1是合理的默认值。但是,当我们为每个部分分配五个子节点时,让我们尝试将批次数设为5。

这进一步将我的平均更新时间减少到1.7ms。使用较大的批处理数量并不能进一步改善,甚至使速度变慢,因此我将其保留为5。

4.11 最后的性能

现在,如果我们评估完全经过Burst优化的分形的性能,我们会发现更新持续时间已变得微不足道。GPU开始变为瓶颈。渲染球体时,没有得到比以前更高的帧频。但是,当渲染立方体时,两个RP都超过了100FPS,即使深度8分形也是如此。

这意味着有足够的空间来使我们的分形结构在计算上更加复杂,但这是另一个教程了。

欢迎扫描二维码,查看更多精彩内容。点击 阅读原文 可以跳转原教程。

本文翻译自 Jasper Flick的系列教程

原文地址:

https://catlikecoding.com/unity/tutorials

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-02-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 壹种念头 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档