首页 统一教程

统一 工作系统和Burst编译器:入门

在本教程中,您将学习如何使用Unity的Job System和Burst编译器创建有效的代码来模拟充满游鱼的水。

4.9/5 9个评分

  • C#3.5,Unity 2019.3,Unity

在游戏中编写可伸缩的多线程代码一直很困难,但是随着Unity面向数据技术堆栈(DOTS)的发布,这种情况正在迅速改变。在本教程中,您将学习如何使用Unity的Job System和Burst编译器创建有效的代码来模拟充满游鱼的水。

您将获得以下主题的动手经验:

  • 将单线程代码变成高效的工作。
  • 使用Burst编译器加速您的项目。
  • 利用Unity的数学系统进行多线程处理。
  • 实时修改网格数据。
注意:本教程假定您了解Unity开发的基础知识。如果您不熟悉Unity,请查看此功能 统一入门 教程。

您需要一份 统一 2019.3 (或更高版本)安装在您的计算机上以遵循本教程。

入门

安装Unity后,通过单击 下载资料 本教程顶部或底部的按钮。

解压缩文件并打开 工作系统 Starter简介 统一中的项目。打开 RW 使用“项目”窗口并查看文件夹结构:

项目的文件夹结构

以下是每个文件夹包含的内容的快速细分:

  • 用料:用于水和鱼的材料。
  • 楷模:水和鱼的模型。
  • 预制件:鱼预制件,您将实例化数百次。
  • 场景主要场景,您将对其进行修改。
  • 剧本:入门脚本准备就绪,可以添加很棒的代码。

打开 主要场景 并查看“游戏”视图。您会看到一片空水。按 按钮,……什么也没发生。

空景

统计资料 游戏视图上的按钮,并注意 第一人称射击。 第一人称射击在很大程度上取决于您拥有的计算机。在整个教程中,您将使用它来对作业系统的性能进行基准测试。

到最后,您将在水面上波涛汹涌,里面有成千上万的鱼在游动。

这是一个细分 一帧:

  1. 该代码遍历水网格的10,000个顶点,并应用数学函数更改其高度。
  2. 1,000至2,000条鱼中的每条鱼都有一个随机的目的地和速度可以在水中游泳。
注意:请记住,并非所有问题都需要多线程。有时,使用不必要的线程时,代码运行速度会变慢。多线程还具有很多限制,您将在本教程中发现这些限制。

安装必需的软件包

在开始使用作业系统之前,必须从“软件包管理器”中安装一些软件包。选择 窗口▸包管理器 从顶部菜单。

从Manager安装软件包

在里面 包装经理, 选择 高级▸显示预览包 并安装以下内容:

  1. 工作系统
  2. 突发编译器
  3. 数学

在整个教程中,您将详细了解这些软件包的用途。

了解工作系统

那么,作业系统到底是什么?与仅编写普通的多线程代码有何不同?

总体而言,它使您可以在多个内核上运行进程 安全地 简单地说,无需担心比赛条件,僵局和其他经常出现的问题。

多线程注释

作业系统允许游戏使用计算机中的所有CPU内核。所有现代CPU都有多个内核,但是许多游戏却没有利用它们。将大型任务分成多个较小的块并并行运行时,可以同时运行而不是线性运行。这大大提高了性能。

统一的工作系统是他们更大的项目(称为 面向数据的技术栈(DOTS)。 DOTS从一开始就牢记性能。它包含作业系统,突发编译器和实体组件系统(ECS)。作业系统用于高度并行的代码。 ECS用于高效的内存管理,而Burst编译器用于高效的本机代码。

了解突发编译器

突发编译器 与作业系统完美配合。编译器的机制已经超出了本教程的范围,但是基本前提是它能够将C#代码编译为效率更高,性能更高的本机代码。

统一的整个脚本使用 单核细胞增多症。 单核细胞增多症是一个实现 。净 可以在Windows,Mac和PlayStation等多个系统上编译C#。不幸的是,能够在多个平台上执行代码的成本很高。托管C#将永远无法达到为特定平台设计的代码的性能。

他们的解决方案是Burst编译器,这是一种“数学感知”的编译器,根据平台的不同会生成高度优化的机器代码。这是一项非常复杂的技术,它利用了 LLVM项目。幸运的是,您所要做的就是添加一行或两行代码以从中受益。

您还安装了 统一数学 软件包,它只是Burst编译器用于低级优化的C#数学库。

设置波形发生器

第一步,您将创建波浪。您将使用阴影线框模式,以便可以看到网格中大量的顶点。

线框网格视图

了解Perlin噪声

要在网格物体上创建波浪,您将要采样一个值 佩林噪音 为每个顶点设置其高度。佩林噪声产生平滑,连续的随机高度,该高度随时间推移会产生类似波浪的特征。

这是一些静态Perlin噪音:

佩林噪声

您可以随时间改变和缩放此Perlin噪声:

修改后的Perlin噪声

设置波形发生器

打开 RW /脚本/WaveGenerator.cs and populate the file with the following 名称spaces to get started:

using 统一Engine.Jobs;
using 统一.Collections;
using 统一.Burst;
using 统一.Jobs;
using 统一.Mathematics;

统一.Collections package brings in 统一’s optimized version of System.Collections. 的 remaining packages came pre-installed from the 包装经理.

请注意以下代表Perlin噪声函数修饰符的变量:

[Header("Wave Parameters")]
public float waveScale; // 1
public float waveOffsetSpeed; // 2
public float waveHeight; // 3
  1. 波浪秤:缩放Perlin杂讯功能。
  2. 波偏速度:Perlin噪声随时间推移的速度。
  3. 波高:Perlin噪声的高度倍数。

不同的场景组件也具有自己的参考变量。

添加以下变量:

NativeArray<Vector3> waterVertices;
NativeArray<Vector3> waterNormals;

waterVertices and waterNormals are responsible for transporting the vertices and normals of the water mesh to and from the jobs.

NativeArray comes from the 统一.Collections 名称space. It’s a key component of sending and receiving information from jobs. A NativeArray is a child of the NativeContainer value type.

了解本机容器

NativeContainer includes the following subtypes, which are mostly modeled from types found within the System.Collections.Generic 名称space:

  • 本地列表: 一种 resizable NativeArray.
  • NativeHashMap:包含键值对。
  • NativeMultiHashMap:每个键包含多个值。
  • NativeQueue:先进先出队列。

So why would you use a NativeArray instead of a simple array?

最重要的是,它与 安全系统 在作业系统中实施:它跟踪读取和写入的内容,以确保线程安全。线程安全可以包括诸如确保两个作业不在同一时间写入内存中的同一点之类的事情。这是至关重要的,因为这些过程是并行进行的。

本机容器的限制

您不能传递对作业的引用,因为这会破坏作业的线程安全性。这意味着您无法以想要的数据作为参考发送数组。如果传递数组,则作业会将数组中的每个元素复制到作业中的新数组。这浪费了内存和性能。

更糟糕的是,您在作业数组中所做的任何更改都不会影响主线程上的数据。使用您在工作中计算出的结果毫无意义,这违反了使用工作的目的。

If you use a NativeContainer, its data is in native shared memory. 的 NativeContainer is simply a shared pointer to memory. This allows you to pass a pointer to the job, allowing you to access data within the main thread. Plus, copying the data of the NativeContainer won’t waste memory.

请记住,您可以传递浮点数,整数和所有原语 值类型 去工作。但是,你不能通过 参考类型 such as GameObjects. To get data out of a job, you have to use a NativeContainer data type.

初始化波形发生器

Add this initialization code into your Start():

waterMesh = waterMeshFilter.mesh; 

waterMesh.MarkDynamic(); // 1

waterVertices = 
new NativeArray<Vector3>(waterMesh.vertices, Allocator.Persistent); // 2

waterNormals = 
new NativeArray<Vector3>(waterMesh.normals, Allocator.Persistent);

以下是发生的情况的细分:

  1. You mark the waterMesh as dynamic so 统一 can optimize sending vertex changes from the CPU to the GPU.
  2. You initialize waterVertices with the vertices of the waterMesh. You also assign a 持久分配器.

的 most important concept here is the allocation type of NativeContainers. 的re are three primary allocation types:

  • 温度:专为寿命不超过一帧的分配而设计,它具有最快的分配。不允许在工作系统中使用。
  • 临时工作:旨在用于具有四帧寿命的分配,它提供的分配速度比 温度。小型工作使用它们。
  • 持久的:提供最慢的分配,但可以在程序的整个生命周期内持续使用。较长的作业可以使用此分配类型。

To update the vertices within the waterVertices throughout the lifetime of the program, you used the 持久分配器. This ensures that you don’t have to re-initialize the NativeArray each time the job finishes.

添加此方法:

private void OnDestroy()
{
    waterVertices.Dispose();
    waterNormals.Dispose();
}

NativeContainers 一定是 处置 within the lifetime of the allocation. Since you’re using the 持久分配器, it’s sufficient to call Dispose() on OnDestroy(). 统一 automatically runs OnDestroy() when the game finishes or the component gets destroyed.

将作业系统实施到波发生器中

现在,您进入真正有趣的东西:工作的创造!创建作业时,必须首先确定所需的类型。以下是一些核心工作类型:

  • 艾伯:标准作业,可以与您计划的所有其他作业并行运行。用于多个不相关的操作。
  • 艾伯ParallelFor:全部 并行 作业允许您在固定数量的迭代中对本机容器的每个元素执行相同的独立操作。 统一将自动将工作分割成定义大小的块。
  • 艾伯ParallelForTransform: 一种 并行 专用于转换的作业类型。

那么,您认为遍历网格中所有顶点并应用Perlin噪声函数的最佳作业类型是什么?

需要帮忙?打开下面的扰流板找出答案。

[剧透标题=“解决方案”]
你会 艾伯ParallelFor 界面,因为您要将相同的操作应用于大量元素。
[/扰流板]

设置工作

A job comes in the form of a struct. Add this empty job inside the scope of WaveGenerator.

private struct 更新网格作业 : 艾伯ParallelFor
{

}

Here, you’ve defined the 名称 of the job as 更新网格作业 and applied the 艾伯ParallelFor interface to it.

波发生器工作结构

Now, there’s a red underline in your IDE. This is because you haven’t implemented the method required for the 艾伯ParallelFor interface.

Apply the following code within the 更新网格作业:

public void Execute (int i)
{
           
}

Each type of job has its own Execute() actions. For 艾伯ParallelFor, Execute runs once for each element in the the array it loops through.

i tells you which 指数 the Execute() iterates on. You can then treat the body of Execute() as one iteration within a simple loop.

Before you fill out Execute(), add the following variables inside the 更新网格作业:

// 1
public NativeArray<Vector3> vertices;

// 2
[ReadOnly]
public NativeArray<Vector3> normals;

// 3
public float offsetSpeed;
public float scale;
public float height;

// 4
public float time;

是时候分解一下:

  1. This is a public NativeArray to read and write vertex data between the job and the main thread.
  2. [ReadOnly] tag tells the 工作系统 that you only want to read the data from the main thread.
  3. 这些变量控制Perlin噪声函数的作用。主线程将它们传入。
  4. 注意 that you cannot access statics such as Time.time within a job. Instead, you pass them in as variables during the job’s initialization.

编写工作功能

在结构中添加以下噪声采样代码:

private float Noise(float x, float y)
{
    float2 pos = math.float2(x, y);
    return noise.snoise(pos);
}

This is the 佩林噪音 function to sample 佩林噪音 given an x and a y parameter.

Now you have everything to fill out the Execute(), so add the following:

// 1
if (normals[i].z > 0f) 
{
    // 2
    var vertex = vertices[i]; 
    
    // 3
    float noiseValue = 
    Noise(vertex.x * scale + offsetSpeed * time, vertex.y * scale + 
    offsetSpeed * time); 
    
    // 4
    vertices[i] = 
    new Vector3(vertex.x , vertex.y, noiseValue * height + 0.3f); 
}

这是正在发生的事情:

  1. 您确保波仅影响面朝上的顶点。这不包括水基。
  2. 在这里,您可以获得对当前顶点的引用。
  3. 您可以使用缩放和偏移转换对Perlin噪声进行采样。
  4. Finally, you apply the value of the current vertex within the vertices.

安排工作

创建作业后,您需要运行它。团结有 概述 解决此问题的正确方法。他们的座右铭是:“提早安排,迟到”。这意味着,在确保完成工作并收集其值之前,计划作业并等待尽可能长的时间。

For you, this means schedule Update() and ensure its completion in LateUpdate(). This prevents the main thread from hanging while it waits for a job to complete.

Why would the main thread hang if it’s running in parallel? Well, you can’t retrieve the data inside a job until it completes. Before you do either, add these two variables to the top of WaveGenerator:

JobHandle meshModificationJobHandle; // 1
UpdateMeshJob meshModificationJob; // 2
  1. This JobHandle serves three primary functions:
    • 正确安排作业。
    • 让主线程等待作业完成。
    • 添加依赖项。依赖关系可确保一个作业仅在另一个作业完成后才开始。这样可以防止两个作业同时更改相同的数据。它细分了游戏的逻辑流程。
  2. Reference an 更新网格作业 so the entire class can access it.

Now, add the following within Update():

// 1
meshModificationJob = new 更新网格作业()
{
    vertices = waterVertices,
    normals = waterNormals,
    offsetSpeed = waveOffsetSpeed,
    time = Time.time,
    scale = waveScale,
    height = waveHeight
};

// 2
meshModificationJobHandle = 
meshModificationJob.Schedule(waterVertices.Length, 64);
  1. You initialize the 更新网格作业 with all the variables required for the job.
  2. 的 艾伯ParallelFor’s Schedule() requires the length of the loop and the 批量。批处理大小决定将工作划分为多少段。

完成工作

Calling Schedule puts the job into the job queue for execution at the appropriate time. Once scheduled, you cannot interrupt a job.

Now that you’ve scheduled the job, you need ensure its completion before assigning the vertices to the mesh. So, in LateUpdate(), add the following:

// 1
meshModificationJobHandle.Complete();

// 2
waterMesh.SetVertices(meshModificationJob.vertices);
        
// 3
waterMesh.RecalculateNormals();

这是此代码的作用:

  1. 确保作业完成,因为您无法在作业完成前获得内部顶点的结果。
  2. 统一使您可以直接从作业设置网格的顶点。这是一项新的改进,消除了在线程之间来回复制数据的麻烦。
  3. 您必须重新计算网格的法线,以使光照与变形的网格正确交互。

实施突发编译器

保存脚本并附加 水网过滤器 以及检查器上的波参数 水务经理.

设置变量

参数设置如下:

  • 波浪秤: 0.24
  • 波偏速度: 1.06
  • 波高: 0.16
  • 水网过滤器:从场景分配参考

享受美丽的海浪。当您在家看电视时,为什么还要去海滩呢?

不带爆发编译器的网格修改

恭喜,您已经使用“作业系统”创建了wave,并且它们毫不费力地运行。但是,缺少了一些东西:您尚未使用Burst编译器。

突发编译器属性

To implement it, include the following line, right above 更新网格作业:

[BurstCompile]

在所有作业之前放置属性可以使编译器在编译期间优化代码,从而充分利用新的数学库和Burst的其他优化功能。

的代码结构 WaveGenerator.cs 应该看起来像这样:

波形发生器代码结构

保存,然后播放场景并观察帧频:

使用Burst编译器修改网格

Burst编译器通过单行代码将帧速率从200提高到800。这可能在您的计算机上有所不同,但是应该有很大的改进。

此刻水看起来有点寂寞。是时候给它装些鱼了。

在水中造鱼

打开 RW /脚本/FishGenerator.cs and add the following 名称spaces:

using 统一.Jobs;
using 统一.Collections;
using 统一.Burst;
using 统一Engine.Jobs;

using math = 统一.Mathematics.math;
using random = 统一.Mathematics.Random;

Now that you have all the 名称spaces, add these additional variables into the class:

// 1
private NativeArray<Vector3> velocities;

// 2
private TransformAccessArray transformAccessArray;

那这些怎么办呢?

  1. velocities keep track of the velocity of each fish throughout the lifetime of the game, so that you can simulate continuous movement.
  2. You can’t have a NativeArray of transforms, as you can’t pass 参考类型 between threads. So, 统一 provides a TransformAccessArray,其中包含变换的值类型信息,包括其位置,旋转和矩阵。附加的优点是,您对 TransformAccessArray 将直接影响场景中的变换。

产卵

现在是繁殖金枪鱼的绝佳场所。

Add the following code in Start():

// 1
velocities = new NativeArray<Vector3>(amountOfFish, Allocator.Persistent);

// 2
transformAccessArray = new TransformAccessArray(amountOfFish);

for (int i = 0; i < amountOfFish; i++)
{

    float distanceX = 
    Random.Range(-spawnBounds.x / 2, spawnBounds.x / 2);

    float distanceZ = 
    Random.Range(-spawnBounds.z / 2, spawnBounds.z / 2);

    // 3
    Vector3 spawnPoint = 
    (transform.position + Vector3.up * spawnHeight) + new Vector3(distanceX, 0, distanceZ);

    // 4
    Transform t = 
    (Transform)Instantiate(objectPrefab, spawnPoint, 
    Quaternion.identity);
    
    // 5
    transformAccessArray.Add(t);
}

在此代码中,您:

  1. Initialize velocities with a 持久分配器 of size amountOfFish, which is a pre-declared variable.
  2. Initialize transformAccessArray with size amountOfFish.
  3. Create a random spawn point within spawnBounds.
  4. Instantiate objectPrefab, which is a fish, at spawnPoint with no rotation.
  5. Add the instantiated transform to transformAccessArray.

Make sure to add OnDestroy() to dispose of the NativeArrays:

private void OnDestroy()
{
        transformAccessArray.Dispose();
        velocities.Dispose();
}

保存并返回到Unity。然后像这样修改检查器中的参数:

设置鱼发生器参数

参数设置如下:

  • 鱼量 : 200
  • 产生边界:X:470,Y:47,Z:470
  • 产卵高度: 0
  • 游泳改变频率: 250
  • 游泳速度: 30
  • 转弯速度: 4.6

并注意水中200条随机散布的鱼:

随机产鱼

没有运动,看起来有点腥。现在是时候赋予鱼类一些生命并让它们移动的时候了。

创建运动工作

To move the fish, the code will loop through each transform within the transformAccessArray and modify its position and velocity.

This requires an 艾伯ParallelForTransform interface for the job, so add a job struct called PositionUpdateJob into the scope of FishGenerator:

[BurstCompile]
struct PositionUpdateJob : 艾伯ParallelForTransform
{
    public NativeArray<Vector3> objectVelocities;

    public Vector3 bounds;
    public Vector3 center;

    public float jobDeltaTime;
    public float time;
    public float swimSpeed;
    public float turnSpeed;
    public int swimChangeFrequency;

    public float seed;

    public void Execute (int i, TransformAccess transform)
    {

    }
}

注意 that you've already added the [BurstCompile] attribute, so you'll get the performance improvements that come with the compiler.

Execute() is also different. It now has an 指数 as well as access to the transform the job currently iterates on. Anything within that method will run once for every transform in transformAccessArray.

PositionUpdateJob also takes a couple of variables. 的 objectVelocities is the NativeArray that stores the velocities. 的 jobDeltaTime brings in Time.deltaTime. 的 other variables are the parameters that the main thread will set.

在下一步中,您将使每条鱼沿其速度方向移动并将其旋转以面向速度矢量。传递给作业的参数控制着鱼的速度。

Add the following code to Execute():

// 1
Vector3 currentVelocity = objectVelocities[i];

// 2            
random randomGen = new random((uint)(i * time + 1 + seed));

// 3
transform.position += 
transform.localToWorldMatrix.MultiplyVector(new Vector3(0, 0, 1)) * 
swimSpeed * 
jobDeltaTime * 
randomGen.NextFloat(0.3f, 1.0f);

// 4
if (currentVelocity != Vector3.zero)
{
    transform.rotation = 
    Quaternion.Lerp(transform.rotation, 
    Quaternion.LookRotation(currentVelocity), turnSpeed * jobDeltaTime);
}

这是此代码的作用:

  1. 设置鱼的当前速度。
  2. 使用Unity的数学库创建伪随机数生成器,该伪数生成器通过使用索引和系统时间来创建种子。
  3. Moves the transform along its local forward direction, using localToWorldMatrix.
  4. Rotates the transform in the direction of currentVelocity.

Now to prevent a fish-out-of-water experience, add the following after the code above in Execute():

Vector3 currentPosition = transform.position;

bool randomise = true;

// 1
if (currentPosition.x > center.x + bounds.x / 2 || 
    currentPosition.x < center.x - bounds.x/2 || 
    currentPosition.z > center.z + bounds.z / 2 || 
    currentPosition.z < center.z - bounds.z / 2)
{
    Vector3 internalPosition = new Vector3(center.x + 
    randomGen.NextFloat(-bounds.x / 2, bounds.x / 2)/1.3f, 
    0, 
    center.z + randomGen.NextFloat(-bounds.z / 2, bounds.z / 2)/1.3f);

    currentVelocity = (internalPosition- currentPosition).normalized;

    objectVelocities[i] = currentVelocity;

    transform.rotation = Quaternion.Lerp(transform.rotation, 
    Quaternion.LookRotation(currentVelocity), 
    turnSpeed * jobDeltaTime * 2);

    randomise = false;
}

// 2
if (randomise)
{
    if (randomGen.NextInt(0, swimChangeFrequency) <= 2)
    {
        objectVelocities[i] = new Vector3(randomGen.NextFloat(-1f, 1f), 
        0, randomGen.NextFloat(-1f, 1f));
    }
}

这是怎么回事:

  1. 您对照边界检查变换的位置。如果在外面,速度会向中心翻转。
  2. 如果变换在边界内,则方向改变的可能性很小,以使鱼具有更自然的运动。

这段代码非常数学化。它无法在单个线程上很好地扩展。

安排运动工作

To run PositionUpdateJob, you have to schedule it. Like before, you'll schedule the job on Update() and complete it on LateUpdate().

首先,将以下变量添加到类的顶部:

private PositionUpdateJob positionUpdateJob;

private JobHandle positionUpdateJobHandle;

This is a reference to the job and its handle, so you can access it throughout Update() and LateUpdate().

Place this code in the Update():

// 1
positionUpdateJob = new PositionUpdateJob()
{
    objectVelocities = velocities,
    jobDeltaTime = Time.deltaTime,
    swimSpeed = this.swimSpeed,
    turnSpeed = this.turnSpeed,
    time = Time.time,
    swimChangeFrequency = this.swimChangeFrequency,
    center = waterObject.position,
    bounds = spawnBounds,
    seed = System.DateTimeOffset.Now.Millisecond
};

// 2
positionUpdateJobHandle = positionUpdateJob.Schedule(transformAccessArray);

First, all the variables within the main thread set the job's data. seed gets the current millisecond from the system time to ensure a different seed for each call.

Secondly, you schedule positionUpdateJob. 注意 that each job type has its own Schedule() parameters. A 艾伯ParallelForTransform takes a TransformAccessArray.

Finally, add this into LateUpdate():

 positionUpdateJobHandle.Complete(); 

这样可以确保在进入下一个更新周期之前完成作业。

的结构 FishGenerator.cs 应该看起来像这样:

鱼发生器代码结构

现在,保存文件并输入Unity。按 看着那些鱼走!

鱼在水中游泳

尽管有200条游泳鱼给人留下了深刻的印象,但该系统的性能却要好得多。为了娱乐,将鱼的数量增加到5,000,进行一点压力测试:

5,000鱼压力测试

5,000条鱼在模拟水中游泳,并且仍以200 第一人称射击的速度运行。显然,工作系统给人留下了深刻的印象。但是,Burst编译器在优化代码中起着主要作用。

检查探查器

作业系统管理跨多个内核的一组工作线程。探查器显示工作的分段。

探查器检查

注意如何有多个工作线程并行运行脚本,从而减少了过程的持续时间。

然后去哪儿?

使用下载完整的项目 下载资料 本教程顶部或底部的按钮。

在本教程中,您学习了如何:

  • 使用作业系统进行多线程循环。
  • 实现Burst编译器。
  • 修改多个线程上的变换的属性。

这个项目仅仅是个开始。还有更多可以添加的内容。我很想知道您的想法!对鱼类实施ECS将是优化此游戏的下一步。

您喜欢本教程吗?想了解更多?看看我们的书 统一游戏按教程,其中提供了有关使用Unity制作游戏的更多信息。

如果您想了解有关Unity工作系统的更多信息,请查看 什么是工作系统? 由Unity。

您还将在官方网站中找到有用的信息 作业系统手册.

如果您有任何建议,问题或想要炫耀您为改进该项目所做的工作,请加入下面的讨论。

平均评分

4.9/5

为此内容添加评分

9个评分

更像这样

贡献者

评论