关于2017GDC上OverWatch团队演讲ECSArchitecture的思考

关于ECS架构基本概念可以参考之前的文章:

DOTS介绍+Unity DOTS-MAN小游戏项目实战


首先是官方给出的OverWatch中ECS基本结构:

 System与Entities:

 System与Components;

在演讲中,ECS是一个gameplay的框架,建立在渲染引擎、物理引擎之上,主要作用是处理对象在Update时会发生的问题。在平常的GO编程中,GO身上很多模块互不相关,但是每一帧还是会调用所有的GO的Update,导致模块之间的耦合性比较高,模块内聚性不佳。

在ECS中,Entity变成Component的载体,值得注意的是OW中给Entity用f32表示id,并且用hashMap把EntityID与Entity连接,方便查询,这样可以更容易指代无效对象。

System只会关心特定的Entity,比如伤害计算的System就只会关心伤害、玩家位置、体积等等,其他的例如音效都不会关心,然后伤害、玩家位置等属性被归纳为Component,在进行计算的时候,System只会从对应Entity中取到Component并且对数据进行计算即可,如果Entity只携带一部分System关心的Component,也不会被列为计算对象

扫描二维码关注公众号,回复: 14591164 查看本文章

所以ECS实际上对于架构要求非常严格,对于特殊的TAG要特殊处理,在一开始的gameplay设计的时候就要考虑到绝大部分因素,否则在后期功能添加或者修改的时候就会十分痛苦。

这里引用云风的Blog:

云风的 BLOG: 浅谈《守望先锋》中的 ECS 构架

“比如在一开始,他们认为 Component 就是大量有某种同类 Entity 属性的集合的筛选器。ECS 框架辅助这个筛选过程,每个 System 模块都用 for each 的方式迭代相关的 Entity 中对象的组件。之后他们发现,其实对于每个游戏对象集合体来说,一类 Component 可以也应该只有一个。比如存放玩家键盘输入的 Component ,就没有多个。很多 System 都需要去读这个唯一的 Component 内的状态(哪些按钮被按下了),可以安排一个 System 来更新这个 Component 。原文把这种 Component 成为 Singleton Component ,我认为这个东西和一开始 ECS 想解决的问题还是有一些差别的:不同种类的 Entity 分别拥有同类的属性组,框架负责管理同类集合。我们的确还是可以创建一个叫做玩家键盘的 Entity 加到游戏世界中,这个 Entity 是由键盘组件构成。但是我们完全不必迭代玩家键盘这个 Entity 集合,因为它肯定只有一个,直接把这个对象放在游戏世界中即可。但把它放在 System 中就不是一个好设计了。因为它破坏了 System 无状态的设计原则,而且也不支持多个游戏世界:在原文中举了个例子,实际游戏和游戏回放就是两个不同的游戏世界,不同的游戏世界意味着不同的业务流程的组合,需要用不同的方式粘合已经开发好的 System 。把游戏键盘状态这种状态内置在特定的 System 中就是不合适的了。从这个角度来说 ECS 的本质还是数据 C 和操作 S 分离。而操作 S 并不局限于对同类组件集合的管理,也可是是针对单个组件。作者自己也说,最终有 40% 的组件就是单件。”

所以个人认为使用HybridECS架构会更好而非纯ECS,像玩家输入等更偏向底层的“Component”可以在Mono中进行计算,也就是把ECS嵌入到常规的架构中,可以在Mono中进行一些交互UI相关的计算,再把数据更新进入ECS中。


关于回放:

死亡瞬间到死亡回放结束,网络流量平稳,下行流量在90Kbps左右,与正常游戏时无明显区别。

战斗结束到播放全场最佳画面间几秒动画时间出现1600到1800Kbps的下行流量。

未尝试限制网速观察无法及时获取最佳画面情况下表现。

推测客户端会保存一段时间的敌我动作数据用于死亡回放。

但全场最佳数据由服务器存储。

 实际上replay也在一直存储数据,暴雪也说过游戏结束之后去看过去的记录,实际上是从服务器下载数据然后在本地客户端进行播放。liveGame和replayGame必须做到高同步,因为在liveGame玩家死亡之后要立刻看到replayGame中的场景,具体实现没有了解,但是个人猜测replayGame应该就是对应复活时间前的liveGame记录,视角切换到敌方播放。所以liveGame和replayGame实际上是同速前进的,但是replayGame会晚那么一段时间(可能是复活时间)。 


关于工具函数:

 云风大佬写的很好,这里直接cv了:

Component 没有方法,而 System 则没有状态,只是对定义好的 Component 状态的加工过程。而许多 System 中很可能会处理同一类问题,涉及的 Component 类型是相同的。如果这个有共性的问题只涉及一个 Entity ,那么直观的方法是设计一个 System ,迭代,逐个把结果计算出来,存为 Component 的状态,别的 System 可以在后续把这个结果作为一个状态读出来就可以了。

但如果这个行为涉及多个 Entity ,比如在不同的 System 中,都需要查询两个 Entity 的敌对关系。我们不可能用一个 System 计算出所有 Entity 间的敌对关系,这样必然产生了大量不必要的计算;又或者这个行为并不想额外修改 Component 的状态,希望对它保持无副作用,比如我想持续模拟一个对象随时间流逝的位置变化,就不能用一个 System 计算好,再从另一个 System 读出来。

这样,就引入了 Utility 函数的概念,来做上面这种类型的操作,再把 Utility 函数共享给不同的 System 调用。为了降低系统复杂度,就要求要么这种函数是无副作用的,随便怎么调用都没问题,比如上面查询敌对关系的例子;要么就限制调用这种函数的地方,仅在很少的地方调用,由调用者小心的保证副作用的影响,比如上面那个持续位置变化的过程。

如果产生状态改变这种副作用的行为必须存在时,又在很多 System 中都会触发,那么为了减少调用的地方,就需要把真正产生副作用的点集中在一处了。这个技巧就是推迟行为的发生时机。就是把行为发生时需要的状态保存起来,放在队列里,由一个单独的 System 在独立的环节集中处理它们。


 关于网络同步:

 因为ECS中数据都是封装在Component中,所以对于数据的快照以及回滚都是更加方便的。一个好的网络同步系统需要实现预测。

OW的同步逻辑是客户端每16ms调用一次UpdateFixed,服务器会晚一些调用。实际上网络游戏就像多个人一起看电影,但是有的人先看有的人后看,看到的内容不一致,只要把每个人的延迟进行一定计算,就能让每个人看到的内容一致了。游戏也是如此,但是游戏世界没有进度条,延迟更高的玩家如果要有更好的游戏体验(因为实际上网络更好的玩家看到的游戏世界是要比网络差的玩家更加接近实时的),就必须要考虑预测问题

但是,游戏和电影不一样的地方是,玩家自己的操作影响了电影的情节。我们需要在服务器仲裁玩家的输入对世界的影响。玩家需要告知服务器的是,我这个操作是在电影开场的几分几秒下达的,服务器按这个时刻,把操作插入到世界的进程中。如果客户端等待服务器回传操作结果那就实在是太卡了,所以客户端要在操作下达后自己模拟后果。如果操作不被打断,其实客户端模拟的结果和服务器仲裁后的结果是一样的,这样服务器在回传后告之客户端过去某个时间点的对象的状态,其实和当初客户端模拟的其实就是一致的,这种情况下,客户端就开开心心继续往前跑就好了。

只有在预测操作时,比如玩家一直在向前跑,但是服务器那里感知到另一个玩家对他释放了一个冰冻,将他顶在原地。这样,服务器回传给玩家的位置数据:他在某时刻停留在某地就和当初他自己预测的那个时刻的位置不同。产生这种预测失败后,客户端就需要自己调节。有 ECS 的帮助,状态回滚到发生分歧的版本,考虑到服务器回传的结果和新了解到的世界变化,重新将之后一段时间的操作重新作用到那一刻的状态上,做起来就相对简单了。

 

 简单来说,就是客户端本地会进行预测,网络越好(延迟越低),预测的成功率就越高,因为玩家的操作对于游戏世界的影响是要在服务器端进行判断的,而且要保证每个玩家对于游戏世界的影响预测是一致的,如果发现某些玩家对世界的影响预测是不对的,这时候就需要进行数据回滚再发送给客户端进行同步——这就是为什么当你网络不好的时候,一直往前走,然后看到小美一直朝你射击但是没有冰冻,过了一段时间之后你的位置会回退然后冻上。

但是基于ECS的强大,数据被封装在Component中,所以数据回滚会比正常的OOP架构游戏更加方便。至于更多的网络相关的解释可以看云风的Blog。


总而言之,ECS是很强大的架构,但是要合理运用才能发挥作用。

猜你喜欢

转载自blog.csdn.net/qq_51773145/article/details/124315589
GDC