Unity——實體組件ECS與Jobs System

原文鏈接:https://blog.csdn.net/jxw167/article/details/81776399

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,前期先做點測試看看運行性能,總之,新系統的測試效果還是不錯的。最後再給讀者看一組數據,如下圖所示,優化帶來了大幅改進,如屏幕上支持的對象數量和更新成本。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章