本文章已于2021.4.13更新,修正了当时因为水平不足而导致的一些错误,拓展了部分内容。

前言

近期想从更深层次上学习ECS,之前一直停留在浅层次的编码模式(即ECS意识流),没有真正的去了解ECS的内部原理,Unity目前在维护一套以ECS为架构开发的DOTS技术栈,非常值得学习。

ECS

什么是ECS

ECS即实体(Entity),组件(Component),系统(System),其中Entity,Component皆为纯数据向的类,System负责操控他们,这种模式会一定程度上优化我们的代码速度。

  • Entities:游戏中的事物,但在ECS中他只作为一个Id
  • Components:与Entity相关的数据,但是这些数据应该由Component本身而不是Entity来组织。(这种组织上的差异正是面向对象和面向数据的设计之间的关键差异之一)。
  • Systems:Systems是把Components的数据从当前状态转换为下一个状态的逻辑,但System本身应当是无状态的。例如,一个system可能会通过他们的速度乘以从前一帧到这一帧的时间间隔来更新所有的移动中的entities的位置。

ECS为什么会快

计算机组成原理前置知识

首先明确几个知识点

  • CPU处理数据的速度非常快,往往会出现CPU处理完数据在那干等着的情况,所以需要设计能跟上CPU的高速缓存区来尽量保证CPU有事干,同时也提高了数据访问效率。
  • CPU自身有三级缓存,俗称高速缓存,CPU访问第一级(L1)缓存最快,容量最小,第三级(L3)缓存最慢,容量最大。
  • 我们常说的内存是指CPU拿取数据的起源点,CPU访问内存所需的时钟周期远大于访问高速缓存所需的时钟周期。
  • CPU操作数据会先从一,二,三级缓存中取得数据,速度非常快,尤其在一级缓存处速率基本可以满足CPU的需求(即不让CPU歇着),但是有些情况下我们请求的数据不在这三级缓存中(即Cache Miss,实际上如果没有在一级缓存中找到数据都算作是Cache Miss,但是在高速缓存中的CacheMiss惩罚并不严重,所以这里说的Cache Miss指的是高级缓存全部没有命中,需要从内存取数据的过程),就需要寻址到内存中的数据(包含这个数据的一整块数据都将被存入缓存),并且把目标数据放到高速缓存中,提高下一次的访问速度(因为这一次调用的数据块往往在不久的将来还会用到)。

ECS的数据组织与使用形式

ECS架构在执行逻辑时,只会操作需要操作的数据:System在操作数据的时候只会收集它关心的Component数据,CPU运行时就会将这一整块内存装入高速缓存中,这样就减少了Cache Miss次数,增加了缓存命中率,整体上提高了程序效率。对于此部分更加详细的内容参见:浅谈Unity ECS(二)Uniy ECS内存管理详解:ECS因何而快
此外现代CPU中的使用数据对齐的先进技术(自动矢量化 即:SIMD)与这种数据密集的架构相性极好,可以进一步提高性能。

ECS有什么优势

对比传统的面向对象编程,ECS模式无疑更加适合现代CPU架构,因为它可以做到高效的处理数据而不用把多余的数据字段存入宝贵的缓存从而导致多次Cache Miss。
举个例子就是传统模式下我们操作Unity对象的Position属性,他会把GameObject所有相关数据都加入缓存,浪费了宝贵的缓存空间。
而如果在ECS模式下,将只会把Position属性集放入内存,节省了缓存空间,也一定程度上减少了Cache Miss,即常说的提高缓存命中率

ECS在实践中真有那么神吗

很遗憾,我的答案是否定的,或许它的理念是没有问题,甚至是完美的,但是当理论应用到实践,就会有很多想不到的问题暴露出来

  • 内存管理,其中包括内存的分配,回收和内存对齐,这是最重要的一点,如果没有做到良好的内存管理,就没有办法享受到ECS的高性能,可以看看Unity ECS为内存管理做了多少事情(Archetype的chunk内存分配都是以指针+unsafe代码的方式进行的)
  • 编码规范,代码必须是无引用的形式,不然就会破坏Cache友好,这也是为什么Unity ECS的Component中的数据不支持引用类型的一个重要原因之一,但是无引用的形式势必会导致一些烦恼,比如自己处理数据的拷贝和移动
  • 如果没有做到上面两点,基本上性能和传统OOP没有什么区别,这就是把ECS当成一个编程范式,这当然也是极好的,它比组件式编程更上一层楼,更利于解耦和维护
  • 开发思想的转变,从OOP到ECS思路需要有很大的转变,在OOP下,A对B发起攻击,就是一个Utility函数处理这个过程,但是在ECS下,你得提供一个专门用来伤害处理的数据类Component与一个System,在System里进行所有此类Component的收集与处理

综上所述,ECS的概念很美好,但是现实是骨感的,真正启用ECS道路上势必会困难重重

但是我们可以在特定模块中使用ECS来提高我们的程序性能,例如寻路模块,渲染模块,这些模块实现起来是强内聚的,几乎不会和外界产生耦合,这就降低了心智负担,开发起来比较容易一些

当然了,我们也没有必要追求纯ECS的实现,可以学习下守望先锋技术团队根据自己的项目做的特化版的ECS,它就是有ECS(比如部分Gameplay模块,PlayerInput,MoveComponent),也有OOP部分(技能系统和网络同步),当然大架构还是ECS,OOP只是包含在这个架构中的一小块。详情参见:《守望先锋》架构设计和网络同步

Unity DOTS

什么是Unity DOTS

Unity DOTS就是Unity官方基于ECS架构开发的一套包含Burst Complier技术和JobSystem技术面向数据的技术栈,它旨在充分利用SIMD,多线程操作充分发挥ECS的优势。

Burst Complier

Burst是使用LLVM从IL/.NET字节码转换为高度优化的本机代码的编译器。它作为Unity package发布,并使用Unity Package Manager集成到Unity中。
它全盘接管了我们编写的新C#编译工作,可以让我们在特定模式下无痛写出高性能代码。

JobSystem

它可以让我们无痛写出多线程并行处理的代码,并且内部配合Burst Complier进行SIMD优化。
你可以把JobSystem和Unity的ECS一起用,两者配合可以让为所有平台生成高性能机器代码变得简单。

JobSystem是如何工作的

编写多线程代码可以带来高性能的收益,包括帧率的显著提高,将Burst Compiler和C# JobSystem一起用可以提高生成代码的质量,这可以大大减少移动设备上的电池消耗

C# JobsSystem另一个重要的点是,他和Unity的native jobsystem整合在一起,用户编写的代码和Unity共享线程,这种合作形式避免了创建多于CPU核心数的线程(会引起CPU资源竞争)

Unity.Mathematics

一个C#数学库提供矢量类型和数学函数(类似Shader里的语法)。由Burst编译器用来将C#/IL编译为高效的本机代码。

这个库的主要目标是提供一个友好的数学API(对于熟悉SIMD和图形/着色器的开发者们来说),使用常说的float4,float3类型…等等。带有由静态类math提供的所有内在函数,可以使用轻松将其导入到C#程序中然后using static Unity.Mathematics.math来使用它。

除此之外,Burst编译器还可以识别这些类型,并为所有受支持的平台(x64,ARMv7a …等)上为正在运行的CPU提供优化的SIMD类型。

注意:该API尚在开发中,我们可能会引入重大更改(API和基本行为)

总结

所以Unity的DOTS就是替我们解决了ECS的一大难题,即内存管理和编码规范部分,Unity还提供了一些自己的概念,例如WriteGroup,SharedComponent,Archetype等,我了解了一下感觉还是很好的,都是为了能在Gameplay中更好的运用而做出的抽象。

总的来说,可以期待。

推荐阅读