Unity 實(shí)體組件系統(tǒng)(ECS)——預(yù)覽與體驗(yàn)

作者:ProcessCA
Hi,大家好。吃飽喝足,該寫(xiě)點(diǎn)東西了。

這次給大家?guī)?lái)一期"新技術(shù)"的介紹。沒(méi)錯(cuò),主角就是Unity官方正在推行的ECS框架(Entity-Component-System)。
相信大家多少聽(tīng)說(shuō)過(guò)ECS(實(shí)體組件系統(tǒng)),或者在網(wǎng)絡(luò)上查找過(guò)相關(guān)資料,甚至動(dòng)手實(shí)現(xiàn)過(guò)一個(gè)自己的簡(jiǎn)易ECS框架。如果沒(méi)有聽(tīng)說(shuō)過(guò)也沒(méi)有關(guān)系,可以通過(guò)實(shí)踐可以更好地理解它。
簡(jiǎn)單介紹一下ECS的核心概念:
Entity(實(shí)體):由一個(gè)唯一ID所標(biāo)識(shí)的一系列組件的集合。
Component(組件):一系列數(shù)據(jù)的集合,本身沒(méi)有任何方法。只能用于存儲(chǔ)狀態(tài)。
System(系統(tǒng)):只有方法,沒(méi)有狀態(tài)的工具,類(lèi)似靜態(tài)類(lèi)。
這種設(shè)計(jì)看上去很新穎且奇特,具體到游戲開(kāi)發(fā)的環(huán)節(jié)中是什么樣的呢?
簡(jiǎn)單來(lái)說(shuō),Entity相當(dāng)于一個(gè)只有唯一ID的GameObject,Component就是一個(gè)只有字段的Struct,System只有方法沒(méi)有任何字段。Entity通過(guò)不同Component的組合可以被不同的System關(guān)注。
如下圖所示:

Player(Entity)擁有Position,MoveSpeed,Velocity,Player這些Component。那么他就會(huì)被PlayerInputSystem,MoveSystem所關(guān)注,這些系統(tǒng)在Update時(shí)會(huì)對(duì)該實(shí)體的組件進(jìn)行讀寫(xiě)操作。
系統(tǒng)的調(diào)用順序也可以打亂。PlayerInputSystem跟AIInputSystem由于寫(xiě)的是不同的實(shí)體的Velocity所以可以并行,可以把他們歸到一個(gè)Group里面。MoveSystem由于需要讀取Velocity,所以得等待Group中所有寫(xiě)入操作都完成后才能Update。
至于為什么要使用ECS,相信很多熟悉OOP(面向?qū)ο缶幊蹋┑耐瑢W(xué)開(kāi)發(fā)稍微復(fù)雜點(diǎn)的游戲時(shí)都遇到過(guò):一大堆類(lèi)不知道繼承哪一個(gè),為了解耦寫(xiě)一大堆管理器等。
ECS這種反直覺(jué)的設(shè)計(jì)理念在游戲開(kāi)發(fā)中比起OOP有這些顯而易見(jiàn)的優(yōu)點(diǎn):
沒(méi)有大量的管理器或者中間件,簡(jiǎn)單地說(shuō)就是避免了OOP中常見(jiàn)的過(guò)度抽象。
比起繼承,組合的方式更容易塑造新的實(shí)體類(lèi)型。
數(shù)據(jù)驅(qū)動(dòng),因?yàn)镃omponent沒(méi)有方法且被統(tǒng)一管理,方便利用Excel配置數(shù)據(jù)。
可以利用Utils(工具類(lèi))抽出System的共有方法,加上SingletonComponent(單例組件)提供全局訪問(wèn)進(jìn)行解耦。
ECS在1998年就已經(jīng)被應(yīng)用在一款叫做:Thief : The Dark Project 的游戲中。直到2017年在 GDC 2017上的演講:Overwatch Gameplay Architecture and Netcode
http://gad.qq.com/article/detail/28682
守望先鋒團(tuán)隊(duì)向大家分享了在守望先鋒中使用的ECS以及一系列實(shí)現(xiàn)上的細(xì)節(jié)。這下才被廣大開(kāi)發(fā)者熟知。
由于ECS架構(gòu)的一些特點(diǎn),他可以很容易利用多個(gè)CPU實(shí)現(xiàn)邏輯并行,緊湊且連續(xù)的內(nèi)存布局,比起OOP可以更方便地獲得更大的性能提升。
在Unity2018中,伴隨Unity ECS推出的還有Burst編譯器與C# Job System。下面列出了一部分Unity ECS的愿景:
我們相信我們可以快速編寫(xiě)高性能代碼,就像MonoBehaviour.Update一樣簡(jiǎn)單。
我們相信,在基礎(chǔ)層面,這將使Unity比現(xiàn)在更加靈活。
我們會(huì)立即為您提供有關(guān)任何競(jìng)態(tài)條件的錯(cuò)誤信息。
對(duì)于小內(nèi)容,我們希望Unity在不到1秒的時(shí)間內(nèi)加載。
在大型項(xiàng)目中更改單個(gè).cs文件時(shí)。組合編譯和熱重載時(shí)間應(yīng)小于500毫秒。
Unity ECS現(xiàn)階段并不推薦直接用于生產(chǎn),但是了解他的使用方法還是很有用處的,因?yàn)镋CS不僅可以提高性能,還可以幫助你編寫(xiě)更清晰,更易于維護(hù)的代碼。
看到這里有沒(méi)有很想體驗(yàn)一下Unity的ECS?

下面我們就來(lái)寫(xiě)一些Unity ECS-Style風(fēng)格的代碼。
首先我們下載一個(gè)Unity 2018.X,新建一個(gè)工程在Window -> Package Manager?中選擇Advanced -> Show Preview Packages,然后選擇Entities并點(diǎn)擊Install。
(在2018.1中點(diǎn)擊All可以看到Entities)

準(zhǔn)備就緒后,我們直接創(chuàng)建一個(gè)腳本并命名Bootstrap。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
?
public class Bootstrap
{
??? [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
??? public static void Awake()
??? {
?
??? }
?
??? [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
??? public static void Start()
??? {
?
??? }
}
?
ECS不同于以往的Monobehavior,他有一套自己的生命周期。
代碼中的Awake跟Start方法都是我自己編寫(xiě)的,只要給他們打上一個(gè)特性就會(huì)被Unity在場(chǎng)景加載的前后時(shí)機(jī)進(jìn)行調(diào)用。(方法必須為靜態(tài)方法)
我們?cè)趫?chǎng)景中創(chuàng)建一個(gè)空物體并命名Player,加上Mesh Instance Renderer Component(渲染組件)。

Mesh選擇球形,新建一個(gè)材質(zhì)球Red并且放在Material中,再把Cast Shadows(投影)設(shè)置為開(kāi)啟。
打開(kāi)Bootstrap腳本,在Awake中創(chuàng)建出EntityManager與EntityArchetype:
? ? private static EntityManager entityManager;???? //所有實(shí)體的管理器, 提供操作Entity的API
?
??? private static EntityArchetype playerArchetype; //Entity原型, 可以看成由組件組成的數(shù)組
?
??? [RuntimeInitializeOnLoadMethod(loadType: RuntimeInitializeLoadType.BeforeSceneLoad)]
??? public static void Awake()
??? {
??????? entityManager = World.Active.GetOrCreateManager<EntityManager>();
?
??????? //下面的的Position類(lèi)型需要引入U(xiǎn)nity.Transforms命名空間
??????? playerArchetype = entityManager.CreateArchetype(typeof(Position));
??? }
?
通過(guò)World創(chuàng)建出EntityManager,EntityManager的對(duì)象提供了創(chuàng)建實(shí)體,給實(shí)體添加組件,獲取組件,移除組件,實(shí)例化與銷(xiāo)毀實(shí)體等功能。
按照Unity的說(shuō)法,默認(rèn)情況下會(huì)在進(jìn)入播放模式時(shí)創(chuàng)建好World,因此我們直接在Awake使用World創(chuàng)建EntityManager就好了。
所以EntityManager就是一個(gè)實(shí)體的管理器。
上個(gè)版本的Unity ECS還是靜態(tài)類(lèi),現(xiàn)在已經(jīng)該為由World創(chuàng)建的實(shí)例了。
等待場(chǎng)景加載完成之后會(huì)調(diào)用Start方法:
? ? [RuntimeInitializeOnLoadMethod(loadType: RuntimeInitializeLoadType.AfterSceneLoad)]
??? public static void Start()
??? {
??????? //把GameObect.Find放在這里因?yàn)閳?chǎng)景加載完成前無(wú)法獲取游戲物體。
??????? GameObject playerGo = GameObject.Find("Player");
?
??????? //下面的類(lèi)型是一個(gè)Struct, 需要引入U(xiǎn)nity.Rendering命名空間
??????? MeshInstanceRenderer playerRenderer =
??????????? playerGo.GetComponent<MeshInstanceRendererComponent>().Value;
?
??????? //獲取到渲染數(shù)據(jù)后可以銷(xiāo)毀空物體
??????? Object.Destroy(playerGo);
?
??????? Entity player = entityManager.CreateEntity(playerArchetype);
?
??????? //修改實(shí)體的Position組件
??????? entityManager.SetComponentData(player, new Position
??????????? { Value = new Unity.Mathematics.float3(0, 2, 0) });
?
??????? // 向?qū)嶓w添加共享數(shù)據(jù)組件
??????? entityManager.AddSharedComponentData(player, playerRenderer);
??? }
float3 是Unity新推出的數(shù)學(xué)庫(kù)(Unity.Mathematics)中的類(lèi)型,用法跟Vector3基本一致。
Unity建議在ECS中使用該數(shù)學(xué)庫(kù)。
通過(guò)entityManager對(duì)象創(chuàng)建的實(shí)體都會(huì)被管理起來(lái),在創(chuàng)建Entity時(shí)我們可以在CreateEntity方法的參數(shù)中填上之前創(chuàng)建好的playerArchetype(玩家原型),即按照原型中包含的組件依次添加到實(shí)體上。
在Update方法中獲取了之前在場(chǎng)景中設(shè)置的渲染組件,并且作為AddSharedComponentData的參數(shù)。
這時(shí)我們的player實(shí)體已經(jīng)擁有了兩個(gè)組件:Position跟MeshInstanceRenderer。
關(guān)于ISharedComponentData接口:他的作用是當(dāng)實(shí)體擁有屬性時(shí),比如:球形的實(shí)體共享同樣的Mesh網(wǎng)格數(shù)據(jù)時(shí)。這些數(shù)據(jù)會(huì)儲(chǔ)存在一個(gè)Chuck中并非每個(gè)實(shí)體上,因此在每個(gè)實(shí)體上可以實(shí)現(xiàn)0內(nèi)存開(kāi)銷(xiāo)。
值得注意的是:如果Entity不包含Position組件,這個(gè)實(shí)體是不會(huì)被Unity的渲染系統(tǒng)關(guān)注的。因此想在屏幕上看見(jiàn)這個(gè)實(shí)體,必須確保MeshInstanceRenderer跟Position都添加到了實(shí)體上。
我們運(yùn)行游戲就會(huì)看到我們創(chuàng)建的Player被顯示出來(lái)了:

細(xì)心的你也發(fā)現(xiàn)了,在Hierarchy中并沒(méi)有這個(gè)實(shí)體的信息。

原因是現(xiàn)在Unity編輯器還沒(méi)有與ECS整合,因此我們需要打開(kāi)Window -> Analysis -> Entity Debugger面板查看我們的系統(tǒng)與實(shí)體。

在EntityManager中可以看到Entity 0,那就是我們創(chuàng)建的player實(shí)體。此時(shí)他的Inspector菜單也會(huì)有數(shù)據(jù)填充:

每個(gè)Value對(duì)應(yīng)一個(gè)Component及其具體的值??梢钥吹剿淖鴺?biāo),Mesh與Material都被更改了。
現(xiàn)在我們搭建一個(gè)簡(jiǎn)易場(chǎng)景,首先創(chuàng)建一個(gè)Plane并命名為Ground。然后創(chuàng)建一個(gè)灰色的材質(zhì)球掛上去:

同時(shí)保持他的默認(rèn)組件就好了:

運(yùn)行游戲看一下效果:

我們只需要修改攝像機(jī)的Transform就能讓游戲畫(huà)面呈現(xiàn)出俯視角的效果:

視角調(diào)的還不錯(cuò):

這時(shí)如果我們想在ECS框架中控制這個(gè)小球(Player)的移動(dòng)該怎么實(shí)現(xiàn)呢?
我們之前在Bootstrap腳本中已經(jīng)實(shí)現(xiàn)了Awake跟Start了,其實(shí)每一個(gè)System都會(huì)實(shí)現(xiàn)Update方法。并且會(huì)在Start調(diào)用后開(kāi)始調(diào)用。
player現(xiàn)在只包含兩個(gè)組件:Position與MeshInstanceRenderer,顯然缺乏一個(gè)標(biāo)識(shí)組件,我們創(chuàng)建一個(gè)腳本并命名為PlayerComponent:
using Unity.Entities;
?
//組件必須是struct并且得繼承IComponentData接口
public struct PlayerComponent : IComponentData
{
}
?
在Bootstrap.Start方法中,在player創(chuàng)建出來(lái)后加上一句:
entityManager.AddComponentData(player, new PlayerComponent()); //添加PlayerComponent組件
?
現(xiàn)在我們創(chuàng)建一個(gè)腳本命名為MovementSystem,繼承自ComponentSystem:
類(lèi)似繼承Monobehavior,我們的系統(tǒng)只要繼承了這個(gè)基類(lèi)就會(huì)被Unity識(shí)別,并且每一幀都調(diào)用OnUpdate。
using UnityEngine;
using Unity.Entities;
using Unity.Transforms;
using Unity.Mathematics;
?
public class MovementSystem : ComponentSystem
{
??? protected override void OnUpdate()
??? {
??? }
}
?
Unity ECS幫我們簡(jiǎn)化了獲取實(shí)體再獲取組件的過(guò)程,現(xiàn)在可以直接獲取不同的組件。也就是說(shuō)我們可以獲取被EntityManager管理的實(shí)體上我們想要的組件組成的集合。
利用一個(gè)特性:[Inject]:
? ? //這里聲明一個(gè)結(jié)構(gòu), 其中包含我們定義的過(guò)濾條件, 也就是必須擁有CameraComponent組件才會(huì)被注入。
??? public struct Group
??? {
??????? public readonly int Length;
?
??????? public ComponentDataArray<Position> Positions;
??? }
?
??? //然后聲明結(jié)構(gòu)類(lèi)型的字段, 并且加上[Inject]
??? [Inject] Group data;
?
??? protected override void OnUpdate()
??? {
??????? float deltaTime = Time.deltaTime;
?
??????? for (int i = 0; i < data.Length; i++)
??????? {
??????????? float3 up = new float3(0, 1, 0);
?
??????????? float3 pos = data.Positions[i].Value; //Read
?
??????????? pos += up * deltaTime;
?
??????????? data.Positions[i] = new Position { Value = pos }; //Write
??????? }
??? }
?
聲明了Length屬性后,Length會(huì)被自動(dòng)注入,它代表結(jié)構(gòu)中每個(gè)數(shù)組的總元素?cái)?shù)量,方便進(jìn)行for循環(huán)迭代。
[Inject]會(huì)從所有Entity中尋找同時(shí)擁有PlayerComponent與Position組件的實(shí)體,接著獲取他們的這些組件,注入我們聲明的不同數(shù)組中。
我們只需要在結(jié)構(gòu)中聲明好篩選的條件與我們需要的組件,ECS就會(huì)在背后幫我們處理,給我們想要的結(jié)果。
運(yùn)行后player果然升天了:

趁熱打鐵,現(xiàn)在我們想自己通過(guò)輸入控制小球在平面上移動(dòng)。
先聲明一個(gè)組件InputComponent作為一個(gè)標(biāo)識(shí):
using Unity.Entities;
?
public struct InputComponent : IComponentData
{
}
?
然后再聲明一個(gè)組件VelocityComponent保存我們的輸入向量:
using Unity.Entities;
using Unity.Mathematics;
?
public struct VelocityComponent : IComponentData
{
??? public float3 moveDir;??
}
?
我們默認(rèn)player的速度為1就不單獨(dú)聲明速度值了。
接著創(chuàng)建InputSystem來(lái)更改VelocityComponent的值,接下來(lái)的工作就是照貓畫(huà)虎了:
using UnityEngine;
using Unity.Entities;
using Unity.Mathematics;
?
public class InputSystem : ComponentSystem
{
??? public struct Group
??? {
??????? public readonly int Length;
?
??????? public ComponentDataArray<PlayerComponent> Players;
?
??????? public ComponentDataArray<InputComponent> Inputs;
?
??? ????public ComponentDataArray<VelocityComponent> Velocities;
??? }
?
??? [Inject] Group data;
?
??? protected override void OnUpdate()
??? {
??????? for (int i = 0; i < data.Length; i++)
??????? {
??????????? float x = Input.GetAxisRaw("Horizontal");
?????? ?????float z = Input.GetAxisRaw("Vertical");
?
??????????? float3 normalized = math.normalize(new float3(x, 0, z));
?
??????????? //Write
??????????? data.Velocities[i] = new VelocityComponent { moveDir = normalized };
??????? }
??? }
}
?
比較麻煩的一點(diǎn)就是,在游戲初期沒(méi)有確立基礎(chǔ)的組件與系統(tǒng)時(shí)需要頻繁修改。
移動(dòng)系統(tǒng)也需要修改:
? ? //這里聲明一個(gè)結(jié)構(gòu), 其中包含我們定義的過(guò)濾條件, 也就是必須擁有CameraComponent組件才會(huì)被注入。
??? public struct Group
??? {
??????? public readonly int Length;
?
??????? public ComponentDataArray<VelocityComponent> Velocities;
?
??????? public ComponentDataArray<Position> Positions;
??? }
?
?? ?//然后聲明結(jié)構(gòu)類(lèi)型的字段, 并且加上[Inject]
??? [Inject] Group data;
?
??? protected override void OnUpdate()
??? {
??????? float deltaTime = Time.deltaTime;
?
??????? for (int i = 0; i < data.Length; i++)
??????? {
??????????? float3 pos = data.Positions[i].Value;??????? ?????//Read
??????????? float3 vector = data.Velocities[i].moveDir;?????? //Read
?
??????????? pos += vector * deltaTime; //Move
?
??????????? data.Positions[i] = new Position { Value = pos }; //Write
??????? }
??? }
?
還要回到Bootstrap.Start中,向我們的player繼續(xù)添加這兩個(gè)組件:
? ? ? ? Entity player = entityManager.CreateEntity(playerArchetype);??? // Position
??????? //添加PlayerComponent組件
??????? entityManager.AddComponentData(player, new PlayerComponent());? // PlayerComponent
??????? entityManager.AddComponentData(player, new VelocityComponent());// VelocityComponent
??????? entityManager.AddComponentData(player, new InputComponent());?? // InputComponent
??????? // 向?qū)嶓w添加共享的數(shù)據(jù)
??????? entityManager.AddSharedComponentData(player, playerRenderer);?? // MeshInstanceRenderer
?
??????? //修改實(shí)體的Position組件
??????? entityManager.SetComponentData(player, new Position
??????????? { Value = new Unity.Mathematics.float3(0, 0.5f, 0) });
?
Duang:

Unity ECS只提供了渲染系統(tǒng)并沒(méi)有提供物理系統(tǒng),如果要跟以前的項(xiàng)目結(jié)合,我們還需要能夠訪問(wèn)場(chǎng)景中的游戲物體,比如一個(gè)經(jīng)典的Cube。


在Bootstrap.Start中獲取我們的Cube,并且加上GameObjectEntity組件。
GameObjectEntity 確實(shí)叫這個(gè)名, 是Unity提供的組件。
添加上這個(gè)組件后Cube就可以被entityManager關(guān)注,并且可以獲取Cube上的任意組件:
? ? ? ? //獲取Cube???????
??????? GameObjectEntity cubeEntity = GameObject.Find("Cube").AddComponent<GameObjectEntity>();
?
????? ??//添加Velocity組件
??????? entityManager.AddComponentData(cubeEntity.Entity, new VelocityComponent
??????????? { moveDir = new Unity.Mathematics.float3(0, 1, 0) });
?
我們向Cube添加了一個(gè)VelocityComponent組件,在MovementSystem加上這些代碼:
? ? public struct GameObject
??? {
??????? public readonly int Length;
?
??????? public ComponentArray<Transform> Transforms; //該數(shù)組可以獲取傳統(tǒng)的Component
?
??????? public ComponentDataArray<VelocityComponent> Velocities;//該數(shù)組獲取繼承IComponentData的
??? }
?
??? [Inject] GameObject go;
?
在OnUpdate中加上這些代碼,針對(duì)Transform進(jìn)行操作:
? ? ? ? for (int i = 0; i < go.Length; i++)
??????? {
??????????? float3 pos = go.Transforms[i].position; //Read
??????????? float3 vector = go.Velocities[i].moveDir; //Read
?
??????????? pos += vector * deltaTime; //Move
?
??????????? go.Transforms[i].position = pos; //Write
??????? }
?
運(yùn)行游戲后,我們可以看到:

Cube跟player的移動(dòng)其實(shí)是被不同的系統(tǒng)實(shí)現(xiàn)的,player是因?yàn)楸荒J(rèn)存在的渲染系統(tǒng)關(guān)注了所以實(shí)現(xiàn)了移動(dòng),而Cube是我們自己的MovementSystem實(shí)現(xiàn)的。
如果想在ECS中用到之前的物理系統(tǒng)最好是自己寫(xiě)一個(gè)單獨(dú)的系統(tǒng)并關(guān)注Rigidbody,BoxCollider這些傳統(tǒng)組件,然后在OnUpdate中使用它們。
看到這里你應(yīng)該已經(jīng)明白了ECS特點(diǎn)與Unity ECS的用法了,希望可以勾起你們對(duì)于ECS的興趣,在以后針對(duì)多核開(kāi)發(fā)的時(shí)代,相信ECS會(huì)成為高性能的代表。
介于篇幅原因,JobComponentSystem,NativeArray,System并行,組件的先后順序,讀寫(xiě)權(quán)限這些跟性能優(yōu)化相關(guān)的點(diǎn)就沒(méi)有介紹了,感興趣的話可以去Unity ECS官網(wǎng)了解。
https://github.com/Unity-Technologies/EntityComponentSystemSamples
附上項(xiàng)目下載地址:Mystery Code:0nl0
https://pan.baidu.com/share/init?surl=GfoxEaWWLDThXa4lkwQumA
等以后Unity ECS更完善時(shí)再出一期。這期文章就到這里了,拜拜咯。
想系統(tǒng)學(xué)習(xí)游戲開(kāi)發(fā)的童鞋,歡迎訪問(wèn)?http://levelpp.com/? ? ? ? ? ? ? ??
游戲開(kāi)發(fā)攪基QQ群:869551769? ? ? ? ? ? ? ??
微信公眾號(hào):皮皮關(guān)