ECS由三个部分构成:
Entity:实体、个体。由多个component组成
Component:组件、数据。由数据组成
System:系统。由逻辑组成
组件Component是一个基类,有几百个子类,每个子类都有System执行Behavior时所需的成员变量。这里的多态是用来重写Create,以及使用虚析构函数管理声明周期,帮助回收垃圾。另外可能有访问内部状态的helper函数,除此之外Component不应该有Behavior。
class Component{
virtual void Create(resource* m_resource);
virtual ~Component();
}
一个Entity其实像是一个对象,可以代表游戏世界中的任意对象,而其内部包含了若干个Component,拥有全局唯一的EntityID,用于标识Entity本身,ID是一个32位无符号整数。
class Entity{
unsigned int ID;
vector<Component> components;
}
而System则是系统,是行为,用来制定游戏规则。System本身没有数据,只有方法。在应用中,System之间不可以直接通信,并且一个System只关心某一个固定的Component组合,这个组合称为tuple。
**使用一个System的条件:**System会遍历所有Entity,若Entity当中,拥有System中tuple指定的所有Component,则对该Entity进行处理。
System本身并不关心Entity是谁,它只关心Entity中包含的component。
class System{
public:
virtual void update(f32 timeStep)=0;
virtual ~System();
}
class HitSystem:public System{
vector<Component> tuple;
public:
virtual void update(f32 timeStep){//其实这段代码干了什么我真不清楚
for(DerpComponent* d:ComponentItr<DerpComponent>(m_admin)){
d->m_timeAccu+=timeStep;
if(d->m_timeAccu>d->m_timeToHerp) this->HerpYourDerp(d,d->Sibling<HerpComponent>());
}
}
for(Entity &entity:world.entitys)
{
if(/*entity中有tuple中的所有component*/)
{//对应处理:
}
}
}
}
实际上的System和tuple的例子:
//tuple
struct PhysicsTuple
{
DynamicPhysicsComponent* m_dynamicPhysics;
TransformComponent* m_transform;
ContactListComponent* m_contacts;
}
//system
void PhysicsSystem::Tick(f32 timeStep)
{
IPhysicWorld* pw=GetPhysicsWorld();
pw->Update(timestep);
//write transeforms of dynamic physics objects
for(PhysicsTuple& t:getPhysicsTuples())
{
IPhysicsProxy* proxy=pw->GetProxy(t.m_dynamicPhysics->m_proxy);
CopyTransform(t.m_transform,proxy);
CopyContacts(t.m_contacts,proxy);
}
}
但是这里我有一点疑问,每一次行为如果需要遍历所有的Entity,那么其复杂度就是tuple的component个数乘以Entity个数乘以Entity内component的个数,这个复杂度会不会很大?
(图来自视频截图)
World即是图中的EntityAdmin,其中包含了所有System,用哈希表存的Entity,以及所有Component。 而World里面有Update函数。
void EntityAdmin::Update(f32 timeStep)
{
for(System* s:m_systems) s->Update(timeStep);
}
参考:https://www.zhihu.com/search?type=content&q=Dots
前置知识点:
CPU自身有三级缓存,第1级最快,容量最小,第3级最慢,容量最大,而CPU访问内存的速率又远小于三级缓存的速率,在操作数据时,会先从1,2,3级缓存中取得数据,但是有些情况下请求的数据并不在这3级缓存中(缺页中断),就需要以寻址的方式去内存中,将一整块数据存放到缓存里,并把目标数据放到3级缓存中,提高下一次的访问速度。
ECS的数据组织与使用形式
ECS架构在执行逻辑时,只会操作需要用到的数据,而E和C这两者配合把相关数据紧密的排列在一起,并且通过Filter组件过滤掉不需要的数据,这样就减少了缺页中断的次数,从整体上提高了程序的效率。而另外现代CPU使用了数据对齐技术(自动矢量化:SIMD)与这种数据密集的架构相性较好,可以进一步提升性能。
ECS的优势与劣势
https://www.zhihu.com/question/286963885/answer/1162365997
——游戏科学联合创始人 招招 如是评论:
对cache友好
事实上在很多情况下,设计对cache友好的代码非常困难,如果游戏像是守望先锋那样,可以触及到底层,能够对数据密集的代码进行优化(寻路、碰撞、移动等等),这些地方很适合用for循环来高效遍历。
做一次伤害结算,你需要读取敌我双方的各种属性、状态,有些复杂的情况甚至还要计算双方的距离、甚至牵连到AI状态。然后一次伤害又可能产生各种附带的事件,比如触发一个buff、触发了死亡、等等等需要处理的逻辑。你需要对数据精心设计和拆分,才可能做到数据连续读写。稍有不注意,可能某个人加了一些功能就会破坏原来连续的存储访问,导致丧失cache友好的优势。
Cache友好的编程方式真正牛逼的地方是用于解决数据单一而密集的问题,比如frostbite在做culling的时候就分享过放弃对场景做树状划分,直接暴力遍历,反而因为cache友好而获得更好的性能以及更简单的代码。这是因为culling时候数据结构单一,逻辑也单一,才获得了好的效果。目前来说,移动逻辑、寻路逻辑,这些相对容易简化的密集运算,才真正适合cache友好的写法。而AI、技能则要视乎项目,往往多数项目在这块需求极其复杂,几乎无法构建出真正cache友好的数据结构。
总结:ECS不适应复杂的技能机制,适合相对容易简化的密集运算。
程序结构清晰
ECS是一种反OOP的设计,其中最大的一点就是ECS没有提供天然的多态支持。多态必须通过为entity装配不同的component来实现,那这样component的设计就会变得很折腾,例如如果你有几十个AI节点或者几十种技能效果,你可能要设计很多很多的component去对应。但是,如果你想用多态来做逻辑,为什么还要用ECS呢?因为多态本身是反Cache友好的。不同逻辑之间使用的数据都不相同,自然也就破坏了数据的连续读写。这就是为什么用ECS做ui、做复杂技能特性,往往会束手束脚。
ECS还有另一点就是,没有什么很约定俗成的跨模块耦合方案。不同的框架有不同的思路。有些框架提供立即触发的事件调用其他的system。守望先锋提倡共享代码抽到util,大的side effect抽到一个单独的system通过延迟事件解决。这里要重点说一下,立即触发和延迟触发的事件,对ECS的意义,是不同的。立即触发的事件,本质上相当于在循环里直接调用另一个system。这首先是反Cache友好,因为我在一个循环里又去尝试访问其他system关注的数据,同时他大大地增加了一个循环内部的逻辑复杂度。然后这也是反多线程友好的,因为多线程必须清晰知道每个system对component的读写关系,而事件隐藏了这块的关系,很容易会造成框架对读写关系判断错误而产生线程同步问题。然后就是延迟事件,这种方式,可以解决掉上面说的两个问题。但是这不是一个万能的方案:延迟事件本身需要将事件参数进行存储,这带来了存储的消耗以及需要增加这部分的代码。大量地使用,也会让你觉得十分折腾,有时很简单的一个side effect都要加一个处理延迟事件的system逻辑来解决。而且延迟事件还要考虑清楚,延迟这个事情本身会不会产生问题。所以,不好说这两种方案的优劣,但是如果system之间如果存在大量的逻辑耦合,要么就是system不是一个好的拆分,要么就是不应该选择ECS这种模式去开发。
总结:需要精心设计跨模块耦合方案,如果需要立即触发事件,去调用其他的system,那就会发生for循环里调用另一个system(再嵌套一个for循环),虽然可以用延迟事件(按顺序调用system,只改变数据?),但需要良好的system设计。