1. 程式人生 > >遊戲開發中的ECS 架構概述

遊戲開發中的ECS 架構概述

0x00 何為ECS架構

ECS,即 Entity-Component-System(實體-元件-系統) 的縮寫,其模式遵循組合優於繼承原則,遊戲內的每一個基本單元都是一個實體,每個實體又由一個或多個元件構成,每個元件僅僅包含代表其特性的資料(即在元件中沒有任何方法),例如:移動相關的元件MoveComponent包含速度、位置、朝向等屬性,一旦一個實體擁有了MoveComponent元件便可以認為它擁有了移動的能力,系統便是來處理擁有一個或多個相同元件實體集合的工具,其只擁有行為(即在系統中沒有任何資料),在這個例子中,處理移動的系統僅僅關心擁有移動能力的實體,它會遍歷所有擁有MoveComponent

元件實體,並根據相關的資料(速度、位置、朝向等),更新實體的位置。

實體元件是一個一對多的關係,實體擁有怎樣的能力,完全是取決於其擁有哪些元件,通過動態新增或刪除元件,可以在(遊戲)執行時改變實體的行為。

0x01 ECS基本結構

一個使用ECS架構開發的遊戲基本結構如下圖所示:

先有一個World,它是系統實體的集合,而實體就是一個ID,這個ID對應了元件的集合。元件用來儲存遊戲狀態並且沒有任何行為,系統擁有處理實體的行為但是沒有狀態。

0x02 詳解ECS中實體、元件與系統

1. 實體

實體只是一個概念上的定義,指的是存在你遊戲世界中的一個獨特物體,是一系列元件的集合。為了方便區分不同的實體,在程式碼層面上一般用一個ID來進行表示。所有組成這個實體的元件將會被這個ID標記,從而明確哪些元件屬於該實體。由於其是一系列元件的集合,因此完全可以在執行時動態地為實體增加一個新的元件或是將元件從實體中移除。比如,玩家實體因為某些原因(可能陷入昏迷)而喪失了移動能力,只需簡單地將移動元件從該實體身上移除,便可以達到無法移動的效果了。

樣例

  • Player(Position, Sprite, Velocity, Health)
  • Enemy(Position, Sprite, Velocity, Health, AI)
  • Tree(Position, Sprite)

注:括號前為實體名,括號內為該實體擁有的元件

2. 元件

一個元件是一堆資料的集合,可以使用C語言中的結構體來進行實現。它沒有方法,即不存在任何的行為,只用來儲存狀態。一個經典的實現是:每一個元件都繼承(或實現)同一個基類(或介面),通過這樣的方法,我們能夠非常方便地在執行時動態新增、識別、移除元件。每一個元件的意義在於描述實體的某一個特性。例如,PositionComponent

(位置元件),其擁有xy兩個資料,用來描述實體的位置資訊,擁有PositionComponent的實體便可以說在遊戲世界中擁有了一席之地。當元件們單獨存在的時候,實際上是沒有什麼意義的,但是當多個元件通過系統的方式組織在一起,才能發揮出真正的力量。同時,我們還可以用空元件(不含任何資料的元件)對實體進行標記,從而在執行時動態地識別它。如,EnemyComponent這個元件可以不含有任何資料,擁有該元件的實體被標記為“敵人”。

根據實際開發需求,這裡還會存在一種特殊的元件,名為 Singleton Component (單例元件),顧名思義,單例元件在一個上下文中有且只有一個。具體在什麼情況下使用下文系統一節中會提到。

樣例

  • PositionComponent(x, y)
  • VelocityComponent(X, y)
  • HealthComponent(value)
  • PlayerComponent()
  • EnemyComponent()

注:括號前為元件名,括號內為該元件擁有的資料

3. 系統

理解了實體和元件便會發現,至此還未曾提到過遊戲邏輯相關的話題。系統便是ECS架構中用來處理遊戲邏輯的部分。何為系統,一個系統就是對擁有一個或多個相同元件的實體集合進行操作的工具,它只有行為,沒有狀態,即不應該存放任何資料。舉個例子,遊戲中玩家要操作對應的角色進行移動,由上面兩部分可知,角色是一個實體,其擁有位置和速度元件,那麼怎麼根據實體擁有的速度去重新整理其位置呢,MoveSystem(移動系統)登場,它可以得到所有擁有位置和速度元件的實體集合,遍歷這個集合,根據每一個實體擁有的速度值和物理引擎去計算該實體應該所處的位置,並重新整理該實體位置元件的值,至此,完成了玩家操控的角色移動了。

注意,我強調了移動系統可以得到所有擁有位置和速度元件的實體集合,因為一個實體同時擁有位置和速度元件,我們便認為該實體擁有移動的能力,因此移動系統可以去重新整理每一個符合要求的實體的位置。這樣做的好處在於,當我們玩家操控的角色因為某種原因不能移動時,我們只需要將速度元件從該實體中移除,移動系統就得不到角色的引用了,同樣的,如果我們希望遊戲場景中的某一個物件動起來,只需要為其新增一個速度元件就萬事大吉。

一個系統關心實體擁有哪些元件是由我們決定的,通過一些手段,我們可以在系統中很快地得到對應實體集合。

上文提到的 Singleton Component (單例元件) ,明白了系統的概念更容易說明,還是玩家操作角色的例子,該實體速度元件的值從何而來,一般情況下是根據玩家的操作輸入去賦予對應的數值。這裡就涉及到一個新元件InputComponent(輸入元件)和一個新系統ChangePlayerVelocitySystem(改變玩家速度系統),改變玩家速度系統會根據輸入元件的值去改變玩家速度,假設還有一個系統FireSystem(開火系統),它會根據玩家是否輸入開火鍵進行開火操作,那麼就有 2 個系統同時依賴輸入元件,真實遊戲情況可能比這還要複雜,有無數個系統都要依賴於輸入元件,同時擁有輸入元件的實體在遊戲中僅僅需要有一個,每幀去重新整理它的值就可以了,這時很容易讓人想到單例模式(便捷地訪問、只有一個引用),同樣的,單例元件也是指整個遊戲世界中有且只有一個實體擁有該元件,並且希望各系統能夠便捷的訪問到它,經過一些處理,在任何系統中都能通過類似world->GetSingletonInput()的方法來獲得該元件引用。

系統這裡比較麻煩,還存在一個常見問題:由於程式碼邏輯分佈於各個系統中,各個系統之間為了解耦又不能互相訪問,那麼如果有多個系統希望運行同樣的邏輯,該如何解決,總不能把程式碼複製 N 份,放到各個系統之中。UtilityFunction(實用函式) 便是用來解決這一問題的,它將被多個系統呼叫的方法單獨提取出來,放到統一的地方,各個系統通過 UtilityFunction 呼叫想執行的方法,同系統一樣, UtilityFunction 中不能存放狀態,它應該是擁有各個方法的純淨集合。

樣例

  • MoveSystem(Position, Velocity)
  • RenderSystem(Position, Sprite)

注:括號前為系統名,括號內為該系統關心的元件集合

0x03 ECS架構實戰

接下來終於到了實戰環節,這裡筆者使用 Unity3d 遊戲引擎(5.6.3p4),配合現成的 Entitas 框架來實現一個小 Demo。由於 Unity3d 遊戲引擎已經為我們提供了輸入類和物理引擎,因此 Demo 中有部分內容可能與上文不太一致,主要以展示整體架構為主,請讀者忽略這些細節。

1. Entitas介紹

Entitas is a super fast Entity Component System Framework (ECS) specifically made for C# and Unity. Internal caching and blazing fast component access makes it second to none. Several design decisions have been made to work optimal in a garbage collected environment and to go easy on the garbage collector. Entitas comes with an optional code generator which radically reduces the amount of code you have to write and makes your code read like well written prose.

以上是 Entitas 官方介紹,簡單來說該框架提供了程式碼生成器,只需要按照它的規範實現元件和系統,便可以一鍵生成我們需要的屬性和方法,同時為了方便我們在系統中獲得感興趣的元件,它還提供了強大的分組、匹配功能。多說無益,直接開始實戰吧。

2. 實戰

下載Unity3d遊戲引擎的步驟這裡就省略了,我們先從 Github 上下載 Entitas,筆者這裡使用的是 Entitas 0.42.4 。下載好解壓後,將其 CodeGenerator 和 Entitas 目錄匯入到一個新的 Unity 工程(這裡一切從簡,建立了一個空的 2D 專案),如下圖所示。

接著,在工具欄找到 Tools -> Entitas ->Preference 對 Entitas 進行配置,由於這只是一個演示 ECS架構的小 Demo,就不對各種配置項進行解釋了,對這些感興趣的同學可以去官網檢視文件,配置如下:

點選綠色按鈕 Generate,如果沒有任何報錯,則配置沒有問題。接下來就可以開始寫程式碼了。

我們 Demo 的目標是控制一個矩形進行上下左右移動。由上文可知,我們至少需要 2 個元件:PositionComponentVelocityComponent。在 Scripts/Components 目錄下分別新建這兩個指令碼:

// PositionComponent.cs
using Entitas;
using UnityEngine;

public class PositionComponent : IComponent
{
    public Vector2 Value;
}
// VelocityComponent.cs
using Entitas;
using UnityEngine;

public class VelocityComponent : IComponent {
    public Vector2 Value;
}

由於在我們 Demo 中,玩家只能操控一個矩形,我們需要對其進行標記,告訴系統這個實體是玩家的代表,於是我們還要加上一個PlayerComponent來進行標記。

// PlayerComponent.cs
using Entitas;

public class PlayerComponent : IComponent { }

它不需要任何資料,僅僅用自身就可以實現標記的效果,擁有該元件的實體便是我們玩家控制的代表了。

實現完這 3 個元件後,我們需要利用 Entitas 框架提供的程式碼生成器,生成一下相應的程式碼,Tools -> Entitas -> Generate 或者快捷鍵control + shift + g

沒有看到任何報錯,很好我們繼續。

接著我們要實現ChangePlayerVelocitySystem,它每一幀都會執行,根據玩家是否輸入wasd來改變矩形的速度。

// ChangePlayerVelocitySystem.cs
using Entitas;
using UnityEngine;

public class ChangePlayerVelocitySystem : IExecuteSystem
{
    // 每一幀都會執行
    public void Execute()
    {
        // 得到擁有 Player、Position、Velocity 元件的實體集合
        var playerCollection = Contexts.sharedInstance.game.GetGroup(
            GameMatcher.AllOf(
                GameMatcher.Player,
                GameMatcher.Position,
                GameMatcher.Velocity));

        var velocity = Vector2.zero;
        if (Input.GetKey(KeyCode.W))
        {
            velocity.y += 1;
        }

        if (Input.GetKey(KeyCode.S))
        {
            velocity.y -= 1;
        }

        if (Input.GetKey(KeyCode.A))
        {
            velocity.x -= 1;
        }

        if (Input.GetKey(KeyCode.D))
        {
            velocity.x += 1;
        }

        foreach (var player in playerCollection)
        {
            player.ReplaceVelocity(velocity);
        }
    }
}

這裡實現了IExecuteSystem介面,每一幀其Execute方法都會執行。

至此,我們每一幀都會根據使用者的輸入去改變矩形的速度,還需要一個ChangePositionSystem,它會根據實體身上速度元件的值,去改變位置元件的值。

// ChangePositionSystem.cs
using System.Collections.Generic;
using Entitas;
using UnityEngine;

public class ChangePositionSystem : ReactiveSystem<GameEntity>
{
    public ChangePositionSystem(Contexts contexts) : base(contexts.game)
    {
    }

    protected override ICollector<GameEntity> GetTrigger(IContext<GameEntity> context)
    {
        return context.CreateCollector(GameMatcher.AllOf(GameMatcher.Position, GameMatcher.Velocity));
    }

    protected override bool Filter(GameEntity entity)
    {
        return entity.hasPosition && entity.hasVelocity;
    }

    protected override void Execute(List<GameEntity> entities)
    {
        foreach (var entity in entities)
        {
            var velocity = entity.velocity.Value;
            var newPosition = entity.position.Value + velocity * Time.deltaTime;

            entity.ReplacePosition(newPosition);
        }
    }
}

這裡我們用到了ReactiveSystem<GameEntity>基類,稍微講解一下,它應該算是一種特殊的IExecuteSystem介面實現,它也會每一幀都執行,但它會幫助我們監聽我們感興趣的元件,只有當這些元件發生變化時,它的Execute方法才會被呼叫,GetTriggerFilter兩個方法相當於過濾器,具體就不細講了,可以去官網檢視一下文件。

由於使用了 Unity3d 遊戲引擎,我們的框架需要由引擎來驅動,因此我們還要新增一個繼承自MonoBehaviourGameController指令碼,在其中的Start方法裡實例化各個系統,Update方法裡呼叫Excute

// GameController.cs
using UnityEngine;
using Entitas;

public class GameController : MonoBehaviour
{

    private Systems _systems;

    private void Start()
    {
        Contexts contexts = Contexts.sharedInstance;

        // 建立系統
        _systems = CreateSystems(contexts);

        // 建立我們的玩家實體
        var player = contexts.game.CreateEntity();
        // 為其新增相應的元件
        player.isPlayer = true;
        player.AddPosition(Vector2.zero);
        player.AddVelocity(Vector2.zero);

        // 初始化系統
        _systems.Initialize();
    }

    private void Update()
    {
        _systems.Execute();
        _systems.Cleanup();
    }

    private void OnDestroy()
    {
        _systems.TearDown();
    }

    private Systems CreateSystems(Contexts contexts)
    {
        // Feature 是 Entitas 框架提供的在 Editor 下進行除錯的類
        return new Feature("Game")
            .Add(new ChangePlayerVelocitySystem())
            .Add(new ChangePositionSystem(contexts));
    }
}

在場景中新建一個名為“GameController”的空物體,將該指令碼新增上去,運行遊戲,在“Hierarchy”頁簽下就可以看到我們建立的系統和實體了,如下圖:

當我們按下wasd時,可以看到左側 Position 下面的數值和 Velocity 下面的數值都根據我們的輸入產生了對應的變化,這說明功能實現的沒有問題。

至此,雖然還沒有圖形顯示在場景中,但一個可操控的 Demo 已經完成了。

為了節省篇幅,SpriteComponent(精靈元件)和RenderSystem(渲染系統),這裡就不再展示了,完整專案可以在我的 Github 裡檢視。

0x04 後記

到此,整篇文章也進入了尾聲,不知讀者是否對 ECS 架構有了自己的理解,其實筆者也是最近這段時間才開始使用該架構編寫一些小專案,還未在商業專案中使用過,因此有些地方的理解可能存在一定的偏差,歡迎大家討論與指正,感謝大家的閱讀。

參考