使用 Unity* 进行并行处理的一种方法

项目理念

该项目的理念是展示如何使用 Unity* 对游戏进行并行处理,以及如何使用游戏引擎执行与游戏相关的物理。在这个领域内,现实感是成功的一个重要标志。为了模拟真实世界,许多动作需要同时发生,这需要并行处理。创建两个不同的应用,然后将它们与单个内核上运行的单线程应用进行比较。

第一个应用在多线程 CPU 上运行,第二个应用在 GPU 上执行物理计算。为了显示这些技术的结果,新开发的应用展示了使用群集算法创建的鱼群。

群集算法

多数群集算法依赖 3 大规则:


图 1.3 大群集规则描述(资料来源:http://www.red3d.com/cwr/boids/)。

什么是群集?

在本示例中,一个群集被定义为一群鱼。如果每条鱼与鱼群里的其他鱼都保持一定的距离,经过计算得出,该鱼在一个鱼群里“游动”。鱼群的成员只能以群集成员的身份行动,不得单独行动,它们共享相同的参数,如速度和方向。


图 2.包含 4 条鱼的鱼群。

复杂度

该算法的复杂度为 O(n2),其中,n 为鱼的数量。为了更新单条鱼的移动,算法需要查看环境中的所有 n 条鱼,以确定鱼是否可以:1) 留在鱼群;2) 离开鱼群;或者 3) 加入新鱼群。单条鱼可能单独“游”一段时间,并有机会加入新鱼群。这需要针对每条鱼执行 n 次。

算法如下所示:

For each fish (n).

Look at every other fish (n).

If this fish is close enough.

Apply rules: Cohesion, Alignment, Separation.

使用 C# 实施群集算法

为了将规则应用于每条鱼,创建了一个 Calc 函数,它需要一个参数:环境中现有鱼的索引。

数据被存储于两个缓冲区,以表示每条鱼的状态。交替使用这两个缓冲区进行读写。需要这两个缓冲区在内存中保存每条鱼之前的状态。然后,使用该信息计算每条鱼的下一个状态。每一帧前,读取当前读取缓冲,以更新场景。


图 3.功能流程框图。

鱼的状态

每条鱼的状态包括:

fishState {
	float speed;
	Vector3 position, forward;
	Quaternion rotation;
}


图 4.包含 fishState 的鱼。

forward 变量包含了鱼面对的方向。

rotation 变量是一个表示三维旋转的四元数,支持鱼旋转以面对目标方向。

群集算法

本项目使用的完整的群集算法为:

邻近状态

执行群集算法后,每条鱼都会被确认为鱼群成员或非鱼群成员。

将两条鱼之间的距离和每条鱼的前进方向用作参数,创建 neighbor 函数。这样做旨在使行为更逼真。如果两条鱼的距离足够小,并且沿着相同方向行进,它们有可能合并。但是,如果它们没有沿相同的方向移动,便不太可能合并。使用分段二次函数和前向矢量的点积创建该合并行为。


图 5.数学函数的表示。

两条鱼之间的距离必须小于最大距离,后者根据前向矢量的点积不断变化。

调用繁重的 Neighbor 函数前,需要调用另一个函数:Call。Call 函数会通知算法是否需要通过 Neighbor 函数来确定两条鱼是否足够接近,能进入同一个鱼群。Call 函数只检查这些元素(鱼)相对其 x 位置的位置。x 位置是首选位置,因为它拥有最宽的尺寸,支持鱼广泛分布。

状态更新

如果只有 1 条鱼,它会在特定速度范围内沿某个方向前进。如果鱼周围有同伴,它将调整自己的方向和速度,以适应鱼群的方向和速度。

速度总是以线性的方式变化,以提高平滑度。速度不会在没有过渡的情况下跳至另一级。

我们界定了一个环境。不允许鱼游到该环境的空间范围之外。如果鱼触碰了边界,将转向,并回到界定的环境中。


图 6.群集行为。

如果鱼将要游出边界,为鱼随机指定一个新的方向和速度,使其仍在界定环境以内。


图 7.边界行为。

检查鱼是否会撞上岩石也十分必要。该算法需要计算鱼的下一个位置是否在岩石内。如果是,鱼将以类似于避开边界的方式避开岩石。

一旦计算出状态,将指示所有鱼开始“游动”,并进行必要的旋转。然后,更新每条鱼的下一个状态,如方向、速度、位置和旋转变量(面向 n 条鱼)。该操作在每次更新帧时发生。

例如,为所有鱼的位置添加了方向,以确保鱼在环境内“游动”。

Unity* 内的集成

Unity 内的主要编程组件为 GameObject。您可以在 GameObject 内添加不同的元素,如待执行的脚本、碰撞器、纹理或材料,以自定义对象,使其按预想的方式运行。然后,可以在 C# 脚本内便捷地访问这些对象。脚本内的每个公共对象将在编辑器内创建一个字段,支持您放置满足要求的对象。

使用 C# 脚本创建群集行为。

将资产导入 Unity

  1. 单击 Assets,,单击 Import package,然后单击 Custom package。 
  2. 单击 All。
  3. 单击 Import。

接下来,将 Main 场景从 Project 选项卡拖放到 Hierarchy 选项卡。右键单击默认场景并选择“Remove Scene”。

运行应用所需的所有游戏对象和附带的脚本均可以随时运行。唯一缺少的部分是岩石模型,这个必须手动添加。

从 Unity 资产商店下载“Yughues Free Rocks”。可以在 Unity 内访问资产商店(或使用该链接:http://u3d.as/64P 访问)。下载完成后,将出现左侧的窗口。选择“Rock 01”并导入。

由于默认模型的规模太大,需要在使用岩石模型前进行调整。网格导入设置的缩放系数应重新调整为 0.0058。将岩石添加到场景后,如果其规模为 1,将匹配比例为 1 的三维球体,后者被用作面向对象的碰撞器。


图 13.分割矩阵阵列-计算变量。

计算变量:nbmat 表示应用使用的矩阵队列数量;rest 表示最后一个矩阵中鱼的数量。

更新每条鱼: 表示矩阵索引;j 表示当前数据块内鱼的索引。只有这样做,才能更新上述阵列内的正确矩阵。

其他特性

水下效果

为了创建多种水下效果,在 Unity 项目中添加了不同的资产。Unity 提供了多款内置软件包,包括本项目使用的水模型。还提供了适用于任意对象的多种纹理和相关材料(“皮肤”)。上述全部(甚至更多)资产可在 Unity 资产商店中获取。

焦散-光反射与阴影

在该项目的水下场景中添加了焦散光照效果。焦散需要一个“投影器”(projector,一种 Unity 对象)。投影器显示场景中的焦散效果。通过假设特定的频率(Hz)改变投射的焦散,提供一种焦散正在移动的效果。

模糊

在水下场景中添加了模糊效果。如果摄像头位于水平面以下,将启用并显示渐进模糊。场景的背景将变为蓝色。默认背景为天空背景(天空盒)。此外,在 Unity 内启动了雾设置。(Microsoft Windows*, 光照,其他设置,已检查雾盒)。

移动摄像头

在摄像头对象中添加脚本,以支持使用键盘和鼠标在场景内移动。这种操作提供了类似于第一人称射击游戏的控制。可以使用方向键进行前进/后退、向左/向右扫射。鼠标支持上/下移动,以及转动摄像头,以对准左侧/右侧。

transform.Rotate (0, rotX, 0);

move 变量表示方向键输入,rot* 表示鼠标方向。修改保留脚本的对象(在本示例中为摄像头)的转换,使其在场景中旋转与平移。

创建 .exe 文件

如前所述,在不修改源代码的情况下,可以构建一个 .exe 文件,以改变应用的参数。请按照以下步骤操作:

  1. 单击:Edit ,并单击  Project Settings ,然后单击  Quality。
  2. Quality 选项卡中,向下滚动至 Other,,并找到 V Sync Count
  3. V Sync Count 设置改为 “Don’t Sync”。这可能使应用显示 60fps 以上的帧速率。
  4. 单击:File ,然后单击  Build and Run,以创建 .exe 文件。

注:除了使用 Build and Run, 之外,您也可以访问 Build Settings ,以选择特定平台(如 Microsoft  Windows、Linux*、Mac* 等)。

编码差异:CPU 对比 GPU

CPU

面向单线程和多线程应用的编码之间只包含一个差异:Calc 函数的调用方式。在本示例中,Calc 函数对执行时间至关重要,因为每一帧它都被调用 n 次。

单线程

如下所示,通过一个“for 循环”以传统的方式完成面向单线程应用的编码:

多线程

使用“Parallel.For”类完成面向多线程应用的编码。Parallel.For 类的作用是分割多个函数调用,并在不同的线程中并行执行这些调用。每个线程包含需要执行的多个调用。当然,应用性能取决于 CPU 可用内核数量。

GPU

计算着色器

GPU 处理以类似于 CPU 多线程处理的方式完成。通过将流程繁杂的 Calc 函数迁移至 GPU(GPU 的内核数量多于 CPU),将更快地预测结果。为此,在 GPU上使用并执行“着色器”。着色器为场景添加图形效果。本项目使用了“计算着色器”。使用 HLSL(高级着色器语言)编写计算着色器。计算着色器复制 Calc 函数的行为(如速度、位置、方向等),无需计算旋转。

使用 Parallel.For 函数的 CPU 调用面向每条鱼的 UpdateStates 函数,以便在绘制每条鱼前,计算其旋转并创建 TRS 矩阵。使用“四元数”类的 Unity 函数 Slerp 计算鱼的旋转。

根据计算着色器调整代码

尽管主要思路是将 Calc 函数循环迁移至 GPU,仍需要考虑以下几点:随机数生成以及需要和 GPU 交换数据。

面向 CPU 的 Calc 函数与面向 GPU 的计算着色器之间最大的差异在于随机数生成。在 CPU 中,使用来自 Unity 随机类的对象生成随机数。计算着色器中使用的是 NVidia* DX10 SDK 函数。

需要在 CPU 和 GPU 之间交换数据。

某些应用参数(如鱼或岩石的数量)包含在浮点矢量或单个浮点中。例如,CPU 中来自 C# 的 Vector3 将匹配 GPU 上以 HLSL 编写的 float3 的内存映射。

读/写缓冲中的鱼状态数据(fishState)和 CPU 中第三个缓冲区上的岩石状态数据(s_Rock)必须在 GPU 上被定义为计算着色器的 3 个不同的 ComputeBuffer。例如,CPU 上的四元数匹配 GPU 上 float4 的内存映射。(四元数是一种包含 4 个浮点的结构。)读/写缓冲在 GPU 上的计算着色器中被声明为 RWStructureBuffer <State>。在 CPU 上描述岩石的结构与之类似,使用浮点表示每块岩石的大小,使用包含 3 个浮点的矢量表示每块岩石的位置。

CPU 上的 RunShader 函数创建了 ComputeBuffer 状态,并调用 GPU,使其在每帧开始时执行计算着色器。

<img data-fid="613900" data-cke-saved-src="/sites/default/files/managed/b7/7a/An-Approach-to-Parallel-Processing-with-Unity-code09_0.png” typeof=" src="/sites/default/files/managed/b7/7a/An-Approach-to-Parallel-Processing-with-Unity-code09_0.png” typeof=" foaf:image"="">

在 CPU 上创建 ComputeBuffer 状态后,对它们进行设置,以匹配 GPU 上相关的缓冲状态(例如,CPU 上的读取缓冲与 GPU 上的“readState”相关)。然后,使用鱼状态数据对两个空缓冲区进行初始化,执行计算着色器,使用与写入缓冲相关的 ComputeBuffer 中的数据更新写入缓冲。

在 CPU 上,Dispatch 函数设置并启动 GPU 上的线程。nbGroups 表示在 GPU 上执行的线程组数。在本示例中,每组包含 256 个线程(一个线程组所包含的线程数不能超过 1,024 个)。

在 GPU 上,“numthreads” 属性必须与 CPU 上建立的线程数一致。如下所示,“int index = 16*8*8/4”提供了 256 个线程。需要将每个线程的索引设置为每条鱼相应的索引,每个线程需要更新每条鱼的状态。

结果


图 14.鱼数量较少时的结果。

结果显示,当鱼的数量少于 500 条时,相比 GPU,单线程与多线程 CPU 的性能更高。这可能得益于在 CPU 与 GPU 之间逐帧完成的数据交换。

当鱼的数量达到 500 条时,相比多线程 CPU 和 GPU,单线程 CPU 的性能降低(单线程 CPU = 164fps 对比多线程 CPU = 295fps,GPU = 200fps)。当鱼的数量达到 1,500 条时,多线程 CPU 的性能降低(单线程 CPU = 23fps,多线程 CPU = 88fps 对比 GPU = 116fps)这可能是因为 GPU 拥有更多内核。

当鱼的数量大于等于 1500 条时,GPU 的性能在所有情况下均优于单线程和多线程 CPU。


图 15.鱼数量较多时的结果。

尽管在所有情况下,GPU 的性能优于两个 CPU 实例的性能,结果显示当鱼的数量为 1,500 条时,整体 GPU 性能最佳(116fps)。随着鱼数量的继续增加,GPU 性能随之下降。即便如此,当鱼的数量为 2000 条时,只有 GPU 的性能超过 60fps,鱼的数量为 2500 条时,GPU 的性能超过 30fps。当鱼的数量约为 6500 条时,GPU 最终降至 30fps 以下。

随着鱼数量的增加,GPU 性能下降的最可能的原因是算法复杂性。例如,当鱼的数量为 10,000 条时,每条鱼需要进行 10,0002 ,即 1 亿次迭代,以在每一帧中与其它鱼进行交互。

对应用进行分析后,需要强调应用内的几个关键点。用于计算每条鱼距离的函数非常繁重,点积导致 Neighbor 函数速度变慢。将 Neighbor 调用替换为两条鱼之间的距离(必须小于最大距离)将小幅提升性能。这意味着邻近的任意两条鱼现在将向同一个方向游动。

关注算法的 O(n2)复杂度是提升性能的另一种方法。面向鱼的替代整理算法可能会改进性能。

(假设两条鱼:f1 和 f2。面向 f1 调用 Calc 函数后,将针对 f2 计算 f1 的 Neighbor 状态。保存 Neighbor 状态值,以在稍后面向 f2 调用 Calc 函数时使用。)

该性能指标评测使用的硬件


图 16.运行测试所用的硬件。

有关编译器优化的更完整信息,请参阅优化通知