ECS架构分析
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并不是主要解决性能问题的,只是顺带的,不必太过于执着,当然有也是极好的~。
Unity ECS引入了Archetype和Chuck两个概念,Archetype即为Entity对应的所有组件的一个组合,然后多个Archetypes会打包成一个个Archetype chunk,按照顺序放在内存里,当一个chunck满了,会在接下来的内存上的位置创建一个新的chunk。因此,这样的设计在CPU寻址时就会更容易找到Entity相关的component
原型Demo示例
using System;
using System.Collections.Generic;
using System.Threading;
namespaceECSDemo
{
publicclassSingleton<T> whereT : Singleton<T>, new()
{
privatestatic T inst;
publicstatic T Inst
{
get
{
if (inst == null)
inst = new T();
return inst;
}
}
}
#region Component 组件publicclassComponent
{
}
publicclassSingleComp<T> : Singleton<T> whereT : Singleton<T>, new()
{
//
}
#endregion#region Entity 实体publicclassEntityFactory
{
staticlong eid = 0;
publicstatic Entity Create()
{
Entity e = new Entity(eid);
eid++;
EntityChangedMsg.Inst.Pub(e);
return e;
}
publicstatic Entity CreatePlayer()
{
var e = Create();
e.AddComp(new PosiComp());
e.AddComp(new NameComp() { name = "Major" });
return e;
}
publicstatic Entity CreateMonster(string name)
{
var e = Create();
e.AddComp(new PosiComp());
e.AddComp(new NameComp() { name = name });
return e;
}
}
publicclassEntity
{
long instID = 0;
publiclong InstID { get => instID; }
publicEntity(long id) { instID = id; }
// 预计一个Entity组件不会很多,故使用链表...
List<Component> comps = new();
publicvoidAddComp<T>(T t) where T : Component
{
comps.Add(t);
EntityChangedMsg.Inst.Pub(this);
}
publicvoidRemoveComp<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;
returndefault(T);
}
publicboolContrainComp(Type type)
{
foreach (var comp in comps)
if (comp.GetType() == type) returntrue;
returnfalse;
}
}
#endregion#region System 系统publicclassSystem
{
protected SystemMsgBox msgBox = new();
publicvirtualvoidRun()
{
msgBox.Each();
OnRun();
}
publicvirtualvoidOnRun()
{
}
}
publicclassSSystem : System
{
//
}
publicclassDSystem : System
{
protected Dictionary<long, Entity> entities = new();
protected List<Type> conds = new();
HashSet<Entity> evalSet = new();
publicDSystem()
{
msgBox.Sub(EntityChangedMsg.Inst, (msg) => {
var body = (EntityChangedMsg.MsgBody)msg;
var e = body.Value;
evalSet.Add(e);
});
}
publicvoidEvalute(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);
elseif (cache != null) entities.Remove(id);
}
publicoverridevoidRun()
{
msgBox.EachEntityMsg();
foreach (var e in evalSet)
Evalute(e);
evalSet.Clear();
msgBox.Each();
OnRun();
}
}
#endregion#region Pipline 流水线publicclassPipeline<ENode, V>
{
publicclassNode<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; }
publicNode(NENode n)
{
node = n;
}
publicvoidAddChild(Node<NENode, NV> c)
{
childern.Add(c);
c.parent = this;
}
publicvoidRemoveChild(Node<NENode, NV> c)
{
childern.Remove(c);
c.parent = null;
}
publicvoidAddItem(NV v)
{
items.Add(v);
}
publicvoidRemoveItem(NV v)
{
items.Remove(v);
}
}
Node<ENode, V> root;
Dictionary<ENode, Node<ENode, V>> dict = new();
publicPipeline(ENode node)
{
root = new Node<ENode, V>(node);
dict.Add(node, root);
}
publicvoidAddNode(ENode n)
{
Node<ENode, V> p = root;
AddNode(n, p);
}
publicvoidAddNode(ENode n, Node<ENode, V> p)
{
var node = new Node<ENode, V>(n);
p.AddChild(node);
dict.Add(n, node);
}
publicvoidAddNode(ENode n, ENode p)
{
Node<ENode, V> node;
dict.TryGetValue(p, out node);
if (node != null)
AddNode(n, node);
}
publicvoidAddItem(ENode n, V item)
{
Node<ENode, V> node;
dict.TryGetValue(n, out node);
if (node != null)
node.AddItem(item);
}
publicvoidRemoveItem(ENode n, V item)
{
Node<ENode, V> node;
dict.TryGetValue(n, out node);
if (node != null)
node.RemoveItem(item);
}
protectedvoidTraveral(Action<V> action)
{
TraveralInner(root, action);
}
protectedvoidTraveralInner(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);
}
}
publicclassSystemPipeline : Pipeline<ESystemNode, System>
{
publicSystemPipeline(ESystemNode en) : base(en)
{
//
}
publicvoidUpdate()
{
Traveral((sys) => sys.Run());
}
}
publicenum ESystemNode : int
{
Root = 0,
Base = 1,
FrameWork = 2,
GamePlay = 3,
}
#endregion#region World 世界publicclassWorld : Singleton<World>
{
SystemPipeline sysPipe;
publicvoidInit()
{
sysPipe = SystemPipelineTemplate.Create();
}
publicvoidUpdate()
{
sysPipe.Update();
}
}
#endregion#region Event 事件publicclassEvent<T> : Singleton<Event<T>>
{
List<Action<T>> actions = new();
publicvoidSub(Action<T> action)
{
actions.Add(action);
}
publicvoidUnSub(Action<T> action)
{
actions.Remove(action);
}
publicvoidPub(T t)
{
foreach (var action in actions)
action(t);
}
}
publicclassEveEntityChanged : Event<Entity> { }
publicinterfaceIMsgBody
{
Type Type();
}
publicinterfaceIMsg
{
voidSub(MsgBox listener);
voidUnSub(MsgBox listener);
}
publicclassMsg<T> : Singleton<Msg<T>>, IMsg
{
publicclassMsgBody : IMsgBody
{
publicMsgBody(T v, Type ty) { Value = v; type = ty; }
Type type;
public T Value { privateset; get; }
public Type Type()
{
return type;
}
}
List<MsgBox> listeners = new();
publicvoidSub(MsgBox listener)
{
listeners.Add(listener);
}
publicvoidUnSub(MsgBox listener)
{
listeners.Remove(listener);
}
publicvoidPub(T t)
{
var msgBody = new MsgBody(t, this.GetType());
foreach (var listener in listeners)
listener.OnMsg(msgBody);
}
}
publicclassEntityChangedMsg : Msg<Entity> { }
publicclassMsgBox
{
protected Queue<IMsgBody> msgs = new();
protected Dictionary<Type, Action<IMsgBody>> handles = new();
publicvirtualvoidOnMsg(IMsgBody body)
{
msgs.Enqueue(body);
}
publicvoidSub(IMsg msg, Action<IMsgBody> cb)
{
msg.Sub(this);
handles.Add(msg.GetType(), cb);
}
publicvoidUnSub(IMsg msg, Action<IMsgBody> cb)
{
msg.UnSub(this);
handles.Remove(msg.GetType());
}
publicvirtualvoidEach()
{
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);
}
}
}
publicclassSystemMsgBox : MsgBox
{
Queue<IMsgBody> entityMsgs = new();
publicoverridevoidOnMsg(IMsgBody body)
{
if (body.Type() == typeof(EntityChangedMsg))
entityMsgs.Enqueue(body);
else
msgs.Enqueue(body);
}
publicvoidEachEntityMsg()
{
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);
}
}
publicoverridevoidEach()
{
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 AppTestpublicclassAppComp : SingleComp<AppComp>
{
publicbool hasInit;
}
publicclassMapComp : SingleComp<MapComp>
{
publicbool hasInit;
publicint monsterCnt = 2;
}
publicclassPosiComp : Component
{
publicint x;
publicint y;
}
publicclassNameComp : Component
{
publicstring name = "";
}
publicclassAppSystem : SSystem
{
publicoverridevoidOnRun()
{
if (!AppComp.Inst.hasInit)
{
AppComp.Inst.hasInit = true;
Console.WriteLine("App 启动");
}
}
}
publicclassSystemPipelineTemplate
{
publicstatic 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;
}
}
publicclassMapSystem : DSystem
{
publicMapSystem() : base()
{
conds.Add(typeof(PosiComp));
conds.Add(typeof(NameComp));
}
publicoverridevoidOnRun()
{
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}");
}
}
}
publicclassPlayerComp : SingleComp<PlayerComp>
{
public Entity Major;
}
publicclassPlayerSystem : SSystem
{
publicoverridevoidOnRun()
{
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;
}
}
}
}
}
#endregionclassProgram
{
staticvoidMain(string[] args)
{
World.Inst.Init();
while (true)
Loop();
}
publicstaticvoidLoop()
{
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)]
publicclassSystemPipelineAttr : Attribute
{
public ESystemNode Type;
publicSystemPipelineAttr(Type type = null)
{
this.Type = type;
}
}
[SystemPipelineAttr(ESystemNode.GamePlay)]
publicclassMapSystem {} // ...// ...publicstatic 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的设计思想