洞明 Unity ECS 基礎概念

貓冬發表於2019-07-14

(因為懶,本文的更新將只發布到 原文地址 。)

雖然網路上已經有不少 ECS 文件的漢化,但自己讀官方文件的時候也會產生不少疑問,為此通過查詢各種資料,寫下本文。

本文從 ECS 官方文件出發,加之記憶體佈局結構的講解,力求讀者能夠和博主一起吃透 ECS 中的基本概念。同時建議讀者可以先讀讀我的上一篇博文《Unity DOTS 走馬觀花》的 ECS 部分,本文不再複述前文已經提到過的相關概念。

ECS 與 Job System

我認為有必要重申其兩者的關係。

  • Job System 能幫我們方便地寫出執行緒安全的多執行緒程式碼,其中每個任務單元稱為 Job。
  • ECS,又稱實體元件系統。與傳統的物件導向程式設計相比,ECS 是一種基於資料設計的程式設計模式。前文從記憶體結構分析了 OOP 模式的缺點,也提到了 ECS 是怎麼樣基於資料的設計記憶體結構的。

Job System 是 Unity 自帶的庫,而要使用 ECS 我們需要從 Package Manager 中安裝 "Entities" 預覽包。這兩者雖說完全是兩種東西,但是他們能很好地相輔相成:ECS 保證資料線性地排列在記憶體中,這樣通過更高效的資料讀取,能有效提升 Job 的執行速度,同時也給了 Burst 編譯器更多優化的機會。

Entities(實體)

World中, EntityManager 管理所有實體和元件。

當你需要建立實體和為其新增元件的時候, EntityManager會一直跟蹤所有獨立的元件組合(也就是原型 Archetype)。

建立實體

最簡單的方法就是在編輯器直接掛一個 ConvertToEntity 指令碼,在執行時中把 GameObject 轉成實體。

在編輯器中掛指令碼,GameObject 會在執行時中轉成實體

在編輯器中掛指令碼,GameObject 會在執行時中轉成實體

指令碼中,你也可以建立系統(System)並在一個 Job 中建立多個實體,也可以通過 EntityManager.CreateEntity 方法來一次生成大量 Entity。

我們可以通過下面四種方法來建立一個實體:

  • ComponentType 陣列建立一個帶元件的實體
  • EntityArchetype 建立一個帶元件的實體
  • 用 Instantiate 複製一個已存在的實體和其當前的資料,
  • 建立一個空的實體然後再為其新增元件

也可以通過下面的方法一次性建立多個實體:

  • CreateEntity 來建立相同原型(archetype)的實體並填滿一個 NativeArray (要多少實體就提前設定好 NativeArray 的長度)
  • Instantiate 來複制一個已存在的實體並填滿一個 NativeArray
  • CreateChunk 來顯式建立記憶體塊(Chunks),並且填入自定數量的給定原型的實體

增加和移除元件

實體被建立之後,我們可以增加和移除其元件。當我們這樣做的時候,相關聯的原型(Archetype)將會被改變, EntityManager 也需要改變記憶體佈局,將受影響的資料移到新的記憶體塊(new Chunk of memory),同時也會壓縮原來記憶體塊中的元件陣列。

對實體的修改會帶來記憶體結構的改變。

實體的修改包括:

  • 增加和移除元件
  • 改變 SharedComponentData的值
  • 增加和刪除實體

這些操作都不能放到 Job 中執行,因為這些都會改變記憶體中的資料結構。因此我們需要用到命令(Commands)來儲存這些操作,將這些操作存到 EntityCommandBuffer 中,然後在 Job 完成後再依次執行 EntityCommandBuffer 中儲存的操作。

World(世界)

每一個 World 包含一個 EntityManager 和一系列的 ComponentSystem。一個世界中的實體、原型、系統等都不能被另外一個世界訪問到。你可以建立很多 World ,例如通常我們會使用或建立一個負責主要邏輯運算的 simulation World 和負責圖形渲染的 rendering World 或 presentation World

當我們點選執行按鈕進入 Play Mode 時,Unity 會預設建立一個 World,並且增加專案中所有可用的 ComponentSystem。我們也可以關閉預設的 World 從而自己建立一個。

  • Default World creation code (see file: Packages/com.unity.entities/Unity.Entities.Hybrid/Injection/DefaultWorldInitialization.cs)
  • Automatic bootstrap entry point (see file:Packages/com.unity.entities/Unity.Entities.Hybrid/Injection/AutomaticWorldBootstrap.cs)

Components(元件)

ECS 中的元件是一種結構,可以通過實現下列介面來實現:

  • IComponentData
  • ISharedComponentData
  • ISystemStateComponentData
  • ISharedSystemStateComponentData

EntityManager 會組織所有實體中獨立的的元件組合成不同的原型(Archetypes),還會將擁有同樣原型的所有實體的元件(資料)儲存到一起,都放到同一個記憶體塊(Chunks)中。

如果你為一個實體新增了一個元件,那麼其原型就改變了,實體的資料也需要從原來的記憶體塊移到新的記憶體塊,因為只有相同原型的實體資料才會放到相同的記憶體塊中。

一個原型由很多個記憶體塊組成,這些記憶體塊中存的都是擁有相同原型的實體。

General Purpose Component(普通用途元件)

這裡指的是最普通的元件,可以通過實現 IComponentData 介面來建立。

IComponentData 不儲存行為,只儲存資料。IComponentData 還是一個結構體(Struct)而不是一個類(Class),這意味著被複制時預設是通過值而不是通過引用。

通常我們會用下面的模式來修改元件資料:

var transform = group.transform[index]; // Read

transform.heading = playerInput.move; // Modify
transform.position += deltaTime * playerInput.move * settings.playerMoveSpeed;

group.transform[index] = transform; // Write

IComponentData 結構不包含託管物件(managed objects)的引用,所有IComponentData 被存在無垃圾回收的塊記憶體(chunk memory)中。

你可能還聽過一種元件是不包含資料、只用來標記的“Tag”元件(Tag component),其用途也很廣,例如我們可以輕易地給實體加標記來區分玩家和敵人,這樣系統中能更容易通過元件的型別來篩選我們想要的實體。如果我們給一個記憶體塊(Chunk)中的所有實體都新增"Tag“元件的話,只有記憶體塊中對應的原型會修改,不新增資料,因此官方也推薦利用好”Tag“元件。

See file: /Packages/com.unity.entities/Unity.Entities/IComponentData.cs.

Shared components(共享元件)

Shared components 是一種特殊的元件,你可以把某些特殊的需要共享的值放到 shared component 中,從而在實體中與其他元件劃分開。例如有時候我們的實體需要共享一套材質,我們可以為需要共享的材質建立 Rendering.RenderMesh,再放到 shared components 中。原型中也可以定義 shared components,這一點和其他元件是一樣的。

[System.Serializable]
public struct RenderMesh : ISharedComponentData
{
    public Mesh                 mesh;
    public Material             material;

    public ShadowCastingMode    castShadows;
    public bool                 receiveShadows;
}

當你為一個實體新增一個 shared components 時, EntityManager 會把所有帶有同樣 shared components 的實體放到一個同樣的記憶體塊中(Chunks)。shared components 允許我們的系統去一併處理相似的(有同樣 shared components 的)實體。

記憶體結構

每個記憶體塊(Chunk)會有一個存放 shared components 索引的陣列。這句話包含了幾個要點:

  1. 對於實體來說,有同樣 SharedComponentData 的實體會被一起放到同樣的記憶體塊(Chunk)中。
  2. 如果我們有兩個儲存在同樣的記憶體塊中的兩個實體,它們有同樣的 SharedComponentData 型別和值。我們修改其中一個實體的 SharedComponentData 的值,這樣會導致這個實體會被移動到一個新的記憶體塊中,因為一個記憶體塊共享同一個陣列的 SharedComponentData 索引。事實上,從一個實體中增加或者移除一個元件,或者改變 shared components 的值都會導致這種操作的發生。
  3. 其索引儲存在記憶體塊而非實體中,因此 SharedComponentData 對實體來說是低開銷的。
  4. 因為記憶體塊只需要存其索引,SharedComponentData 的記憶體消耗幾乎可以忽略不計。

因為上面的第二個要點,我們不能濫用 shared components。濫用 shared components 將讓 Unity 不能利用好記憶體塊(Chunk),因此我們要避免新增不必要的資料或修改資料到 shared components 中。我們可以通過 Entity Debugger 來監測記憶體塊的利用。

拿上一段 RenderMesh 的例子來說,共享材質會更有效率,因為 shared components 有其自己的 manager 和雜湊表。其中 manager 帶有一個儲存 shared components 資料的自由列表(freelist),雜湊表可以快速地找到相應的值。記憶體塊裡面存的是索引陣列,需要找資料的時候就會從 Shared Component Manager 中找。

其他要點

  • EntityQuery 可以迭代所有擁有相同 SharedComponentData 的實體
  • 我們可以用 EntityQuery.SetFilter() 來迭代所有擁有某個特定 SharedComponentData 的實體。這種操作開銷十分低,因為 SetFilter 內部篩選的只是 int 的索引。前面說了每個記憶體塊都有一個SharedComponentData 索引陣列,因此對於每個記憶體塊來說,篩選(filtering)的消耗都是可以忽略不計的。
  • 怎麼樣獲取 SharedComponentData 的值呢?EntityManager.GetAllUniqueSharedComponentData<T> 可以得到在存活的實體中(alive entities)的所有的泛型 T 型別的SharedComponentData 值,結果以引數中的列表返回,你也可以通過其過載的方法獲得所有值的索引。其他獲取值的方法可以參考 /Packages/com.unity.entities/Unity.Entities/EntityManagerAccessComponentData.cs。
  • SharedComponentData 是自動引用計數的,例如在沒有任何記憶體塊擁有某個SharedComponentData 索引的時候,引用計數會置零,從而知道要刪除SharedComponentData 的資料 。這一點就能看出其在 ECS 的世界中是非常獨特的存在,想要深入瞭解可以看這篇文章《Everything about ISharedComponentData》
  • SharedComponentData 應該儘量不去更改,因為更改 SharedComponentData 會導致實體的元件資料需要複製到其他的記憶體塊中。

你也可以讀讀這篇更深入的文章《Everything about ISharedComponentData》

System state components(系統狀態元件)

SystemStateComponentData 允許你跟蹤系統(System)的資源,並允許你合適地建立和刪除某些資源,這些過程中不依賴獨立的回撥(individual callback)。

假設有一個網路同步 System State,其監控一個 Component A 的同步,則我只需要定義一個 SystemStateComponent SA。當 Entity [有 A,無 SA] 時,表示 A 剛新增,此時新增 SA。等到 Entity [無 A,有 SA] 時,表示 A 被刪除(嘗試銷燬Entity 時也會刪除 A)。 《淺入淺出Unity ECS》 BenzzZX

SystemStateComponentDataSystemStateSharedComponentData 這兩個型別與 ComponentDataSharedComponentData 十分相似,不同的是前者兩個型別都是系統級別的,不會在實體刪除的時候被刪除。

Motivation(誘因)

System state components 有這樣特殊的行為,是因為:

  • 系統可能需要保持一個基於 ComponentData 的內部狀態。例如已經被分配的資源。
  • 系統需要通過值來管理這些狀態,也需要管理其他系統所造成的的狀態改變。例如在元件中的值改變的時候,或者在相關元件被新增或者被刪除的時候。
  • “沒有回撥”是 ECS 設計規則的重要元素。

Concept(概念)

SystemStateComponentData 普遍用法是映象一個使用者元件,並提供內部狀態。

上面引用的網路同步的例子中,A 就是使用者分配的 ComponentData,SA 就是系統分配的 SystemComponentData

下面以 FooComponent (ComponentData)和 FooStateComponent(SystemComponentData)做主要用途的示例。前兩個用途已經在前面的網路同步例子中呈現過。

檢測元件的新增

如果使用者新增 FooComponent 時,FooStateComponent 還不存在。FooSystem 會在 update 中查詢,如果實體只有 FooComponent 而沒有 FooStateComponent,,則可以判斷這個實體是新新增的。這時候 FooSystem 會加上 FooStateComponent 元件和其他需要的內部狀態。

檢測元件的刪除

如果使用者刪除 FooComponent 後,FooStateComponent 仍然存在。FooSystem 會在 update 中查詢,如果實體沒有 FooComponent 而有 FooStateComponent,,則可以判斷 FooComponent 已經被刪除了。這時候 FooSystem 會給刪除 FooStateComponent 元件和修改其他需要的內部狀態。

監測實體的刪除

通常 DestroyEntity 這個方法可以用來:

  1. 找到所有由某個實體 ID 標記的所有元件
  2. 刪除那些元件
  3. 回收實體 ID 以作重用

然而,DestroyEntity 無法刪除 SystemStateComponentData

在你刪除實體時,EntityManager 不會移除任何 system state components,在它們沒被刪除的時候,EntityManager 也不會回收其實體的 ID 。這樣允許系統(System)在一個實體被刪除的時候,去整理內部的狀態(internal state),也能清理關聯著實體 ID 的相關的資源和狀態。實體 ID 只會在所有 SystemStateComponentData 被刪除的時候才被重用。

Dynamic Buffers(動態緩衝)

DynamicBuffer 也是元件的一種型別,它能把一個變數記憶體空間大小的彈性的緩衝(variable-sized, "stretchy" buffer)和一個實體關聯起來。它內部儲存著一定數量的元素,但如果內部所佔記憶體空間太大,會額外劃分一個堆記憶體(heap memory)來儲存。

動態緩衝的記憶體管理是全自動的。與 DynamicBuffer 關聯的記憶體由 EntityManager 來管理,這樣當DynamicBuffer 元件被刪除的時候,所關聯的堆記憶體空間也會自動釋放掉。

上面的解釋可能略顯蒼白,實際上 DynamicBuffer 可以看成一個有預設大小的陣列,其行為和效能都和 NativeArray(在 ECS 中常用的無 GC 容器型別)差不多,但是儲存資料超過預設大小也沒關係,上文提到了會建立一個堆記憶體來儲存多的資料。DynamicBuffer 可以通過 ToNativeArray 轉成 NativeArray 型別,其中只是把指標重新指向緩衝,不會複製資料。

【Unity】ECSで配列を格納する Dynamic Buffers 這篇文章中,作者用DynamicBuffer 來儲存臨近的圓柱體實體,從而更方便地與這些實體互動。

定義緩衝

// 8 指的是緩衝中預設元素的數量,例如這例子中存的是 Integer 型別
// 那麼 8 integers (32 bytes)就是緩衝的預設大小
// 64 位機器中則佔 16 bytes
[InternalBufferCapacity(8)]
public struct MyBufferElement : IBufferElementData
{
    // 下面的隱式轉換是可選的,這樣可以少寫些程式碼
    public static implicit operator int(MyBufferElement e) { return e.Value; }
    public static implicit operator MyBufferElement(int e) { return new MyBufferElement { Value = e }; }

    // 每個緩衝元素要儲存的值
    public int Value;
}

可能有點奇怪,我們要定義緩衝中元素的結構而不是 Buffer 緩衝本身,其實這樣在 ECS 中有兩個好處:

  1. 對於 float3 或者其他常見的值型別來說,這樣能支援多種 DynamicBuffer 。我們可以重用已有的緩衝元素的結構,來定義其他的 Buffers
  2. 我們可以將 Buffer 的元素型別包含在 EntityArchetypes 中,這樣它會表現得像擁有一個元件一樣。例如用 AddBuffer() 方法,可以通過 entityManager.AddBuffer<MyBufferElement>(entity); 來新增緩衝。

Systems(系統)

系統負責將元件資料從一個狀態(state)通過邏輯處理到下一個狀態。例如系統可以根據幀間隔和實體的速度,在當前幀更新所有移動實體的位置。

世界初始化後提供了三個系統組(system groups),分別是 initialization、simulation 和 presentation,它們會按順序在每幀中執行。

系統組的概念會在下文提到。

ComponentSystem(元件系統)

ComponentSystem 通常指 ECS 實體元件系統中最基本的概念 System,它提供要執行的操作給實體。

ComponentSystem 不能包含實體的資料。從傳統的開發模式來看,它與舊的 Component 類有點相似,不過 ComponentSystem 只包含方法

一個 ComponentSystem 負責更新所有匹配元件型別的實體。例如:系統可以通過條件過濾來獲得所有擁有 Player 標記(Tag)和位置(Translation)的實體,再對獲得的一系列 Player 實體進行處理。其中這種條件過濾由 EntityQuery 結構定義。

要注意的是,ComponentSystem 只在主執行緒中執行。

我們可以通過繼承 ComponentSystem 抽象類來定義我們的系統。

See file: /Packages/com.unity.entities/Unity.Entities/ComponentSystem.cs.

JobComponentSystem(任務元件系統)

前文提到了 ECS 能很好的和 JobSystem 一起合作,那麼這個型別就是一個很好的例子。ComponentSystem 只在主執行緒中執行,而 JobComponentSystem 則能在多執行緒中執行,更能利用多核的優勢。

自動化的 Job 依賴管理

JobComponentSystem 能幫我們自動管理依賴。原理很簡單,來自不同系統的 Job 可以並行地讀取相同型別的 IComponentData。如果其中一個 Job 正在寫(write)資料,那麼所有的 Job 就不能並行地執行,而是設定它們的依賴來安排執行順序。

public class RotationSpeedSystem : JobComponentSystem
{
    [BurstCompile]
    struct RotationSpeedRotation : IJobForEach<Rotation, RotationSpeed>
    {
        public float dt;

        public void Execute(ref Rotation rotation, [ReadOnly]ref RotationSpeed speed)
        {
            rotation.value = math.mul(math.normalize(rotation.value), quaternion.axisAngle(math.up(), speed.speed * dt));
        }
    }

    // 所有對 Rotation 讀/寫的和對 RotationSpeed 進行寫操作的
        // 已經排程的 Job 會自動放到 JobHandle 型別的依賴控制程式碼 inputDeps 中
        // 在方法中,我們也需要把自己的 Job 依賴加進控制程式碼中,並在方法末尾返回回來。
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var job = new RotationSpeedRotation() { dt = Time.deltaTime };
        return job.Schedule(this, inputDeps);
    } 
}

怎麼執行的?

所有 Jobs 和系統會宣告它們會讀/寫哪些元件型別(ComponentTypes)。JobComponentSystem 返回的 JobHandle 依賴控制程式碼會自動註冊到 EntityManager 中,以及所有包含讀或寫(reading or writing)資訊的型別中。

這樣如果一個系統對 Component A 進行寫操作而之後另一個系統會對其進行讀操作, JobComponentSystem 會查詢讀取(reading)的型別列表,然後傳給你一個依賴。依賴包含第一個系統返回的 JobHandle,也就是包含“一個系統對 Component A 進行寫操作”這個依賴,並將其作為第二個系統的引數傳入。

JobComponentSystem 簡單地按照需求維護一個依賴鏈,這樣不會對主執行緒造成影響。但是如果一個非 Job 的 ComponentSystem 要存取(access)相同的資料會怎麼樣呢?因為所有的存取都是宣告好的,因此對於所有 ComponentSystem 需要進行存取的元件型別(component type)相關聯的 Jobs,ComponentSystem 都會先自動完成這些相關的 Jobs,再在 OnUpdate 中呼叫依賴。

依賴管理是保守的(conservative)和確定性的(deterministic)

依賴管理是保守的。 ComponentSystem 只是簡單的跟蹤所有使用的 EntityQuery,然後基於 EntityQuery 儲存需要讀或寫的型別。

當在一個系統中分發多個 Jobs 的時候,依賴必須被髮送到所有 Jobs 中,即使不同的 Jobs 可能需要更少的依賴。如果這裡被證明有效能問題,那最好的解決方法是將系統一分為二。

依賴管理的手段也是保守的。它通過提供一個非常簡單的 API 來允許確定性和正確的行為。

Sync points(同步點)

所有結構性的變化都有確切的同步點(hard sync points)。 CreateEntityInstantiate、 Destroy、 AddComponent、 RemoveComponentSetSharedComponentData 都有一個確切的同步點。這代表所有通過 JobComponentSystem 排期的 Jobs 都會在建立實體之前自動完成。

例如,在一幀中間的 EntityManager.CreateEntity 可能帶來較大的停滯,因為所有在世界中的提前排期好的 Jobs 都需要完成。

如果要在遊戲中避免上面提到的停滯,可以使用 EntityCommandBuffer

Multiple Worlds(多個世界)

所有世界(World)都有自己的 EntityManager ,因此 JobHandle 依賴控制程式碼的集合都是分開的。一個世界中的確切的同步點(hard sync points)不會影響另外一個世界。因此,對於流式傳輸和程式化生成的場景,最後在一個世界中建立實體然後移到另一個世界作為一個事務(transaction)並在幀的開始執行。

對於上面的問題可以參考 ExclusiveEntityTransaction 和 System update order。

Entity Command Buffer(實體命令緩衝)

EntityCommandBuffer 解決了兩個重要問題:

  1. 在 Job 中無法訪問 EntityManager,因此不能通過它來管理實體。
  2. 當你使用 EntityManager 時(例如建立一個實體),你會使所有已被注入的陣列和 EntityQuery 無效。(這裡注入的概念大概是指:系統中可以設定某個過濾條件,給過濾條件加上 [inject] 後,系統會在啟動時為這個屬性根據條件注入資料,這樣就能得到我們想要的資料。會無效是因為你修改了實體資料,那麼結果可能會發生改變。)

EntityCommandBuffer 的抽象允許我們去把需要對資料的更改(changes)排好隊,這個更改可以來自主執行緒或者 Jobs,這樣資料可以晚一點在主執行緒接受更改,從而將其和獲取資料分離開來。

我們有兩種方法來使用 EntityCommandBuffer

  • 在主執行緒 update 的 ComponentSystem 子類有一個 PostUpdateCommands(其本身是一個EntityCommandBuffer ) 可以用,我們只要簡單地把變化按順序放進去即可。在系統的 Update 呼叫之後,它會立刻自動在世界(World)中進行所有資料更改。這樣可以防止陣列資料無效,API 也和 EntityManager 很相似。

    PostUpdateCommands.CreateEntity(TwoStickBootstrap.BasicEnemyArchetype);
    PostUpdateCommands.SetComponent(new Position2D { Value = spawnPosition });
    PostUpdateCommands.SetComponent(new Heading2D { Value = new float2(0.0f, -1.0f) });
    PostUpdateCommands.SetComponent(default(Enemy));
    PostUpdateCommands.SetComponent(new Health { Value = TwoStickBootstrap.Settings.enemyInitialHealth });
    PostUpdateCommands.SetComponent(new EnemyShootState { Cooldown = 0.5f });
    PostUpdateCommands.SetComponent(new MoveSpeed { speed = TwoStickBootstrap.Settings.enemySpeed });
    PostUpdateCommands.AddSharedComponent(TwoStickBootstrap.EnemyLook);
    
  • 對於 Jobs 來說,我們必須從主執行緒的 EntityCommandBufferSystem 中請求一個 EntityCommandBuffer,再傳到 Job 裡面讓其呼叫。 每當 EntityCommandBufferSystem 進行 update,命令緩衝都會在主執行緒中重新把更改按建立的順序執行一遍。這樣允許我們集中進行記憶體管理,也保證了建立的實體和元件的確定性。

Entity Command Buffer Systems(實體命令緩衝系統)

在一個系統組中,有一個 Entity Command Buffer Systems 執行在所有系統組之前,還有一個執行在所有系統組之後。比較建議的是我們可以用已存在的命令快取系統(command buffer system)之一,而不用建立自己的,這樣可以最小化同步點(sync point)。

在 ParallelFor jobs 中使用 EntityCommandBuffers

ParallelFor jobs 使用 EntityCommandBufferEntityManager 的命令(command)時, EntityCommandBuffer.Concurrent 介面能保證執行緒安全和確定性的回放(deterministic playback)。

// See file: /Packages/com.unity.entities/Unity.Entities/EntityCommandBuffer.cs.
public Entity CreateEntity(int jobIndex, EntityArchetype archetype = new EntityArchetype())
{
    ...
    m_Data->AddCreateCommand(chain, jobIndex, ECBCommand.CreateEntity,  index, archetype, kBatchableCommand);
    return new Entity {Index = index};
}

EntityCommandBuffer.Concurrent 的公共方法都會接受一個 jobIndex 引數,這樣能回放(playback)已經按順序儲存好的命令。 jobIndex 作為 ID 必須在每個 Job 中唯一。從效能考慮,jobIndex 必須是傳進 IJobParallelFor.Execute() 的不斷增長的 index。除非你真的知道你傳的是啥,否則最安全的做法就是把引數中的 index 作為 jobIndex 傳進去。用其他 jobIndex 可能會產生正確的結果,但是可能在某些情況下會有嚴重的效能影響。

namespace Unity.Jobs
{
  [JobProducerType(typeof (IJobParallelForExtensions.ParallelForJobStruct<>))]
  public interface IJobParallelFor
  {
    /// <summary>
    ///   <para>Implement this method to perform work against a specific iteration index.</para>
    /// </summary>
    /// <param name="index">The index of the Parallel for loop at which to perform work.</param>
    void Execute(int index);
  }
}

System Update Order(系統更新順序)

元件系統組(Component System Groups)其實是為了解決世界(World)中各種 update 的順序問題。一個系統組中包含了很多需要按照順序一起 update 的元件系統(component systems),可以來指定它成員系統(member system)的 update 順序。

和其他系統一樣, ComponentSystemGroup 也繼承自 ComponentSystemBase ,因此係統組可以當成一個大的“系統”,裡面也用 OnUpdate() 函式來更新系統。它也可以被指定更新的順序(在某個系統的之前或之後更新等,下文會講),並且也可以嵌入到其他系統組中。

預設情況下, ComponentSystemGroupOnUpdate() 方法會按照成員系統(member system)的順序來呼叫他們的 Update(),如果成員系統也是一個系統組,那麼這個系統組也會遞迴地更新它的成員系統。總體的系統遵循樹的深度優先遍歷。

// See file: /Packages/com.unity.entities/Unity.Entities/ComponentSystemGroup.cs.
protected override void OnUpdate()
{
    if (m_systemSortDirty)
        SortSystemUpdateList();

    foreach (var sys in m_systemsToUpdate)
    {
        try
        {
            sys.Update();
        }
        catch (Exception e)
        {
            Debug.LogException(e);
        }
        if (World.QuitUpdate)
            break;
    }
}

System Ordering Attributes(系統順序屬性)

  • [UpdateInGroup] 指定某個系統成為一個 ComponentSystemGroup 中的成員系統。如果沒有用這個屬性,這個系統會自動被新增到預設世界(default World)的 SimulationSystemGroup 中。
  • [UpdateBefore][UpdateAfter] 指定系統相對於其他系統的更新順序。這兩個系統必須在同一個系統組(system group)中,前文說到系統組也可以巢狀,因此只要兩個系統身處同一個根系統組即可。
    • 例子:如果 System A 在 Group A 中、System B 在 Group B 中,而且 Group A 和 Group B 都是 Group C 的成員系統,那麼 Group A 和 Group B 的相對順序也決定著 System A 和 System B 的相對順序,這時候就不需要明確地用屬性標明順序了。
  • [DisableAutoCreation] 阻止系統從預設的世界初始化中建立或新增到世界中。這時候我們需要顯式地建立和更新系統。然而我們也可以把這個系統和它的標記(tag)加到 ComponentSystemGroup 的更新列表中(update list),這樣這個系統會正常地自動更新。

Default System Groups(預設系統組)

預設世界(default World)包含 ComponentSystemGroup 例項的層次結構(hierarchy)。在 Unity Player Loop 中會新增三個根層次(root-level)的系統組。

下圖中開啟 Entity Debugger,也能看到這三個系統組和其順序。

這三個系統組各司其職, InitializationSystemGroup 做初始化工作, SimulationSystemGroup 在 Update 中做主要的邏輯運算, PresentationSystemGroup 做圖形渲染工作。

如果勾選 “Show Full Player Loop” 項,還能看到完整的遊戲主迴圈,以及系統組執行的順序。

下面列表也展示了預定義的系統組和其成員系統:

  • InitializationSystemGroup (在遊戲迴圈(Player Loop)的 Initialization 層最後 update)
    • BeginInitializationEntityCommandBufferSystem
    • CopyInitialTransformFromGameObjectSystem
    • SubSceneLiveLinkSystem
    • SubSceneStreamingSystem
    • EndInitializationEntityCommandBufferSystem
  • SimulationSystemGroup(在遊戲迴圈的 Update 層最後 update)
    • BeginSimulationEntityCommandBufferSystem
    • TransformSystemGroup
      • EndFrameParentSystem
      • CopyTransformFromGameObjectSystem
      • EndFrameTRSToLocalToWorldSystem
      • EndFrameTRSToLocalToParentSystem
      • EndFrameLocalToParentSystem
      • CopyTransformToGameObjectSystem
    • LateSimulationSystemGroup
    • EndSimulationEntityCommandBufferSystem
  • PresentationSystemGroup(在遊戲迴圈的 PreLateUpdate 層最後 update)
    • BeginPresentationEntityCommandBufferSystem
    • CreateMissingRenderBoundsFromMeshRenderer
    • RenderingSystemBootstrap
    • RenderBoundsUpdateSystem
    • RenderMeshSystem
    • LODGroupSystemV1
    • LodRequirementsUpdateSystem
    • EndPresentationEntityCommandBufferSystem

P.S. 內容可能在未來有更改

Multiple Worlds(多個世界)

前文多處提到預設的世界,實際上我們可以建立多個世界。同樣的元件系統(component system)的類可以在不同的世界中初始化,而且每個例項都可以處於不同的同步點以不同的速度進行update。

當前沒有方法手動更新一個世界中的所有系統,但是我們可以控制哪些系統被哪個世界控制,和它們要被加到哪個現存的世界中。自定義的世界可以通過實現 ICustomBootstrap 介面來建立。

Tips and Best Practices(提示與最佳實踐)

  • [UpdateInGroup] 為你的系統指定一個 ComponentSystemGroup 系統組。如果沒有用這個屬性,這個系統會自動被新增到預設世界(default World)的 SimulationSystemGroup 中。
  • 用手動更新迴圈(manually-ticked)的 ComponentSystemGroups 來 update 在主迴圈中的系統。新增 [DisableAutoCreation] 阻止系統從預設的世界初始化中建立或新增到世界中。這時候我們可以在主執行緒中呼叫 World.GetOrCreateSystem() 來建立系統,呼叫 MySystem.Update() 來 update 系統。如果你有一個系統要在幀中早點或者晚點執行,這種做法能更簡單地把系統插到主迴圈中。
  • 儘量使用已存在的 EntityCommandBufferSystem 而不是重新新增一個新的。因為一個 EntityCommandBufferSystem 代表一個主執行緒等待子執行緒完成的同步點(sync point),如果重用一個在每個根系統組(root-level system group)中預定義的 Begin/End 系統,就能節省多個同步點所帶來的額外時間間隔(可以回去看同步點小節的示意圖,同步點的位置是由最晚執行完的子執行緒所決定的)。
  • 避免放自定義的邏輯到 ComponentSystemGroup.OnUpdate() 中。雖然 ComponentSystemGroup 功能上和一個元件系統(component system)一樣,但是我們應該避免這麼做。因為它作為一個系統組,在外面不能馬上知道成員系統是否已經執行了 update,因此推薦的做法是隻讓系統組當一個組(group)來用,而把邏輯放到與其分離的元件系統中,再定好該系統與系統組的相對順序。

最後

自己才剛考完試,所以計劃的文章一直拖到現在。ECS 對我而言充滿著吸引力,可能有些程式設計師也會對效能特別執著吧,它就像魔法一樣,完全不同的開發模式,還需要我們深入瞭解記憶體的結構。儘管 ECS 可能在工作中對我是一種屠龍技,但有些知識啊,學了就已經很開心了~

我的畢業季也到來了,有空的話可能會寫寫 Demo 把剩下的實踐部分補完,當然計劃也可能擱淺。不管怎麼樣,希望本文對 ECS 同好有所幫助,有問題也歡迎在評論指出。

參考

相關文章