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日

#5 in #archetypes

每月下载量 36

Apache-2.0AGPL-3.0

510KB
10K 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 提供了一个 derive 宏来实现这一点,但由于 Rust 目前不支持特殊化,需要在可能引用实体的每个字段上应用 #[entity] 属性。然而,通过使用静态断言,大多数在特性实现中的错误都可以在编译时被发现(异常是具有泛型参数的类型,需要手动确认)。尽管有这么多麻烦,但扫描实体引用的能力使得更多功能成为可能,包括自动系统依赖声明和实体重排(以下将描述)。

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

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

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

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

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

在dynec中,由于所有实体引用都是可追踪的,因此可以重新排列同一原型的所有实体,以便相关实体更靠近。例如,在空间图的情况下(其中边长与节点密度相当,即非常少的超长边),可以对所有节点和边执行quadtree/octree排序,以便迭代所有边实体时处理空间上附近的边,这反过来又访问空间上附近的节点,两者都有更高的机会得到附近的内存分配。

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

依赖关系

~4–11MB
~120K SLoC