什么是存储动态游戏对象的最有效容器?[关闭]


20

我正在做第一人称射击游戏,我知道很多不同的容器类型,但是我想找到最有效地存储动态对象的容器,该对象将在游戏中频繁添加和删除。EX子弹。

我认为在那种情况下,它将是一个列表,以便内存不连续并且永远不会进行任何大小调整。但随后我也正在考虑使用地图或集合。如果有人有任何有用的信息,我将不胜感激。

我是用c ++编写的。

另外,我想出了一个我认为可行的解决方案。

首先,我将分配一个大尺寸的向量。比如说1000个对象。我将跟踪此向量中最后添加的索引,以便知道对象的末尾在哪里。然后,我还将创建一个队列,该队列将保存从向量中“删除”的所有对象的索引。(不会进行实际的删除,我只知道该插槽是免费的)。因此,如果队列为空,我将添加到向量+ 1中最后添加的索引,否则,我将添加至队列前面的向量的索引。


您要定位的任何特定语言?
Phill.Zitt 2012年

这个问题太难了没有一个好的更多的细节来回答,包括硬件平台,语言/框架等
PlayDeezGames

1
专家提示,您可以将空闲列表存储在已删除元素的内存中(因此您不需要额外的队列)。
杰夫·盖茨

2
这个问题有问题吗?
特雷弗·鲍威尔

请注意,您不必跟踪最大的索引,也不必预先分配许多元素。std :: vector会为您解决所有这些问题。
API-Beast

Answers:


33

答案总是使用数组或std :: vector。诸如链表或std :: map之类的类型在游戏中通常绝对是可怕的,并且肯定包括诸如游戏对象集合之类的情况。

您应该将对象本身(而不是指向它们的指针)存储在数组/向量中。

需要连续的内存。您真的很想要它。遍历非连续内存中的任何数据通常会导致很多高速缓存未命中,并且使编译器和CPU无法进行有效的高速缓存预取。仅此一项就可以降低性能。

您还希望避免内存分配和释放。即使使用快速的内存分配器,它们也非常慢。我已经看到,只要删除每帧几百个内存分配,游戏就可以获得10倍FPS的提升。看起来应该没有那么糟糕,但事实可能如此。

最后,您关心的用于管理游戏对象的大多数数据结构可以比使用树或列表更有效地实现在数组或向量上。

例如,要删除游戏对象,您可以使用交换和弹出。可以轻松实现,例如:

std::swap(objects[index], objects.back());
objects.pop_back();

您也可以将对象标记为已删除,并在下次需要创建新对象时将它们的索引放在空闲列表中,但是进行交换和弹出效果更好。它使您可以对所有活动对象进行简单的for循环,而无需循环本身。对于子弹物理集成等而言,这可以显着提高性能。

更重要的是,您可以使用槽位图结构从稳定的唯一表中查找一对简单的表查询对象。

您的游戏对象的主数组中都有一个索引。仅使用此索引就可以非常有效地查找它们(比映射甚至是哈希表要快得多)。但是,索引在删除对象时由于交换和弹出而不稳定。

插槽映射需要两层间接寻址,但是两者都是具有恒定索引的简单数组查找。他们很快。真快。

基本思想是,您具有三个数组:主对象列表,间接列表和间接列表的空闲列表。您的主要对象列表包含您的实际对象,其中每个对象都知道自己的唯一ID。唯一ID由索引和版本标签组成。间接列表只是主对象列表的索引数组。空闲列表是指向间接列表的索引的堆栈。

在主列表中创建对象时,您会在间接列表中找到一个未使用的条目(使用空闲列表)。间接列表中的条目指向主列表中未使用的条目。您可以在该位置初始化对象,然后将其唯一ID设置为所选的间接列表条目的索引以及主列表元素中现有的版本标签加上一个。

销毁对象时,您可以照常进行交换和弹出操作,但同时还要增加版本号。然后,您还将间接列表索引(对象唯一ID的一部分)添加到空闲列表中。在将对象作为交换和弹出的一部分进行移动时,还可以将其在间接列表中的条目更新为新位置。

伪代码示例:

Object:
  int index
  int version
  other data

SlotMap:
  Object objects[]
  int slots[]
  int freelist[]
  int count

  Get(id):
    index = indirection[id.index]
    if objects[index].version = id.version:
      return &objects[index]
    else:
      return null

  CreateObject():
    index = freelist.pop()

    objects[count].index = id
    objects[count].version += 1

    indirection[index] = count

    Object* object = &objects[count].object
    object.initialize()

    count += 1

    return object

  Remove(id):
    index = indirection[id.index]
    if objects[index].version = id.version:
      objects[index].version += 1
      objects[count - 1].version += 1

      swap(objects[index].data, objects[count - 1].data)

间接层使您可以为压缩期间可以移动的资源(主对象列表)具有稳定的标识符(间接层的索引,其中条目不移动)。

版本标记允许您将ID存储到可能被删除的对象。例如,您有一个ID(10,1)。索引为10的对象将被删除(例如,子弹击中一个对象并被销毁)。然后,位于主对象列表中该内存位置中的对象的版本号会增加,即为(10,2)。如果尝试从一个过时的ID再次查找(10,1),则该查找通过索引10返回该对象,但可以看到版本号已更改,因此该ID不再有效。

这是具有绝对稳定的ID的绝对最快的数据结构,该ID允许对象在内存中移动,这对于数据局部性和缓存一致性非常重要。这比哈希表的任何实现都快。哈希表至少需要计算一个哈希(比表查找更多的指令),然后必须遵循哈希链(在std :: unordered_map的可怕情况下是一个链表,或者在其中是一个打开地址的列表)哈希表的所有非愚蠢实现),然后必须对每个键进行值比较(比版本标签检查更昂贵,但可能更便宜)。一个非常好的哈希表(不是STL的任何实现中的哈希表,因为STL要求哈希表针对与游戏对象列表有关的不同用例进行优化)可以节省一个间接访问,

您可以对基本算法进行各种改进。例如,对主对象列表使用类似std :: deque的东西;间接层的另一层,但允许将对象插入完整列表,而不会使您从插槽图获取的任何临时指针无效。

您还可以避免将索引存储在对象内部,因为可以从对象的内存地址(此-对象)计算索引,并且只有在删除对象时才需要更好,在这种情况下,您已经有了对象的ID(因此,索引)作为参数。

致歉 我认为这不是最清晰的描述。时间太晚了,如果不花比我更多的时间在代码示例上的时间,就很难解释。


1
您需要权衡额外的deref和每次访问“紧凑”存储所需要的高分配/免费成本(交换)。以我在视频游戏方面的经验来看,这是一个不好的交易:)当然,YMMV。
杰夫·盖茨

1
实际上,您实际上并不需要经常在现实世界中进行取消引用。当您这样做时,可以将返回的指针存储在本地,尤其是如果您使用双端队列(deque)变量或知道拥有该指针将不会创建新对象时。遍历集合是一个非常昂贵且频繁的操作,您需要稳定的id,要为易失性对象(如项目符号,粒子等)压缩内存,并且间接寻址在调制解调器硬件上非常有效。这项技术已用于许多超高性能商用引擎中。:)
肖恩·米德迪奇

1
根据我的经验:(1)判断视频游戏的表现是最差情况,而不是平均情况。(2)通常,每帧集合上有1次迭代,因此压缩只是“降低了最坏情况的发生频率”。(3)您通常在一个帧中有许多分配/释放,高成本意味着您限制了该功能。(4)您每帧具有无限制的解引用(在我从事过的游戏中,包括《暗黑破坏神3》,解引用通常是经过适度优化后性能成本最高的操作,>服务器负载的5%)。我并不是要放弃其他解决方案,只是指出我的经验和推理!
杰夫·盖茨2012年

3
我喜欢这个数据结构。我很惊讶它并不知名。它很简单,可以解决几个月来困扰我的所有问题。感谢分享。
2013年

2
任何阅读此书的新手都应该非常警惕此建议。这是一个非常误导的答案。“答案总是要使用数组或std :: vector。像链表或std :: map这样的类型在游戏中通常绝对可怕,而且肯定包括诸如游戏对象集合之类的情况。” 被大大夸大了。没有“ ALWAYS”答案,否则将不会创建这些其他容器。说地图/列表“可怕”也是夸张的。有很多使用它们的视频游戏。“最有效”不是“最实用”,可能会被误认为是主观“最佳”。
user50286

12

固定大小的数组(线性内存),
带有内部空闲列表(O(1)分配/空闲,稳定的索引),
带有弱引用键(重复使用插槽无效键),
零开销解除引用(当已知有效时)

struct DataArray<T>
{
  void Init(int count); // allocs items (max 64k), then Clear()
  void Dispose();       // frees items
  void Clear();         // resets data members, (runs destructors* on outstanding items, *optional)

  T &Alloc();           // alloc (memclear* and/or construct*, *optional) an item from freeList or items[maxUsed++], sets id to (nextKey++ << 16) | index
  void Free(T &);       // puts entry on free list (uses id to store next)

  int GetID(T &);       // accessor to the id part if Item

  T &Get(id)            // return item[id & 0xFFFF]; 
  T *TryToGet(id);      // validates id, then returns item, returns null if invalid.  for cases like AI references and others where 'the thing might have been deleted out from under me'

  bool Next(T *&);      // return next item where id & 0xFFFF0000 != 0 (ie items not on free list)

  struct Item {
    T item;
    int id;             // (key << 16 | index) for alloced entries, (0 | nextFreeIndex) for free list entries
  };

  Item *items;
  int maxSize;          // total size
  int maxUsed;          // highest index ever alloced
  int count;            // num alloced items
  int nextKey;          // [1..2^16] (don't let == 0)
  int freeHead;         // index of first free entry
};

处理从子弹到怪物到纹理再到粒子等的所有内容。这是视频游戏的最佳数据结构。我认为它来自Bungie(在马拉松/神话时代),是我在暴雪中学到的,当时我认为它是游戏编程中的瑰宝。此时可能遍及整个游戏行业。

问:“为什么不使用动态数组?” 答:动态数组会导致崩溃。简单的例子:

foreach(Foo *foo in array)
  if (ShouldSpawnBaby(*foo))
    Foo &baby = array.Alloc();
    foo->numBabies++; // crash!

您可以想象情况更复杂的情况(例如深层调用堆栈)。对于所有类似数组的容器都是如此。在制作游戏时,我们对问题有足够的了解,可以迫使一切尺寸和预算以换取性能。

我不能说足够:确实,这是有史以来最好的事情。(如果您不同意,请发布更好的解决方案!注意事项-必须解决本文顶部列出的问题:线性内存/迭代,O(1)分配/空闲,稳定索引,弱引用,零开销deref或有一个令人惊讶的原因,为什么您不需要其中的一个;)


你对动态数组是什么意思?我问这个问题是因为DataArray似乎也在ctor中动态分配了一个数组。因此,按照我的理解,它可能具有不同的含义。
Eonil 2014年

我的意思是在使用过程中调整大小/内存移动的数组(与其构造相反)。stl向量是我称为动态数组的示例。
杰夫·盖茨2014年

@JeffGates真的很喜欢这个答案。完全同意接受最坏情况作为标准情况下的运行时成本。使用现有数组支持免费链表非常好。问题 Q1:maxUsed的目的?问题2:将索引存储在ID的低位中以用于分配条目的目的是什么?为什么不为0?问题3:如何处理实体世代?如果不是这样,我建议使用Q2的低位进行ushort生成计数。- 谢谢。
工程师

1
A1:使用的最大值允许您限制迭代。您还分摊任何建设成本。A2:1)您经常从项目-> id转到。2)它使比较便宜/显而易见。A3:我不确定“世代”是什么意思。我将其解释为“您如何区分插槽7中分配的第五项和第六项?” 其中5和6是几代人。所提出的方案对所有时隙全局使用一个计数器。(实际上,我们为每个DataArray实例从一个不同的数字启动此计数器,以更轻松地区分ID。)我确信您可以重新调整每个项目跟踪的位很重要。
杰夫·盖茨

1
@JeffGates-我知道这是一个老话题,但是我真的很喜欢这个主意,您能给我一些关于void Free(T&)超过void Free(id)的内部工作的信息吗?
TheStatehz

1

没有正确的答案。这完全取决于算法的实现。随便找一个您认为最好的人。不要在此早期阶段进行优化。

如果您经常删除对象并重新创建它们,建议您看一下对象池的实现方式。

编辑:为什么使带有插槽的东西变得复杂而没有。为什么不只使用堆栈并弹出最后一个项目并重复使用呢?因此,当您添加一个时,您将执行++,而当您弹出一个时,您将执行++-以跟踪结束索引。


简单的堆栈无法处理以任意顺序删除项目的情况。
杰夫·盖茨

公平地说,他的目标尚不明确。至少对我来说不是。
2012年

1

这取决于您的游戏。容器在访问特定元素的速度,删除元素的速度和添加元素的速度方面有所不同。


  • std :: vector-快速访问以及快速删除和添加到最后。从开头和中间删除比较慢。
  • std :: list-遍历列表并不比向量慢很多,但是访问列表的特定点很慢(因为迭代基本上是您可以对列表执行的唯一操作)。在任何地方添加和删除项目都非常快捷。大多数内存开销。不连续。
  • std :: deque-快速访问以及从末尾开始/删除/添加的速度很快,但在中间却很慢。

通常,如果您希望对象列表的排序方式不同于按时间顺序排序,并且因此必须插入新对象而不是追加对象,否则就需要使用列表。双端队列比矢量具有更大的灵活性,但实际上并没有太大的缺点。

如果您确实有很多实体,则应该看看“空间分区”。


不是真的re:清单。双端队列的建议完全取决于双端队列的实现,而双端队列的实现速度和执行力却千差万别。
变态
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.