1. 程式人生 > 其它 >Job System and Burst Compiler

Job System and Burst Compiler

前言

本文章學習自這裡這是素材檔案的下載連結

多半內容都是文章內容加上自身理解,受限於時間以及自身能力水平,謹慎參考

首先需要3個Unity的官方包,請開啟Unity的預覽包選項,不然很多預覽階段的包你是無法在包管理器中看到的,具體如圖

隨後在Package Manager中安裝Jobs、Burst以及Mathematics

其中前兩個可以直接在包管理器中搜到,而數學庫Mathematics需要通過gir url安裝,具體如圖

在出現的搜尋欄中輸入[email protected]並點選Add就可以新增數學庫了

順帶一提,Burst似乎是隨著Jobs一起安裝的,目前好像不需要額外安裝了,具體關係我沒有研究,反正保證這三個包都裝上了就行

關於Job System

Jobs System(任務系統)在我看來就是一種安全的多執行緒使用方式,不過目前看來使用方法也比較侷限,正常情況下咱們PC端都是多核心的CPU了(這都2021年了),但是很多時候做遊戲寫C#指令碼很少會直接呼叫多執行緒,一是我沒那水平,二是多執行緒也容易翻車...

Jobs就是Unity官方提供的一種操作多執行緒的方式...雖然說必須得按照官方給的”模板“來寫程式碼,有點難受,但不論如何,用起來是很方便快捷的,也很安全...這就是我對Jobs的理解...

Starter

開啟Introduction to Job System Starter專案,並開啟主場景,是一個水面,本節的目的就是通過移動水面的頂點來製造波浪,這個模型的頂點還是不少的,如果通過傳統的遍歷頂點的方法,一個一個移動,那可想而知FPS肯定是非常之低的...

開啟指令碼WaveGenerator.cs

其中[Header("Wave Parameters")]下的3個引數用於生成隨機數,也就是隨機波浪,並不關鍵

而[Header("References and Prefabs")]下的引數則是對於波浪的引用,也不是關鍵,等下直接拖Prefab進去就行了

首先新增兩個引數

NativeArray<Vector3> waterVertices; // 頂點
NativeArray<Vector3> waterNormals;  // 法線

NativeArray是Job System所提供的的特殊容器型別,為什麼多執行緒不安全?一個原因就是各個執行緒之間同時訪問某個資料時,容易引起競爭,從而導致很多的BUG,而這個NativeArray型別的目的就是為了防止競爭導致的問題,Job System提供了以下幾種容器型別:

  • NativeList 一個可以調整長度的陣列(Job版List)
  • NativeHashMap Job版HasMap
  • NativeMultiHashMap 一個鍵可以對應多個值
  • NativeQueue Job版Queue

當兩個Job同時寫入某一個相同的NativeContainer時,系統會自動報錯,保證了執行緒安全

這裡還有2個點需要注意

一、不可以向Native容器中傳入引用型別的資料,那會導致執行緒安全問題,所以只能傳值型別

二、如果某一個Job執行緒只需要訪問Native容器,而不需要寫入資料,那需要將容器標記為[ReadOnly],下文會提到

在Start()中初始化

private void 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. 讓頂點能夠被動態修改,和Job無關

  2. 初始化Native容器,第一個引數是資料,第二個引數是分配方式,有3種分配方式,如下:

目前來看我還是喜歡第三種,畢竟生命週期長,隨時能用...前兩種分配方式的作用沒有搞明白...

在OnDestroy()中銷燬

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

就當做是必須操作就好...

實現Job System

Job本質是一個一個的Struct,每一個Struct就是一個小的Job,但Job必須實現自以下3個介面之一:

  • IJob,標準任務,可以和其他所有定義的Job並行工作
  • IJobParallelFor,並行任務,可以對某個Native容器中的所有資料並行的執行某個操作
  • IJobParallelForTransform,類似於第二種,但專門為Transform型別的Native容器工作

我們的目的是動態修改所有的頂點資料,那肯定是採用第二種方式了,頂點資料不涉及Transform哦!

定義如下結構體

private struct UpdateMeshJob : IJobParallelFor
{
        // 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;
        
        // 5
        private float Noise(float x, float y)
        {
            float2 pos = math.float2(x, y);
            return noise.snoise(pos);
        }

		// 6
        public void Execute(int i)
        {
            // 7
            if (normals[i].z > 0f) 
            {
                // 8
                var vertex = vertices[i]; 
    
                // 9
                float noiseValue = 
                    Noise(vertex.x * scale + offsetSpeed * time, vertex.y * scale + 
                                                                 offsetSpeed * time); 
    
                // 10
                vertices[i] = 
                    new Vector3(vertex.x , vertex.y, noiseValue * height + 0.3f); 
            }

        }
}

咱們一個個解析

  1. 定義一個原生容器,定義在結構體內部的容器是用於複製或者修改外部容器的資料的,此處的容器沒有定義為[ReadOnly]說明這個容器會修改某個容器的資料

  2. 同上,但注意這裡是[ReadOnly],說明這個容器只是用於訪問某個主執行緒上的資料

    P.S.這裡我的理解是,可以吧定義在類內的容器理解為主執行緒上的資料,定義在結構體內的容器理解為子執行緒上的資料,子執行緒想要訪問主執行緒的資料,只可以通過這樣的方式進行復制,而且如果兩個子執行緒同時在修改主執行緒上某個容器的資料,那麼Unity會報錯。

  3. 這些資料用於生成隨機數,但我們沒有初始化,因為這些資料也是可以通過外面傳入進來的,由於是值型別,所以無所謂,但如果你傳一個Transform進來就不行了

    P.S.這裡也有個疑惑,第一個容器需要定義為NativeArray可以理解,因為他會修改主執行緒的資料,但是第二個容器定義為了[ReadOnly]說明他只是訪問資料而不修改,那我們為什麼不定以為普通的List,然後從外面傳入呢?事實上按我的理解,這是可行的,但是!加入這個List有1000的長度,我們想從外面拷貝一個List進來,那就需要一個一個複製,這是很浪費空間的,而NativeArray本質是一個共享指標,NativeArray之間的複製是沒有消耗的!

  4. 不能傳Time.time進來,所以就用一個引數暫存了,也是用於生成隨機數的

  5. 生成隨機數的一個方法,很常見的噪聲,具體不贅述了,和Job無關

  6. 這個方法來自於介面IJobParallelFor,屬於必須實現的介面,咱們不是在上面傳入了一個NativeArray vertices嘛?這個原生陣列中包含了咱們水模型的所有頂點資訊,而我們的目標就是同時修改他的頂點資料,這個Execute(i)方法代表著我們將對每一個頂點所做的操作,其中i代表著這個頂點在陣列中的序號,看起來這個函式類似於一個For迴圈有木有?但For迴圈是一個一個往後執行的,而這個Execute方法會被Unity自動分配到多個執行緒中

  7. vertices是所有頂點的資訊,而normals則是所有頂點的法線資訊,通過這個i,我們很輕易的就能夠訪問到某個頂點的法線資訊,這個法線可能需要一點點圖形學知識,咱們不多說,這裡的意思就是說,如果某個頂點是朝上的,那麼我們才修改他的資訊,再說的通俗一點,咱們只修改水面的上一側,下側不管

  8. 獲取噪聲,與Job無關,就是個隨機數

  9. 修改頂點的資料

執行任務

上面只是定義好了任務,但還沒執行,我們想每一幀都修改資料,這樣才能形成連續的波浪!

先定義兩個引數

JobHandle meshModificationJobHandle; // 1
UpdateMeshJob meshModificationJob; // 2
  1. 控制代碼,比較抽象我也不知道該怎麼解釋,可以當做是某一個任務的當前資訊,擬執行了某個Job他就會返回一個控制代碼給你,裡面可以訪問當前這個Job的執行情況,多數情況下我們需要等一個Job執行完成,才執行下一個Job,控制代碼的作用就是等待
  2. 就是上面定義的那個Job結構體

在Update()中執行任務

private void Update()
{
        // 1
        meshModificationJob = new UpdateMeshJob()
        {
            vertices = waterVertices,
            normals = waterNormals,
            offsetSpeed = waveOffsetSpeed,
            time = Time.time,
            scale = waveScale,
            height = waveHeight
        };

        // 2
        meshModificationJobHandle = 
            meshModificationJob.Schedule(waterVertices.Length, 64);

}
  1. 初始化任務,其中的引數就是咱們定義好的各種引數
  2. 執行任務,有兩個引數,第一個引數的資料長度,第二個引數是並行長度,比如頂點數是128,咱們並行長度是64,那麼這個Job就會被劃分為2部分,分別在2個子執行緒中進行,這個解釋很模糊,大概是這個意思,著重在理解,表述可能是錯的,具體還得看官方給的執行緒圖,很難幾句話說清楚

等待任務結束

private void LateUpdate()
{
    // 1
    meshModificationJobHandle.Complete();

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

}
  1. 控制代碼的作用就是這個,表示在這裡需要等待上面Schedule()的Job結束,如果不結束我們怎麼能獲取新的頂點資料呢...
  2. 設定頂點資料
  3. 重新計演算法線

測試一下

設定好指令碼資料

點選執行,應該是有用的,此時檢視一下Status

這麼多的頂點還能有這個資料,也是挺喜人的,檢視一下工作管理員,發現所有核心確實是都在跑的

Burst

輕鬆一步,瞬間超神,具體原理我沒有研究,總之咱們現在給剛剛定義的Job加上一個標籤

然後再執行

直接超神!

IJobParallelForTransform

上面我們還提到過一種特殊的Job,專門針對於Transform型別,因為Transform型別是引用型別,所以必須通過特定的方法實現Job化

開啟指令碼FishGenerator.cs,這個指令碼會生成大量自由移動的小魚

首先新增兩個屬性

// 1
private NativeArray<Vector3> velocities;

// 2
private TransformAccessArray transformAccessArray;
  1. 移動速度,和上面提到的容器是一樣的,只不過儲存的是Vector3型別
  2. 專門針對於Transform的特殊容器,只能輸出Transform型別的資料,且是一個數組

在Start()中初始化

private void 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. 和上面沒啥區別,就是初始化容器,第一個引數是魚的數量,第二個引數是分配方式

  2. 也是初始化容器,不過初始化Transform容器和其他容器稍微有一點區別,只要寫好大小就行

    3.4. 生成隨機數罷了,和Job無關,目的是隨機生成魚的位置,隨便看看就行

  3. 把生成的隨機位置新增到容器中

同樣需要在OnDestroy()時銷燬容器

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

然後填寫好引數,測試一下

會發現隨機生成了很多小魚,但是是靜止的

建立小魚移動Job

[BurstCompile]
struct PositionUpdateJob : IJobParallelForTransform
{
    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)
    {
        // 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);
        }
        
        Vector3 currentPosition = transform.position;

        bool randomise = true;

        // 5
        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;
        }

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

    }
}

這個Job和上面那個波浪Job很像,區別是實現自介面IJobParallelForTransform,只有這個介面可以訪問Transform容器,同樣也有一個Execute方法需要實現,其中i代表容器內的序號,transform則是引用

具體演算法其實並不重要,主要目的就是讓小魚的Transform線性變化,如果不使用Job而在Update中寫,相信很多人隨便就能寫了

  1. 第i條小魚當前的速度
  2. 隨機種子,需要using random = Unity.Mathematics.Random;
  3. 隨機移動距離,會根據上面幾個指定的引數決定
  4. 旋轉
    1. 防止小魚移出水面的範圍

執行任務

同樣需要先建立一個控制代碼以及任務

private PositionUpdateJob positionUpdateJob;

private JobHandle positionUpdateJobHandle;

在Update()中初始化並執行

private void 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);

}
  1. 初始化任務,注意我們沒並有傳入一個Transform陣列
  2. 執行任務,此時可以傳入咱們的transformAccessArray

同樣在LateUpdate()中等待任務結束

private void LateUpdate()
{
    positionUpdateJobHandle.Complete(); 
}

點選執行,小魚就動了起來!