最近为了后续游戏开发工作,自己实现了一套ECS架构。
虽然前些年的开发经历,以及刚接触ECS时的思路整理,已经让我对他有了明确的认知,但是在具体实现层面,终究还是有很多需要考虑的地方。尤其考虑到当一个架构需要面临工业级开发所带来的压力的时候,如何确保ECS概念的完整以及满足工业开发中的各种压力,成为了真正设计实现ECS架构时的主要矛盾。
结合这些年的开发经历,以及我对ECS概念的认知和极致效率的追求,我设计实现了最新一般的ECS架构,现分享开发时的思路历程。
PS:本ECS架构依托于Unity,使用C#实现,文中实现均在此基础上进行。
ECS的核心实现
ECS的核心实现自然就是实体(Entity)、组件(Component)、系统(System)的主体实现。
ECS核心说明
实体 在ECS核心思路中,仅承载标识作用,简单点说实体甚至就只是一个id,所以实现实体较为简单,完全可以在架构中定义为一个不可派生类型。
组件 作为具体业务开发时实际存储数据的类型,因组件的不同而有不同的实现,故而组件需要设计为抽象类以供ECS进行管理,最佳实现便是ECS核心不需了解组件的具体实现便可完成其功能。
系统 与组件类似,也是仅在具体开发时才会拥有其具体逻辑,所以系统也需设计为抽象类,但与 组件 所不同之处在于 系统 仅有方法,组件 仅有数据。所以还需在某一处对 系统 进行寻轮执行系统自身的心跳函数。
因此,ECS的核心实现大致有如下几部分:
- 实体管理器,用于存储 实体 数组并进行添加删除以及赋值操作。
- 组件管理器,管理不同的组件类型,且不同组件类型内部通过id将具体 组件 与 实体 相连。
- 系统管理器,用于管理所有 系统 ,按序循环执行其心跳。
ECS核心实现说明
Entity
实体 作为标识,最核心数据便是其唯一id。此外,为了在ECS中可以立即获取某实体所拥有所有数据类型,需要有专门的组件掩码 (可以用哈希表,但下文会提供我的解决方案) ,用于存储实体所拥有的的组件类型,并提供相关的快速运算。此外实体拥有 有效标识符 以标明实体的有效性。
实体管理器 存储 实体 时,由于每个 实体 拥有自己唯一id,因此可以将其存储在数组中,并利用数组下标表示其唯一id。此外,由于存在 实体 的添加、删除操作,因此删除 实体 时并不将其从数组中移除,而改为设置其 有效标识符 ,这样在每一帧中依旧可以正常遍历这一帧初始时有效的 实体 ,而不必每一帧运算中都要考虑 实体 的有效性而带来的计算逻辑的不同。
Component
组件 因自身内容的不同,所以需要一个 组件枚举 对不同的组件类型进行标识。而在不同的枚举类型下,有需要存在对应的 组件 实体类型。所以对 组件 进行抽象,提供出基础的类型、生命周期函数等接口。
组件池 作为单一类型 组件 的集合,使用模板实现。在 组件池 中,以数组的形式对 组件 进行存储,其在数组中的id便是该 组件 对应的 实体 的唯一id。除此之外,池中还实现组件实体的缓冲机制,避免实体的频繁创建带来的GC。
组件管理器 内部以 组件枚举 为索引构建数组,存储所有的 组件池 。这样通过id和类型,便可从 组建管理器 中获取相应的 组件 实体,同时实现 组件池 对ECS核心外部的隐藏,避免直接访问可能带来的不可预知的问题。
System
系统 虽相互之间内容也不同,但因为不存在更高的层级对其进行调用,因此可以将 系统 单纯抽象出心跳接口即可。这样 系统 每帧只需要实现对其关注的 组件 组合体进行遍历并操作即可。
系统管理器 通过存储所有 系统 实例,将其存储至数组中,便可通过对数组遍历实现所有 系统 的心跳。
初版ECS核心实现的问题
虽然已经实现了第一版的ECS核心,但是有不少问题是可以第一时间进行设计改进的。问题如下:
- 组件掩码:作为系统筛选关注实体所用的标识,每帧都可能存在的大量调用,哈希运算效率不尽人意。
- 组件枚举:组件因为可扩展的关系,组件枚举和组件的对应关系以及组件池的创建均需要人为管理维护,可以考虑自动化管理。
- 系统顺序:系统的先后关系可能带来逻辑运算的不一致,因而如何确保系统运算的一致,需要考虑系统的排序机制。
修复初版ECS核心问题的实现
新的组件掩码实现
组件掩码 的实现原本是借助于每一个实体中安插一个哈希表,但是哈希算法本身需要消耗一定的计算资源。此外已知,我们的组件类型是由0开始递增的,且总长度可知。
由此,组件掩码 可以设计采用位运算的思路,使用封装 n
个 ulong
的结构,内部通过位运算存储组件数据。这样,无论对 组件掩码 进行遍历、增减、包含判断,都可以以原子操作的形式进行,相比哈希运算可以对算力进行大量节省。
组件枚举维护自动化
由于 组件枚举 、 组件 、 组件管理器 相互之间的关联性,使用人工管理维护必然存在纰漏,所以自动化工具提上日程。
自动化组件 工具核心负责一件事,便是检测 组件枚举 的变动。一旦枚举发生变动,那么 自动化组件 工具就需要第一时间 生成新组件文件 、 组建管理器中新增新组件的组件池 、 判断组件掩码可提供组件长度并进行刷新 。
其中,组件掩码 内部可提供位运算的数据长度是固定的,而组件类型数量本身是变动的,因此也需要在 组件枚举 发生变动时,由 自动化组件 工具生成新的 组件掩码 结构内部实现。
固定系统顺序
因为系统执行的先后顺序会影响逻辑计算的唯一性,因此只有当系统排序固定后,才可以确保逻辑运算的稳定性。
所以针对这个问题,我设计 系统属性 用于协助系统排序。
系统属性 当中包含两个字段:系统组id 、 系统组内id 。排序时对系统获取 系统属性 ,之后先按照系统组id进行排序,当系统同属一组时,再按照组内id进行排序。若两者完全一致,则按照系统名进行排序。
这样的设计,既可以满足系统固定排序的需求,同时作为开发者,也可以通过调整属性数据实现控制系统的排序。
新的问题
在针对上一版的问题通过新的设计进行解决后,新的问题就会接踵而至了。
现在的实现,可以让我们开始ECS模式的开发,但是依旧有性能问题需要我们持续关注:
- 系统每一帧的遍历,虽然依托新版本的 组件掩码 可以快速完成筛选运算,但是频繁的筛选依旧是性能热点。
- 系统中获取到 实体 后,如何优雅的获取组件也是一个问题。现在的版本需要依据实体id再通过ECS核心对 组件 进行获取实在不算太优雅。
- 针对ECS实现同步策略,需要底层拥有对快照系统的支持。
ECS核心扩展版实现
解决系统性能热点的缓存机制
从帧说起
ECS中的一帧,可以这么理解:
进入一帧时,所有的数据是经过了上一帧计算后,数据已经产生变更的全新状态。
在一帧中,系统负责的是流式计算数据的变更,而系统与系统之间应当保持独立,不会因某一系统的执行而导致另一系统的运算出错(虽然这一点很难保证,但实现这一点可以让两个人同时杀死对方的想法成为可能)。在保证了系统的相互独立性之后,我们便可以进行面向数据的编程,而不必理会对象方法执行先后的问题。
在一帧运算后,所有的数据便完成了一次完整的运算,下文的快照模块便可以执行对数据进行最终的记录。
缓存引入
在描述了ECS的一帧概念后,缓存便可以被我们加入到ECS核心中了。
由于系统之间的独立性以及帧与帧之间的数据独立性,所以可以引入缓存机制来解决系统中对实体筛选的性能热点。
缓存 依据各系统所需的组件掩码作为键,对实体进行缓存。各系统运行时由缓存中获取需要的组件实体。每一帧当中对实体进行的增减操作或对实体组件的增减操作,由 缓存管理器 进行记录。在一帧运算后 缓存管理器 则进行缓存刷新。
缓存 的引入可带来每帧效率的大幅改善。而且由于系统之间的独立,可以保证缓存不会因数据的非实时性而对系统的运算造成破坏性影响。
由缓存引来的新功能
因为引入 缓存 ,实体 与 组件 有效性的运算放在了帧尾,这也就造成了数据的非实时性。
而在业务的开发中,难免会牵扯到一些运算,需要在数据的有效性发生变更时进行操作,所以缓存内部记录的变更数据对需要这些数据的系统而言至关重要又存在缺陷。重要在于这些有效性的变更数据在某些系统中会引起反馈;而缺陷在于有效性变更会随机分布在系统中,如果关注这些数据的系统排序不合理,便会造成部分数据的丢失,且在非实时性运算中引入实时数据,本身也会存在兼容问题。
基于此,又引入了ECS核心的 通知中心,专供记录数据有效性的变更消息。而且对于消息保留其扩展性,便于在工业开发中依据自身需求新增特定消息供系统获取。
通知中心 收集消息,并在下一帧提供给系统。系统通过 通知中心 获取数据变动消息,且消息都是上一帧的运算结果。以此保证系统和数据的兼容性。
优雅获取组件
优雅地获取 组件 ,其实可以理解为更简单去获取 组件 。
现有机制下,由于系统已经改为从 缓存 中获取 实体 ,因此可以对 缓存 中的 实体 进行封装,使得遍历 实体 时返回的结构提供方法进行组件获取、增、删等操作。
底层支持的快照
ECS本身是依托输入进行运算的,所以针对复盘的问题,记录操作队列是可以达到目的的。但是针对同步,由于存在预判的机制,因此在预判失败返回正常状态时,便需要运用快照实现数据的快速回滚,这也就有了 快照 的引入。
快照 针对的是ECS中的 实体 和 组件 进行操作,而其他模块因没有数据存在,故而无需进行 快照。因 快照 需要存储数据,所以就需要可以对数据进行序列化与反序列化操作。
为此,对原有的 实体 和 组件 添加序列化与反序列化方法。其中,由于 组件 的可扩展性,序列化与反序列化作为 组件 的接口而存在。每次 快照 时对所有数据进行序列化并存储即可。当需要数据回滚时,则取出对应 快照 数据并进行反序列化。
最终的ECS
经过上述开发历程,一个ECS架构便算完成了。
这个ECS架构提供基础的 实体 、 组件 、 系统 模块,同时带有 缓存 、 通知 、 快照 等辅助模块,已经足够迎接挑战。
此外,现有的自动化工具可以在一定程度上辅助代码的生成与管理,尽可能减少人为因素导致的Bug。
不完美
当一套架构诞生后,如果没有经历实测,终究也只是纸上谈兵。只有经历实际项目的检验,才能确认架构的成熟性。
现有框架下,代码的自动化生成是一个重要的可开发点。此外对于运算效率以及空间效率的追求是永无止境的,这一点上,由于使用数组存储 组件 且 组件 自身的离散型,注定这里依旧可以寻求更优解。
最关键 的是,ECS架构或许是面向对象的,但是使用ECS却是面向数据的。如果思维没有转变,那么一切架构也只能朝着最坏的方向去使用。
结语
初始版本的ECS构筑完了,如果有更多不完美,也欢迎大家热烈吐槽。