是否有更好的数据结构来存储组件及其关联实体?

问题描述

我正在用 Javascript(特别是 Typescript)编写一个小实体组件系统 (ECS),它目前可以工作,但我想知道它是否可以更高效。 ECS 的工作方式是实体基本上只是组件包。因此,玩家实体可能具有 HealthComponentPositionComponentSpriteComponent 等。然后您可以创建一个 RenderingSystem查询具有 PositionComponent一个 SpriteComponent 然后它呈现它们。像这样:

for (let entity of scene.query(CT.Position,CT.Sprite) {
  // draw entity
}

为了在查询时提高效率,而不是每次都遍历场景中的每个实体以查看它是否有 Position 组件和 Sprite 组件,我们要做的是缓存它在第一次查询调用之后,然后保持更新,所以每次查询调用都可以只返回实体列表,而不是每次都先遍历所有实体的整个列表。

因此,例如,缓存可能如下所示:

{ "6,1,20" => Map(1) }
{ "2,3,6" => Map(1) }
{ "2,3" => Map(31) }
{ "9" => Map(5) }
{ "2,8" => Map(5) }
{ "29,24,2" => Map(5) }

// etc..

数字指的是枚举值的值,如 CT.PositionCT.Sprite 等。在这种情况下,CT.Position 是 2,CT.Sprite 是 3,还有是具有这两个组件的 31 个实体。因此,当查询具有这两个组件的所有实体时,我们只需返回该实体列表,而不是每次都计算它。

这一切都有效,但效率不高,因为向场景添加(和删除!)实体是一个 O(n) 操作,还涉及大量字符串拆分和连接。您需要遍历缓存中的每个项目,以查看该条目是否包含实体的组件列表。

有什么办法可以将其改进为更像 O(log n) 或更像 O(1)?如果这一切都清楚,或者是否有任何细节需要澄清,请告诉我。

这是 Typescript Playground URL reproduction example链接

解决方法

我希望缓存中的查询数量非常少,因为每个查询都将单独绑定到处理结果的一堆代码。因此,遍历查询列表并为每个查询列表执行一些操作不会那么昂贵,但是如果您在添加或删除一大堆实体时遇到问题,那么这当然可以解决。

首先,您用于组件类型子集的字符串表示确实非常低效。有很多选择。也许尝试这样的事情:

  1. 首先,为每个组件类型分配一个整数(您已经这样做了)
  2. 按整数对子集中的组件类型进行排序
  3. 使用每个整数作为一个字符构建一个字符串

这种表示不是太花哨,但它允许您使用 charCodeAt() 快速获取子集中的组件类型,并且您可以通过同时遍历两个字符串或遍历来使用它来测试子集通过一个同时在另一个中进行二分查找。

然而,真正的改进将来自按实体呈现的组件类型子集对实体进行分组。有很多方法。我认为这样的事情对你有用:

  • 对于每个实体,预先计算其组件类型子集字符串
  • 对于正在使用的每个子集,维护与该子集匹配的缓存查询列表。仅在您引入新查询或新子集时才需要修改此列表。
  • 添加或删除实体后,获取其子集的查询,并将其直接添加到结果中或从结果中删除。
  • 当您收到新查询时,创建一组与其匹配的子集,将其添加到这些子集的查询列表中,并检查每个实体以查看其子集是否包含在匹配集中。
,

好的,我认为对此我有一个初步的答案,因为它似乎有效,但是代码对我来说非常复杂,所以我无法理解不确定这是否真的有效,或者它只是看起来是真的坏了。

因此,对于该解决方案,我希望保持查询性能,因为每次帧更新都会为每个系统调用查询,因此它的执行频率是实体创建/删除的 1000 倍。当前查询通过首先检查缓存是否包含此组件映射来作为分摊 O(1) 算法工作。如果没有,它会创建与该组件(原型)分组相关联的实体列表,然后从缓存中获取该列表。缓存始终保持最新。

我的问题中的问题是,虽然有一个 O(1) 查询操作很好,但希望有更有效的添加和删除操作,因为它们是 O(n*k),其中 n 是不同查询操作(缓存成员)的数量,k 是实体中的组件数量。也就是说,无论何时创建或销毁实体,程序都必须遍历缓存中的每个项目并检查该实体是否应属于此查询操作。如果是,请将其添加到该集合中,如果不是,则将其删除。

我今天早上的想法是实现另一个缓存/映射。也就是说,原始缓存从查询组件列表(原型)映射到包含这些组件的实体集。示例:

{ "6,1,20" => Set(1) }
{ "2,3,6" => Set(1) }
{ "2,3" => Set(31) }

假设 2 指代 PositionComponent3 指代 SpriteComponent。这意味着可以在这组 31 个实体中找到包含这两个组件的所有实体。

因此,我对原始问题的初步解决方案是有一个映射,其中组件列表对应于它们所属的所有缓存条目。也就是说,假设我们有一个包含以下组件的实体:1,2,6,25。那么它在这个缓存中的对应条目将如下所示:

1,25 => [ "2,6","2,3" ]

第一次构建该原型的实体(组件列表)时,该列表是手动创建的。但是,之后它只是简单地维护。然后,每当有创建该原型实体的请求时,我们可以简单地查询该缓存以找出我们需要修改哪些缓存条目。

这样,我们不必遍历整个缓存,然后遍历每个缓存项以确定它是否应该是成员,而是只需查询二级缓存以确定它属于哪些缓存条目。所以,我相信摊销复杂度从 O(n*k) 缩减到 O(c),其中 c 是它所属的缓存条目的数量。