Qual è il contenitore più efficiente in cui archiviare oggetti di gioco dinamici? [chiuso]


20

Sto realizzando uno sparatutto in prima persona e conosco molti tipi di container diversi, ma vorrei trovare il container più efficiente per la memorizzazione di oggetti dinamici che verranno aggiunti ed eliminati dal gioco abbastanza frequentemente. EX-Bullets.

Penso che in quel caso sarebbe un elenco in modo che la memoria non sia contigua e non ci sia mai nessun ridimensionamento in corso. Ma poi sto anche pensando di usare una mappa o un set. Se qualcuno ha qualche informazione utile lo apprezzerei.

Sto scrivendo questo in c ++ tra l'altro.

Inoltre ho trovato una soluzione che penso funzionerà.

Per cominciare, assegnerò un vettore di grandi dimensioni .. diciamo 1000 oggetti. Terrò traccia dell'ultimo indice aggiunto in questo vettore in modo da sapere dove si trova la fine degli oggetti. Quindi creerò anche una coda che conterrà gli indici di tutti gli oggetti che vengono "cancellati" dal vettore. (Non verrà effettuata alcuna eliminazione effettiva, saprò solo che lo slot è gratuito). Quindi, se la coda è vuota, aggiungerò all'ultimo indice aggiunto nel vettore + 1, altrimenti aggiungerò all'indice del vettore che si trovava all'inizio della coda.


Qualche lingua specifica a cui ti rivolgi?
Phill.Zitt

A questa domanda è troppo difficile rispondere senza molte altre specifiche, tra cui piattaforma hardware, linguaggio / framework, ecc.
PlayDeezGames

1
Suggerimento professionale, è possibile memorizzare l'elenco gratuito nella memoria degli elementi eliminati (quindi non è necessaria la coda aggiuntiva).
Jeff Gates,

2
C'è una domanda in questa domanda?
Trevor Powell,

Si noti che non è necessario tenere traccia dell'indice più grande né preallocare un numero di elementi. std :: vector si occupa di tutto ciò per te.
API-Bestia

Risposte:


33

La risposta è sempre di usare un array o std :: vector. Tipi come un elenco collegato o una std :: map sono di solito assolutamente orrendi nei giochi e questo include sicuramente casi come raccolte di oggetti di gioco.

È necessario memorizzare gli oggetti stessi (non i puntatori ad essi) nell'array / vettore.

Si desidera memoria contigua. Lo vuoi davvero davvero. L'iterazione su tutti i dati nella memoria non contigua impone un sacco di errori di cache in generale e rimuove la capacità del compilatore e della CPU di eseguire un prefetching della cache efficace. Questo da solo può uccidere le prestazioni.

Si desidera inoltre evitare allocazioni di memoria e deallocazioni. Sono molto lenti, anche con un allocatore di memoria veloce. Ho visto i giochi ottenere un bump 10x FPS semplicemente rimuovendo alcune centinaia di allocazioni di memoria per ogni frame. Non sembra che dovrebbe essere così male, ma può essere.

Infine, la maggior parte delle strutture di dati che ti interessano per la gestione degli oggetti di gioco possono essere implementate in modo molto più efficiente su un array o un vettore di quanto possano fare con un albero o un elenco.

Ad esempio, per la rimozione di oggetti di gioco, è possibile utilizzare swap-and-pop. Facilmente implementabile con qualcosa come:

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

Puoi anche contrassegnare gli oggetti come eliminati e mettere il loro indice in un elenco gratuito per la prossima volta che devi creare un nuovo oggetto, ma fare lo scambio e pop è meglio. Ti permette di fare un semplice ciclo per tutti gli oggetti vivi senza ramificarsi a parte il ciclo stesso. Per l'integrazione della fisica dei proiettili e simili, questo può essere un significativo aumento delle prestazioni.

Ancora più importante, è possibile trovare oggetti con una semplice coppia di ricerche di tabelle da un unico stabile che utilizza la struttura della mappa di slot.

I tuoi oggetti di gioco hanno un indice nella loro matrice principale. Possono essere cercati in modo molto efficiente solo con questo indice (molto più veloce di una mappa o persino di una tabella hash). Tuttavia, l'indice non è stabile a causa dello scambio e del pop durante la rimozione di oggetti.

Una mappa di slot richiede due livelli di riferimento indiretto, ma entrambi sono semplici ricerche di array con indici costanti. Sono veloci . Davvero veloce.

L'idea di base è che hai tre array: il tuo elenco di oggetti principale, il tuo elenco di riferimenti indiretti e un elenco gratuito per l'elenco di riferimenti indiretti. Il tuo elenco di oggetti principale contiene i tuoi oggetti reali, dove ogni oggetto conosce il proprio ID univoco. L'ID univoco è composto da un indice e un tag di versione. L'elenco indiretto è semplicemente un array di indici all'elenco oggetti principale. L'elenco gratuito è una pila di indici nell'elenco indiretto.

Quando si crea un oggetto nell'elenco principale, si trova una voce non utilizzata nell'elenco indiretto (utilizzando l'elenco gratuito). La voce nell'elenco indiretto punta a una voce non utilizzata nell'elenco principale. Inizializzi il tuo oggetto in quella posizione e imposti il ​​suo ID univoco sull'indice della voce dell'elenco di riferimenti indiretti che hai scelto e il tag della versione esistente nell'elemento dell'elenco principale, più uno.

Quando distruggi un oggetto, esegui lo scambio e il pop normalmente, ma aumenti anche il numero di versione. Quindi aggiungere anche l'indice dell'elenco di riferimenti indiretti (parte dell'ID univoco dell'oggetto) all'elenco libero. Quando si sposta un oggetto come parte di swap-and-pop, si aggiorna anche la sua voce nell'elenco indiretto nella nuova posizione.

Esempio di pseudo-codice:

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)

Il livello di riferimento indiretto consente di avere un identificatore stabile (l'indice nel livello di riferimento indiretto, in cui le voci non si spostano) per una risorsa che può spostarsi durante la compattazione (l'elenco di oggetti principale).

Il tag versione consente di archiviare un ID in un oggetto che potrebbe essere eliminato. Ad esempio, hai l'id (10,1). L'oggetto con indice 10 viene eliminato (ad esempio, il proiettile colpisce un oggetto e viene distrutto). L'oggetto in quella posizione di memoria nell'elenco principale degli oggetti ha quindi il suo numero di versione bloccato, dandogli (10,2). Se si tenta di cercare nuovamente (10,1) da un ID non aggiornato, la ricerca restituisce quell'oggetto tramite l'indice 10, ma può vedere che il numero di versione è cambiato, quindi l'ID non è più valido.

Questa è la struttura dati più veloce in assoluto che puoi avere con un ID stabile che consente agli oggetti di spostarsi in memoria, il che è importante per la localizzazione dei dati e la coerenza della cache. Questo è più veloce di qualsiasi implementazione di una tabella hash possibile; una tabella di hash deve almeno calcolare un hash (più istruzioni di una ricerca di tabella) e quindi deve seguire la catena di hash (o un elenco collegato nel caso orribile di std :: unordered_map o un elenco aperto in qualsiasi implementazione non stupida di una tabella hash), e quindi deve fare un confronto di valore su ciascuna chiave (non più costoso, ma forse meno costoso, rispetto al controllo del tag di versione). Una tabella hash molto buona (non quella in nessuna implementazione dell'STL, poiché l'STL impone una tabella hash che ottimizza per diversi casi d'uso rispetto al gioco per un elenco di oggetti di gioco) potrebbe salvare su una indiretta,

Ci sono vari miglioramenti che puoi apportare all'algoritmo di base. Usare qualcosa come std :: deque per la lista degli oggetti principale, per esempio; un ulteriore livello di riferimento indiretto, ma consente l'inserimento di oggetti in un elenco completo senza invalidare i puntatori temporanei acquisiti dalla slotmap.

Puoi anche evitare di memorizzare l'indice all'interno dell'oggetto, poiché l'indice può essere calcolato dall'indirizzo di memoria dell'oggetto (questo - oggetti), e anche meglio è necessario solo quando rimuovi l'oggetto nel qual caso hai già l'ID dell'oggetto (e quindi indice) come parametro.

Scuse per la redazione; Non credo sia la descrizione più chiara che potrebbe essere. È tardi ed è difficile da spiegare senza passare più tempo di quello che ho sugli esempi di codice.


1
Stai negoziando un extra deref e un alto costo allocato / gratuito (swap) ogni accesso per l'archiviazione "compatta". Nella mia esperienza con i videogiochi, è un brutto affare :) YMMV ovviamente.
Jeff Gates,

1
In realtà non fai la dereferenza che spesso negli scenari del mondo reale. Quando lo fai, puoi memorizzare il puntatore restituito localmente, specialmente se usi la variante deque o sai che non creerai nuovi oggetti mentre hai il puntatore. Iterare sulle raccolte è un'operazione molto costosa e frequente, è necessario l'id stabile, si desidera la compattazione della memoria per oggetti volatili (come proiettili, particelle, ecc.) E l'indirizzamento indiretto è molto efficiente sull'hardware del modem. Questa tecnica è utilizzata in più di alcuni motori commerciali ad altissime prestazioni. :)
Sean Middleditch il

1
Nella mia esperienza: (1) I videogiochi sono giudicati in base alle prestazioni del caso peggiore, non alle prestazioni del caso medio. (2) Normalmente hai 1 iterazione su una raccolta per frame, quindi la compattazione semplicemente "rende il tuo caso peggiore meno frequente". (3) Spesso hai molte allocazioni / liberazioni in un singolo frame, costo elevato significa limitare tale capacità. (4) Hai deref illimitati per frame (nei giochi a cui ho lavorato, incluso Diablo 3, spesso il deref era il costo perf più elevato dopo un'ottimizzazione moderata,> 5% del carico del server). Non intendo respingere altre soluzioni, ma solo sottolineare le mie esperienze e il mio ragionamento!
Jeff Gates,

3
Adoro questa struttura di dati. Sono sorpreso che non sia più noto. È semplice e risolve tutti i problemi che mi fanno battere la testa da mesi. Grazie per la condivisione.
Jo Bates,

2
Ogni neofita che legge questo dovrebbe essere molto diffidente nei confronti di questo consiglio. Questa è una risposta molto fuorviante. "La risposta è sempre quella di usare un array o std :: vector. Tipi come un elenco collegato o uno std :: map sono di solito assolutamente orrendi nei giochi, e questo include sicuramente casi come raccolte di oggetti di gioco." è molto esagerato. Non esiste una risposta "SEMPRE", altrimenti questi altri contenitori non sarebbero stati creati. Dire mappe / elenchi "orrendi" è anche iperbole. Ci sono MOLTI videogiochi che usano questi. "Più efficiente" non è "Più pratico" e può essere interpretato erroneamente come un "Migliore" soggettivo.
user50286

12

array a dimensione fissa (memoria lineare)
con lista libera interna (O (1) alloc / free, indicazioni stabili)
con chiavi di riferimento deboli (riutilizzo della chiave invalida slot)
zero dereferenze generali (se noto-valido)

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
};

Gestisce di tutto, dai proiettili ai mostri, dalle trame alle particelle, ecc. Questa è la migliore struttura di dati per i videogiochi. Penso che sia venuto da Bungie (ai tempi della maratona / dei miti), l'ho imparato a Blizzard e penso che fosse in un gioco programmando gemme nel passato. Probabilmente è in tutto il settore dei giochi a questo punto.

D: "Perché non utilizzare un array dinamico?" A: Le matrici dinamiche causano arresti anomali. Esempio semplice:

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

Puoi immaginare casi con più complicazioni (come i deep callstacks). Questo è vero per tutti i contenitori come array. Quando realizziamo giochi, abbiamo abbastanza comprensione del nostro problema da imporre dimensioni e budget su tutto in cambio di prestazioni.

E non posso dirlo abbastanza: davvero, questa è la cosa migliore di sempre. (Se non sei d'accordo, pubblica la tua soluzione migliore! Avvertenza - deve affrontare i problemi elencati all'inizio di questo post: memoria lineare / iterazione, O (1) alloc / free, indici stabili, riferimenti deboli, zero deref generali o hai una ragione incredibile per cui non hai bisogno di uno di quelli;)


Cosa intendi con array dinamico ? Lo sto chiedendo perché DataArraysembra anche allocare dinamicamente un array in ctor. Quindi potrebbe avere un significato diverso con la mia comprensione.
Eonil

Intendo un array che ridimensiona / memorizza durante l'uso (al contrario della sua costruzione). Un vettore stl è un esempio di ciò che definirei un array dinamico.
Jeff Gates,

@JeffGates Mi piace molto questa risposta. Accetto pienamente l'accettazione del caso peggiore come costo di runtime del caso standard. Il backup dell'elenco dei collegamenti gratuiti utilizzando l'array esistente è molto elegante. Domande Q1: scopo di maxUsed? Q2: Qual è lo scopo di memorizzare l'indice nei bit di ordine inferiore per le voci allocate? Perché non 0? Q3: come gestisce le generazioni di entità? In caso contrario, suggerirei di utilizzare i bit di basso ordine di Q2 per il conteggio della generazione di ushort. - Grazie.
Ingegnere

1
A1: Max utilizzato ti consente di limitare la tua iterazione. Inoltre si ammortizza qualsiasi costo di costruzione. A2: 1) Passi spesso dall'elemento -> id. 2) Rende il confronto economico / ovvio. A3: Non sono sicuro del significato di "generazioni". Lo interpreterò come "come si fa a differenziare il quinto oggetto allocato nello slot 7 dal sesto oggetto?" dove 5 e 6 sono le generazioni. Lo schema proposto utilizza un contatore a livello globale per tutti gli slot. (In realtà questo contatore viene avviato da un numero diverso per ogni istanza di DataArray per differenziare più facilmente gli ID). Sono sicuro che potresti reimpostare i bit per tracciamento degli articoli era importante.
Jeff Gates,

1
@JeffGates - So che questo è un vecchio argomento, ma mi piace davvero questa idea, potresti darmi qualche informazione sul funzionamento interno del vuoto Free (T &) su void Free (id)?
TheStatehz

1

Non esiste una risposta giusta a questo. Tutto dipende dall'implementazione dei tuoi algoritmi. Basta andare con uno che pensi sia il migliore. Non tentare di ottimizzare in questa fase iniziale.

Se si eliminano spesso oggetti e li si ricrea, suggerisco di esaminare come vengono implementati i pool di oggetti.

Modifica: perché complicare le cose con le slot e cosa no. Perché non usare semplicemente uno stack e togliere l'ultimo oggetto e riutilizzarlo? Quindi quando ne aggiungi uno, farai ++, quando ne fai uno, per tenere traccia del tuo indice finale.


Uno stack semplice non gestisce il caso in cui gli elementi vengono eliminati in ordine arbitrario.
Jeff Gates,

Ad essere sinceri, il suo obiettivo non era esattamente chiaro. Al meno non a me.
Sidar,

1

Dipende dal tuo gioco. I contenitori differiscono per quanto è veloce l'accesso a un elemento specifico, quanto velocemente viene rimosso un elemento e quanto velocemente viene aggiunto un elemento.


  • std :: vector - Accesso rapido, rimozione e aggiunta alla fine sono veloci. La rimozione dall'inizio e dal centro è lenta.
  • std :: list - L'iterazione sull'elenco non è molto più lenta di un vettore ma l'accesso ad un punto specifico dell'elenco è lento (perché l'iterazione è sostanzialmente l'unica cosa che puoi fare con un elenco). L'aggiunta e la rimozione di elementi ovunque è veloce. Gran parte dell'overhead di memoria. Non-continua.
  • std :: deque - Accesso veloce e rimozione / aggiunta alla fine e l'inizio è veloce ma lento nel mezzo.

Di solito si desidera utilizzare un elenco se si desidera che l'elenco degli oggetti sia ordinato in modo diverso rispetto a quello cronologico e quindi è necessario inserire nuovi oggetti anziché aggiungere, altrimenti un deque. Una deque ha aumentato la flessibilità su un vettore ma non ha davvero un aspetto negativo.

Se hai davvero molte entità dovresti dare un'occhiata a Space Partitioning.


Non vero riguardo a: list. I consigli sul deque dipendono interamente dalle implementazioni del deque, che variano notevolmente in termini di velocità ed esecuzione.
metamorfosi,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.