6 个版本

0.2.1 2024 年 4 月 10 日
0.2.0 2024 年 4 月 7 日
0.2.0-alpha.32024 年 2 月 3 日
0.2.0-alpha.22023 年 12 月 17 日
0.1.0 2022 年 11 月 19 日

#1931游戏开发

31 每月下载量
dynec 中使用

AGPL-3.0

110KB
2.5K SLoC

dynec

一个具有观点的类似 ECS 的框架。

CI codecov

什么是 ECS?

ECS 是一种面向数据编程范式,它侧重于优化 CPU 缓存。对象("实体")将其数据存储在 "组件" 中,这些组件在 "系统" 中进行处理。

dynec 有 E、C 和 S,但它不是典型的 ECS

dynec 是 静态架构类型。实体的架构类型指的是它可能拥有的组件集合,这类似于面向对象中的对象的 。在 dynec 中,实体一旦创建,就不能更改其架构类型。实体仍然可以有可选组件,但必须提前知道。

这允许实体引用具有严格的类型。当你持有实体引用时,你可以确保引用中的所有实体都存在。不同架构类型的实体分别存储,这进一步提高了 缓存局部性(因为不同架构类型的组件大多不相关)。组件还可以声明它们必须在架构类型的实体上始终存在,这使你对组件确实存在的信心更大。

此外,架构类型不能子类型化。这意味着,与传统的 ECS 不同,没有 "连接查询" 可以查询具有组件子集的所有实体。迭代只能在架构类型的所有实体上执行(也可以迭代具有单个组件的所有实体,但这用于不同的目的)。如果你想要获取具有多个组件的所有实体,就像在其他框架中通常做的那样,你可能想要将它们拆分成一个单独的实体。

这不会使多态更难使用吗?

我想象你的设计是这样的:一些实体具有 "猪" 架构类型,一些具有 "鸟" 架构类型,它们都共享常见的动物组件,而 "鸟" 还具有额外的飞行和与蛋相关的组件。

这不是在dynec中组织实体的视角。猪和鸟都是相同的原型,我们可以称之为“动物”。术语“鸟”只是一个包含飞行和产蛋等能力的总称。它根本不应该出现在代码逻辑中,因为在编程层面上“鸟”并没有实际意义。从某种意义上说,dynec中的实体与传统ECS中的一些可选组件类似。

这意味着实体之间会有很多引用——一个鸟实体需要引用其飞行管理实体和产蛋实体。尽管这似乎会使设计变得很复杂,但实际上在高质量软件中,一对一关系可能比一对多关系更少见。例如,一只鸟可能产多种类型的蛋,这将导致多个产蛋实体;无论如何,这都是无法简单管理的。

实体(可选)是引用计数和可追踪的

当启用调试断言时,会计数所有实体引用。当一个实体被删除时,如果还存在对该实体的悬空引用,dynec会恐慌并从世界中的所有组件和状态中搜索悬空引用。这意味着我们可以(基本上)确信任何实体引用都指向一个活着的实体,并且允许将强实体引用的大小减少到一个整数,因为强引用不应该能够存活于被引用的实体之外(大多数ECS框架需要另一个整数来存储“生成”以避免悬空引用指向在相同偏移量处重新创建的新实体)。

在删除实体之前删除所有引用似乎很麻烦实现,但dynec提供了两种解决方案。首先,dynec支持“终结器组件”,其中组件作为异步终结器。系统可以创建终结器,确保在实际删除(以及悬空引用检查)之前删除对实体的引用。这给不同的系统提供了清理实体的机会,而不会丢失描述实体的上下文(因为组件在删除后会消失并且无法再读取)。其次,如果确实有必要保留(悬空的)实体引用,你可以存储一个“弱引用”——弱引用也是引用计数的,但它们不会导致悬空引用恐慌。

尽管如此,为了跟踪实体的位置,所有组件和全局及系统本地状态(基本上是dynec管理的所有存储)都必须实现一个支持扫描所有拥有的强/弱实体引用的特性。dynec提供了一个派生宏来实现这一点,但由于Rust不支持特化(目前),需要在可能引用实体的每个字段上应用#[entity]属性。然而,通过使用静态断言,大多数在实现特性时的错误可以在编译时被揭示(异常是带有泛型参数的类型,需要手动确认)。尽管很麻烦,但扫描实体引用的能力使得更多功能成为可能,包括自动系统依赖声明和实体重排(下面描述)。

实体可以有多个同一类型的组件

我们如何存储玩家的健康状态呢?我们为玩家实体创建一个Health组件。如果我们还想存储玩家的饥饿度呢?好的,我们也为玩家实体添加一个Hunger组件。现在,如果我们有不确定数量的此类属性,这些属性在运行时确定(例如,通过插件或主游戏服务器)呢?由于我们无法动态声明类型,似乎我们必须重构为map或Vec。或者,可能是一个SmallVec<[Attribute; N]>,以避免堆分配。等等,N是多少?

在dynec中,我们通过“同位素组件”避免这个问题。类似于化学中的同位素,同一个实体可能有多个同类型的组件(Attribute),但这些组件属于不同的“区分符”(例如,属性ID)。从语义上讲,这看起来像我们得到了一个HashMap<AttributeId, Attribute>组件,但从性能上讲,每个AttributeId都分配在新的存储中,就像是一个不同的组件。这种设计在本例中效率更高,因为某些系统可能只想操作健康状态,而不想操作饥饿状态,因此它应该能够与使用饥饿属性的系统并发执行;它还有助于缓存局部性,因为它避免了跳过未使用的值。这在只支持基于类型的组件键的ECS框架中是不可能的,这些框架缺乏对动态定义逻辑的灵活性。

实体可以重新排列以优化随机访问(尚未实现)

与传统的基于OOP的代码风格相比,ECS表现更好的一个原因是组件存储在一个紧凑的区域,而不是堆中的各个地方,这减少了由于慢速内存访问而导致的CPU缓存穿透的频率。然而,当数据量很大时,由于实体通常是随机排列的(至少与堆分配一样随机),系统可能需要从排列得很远的实体访问组件。例如,在遍历网络模拟中所有边的情况下(其中节点和边是实体,边具有引用端点节点的组件),尽管描述边的数据是连续排列的,但访问端点节点的数据会导致随机内存访问,从而大大降低性能。

在dynec中,由于所有实体引用都是可追踪的,因此可以将相同构型的所有实体进行排列,使得相关实体更靠近。例如,在空间图的情况下(其中边长与节点密度相当,即几乎没有超长边),我们可以对所有节点和边执行quadtree/octree排序,使得遍历所有边实体时会处理空间上相邻的边,这反过来会访问空间上相邻的节点,两者都有更高的机会进行附近的内存分配。

当然,实体重新排列仅适用于那些理想实体排列可以长期保留的场景。例如,重新排列地图上的建筑是有用的,因为它们大多数是静止的,但重新排列地图上行驶的汽车是没有用的,因为它们的顺序很快就会改变(除非汽车速度非常慢或以与附近汽车相同的方式移动)。由于实体重新排列需要对一个构型的所有组件存储进行可变访问,并且同时处理大量数据,这是一次停止整个世界操作的,因此必须不频繁执行,因此排列偏离的理想时间应该非常短,这样就不会影响用户体验。

依赖关系

~0.7–1.2MB
~26K SLoC