概述

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

ECS职责定义

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

运行逻辑

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

基本特点

Entity数据结构抽象:

PosiCompMoveCompAttrComp
PosVelocityHp
MapMp
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并不是主要解决性能问题的,只是顺带的,不必太过于执着,当然有也是极好的~。

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

原型Demo示例

using System;using System.Collections.Generic;using System.Threading;namespace ECSDemo{    public class Singleton where T : Singleton, 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 : Singleton where T : Singleton, 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 comps = new();        public void AddComp(T t) where T : Component        {            comps.Add(t);            EntityChangedMsg.Inst.Pub(this);        }        public void RemoveComp(T t) where T : Component        {            comps.Remove(t);            EntityChangedMsg.Inst.Pub(this);        }        public T GetComp() 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 entities = new();        protected List conds = new();        HashSet 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    {        public class Node        {            List items = new();            NENode node;            Node parent;            List<Node> childern = new();            public List<Node> Childern { get => childern; }            public List Items { get => items; }            public Node(NENode n)            {                node = n;            }            public void AddChild(Node c)            {                childern.Add(c);                c.parent = this;            }            public void RemoveChild(Node c)            {                childern.Remove(c);                c.parent = null;            }            public void AddItem(NV v)            {                items.Add(v);            }            public void RemoveItem(NV v)            {                items.Remove(v);            }        }        Node root;        Dictionary<ENode, Node> dict = new();        public Pipeline(ENode node)        {            root = new Node(node);            dict.Add(node, root);        }        public void AddNode(ENode n)        {            Node p = root;            AddNode(n, p);        }        public void AddNode(ENode n, Node p)        {            var node = new Node(n);            p.AddChild(node);            dict.Add(n, node);        }        public void AddNode(ENode n, ENode p)        {            Node node;            dict.TryGetValue(p, out node);            if (node != null)                AddNode(n, node);        }        public void AddItem(ENode n, V item)        {            Node node;            dict.TryGetValue(n, out node);            if (node != null)                node.AddItem(item);        }        public void RemoveItem(ENode n, V item)        {            Node node;            dict.TryGetValue(n, out node);            if (node != null)                node.RemoveItem(item);        }        protected void Traveral(Action action)        {            TraveralInner(root, action);        }        protected void TraveralInner(Node node, Action 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    {        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    {        SystemPipeline sysPipe;        public void Init()        {            sysPipe = SystemPipelineTemplate.Create();        }        public void Update()        {            sysPipe.Update();        }    }    #endregion    #region Event 事件    public class Event : Singleton<Event>    {        List<Action> actions = new();        public void Sub(Action action)        {            actions.Add(action);        }        public void UnSub(Action action)        {            actions.Remove(action);        }        public void Pub(T t)        {            foreach (var action in actions)                action(t);        }    }    public class EveEntityChanged : Event { }    public interface IMsgBody    {        Type Type();    }    public interface IMsg    {        void Sub(MsgBox listener);        void UnSub(MsgBox listener);    }    public class Msg : Singleton<Msg>, 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 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 { }    public class MsgBox    {        protected Queue msgs = new();        protected Dictionary<Type, Action> handles = new();        public virtual void OnMsg(IMsgBody body)        {            msgs.Enqueue(body);        }        public void Sub(IMsg msg, Action cb)        {            msg.Sub(this);            handles.Add(msg.GetType(), cb);        }        public void UnSub(IMsg msg, Action 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 handle;                handles.TryGetValue(type, out handle);                if (handle != null)                    handle(msg);            }        }    }    public class SystemMsgBox : MsgBox    {        Queue 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 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 handle;                handles.TryGetValue(type, out handle);                if (handle != null)                    handle(msg);            }        }    }    #endregion    #region AppTest    public class AppComp : SingleComp    {        public bool hasInit;    }    public class MapComp : SingleComp    {        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().name;                var x = e.GetComp().x;                var y = e.GetComp().y;                Console.WriteLine($"【{name}】 在地图的 x = {x}, y = {y}");            }        }    }    public class PlayerComp : SingleComp    {        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();                    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 GetAssemblyTypes(params Assembly[] args){        Dictionary types = new Dictionary();        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的设计思想