Який найефективніший контейнер для зберігання динамічних ігрових об’єктів? [зачинено]


20

Я роблю шутер від першої особи, і мені відомо про багато різних типів контейнерів, але я хотів би знайти контейнер, який є найбільш ефективним для зберігання динамічних об'єктів, які будуть додаватися та видалятися з гри досить часто. EX-куль.

Я думаю, що в такому випадку це був би список, щоб пам'ять не була суміжною і ніколи не було зміни, яке триває. Але тоді я також роздумую про використання карти чи набору. Якщо у когось є якась корисна інформація, я би вдячний.

Я це до речі пишу в c ++.

Також я придумав рішення, яке, на мою думку, спрацює.

Для початку я буду виділяти вектор великого розміру .. скажімо, 1000 об’єктів. Я збираюсь відслідковувати останній доданий індекс у цьому векторі, щоб я знав, де знаходиться кінець об’єктів. Тоді я також створять чергу, яка буде містити індекси всіх об’єктів, які "видаляються" з вектора. (Ніякого фактичного видалення не буде зроблено, я просто знаю, що цей слот він безкоштовний). Отже, якщо черга порожня, я додаю до останнього доданого індексу у векторі + 1, інакше додаю до індексу вектора, який був на передній частині черги.


Якусь конкретну мову ви орієнтуєте?
Phill.Zitt

На це питання занадто важко відповісти без багатьох інших конкретних питань, включаючи апаратну платформу, мову / рамки тощо
PlayDeezGames

1
Що стосується поради, ви можете зберігати безкоштовний список у пам'яті видалених елементів (так що вам не потрібна додаткова черга).
Джефф Гейтс

2
Чи є в цьому питанні питання?
Тревор Пауелл

Зауважте, що вам не потрібно слідкувати за найбільшим індексом, а також не потрібно виділяти ряд елементів. std :: вектор піклується про все, що для вас.
API-Beast

Відповіді:


33

Відповідь завжди полягає у використанні масиву або std :: vector. Такі типи, як пов'язаний список або std :: map, зазвичай абсолютно жахливі в іграх, і це, безумовно, включає такі випадки, як колекції ігрових об'єктів.

Ви повинні зберігати самі об'єкти (а не покажчики на них) у масиві / векторі.

Ви хочете суцільну пам'ять. Ти дуже хочеш цього. Ітерація над будь-якими даними в безперервній пам'яті накладає багато помилок кеша в цілому і знімає можливість компілятора і процесора робити ефективне попереднє завантаження кешу. Це одне може вбити продуктивність.

Ви також хочете уникати розподілу пам’яті та розсилок. Вони дуже повільні, навіть із швидким розподільником пам'яті. Я бачив, як ігри отримують 10-кратну частоту кадрів у секунду, просто видаляючи кілька сотень розподілу пам'яті кожного кадру. Здається, це не так вже й погано, але може бути.

Нарешті, більшість структур даних, які вам цікаві для управління ігровими об'єктами, можуть бути набагато ефективніше реалізовані в масиві чи векторі, ніж вони можуть мати дерево або список.

Наприклад, для видалення ігрових об'єктів можна використовувати swap-and-pop. Легко реалізується з чимось на зразок:

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

Ви також можете просто позначити об'єкти як видалені та поставити їх індекс у вільний список наступного разу, коли вам потрібно створити новий об’єкт, але робити заміну і попп краще. Це дозволяє робити простий цикл для всіх живих об'єктів, не розгалужуючи окрім самого циклу. Для інтеграції фізики кульок тощо, це може бути значним підвищенням продуктивності.

Що ще важливіше, ви можете знайти об'єкти з простою парою пошуку таблиць із стабільної унікальної, використовуючи структуру карти слотів.

Ваші ігрові об’єкти мають індекс у своєму головному масиві. Їх можна дуже ефективно шукати лише за допомогою цього індексу (набагато швидше, ніж карта чи навіть хеш-таблиця). Однак індекс не є стабільним через своп і поп при видаленні об’єктів.

Карта слотів вимагає двох шарів непрямості, але обидва - це прості пошукові масиви з постійними індексами. Вони швидкі . Дійсно швидко.

Основна ідея полягає у тому, що у вас є три масиви: ваш основний список об’єктів, ваш список непрямості та безкоштовний список для переліку непрямості. Ваш основний список об'єктів містить ваші фактичні об’єкти, де кожен об’єкт знає свій унікальний ідентифікатор. Унікальний ідентифікатор складається з індексу та тегу версії. Список непрямості - це просто масив індексів до основного списку об'єктів. Вільний список - це стек індексів до непрямого списку.

Коли ви створюєте об’єкт у головному списку, ви знаходите невикористаний запис у списку непрямості (використовуючи безкоштовний список). Запис у непрямому списку вказує на невикористаний запис у головному списку. Ви ініціалізуєте ваш об'єкт у цьому місці та встановлюєте його унікальний ідентифікатор до індексу обраної вами списку непрямості та наявного тегу версії в головному елементі списку плюс один.

Коли ви знищуєте об'єкт, ви робите своп-поп як звичайний, але також збільшуєте номер версії. Потім ви також додаєте до вільного списку індекс непрямого переліку (частина унікального ідентифікатора об’єкта). Під час переміщення об'єкта як частини підкачки, ви також оновите його запис у списку непрямості на його нове місце.

Приклад псевдокоду:

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)

Шар непрямості дозволяє мати стабільний ідентифікатор (індекс у шар непрямості, куди записи не переміщуються) для ресурсу, який може переміщатися під час ущільнення (основний список об'єктів).

Тег версії дозволяє зберігати ідентифікатор до об'єкта, який може бути видалений. Наприклад, у вас є ідентифікатор (10,1). Об'єкт з індексом 10 видаляється (скажімо, ваша куля потрапляє в об’єкт і знищується). Об'єкт у цьому місці пам'яті в головному списку об'єктів потім має свій номер версії, надісланий (10,2). Якщо ви спробуєте переглянути (10,1) ще раз із застарілого ідентифікатора, пошук повертає цей об’єкт через індекс 10, але може побачити, що номер версії змінився, тому ідентифікатор більше не дійсний.

Це абсолютна найшвидша структура даних, яку ви можете мати зі стабільним ідентифікатором, який дозволяє переміщувати об’єкти в пам'яті, що важливо для локальності даних та узгодженості кешу. Це швидше, ніж будь-яка реалізація хеш-таблиці; хеш-таблиці як мінімум потрібно обчислити хеш (більше інструкцій, ніж пошук таблиці), а потім повинен слідувати хеш-ланцюжку (або пов'язаний список у жахливому випадку std :: unordered_map, або список з відкритою адресою в будь-яка нерозумна реалізація хеш-таблиці), а потім має зробити порівняння значень для кожного ключа (не дорожче, але можливо менш дороге, ніж перевірка тегів версії). Дуже хороша хеш-таблиця (не та, яка застосовується в будь-якій реалізації STL, оскільки STL призначає хеш-таблицю, яка оптимізується для різних випадків використання, ніж ви граєте для списку ігрових об'єктів), може економити на одному непрямому,

Ви можете вдосконалити базовий алгоритм. Використання, наприклад, std :: deque для основного списку об'єктів; ще один додатковий шар непрямості, але дозволяє об’єкти вставляти у повний список без недійсних тимчасових покажчиків, які ви придбали зі слотмапи.

Ви також можете уникнути зберігання індексу всередині об'єкта, оскільки індекс можна обчислити з адреси пам'яті об'єкта (це - об'єкти), а ще краще це потрібно лише при видаленні об'єкта, і в цьому випадку ви вже маєте ідентифікатор об'єкта (і, отже, індекс) як параметр.

Вибачення за списання; Я не відчуваю, що це найясніший опис. Пізно і важко пояснити, не витрачаючи більше часу, ніж у мене на зразки коду.


1
Ви торгуєте додатковим дерефоном та високою алокацією / безкоштовною вартістю (своп) кожного доступу для "компактного" сховища. З мого досвіду з відеоіграми, це погана торгівля :) YMMV звичайно.
Джефф Гейтс

1
Ви насправді не виконуєте розрядку, що часто в реальному сценарії. Коли ви це робите, ви можете зберігати повернутий вказівник локально, особливо якщо ви використовуєте варіант deque або знаєте, що не будете створювати нові об’єкти, поки у вас є покажчик. Ітерація над колекціями - дуже дорога і часта операція, вам потрібен стабільний ідентифікатор, ви хочете ущільнення пам’яті для летючих об'єктів (наприклад, куль, частинок тощо), а непряме використання є дуже ефективним для модемного обладнання. Ця методика використовується в більш ніж декількох комерційних двигунах дуже високої продуктивності. :)
Sean Middleditch

1
На мій досвід: (1) Відеоігри оцінюють за найгіршими показниками, а не за середніми показниками. (2) Зазвичай ви маєте 1 ітерацію над колекцією на кадр, таким чином ущільнення просто "робить ваш гірший випадок менш частим". (3) У вас часто є багато алоків / звільнень в одному кадрі, висока вартість означає, що ви обмежуєте цю можливість. (4) У вас є без обмежень дерефів на кадр (в іграх, над якими я працював, включаючи Diablo 3, часто дереф був найвищою вартістю перф. Після помірної оптимізації,> 5% завантаженості сервера). Я не хочу відмовлятися від інших рішень, просто вказуючи на свій досвід та міркування!
Джефф Гейтс

3
Я люблю цю структуру даних. Я здивований, що це не більше відоме. Це просто і вирішує всі проблеми, які змушують мене головою місяцями. Дякую, що поділились.
Джо Бейтс

2
Будь-який новачок, читаючи це, повинен бути дуже обережним щодо цієї поради. Це дуже оманлива відповідь. "Відповідь завжди полягає у використанні масиву або std :: vector. Такі типи, як пов'язаний список або std :: map, зазвичай абсолютно жахливі в іграх, і це, безумовно, включає випадки, такі як колекції ігрових об'єктів." сильно перебільшена. Відповіді "ЗАВЖДИ" немає, інакше ці інші контейнери не були б створені. Сказати, що карти / списки є "жахливими" - це також гіпербола. Є багато відеоігор, які використовують ці. "Найефективніший" не є "Найпрактичнішим" і може бути неправильно прочитаний як суб'єктивний "Найкращий".
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, і я думаю, що це було в дорогоцінних каменях для програмування ігор. Це, мабуть, по всій ігровій індустрії.

Питання: "Чому б не використовувати динамічний масив?" A: Динамічні масиви викликають збої. Простий приклад:

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

Ви можете уявити випадки з більшим ускладненням (наприклад, глибокі столові дзвінки). Це справедливо для всіх масивів, як контейнери. Створюючи ігри, ми достатньо розуміємо нашу проблему, щоб змусити розміри та бюджети на все в обмін на продуктивність.

І я цього не можу сказати достатньо: Дійсно, це найкраща річ. (Якщо ви не згодні, опублікуйте своє краще рішення! Caveat - повинен вирішити проблеми, перелічені вгорі цієї публікації: лінійна пам'ять / ітерація, O (1) аллока / вільні, стабільні індекси, слабкі посилання, нульові накладні дерефеї або є дивовижна причина, чому вам не потрібна одна з них;)


Що ви маєте на увазі під динамічним масивом ? Я запитую це, тому що, DataArrayздається, також розподіляється масив динамічно в ctor. Тож це могло мати якесь інше значення з мого розуміння.
Еоніл

Я маю на увазі масив, який змінює розмір / пам'ять під час його використання (на відміну від його побудови). Вектор stl - це приклад того, що я б назвав динамічним масивом.
Джефф Гейтс

@JeffGates Дуже подобається ця відповідь. Повністю погоджуйтеся приймати найгірший випадок як стандартну вартість виконання. Резервне копіювання безкоштовного пов'язаного списку за допомогою наявного масиву дуже елегантне. Запитання Q1: Призначення maxUsed? Q2: Яка мета зберігання індексу в бітах низького порядку id для виділених записів? Чому б не 0? Q3: Як це поводиться з поколіннями сутностей? Якщо це не так, я б запропонував використовувати біти низького порядку Q2 для підрахунку генерації. - Спасибі.
Інженер

1
A1: Макс, що використовується, дозволяє обмежувати ітерацію. Також ви амортизуєте будь-які витрати на будівництво. A2: 1) Ви часто переходите з елемента -> id. 2) Це робить порівняння дешевим / очевидним. A3: Я не впевнений, що означає "покоління". Я тлумачу це як "як ви відрізняєте 5-й елемент, виділений у слоті 7, від 6-го елемента?" де 5 і 6 - покоління. Запропонована схема використовує один лічильник глобально для всіх слотів. (Ми насправді запускаємо цей лічильник з різної кількості для кожного екземпляра DataArray, щоб легше розмежувати ідентифікатори.) Я впевнений, що ви могли б повторно запускати біти за відстеження елемента.
Джефф Гейтс

1
@JeffGates - Я знаю, що це стара тема, але мені дуже подобається ця ідея, чи не могли б ви дати мені інформацію про внутрішню роботу void Free (T &) over void Free (id)?
TheStatehz

1

Правильної відповіді на це немає. Все залежить від реалізації ваших алгоритмів. Просто йдіть з тим, кого ви вважаєте найкращим. Не намагайтеся оптимізувати на цій ранній стадії.

Якщо ви часто видаляєте об'єкти і відтворюєте їх, пропоную вам переглянути, як реалізуються пули об'єктів.

Редагувати: навіщо ускладнювати речі зі слотами, а що ні. Чому б просто не використати стек і спливати останній елемент і повторно використовувати його? Тож коли ви додасте один, ви зробите ++, коли ви викладете те, що зробите - слідкувати за кінцевим індексом.


Простий стек не обробляє випадок, коли елементи видаляються у довільному порядку.
Джефф Гейтс

Якщо чесно, його мета була не зовсім чітка. Принаймні не мені.
Сидар

1

Це залежить від вашої гри. Контейнери відрізняються тим, наскільки швидкий доступ до певного елемента, як швидко видаляється елемент і як швидко додається елемент.


  • std :: vector - Швидкий доступ і видалення та додавання до кінця швидко. Видалення з початку і середини відбувається повільно.
  • std :: list - Ітерація над списком відбувається не набагато повільніше, ніж вектор, але доступ до певної точки списку відбувається повільно (адже ітерація - це єдине, що ви можете зробити зі списком). Додавання та видалення елементів будь-де відбувається швидко. Більшість пам'яті накладні. Безперервне.
  • std :: deque - Швидкий доступ та видалення / додавання до кінця та початку - швидко, але повільно в середині.

Зазвичай ви хочете використовувати список, якщо ви хочете, щоб ваш список об'єктів був відсортований інакше, ніж хронологічно, і, таким чином, вам доведеться вставляти нові об'єкти, а не додавати, дек в іншому випадку. Дека збільшила гнучкість над вектором, але насправді не має великого мінусу.

Якщо у вас дійсно багато сутностей, вам слід поглянути на розділення простору.


Неправда: перелік. Поради Deque повністю залежать від реалізації deque, які різко відрізняються за швидкістю та виконанням.
метаморфоза
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.