最近發(fā)覺剛看過的東西在腦中卻忘越來越快了,比如Unity的ECS,僅僅一個(gè)月,就忘的什么也不剩。有些記憶可能還是寫下來更持久些,從今天開始邊寫邊重新學(xué)吧。
了解過ECS的開發(fā)者都知道ECS與Unity原本的開發(fā)理念相差很大,需要所有Unity開發(fā)者重新去學(xué)習(xí)和適應(yīng)新的開發(fā)框架的代價(jià)還是很大的,Unity為何要做出這么大跨度的嘗試呢?
Unity正在嘗試解決什么問題?
以前我們基于Unity的GameObject/MonoBehaviour機(jī)制,可以非常簡(jiǎn)單為創(chuàng)作游戲編寫代碼,但最終往往讓代碼陷入難以閱讀,維護(hù)和優(yōu)化的境地。這是一系列因素聯(lián)合導(dǎo)致的:
面向?qū)ο竽P?/p>
由Mono編譯的非最優(yōu)機(jī)器碼
GC機(jī)制
單線程開發(fā)
Entity-Component-System 登場(chǎng)
Entity-Component-System 是一種編寫代碼的方式,簡(jiǎn)稱ECS,近年因OW被廣泛熟知,ECS主要關(guān)注開發(fā)中一個(gè)很基本的問題:如何組建并處理游戲中的數(shù)據(jù)和行為。
后續(xù)文章我們會(huì)更具體的講解ECS的概念,本章我們簡(jiǎn)單介紹ECS在Unity中的使用。
采用ECS不但在設(shè)計(jì)上可以更好的進(jìn)行游戲編程,還可以利用Unity提供的JobSystem和Brush編譯器充分發(fā)揮多核處理器的性能。
Unity2017以后已經(jīng)發(fā)布了JobSystem,基于JobSystem可以在C#代碼中更好的實(shí)現(xiàn)多線程批處理技術(shù),JoySystem底層為多線程間的競(jìng)爭(zhēng)提供的安全保障。
對(duì)于開發(fā)者而言,更重要的是要使用一種新的思維方式和編碼方式來充分利用JobSystem。
ECS有什么不同?
MonoBehavior -我們的老戰(zhàn)友
MonoBehavior 既包含數(shù)據(jù)也包含行為。下面這段代碼演示了Rotator組件每幀都要對(duì)Transform組件進(jìn)行旋轉(zhuǎn)操作。
using UnityEngine;
class Rotator : MonoBehaviour
{
// 數(shù)據(jù):可以在Inspector窗口中編輯的旋轉(zhuǎn)速度值
public float speed;
// 行為:從component中讀取速度值,然后修改Transform組件中的rotation
void Update()
{
transform.rotation *= Quaternion.AngleAxis(Time.deltaTime * speed, Vector3.up);
}
}
然而MonoBehaviour 是繼承于數(shù)個(gè)其它類的,且每個(gè)其它類包含了他們自己的數(shù)據(jù),除了Transform,上面代碼中沒有用到任何他們中的數(shù)據(jù)。這其實(shí)浪費(fèi)了很多不必要的內(nèi)存,因此我們?cè)谠O(shè)計(jì)一個(gè)系統(tǒng)時(shí),需要考慮哪些數(shù)據(jù)是我們真正需要的。
ComponentSystem -邁入新紀(jì)元的一步
using Unity.Entities;
using UnityEngine;
// 數(shù)據(jù):可以在Inspector窗口中編輯的旋轉(zhuǎn)速度值
class Rotator : MonoBehaviour
{
public float Speed;
}
// 行為:繼承自ComponentSystem來處理旋轉(zhuǎn)操作
class RotatorSystem : ComponentSystem
{
struct Group
{
// 定義該ComponentSystem需要獲取哪些components
public Transform Transform;
public Rotator Rotator;
}
override protected void OnUpdate()
{
// 這里可以看第一個(gè)優(yōu)化點(diǎn):
// 我們知道所有Rotator所經(jīng)過的deltaTime是一樣的,
// 因此可以將deltaTime先保存至一個(gè)局部變量中供后續(xù)使用,
// 這樣避免了每次調(diào)用Time.deltaTime的開銷。
float deltaTime = Time.deltaTime;
// ComponentSystem.GetEntities<Group>可以高效的遍歷所有符合匹配條件的GameObject
// 匹配條件:即包含Transform又包含Rotator組件(在上面struct Group中定義)
foreach (var e in GetEntities<Group>())
{
e.Transform.rotation *= Quaternion.AngleAxis(e.Rotator.Speed * deltaTime, Vector3.up);
}
}
}
在ECS模型中,Component(組件)只包含數(shù)據(jù)。
ComponentSystem 則包含行為,一個(gè) ComponentSystem 更新所有與之組件類型匹配的GameObject。
混合ECS:使用與 ComponentSystem 現(xiàn)有的 GameObject & components 一起工作
目前,現(xiàn)有的Unity工程基本都是基于MonoBehaviour&GameObject&components,如果想與現(xiàn)有GameObject&components一起使用ECS,混合ECS將是個(gè)不錯(cuò)的選擇。上面的例子演示了我們可以簡(jiǎn)單的遍歷訪問即包含Rotator又包含Transform組件的實(shí)體對(duì)象。
ComponentSystem 是怎么訪問Rotator和Transform的?
為了能像上面例子中那樣可以遍歷所有匹配組件類型的實(shí)體,這些實(shí)體必須由 EntityManager 創(chuàng)建。
ECS 框架提供了一個(gè)叫 GameObjectEntity 的組件,在OnEnable時(shí),GameObjectEntity會(huì)在GameObject上創(chuàng)建一個(gè)含有所有組件的實(shí)體(Entity)。所以ComponentSystems 可以獲取完整的GameObject及其所有組件。
因此在目前的情況下,如果你需要在ComponentSystems訪問一個(gè)GameObject,則必須在該GameObject上添加一個(gè)GameObjectEntity組件。
如何將現(xiàn)有代碼轉(zhuǎn)為混合ECS?
我們要把MonoBehaviour.Update轉(zhuǎn)換為ComponentSystems.OnUpdate的方式,可以繼續(xù)將所有的數(shù)據(jù)保存在MonoBehaviour中,這是一種很簡(jiǎn)單向ECS的過渡方式。
因此場(chǎng)景數(shù)據(jù)仍然存在于GameObjects & components中,可以繼續(xù)使用GameObject.Instantiate以創(chuàng)建實(shí)例等。
混合ECS的優(yōu)點(diǎn):
數(shù)據(jù)與行為的分離的方式,會(huì)讓代碼整體看起來更清晰
系統(tǒng)對(duì)許多對(duì)象是可以進(jìn)行批量操作的,避免了一些無意義的調(diào)用。(見上面deltaTime優(yōu)化)
我們可以繼續(xù)使用現(xiàn)有的Inspectors, Editor tools等工具
混合ECS的缺點(diǎn):
實(shí)例化時(shí)間并沒有得到優(yōu)化
加載時(shí)間并沒有得到優(yōu)化
數(shù)據(jù)是隨機(jī)訪問的,沒有線性內(nèi)存訪問的高效性
沒有發(fā)揮多核功能
沒有SIMD
因此,使用ComponentSystem, GameObject 和 MonoBehaviour 結(jié)合是編寫ECS代碼的一個(gè)簡(jiǎn)易的改變?;旌螮CS提供了一些簡(jiǎn)單的性能改進(jìn),但是它并沒有充分發(fā)揮ECS的所有性能優(yōu)勢(shì)。
純ECS: 使用IComponentData & Jobs全面提升性能
通常讓游戲具有更好的性能是選擇ECS的一個(gè)重要原因,但如果我們利用CPU的SIMD特性來編寫所有代碼,其實(shí)最終的性能和基于ECS編寫的是差不多的。
結(jié)合ECS與C# JobSystem將提供SIMD的可能性,以發(fā)揮CPU最大性能。
C# JobSystem 只支持structs和NativeContainers,并不支持托管數(shù)據(jù)類型。所以,在C# JobSystem中,只有IComponentData數(shù)據(jù)可以被安全的訪問。
另外,EntityManager內(nèi)部保證了ComponentData(組件)數(shù)據(jù)的線性內(nèi)存布局,這是C# JobSystem中可以高效的使用IComponentData最重要的依據(jù)。
using System;
using Unity.Entities;
// 定義一個(gè)ComponentData用于存儲(chǔ)旋轉(zhuǎn)速度
[Serializable]
public struct RotationSpeed : IComponentData
{
public float Value;
}
// ComponentDataWrapper用于將ComponentData添加到GameObject,
// 這一步需要手動(dòng)添加,將來Unity會(huì)自動(dòng)化這步操作。
public class RotationSpeedComponent : ComponentDataWrapper<RotationSpeed> { }
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Burst;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;
// IJobProcessComponentData 是遍歷匹配組件類型Entity的一種很簡(jiǎn)易的方式,
// 這比使用 IJobParallelFor 更方便、有效。
// Entity的處理(Execute)是并行的,主線程只負(fù)責(zé)調(diào)度Job
public class RotationSpeedSystem : JobComponentSystem
{
[BurstCompile]
struct RotationSpeedRotation : IJobProcessComponentData<Rotation, RotationSpeed>
{
public float dt;
// IJobProcessComponentData 聲明了需要讀取 RotationSpeed 和寫入 Rotation.
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會(huì)讓系統(tǒng)為Job提供必要的依賴關(guān)系,
// 其它之前任何寫入Rotation或RotationSpeed的JobComponentSystem都將參與依賴計(jì)算.
// 這里必須返回調(diào)度后的JobHandle,以便系統(tǒng)處理依賴執(zhí)行順序。
// 這樣處理的優(yōu)點(diǎn):
// * 主線程是非阻塞的,只需考慮依賴關(guān)系調(diào)度Job,當(dāng)依賴項(xiàng)全部執(zhí)行完成,Job才會(huì)執(zhí)行。
// * 依賴項(xiàng)的構(gòu)成是自動(dòng)計(jì)算的,因此我們可以模塊化的編寫多線程代碼。
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var job = new RotationSpeedRotation() { dt = Time.deltaTime };
return job.Schedule(this, 64, inputDeps);
}
}
下一次我們將更詳細(xì)的介紹ECS。