1. 程式人生 > >談談Unity實體元件ECS與Jobs System

談談Unity實體元件ECS與Jobs System

Unity2018版本提供了ECS和Jobs System功能,網上也有很多這方面的技術介紹,本篇部落格從Unity架構優化的角度給讀者介紹關於ECS和Jobs System的使用,結合著實際案例希望讓讀者更容易理解它們,尤其是在IT遊戲行業工作了兩年以上的開發者,更應該掌握一些架構技術。
Unity 實體元件系統和 C# Job System 是兩個不同的系統,但它們密不可分,若要了解這兩個系統,我們先看看在 Unity 場景中建立遊戲物件的工作流程如下所示:

  • 建立一個GameObject物件;
  • 在物件上新增元件:Renderer,Collider,Rigidbody physics;
  • 建立 MonoBehaviour 指令碼並將其新增到物件中,以便在執行時控制和更改這些元件的狀態屬性;
    以上三個步驟執行,我們稱為Unity的執行流程,作為Unity開發者來說,這個是最基本的流程。但是這種做法有它自己的缺點和效能問題。比如資料和邏輯是緊密耦合的,這意味著程式碼重用的頻率較低,因為邏輯與特定資料相關聯,無法單獨分離出來。
    例如下圖所示的 GameObject 和 Components 示例中,GameObject 依賴於 Transform、Renderer、Rigidbody 和 Collider 引用,在這些指令碼中引用的物件分散在堆記憶體中。
    這裡寫圖片描述

    遊戲物件、其行為及其元件之間的記憶體引用,看下圖:
    這裡寫圖片描述
    Unity GameObject 場景可以讓遊戲在非常短的時間內完成原型構建並執行,這個也是Unity的特色可以讓開發者快速入手,但它對於效能來說不太理想。我們再深層次的探討這個問題,每個引用型別都包含可能不需要訪問的許多額外資料,這些未使用的成員也佔用了處理器快取中的寶貴空間。比如我們繼承的Mono就是一個典型的案例,如果只需要現有元件的極少功能介面函式或者變數,則可以將其餘部分視為浪費空間,如下面的“浪費空間”圖所示:
    這裡寫圖片描述
    在上圖中,粗體表示實際用於移動操作的成員,其餘的就是浪費空間,若要移動 GameObject,指令碼需要從 Transform 元件訪問位置和旋轉資料成員。當硬體從記憶體中獲取資料時,快取行中會填充許多可能無用的資料,如果只是為所有應該移動的GameObjects 設定一個只有位置和旋轉成員的陣列,這將能夠在很短的時間內執行,如何去掉無用的資料?ECS就是為解決此問題而設計的。
  • ECS實體元件系統
    Unity 的新實體元件系統可幫助消除低效的物件引用,我們考慮只包含它所需資料的實體,而不考慮帶自己元件集合的GameObjects 。
    在下面的實體元件系統中,請注意 Bullet 實體沒有附加Transform 或 Rigidbody 元件,Bullet 實體只是顯式執行更新所需的原始資料,藉助這個新系統,您可以將邏輯與各個物件型別完全分離。
    這裡寫圖片描述
    這個系統具有很大的優勢:它不僅可以提高快取效率,縮短訪問時間;它還支援在需要使用這種資料對齊方式的現代 CPU 中採用先進技術(自動向量化/SIMD)這為遊戲提供了所需的預設效能。如下圖所示:
    這裡寫圖片描述
    這裡寫圖片描述
    上圖請注意快取行儲存中的碎片和繼承Mono系統生成的空間浪費,資料對比如下所示:
    這裡寫圖片描述


    上圖是將與單個移動操作相關的記憶體空間與實現相同目標的兩個操作進行對比的結果。

  • C# Jobs System
    大多數使用多執行緒程式碼的人都知道編寫執行緒安全程式碼很難,執行緒爭搶資源可能會發生,但機會非常少,如果程式設計師沒有想到這個問題,可能會導致潛在的嚴重錯誤。除此之外,上下文切換的成本很高,因此學習如何平衡工作負載以儘可能高效地執行是很困難的。新的 Unity C# Jobs System為您解決所有這些難題,如下圖所示:
    這裡寫圖片描述
    我們來看一下簡單的子彈運動系統,大多數遊戲程式設計師都會為 GameObject 編寫一個管理器,如 Bullet Manager 中所示,通常,這些管理器會管理一個 GameObjects 列表,並每幀更新場景中所有子彈活動的位置。這非常符合使用 C# Jobs System的條件,由於子彈運動可以單獨處理,因此非常適合並行化,藉助 C# Jobs System,可以輕鬆地將此功能拉出來,並行執行不同的資料塊,作為開發人員,您只需要專注於遊戲邏輯程式碼即可。再介紹介紹實體元件系統和C# Jobs System二者的結合。

實體元件系統和 C# Jobs System的結合可以提供更強大的功能,由於實體元件系統以高效、緊湊的方式設定資料,因此Jobs System可以拆分資料陣列,以便可以高效地並行操作。但我如何使用這個新系統?
通過一個案例給讀者介紹,下面是我們設計的遊戲執行方式:

  • 玩家敲擊空格鍵並在該幀中產生一定數量的船隻。
  • 生成的每個船隻都設定為螢幕邊界內的隨機 X 座標。
  • 生成的每個船隻都有一個移動功能,可將其傳送到螢幕底部。
  • 一旦超過底部界限,生成的每個船隻將重置其位置。
    這是一個比較簡單的遊戲邏輯,我們以此為例給讀者介紹幾種實現方式:
  • 繼承Mono的設計
    這個是最常用的設計,作為遊戲開發者,最容易掌握的,只需要檢查每幀的空格鍵輸入並觸發 AddShips() 方法,這種方法在螢幕的左側和右側之間找到隨機 X/Z 位置,將船的旋轉角度設定為指向下方,並在該位置生成船隻預製體。
void Update()
{
    if (Input.GetKeyDown("space"))
        AddShips(enemyShipIncremement);
}

void AddShips(int amount)
{
    for (int i = 0; i < amount; i++)
    {
        float xVal = Random.Range(leftBound, rightBound);
        float zVal = Random.Range(0f, 10f);

        Vector3 pos = new Vector3(xVal, 0f, zVal + topBound);
        Quaternion rot = Quaternion.Euler(0f, 180f, 0f);

        var obj = Instantiate(enemyShipPrefab, pos, rot) as GameObject;
    }
}

這裡寫圖片描述
船隻物件生成,其每個元件都在堆記憶體中建立,附加的移動指令碼每幀更新位置,確保保持在螢幕的底部和頂部邊界之間,超級簡單!

using UnityEngine;

namespace Shooter.Classic
{
    public class Movement : MonoBehaviour
    {
        void Update()
        {
            Vector3 pos = transform.position;
            pos += transform.forward * GameManager.GM.enemySpeed * Time.deltaTime;

            if (pos.z < GameManager.GM.bottomBound)
                pos.z = GameManager.GM.topBound;

            transform.position = pos;
        }
    }
}

下圖顯示了分析器一次在螢幕上跟蹤 16,500 個物件。不錯,但我們可以做得更好!繼續給讀者分析。
這裡寫圖片描述

這裡寫圖片描述
我們檢視 BehaviorUpdate() 方法,可以看到完成所有的行為更新需要 8.67 毫秒,另請注意,這一切都在主執行緒上進行,在 C# Jobs System中,該工作將分配到所有可用CPU上執行。我們測試時還是要充分利用Unity提供的工具分析方法的可行性是否影響效率?是由有優化的空間等等。
下面再看一種實現方式,在上述方案的基礎上加入Jobs System。

  • 繼承Mono的Jobs System
    上述方法編寫指令碼簡單,作為剛入門的開發者是可以這麼做的,但是作為工作幾年的開發者如果還這樣做就有問題了,我們要繼續學習架構設計嘍,先看程式碼實現:
using Unity.Jobs;
using UnityEngine;
using UnityEngine.Jobs;

namespace Shooter.JobSystem
{
    [ComputeJobOptimization]
    public struct MovementJob : IJobParallelForTransform
    {
        public float moveSpeed;
        public float topBound;
        public float bottomBound;
        public float deltaTime;

        public void Execute(int index, TransformAccess transform)
        {
            Vector3 pos = transform.position;
            pos += moveSpeed * deltaTime * (transform.rotation * new Vector3(0f, 0f, 1f));

            if (pos.z < bottomBound)
                pos.z = topBound;

            transform.position = pos;
        }
    }
}

我們的新 MovementJob 指令碼是一個實現 IJob 介面的結構,對於每個船隻的移動和邊界檢查計算,需要計算移動速度、上限、下限和 增量時間 值。該Jobs System沒有增量時間的概念,因此必須明確提供資料,新位置的計算邏輯本身與繼承Mono相同,但是將資料分配回Transform變換必須通過 TransformAccess 引數進行更新,因為引用型別(如 Transform)在此處無效。如上面程式碼中的 IJobParallelForTransform 執行 Execute 方法,可以將此結構傳遞到 Job Scheduler 中,在此處,系統將完成所有執行和相應邏輯執行。
為了瞭解關於這一任務結構的更多資訊,我們來分析一下它使用的介面:IJob | ParallelFor | Transform,IJob 是所有 IJob 變體繼承的基本介面,Parallel For Loop 是一種並行模式,它基本上採用的是單執行緒進行迴圈,並根據在不同CPU中操作的索引範圍將主體拆分為塊。最後,Transform 表示要執行的 Execute 函式將包含 TransformAcess引數,用於將移動資料提供給外部 Transform引用,考慮在常規 for 迴圈中迭代的 800 個元素的陣列,如果有一個 8 核系統並且每個CPU可以自動完成 100 個實體的工作,將會如何?這正是該系統要做的。
這裡寫圖片描述
介面名稱末尾的 Transform 關鍵詞為我們的 Execute 方法提供了 TransformAccess 引數,現在,只需知道針對每個 Execute 呼叫,每個船隻的個別轉換資料都會被傳入,現在我們來看看遊戲管理器中的 AddShips() 和 Update() 方法,瞭解每幀如何設定這些資料。

using UnityEngine;
using UnityEngine.Jobs;

namespace Shooter.JobSystem
{
    public class GameManager : MonoBehaviour
    {

        // ...
        // GameManager classic members
        // ...

        TransformAccessArray transforms;
        MovementJob moveJob;
        JobHandle moveHandle;


        // ...
        // GameManager code
        // ...
    }
}

我們需要跟蹤一些新變數:
TransformAccessArray 是資料容器,它將儲存對每個船隻 Transform (job-ready TransformAccess) 的修改,普通的 Transform 資料型別不是執行緒安全的,用於為GameObjects設定移動相關資料。
MovementJob 是我們剛剛建立的Jobs結構的一個例項,我們將使用它在Jobs System中配置工作,JobHandle 是任務的唯一識別符號,用於為各種操作(例如驗證完成)引用的任務,當安排工作時,您將收到任務的控制代碼。

void Update()
{
    moveHandle.Complete();

    if (Input.GetKeyDown("space"))
        AddShips(enemyShipIncremement);

    moveJob = new MovementJob()
    {
        moveSpeed = enemySpeed,
        topBound = topBound,
        bottomBound = bottomBound,
        deltaTime = Time.deltaTime
    };

    moveHandle = moveJob.Schedule(transforms);

    JobHandle.ScheduleBatchedJobs();
}

void AddShips(int amount)
{
    moveHandle.Complete();

    transforms.capacity = transforms.length + amount;

    for (int i = 0; i < amount; i++)
    {
        float xVal = Random.Range(leftBound, rightBound);
        float zVal = Random.Range(0f, 10f);

        Vector3 pos = new Vector3(xVal, 0f, zVal + topBound);
        Quaternion rot = Quaternion.Euler(0f, 180f, 0f);

        var obj = Instantiate(enemyShipPrefab, pos, rot) as GameObject;

        transforms.Add(obj.transform);
    }
}

我們要確保它完成並重新安排每幀的新資料,上面的moveHandle.Complete() 行可確保主執行緒在計劃任務完成之前不會繼續執行,使用此Job控制代碼,可以準備並再次分派Job,返回 moveHandle.Complete() 後,可以使用當前幀的新資料更新我們的 MovementJob,然後安排Job再次執行。雖然這是一個阻止操作,但它會阻止安排Job,同時仍執行舊Job。此外,它還會阻止我們在船隻集合仍在迭代時新增新船隻,在一個有很多Job的系統中,出於該原因,我們可能不想使用 Complete() 方法。

當在 Update() 結束時呼叫 MovementJob 時,還會向其傳遞需要從船隻更新的所有Transform的列表,通過 TransformAccessArray 訪問,當所有Job都完成設定和計劃後,可以使用 JobHandle.ScheduleBatchedJobs() 方法排程所有Jobs。
AddShips() 方法類似於之前的Execute,但有一些小的區別,如果從其他地方呼叫該方法,它會仔細檢查Job是否已完成,這應該不會發生,但小心不出大錯!此外,它還儲存了對 TransformAccessArray 成員中新生成的Transform的引用。讓我們看看Job效能如何。
這裡寫圖片描述
通過使用 C# Job System,我們可以在相同的幀時間(約 33 毫秒)內將繼承Mono中的螢幕物件數量增加近一倍。
這裡寫圖片描述
現在可以看到,Movement 和 UpdateBoundingVolumes 作業每幀大約需要 4 毫秒,這有大幅改進!另請注意,螢幕上的船隻數量幾乎是繼承Mono的兩倍!
但是,我們仍然可以做得更好,目前的方法仍然存在一些限制:

  • GameObject 例項化是一個冗長的過程,涉及系統呼叫記憶體分配。
  • Transforms 仍然分配在堆中的隨機位置。
  • Transforms 仍包含未使用的資料並降低記憶體訪問效率。
  • C# Jobs System
    這個問題有一些複雜,但是一旦明白了,就會永遠掌握這個知識,我們先來看看我們的新敵艦預製體如何解決這個問題:
    這裡寫圖片描述
    你可能會注意到一些新的東西,首先,除了 Transform 元件(未使用)之外,沒有附加的內建 Unity 元件,這一預製件現在代表我們將用於生成實體的模板,而不是帶元件的 GameObject 。預製體的概念並不像習慣的那樣完全適用於新系統,可以將其視為儲存實體資料的便捷容器,這一切都可以完全在指令碼中完成,現在還有一個附加到預製體的 GameObjectEntity.cs 指令碼,這一必需元件表示此 GameObject 將被視為實體並使用新的實體元件系統,可以看到,物件現在也包含一個 RotationComponent、一個PositionComponent 和一個 MoveSpeedComponent。標準組件(如位置和旋轉)是內建的,不需要顯式建立,但 MoveSpeed 需要,除此之外,我們有一個MeshInstanceRendererComponent,它向公共成員公開了一個支援 GPU 例項化的材質參考,這是新實體元件系統所必需的,讓我們看看這些如何與新系統相結合。
using System;
using Unity.Entities;

namespace Shooter.ECS
{
    [Serializable]
    public struct MoveSpeed : IComponentData
    {
        public float Value;
    }

    public class MoveSpeedComponent : ComponentDataWrapper<MoveSpeed> { }
}

當開啟其中一個數據指令碼時,您會看到每個結構都繼承自 IComponentData,這將資料標記為實體元件系統要使用和跟蹤的型別,並允許在後臺以智慧方式分配和打包資料,同時可以完全專注於遊戲程式碼。ComponentDataWrapper 類允許將這些資料公開到其附加的預製體的檢視窗,可以看到與此預製體關聯的資料僅表示基本移動(位置和旋轉)和移動速度所需的 Transform 元件的一部分,將不會在這一新工作流程中使用 Transform 元件。讓我們看看 GameplayManager 指令碼的新版本:

using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;

namespace Shooter.ECS
{
    public class GameManager : MonoBehaviour
    {
        EntityManager manager;


        void Start()
        {
            manager = World.Active.GetOrCreateManager<EntityManager>();
            AddShips(enemyShipCount);
        }

        void Update()
        {
            if (Input.GetKeyDown("space"))
                AddShips(enemyShipIncremement);
        }

        void AddShips(int amount)
        {
            NativeArray<Entity> entities = new NativeArray<Entity>(amount, Allocator.Temp);
            manager.Instantiate(enemyShipPrefab, entities);

            for (int i = 0; i < amount; i++)
            {
                float xVal = Random.Range(leftBound, rightBound);
                float zVal = Random.Range(0f, 10f);
                manager.SetComponentData(entities[i], new Position { Value = new float3(xVal, 0f, topBound + zVal) });
                manager.SetComponentData(entities[i], new Rotation { Value = new quaternion(0, 1, 0, 0) });
                manager.SetComponentData(entities[i], new MoveSpeed { Value = enemySpeed });
            }
            entities.Dispose();
        }
    }
}

我們做了一些改變,以使實體元件系統能夠使用指令碼,請注意,現在有一個 EntityManager 變數,可以將此視為建立、更新或銷燬實體的渠道,還需要注意到,用船隻數量構建的NativeArray 型別將生成,管理器的例項化方法採用 GameObject 引數和指定例項化實體數量的 NativeArray 設定,傳入的 GameObject 必須包含前面提到的 GameObjectEntity 指令碼以及所需的任何元件資料。EntityManager 會根據 預製體上的資料元件建立實體,而從未實際建立或使用任何 GameObjects,建立實體後,遍歷所有實體並設定每個新例項的起始資料,此示例會設定起始位置、旋轉和移動速度,完成後,必須釋放安全且強大的新資料容器,以防止記憶體洩漏。

using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;

namespace Shooter.ECS
{
    public class MovementSystem : JobComponentSystem
    {
        [ComputeJobOptimization]
        struct MovementJob : IJobProcessComponentData<Position, Rotation, MoveSpeed>
        {
            public float topBound;
            public float bottomBound;
            public float deltaTime;

            public void Execute(ref Position position, [ReadOnly] ref Rotation rotation, [ReadOnly] ref MoveSpeed speed)
            {
                float3 value = position.Value;

                value += deltaTime * speed.Value * math.forward(rotation.Value);

                if (value.z < bottomBound)
                    value.z = topBound;

                position.Value = value;
            }
        }

        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            MovementJob moveJob = new MovementJob
            {
                topBound = GameManager.GM.topBound,
                bottomBound = GameManager.GM.bottomBound,
                deltaTime = Time.deltaTime
            };

            JobHandle moveHandle = moveJob.Schedule(this, 64, inputDeps);

            return moveHandle;
        }
    }
}

設定實體後,您可以將所有相關的移動工作隔離到新的 MovementSystem,我們從示例程式碼的頂部到底部來介紹每個新概念:
MovementSystem 類繼承自 JobComponentSystem,這個基類為您提供了實施所需的回撥函式,如 OnUpdate(),以確保與系統相關的所有程式碼保持獨立,可以在這個簡潔的軟體包中執行系統特定更新,而不是擁有 uber-GameplayManager.cs,JobComponentSystem 的理念是將包含的所有資料和生命週期管理儲存在一個地方。

MovementJob 結構封裝了Job所需的所有資訊,包括通過 Execute 函式中的引數輸入的每個例項資料以及通過 OnUpdate() 更新的成員變數的每個Job資料。請注意,除 position 引數之外,所有每個例項資料都標有 [ReadOnly] 屬性,這是因為在這個例子中我們只更新每幀的位置。每個船隻實體的 旋轉 和 移動速度在其生命週期內都是固定的,實際的 Execute 函式包含對所有必需資料進行操作的程式碼。

您可能想知道如何將所有位置、旋轉和移動速度資料輸入到 Execute 函式呼叫中,這些操作會在後臺自動進行,實體元件系統非常智慧,能夠針對包含 IComponentData 型別(指定為 IJobProcessComponentData 的模板引數)的所有實體自動過濾和注入資料。

using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;

namespace Shooter.ECS
{
    public class MovementSystem : JobComponentSystem
    {

        // ...
        // Movement Job
        // ...

        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            MovementJob moveJob = new MovementJob
            {
                topBound = GameManager.GM.topBound,
                bottomBound = GameManager.GM.bottomBound,
                deltaTime = Time.deltaTime
            };

            JobHandle moveHandle = moveJob.Schedule(this, 64, inputDeps);

            return moveHandle;
        }
    }
}

下面的 OnUpdate() 方法 MovementJob 也是新方法,這是 JobComponentSystem 提供的一個虛擬功能,因此可以在同一個指令碼中更輕鬆地組織每幀設定和排程,這裡所做的一切都是:

  • 設定 MovementJob 資料,使用新注入的 ComponentDataArrays (每個實體例項資料)
  • 設定每幀資料(時間和邊界)
  • 排程任務

我們的Job已經設定並且完全獨立,在首次例項化包含這一特定資料元件組的實體之前,不會呼叫OnUpdate() 函式。如果決定新增一些具有相同移動行為的小行星,那麼只需要 GameObject 新增這三個相同的元件指令碼(包含您例項化的代表性 GameObject 上的資料型別)即可。這裡要知道的重要一點是:MovementSystem 並不關心它正在執行的實體是什麼,它只關心實體是否包含它關注的資料型別,還有一些機制可以幫助控制生命週期。
這裡寫圖片描述
以約 33 毫秒的相同幀時間執行,我們現在可以使用實體元件系統在螢幕上一次擁有 91,000 個物件。
這裡寫圖片描述
由於不依賴於Mono,實體元件系統可以使用可用的 CPU 時間來跟蹤和更新更多物件。
正如在上面的分析器視窗中看到的那樣,因為我們完全繞過了之前的 TransformArrayAccess 管道,並直接更新了 MovementJob 中的位置和旋轉資訊,然後顯式構建了我們自己的渲染矩陣,這意味著無需寫回傳統的 Transform 元件。

  • 結論
    花幾天時間研究所有這些新概念,它們將為後續的專案帶來好處,我們的新專案也準備使用Unity2018.2,前期先做點測試看看執行效能,總之,新系統的測試效果還是不錯的。最後再給讀者看一組資料,如下圖所示,優化帶來了大幅改進,如螢幕上支援的物件數量和更新成本。

    這裡寫圖片描述

  • 參考: