Unity ECS編程官方文檔選譯——Getting Started
譯註:Unity項目往往稍微大點就會有性能瓶頸。Unity 2018提出了面向數據編程模式的ECS系統,結合新的安全的多線程機制Job System來解決這個問題,來取代過去的單線程、面向對象的GameObject/MonoBehaviour模式。
一、前言
1,我們試圖解決什麼問題?
當用 GameObject/MonoBehaviour模式做應用時,很容易編寫代碼,但結果卻是難以閱讀、難以維護、難以優化。這是多種因素綜合導致的,包括:面向對象模式、由Mono編譯的非機器碼、垃圾回收和單線程編程。
2,使用Entity-Component-System(ECS)來拯救你的工程
ECS是一種編寫代碼的新方式,著重於你真正該解決的問題:構成你應用的數據(data)和行為(behavior)。
譯註:所謂的行為,具體來說就是方法。
除了從設計角度講這是種更好地編程方式之外,使用ECS還可讓你發揮Unity Job System和Burst編譯器的功力,充分利用當今的多核處理器。
我們發布了Unity原生的Job System,用戶可以使用它並結合ECS C#腳本來獲得多線程批處理的性能優勢。這套Job System內置了用於檢測線程競爭條件(race condition)的安全功能。
所以,我們需要引入一種新的思維和編碼方式,以充分發揮Job System的優勢。
二、什麼是ESC?
1,MonoBehavior —— 親切的老朋友
MonoBehaviours既包含數據也包含行為。一個進行旋轉的簡單例子:
class Rotator : MonoBehaviour{ //數據-可以在編輯器面板里編輯值 public float speed; //行為-從這個Coponent中讀取speed,然後根據它改變Transform的rotation void Update() { transform.rotation *= Quaternion.AxisAngle(Time.deltaTime * speed, Vector3.up); }}
但是,MonoBehaviour繼承了一大堆類;每個都包含了它自己的一大套數據——不管我們用不用得到。因此,我們無緣無故浪費了許多內存。所以,我們得想一想到底需要哪些數據,然後好好優化一下我們的代碼。
譯註:繼承是面向對象編程的三大特徵之一,同時也是一大缺點,它導致我們繼承了一些無用的數據,浪費了太多內存,內存命中率低下。因此,我們不得不放棄過去的面向對象編程方式,用面向數據編程來進行連續緊湊的內存布局,以提高內存命中率。
2,ComponentSystem——邁入新領域的第一步
在我們的新模式中,一個Component只包含數據,而不包含行為。ComponentSystem才會包含行為,它負責用一組匹配的Component來更新所有GameObject(這些Component也不同於過去繼承自MonoBehaviour的組件,它們是用結構struct體定義的,而非類class)。
用ComponentSystem實現上文MonoBehavior 相同的功能:
private class Rotator : MonoBehaviour{ //數據 - 可以在編輯器面板里編輯值 public float Speed;}class RotatorSystem : ComponentSystem{ struct Group { //定義這個ComponentSystem需要處理哪些Component Transform Transform; Rotator Rotator; } override protected OnUpdate() { // 我們馬上看到了第一個優化 // 我們知道所有rotator的deltaTime都是一樣的, // 我們就把它存成個本地變數,以便獲得根本更好的性能。 float deltaTime = Time.deltaTime; // ComponentSystem.GetEntities<Group> // 讓我們可以高效地遍歷每個有Transform & Rotator的GameObject // (因為它們都被定義到上文的Group結構體里了)。 foreach (var e in GetEntities<Group>()) { e.Transform.rotation *= Quaternion.AxisAngle(e.Rotator.Speed * deltaTime, Vector3.up); } }}
三、混合ECS: 用ComponentSystem配合現存的GameObject/Component模式
目前還存在大量基於MonoBehaviour/GameObject編寫的代碼,我們或許想要不費力地讓ComponentSystem配合現存的GameObject/Component一起工作。其實一次性把一個項目轉換成ComponentSystem風格其實也不難的。
從上述例子你就能看出來,我們很輕易地用ComponentSystem配合GameObject/Component遍歷了所有的Rotator和Transformt。
1,ComponentSystem是怎麼知道GameObject身上的Rotator和Transform的?
EntityManager需要事先知道那些對應的Entity,然後才能像上面的例子那樣去遍歷所有的Component。
ECS附帶一種GameObjectEntity組件。 當 OnEnable時,GameObjectEntity會創建一個包含GameObject身上所有Component的Entity。這樣,整個GameObject和它的全部Component就都能被ComponentSystem遍歷到了。
注意:所以,目前你必須要在你想讓ComponentSystem能遍歷得到或看得到的GameObject上添加GameObjectEntity組件。
2,這對我們程序來說意味著什麼呢?
這意味著你可以一個接一個地,把MonoBehaviour.Update模式轉變成ComponentSystem模式。
你可以繼續使用GameObject.Instantiate 來創建實例等。你只是簡單地把MonoBehaviour.Update的內容移到了 ComponentSystem.OnUpdate裡面去。 數據仍然保存在那個MonoBehaviour或別的類型的Component中。
你這麼做能夠:
- 用更簡潔地方式把數據和方法剝離;
- System對物體的操作都是批處理的, 避免了逐物體的虛擬調用(virtual call)。在批處理中進行優化就很簡單了 (參見上述的deltaTime優化);
- 你還能繼續使用當前的編輯器面板及其他編輯器工具等;
你這麼做不能夠:
- 實例化耗時得不到優化;
- 載入化耗時得不到優化;
- 數據是隨機訪問的,線性內存布局得不到保證;
- 沒有多線程;
- 沒有單指令流多數據流SIMD;
所以結合使用ComponentSystem、GameObject和MonoBehaviour是寫ECS代碼的良好開頭,它能給你立即的性能提升,但是它並沒有發揮出所有的性能潛力!
四、純粹ECS:IComponentData 和 Job
使用ECS的動機之一就是你想讓程序有最佳性能。所謂最佳性能是說,你寫一些簡單的ECS 代碼就能得到跟你完全手寫SIMD指令集代碼差不多的性能。
C# Job System不支持託管的類(class),只支持結構體(struct)類型和原生容器(NativeContainer)。所以,只有IComponentData才能被安全地用於C# Job System。EntityManager 為組件數據的線性內存布局做出了有力的保證。這是使用IComponentData可以實現的C# Job System的重要組成部分。譯註:為什麼要進行線性內存布局,EntityManager 怎樣為組件數據的線性內存布局做出了有力的保證的,後面的ECS In Detail(1)文章會有闡述。
下面是使用純粹ECS方式實現上述相同功能的例子:
1,使用IComponentData儲存數據:
// 這個RotationSpeed就是簡單地儲存一下旋轉速度[Serializable]public struct RotationSpeed : IComponentData{ public float Value;}// 目前而言,你要想添加或移除Component,就必須要使用這個ComponentDataWrapper,// 將來我們想把這個ComponentDataWrapper搞成自動的。public class RotationSpeedComponent : ComponentDataWrapper<RotationSpeed> { }
2,使用JobComponentSystem實現對數據的多線程批處理:
// 使用IJobProcessComponentData去遍歷所有符合這個組件類型的Entity// Entity的處理時並行的。主線程只負責安排Job。public class RotationSpeedSystem : JobComponentSystem{ //IJobProcessComponentData是用來遍歷所有帶有所需Compoenent類型Enity的簡單方法 //它也比IJobParallelFor更高效更便捷。 [ComputeJobOptimization] struct RotationSpeedRotation : IJobProcessComponentData<Rotation, RotationSpeed> { public float dt; public void Execute(ref Rotation rotation, [ReadOnly]ref RotationSpeed speed) { rotation.Value = math.mul(math.normalize(rotation.Value), math.axisAngle(math.up(), speed.Value * dt)); } } // 我們繼承JobComponentSystem,這樣System就可以自動提供給我們所需Job之間的依賴關係了。 // IJobProcessComponentData聲明了它要對RotationSpeed讀操作,並且對Rotation寫操作。 // 這樣聲明以後,JobComponentSystem就連可以給我們Job之間的依賴關係了,包括之前已經安排好的要寫Rotation或RotationSpeed的那些Job。 // 我們要把這個依賴關係renturn出來,這樣,依據類型我們已經安排好的Job就能註冊到下一個可能會運行的System里去了。 // 這麼做意味著: // * 主線程不發生等待, 主線程只需要根據依賴關係去安排Job (只有依賴關係被確定以後,Job才會被啟動)。 // * 依賴關係為我們自動計算出來了, 這樣我們就只寫一些模塊化的多線程代碼就可以了。 protected override JobHandle OnUpdate(JobHandle inputDeps) { var job = new RotationSpeedRotation() { dt = Time.deltaTime }; return job.Schedule(this, 64, inputDeps); } }
譯註:進一步內容可以參閱ECS In Detail(1)
推薦閱讀: