1 个不稳定版本

0.1.0 2024年1月16日

#559 in 数据结构

Apache-2.0

105KB
2K SLoC

绝对不是索尼PlayStation

tnaps,发音为 tiː-næps,是一个用 Rust 编写的纯实体-组件-系统(ECS)框架。它是为了测试这样一个假设而编写的,即允许游戏及其开发过程通过 ECS 进行扩展的技术也可以允许快速且高效地构建交互式应用程序。tnaps 是一个 MVP,用于验证这一假设。

实体-组件-系统概述

根据维基百科,实体-组件-系统范式至少自 1998 年的 "Thief: The Dark Project" 以来就已经存在。这个系统遵循“组合优于继承”的原则。实体存在,它们有组件,系统对组件进行操作。在 tnaps 中,实体是一组组件。可以任意地将组件添加到实体中或从实体中解绑。它可以被视为一个指针。组件是纯数据,或者数据具有少量局部方法,这些方法不接触其他组件。系统在一系列实体及其组件上操作,并作为实体之间相互交互的手段。换句话说,对于熟悉数据库的人来说,实体是行指针,组件是列,系统是查询引擎。

实体-组件-系统的主要优势可以通过以下表格最好地体现

组件 实体 1 实体 2 实体 3 ... 实体 N
ABC
...
XYZ

组件 ABC 绑定到实体 1、2 和 N,而组件 XYZ 绑定到实体 2、3 和 N。对 ABC 组件进行操作的系统必然访问实体 1、2 和 N,而对 XYZ 组件进行操作的系统必然访问 2、3 和 N。同时操作 ABC 和 XYZ 组件的系统只需接触实体 2 和 N。由于实体 2 和 N 是不同的,系统可以像顺序操作一样并行地对它们进行操作。

这个观察结果是 tnaps 的核心。

此表展示了tnaps所展现的两个自由度。首先,数据按组件进行分区。组件被定义为自包含的单位。它可以是一个健康仪表、一个位置或实体的任何其他属性。tnaps奖励小型、隔离的组件。其次,数据按实体进行分区。支持并发和并行性的系统可以并行操作不同的实体。

tnaps利用Rust的并发保证——即发送和同步标记特质——提供了一个框架,通过该框架可以使代码并发化。状态完全保持在组件内的系统可以通过最小化的代码更改轻松并行化。

入门指南

让我们直接进入一个端到端示例。以下是一个使用tnaps处理两个组件的系统的完整实现。

# use tnaps::{
#     system, system_parallel, ComponentChange, ComponentCollection,
#     ComponentRef, CopyOnWriteComponentCollection, CopyOnWriteComponentRef,
#     Entity as EntityTrait, MutableComponentCollection, MutableComponentRef,
#     NopPartitioningScheme, Partitioned, PartitioningScheme, ThreadPool,
# };
// Declare Entity to be a u128.
// Out of the box, tnaps supports u128, u64, and u32 entities.
type Entity = u128;

// Two sample componenents.
// They are just plain-old-data.
// Components can be practically any Rust type.
#[derive(Clone, Debug)]
struct ComponentAbc {
    x: u64,
    y: u64,
}

#[derive(Debug)]
struct ComponentXyz {
    z: f64,
}

// Let's declare a system that operates only on ABC.  It's totally
// permitted---and even desirable---for systems to have no state of their own.
// More complicated systems such as those we'll see later can have state, but it
// is subject to Rust's standard rules for concurrency.
struct SystemAbc;

// The system macro is the means by which we create a `run` method for our
// system.  We declare SystemAbc to have entity type Entity, and that it
// operates on a CopyOnWriteComponentCollection of type ComponentAbc.
system! {
    SystemAbc<Entity> {
        abc: CopyOnWriteComponentCollection<ComponentAbc>,
    }
}

// Our implementation of the system operates on an entity and a reference to a
// ComponentAbc.  Notice there's no mention of other entities.  We are operating
// on a single column from the above table.
impl SystemAbc {
    fn process(&self, entity: Entity, abc: &mut CopyOnWriteComponentRef<ComponentAbc>) {
        println!("processing: {}", entity);
    }
}

// Our system that operates entirely on a mutable component collection of
// ComponentXyz.
struct SystemXyz;

system! {
    SystemXyz<Entity> {
        xyz: MutableComponentCollection<ComponentXyz>,
    }
}

// Our implementation of SystemXyz.
impl SystemXyz {
    fn process(
        &self,
        entity: Entity,
        xyz: &mut MutableComponentRef<ComponentXyz>,
    ) {
        // We unbind entity two.
        if entity == 2 {
            xyz.unbind();
        }
        println!("processing: {}", entity);
    }
}

// A system that operates on both ABC and YXZ.
struct SystemAbcXyz;

system! {
    SystemAbcXyz<Entity> {
        abc: CopyOnWriteComponentCollection<ComponentAbc>,
        xyz: MutableComponentCollection<ComponentXyz>,
    }
}

// Our implementation of SystemAbcXyz.
impl SystemAbcXyz {
    fn process(
        &self,
        entity: Entity,
        abc: &mut CopyOnWriteComponentRef<ComponentAbc>,
        xyz: &mut MutableComponentRef<ComponentXyz>,
    ) {
        // We unbind entity two.
        if entity == 2 {
            xyz.unbind();
        }
        println!("processing: {}", entity);
    }
}

fn main() {
    let mut collection_abc = CopyOnWriteComponentCollection::from_iter(vec![
        (1u128, ComponentAbc { x: 10, y: 20 }),
        (3u128, ComponentAbc { x: 42, y: 43 }),
    ]);
    let mut collection_xyz = MutableComponentCollection::from_iter(vec![
        (2u128, ComponentXyz { z: std::f64::consts::PI }),
        (3u128, ComponentXyz { z: std::f64::consts::E }),
    ]);
    let sys1 = SystemAbc;
    let sys2 = SystemXyz;
    let sys3 = SystemAbcXyz;
    // Execute sys1 against abc.
    let (changes_abc,) = sys1.run(&mut collection_abc);
    assert!(changes_abc.is_empty());
    // Execute sys2 against xyz
    let (changes_xyz,) = sys2.run(&mut collection_xyz);
    assert!(!changes_xyz.is_empty());
    collection_xyz.apply(changes_xyz);
    // Execute sys3 against both abc and xyz.
    let (changes_abc, changes_xyz) = sys3.run(&mut collection_abc, &mut collection_xyz);
    collection_abc.apply(changes_abc);
    collection_xyz.apply(changes_xyz);
}

根据直观理解,这将运行sys1针对实体1和3,sys2针对实体2和3,以及sys3针对它们的交集:实体3。

这就是tnaps框架的威力:它通过实体和组件的交集,有效地选择只与系统相关的实体。

此示例展示了tnaps的大多数功能;它旨在轻量级且易于在几小时内消化。

我们可以将我们的示例并行化(仅针对sys3

# use std::sync::Arc;
#
# use tnaps::{
#     system, system_parallel, ComponentChange, ComponentCollection,
#     ComponentRef, CopyOnWriteComponentCollection, CopyOnWriteComponentRef,
#     Entity as EntityTrait, MutableComponentCollection, MutableComponentRef,
#     NopPartitioningScheme, Partitioned, PartitioningScheme, ThreadPool,
# };
#
# type Entity = u128;
#
# #[derive(Clone, Debug)]
# struct ComponentAbc {
#     x: u64,
#     y: u64,
# }
#
# #[derive(Debug)]
# struct ComponentXyz {
#     z: f64,
# }
// A system that operates on both ABC and YXZ, in parallel.
struct SystemAbcXyz;

system_parallel! {
    SystemAbcXyz<Entity> {
        abc: CopyOnWriteComponentCollection<ComponentAbc>,
        xyz: MutableComponentCollection<ComponentXyz>,
    }
}

// Our implementation of SystemAbcXyz.  Unchanged from our first example.
impl SystemAbcXyz {
    fn process(
        &self,
        entity: Entity,
        abc: &mut CopyOnWriteComponentRef<ComponentAbc>,
        xyz: &mut MutableComponentRef<ComponentXyz>,
    ) {
        // We unbind entity two.
        if entity == 2 {
            xyz.unbind();
        }
        println!("processing: {}", entity);
    }
}

fn main() {
    let partitioning: Arc<dyn PartitioningScheme<Entity>> = Arc::new(NopPartitioningScheme) as _;
    let collection_abc = CopyOnWriteComponentCollection::from_iter(vec![
        (1u128, ComponentAbc { x: 10, y: 20 }),
        (3u128, ComponentAbc { x: 42, y: 43 }),
    ]);
    let mut collection_abc = Partitioned::from(&partitioning, collection_abc.partition(&*partitioning));
    let mut collection_xyz = MutableComponentCollection::from_iter(vec![
        (2u128, ComponentXyz { z: std::f64::consts::PI }),
        (3u128, ComponentXyz { z: std::f64::consts::E }),
    ]);
    let mut collection_xyz = Partitioned::from(&partitioning, collection_xyz.partition(&*partitioning));
    let sys3 = Arc::new(SystemAbcXyz);
    // Execute sys3 against both abc and xyz.
    let thread_pool = ThreadPool::new("example", 4);
    let waiter = sys3.run(&thread_pool, &mut collection_abc, &mut collection_xyz);
    let (changes_abc, changes_xyz) = waiter();
    collection_abc.apply(changes_abc);
    collection_xyz.apply(changes_xyz);
}

正如我们所见,我们的系统实现没有改变。我们唯一的更改是使用system_parallel宏代替system,并将每个实体包装为Partitioned集合。我们使用了NopPartitioningScheme,但使用其他分区方案也是同样有效的。

这就是将系统从非并发转换为并发行为所需的所有步骤。

组件集合

tnaps的设计围绕组件及其集合。单个组件集合旨在以高效的方式将实体映射到组件,使我们能够在单个系统中将多个组件组合在一起。tnaps自带四个组件集合,合理地涵盖了更多用例。总是可以手动实现ComponentCollection特质来创建自定义集合。

包含的集合包括:

  • 写时复制:此组件集合返回其所有更改以用于apply集合调用。然后它将重写整个集合以维护组件的顺序,按照它们的实体。

  • 插入优化:此组件集合针对将组件绑定到实体或很少更新实体的工作负载进行优化。与写时复制集合不同,它分摊了更新成本,插入优化集合公开了一种快速插入单个元素的方法,因此插入元素的平均成本大于其他集合,但没有分摊成本。

  • 可变:此组件集合在组件不会频繁插入,但将就地更新时很有用。将组件从实体绑定和解绑的成本略高于写时复制集合,并且比插入优化集合的成本低得多。

  • 分区:此组件集合将另一种类型的组件集合包装起来,并根据分区方案将其分片。这很有用,因为不同的线程可以并行操作不同的分区。因此,分区集合是并行系统的先决条件。

常见模式

本节概述了一些常见模式,以充分利用tnaps。

  • 当与操作不同组件的系统一起工作时,按增加的基数对组件进行排序是有利的。组件中的项目越少,它就越能从其他组件中剪枝实体。例如,在一个尝试检测玩家被子弹击中的系统的组件中,在玩家导向的组件之前对子弹导向的组件进行排序可能是有意义的。

  • 写时复制组件集合是 SendSync,这意味着它可以由多个系统并行使用。这允许一个应用从多个并行系统中聚合更改,并在主循环迭代结束时作为一个批次应用。

  • 使用系统的 run_subset 功能来子选择要操作的实体很有诱惑力。这比使用标记组件进行系统剪枝稍微高效一些。为每个要传递给 run_subset 的实体创建一个标记组件,并信任系统能够并行运行。run_subset 无法在并行系统上工作。

  • run_subset 对枚举顺序无关的实体很有用。这允许一个顺序系统(可能是碰撞检测)通过谓词预先对实体进行排序,并保证实体将以任意顺序枚举。这对于需要考虑具有不匹配实体总序的局部性的实体系统来说可能是一个优势。

  • 使用插入优化的映射将新组件绑定到实体上是有成本的。除非实体需要立即绑定,或者每帧更新很少,否则使用写时复制或可变组件集合并将所有更新作为一个批次应用更高效。

  • 所有实体始终存在,只是可能没有被绑定到任何组件上,因此被排除在考虑之外。这消除了中央实体注册表。

  • 可以创建一个不是整数的实体类型。这在 tnaps 中是一种反模式。对于大多数应用程序,请使用内置的实体类型。

  • 使用模运算生成实体。选择一个与可能实体数量互质的数字,从一个任意随机数开始,然后使用 wrapping_mul 枚举实体。根据模运算的性质,所有实体将在回收之前枚举。为了有效地选择互质数,随机选择除二之外的小素数,并将它们相乘,直到下一个选定的素数会溢出你的实体类型。根据质因数分解的性质,这个数字将与 128 的 2 的幂互质。

  • 或者,使用 u128 作为实体并生成一个 UUID。

  • 使用同步组件允许就地更新,甚至对于 CopyOnWriteComponentCollection

  • FastEntityMap 的构建速度较慢,但查询速度显著更快。有一个待办事项要使所有组件集合泛型化。

  • 考虑将主循环编码为数据流的有向无环图(DAG)。这将有助于更好地建模系统,类似于数据流引擎。

  • 可以将系统视为类似数据库的“连接”组件。在需要不同实体交互的地方,存在一个连接它们组件数据的系统。系统的 process 方法应收集连接的数据,然后系统应在 process 调用中或 runrun_parallel 完成后单独执行连接。

  • 可以通过将监听器与通知者连接来实现“信号”。

  • 可以为实体创建“区域”,其中每个区域都有独立的集合和系统。为了在区域之间移动实体,有一个系统完全解除实体与一个区域的集合的绑定,并将其绑定到另一个区域的所有必需组件集合中。有一个待办事项要统一这和分区。

  • 输入是一个组件。

  • 创建标记组件以将实体与最近的活动关联起来。

  • 持久化系统应检测组件的变化并持久化这些变化。这可以用来获得整个实体-组件-系统应用程序的持久性。将所有状态从数据库中尝试保持到tnaps中是一种反模式。相反,应保留主键并将它们复制到返回数据库引用的系统。

设计选择

tnaps中故意设计了许多设计选择,使其既功能强大又小巧。功能优先于大小,但能够将整个框架保持在脑海中也有其优势。

  • 每个组件实例绑定到恰好一个实体。这是tnaps的一个基本假设,使其能够有效地修剪实体和组件。始终可以将共享状态放在Arc后面,以使组件能够在实体间共享状态。

  • 系统接口被有意设计为一次处理一个实体。这允许在实体间进行并行化,而不泄漏任何关于这种并行化的细节。将来,将可能出现类似SIMD的系统。

  • tnaps假设所有组件集合都将根据实体映射进行排序。这可以使用户高效地扫描所有组件,以找到存在于两个或更多组件集合中的实体交集。

  • tnaps中不包括main。这高度依赖于应用程序。它看起来像是一个带有循环的主函数,该循环反复在组件上运行系统。相似之处到此为止,所以tnaps不提供任何内容。

  • tnaps中不包括系统。大型交互式应用程序的常见组件将单独提供。

  • 实体始终存在。因此,将新组件绑定到实体永远不会是错误。这个假设使得性能得到提升。

  • 选择Rust作为tnaps的原因是其安全性功能,主要是Send + Sync。

  • 大多数tnaps测试使用随机、基于属性的测试。

  • 实体被选为纯净的,并且不包含有意义的日期。因此,它们轻量级且易于复制。这就是为什么Entity必须实现Copy

  • 组件应该是纯数据。不同的数据应该是不同的组件。多个稀疏集合应该与一个巨大的基于继承的集合一样高效地处理。组件没有一般要求(除了在适当的地方要求Debug和Clone)。

  • 系统应该是代码加系统状态。tnaps对它们的外观没有假设。这是自由,而不是限制。

  • tnaps的系统组件接口允许灵活的调度。将调度动态地根据分区执行时间重新分区集合是一个开放的待办事项。

餐巾纸计算

让我们假装我们正在酒吧里,在餐巾纸上工作,以找出tnaps可能实现的内容。

首先,让我们澄清一下:一百万对于计算机来说并不是一个大数字。

ENTITIES = 1_000_000

现代计算机对于低成本来说非常庞大。对于每小时8-11美元,我们可以获得超过100个核心和数百GB内存的机器。

具体来说,我们可以从Amazon AWS租用以下两个参考点

c7i.48xl i4i.32xl
CPU核心 192 128
内存 384 GB 1024 GB
网络 50 Gbit/s 75 Gbit/s
附加SSD N/A 8 x SSD
价格/小时 $8.568 $10.982

这些机器功能强大,相对于常规面向软件的业务成本来说相对较少。如果我们能够将我们的整个应用程序放入tnaps(我们的中心假设),这些机器应该能够满足需求。

让我们从计算开始。tnaps围绕一个每次tick执行一次的循环构建。什么是tick?让我们使其可变,并看看c7i.48xl需要多少计算时间。

tick间隔 计算 每个实体
1/60秒 3.2秒 3.2微秒
1/30秒 6.4秒 6.4微秒
1秒 192秒 192微秒
5秒 950秒 960微秒
15秒 48分钟 2.88毫秒
60秒 192分钟 11.5毫秒

我等待我的共享出行和外卖应用确认订单超过了一分钟。他们在这段时间里都在做什么呢?

当然,我在这里忽略了耐用性。让我们继续使用我们的c7i.48xl机器。它为Amazon EBS提供40Gbit/s的带宽。

恢复卷 恢复时间
64字节/实体 12.8毫秒
128字节/实体 25.6毫秒
256字节/实体 51.2毫秒
512字节/实体 102毫秒
1KB/实体 204毫秒
... ...
64KB/实体 13.1秒
1MB/实体 209秒

当然,这些都是完美的数字,209秒的停机时间也不是什么大问题。每次应用程序重启时可能会发生的三分钟停机时间可能或可能不被接受。但完整的状态不必在启动时立即体现出来。它可以随着请求的逐渐到来而缓慢地体现出来。

总结

我坚信,实体-组件-系统是构建适合单机数据的应用程序的最佳方式。通过查看共享经济中常见交互式应用的SEC文件,我认为1e6个活跃实体是应用程序的合理目标点。在单个监管领域或地理围栏区域内不太可能存在那么多的活跃实体。

tnaps提供了一个接口,其中组件和系统都易于实现和测试。测试系统就像实例化系统并在其中调用process一样简单,就像tnaps本身一样。

回到我的假设,有一个位置可以设置应用程序的数据流(主)。从那里,可以将不同的关注点干净地隔离到系统中的可重用组件。添加功能可以通过添加数据(组件)和实现代码(系统)来完成。关键的是,这只需要与其他系统协调,Rust + tnaps在很大程度上负责这种协调。通过查找系统中的run调用,你可以毫无疑问地知道哪些系统触动了哪些组件。同样,你可以知道由于类型系统做出的保证,哪些数据发生了变化。

展望未来

我想验证tnaps背后的假设,并消除在无持久性下运行单机域的假设。

免责声明

正如本文件的标题所暗示的,tnaps与索尼无关。我喜欢我的PlayStation和tnax和tnan,但它们的音节不如tnaps响亮。

无运行时依赖