概述
ECS全称Entity-Component-System,即实体-组件-系统。是一种面向数据(Data-Oriented Programming)的编程架构模式。
这种架构思想是在GDC的一篇演讲《Overwatch Gameplay Architecture and Netcode》(翻成:守望先锋的游戏架构和网络代码)后受到了广泛的学习讨论。在代码设计上有一个原则“组合优于继承”,它的核心设计思想是基于这一思想的“组件式设计”。
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并不是主要解决性能问题的,只是顺带的,不必太过于执着,当然有也是极好的~。
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的设计思想