一個無框架的ECS實現(Entity-Component-System)
咱們先從一切的起源說起——
只要是遊戲,大多都會出現這樣一個Enity-Manager系統。因為遊戲本質就是大量實體行為(Enity)以及他們之間的互動(Manager)。
但很顯然,一個遊戲不可能只有兩個類。隨著邏輯的膨脹,出於各種原因都會進行邏輯的拆分。而比起繼承,複合的靈活性更強,所以最後基本都會變成這樣一個狀態:
其實一般的遊戲到這個狀態就可以了,偶爾也會有一些繼承關係穿插其中。但在實際的邏輯編寫過程中,經常會出現一些惱人的兩擇問題:
同一段邏輯,我到底是應該放在Component,還是Manager裡呢?
因為這兩個東西是相互依賴的,放哪兒其實都一樣,而到底放那裡才合適往往並不是那麼容易判斷的。因此過一陣子,即使是程式碼的編寫者也不記得到底是放在Component還是Manager裡了,得兩邊都找一次才可以。假如,Component和Manager並不是簡單的兩級關係,而是多級,就更好玩兒了。
通常,與多個Component相關的邏輯程式碼,放在Manager更合適。但是假如把只和一個Component相關的程式碼放在Manager,也只是看起來有點像靜態方法,有點蠢,但並沒有大礙。所以,經過權衡之後,開發者決定把Component的所有邏輯全部移動到對應的Manager上,以消除這種二擇難題,這就產生了System:
這樣移動邏輯之後,由於Data(Component)的依賴關係變得很簡單,開發者又發現,其實Data胡亂拆分也沒有關係,System也可以不受限制根據需要操作多個Data的資料,於是就變成了這樣:
這就是ECS(Entity-Component-System)
實際上,這只是一個正常的架構優化,最主要的“特色”是將Component的邏輯全部移動到了System上,其他部分都是順理成章的結果。
基本特徵如下:
- System是唯一承載邏輯的地方
- Data(阿呸,是Component)不允許有邏輯,對外依賴就更不能有了
- Entity首先是一個Data,但本質上是個多個Data的橋樑,用於標識它們屬於同一物體。在不同的資料結構下,它甚至可以僅僅是一個int。
- 在允許的情況下,System並不直接依賴Entity,因為並不需要。System直接依賴Data也有利於清晰判斷依賴關係。
- 至於System之間的相互依賴關係,和以前Component,Manager之間的相互依賴還是一樣的。該怎麼處理就怎麼處理,這是ECS之外的問題。
一些意外的收穫
- 由於Data被拆散了,不容易遇到讀入整個物件卻只使用其中一個屬性的情況(比如我們常見的讀入一個Vector3卻只使用一個x),有利於Cache(不過一般不會摳到這個份兒上)
- 由於Data被拆散了,狀態同步的功能可以直接放在Data上,同步邏輯會變得簡單。
- 由於Data和System之間依賴關係明確,交叉較少,對執行緒安全非常友好。在摩爾定律單核失效的現在,多執行緒會變得越來越重要
下面是個示例,玩家控制的兩個球會吞吃螢幕中的點變大,球之間會相互推擠保證不重疊,包含一個吞食運動動畫。
flashyiyi/A-Simple-ECS-Example
這個示例裡,並沒有框架程式碼。裡面那個DList/DItem是個處理foreach時增刪元素的東西,用普通的List只要用for倒序遍歷也是一樣的。
首先是Component,也就是些純資料類。資料類固定包含一個Entity的連結讓它們能聯絡在一起。
public class BaseComponent : DItem
{
public Entity entity;
}
public class PositionComponent : BaseComponent
{
public Vector2 value;
}
public class SizeComponent : BaseComponent
{
public float value;
}
public class SpeedComponent : BaseComponent
{
public Vector2 value;
public float maxValue;
}
public class ColorComponent : BaseComponent
{
public Color value = Color.white;
}
public class TeamComponent : BaseComponent
{
public int id;
}
//與Unity元件的橋接
public class GameObjectComponent : BaseComponent
{
public GameObject gameObject;
public Transform transform;
public SpriteRenderer spriteRenderer;
}
//臨時特效型Component
public class EatingComponent : BaseComponent
{
public GameObjectComponent go;
public PositionComponent target;
public Vector2 startOffest;
public Vector2 endOffest;
public float dur = 0.2f;
public float endTime;
//僅操作資料的方法可以存在
public float GetLifePercent()
{
return 1f - (endTime - Time.time) / dur;
}
public void Start()
{
endTime = Time.time + dur;
}
public Vector2 GetCurPosition()
{
return target.value + Vector2.Lerp(startOffest, endOffest, GetLifePercent());
}
}
Entity部分,這裡並沒有維護Component陣列,而是以“寫死”的方式把固定的Component創建出來並儲存在欄位裡。因為背後並沒有框架,並不需要提供框架需要的資料。而即使背後有框架,為了效能通常也會像這樣把每個Component取出來“寫死”放在一個固定的地方,其實也沒啥太大的區別。
這樣做的缺陷是無法動態增加Component,但是在專案邏輯程式碼內,需要動態Component的情況又有多少呢?真需要動態Component的時候(比如Buff),再加一個專門的陣列管理也不遲。
public class Entity : DItem
{
public GameObjectComponent gameObject;
public PositionComponent position;
public SizeComponent size;
public ColorComponent color;
public TeamComponent team;
public Entity()
{
gameObject = new GameObjectComponent() { entity = this };
position = new PositionComponent() { entity = this };
size = new SizeComponent() { entity = this };
color = new ColorComponent() { entity = this };
team = new TeamComponent() { entity = this };
}
}
public class MoveAbleEntity : Entity
{
public SpeedComponent speed;
public MoveAbleEntity() : base()
{
speed = new SpeedComponent() { entity = this };
}
}
System部分其實近似於靜態類,僅僅保留一個Root物件的連結以避免出現單例。而整個系統中,也只有System才有許可權訪問Root物件。
目前所有的資料列表都儲存在GameWorld這個Root物件中,通過Root物件也可以訪問到其他的System。
可以看到,下面這些類只有EatSystem直接依賴了Entity,那是因為它涉及到了Entity本身的增刪。其他的System都避免了對具體Entity的依賴,而只依賴零散的Component。
雖然看上去有點蠢,但這樣在Entity擁有多個版本的時候,System並不需要關心自己操作的具體是哪一個,也就是Entity實際上擁有了“無限的多型特性”。
public class SystemBase
{
public GameWorld world;
public SystemBase(GameWorld world)
{
this.world = world;
}
}
//移動
public class MoveSystem : SystemBase
{
public MoveSystem(GameWorld world) : base(world) { }
public void Add(SpeedComponent speed)
{
world.speeds.DelayAdd(speed);
}
public void Remove(SpeedComponent speed)
{
world.speeds.DelayRemove(speed);
}
public void Update(SpeedComponent speed, PositionComponent position, SizeComponent size)
{
position.value += speed.value * Time.deltaTime;
if (position.value.x > world.screenRect.xMax - size.value)
{
position.value.x = world.screenRect.xMax - size.value;
speed.value.x = 0f;
}
else if (position.value.x < world.screenRect.xMin + size.value)
{
position.value.x = world.screenRect.xMin + size.value;
speed.value.x = 0f;
}
if (position.value.y > world.screenRect.yMax - size.value)
{
position.value.y = world.screenRect.yMax - size.value;
speed.value.y = 0f;
}
else if (position.value.y < world.screenRect.yMin + size.value)
{
position.value.y = world.screenRect.yMin + size.value;
speed.value.y = 0f;
}
}
}
//操控
public class InputSystem : SystemBase
{
public InputSystem(GameWorld world) : base(world) { }
public void Update(SpeedComponent speed, PositionComponent position)
{
Vector2 delta = (Vector2)world.mainCamera.ScreenToWorldPoint(Input.mousePosition) - position.value;
speed.value = Vector2.ClampMagnitude(speed.value + delta.normalized * Time.deltaTime, speed.maxValue);
}
}
//吞食邏輯
public class EatSystem : SystemBase
{
public EatSystem(GameWorld world) : base(world) { }
public void Update(PositionComponent sourcePosition, SizeComponent sourceSize, PositionComponent targetPosition, SizeComponent targetSize, Entity target)
{
float sizeSum = sourceSize.value + targetSize.value + 0.05f;
if ((sourcePosition.value - target.position.value).sqrMagnitude < sizeSum * sizeSum)
{
sourceSize.value = Mathf.Sqrt(sourceSize.value * sourceSize.value + targetSize.value * targetSize.value);
Kill(target, sourcePosition);
}
}
public void Kill(Entity food, PositionComponent sourcePosition)
{
world.eatingSystem.CreateFrom(food.gameObject, food.position, sourcePosition);
world.entitySystem.RemoveEntity(food);
world.entitySystem.AddRandomEntity();
}
}
//圓推擠
public class CirclePushSystem : SystemBase
{
public CirclePushSystem(GameWorld world) : base(world) { }
public void Update(PositionComponent pos1, SizeComponent size1, PositionComponent pos2, SizeComponent size2)
{
Vector2 center = Vector2.Lerp(pos1.value, pos2.value, size1.value / (size1.value + size2.value));
Vector2 offest = pos1.value - center;
float offestSqrMagnitude = offest.sqrMagnitude;
float sqrRadius = size1.value * size1.value;
if (offestSqrMagnitude < sqrRadius)
{
float offestMagnitude = Mathf.Sqrt(offestSqrMagnitude);
if (offestMagnitude == 0)
offestMagnitude = 0.01f;
float pushMul = Mathf.Min(size1.value - offestMagnitude, (1 - offestMagnitude / size1.value) * Time.deltaTime * 10f);
pos1.value += offest.normalized * pushMul;
}
}
}
這裡要專門提下這個EatingSystem。它對應的是特殊的Component,和Entity無關,是在小球被吃掉時臨時建立並管理它被吃掉的過程動畫,操作的物件也僅僅是GameObjectComponent ,在它被建立之後,原本的Entity的生命週期其實已經結束了。
Entity唯一的作用是連線相關的Component,如果你僅僅關心它的一部分內容,就只需要引用那部分內容。GameObjectComponent就是那個小球的一張皮,我們不需要小球身上的其他邏輯,借用這張皮播一個死亡動畫就可以了。
另外EatingSystem對應的EatingComponent,本身也沒有對應的Entity,因為它並不需要和其他的Component連線起來。
時刻記住,在ECS裡,System直接相關的是Component,而非Entity。沒有什麼比Entity的地位更低了。
//吞食動畫
public class EatingSystem : SystemBase
{
public EatingSystem(GameWorld world) : base(world) { }
public void Update(EatingComponent e)
{
e.go.transform.position = e.GetCurPosition();
if (Time.time >= e.endTime)
{
world.eatings.DelayRemove(e);
world.gameObjectSystem.Remove(e.go);
}
}
public void CreateFrom(GameObjectComponent gameObject, PositionComponent source, PositionComponent target)
{
gameObject.entity.gameObject = null;//解除和原entity的關係
EatingComponent comp = new EatingComponent();
comp.go = gameObject;
comp.target = target;
comp.startOffest = source.value - target.value;
comp.endOffest = Vector2.Lerp(source.value, target.value, 0.5f) - target.value;
comp.Start();
world.eatings.DelayAdd(comp);
}
}
這是這個系統如何和Unity的可視部分連線的。因為System不能保持狀態,Unity的物件是存在專門的Component裡的。除非為了接受事件,儘量不要往Untity的GameObject上新增MonoBehaviour指令碼。除了效能上的考慮(Update涉及到反射),不需要加的東西幹嘛非要加上去呢。
//和Unity顯示部分的橋接
public class GameObjectSystem : SystemBase
{
public GameObjectSystem(GameWorld world) : base(world) { }
public void Add(GameObjectComponent e, PositionComponent position, SizeComponent size, ColorComponent color)
{
e.gameObject = new GameObject("Entity");
e.transform = e.gameObject.transform;
e.transform.localScale = Vector2.one * 0.001f;
e.spriteRenderer = e.gameObject.AddComponent<SpriteRenderer>();
e.spriteRenderer.sprite = UnityEditor.AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/Knob.psd");
Update(e, position, size, color);
}
public void Remove(GameObjectComponent go)
{
GameObject.Destroy(go.gameObject);
go.transform = null;
go.gameObject = null;
go.spriteRenderer = null;
}
public void Update(GameObjectComponent go, PositionComponent position, SizeComponent size, ColorComponent color)
{
go.transform.position = position.value;
go.transform.localScale = Vector2.one * Mathf.MoveTowards(go.transform.localScale.x, size.value * 11f, Mathf.Max(0.01f, Mathf.Abs(go.transform.localScale.x - size.value)) * 10f * Time.deltaTime);
go.spriteRenderer.color = color.value;
}
public void SetToTop(GameObjectComponent go)
{
go.gameObject.AddComponent<SortingGroup>().sortingOrder = 1;
}
}
最後是這個EntitySystem。它的邏輯都是最基本的增刪Entity的過程。本來按道理System應該儘量少訪問Entity,但Entity總得有個人管理才對啊。本來這段是放在GameWorld裡的,猶豫了下還是單獨列成了System,算是個特殊的System吧。而它的依賴關係也是最多的。
//增刪物體和場景初始化
public class EntitySystem : SystemBase
{
public EntitySystem(GameWorld world) : base(world) { }
public void AddEntity(Entity e)
{
world.entitys.DelayAdd(e);
world.gameObjectSystem.Add(e.gameObject, e.position, e.size, e.color);
}
public void RemoveEntity(Entity e)
{
world.entitys.DelayRemove(e);
if (e.gameObject != null)
world.gameObjectSystem.Remove(e.gameObject);
}
public void AddRandomEntity()
{
Entity e = new Entity();
e.size.value = 0.025f;
e.team.id = 0;
e.position.value = new Vector2(Random.Range(world.screenRect.xMin + e.size.value, world.screenRect.xMax - e.size.value), Random.Range(world.screenRect.yMin + e.size.value, world.screenRect.yMax - e.size.value));
AddEntity(e);
}
public void AddMoveAbleEnity(MoveAbleEntity e)
{
this.AddEntity(e);
world.playerEntitys.Add(e);
world.moveSystem.Add(e.speed);
world.gameObjectSystem.SetToTop(e.gameObject);
}
public void InitScene()
{
for (int i = 0; i < 50; i++)
{
AddRandomEntity();
}
for (int i = 0; i < 2; i++)
{
MoveAbleEntity playerEntity = new MoveAbleEntity();
playerEntity.position.value = Vector2.zero;
playerEntity.size.value = 0.05f;
playerEntity.color.value = Color.yellow;
playerEntity.speed.maxValue = 1f;
playerEntity.team.id = 1;
playerEntity.position.value = new Vector2(Random.Range(-0.1f, 0.1f), Random.Range(-0.1f, 0.1f));
AddMoveAbleEnity(playerEntity);
}
}
}
最後就是GameWorld部分了。首先它是一個MonoBehaviour,因為Unity程式的入口必須是MonoBehaviour。它的作用就是儲存遊戲裡的全部物件,因為它們總得有一個地方儲存。在它的Update方法裡,決定不同資料的遍歷邏輯,以及System的執行方式。ECS每個部分基本都很零散,總需要一個地方將它們連線在一起。
Gameworld應該是整個專案中交叉修改最多的一個檔案,但也只有這個檔案會這樣。由於所有System同時也依賴了Gameworld,導致它的可替換性很弱,這也是這個無框架的系統最大的弱點了。
如果希望System可以多專案複用(或者更廣範圍的單元測試),需要對GameWorld做一些解耦處理,比如使用Event系統讓System間通訊,以及對資料提供通用化的儲存方式,還有個辦法是把System對GameWorld依賴的部分介面化……
嘛,需要的時候就做這樣的修改就好了,畢竟要功能總有代價。但畢竟也有大量的團隊並不需要這樣做。耦合低,自然程式就會複雜,複雜就會導致成本,處理不好還會有效能損失和可靠性降低。關鍵在於,這種問題並不是ECS獨有的,任何時候都存在這個權衡問題,沒必要在這裡討論。如果要做到對GameWorld的解耦(同時保證可維護性和效率),程式碼量是肯定要增加的,也會讓我這個示例看起來和別人寫的沒啥區別。
好在要處理的耦合度問題也只有System - GameWorld而已,起碼問題被集中了。
(此外在Update內我有意試圖多寫了幾種遍歷方式,其實並不需要這樣,僅作拋磚引玉用)
public class GameWorld : MonoBehaviour
{
public DList<Entity> entitys;
public DList<SpeedComponent> speeds;
public DList<MoveAbleEntity> playerEntitys;
public DList<EatingComponent> eatings;
public EntitySystem entitySystem;
public MoveSystem moveSystem;
public GameObjectSystem gameObjectSystem;
public InputSystem inputSystem;
public CirclePushSystem circlePushSystem;
public EatSystem eatSystem;
public EatingSystem eatingSystem;
public Camera mainCamera;
public Rect screenRect;
void Start ()
{
if (Camera.main == null)
{
GameObject go = new GameObject("Camera");
mainCamera = go.AddComponent<Camera>();
}
else
{
mainCamera = Camera.main;
}
mainCamera.clearFlags = CameraClearFlags.Color;
mainCamera.backgroundColor = Color.black;
mainCamera.orthographic = true;
mainCamera.orthographicSize = 1f;
mainCamera.nearClipPlane = 0f;
screenRect = Rect.MinMaxRect(-mainCamera.aspect, -1f, mainCamera.aspect, 1f);
entitys = new DList<Entity>();
playerEntitys = new DList<MoveAbleEntity>();
speeds = new DList<SpeedComponent>();
eatings = new DList<EatingComponent>();
entitySystem = new EntitySystem(this);
moveSystem = new MoveSystem(this);
gameObjectSystem = new GameObjectSystem(this);
inputSystem = new InputSystem(this);
eatSystem = new EatSystem(this);
eatingSystem = new EatingSystem(this);
circlePushSystem = new CirclePushSystem(this);
entitySystem.InitScene();
ApplyDelayCommands();//執行延遲增刪陣列內容的操作
}
public void ApplyDelayCommands()
{
entitys.ApplyDelayCommands();
playerEntitys.ApplyDelayCommands();
speeds.ApplyDelayCommands();
eatings.ApplyDelayCommands();
}
void Update ()
{
//遍歷所有Entity並執行所有相關System
foreach (Entity item in entitys)
{
if (item.destroyed)
continue;
gameObjectSystem.Update(item.gameObject, item.position, item.size,item.color);
}
//多對多關係
foreach (MoveAbleEntity player in playerEntitys)
{
if (player.destroyed)
continue;
inputSystem.Update(player.speed,player.position);
foreach (Entity item in entitys)
{
if (item == player || item.destroyed)
continue;
if (item.team.id == 0) //是食物,執行吃邏輯
eatSystem.Update(player.position, player.size, item.position, item.size, item);
else if (item.team.id == 1) //是玩家控制角色,執行圓推擠邏輯
circlePushSystem.Update(player.position, player.size, item.position, item.size);
}
}
//單獨遍歷某些Component
foreach (SpeedComponent speed in speeds)
{
if (speed.destroyed)
continue;
Entity enity = speed.entity;
moveSystem.Update(speed, enity.position, enity.size);
}
//和Entity無關的Component
foreach (EatingComponent item in eatings)
{
if (item.destroyed)
continue;
eatingSystem.Update(item);
}
ApplyDelayCommands();
}
}
最後:
- 本文的寫作理由是:偶爾看到有人說“ECS只適合大專案”,這是一個對這個觀點的反駁。確實某些ECS的“寫法”很適合大專案不適合小專案,但這是那個“寫法”導致的,和ECS本身其實沒啥關係。事實上,在同樣的“寫法”下,ECS相比非ECS還是有不少優點的,而且代價並不高。
- ECS的代價,個人認為是“Component無邏輯產生的反直覺會讓工程師極端不適應”,“基本廢掉了繼承的多型特性,導致繼承無用”。有一種大眾言論是“ECS是反OOP的”,如果把OOP僅僅理解成“封裝,繼承,多型”,這種說法確實沒錯,因為多型才是三者最重要的部分,而ECS確實把繼承的多型特性毀掉了。但是把“OOP”理解成“面向物件程式設計”,那“ECS”則依然是面向物件程式設計的,因為System依然是物件。毀滅了繼承的“多型”雖然可惜,但“多型”還是有很多其他方式可以實現的(比如說利用策略模式)。ECS本身並不會造成專案的可維護性降低。
- ECS雖然解決了一些兩擇難題,但當一段邏輯放在這個System上可以,放另一個System也可以,還是會出現兩擇難題。放到Util類上是能解決,但用不用Util,依然是個兩擇難題。
- ECS還有一個顯而易見的問題。由於邏輯和狀態在兩個不同的類裡,狀態要能訪問就只能全public,這多多少少還是有些隱患。
- 雖然都要求System不能持有狀態,但是假如一個狀態陣列和System強相關,或者僅允許這個System訪問,是否應該允許將陣列放在System內以限制可見度?(Component同理),對於ECS的邏輯限制到底需要遵守到什麼程度還需要摸索。設計模式是用來解決問題的,而非用來遵守的。總想著遵守,最終反而會解決不了問題。這就是所謂的“過度設計”了。