来自"玻璃渣"的 Timothy Ford 在 GDC 2017 大会做了一个专题演讲“《守望先锋》架构设计与网络同步(Overwatch Gameplay Architecture and Netcode)”,其中介绍的 ECS 架构一时引起众多关注。在转过年来的 Unity 2018 新版中也支持了 ECS 架构,并且性能有大幅提升!
ECS - Entity, Component, System 这一系列概念都是非常简单清晰的,那么为什么 ECS 会成为游戏行业的主流设计思想呢?这还要从"面向数据的设计(Data-Oriented Design)"讲起。ECS 是一种面向数据的设计在咱们游戏、引擎行业中的成功应用,要想理解“WHY”,就必须要理解面向数据的设计这一思想!
现代的程序设计思想要求我们以计算机的方式去解决问题(而不是以人脑的方式),“面向数据的设计”思想可以说是沿着这个方向又前进了一大步。这种程序设计的思想的出现和流行和计算机硬件的发展是息息相关的。乔布斯曾经引用过 Alan Kay 的一句名言说软件开发者必须关心硬件。下面我就结合计算机硬件的发展现状来说明一下这种设计思路的优势在哪里,也就是说为什么 ECS 之类是更好的设计!
在过去几十年 CPU 的工作频率得到了快速提升,而内存(DRAM)的工作频率提升却没有那么快!我作为一个电脑 DIY 玩家是有切身体会的,选购主板、内存、CPU的时候,有两个常见的概念:外频和倍频!外频也就是系统总线工作的频率。例如我家里这台电脑的总线速率是1GHz,CPU 的主频是3.2GHz,倍频为32,而内存主频只有800MHz。CPU通过倍频机制工作在更高的主频之上。从下面这张图,我们可以非常直观的看出内存与 CPU 之间的性能差距。
对于 CPU 来说“内存”已经是一个非常缓慢的设备了!为了少被内存拖后腿,CPU 内部集成了越来越多的 Cache。CPU 的内存预读取(prefetch)、多级 Cache 对于我们软件开发者都是透明的,为了验证这个点上的性能差异,我写了一个小小的测试程序。这个测试程序分别以“基于组件的对象设计”和“基于数据的设计”两种方式,对 50000 个 GameObject (或者叫做 Entity) 的 Transform 矩阵进行计算。核心的计算代码都是一样的,最重要的差别就是这些 Transform 数据的内存布局不同。测试的结果十分惊人,在我的电脑上前者要比后者慢 2.8 倍! 这个测试程序的代码请见:https://github.com/neil3d/GLab/tree/master/ECSProfile 。
测试的两者矩阵计算都是一样一样的,它们的差别主要就是内存组织方式,请见下图:
由于内存与 CPU 之间的性能差距还在加大,所以将数据以 Cache 友好的方式组织,可以显著提升程序性能。 而且,迄今为止,在这个点上,编译器是帮不上忙的!
2005年 Herb Sutter 发表了著名的文章:The Free Lunch Is Over,宣称“免费的午餐结束了”,并行计算是软件开发的下一次变革。所谓“免费的午餐”是指 CPU 主频快速提升的那些年,软件开发者不用花费什么力气就可以得到更高的性能。在 2005 年 CPU 的主频提升已经受到了硅晶片的物理限制,CPU 厂商改走“多核”路线了,作为软件开发者必须掌握“并行计算”的编程技巧才能得到更高的性能。一时间并行编程成了热门,回头看看,OpenMP、Threading Building Blocks 好像都火了一阵子,但最终。。。。至少在3D引擎、游戏开发领域少有人问津。
面向对象在并行编程方面有天生的劣势! 我们通过“封装”把数据放到对象内部去管理,一个对象往往管理了其抽象概念之下的复杂数据。在这种设计之下,这些数据之间的“并行计算”的关系是很难理清的,因为很多东西被刻意“隐藏”起来。我曾经花了很多时间在虚幻引擎的源代码上,虚幻3、4引擎就是一个典型的以面向对象为核心设计思想的多线程架构。现在虚幻4包括主线程、渲染线程、RHI线程、Texture Streamming线程,IO线程、物理线程等等!就拿渲染线程和主线程的交互方式来看,首先你就会发现大量的冗余数据,游戏逻辑层的对象中渲染相关的数据,必须通过 Primitive Proxy 的方式拷贝一份,发送到渲染线程;其次,还是有很多对象是在两个线程上同时要操作的,例如 View 系列的类,想改底层的话,分分钟被锁死!
面向数据的设计,从起点就考虑并行的问题! 在面向数据的设计中,设计的焦点是数据,首先要把数据理清楚,然后把逻辑分离出去。这样做的最大的好处是,每个逻辑模块对数据的读或是写是可以清晰界定的!这样一看,那些计算可以并行,那些是互相依赖的,就一清二楚了!这方面 Unity3D 的 JobSystem 是很成功的!每一个 Job class 可以通过指定 UpdateAfter/Before Attribute 的方式建立依赖关系图,而引擎根据这个图自动调度任务的并行执行!实际上,我认为UpdateAfter/Before 这个名字起的不好,因为本质上你不是在排序 Job 的执行顺序,而是要思考对 ComponentData 的读写!
下面我们就通过对比来回顾一下“面向数据的设计”的核心思想:
面向对象(OOD) | 面向数据(DOD) |
---|---|
设计的焦点是“对象” | 设计的焦点是“数据” |
将数据和行为封装在一起 | 数据和逻辑分离 |
将数据隐藏到对象内部 | 将数据开放给逻辑 |
以人更容易理解的方式组织对象 | 以计算机更友好的方式组织数据 |
通过理解“面向数据的设计”,我们就可以对 ECS 架构有一个更清晰的认识:不仅知道“是什么”,还知道了“为什么”。
ECS 只是“面向数据的设计”思想下的一种设计模式,可以预见接下来在各个特定的领域会产生更多的以数据为中心的设计模式,特别是在并行计算领域,面向数据的设计将占主导地位!
【题外话】
DRAM 必须以一定频率充电才能保持其存储的内容不丢失。Oculus 现任的首席科学家迈克尔·亚伯拉什(Michael Abrash)在早年就写个一篇文章,讲图形程序优化的。其中讲到一个技巧就是计算每个汇编指令的时钟周期,然后手动排列汇编指令,避开这个 DRAM “充电”时间。因为在 DRAM 充电的时候是无法进行读写操作的!优化到如此程度,实在令人咋舌!