1. 程式人生 > >一個無框架的ECS實現(Entity-Component-System)

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


咱們先從一切的起源說起——

只要是遊戲,大多都會出現這樣一個Enity-Manager系統。因為遊戲本質就是大量實體行為(Enity)以及他們之間的互動(Manager)。

但很顯然,一個遊戲不可能只有兩個類。隨著邏輯的膨脹,出於各種原因都會進行邏輯的拆分。而比起繼承,複合的靈活性更強,所以最後基本都會變成這樣一個狀態:

其實一般的遊戲到這個狀態就可以了,偶爾也會有一些繼承關係穿插其中。但在實際的邏輯編寫過程中,經常會出現一些惱人的兩擇問題:

同一段邏輯,我到底是應該放在Component,還是Manager裡呢?

因為這兩個東西是相互依賴的,放哪兒其實都一樣,而到底放那裡才合適往往並不是那麼容易判斷的。因此過一陣子,即使是程式碼的編寫者也不記得到底是放在Component還是Manager裡了,得兩邊都找一次才可以。假如,Component和Manager並不是簡單的兩級關係,而是多級,就更好玩兒了。

通常,與多個Component相關的邏輯程式碼,放在Manager更合適。但是假如把只和一個Component相關的程式碼放在Manager,也只是看起來有點像靜態方法,有點蠢,但並沒有大礙。所以,經過權衡之後,開發者決定把Component的所有邏輯全部移動到對應的Manager上,以消除這種二擇難題,這就產生了System:


寫作Component,實為Data

這樣移動邏輯之後,由於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-Examplegithub.com

這個示例裡,並沒有框架程式碼。裡面那個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的邏輯限制到底需要遵守到什麼程度還需要摸索。設計模式是用來解決問題的,而非用來遵守的。總想著遵守,最終反而會解決不了問題。這就是所謂的“過度設計”了。