星际战士2的ECS实践记录
前言
这是有关于GDC2025上面。《战锤40K:星际战士2》背后的ECS这篇分享的阅读笔记。
ECS的优势
- 性能友好,线性存储和访问的组件能够充分利用数据缓存和指令缓存
- 易于性能分析和调试,同时减少悬垂指针等问题对数据更具有掌控
- 易于多线程,他们的游戏类型和引擎迫切需要一个多线程方案来优化表现
这三点促使他们团队在《战锤40K:星际战士2》中选用这个技术架构。
他们主要在脚本层面使用ECS,同时希望提供开箱即用的并行方案,避免Gameplay程序关注的多线程方案。
常规ECS方案介绍
简单介绍了一下两种的ECS实现方案,稀疏集和原型模式。
最后他们采用的是原型模式。
稀疏集
每个组件都被存储在一个大型稀疏集的固定位置,可以通过实体对应的组件位置。
- 优点:组件可以快速访问、添加、移除
- 缺点:存在大量空白间隙时会导致遍历速度低
原型模式
具备相同组件组合的一类被汇总起来,称之为一类原型。相同原型的实体放置在一起。
- 优点:原型架构更适合迭代访问,因为存在线性内存中。并且认为原型模式更适合多线程处理(这点没明确说为什么)
- 缺点:难以动态调整组件,添加和移除都要原型变更
原型变更
原型变更指导致一个实体的原型发生变化。发生在一个实体的组件添加或者移除之后。
这是一项负责且耗时的工作,必须要在所有线程间同步进行。
如果每次实体的组件变化都要发生原型变更的话,会导致性能消耗爆炸。因此必须要收集不同线程上要产生原型变更,在某个同步时间点固定发生。
为了处理原型变更,需要一个特定的处理方案
动态队列
一个简单易行的方案是一个公共队列。每个线程提交对于组件变更的指令到队列中,后续统一处理指令。
这种方案简单易行。
但是必须按照指令添加的原始顺序来操作,因为该方案不好解决对特定实体存在多个变化时会出现的冗杂操作。
同时也需要额外的存储空间以临时存储将要添加的组件。
因此未采用该方案。
重分配表
他们选择临时存储实体组件的新组合状态,直到该实体确定了最后的组件状态后才会赋予原型。
预先划分了一个存储空间称之为重分配表,为所有可能参与变更的组件预留位置。当某个实体需要进行组件变化后,将涉及到的组件临时存入重分配表的对应位置。
该方案不可避免地消耗了大量内存空间。
ReassiginInfo | EntityInfo | EntityInfo |
---|---|---|
PositionCom | ComInfo | …… |
VelocityCom | …… | …… |
GeomatryCom | …… | ComInfo |
…… |
重分配表实现如下
添加组件
- 当添加组件时,会拓展重分配表,将新的组件写入表中对应位置(没看懂他说的拓展是一个什么意思)并记录下重分配信息
- 同步点来临时,从旧原型块中复制其他组件的信息到新原型块中,并从重分配表中复制新添加的组件
- 将被移除旧原型块中的实体交换到末位位置并擦除清理,保证了线性内存
优势在于:
- 速度快
- 无需存储中间的状态变更
- 每次重分配操作都是独立的,可以轻松并行化
缺点在于:
- 每次对同一实体内的组件更改都需要对组件加锁
- 重分配表需要占据大量内存,尤其是组件的实体创建阶段,为了避免这个问题,使用原型来克隆预先定义好的实体,克隆不通过重分配表处理
这部分个人没太懂重分配表相关的,可能是因为没有实操过,不是很理解ECS的实际使用
同步点
同步点是一个处理所有原型变动的安全时间点。
问题在于,同步点需要多久创建一次?每个系统执行后还是每帧创建一次?
理想情况下,每帧一次是比较合理的,通常也不会比帧率更频繁的处理状态变更。
但实际上某些需求也需要额外确定性的处理,比如说在服务器快照同步时,需要使用一个额外的同步点。
同步点应用
除去原型变更以外,同步点还有其他关键操作需要处理:
- 控制实体的生命周期
- 执行查询过滤
- 处理缓存
只有同步点操作完成后,其他线程才会感知到这些变更。
基本规则
由于同步点的设计和规则,自然衍生出了以下管理规则:
- 实体的创建、克隆、销毁的结果对所有的线程均不可见,直到经历过了一次同步点。即新实体在创建后不可被直接使用,而销毁实体也依然保持有效。
- 所有组件的添加和移除效果在对所有的线程均不可见,直到经历过了一次同步点。
- 禁止添加后立即移除组件的操作,因为此时组件并未真正存在。
- 只允许通过特定的实体构造器用来创建并操作新实体,不允许通过其他方式访问到待创建的新实体。
系统
系统即是查询和逻辑函数的组合。
宽窄函数
根据是否在系统的逻辑函数中处理实体的迭代,来区分出宽函数和窄函数。
- 宽函数:在系统的逻辑代码中,手动迭代每个实体并进行操作。函数中可以获得所有实体并进行相关的操作。
- 窄函数:在系统的逻辑代码中,只处理一个特定的实体,函数中不访问其他实体。
团队倾向于使用窄函数理念,虽然限制了系统中的功能,但是更易于控制。
宽函数理念还会导致隐含一些处理时的信息在系统的逻辑代码中
多线程原则
通过指定一系列原则,实现出了开箱即用的并行处理方案。
系统的代码仅针对单个实体运行,即窄函数原则。
- 这允许系统更新能够轻易的拆分到成不同模块,方便进行多线程规划
- 每个系统更新也只能够使用一个基础的查询得到的实体,能够限制依赖项的数量
- 一个系统 = 一个基础的查询 + 一个窄函数
系统的所有效果必须能够按照器参数类型推导得出,无隐藏副作用
函数内部不能存在隐藏的数据获取器,所有数据来源均需要通过外部传参以得到
参数类型支持以下内容:
- 基本组件
- 可选的组件
- 信息系统
- 其他固定资源
- 查询,用于获得其他实体,只用通过这种方式才能允许一个系统内获取到它基础查询以外的其他实体。
这个逻辑设计应该是为了快速判别一个系统涉及到的组件而产生的规范。在大规模开发中应该能减少沟通的效率损耗。
同时也能够更方便地进行多线程并行的自动适配。
消息系统
ECS的常规信号处理
常规ECS使用组件的添加和移除来进行标记,以处理特定的相关行为。例如处理伤害则通过添加伤害组件,以便在伤害处理系统中被处理。随后这些伤害组件会被移除。
毫无疑问的,这种方式会导致大量的组件原型变更,因此在他们项目中被舍弃了。
消息队列
他们的消息系统是无中心的,非即时的设计。
当某些系统需要注册消息时,除去消息以外,他们还需要注册这个消息所属的查询,即他们需要的消息来源的实体需要拥有哪些组件。
消息和查询结合起来被称为一个消息队列。
当一个实体需要发出消息时,它会根据这条实体存在的组件和消息队列,将消息发到对应的消息队列中。
例如一个实体存在位置和速度组件,而一个观察者只需要位置组件,一个观察者需要位置和速度组件。
那么此时该消息存在两个消息队列。
一个具备位置和速度组件的实体发出的消息会进入两个队列中。
一个只具备位置组件的实体发出的消息会进入第一个队列。
一个只具备速度组件的实体发出的消息不会被处理
消息队列并不是即时处理的,加入到消息队列的组件会在系统执行被遍历以处理。
消息系统的特点
每个消息会被分发到所有符合要求的消息队列中
消息发送是无锁的,可以从任意位置发出
消息是系统内顺序执行的,但不幸的是,在访问组件时是无序的(因为消息的顺序和实体的时序并不存在任何相关性)
这个问题他们通过预取10个组件来进行处理,大大优化了缓存命中的概率,获得了2.5倍性能提升
组件会在同步点前后可能发生变更,因此需要在同步点对实体和消息队列做额外的过滤
组件变更和消息系统
作业调度系统
他们需要提供一个开箱即用的多线程工具,无论是单线程或四线程,逻辑效果都应该毫无差别,这是核心目标。
构建流程
为每个系统都定义好固定的执行顺序
构建单线程测试环境作为基准,以即时发现并行导致的问题。
基于系统无隐藏副作用的前提,在不影响单线程的情况下隐式重排系统的执行顺序
基本设计
这里可以先回顾一下,系统的执行存在两种
基于原型顺序,通过查询获得顺序实体队列的顺序系统
基于消息队列,通过消息发生以获得无序实体队列的无序系统
将每个系统便利迭代分成多个块,以能够分配到多线程上并行运行,这被称之为自并行。
当一个系统的所有实体迭代完成后就可以让下个系统进行迭代。
一个顺序系统读取和写入其基础的组件时,这个系统是可以自并行的。
当一个顺序系统会随机写入组件时,就无法实现自并行。
无序系统只在无读取操作时可以实现自并行
除了单系统的自并行以外,系统间也会存在依赖关系阻碍其并行。
当两个系统间对同一实体的组件都只存在读取关系时,不存在依赖。
当两个系统存在任意写入关系时,则形成完全的依赖关系
通过信息连接的视为存在完全依赖
通过系统间的依赖关系和拓扑排序,可以运行时动态将下一个系统的任务调度出来,占据空线程进行执行。
热点系统和数据依赖
当某个系统存在大量写入组件操作,导致其他系统都需要依赖该系统,且该系统难以自并行时,就会被称为热点系统。
热点系统会导致需要等待它执行完成,即在某一个线程中该系统执行完成,这事实上短暂地变成了一个单线程模式。
在实际运行时发现热点系统往往需要某些被广泛使用的组件,例如位置、速度等。
但是实际上会它只会运行在特定原型的实体上,比如士兵等。
因此热点系统哪怕占用了某个特定组件的所有实体,但其中很大一部分实体都不会被影响到,在实际运行时就可以根据实际的数据使用情况来进一步优化执行顺序。
在静态依赖外,如果存在仅对某一个实体要执行的系统,会动态调整依赖情况,对非交集原型的实体可以单独执行。
流水线执行
更极端的情况下,两个系统需要对同一个的实体先后进行操作。这种情况更为难以处理。
这里可以采用流水线处理的方案,每个线程上一个系统迭代完成后,将另一个系统在该线程上处理这些组件,而非等到上一个系统全部执行完成。
这需要保证某个原型的所有实体按照固定的方式进行多线程分割处理,才能够保证流水线执行。
工具链
代码生成器
组件和系统会在专门的.ecs文件中声明,通过代码生成器生成脚本代码、C++代码及绑定文件,能够在遇到性能瓶颈时方便的从脚本语言中迁移到C++。
编辑器集成
需要将ECS道具加入编辑器中,方便关卡设计的布置和程序员运行时的调试。
提供追踪、调试、单步执行等功能。
ECS和OOP的兼容
游戏并非完全构建在ECS上,有很多东西还是按照面向对象设计的,比如主控人物。
现在假设需要同时影响ECS实体和面向对象实体,该如何操作?
例如,一个爆炸陷阱需要同时对ECS实体大量小兵和主控人物造成伤害。
面向对象的逻辑早已经完成,现在如何兼容ECS?
因此需要提供对ECS实体的封装接口,包括:
根据条件获取批量的ECS实体
将直接的函数调用转换为ECS的信息调用
取值方式转换为获取组件信息
但是此类接口对ECS并不安全,因此必须要延迟到一帧ECS行为结束后才能执行,这种异步逻辑会在编码时带来许多烦恼,而且会根据时序导致其它问题。
为了更优化其代码逻辑,采用了如下方案:
对于ECS世界来说,并不需要即时的数据,因此可以直接获取上一同步周期的组件数据。
执行函数也会放到下一个同步点
通过编译手段将其维护正确,他们将其称为Facades。
这部分个人其实没太读懂,希望有人能更详细指导一下
后记
这是一个很罕见的在大型项目中采用ECS架构的分享,个人断断续续花了一周来读和总结,然而有些内容个人也没太读懂,希望有能
由于个人视频来源的限制,我无法提供相关截图,只能以纯文字形式进行总结,敬请谅解。
如果有人能提供合适的视频素材的话也可联系我进行补充。