動的なゲームオブジェクトを保存する最も効率的なコンテナは何ですか?[閉まっている]


20

私は一人称シューティングゲームを作成していますが、さまざまな種類のコンテナについて知っていますが、ゲームで頻繁に追加および削除される動的オブジェクトを保存するのに最も効率的なコンテナを見つけたいと思います。元弾丸。

その場合、それはリストであるため、メモリは連続しておらず、サイズ変更が行われることはありません。しかし、その後、マップまたはセットを使用することも考えています。有益な情報があれば教えてください。

ちなみに私はこれをC ++で書いています。

また、私はうまくいくと思う解決策を思いつきました。

まず、大きなサイズのベクトルを割り当てます。たとえば、1000個のオブジェクトを割り当てます。このベクターに最後に追加されたインデックスを追跡して、オブジェクトの終わりがどこにあるかを把握します。次に、ベクターから「削除」されたすべてのオブジェクトのインデックスを保持するキューも作成します。(実際の削除は行われません。そのスロットが空いていることがわかります)。したがって、キューが空の場合、ベクトルに最後に追加されたインデックス+ 1に追加します。そうでない場合は、キューの先頭にあったベクトルのインデックスに追加します。


ターゲットとする特定の言語はありますか?
Phill.Zitt

この質問は、などのハードウェア・プラットフォーム、言語/フレームワーク、など、かなり多くのより多くの詳細なしに答えるには余りにも難しいです
PlayDeezGames

1
プロのヒントとして、削除した要素のメモリに空きリストを保存できます(追加のキューは不要です)。
ジェフゲイツ

2
この質問に質問はありますか?
トレバーパウエル

最大のインデックスを追跡する必要も、多くの要素を事前に割り当てる必要もないことに注意してください。std :: vectorがすべてを処理します。
API獣

回答:


33

答えは、常に配列またはstd :: vectorを使用することです。リンクリストやstd :: mapなどの型は、通常、ゲームでは絶対に恐ろしいものであり、ゲームオブジェクトのコレクションなどの場合も含まれます。

オブジェクト自体(オブジェクトへのポインタではない)を配列/ベクトルに保存する必要があります。

連続したメモリが必要です。あなたは本当にそれが欲しいです。不連続メモリ内のデータを反復処理すると、一般に多くのキャッシュミスが発生し、コンパイラとCPUが効果的なキャッシュプリフェッチを実行できなくなります。これだけでパフォーマンスが低下する可能性があります。

また、メモリの割り当てと割り当て解除を回避する必要があります。高速のメモリアロケータを使用しても、非常に低速です。各フレームで数百のメモリ割り当てを削除するだけで、ゲームで10倍のFPSバンプが発生するのを見ました。そんなに悪くないように思えますが、そうかもしれません。

最後に、ゲームオブジェクトの管理に関心のあるほとんどのデータ構造は、ツリーやリストを使用するよりもはるかに効率的に配列またはベクターに実装できます。

たとえば、ゲームオブジェクトを削除するには、スワップとポップを使用できます。次のようなもので簡単に実装できます。

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

また、オブジェクトを削除済みとしてマークし、次に新しいオブジェクトを作成する必要があるときにそのインデックスを空きリストに入れることもできますが、スワップとポップを行う方が優れています。ループ自体を除いて分岐することなく、すべてのライブオブジェクトに対して単純なforループを実行できます。弾丸物理学の統合などでは、これによりパフォーマンスが大幅に向上します。

さらに重要なことは、スロットマップ構造を使用している安定した一意のテーブルルックアップの単純なペアでオブジェクトを検索できることです。

ゲームオブジェクトのメイン配列にはインデックスがあります。このインデックスだけで非常に効率的に検索できます(マップやハッシュテーブルよりもはるかに高速です)。ただし、オブジェクトを削除するときのスワップとポップのため、インデックスは安定していません。

スロットマップには2層のインダイレクションが必要ですが、どちらも定数インデックスを使用した単純な配列検索です。彼らは速いです。本当に速い。

基本的な考え方は、メインオブジェクトリスト、インダイレクションリスト、インダイレクションリスト用のフリーリストの3つの配列があるということです。メインオブジェクトリストには実際のオブジェクトが含まれ、各オブジェクトは独自の一意のIDを知っています。一意のIDは、インデックスとバージョンタグで構成されます。間接リストは、単にメインオブジェクトリストへのインデックスの配列です。フリーリストは、間接リストへのインデックスのスタックです。

メインリストでオブジェクトを作成すると、間接リストで未使用のエントリが見つかります(空きリストを使用)。間接リストのエントリは、メインリストの未使用のエントリを指します。その場所でオブジェクトを初期化し、選択した間接リストエントリのインデックスとメインリスト要素の既存のバージョンタグに1を加えた一意の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を使用して保持できる絶対最速のデータ構造です。これは、データの局所性とキャッシュの一貫性にとって重要です。これは、可能なハッシュテーブルの実装よりも高速です。ハッシュテーブルは、少なくともハッシュを計算する必要があり(テーブルルックアップよりも多くの指示)、ハッシュチェーン(std :: unordered_mapの恐ろしい場合のリンクリスト、またはハッシュテーブルのバカではない実装)、各キーで値の比較を行う必要があります(バージョンタグチェックよりも高価ではありませんが、可能性は低くなります)。非常に優れたハッシュテーブル(STLは、ゲームオブジェクトリスト用にゲームとは異なるさまざまなユースケースに最適化するハッシュテーブルを義務付けているため、STLの実装ではありません)は、1つのインダイレクションを節約できます。

基本アルゴリズムにはさまざまな改善を加えることができます。たとえば、メインオブジェクトリストにstd :: dequeのようなものを使用します。間接的な1つの追加レイヤー。ただし、スロットマップから取得した一時的なポインターを無効にすることなく、オブジェクトを完全なリストに挿入できます。

また、オブジェクトのメモリアドレス(this-objects)からインデックスを計算できるため、オブジェクト内にインデックスを保存することも避けられます。オブジェクトを削除する場合にのみ必要です。インデックス)パラメータとして。

謝罪; それが最も明確な説明だとは思わない。遅く、コードサンプルよりも多くの時間を費やすことなく説明するのは困難です。


1
「コンパクト」ストレージのアクセスごとに、余分なderefと高いalloc / freeコスト(スワップ)をトレードオフしています。ビデオゲームの私の経験では、それは悪い取引です:)もちろんYMMV。
ジェフゲイツ

1
実際のシナリオでよく見られる逆参照は実際には行いません。そうすると、特にdequeバリアントを使用する場合や、ポインターを持っている間は新しいオブジェクトを作成しないことがわかっている場合は、返されたポインターをローカルに保存できます。コレクションの反復処理は非常に高価で頻繁な操作であり、安定したIDが必要であり、揮発性オブジェクト(弾丸、パーティクルなど)のメモリ圧縮が必要であり、間接指定はモデムハードウェアで非常に効率的です。この手法は、いくつかの非常に高性能な商用エンジンで使用されています。:)
ショーンミドルディッチ

1
私の経験では:(1)ビデオゲームは、平均的なケースパフォーマンスではなく、最悪のケースパフォーマンスで判断されます。(2)通常、フレームごとのコレクションに対して1回の反復があるため、単純に「最悪の場合の頻度を減らします」。(3)多くの場合、1つのフレームに多くのallocs / freesがあり、コストが高いため、その機能が制限されます。(4)フレームごとに無制限のderefがあります(Diablo 3を含む、私が取り組んだゲームでは、しばしばderefが中程度の最適化後の最高のパフォーマンスコストで、サーバー負荷の5%を超えていました)。私は自分の経験と推論を指摘するだけで、他の解決策を却下するつもりはありません!
ジェフゲイツ

3
このデータ構造が大好きです。あまり知られていないことに驚いています。それは簡単で、何ヶ月も頭を痛めたすべての問題を解決します。共有してくれてありがとう。
ジョーベイツ

2
これを読んでいる初心者は、このアドバイスに非常に注意する必要があります。これは非常に誤解を招く答えです。「答えは常に配列またはstd :: vectorを使用することです。リンクリストやstd :: mapなどのタイプは、通常ゲームでは絶対に恐ろしいものであり、ゲームオブジェクトのコレクションのようなケースも含まれます。」非常に誇張されています。「常に」という答えはありません。そうでなければ、これらの他のコンテナは作成されません。マップ/リストが「恐ろしい」と言うことも誇張です。これらを使用するビデオゲームがたくさんあります。「Most Efficient」は「Most Practical」ではなく、主観的な「Best」と誤解される可能性があります。
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から来たものだと思うし、Blizzardでそれを知ったし、ゲームプログラミングの宝石の中にあったと思う。現時点ではおそらくゲーム業界全体です。

Q:「なぜ動的配列を使用しないのですか?」A:動的配列はクラッシュを引き起こします。簡単な例:

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

より複雑なケース(深いコールスタックなど)を想像できます。これは、コンテナのようなすべての配列に当てはまります。ゲームを作成する際には、パフォーマンスと引き換えにすべてのサイズと予算を強制する問題を十分に理解しています。

そして、私はそれを十分に言うことはできません:本当に、これはこれまでで最高のものです。(同意しない場合は、より良い解決策を投稿してください!警告-この投稿の先頭にリストされている問題に対処する必要があります:線形メモリ/反復、O(1)割り当て/解放、安定したインデックス、弱い参照、ゼロの間接参照またはそれらのいずれかが必要ない驚くべき理由があります;)


動的配列とはどういう意味ですか?これはDataArray、ctorで配列を動的に割り当てているようにも見えるためです。だから、私の理解では異なる意味を持つかもしれません。
エオニル14年

私はその構築中とは対照的に、使用中にサイズ変更/メモリ移動する配列を意味します。stlベクトルは、動的配列と呼ばれるものの例です。
ジェフゲイツ14年

@JeffGates本当にこの答えが好きです。ワーストケースを標準ケースランタイムコストとして再度受け入れることに完全に同意します。既存の配列を使用してフリーリンクリストをバッキングすることは非常にエレガントです。質問 Q1:maxUsedの目的は?Q2:割り当てられたエントリのidの下位ビットにインデックスを格納する目的は何ですか?なぜ0ではないのですか?Q3:これは、エンティティ生成をどのように処理しますか?そうでない場合は、ushort生成カウントにQ2の下位ビットを使用することをお勧めします。-ありがとう。
エンジニア

1
A1:Max usedを使用すると、反復を制限できます。また、建設費を償却します。A2:1)多くの場合、アイテム-> idから移動します。2)比較を安く/明白にする。A3:「世代」の意味がわかりません。これを「スロット7に割り当てられた5番目のアイテムと6番目のアイテムをどのように区別しますか」と解釈します。ここで、5と6は世代です。提案されたスキームは、すべてのスロットに対して1つのカウンターをグローバルに使用します。(実際には、IDをより簡単に区別するために、DataArrayインスタンスごとに異なる数でこのカウンターを開始します。)アイテムごとのビット追跡を再調整できると確信しています。
ジェフゲイツ

1
@JeffGates-これは古いトピックであることは知っていますが、このアイデアが本当に好きです。voidFree(T&)over void Free(id)の内部動作に関する情報を教えてください。
-TheStatehz

1

これに対する正しい答えはありません。それはすべて、アルゴリズムの実装に依存します。あなたが最高だと思うものと一緒に行ってください。この初期段階で最適化を試みないでください。

オブジェクトを頻繁に削除して再作成する場合は、オブジェクトプールの実装方法を確認することをお勧めします。

編集:スロットで物事を複雑にするのはなぜですか?スタックを使用して最後のアイテムをポップして再利用しないのはなぜですか?したがって、追加するときは、++を実行し、ポップするときは、終了インデックスを追跡するために++を実行します。


単純なスタックでは、アイテムが任意の順序で削除される場合は処理されません。
ジェフゲイツ

公平を期して、彼の目標は明確ではありませんでした。少なくとも私には。
シダー

1

それはあなたのゲームに依存します。コンテナは、特定の要素へのアクセス速度、要素の削除速度、および要素の追加速度が異なります。


  • std :: vector-高速アクセスおよび削除と末尾への追加が高速です。最初と中央から削除するのは遅いです。
  • std :: list- リストの繰り返しはベクターほど遅くはありませんが、リストの特定のポイントへのアクセスは遅くなります(繰り返しは基本的にリストでできるため)。どこでもアイテムの追加と削除は高速です。ほとんどのメモリオーバーヘッド。非連続。
  • std :: deque-高速アクセス、および末尾と先頭への削除/追加は高速ですが、途中で低速です。

通常、オブジェクトリストを時系列とは異なる方法で並べ替える場合はリストを使用するため、追加するのではなく新しいオブジェクトを挿入する必要があります。両端キューは、ベクターよりも柔軟性が向上していますが、実際にはあまりマイナス面はありません。

本当にたくさんのエンティティがある場合は、スペース分割を見てください。


本当ではない:リスト。Dequeのアドバイスは、Dequeの実装に完全に依存しており、速度と実行が大きく異なります。
変態
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.