概述
ECS全称Entity-Component-System,即实体-组件-系统。是一种面向数据(Data-Oriented Programming)的编程架构模式。
这种架构思想是在GDC的一篇演讲《Overwatch Gameplay Architecture and Netcode》(翻成:守望先锋的游戏架构和网络代码)后受到了广泛的学习讨论。在代码设计上有一个原则“组合优于继承”,它的核心设计思想是基于这一思想的“组件式设计”。
ECS的基本类型
- Entity(实体):在ECS架构中表示“一个单位”,可以被ECS内部标识,可以挂载若干组件。
- Component(组件):挂载在Entity上的组件,负载实体某部分的属性,是纯数据结构不包含函数。
- System(系统):纯函数不包含数据,只关心具有某些特定属性(组件)的Entity,对这些属性进行处理。
运行逻辑
某个业务系统筛选出拥有这个业务系统相关组件的实体,对这些实体的相关组件进行处理更新。
基本特点
Entity数据结构抽象:
PosiCompMoveCompAttrComp...PosVelocityHp...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
原型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> 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
- {
- 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的设计思想
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |