ECS架构分析

时间:2020-12-27 00:41:30

ECS全称Entity-Component-System,即实体-组件-系统。是一种面向数据(Data-Oriented Programming)的编程架构模式。
这种架构思想是在GDC的一篇演讲《Overwatch Gameplay Architecture and Netcode》(翻成:守望先锋的游戏架构和网络代码)后受到了广泛的学习讨论。在代码设计上有一个原则“组合优于继承”,它的核心设计思想是基于这一思想的“组件式设计”。

ECS职责定义

ECS架构分析

  • Entity(实体):在ECS架构中表示“一个单位”,可以被ECS内部标识,可以挂载若干组件。
  • Component(组件):挂载在Entity上的组件,负载实体某部分的属性,是纯数据结构不包含函数。
  • System(系统):纯函数不包含数据,只关心具有某些特定属性(组件)的Entity,对这些属性进行处理。

运行逻辑

某个业务系统筛选出拥有这个业务系统相关组件的实体,对这些实体的相关组件进行处理更新。

基本特点

Entity数据结构抽象:

PosiComp MoveComp AttrComp ...
Pos Velocity Hp ...
Map - Mp ...
- - ATK ...
  • 组件内聚本业务相关的属性,某个实体不同业务的属性通过组件聚合在一起。
    • 从数据结构角度上看,Entity类似一个2维的稀疏表,如上述Entity数据结构抽象
    • OOP的思路知道类型就知道了这个对象的属性,ECS的实体是知道了有哪些组件知道这个实体大概是什么,有点像鸭子理论:如果走路像鸭子、说话像鸭子、长得像鸭子、啄食也像鸭子,那它肯定就是一只鸭子。
  • 业务系统收集所有具有本业务要求组件的Entity,集中批量的处理这些Entity的相关组件

推论

  • ECS的组件式设计,是高内聚、低耦合的,对千变万化的业务需求十分友好
  • 批量处理数据在这些数据在连续内存的场合下对CPU缓存机制友好
  • 低数据耦合可以减少资源竞争对并行友好
  • ECS处理数据的方式是批量处理的,一个实体需要连续处理的场合十分不友好

个人见解

个人认为ECS架构的核心是为了解决对象中复杂的聚合问题,能有效的管理代码的复杂度,至于某些场合下的性能的提升,在大多数情况下只是锦上添花的作用(一些SLG游戏具有大量单位可能会有提升吧)。它没有传统OOP编程模式的复杂的继承关系造成的不必要的耦合,结构更加扁平化,相比之下更易于业务的阅读理解和拓展。但这种技术并非是完美无缺的,它十分不擅长单个实体需要连续处理业务(如序列化等)或实体之间相互关联等场合(如更新两个实体的距离),而且对于一些业务逻辑相对固定的模块或者一些底层模块来说,松耦合和管理复杂度可能不是首要问题,有可能在设计上硬拗ECS组件式设计反而带来困扰。对于游戏来说,ECS架构在GamePlay上的实用程度相对较高,在其他符合其特性的模块如(网络模块)也能提供一些不同以往的解题思路。

细节讨论

单例组件

Q:有些数据只需要一份或被全局访问等情况下,没必要挂载在Entity上和筛选
A:使用单例组件,和其他组件一样是纯数据,但是可以通过单例全局访问,即可以被任意系统任意访问。

工具方法

Q:有些处理方法,不适合进行批量处理(例如计算两个单位的距离,没必要弄个系统每个单位都相互计算距离)
A:用工具方法,它通常是无副作用的,不会改变任何状态,只返回计算结果

System之间的依赖关系

Q: 假设有渲染系统和碰撞系统,要像在这一帧正确的渲染目标的位置,就需要碰撞系统先更新位置信息,渲染系统在进行位置,需要正确处理系统间的前后依赖关系。

A:一个很自然的思路就是分层,根据不同层级的优先级进行处理。由此提出流水线(Piepline)的抽象,定义一颗树和相关节点,系统挂载在其节点上,运行时以某种顺序(先序遍历)展开,同一个节点的系统可以并行(没有依赖)。有需要的话流水线还可以定义系统/实体/组件的初始化等其他问题。

System对Entity的筛选

Q:“原教旨主义”的ECS框架有ECS帧的概念,系统会在每一帧重新筛选需要处理的Entity。这种处理方式引起了很大的争论,大家认为是有一些优化空间。

A:社区中几乎没人赞同“原教旨主义”的做法,原因很简单:很多Entity在整个生命周期中都没组件的增删操作,还有相当部分有的有增删操作的Entity其操作频率也很低,每帧都遍历重新筛选代价相对太过昂贵,所以有人提出了缓存、分类、延迟增删操作等思路。一种思路是:Entity的增删/组件的增删的操作进行缓存,延迟到该系统运行时在进行评估筛选,以减少遍历和重复操作。

Entity是否在运行期动态更改组件分类&System是否每帧筛选Entity分类

Q:并不是每个Entity运行期都会改变动态变更组件,有些Entity在运行期压根就不变更组件,甚至它只被编译期就知道的指定System处理。也有些System不在运行期筛选Entity,要么编译期就知道处理哪些Entity,要么是处理一些单例组件。所以有人提出要不要对Entity和System对它们是否在运行期动态操作进行分类,以提升效率。

A:个人认为,Entity不变更组件,本身变动消息就很少只有增删,配合一些缓存、延迟筛选等方法其实没什么影响。不动态筛选Entity的System倒是可以分类型关闭Entity筛选。

是否加入响应式处理

Q:ECS是“自驱式”的更新,就像是U3D的Mono的Update方法更新。还有一种响应式的更新,即基于消息事件的通知。“原教旨主义”式的ECS框架是完全自驱的,没有消息机制。系统之间“消息传递”是通过组件的数据传递的,所以在处理“当进入地图时”这种场合,只能使用“HasEnterMap”或者“Enum.EnterMap”之类的标签,或者添加一个“EnterMapComponent"来处理。

A:个人倾向于加入一些消息的处理机制,可以更灵活些。基本思路是:给System添加一个收件箱,收到的消息放在收件箱的队列里。Entity相关变更(增删、变更组件)的一些消息单独使用一个队列管道,在系统刷新的时候首先处理Entity变更消息,进行评估筛选Entity,然后处理信箱里的其他消息,然后在处理System的更新逻辑。

内存效率优化

Q:批量处理数据在物理内存连续的场合有利于CPU缓存机制,关键是如何让数据的内存连续。首先想到的是使用数组,那么是组件使用数组还是Entity使用数组呢?
A:如果是组件使用数组,那么当系统处理的Entity包含多个组件的话,那么内存访问会在不同的数组中“跳来跳去”,优化效果十分有限。个人认为若是一定要优化内存访问,关键是保证组件一样的Entity存放在连续内存(Chuck)中,这样保证System访问Entity的内存连续,具体实现方案可以参考U3D的ECS设计Archetype和Chuck。另外,也有对象池的优化空间。上面提到,ECS并不是主要解决性能问题的,只是顺带的,不必太过于执着,当然有也是极好的~。

Unity ECS引入了Archetype和Chuck两个概念,Archetype即为Entity对应的所有组件的一个组合,然后多个Archetypes会打包成一个个Archetype chunk,按照顺序放在内存里,当一个chunck满了,会在接下来的内存上的位置创建一个新的chunk。因此,这样的设计在CPU寻址时就会更容易找到Entity相关的component

ECS架构分析

原型Demo示例

using System;
using System.Collections.Generic;
using System.Threading;

namespace ECSDemo
{
    public class Singleton<T> where T : Singleton<T>, new()
    {
        private static T inst;

        public static T Inst
        {
            get
            {
                if (inst == null)
                    inst = new T();
                return inst;
            }
        }
    }

    #region Component 组件
    public class Component
    {

    }

    public class SingleComp<T> : Singleton<T> where T : Singleton<T>, new()
    {
        //
    }
    #endregion 

    #region Entity 实体

    public class EntityFactory
    {
        static long eid = 0;

        public static Entity Create()
        {
            Entity e = new Entity(eid);
            eid++;
            EntityChangedMsg.Inst.Pub(e);
            return e;
        }

        public static Entity CreatePlayer()
        {
            var e = Create();
            e.AddComp(new PosiComp());
            e.AddComp(new NameComp() { name = "Major" });
            return e;
        }

        public static Entity CreateMonster(string name)
        {
            var e = Create();
            e.AddComp(new PosiComp());
            e.AddComp(new NameComp() { name = name });

            return e;
        }
    }

    public class Entity
    {
        long instID = 0;

        public long InstID { get => instID; }

        public Entity(long id) { instID = id; }

        // 预计一个Entity组件不会很多,故使用链表...
        List<Component> comps = new();

        public void AddComp<T>(T t) where T : Component
        {
            comps.Add(t);
            EntityChangedMsg.Inst.Pub(this);
        }

        public void RemoveComp<T>(T t) where T : Component
        {
            comps.Remove(t);
            EntityChangedMsg.Inst.Pub(this);
        }

        public T GetComp<T>() where T : Component
        {
            foreach (var comp in comps)
                if (comp is T) return comp as T;

            return default(T);
        }

        public bool ContrainComp(Type type)
        {
            foreach (var comp in comps)
                if (comp.GetType() == type) return true;
            return false;
        }
    }
    #endregion

    #region System 系统
    public class System
    {
        protected SystemMsgBox msgBox = new();

        public virtual void Run()
        {
            msgBox.Each();
            OnRun();
        }

        public virtual void OnRun()
        {

        }
    }

    public class SSystem : System
    {
        //
    }

    public class DSystem : System
    {
        protected Dictionary<long, Entity> entities = new();
        protected List<Type> conds = new();
        HashSet<Entity> evalSet = new();

        public DSystem()
        {
            msgBox.Sub(EntityChangedMsg.Inst, (msg) => {
                var body = (EntityChangedMsg.MsgBody)msg;
                var e = body.Value;
                evalSet.Add(e);
            });
        }

        public void Evalute(Entity e)
        {
            var id = e.InstID;
            bool test = true;
            foreach (var cond in conds)
                if (!e.ContrainComp(cond))
                {
                    test = false;
                    break;
                }

            Entity cache;
            entities.TryGetValue(id, out cache);
            if (test)
                if (cache == null) entities.Add(id, e);
                else
                if (cache != null) entities.Remove(id);
        }

        public override void Run()
        {
            msgBox.EachEntityMsg();
            foreach (var e in evalSet)
                Evalute(e);
            evalSet.Clear();
            msgBox.Each();
            OnRun();
        }
    }
    #endregion

    #region Pipline 流水线
    public class Pipeline<ENode, V>
    {
        public class Node<NENode, NV>
        {
            List<NV> items = new();
            NENode node;
            Node<NENode, NV> parent;
            List<Node<NENode, NV>> childern = new();

            public List<Node<NENode, NV>> Childern { get => childern; }
            public List<NV> Items { get => items; }

            public Node(NENode n)
            {
                node = n;
            }

            public void AddChild(Node<NENode, NV> c)
            {
                childern.Add(c);
                c.parent = this;
            }

            public void RemoveChild(Node<NENode, NV> c)
            {
                childern.Remove(c);
                c.parent = null;
            }

            public void AddItem(NV v)
            {
                items.Add(v);
            }

            public void RemoveItem(NV v)
            {
                items.Remove(v);
            }

        }

        Node<ENode, V> root;
        Dictionary<ENode, Node<ENode, V>> dict = new();
        public Pipeline(ENode node)
        {
            root = new Node<ENode, V>(node);
            dict.Add(node, root);
        }

        public void AddNode(ENode n)
        {
            Node<ENode, V> p = root;
            AddNode(n, p);
        }

        public void AddNode(ENode n, Node<ENode, V> p)
        {
            var node = new Node<ENode, V>(n);
            p.AddChild(node);
            dict.Add(n, node);
        }

        public void AddNode(ENode n, ENode p)
        {
            Node<ENode, V> node;
            dict.TryGetValue(p, out node);
            if (node != null)
                AddNode(n, node);
        }

        public void AddItem(ENode n, V item)
        {
            Node<ENode, V> node;
            dict.TryGetValue(n, out node);
            if (node != null)
                node.AddItem(item);
        }

        public void RemoveItem(ENode n, V item)
        {
            Node<ENode, V> node;
            dict.TryGetValue(n, out node);
            if (node != null)
                node.RemoveItem(item);
        }

        protected void Traveral(Action<V> action)
        {
            TraveralInner(root, action);
        }

        protected void TraveralInner(Node<ENode, V> node, Action<V> action)
        {
            var childern = node.Childern;
            var items = node.Items;
            foreach (var child in childern)
                TraveralInner(child, action);
            foreach (var item in items)
                action(item);
        }
    }

    public class SystemPipeline : Pipeline<ESystemNode, System>
    {
        public SystemPipeline(ESystemNode en) : base(en)
        {
            //
        }

        public void Update()
        {
            Traveral((sys) => sys.Run());
        }
    }

    public enum ESystemNode : int
    {
        Root = 0,
        Base = 1,
        FrameWork = 2,
        GamePlay = 3,
    }

    
    #endregion

    #region World 世界
    public class World : Singleton<World>
    {
        SystemPipeline sysPipe;

        public void Init()
        {
            sysPipe = SystemPipelineTemplate.Create();
        }

        public void Update()
        {
            sysPipe.Update();
        }
    }
    #endregion

    #region Event 事件
    public class Event<T> : Singleton<Event<T>>
    {
        List<Action<T>> actions = new();

        public void Sub(Action<T> action)
        {
            actions.Add(action);
        }

        public void UnSub(Action<T> action)
        {
            actions.Remove(action);
        }

        public void Pub(T t)
        {
            foreach (var action in actions)
                action(t);
        }
    }

    public class EveEntityChanged : Event<Entity> { }

    public interface IMsgBody
    {
        Type Type();
    }

    public interface IMsg
    {
        void Sub(MsgBox listener);
        void UnSub(MsgBox listener);
    }

    public class Msg<T> : Singleton<Msg<T>>, IMsg
    {
        public class MsgBody : IMsgBody
        {
            public MsgBody(T v, Type ty) { Value = v; type = ty; }
            Type type;
            public T Value { private set; get; }

            public Type Type()
            {
                return type;
            }
        }

        List<MsgBox> listeners = new();

        public void Sub(MsgBox listener)
        {
            listeners.Add(listener);
        }

        public void UnSub(MsgBox listener)
        {
            listeners.Remove(listener);
        }

        public void Pub(T t)
        {
            var msgBody = new MsgBody(t, this.GetType());
            foreach (var listener in listeners)
                listener.OnMsg(msgBody);
        }
    }

    public class EntityChangedMsg : Msg<Entity> { }

    public class MsgBox
    {
        protected Queue<IMsgBody> msgs = new();
        protected Dictionary<Type, Action<IMsgBody>> handles = new();

        public virtual void OnMsg(IMsgBody body)
        {
            msgs.Enqueue(body);
        }


        public void Sub(IMsg msg, Action<IMsgBody> cb)
        {
            msg.Sub(this);
            handles.Add(msg.GetType(), cb);
        }

        public void UnSub(IMsg msg, Action<IMsgBody> cb)
        {
            msg.UnSub(this);
            handles.Remove(msg.GetType());
        }

        public virtual void Each()
        {
            while (msgs.Count != 0)
            {
                var msg = msgs.Dequeue();
                var type = msg.Type();
                Action<IMsgBody> handle;
                handles.TryGetValue(type, out handle);
                if (handle != null)
                    handle(msg);
            }
        }
    }

    public class SystemMsgBox : MsgBox
    {
        Queue<IMsgBody> entityMsgs = new();

        public override void OnMsg(IMsgBody body)
        {
            if (body.Type() == typeof(EntityChangedMsg))
                entityMsgs.Enqueue(body);
            else
                msgs.Enqueue(body);
        }

        public void EachEntityMsg()
        {
            while (entityMsgs.Count != 0)
            {
                var msg = entityMsgs.Dequeue();
                var type = msg.Type();
                Action<IMsgBody> handle;
                handles.TryGetValue(type, out handle);
                if (handle != null)
                    handle(msg);
            }
        }

        public override void Each()
        {
            while (msgs.Count != 0)
            {
                var msg = msgs.Dequeue();
                var type = msg.Type();
                Action<IMsgBody> handle;
                handles.TryGetValue(type, out handle);
                if (handle != null)
                    handle(msg);
            }
        }
    }

    #endregion

    #region AppTest

    public class AppComp : SingleComp<AppComp>
    {
        public bool hasInit;
    }

    public class MapComp : SingleComp<MapComp>
    {
        public bool hasInit;
        public int monsterCnt = 2;
    }

    public class PosiComp : Component
    {
        public int x;
        public int y;
    }

    public class NameComp : Component
    {
        public string name = "";
    }

    public class AppSystem : SSystem
    {
        public override void OnRun()
        {
            if (!AppComp.Inst.hasInit)
            {
                AppComp.Inst.hasInit = true;
                Console.WriteLine("App 启动");
            }
        }
    }

    public class SystemPipelineTemplate
    {
        public static SystemPipeline Create()
        {
            SystemPipeline pipeline = new(ESystemNode.Root);

            // 基本系统
            pipeline.AddNode(ESystemNode.Base, ESystemNode.Root);
            pipeline.AddItem(ESystemNode.Base, new AppSystem());

            pipeline.AddNode(ESystemNode.GamePlay, ESystemNode.Root);
            pipeline.AddItem(ESystemNode.GamePlay, new PlayerSystem());
            pipeline.AddItem(ESystemNode.GamePlay, new MapSystem());

            return pipeline;
        }
    }

    public class MapSystem : DSystem
    {
        public MapSystem() : base()
        {
            conds.Add(typeof(PosiComp));
            conds.Add(typeof(NameComp));
        }

        public override void OnRun()
        {
            if (!MapComp.Inst.hasInit)
            {
                MapComp.Inst.hasInit = true;
                for (int i = 0; i < MapComp.Inst.monsterCnt; i++)
                    EntityFactory.CreateMonster($"Monster{i + 1}");

                Console.WriteLine($"进入地图 生成{MapComp.Inst.monsterCnt}只小怪");
            }

            foreach (var (id, e) in entities)
            {
                var name = e.GetComp<NameComp>().name;
                var x = e.GetComp<PosiComp>().x;
                var y = e.GetComp<PosiComp>().y;

                Console.WriteLine($"【{name}】 在地图的 x = {x}, y = {y}");

            }
        }
    }

    public class PlayerComp : SingleComp<PlayerComp>
    {
        public Entity Major;
    }

    public class PlayerSystem : SSystem
    {
        public override void OnRun()
        {
            base.OnRun();
            if (PlayerComp.Inst.Major == null)
                PlayerComp.Inst.Major = EntityFactory.CreatePlayer();

            if (Console.KeyAvailable)
            {
                int dx = 0;
                int dy = 0;
                ConsoleKeyInfo key = Console.ReadKey(true);
                switch (key.Key)
                {
                    case ConsoleKey.A:
                        dx = -1;
                        break;
                    case ConsoleKey.D:
                        dx = 1;
                        break;
                    case ConsoleKey.W:
                        dy = 1;
                        break;
                    case ConsoleKey.S:
                        dy = -1;
                        break;


                    default:
                        break;
                }

                if (dx != 0 || dy != 0)
                {
                    var comp = PlayerComp.Inst.Major.GetComp<PosiComp>();
                    if (comp != null)
                    {
                        Console.WriteLine($"玩家移动 Delta X = {dx}, Delta Y = {dy}");
                        comp.x += dx;
                        comp.y += dy;
                    }
                }
            }
        }
    }

    #endregion

    class Program
    {
        static void Main(string[] args)
        {
            World.Inst.Init();
            while (true)
                Loop();
        }

        public static void Loop()
        {
            World.Inst.Update();
            Console.WriteLine("--------------------------------------------");
            Thread.Sleep(1000);
        }
    }
}

  • Demo包含了ECS的基本定义和分层、筛选、消息等机制,简单的原型多看下应该可以看明白。
  • 当XXX的消息使用组件的数据HasInit实现,当然也可以使用消息,思路是:给System加虚函数Awake、Start、End、Destory等虚函数,SystemPipeline初始化时两次遍历分别Awake、Start,同样,清理时两次遍历调用End、Destory函数。可以在Start时监听一些消息,在End时清理。
  • Pipeline流水线有一种更加自动化的绑定节点的方法:使用C#的特性(Attribute)标记System,在程序启动通过反射自动组装。大概类似这样:
[AttributeUsage(AttributeTargets.Class)]
public class SystemPipelineAttr : Attribute
{
    public ESystemNode Type;

    public SystemPipelineAttr(Type type = null)
    {
        this.Type = type;
    }
}

[SystemPipelineAttr(ESystemNode.GamePlay)]
public class MapSystem {} // ...

// ...
public static Dictionary<string, Type> GetAssemblyTypes(params Assembly[] args)
{
        Dictionary<string, Type> types = new Dictionary<string, Type>();

        foreach (Assembly ass in args)
        {
            foreach (Type type in ass.GetTypes())
            {
                types[type.FullName] = type;
            }
        }

        return types;
}

// ...
foreach (Type type in types[typeof (SystemPipelineAttr)])
{
	object[] attrs = type.GetCustomAttributes(typeof(SystemPipelineAttr), false);
	foreach (object attr in attrs)
	{
		SystemPipelineAttr attribute = attr as SystemPipelineAttr;
		// ...
	}
}

备注

  • ECS的架构目前使用的非常的多,很多有名的框架设计都或多或少的受到了其影响,有:
    • U3D的ECS架构:不是指原来的GameObj那套,有专门的插件,有内存优化
    • UE4的组件设计:采用了特殊的组件实现父子关系
    • ET框架:消息 + ECS,采用ECS解耦,更注重消息驱动的响应式设计,Entity和Comp的思路也独特:Entity同时是组件,并有父子关系
    • 云风大佬的引擎:好像未开源,只有一些blog在讨论ECS,貌似连引擎层面和Lua侧都涉及ECS的设计思想