为了账号安全,请及时绑定邮箱和手机立即绑定

Flecs存储机制详解:图解 ECS 数据管理

在我的之前的“构建ECS”文章中,我使用了_代码示例_来解释几个要点。这次我将用图片来解释Flecs如何存储游戏数据的多个方面。这将(希望)提供一个更直观和整体的理解,帮助大家了解实体组件系统(ECS)的内部运作,即使我在某些地方省略了一些细节 :))

假设你对实体组件系统(Entity Component Systems,ECS)有一定了解,如果对细节还不太熟悉,参见https://github.com/SanderMertens/ecs-faq

既然行动胜于言语,咱们开始吧!

原型角色:1/14

原型(archetype)就像是数据库表,每一行代表一个实体,每一列代表一个组件。不过不同的是,实体仅存储在一个原型中。因此,这个原型包含了所有 _ 组件,即所有实体的所有组件。

以下图显示了一个原型,其中包含了实体(e1, e2, e3),每个实体恰好有两个属性(PositionVelocity)。

让我们剖析这个模型,将其拆解成各个元素:

  • 组件ID列表:这是一个列表,每个组件ID在这个列表中只出现一次,并且已排序,有助于加快ECS操作。
  • 实体ID列表:此列表在遍历时可以快速访问实体ID。
  • 列数组:此数组为每个组件存储一个数组(“列”),元素与组件ID列表相对应。
  • 组件列表:列表存储组件数据,元素与实体ID列表相对应。

下图展示了数组中的结构体(AoS)存储的一个例子。另一种选择是使用结构体中的数组(SoA)存储。

在这里,实体ID和所有组件都存储在一个大的数组(或有时分成多个“块”)的一行中。接下来的文章将假设使用SOA存储方式,因为这是一般采用的形式。

原型动作 (2/14)

当一个组件被添加到一个实体时,它会被移到另一个架构中。这意味着实体的其他组件也需要被重新安排。我们来看看在添加 Mass 组件时会发生什么:

注意这个操作在[位置速度]原型框架中留下了一个空位,这意味着需要填补,否则查询将读取无效数据。通过将e3移动到e1原来的位置并删除最后一行即可解决。:

(注:句末的冒号后应有空格,但为了格式显示正确,此处冒号后未添加空格。实际书写时请在冒号后添加空格。)

我们来看看,当我们从e1中再次移除质量会发生什么

注意,这和添加组件多么像!另外,因为我们这次没有在实体间留空位,所以不需要做额外的移动。

原型图谱 (3/14)

在我们转移至另一个原型之前,我们首先需要找到它。原型图数据结构是一种数据结构,首次在Flecs中引入,用来加快这一搜索过程。

这个想法很简单:每个原型角色是一个图节点,节点之间的边代表添加或移除的组件。

当添加了一个新的组件且没有相关边时,我们会先进行(更耗时的)查找,然后创建一条新的边。这意味着随着边的增多,遍历原型会变得更加高效!

原型图的一个优点是我们可以在图的边上编码不变量。如果 Power 总是伴随着 Responsibility ,我们可以在图上这样表达:

这便是 Flecs 中 With 特性的运作方式,它允许我们在一次架构移动的成本下添加多个组件。

专属关系也以特殊方式使用原型图表,其中沿着一条边会替换掉一个部件。

好的地方是这是一个原子操作:应用程序不会看到一个实体暂时有两个父节点或没有父节点。

标签 (4/14)

标签(也称为零大小类型(ZST))是没有数据的组件。标签的例子包括 NpcArcherIsAttacking,和 Idle。例如,在Flecs中,标签不会像其他组件那样作为架构列存储。这样做有两个好处:

  • 它能节省一些内存。
  • 在实体从一个模板移动到另一个模板时,标签是免费的。

这里有一个带有标签的原型例子。注意它只包含两列。

这带来了一个问题:组件ID数组与列不再对齐,这使得很难确定某一列的组件。于是我们添加了一个新的列映射数组:

这个数组将组件ID数组中的索引映射到列数组中的列。数组中的负数表明该组件ID没有对应的列,比如,它可能是一个标签。

有时候我们想反过来操作:从一列变成组件ID。反过来这样做映射就能实现。

在极端情况下,对于仅带有标签的原型实例,这种优化将原型实例的移动操作变成常数时间操作!这在关系密集的用例中特别有用。

关于关系的话题 (5/14)

关系可能是实现的一个复杂功能,但在原型存储方面,实际上只是一个很小的扩展。如下图所示,一个带有关系的原型:

关于在ECS中使用实体关系构建游戏

注意,这些关系对看起来就像标签一样!那是因为在存储层面上,关系只是大型组件ID标识符。这意味着我们也可以同样轻松地将这些关系数据存储起来。

找出一对中的类型需要稍微多一点逻辑,除此之外,基本上这就是关系存储所需的所有内容了!

稀疏成分 (6/14)

在 Flecs 中,可以将一个组件标记为“稀疏”。这意味着它会使用稀疏存储,而不是密集型存储。下面的图表展示了一个带有稀疏 Velocity 组件的架构:

可以看到这里的速度在这个架构中看起来就像是一个标签。稀疏组件存储在架构之外,因此在移动实体时不需要移动它们。

下图显示了速度(Velocity)的稀疏存储:

这里事情不少,咱们一步步来分析吧。

  • 稠密数组: 存储具有 Velocity 组件的实体 ID。
  • 稀疏索引数组: 存储组件数据,并通过实体进行索引。
  • 稠密索引: 指向稠密数组中的元素。
  • 空位标记: 稠密数组中的元素 0 未使用,因此我们可以使用稠密索引 0 来表示某个实体没有该组件。
  • 稀疏数组中的元素 0 永远不会被填充,因为 0 用于表示无效或不存在的实体。
  • 要获取实体 e2Velocity,可以执行 sparse_array[2]

稀疏数组保证不会在内存中移动,这意味着稀疏数组不能调整大小。为此,存储采用分页技术,将稀疏数组分割成固定大小的块:

致敬 EnTT,它对稀疏存储的设计产生了重大影响!

已禁用的组件 (7/14)

在某些场景下,防止一个组件被查询到,而不从实体中移除该组件,这样做可能有两个目的:

  • 我们可以以后某个时候恢复原来的组件值。
  • 这样就可以节省性能,因为实体无需更换架构。

在 Flecs 中,组件可以通过以下方式禁用或启用:

    e.disable<速度功能>();
    e.enable<速度功能>();

我们来看看如何存储失效的组件

有几个地方特别引人注目:

  • 组件 ID 数组中包含一个 TOGGLE | 速度 __ 组件,这表明可以禁用该 速度 组件。
  • TOGGLE 是一个“类型标记”,可以添加到任何组件 ID 上。
  • 位集表示哪些 速度 行被禁用。
  • 要启用或禁用组件,只需翻转相应的位。

位数组是一系列布尔值的集合,但与其将每个布尔值存储为单个字节,它被存储为一个比特。CPU可以在一条指令中处理8个字节(或64位)。这意味着查询可以通过一次检查就检查64个实体。

这里有一个具有多个可以开启或关闭的组件的模板:

工会关系 (8/14)

关系倾向于将实体分解成多个不同的原型,如果不加以控制。联合关系可以解决这个问题,通过将不同关系目标的实体存放在同一原型中。这会让联合关系的查询稍微慢一些,但其他查询会更快。这里有一个带有联合关系的原型:

请注意,关系对的目标是Union,而不是(Movement, Walking)(Movement, Running)。这表明实体存储了一个联合类型,Flecs会识别这一点。

目标存储在一个外部数据结构中,称为“切换列表”,这个数据结构是专门为此类联合关系设计的。

简单来说,这个交换表实现了这些目标。

  • 它可以快速迭代所有实体对象,适用于任何目标。
  • 它可以快速迭代所有实体对象,适用于特定目标对象。
  • 它具有 O(1) 的插入和删除。
  • 它具有 O(1) 的查询。

目标数组在开关列表中通过实体ID进行索引。为了节省内存,特别是在处理较大实体ID时,开关列表采用了与稀疏组件存储类似的分页技术。

实体篇 (9/14日)

实体索引告诉我们一个实体存储在哪一种原型和哪一行。此外,实体索引还记录了当前实体的哪个版本。下图显示实体索引:

密集/稀疏数组中的0元素为了简洁而省略。

注意这个图表与稀疏组件存储的相似性——这是因为这也是一个稀疏集合。让我们来看看这个数据结构的不同部分。

  • 密集数组:存储了由世界创建的所有实体。
  • 密集数组分为两部分:一部分用于存放活着的实体,另一部分用于存放已死亡(可回收)的实体。
  • 一个 alive_count 记录了密集数组中存活的实体数量。
  • 稀疏数组:通过实体ID进行索引。例如,实体 e1 存储在 sparse_array[1],实体 e2 存储在 sparse_array[2],以此类推。sparse_array[0] 未被使用,因为0用来表示无效的实体。
  • 稀疏数组储存了带有版本号的实体ID在密集数组中的索引。
  • 通过这个检查 Flecs 可以判断一个实体是否存活:dense_array[sparse_array[entity_without_version].dense] == entity
删除对象(10/14)

让我们看看删除实体e4后实体索引会怎样变化。黄色部分都变了:

为了简洁,稠密/稀疏数组中的0元素值被省略了

就这样发生了:

  • 在密集数组中,e3e4 的位置互换了。
  • 在稀疏数组中,索引已调整以反映这种变化。
  • alive_count 减了1。
  • 稀疏数组中对应的原型/行被清空了。
  • e4 的版本从 v1 升级到了 v2

一些稀疏集合设计不包含删除替换功能,因此可以实现稍微更好的删除效率。然而,这样的设计在以下情况下会更加突出其优势:

回收那些实体 (回收物实体, 11/14)

让我们现在来看看当我们重新处理两个实体时,实体索引发生了什么变化:黄色部分都变了。

数组中的0元素(密集或稀疏)为了简洁而省略。

  • alive_count 增加了 2;
  • 更新了 e2e4 的原型及行数据。

注意,除了更新稀疏数组的条目外,这还让我们能够有效地回收利用任意数量的实体,只需一次操作,只要存在足够的死亡实体!

实体划分

分区的ID(身份标识)可以用来快速判断实体是否属于某个特定的组。一个典型的用例是联网游戏,其中本地和远程的实体分别从不同的ID范围生成。

这确实给实体索引带来了一个挑战。一个应用程序可能希望将 ID 的最后一个位保留给网络中的实体,这意味着 ID 可能会变得非常非常大。如果稀疏数组必须容纳最大的实体 ID,我们很快就会耗尽内存。

解决方法是使用分页稀疏集合,类似于稀疏组件中使用的那种。利用分页,我们只需为实际包含实体的ID范围分配稀疏数组。

组件列表 (13/14)

组件索引是一种数据结构,首次在 Flecs 中引入,用于快速查找包含特定组件的所有实体。以下显示了组件索引:

  • 组件映射: 将每个 ECS 组件映射到一个架构模板。
  • 架构映射: 列出拥有该组件的所有架构模板。
  • 架构记录: 记录组件在架构中的具体位置。

组件索引有助于解决两种类型的问题。

  • 哪些原型有位置
  • 原型X有没有位置

这使得组件索引在评估查询时非常有用。要找到所有匹配“PositionVelocity”查询的组件原型,我们可以先用组件索引找到所有带有Position组件的原型,接着检查这些原型是否也带有Velocity组件。这实际上是 Flecs 中未缓存查询的工作原理。

通配查询

共14步,第14步

通配符查询可以做到诸如“找到所有喜欢任何人的实体们”,这会看起来像这样 (Likes, *)。这将返回如 (Likes, Bob)(Likes, Alice)(Likes, Pizza) 等实体。我们如何利用组件索引来查找所有“喜欢任何人”的实体们?

我们如何利用组件索引来查找所有包含 (Likes, *) 的原型实体?

为解决这个问题,我们可以在组件索引中添加一个条目,键为 (Likes, *),该条目包含一个原型图,其中包含所有包含 Likes 对的原型。

现在我们可以通过原型关系图轻松获取所有含有 Likes 关联的原型,通过 (Likes, *) 进行迭代。但这没有告诉我们这个原型包含多少 Likes 关联,如果我们希望查询返回所有匹配项,那么这一点是很重要的信息。

这可以通过在原型记录中简单地添加一个 count 成员来实现。对于常规组件,该计数始终为 1,但对于通配符,该计数可以大于 1。例如:

这里,index 表示组件在原型组件 ID 数组中的位置,count 表示组件出现次数。

乍一看可能没什么特别的,但这个数据结构是处理这篇文章中提及的复杂查询的核心。

总之,

构建一个功能性的ECS并不难,但构建一个在多种场景下都能表现良好的ECS则是一个可以让你花上几年时间探索的乐趣。希望这篇文章能让你对编写ECS有个初步了解,并对想要构建自己ECS的人有所帮助。ECS即实体组件系统,希望这能让你对它有更清晰的认识。

期待未来能更多地看到这样的内容,还有很多内容要介绍,比如存储层级、命令批处理、处理复杂查询、响应性、多线程等等!

如果你喜欢这篇文章,不妨给 Flecs 点个赞:https://github.com/SanderMertens/flecs

如果你想更详细地讨论 ECS,可以加入我们的 Discord 服务器:https://discord.gg/Kw9KcvP8ZK

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消