關於Unity2018的新版ECS框架

官方文檔:

Unity-Technologies/EntityComponentSystemSamples?

github.com圖標Unity-Technologies/EntityComponentSystemSamples?

github.com圖標

至於ECS的定義咱們先跳過,也可以看看我之前的一篇文章:

flashyiyi:一個無框架的ECS實現(Entity-Component-System)?

zhuanlan.zhihu.com圖標

先說說和它一同推出的,和ECS沒直接關係的新特性:

NativeArray<T>

按照官方的說法,以後還會有NativeList,NativeHashMap,NativeQueue之類(這些在C#端就能實現)。

NativeArray內部只能容納值對象。而且在創建的時候除了指定length外,還需要指定allocator模式:

  • Temp(臨時)
  • TempJob(Job內臨時)
  • Persistent(持久)

按文檔的說法,這種Native的數組更加有助於數據的連續性,對cache友好。但僅僅如此的話,一個普通的只包含struct的Array也可以達到同樣的效果。

我個人是相當懷疑這玩意兒其實是在非託管堆上申請的內存,也就是和Mono的內存管理沒啥關係,畢竟這東西是Jobs系統必須的東西,而Jobs看上去和C++部分走的很近。從名字上,也比較像。

先不管這個。至少從表面上看,它就是個普通的struct數組。

使用struct數組其實並不是為了減少GC(雖然實際上也大幅減少了),目的是為了更快的內存訪問速度,在訪問速度上通常能提高>100%的效率。這個和GPU里對紋理的cache是一樣的道理,數據會先從內存到達二級緩存,在二級緩存內的讀取速度遠大於內存,而連續的內存會很大概率被提前放進二級緩存中。

在CPU的工作過程中,數據的讀取成為瓶頸的情況其實並不罕見,只是我們平時使用的情況下,很多數據本來就是連續的,所以連續性相當不友好的「鏈表」才較少被使用。這種整塊的內存結構也能減輕內存管理器整理內存的成本。

即使,數據讀取確確實實沒有成為瓶頸,但CPU內的晶體管可不是光用來計算的,也有很大一部分用於數據存取,減少它們的工作量會很明顯的降低功耗和發熱。

這點在GPU上提得比較多。但其實,只要是晶元,都是一樣的。

另一個就是提供了SIMD的可能性……只是可能性而已。

不過C#里struct的使用有一個「複製」的大坑。因為C#不提倡使用指針,在存取數組裡struct數據的時候只能多次複製,語言特性註定這個問題現在沒法解決,所以雖然struct大家都知道好處,真去用它卻是不現實的。

而最新版的C#7則適時提供了local/return ref進行了支持。

但目前的Unity最多支持到C#6。

你們也知道這事兒啊……

所以這個框架的存在確實有可能會督促Unity將Mono編譯環境升級到C#7,那樣struct就能真正開始用了。沒這玩意兒struct很容易搞成負優化的。

Jobs System

ECS的一個重要特性就是並發優勢,甚至可以說,不支持多並發的ECS幾乎沒有價值。

(多線程可以支持多核執行,在單核天花板已經到達的現在,多核邏輯的時代也差不多該到了。而ECS是少數有可能自動實現多核的邏輯架構,因為提供了數據隔離的依據。)

Jobs是Unity自己的多線程框架,在這裡就對ECS提供了支持。

public class RotationSpeedSystem : JobComponentSystem{ [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)); } } protected override JobHandle OnUpdate(JobHandle inputDeps) { var job = new RotationSpeedRotation() { dt = Time.deltaTime }; return job.Schedule(this, 64, inputDeps); //64指的是每個JOBS至少分配64個循環,循環可以由此拆分到多個線程同時執行。 } }

整套東西使用起來還是很簡單的,用實現IJobProcessComponentData<T1, T2...>的類標記要讀寫的ComponentData,Execute是邏輯,然後用Schedule推入Jobs系統。按這個模板書寫就行了。

[ReadOnly][WriteOnly]元標籤可以指定Component的讀寫模式,將數據進一步分離。

剩下就是歸系統內部調配了,會在不產生線程衝突的情況下分配邏輯線程的時間片,實現高效率的並發。

上面這個IJobProcessComponentData是ECS框架實現的一個比較簡便的用法(不需要寫循環,只寫其中一個循環節就可以了),本身是IJob的一個擴展,可以點進去看看稍微原始一點的寫法。如果在循環前後還有處理,或者需要對數組做篩選就必須用這個了。

ISharedComponentData

這個不是個底層相關特性,卻是個很有趣的東西。

與普通的IComponentData不同,雖然都是struct,如果你先初始化它的值再給每個Entity用AddSharedComponent附加上它的話,並不會產生複製,只會佔用一塊內存(估計傳過去的是地址)

只是讀取很正常,但當你修改某個Entity上它的副本的時候,它會把自己立即複製一份,然後應用這個修改。

這玩意兒很類似PSS或者WindowDLL共享內存的做法,充分體現了「這是一個性能框架」的特徵。

不過認真一想……

這東西不就是shareMaterial和material的關係么……


至於這個ECS框架本身……

其實還是很傳統的。Entitiy實際上是一個int,ComponentData也就是普通的struct,System則是各種系統事件的接受者,類似MonoBehaviour。他們都只要實現對應的介面就能實現功能。在用EnitityManager創建Entity,添加Component之後(和原來的GameObject一樣),System邏輯就會自動生效。

System的邏輯實現,是在特定的事件上(如OnUpdate)用自定義的代碼拉取一些Component數組來執行自己的邏輯,主要用的就是GetEntities<Group>方法,這會按Group的內容,拉取指定的Component數組出來,然後遍歷就可以了。

(注意拉取的是整個場景上同類型的所有Component,而不是一個)

拉取的時候會進行篩選,沒有添加指定Component的Entitiy並不會被拉取(除此之外,還可以用ComponentGroup設置篩選條件)

總之就是每個System手動遍歷一遍指定的Component執行邏輯了——但也可以不遍歷,或者遍歷多次,兩兩交叉遍歷都是可以的,因為全都是自定義代碼,就算是JobComponentSystem也一樣。不要被上面的Job的簡單例子誤導,用IJob就可以在Jobs里寫任意邏輯了,並不需要一定按循環走。

所以,System其實就是MonoBehaviour(刪除數據後)的集合體,MonoBehaviour執行一個Entity的邏輯,而System執行全部的。

class RotatorSystem : ComponentSystem{ struct Group { RotateComponentData rotation; SpeedComponentData speed; } override protected OnUpdate() { float deltaTime = Time.deltaTime; foreach (var e in GetEntities<Group>()) { e.rotation *= Quaternion.AxisAngle(e.speed * deltaTime, Vector3.up); } }}

執行順序

然後有心人就會意識到,MonoBehaviour執行順序不確定的問題在這裡依然存在……

雖然並發的系統本來就希望允許亂序執行嘛,系統還是給了方法確定順序的,只要在System上加元標籤就行了:

[UpdateBefore(typeof(RotationSpeedSystem))]

覺得可以亂序執行的部分不加就是了,在使用Jobs的時候不限制順序才能增加性能。

對GameObject系統的支持

ECS和原有的GameObject+MonoBehaviour功能其實並沒有完全重合。至少在現在,Transfrom,Renderer和物理組件依然必須掛接在GameObject上,並且不掛在GameObject上也無法在場景里編輯。

Entity本來是用EntityManager.CreateEntity()來創建,然後這樣進行複製的(非必須)

EntityManager.Instantiate(entity, instances);

但是如果你把上面參數里的entity換成一個Prefab對象的話,它也能正常執行,並和GameObject.Instantiate一樣生成一個可顯示的GameObject對象,並且照常生成對應的entity。這些生成的entity就是和GameObject綁定的存在了,也就把GameObject引入了ECS系統。

對於引入ECS的GameObject,不管是GameObject還是上面的組件都可以和ComponentData一樣管理。

雖然Renderer之類必須得是Behaviour組件,但是一些掛載在GameObject用來填寫數據的腳本,我們顯然還是希望它們進入系統用的是ComponentData,而不是舊版的MonoBehaviour。

另外,我們的ComponentData也是需要能夠在編輯器環境顯示以及編輯的,而ComponentData並不能掛在GameObject上。

這個框架提供了一個橋接類來處理這個問題:

[Serializable] public struct Radius : IComponentData { public float radius; } public class RadiusComponent : ComponentDataWrapper<Radius> { }

下面那個RadiusComponent 是可以正常掛到腳本上的。而在ECS系統內,它則會被處理隱式地處理成Radius。

依賴注入

使用[Inject]標籤,可以根據欄位類型自動注入一些數據。

public struct Group{ //獲得某個類型的ComponentData public ComponentDataArray<Position> Position; //獲得某個類型的Component(這裡的Component指的是原本的Unity組件) public ComponentArray<Rigidbody> Rigidbodies; //獲得Entity列表 public EntityArray Entities; //獲得GameObject列表 public GameObjectArray GameObjects; //標識排除所有包含MeshCollider的對象 public SubtractiveComponent<MeshCollider> MeshColliders; //數據數量 public int Length;}[Inject]Group m_Group;

和之前GetEntities<Group>不同,這次的Group的不會返回迭代器,所以Group里的欄位本身就必須是數組。但依然會進行篩選,並且按Entity順序排列。

[Inject]OtherSystem m_SomeOtherSystem;

用這個方法可以直接和另一個System通信。這個框架並沒有提供任何和解耦相關的東西,所以System間通信都是直接調用。

[Inject]ComponentDataFromEntity<LocalPosition> m_LocalPositions;Entity myEntity = ...;var position = m_LocalPositions[myEntity];

這個東西看上去和GetEntities<Group>差不多(除了只能獲得一個Component外),但其實功能完全不同。GetEntities會做篩選,而它不會。

但為啥不直接用EntityManager.GetComponentData<LocalPosition>(myEntity)就不了解了,可能是性能問題吧。

EntityCommandBuffer

寫過物體管理系統的都會遇到「遍歷中增刪對象」這個麻煩的問題。雖然確實可以用倒序遍歷處理,但其實這樣很不穩定,而且很可能出現刪除未遍歷到的對象這個問題,然後產生非必現錯誤。

通常的做法就是將所有這些操作延後執行。

這個框架里雖然沒有這個問題,但是在多線程的時候,增刪對象依然會產生較大的數據變動,這容易對同時運行的其他線程產生阻塞,就依然需要進行延後處理。

可以在一個調用次數靠後的System(SystemA)調用CreateCommandBuffer()創建EntityCommandBuffer對象,然後把它的實例傳給其他需要增刪對象的System(SystemB),調用這個實例的函數來負責增刪。

這樣增刪操作會延遲到SystemA時才執行。

由於系統會特地將增刪操作向後延遲,所以SystemA不做任何處理就會是最後一個執行的System,像示例里那樣,用一個空System當這個SystemA也是可以的。

RemoveDeadSystem.cs


對方的文檔也不全,先這樣吧。

其實這次除了ECS,Jobs的部分存在感也極強。你們可以看看下面這個是啥玩意兒……

NativeCounter.cs?

github.com圖標

另外這個玩意兒什麼時候加進來的?

using System;using System.Runtime.CompilerServices;using UnityEngine.Collections;namespace UnityEngine{ internal static class UnsafeUtility { public unsafe static void CopyPtrToStructure<T>(IntPtr ptr, out T output) where T : struct { output = *ptr; } public unsafe static void CopyStructureToPtr<T>(ref T output, IntPtr ptr) where T : struct { *ptr = output; } public unsafe static T ReadArrayElement<T>(IntPtr source, int index) { return *(source + (IntPtr)index); } public unsafe static void WriteArrayElement<T>(IntPtr destination, int index, T value) { *(destination + (IntPtr)index) = value; } public static IntPtr AddressOf<T>(ref T output) where T : struct { return ref output; } public static int SizeOf<T>() where T : struct { return UnsafeUtility.SizeOfStruct(typeof(T)); } public static int AlignOf<T>() where T : struct { return 4; } [MethodImpl(MethodImplOptions.InternalCall)] public static extern IntPtr Malloc(int size, int alignment, Allocator label); [MethodImpl(MethodImplOptions.InternalCall)] public static extern void Free(IntPtr memory, Allocator label); [MethodImpl(MethodImplOptions.InternalCall)] public static extern void MemCpy(IntPtr destination, IntPtr source, int size); [MethodImpl(MethodImplOptions.InternalCall)] public static extern int SizeOfStruct(Type type); [MethodImpl(MethodImplOptions.InternalCall)] public static extern void LogError(string msg, string filename, int linenumber); }}

所以NativeXXX系列是在非託管堆分配內存這一點已經沒跑了。不過我個人覺得NativeXXX並不是單純的值對象數組,很有可能是自己做了個內存管理器,把值對象放在連續內存上,然後再搞一個指針數組來對應索引和實際數據的關係,並不是簡單把索引乘個size(與引用數組的區別是:還是要盡量讓背後的數據連續,alloc時的成本也低)

現在最擔心的就是這玩意兒的性能問題,因為到處都是值對象,而且每次Update還會生成新的數組,大量複製光是內存大小都是個大問題,所以他們「應該」是有考慮這方面的事兒的。

System在Update里篩選數據的問題,最簡單的辦法就是做一次cache,數據列表變化時接受廣播並更新。因為數據列表變化它們也說了會有sync等待,這事兒還是挺有可能的,可以解決大部分問題,但光這樣還是稍微有點不夠,數據變化還是很常見的。

另外還有人提到,JOB的函數內部有可能會進行SIMD優化。雖然我是沒看到依據,但JOBS現在那幾個類里專門弄了個IJobParallelFor,只支持數組,確實提供了可能(雖然主要的目的還是把循環拆到多個線程上)。內存連續也是實現SIMD的基礎。

而且還有那個可疑的[ComputeJobOptimization]元標籤,既然是優化,那優化的是什麼呢?

示例中有一個地方注釋掉了這個,給的理由是使用了靜態方法,並且明確說明了這個東西是Burst。

Burst按宣傳是LLVM。

所以確實有這樣的可能性,畢竟看起來路也有了,車也有了,就差一個司機了。

另外JOBS這東西是完全獨立的,使用起來也足夠傻瓜。可能會有人覺得,邏輯代碼什麼的關心那麼多效率幹嘛。但問題在於,這東西庫代碼也能用啊。

比如Spine的效率問題就有望通過這玩意兒解決,CPU蒙皮就是JOBS最適合的領域。還有各種序列化,這些東西都可以簡單通過JOBS進行優化,達到和Unity原生類似的性能。

這點足以勾引很多團隊強上2018。

推薦閱讀:

TAG:Unity遊戲引擎 | 遊戲開發 |