02 - ECS架构

本文将简要介绍游戏引擎中的如下内容:

  • 编程范式,OOP,DOP
  • 性能敏感编程
  • ECS架构

编程范式

OOP存在的问题

有许多种类的编程范式,例如面向过程的编程,面向对象的编程,函数式编程等。

对于面向对象的编程OOP,它可以让人们很顺畅地编写代码,但对代码的执行者计算机来说,可能就不怎么顺畅了。

OOP编程存在如下问题:

  1. 二义性:假设有人向怪物攻击这一场景,应该是人对怪物施加伤害还是怪物受到来自人的伤害,这段代码不知道要放到哪个类中。
  2. 继承地狱:在一个超大项目中找函数的实现,需要沿着父类不断的找。
  3. 巨大的基类:在一个超大项目中,基类可能有很多很多功能。
  4. 低性能:数据分散到各个对象中,对内存整体一致性不友好。
  5. 难测试:为了测试小个功能要加载一整个对象,比较麻烦。

面向数据的编程DOP

内存

内存的发展相对CPU来说很慢,数年内读写仅提高了10个数量级。为了缓解与CPU高速读写带来的差距,人们研究硬件的金字塔缓冲架构,在CPU和内存之间设计几层高速缓存,进而减少CPU直接访问内存的次数。

除了从硬件层次优化之外,还有软件方面的优化。为了对内存友好,代码中声明的变量内存分布要紧凑。在读内存到缓存中是一次性读取一行64字节内容,如果该变量按行主序遍历,缓存未命中的次数会少一些。此外还研究了SIMD技术,LRU缓存分配算法等。

DOP

面向数据的编程(Data-Oriented Programming,DOP)认为万物皆数据,需要尽可能地让数据缓存未命中次数减少。

DOP力保数据和代码在缓存中的紧凑性,不要让代码等待数据,数据等待代码:

性能敏感编程

如果要编写对性能敏感的代码,需要注意如下几点:

减少顺序依赖

要尽量避免变量的顺序依赖,这会导致代码无法并行运行:

这是因为在并行执行的时候,相应变量在不同核的高速缓存中可能会不一样,而CPU必须纠正这个问题,会消耗额外的事件让变量保持一致。

减少分支预测

代码被送到CPU前,CPU会进行分支预测。它将最有可能发生的代码送进CPU进而加快程序运行速度,如果此时条件不符合,CPU需要重新将不符合条件的分支情况交换进高速缓存中,会拖慢一点时间。

例如下面一段代码,CPU在执行过程中会进行分支预测,前三次执行都是doFunc1(),于是CPU继续预测下一次也执行doFunc1(),但实际上是doFunc2(),这时候要把执行doFunc2()的代码加载进CPU。然后在再下一次执行中预测doFunc2(),实际上却是doFunc1(),又需要重新加载代码。

如果我们事先对数组进行排序,那么出现预测错误的情况只有1次,进而相对加快程序运行速度。

因此有必要减少分支预测出现误判的情况,或者从根本上删除代码中的分支:

使用性能敏感的数据结构

减少内存依赖

性能敏感的数据结构需要减少链式内存依赖,如指针。这是因为读取到指针,再读取它指向的内存时容易发生缓存未命中。

结构体数组还是数组结构体

如果想要快速读取所有物体的相同属性,需要将其设置为数组结构体,这样读取属性的时候会更快。

ECS架构

ECS架构是DOP编程范式的一种实现,它分为如下3个部分:

  • 实体 Entity:一个ID,引用了一组组件;
  • 组件 Component:由系统处理的一些数据,这里面没有逻辑处理;
  • 系统 System:专门负责组件的逻辑处理部分,对各组件数据进行读写;

ECS架构可以统一管理离散的数据,对并行处理友好,例如一个线程分配一个系统。

Unity的DOTS系统

Unity专门开发了一套面向数据编程的系统,它包含如下三部分:

  • ECS框架:提供DOP编程框架;
  • C#任务系统:提供并行处理功能;
  • Burst编译器:生成快速高效的优化机器码(C#本身在虚拟机上运行,速度较慢);

ECS框架

DOTS系统中的ECS框架对各种组件的组合进行了抽象,可以用 Archetype 来描述某些组件的特定组合,方便处理各类实体的组件组合。

为了缓存友好,Archetype中的组件被打包为一个个区块,一个区块就是拥有固定大小的内存块,例如16KB。这样区块就能和它对应的系统一起被送进CPU进行处理,减少缓存未命中的次数。

有了上面的铺垫,系统部分的内容就相对简单了:

C#任务系统

C#的任务系统让人们方便去写多线程代码,并且任务之间也有依赖关系。

为了性能和ECS框架的正确性,容器必须是底层可控的,也就是说要像C++那样在C#中对容器进行内存分配:

高性能C#和Burst编译器

高性能C#(HPC#)是C#的子集,它舍弃了C#的大多数标准库和一些语言特性,以提高用C#写代码的性能。这离不开编译它的Burst编译器,Burst编译器能够直接将HPC#编译成高效执行的机器码。

UE的Mass系统

UE的Mass系统也是对ECS框架的一种类似实现。

它的实体就是一个不重复的自增ID:

为了不和UE原来的各种组件混淆,它将ECS的组件改名为 Fragment,也就是一小块数据。和Unity一样,Mass系统也对Fragment的各种组合抽象为Archetype。

Mass系统中ECS的S被称为 Processor,它有两个重要的接口:

  • ConfigureQueries():获取执行该Processor的目标Entity有哪些,要处理的Fragment又有哪些;

  • Execute():Processor会获取Fragment的引用,对其进行读写逻辑处理。

参考资料

  • GAMES104 (boomingtech.com)

待阅读的资料

DOP:

  • GDC’cn 为实现极限性能的面向数据编程范式 叶劲峰

    https://ubm-twvideo01.s3.amazonaws.com/o1/vault/gdcchina14/presentations/833779_MiloYip_ADataOrientedCN.pdf

  • Data-Oriented Design, Fabian R, CRC Press, 2018.

    https://www.dataorienteddesign.com/dodbook.pdf

  • OOP Is Dead, Long Live Data-oriented Design. Nikolov S, Coherent Labs. CppCon 2018.

    https://www.bilibili.com/video/BV1kW41117uw?p=66&vd_source=f12a5db552661d28e8507875c37983cd

  • Data-Oriented Design Resources

    https://github.com/dbartolini/data-oriented-design