守望先锋中的 ECS 系统

原标题

Overwatch Gameplay Architecture and Netcode

  • Component:只保存状态,不含逻辑
  • System:只有逻辑,没有数据

系统按顺序更新(Tick),每个系统访问多个不同的组件,称之为元组(Tuple)
元组之后被 Sibling 取代了。
系统不关心实体,只关心具体的组件。

World

  • array<System *>
  • hash_map<EntityID, Entity *>
  • object_pool< Component >*
  • array<Component >

Entity

  • EntityID
  • array<Component *>
  • resourceHandle

Component

  • void Create(resource *)
  • ~Component()

基类,其子类可以重载构造 / 析构函数用于管理资源。
有一些工具函数,主要为数据的访问接口。

System

  • void Update(float)
  • void NotifyComponent…(Component *)

示例

一个检测玩家挂机行为的 System,这个 System 必须同时取得 ConnectionComponent,InputStreamComponent 和 StatsComponent。
而对于没有 ConnectionComponent 和 InputStreamComponent 的 AI 玩家则不受这个系统的约束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void PlayerConnectionSystem::Update(float timeStep)
{
for(ConnectionComponent *c : ComponentItr<ConnectionComponent>(m_admin))
{
// 从一个具体的 Component 所在的 Entity 上获得指定类型的 Component。
InputStreamComponent *is = c->Sibling<InputStreamComponent>();
StatsComponent *stats = c->Sibling<StatsComponent>();
if (is->m_inputThisFrame || stats->m_didSomethingCoolLately)
{
c->m_afkTimer = 0.0f;
C->m_sentAFKMessage = false;
}
else
{
C->m_afkTimer += timeStep;
if (!c->m_sentAFKMessage && c->m_afkTimer > AFK_MESSAGE_SECONDS)
{
c->m_sentAFKMessage = true;
// 辅助函数,之后会讲到
ConnectionUtil::SendMessage(c->m_connectionHandle, "Move scrub!");
}
}
}
}

WHY NOT OOP IT

观点:同一份数据在不同视角下的意义是不同的。

全局变量

为了避免奇怪的访问方式(比如一个系统通过全局变量拿到另一个系统的指针,而死亡回放时存在两个 World,便存在了两个“全局变量”)
可以定义一些仅存在一个实例的 Component,称之为单例组件。存在于一匿名 Entity 上,通常直接通过 World 访问。
单例组件极少只被单个 System 访问,并且事实上 OW 有百分之四十的 Component 都是单例组件。

全局行为(Share behavior)

为了避免系统之间的耦合(比如在一个系统内调用另一系统的函数)
有一些逻辑是许多系统都关心的,比如判断两个实体是否敌对,可以将其抽象为辅助函数。
这里有一些设计规则:

  1. 尽量少读取 Component,尽量少产生副作用。
  2. 如果做不到,就尽量少调用它。

减小副作用的方法

假设有一个会影响很多 Component 的函数,并且允许其在多个地方调用。首先对其本身的任何修改就需要进行大量的测试,其次调用者容易对其真正的复杂度产生误解进而无限制的进行调用。两者放在一起更是让代码很难理解。
Nightmare
解决方案:延迟调用
在原本的调用位置只添加一个包含足够多信息的用于稍后进行真正的调用的记录到 array,最后统一进行处理,这样一来每一帧就只有一处调用会产生明显的副作用了。
我的理解是:复杂度是无法减小的,但是是可以被错误的设计放大的,而我们的设计应当尽量逼近最小的复杂度。
更多好处:

  1. 数据与指令内存布局连续,访存友好。
  2. 你甚至可以将真正的调用延迟到之后几帧来分摊性能。

PS:实体的创建是立即的,具体情况具体分析。