Unity3D 带来的 ECS 曾经广受诟病。 在之前的这个版本中,Unity 做出了以编辑器为中心,数据驱动的开发框架。从此策划可以直接在编辑器中开发新的关卡和玩法而无需改动代码。组件复用的特性也将开发人力解放出来,为游戏开发节省了大量人力。尽管如此,这仍然不是一个足够准确和优秀的 ECS 系统。
由于 Component 仍然是数据和行为的混合体,导致 Component 为了实现多个行为,就需要持有多种数据,最终使 Component 不断膨胀并且难以拆分。多个 Component 的相互依赖,随着游戏开发不断推进,引用链可能会缠绕整个代码库,带来非常难以优化的耦合灾难。同时 GameComponent 还带上了多态的能力。维护多态指针但没有构造对 CPU 友好的内存结构,cache miss 和内存换页的问题依然困扰着开发者。
时光荏苒,2018 年的 GDC 大会上,Unity 带来了他们全新的 ECS 系统。这次的更新不仅完全符合目前主流对 ECS 的设定,同时还带来了诚意满满的 Jobs 系统,Jobs 背后的思想是目前业界对 ECS 模型面向多核进行性能优化的主流思路。Unity 在框架中实现了这个系统,可以帮助开发者节省大量大量的工作,表明了 Unity 希望推动游戏行业将 ECS 作为主要开发模型的决心。这次演讲的视频链接在这里:https://www.youtube.com/embed/kwnb9Clh2Is
ECS 的主要性质:
Entity 没有数据也没有逻辑,仅仅作为标记,用来聚合一组 Component(我觉得 Entity 描述了数据关系,这是一种不体现在数据中的重要信息)
Component 只有数据,没有逻辑
System 实现逻辑,操作一系列 Component
在这个模型中,Components 将成为数据的最小集合,我们实现中应当根据语义尽力将 Components 拆分地更细,因为更小的 Component 可以带来更好的复用能力和并行能力。例如两个数据分别属于两个 Component,那么分别处理这两个 Component 的多个 System 就可以并行。如果没有拆开,则由于 System 需要整块地读取和回写内存,多个 System 就不可以并行了。从此数据设计将成为框架关心的部分,框架有了这些信息,可以提供更强的优化能力。
---
那么 Unity 的 ECS 系统在这个基础上做了什么事呢?
Unity 的注入特性方便我们把一组 Entity 的数据按照类型快速注入到我们需要的 Component 中。这可以帮助我们少写非常多的代码。同时还可以帮助我们追踪 Sytem 对 Component 的依赖关系,便于合理地进行并行调度,提升代码性能。 其中并行能力由后文的 Jobs 系统实现。[视频 27:00][视频 36:00]
在经典的 ECS 实现中,System 经常要做的事情是,操作一组 Entity 中的指定部分 Component。有了注入特性,我们就可以省去很多操作数据的工作,可以直接描述业务逻辑了。同时注入特性也包括了依赖注入,可以帮助我们描述数据关系,框架通过这些信息来决定两个 System 是否可以并行。在视频中还提到了 AlwaysUpdateSystem 特性,用于在 System 没有数据时要求 System 继续 Update。也是部分 System 会用到的功能。
经典 ECS 实现中,经常难以控制 System 的执行顺序,导致数据安全性和并行能力上的限制。Unity 提供了 UpdateBefore 和 UpdateInGroup 这两个元标签,向我们揭示了 System 上层的 Group 设计。需要强制顺序的,使用 UpdateBefore 就可以确保先执行准备工作。可以并行的,使用 UpdateInGroup 可以方便系统进行并行调度。最终的模型如图所示:
System 之间相互合作,操作不同数据的 System 可以并行,属于一条流水线的几组 System 需要严格按照顺序执行。这种从实践中反哺出来的思想和新 Unity 带来的 Job 模型完全一致。这也是 ECS 设计模型的主流优化方向。
从模型上解决问题,可以提供易于实现的并行能力,在目前 CPU 产业单核性能逐渐走向瓶颈,多核架构能力不断增强的生态下,将会为游戏性能提升带来新的活力。
这张图展示了近年来 CPU 产品单核性能和核数的增长曲线。可以看到随着时间线核数增长率不断上升,单核性能增长率不断下降,多核能力变得愈发重要。
游戏的逻辑本身也正是大量任务的聚合,任务之间没有强依赖关系,适合使用任务模型开发。多核逻辑时代正在到来,而 ECS 是目前少数有可能从模型上支持多核的逻辑结构,因为这种模型提供了数据隔离的依据。
下图展示了 Jobs 系统的基本定义:[视频 3:18]
从图中可以看到,这不是传统的并行模型或流水线模型,而是一种面向任务的设计思想。Jobs 系统通过在调用链中传递 JobHandle 来构造流水线。一个 Job 可以依赖之前多个 Job 的工作结果。 如果数据和逻辑都为任务服务,通过依赖注入的方式由框架来整理数据的并行关系,则拓展任务之间的并行能力就变得非常简单可控了。
说到并行,就不得不提到 Race Condition 的问题。ECS 通过数据隔离来解决这个问题。由于每一个 System 所使用的数据都是可以追踪的,因此 Job 之间要么使用数据的拷贝,要么转移数据的所有权。因此在 ECS 提供的并行方案里没有 Race Condition 的问题。
同时 Unity 还做了一件事,就是在引擎中也使用了这个 Job 模型,因此在引擎代码和逻辑代码中没有上下文切换成本,对开发者的性能调优工作更加友好了。
由于 Jobs 需要 System 进行依赖注入来判断 Component 相互是否可以并行。所以尽量把数据都放在 Component 中,并做好 Inject 声明。如果我们必须要操作一个 Global 的数据,不属于某一个 Component,Unity 的做法是为这个场景声明一个空的 Component,便于 Jobs 系统的追踪。
值得一提的是,Unity 在实现可转移数据所有权的内存块时,引出了一类叫做 "Natice Container" 的东西,这类 Container 的内存需要开发者手动释放。当然为了不留坑,Unity 提供了 DisposeSentinel 用于追踪内存泄漏情况,以及一个 AtomicSafetyHandle 用于追踪数据归属和访问权限相关的问题。可以看到 Unity 在亲身实践这种并行系统时还是帮我们踩了一些坑,也解决了一些问题。同时 Unity 开放了这些容器的代码,并且允许用户自定义容器。这些容器可以帮助我们更方便地使用 Jobs 系统。
这部分内存应该是 Unity 自己实现了内存管理,视频中还提到 IJobParallelFor 这个接口,可能还有一些其他的硬核改造(如 SIMD ?)。在下文中,他们还实现了一个专门用于优化的编译器。
Unity 内部实现了一个 C# 子集语法的编译器,可以帮助生成一些针对机器实例更优化的代码。配合这个编译器可以发挥 Jobs 系统的最大能力,可能在未来我们可以看到一个对并行更友好的 Unity。这也是为了推广 ECS 所做的底层工作,毕竟有了这些令人垂涎的性能蛋糕,才可以吸引更多的团队更新 Unity 版本。有一篇讲这个编译器的视频:https://www.youtube.com/embed/NF6kcNS6U80
这一部分介绍了 Unity 在关注 CPU 的 L2 缓存、pre-fetch 机制上做的事情。主要是实现了一个优化过的 memory packed 机制。在前文提到,相同 Component 结构的数据集合经常需要在某个 System 中遍历操作,因此 Untiy 实现了一个这样的内存布局:任何由相同 Component 结构拼成的数据被称为 Archetype(原型,理解为数据关系),同样的 Archetype 存储在相邻的内存。
对每一个 Archetype,都维护了一连串连续的内存 Block,其中的 Component 在这些内存块中紧密地连续排布。这样的连续排布规则使得 CPU 每处理一个 Block,就可以并行地读下一个 Block 地数据。由于 Archetype 所做的内存工作使相同结构的 Component 都在连续内存上,在一个 System 或一个 Job 运行时,可以最大程度提高 CPU 的 Cache 命中率。当然这种优化对于一些需要乱序访问的场景就帮不上忙了,实际业务中也一定会遇到不是很完美的 System 场景,但是这已经是框架在底层能做的非常有用的事情了。
最后,提到了 ECS 可以兼容现有的 GameObject,要做的就是声明 GameObjectEntity,然后把 GameObject 中可以 ECS 的数据移进去。ECS 和 GameObject 中的数据是相互可见的。不过一般情况下老项目中核心的数据基本都有非常严重的依赖问题,不太好移动,这种兼容性带来的性能提升可能需要一定的重构才能逐步显现出来。
总得来看 Unity 这次可以把没有做好 ECS 的帽子摘下来了,期待 Unity 2018 的推广能给我们带来更多的惊喜。
20180729
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。