Miglioramento di una funzione O (N ^ 2) (tutte le entità che ripetono su tutte le altre entità)


21

Un po 'di background, sto programmando un gioco di evoluzione con un amico in C ++, usando ENTT per il sistema di entità. Le creature camminano in una mappa 2D, mangiano verdure o altre creature, si riproducono e i loro tratti mutano.

Inoltre, le prestazioni vanno bene (60 fps senza problemi) quando il gioco viene eseguito in tempo reale, ma voglio essere in grado di accelerare in modo significativo per non dover aspettare 4 ore per vedere eventuali cambiamenti significativi. Quindi voglio ottenerlo il più velocemente possibile.

Sto lottando per trovare un metodo efficiente per le creature per trovare il loro cibo. Ogni creatura dovrebbe cercare il miglior cibo che è abbastanza vicino a loro.

Schermata di esempio del gioco

Se vuole mangiare, la creatura raffigurata al centro dovrebbe guardarsi attorno in un raggio di 149,64 (la sua distanza di vista) e giudicare quale cibo dovrebbe perseguire, che si basa su nutrizione, distanza e tipo (carne o pianta) .

La funzione responsabile di trovare ogni creatura il loro cibo sta consumando circa il 70% del tempo di esecuzione. Semplificando come è scritto attualmente, va qualcosa del genere:

for (creature : all_creatures)
{
  for (food : all_entities_with_food_value)
  {
    // if the food is within the creatures view and it's
    // the best food found yet, it becomes the best food
  }
  // set the best food as the target for creature
  // make the creature chase it (change its state)
}

Questa funzione esegue ogni tick per ogni creatura in cerca di cibo, fino a quando la creatura non trova cibo e cambia stato. Viene inoltre eseguito ogni volta che vengono generati nuovi alimenti per le creature che inseguono già un determinato cibo, per assicurarsi che tutti vadano alla ricerca del miglior cibo disponibile.

Sono aperto alle idee su come rendere questo processo più efficiente. Mi piacerebbe ridurre la complessità di O(N2) , ma non so se sia possibile.

Un modo per migliorarlo è quello di ordinare il all_entities_with_food_valuegruppo in modo tale che quando una creatura scorre il cibo troppo grande per essere mangiato, si ferma lì. Qualsiasi altro miglioramento è più che benvenuto!

EDIT: Grazie a tutti per le risposte! Ho implementato varie cose da varie risposte:

In primo luogo e semplicemente ho fatto in modo che la funzione colpevole funzioni solo una volta ogni cinque tick, questo ha reso il gioco circa 4 volte più veloce, senza cambiare visibilmente nulla del gioco.

Successivamente, ho archiviato nel sistema di ricerca degli alimenti un array con il cibo generato nella stessa spunta che corre. In questo modo ho solo bisogno di confrontare il cibo che la creatura sta inseguendo con i nuovi cibi che sono apparsi.

Infine, dopo una ricerca sul partizionamento dello spazio e sulla considerazione di BVH e quadtree, sono andato con quest'ultimo, poiché ritengo che sia molto più semplice e più adatto al mio caso. Lo implemento abbastanza rapidamente e ha notevolmente migliorato le prestazioni, la ricerca di cibo impiega pochissimo tempo!

Ora il rendering è ciò che mi sta rallentando, ma è un problema per un altro giorno. Grazie a tutti!


2
Hai sperimentato più thread su più core della CPU in esecuzione contemporaneamente?
Ed Marty,

6
Quante creature hai in media? Non sembra essere così alto, a giudicare dall'istantanea. Se è sempre così, il partizionamento dello spazio non sarà di grande aiuto. Hai considerato di non eseguire questa funzione ad ogni tick? Potresti eseguirlo ogni, diciamo, 10 tick. I risultati della simulazione non dovrebbero cambiare qualitativamente.
Turms,

4
Hai fatto una profilazione dettagliata per capire la parte più costosa della valutazione degli alimenti? Invece di guardare alla complessità generale, forse devi vedere se c'è qualche calcolo specifico o l'accesso alla struttura della memoria che ti sta soffocando.
Harabeck,

Un suggerimento ingenuo: potresti usare un quadtree o una struttura dati correlata invece del modo O (N ^ 2) in cui lo stai facendo ora.
Seiyria,

3
Come suggerito da @Harabeck, scaverei più a fondo per vedere dove nel ciclo viene trascorso tutto quel tempo. Se si tratta di calcoli con radice quadrata per la distanza, ad esempio, potresti essere in grado di confrontare i coordini XY per pre-eliminare molti candidati prima di dover fare il costoso sqrt su quelli rimanenti. L'aggiunta if (food.x>creature.x+149.64 or food.x<creature.x-149.64) continue;dovrebbe essere più semplice dell'implementazione di una struttura di archiviazione "complicata" SE è abbastanza performante. (Correlato: potrebbe esserci d'aiuto se hai inserito un po 'più di codice nel tuo ciclo interno)
AC

Risposte:


34

So che non concettualizzi questo come collisioni, tuttavia quello che stai facendo è far scontrare un cerchio centrato sulla creatura, con tutto il cibo.

Non vuoi davvero controllare il cibo che sai essere distante, solo ciò che è vicino. Questo è il consiglio generale per l'ottimizzazione delle collisioni. Vorrei incoraggiare a cercare tecniche per ottimizzare le collisioni e non limitarti al C ++ durante la ricerca.


Creatura che trova cibo

Per il tuo scenario, suggerirei di mettere il mondo su una griglia. Rendi le celle almeno il raggio dei cerchi che vuoi scontrare. Quindi puoi scegliere l'unica cella in cui si trova la creatura e i suoi fino a otto vicini e cercare solo quelle fino a nove celle.

Nota : potresti creare celle più piccole, il che significherebbe che il cerchio che stai cercando si estenderebbe oltre i vicini immidiati, richiedendo di iterare lì. Tuttavia, se il problema è che c'è troppo cibo, le cellule più piccole potrebbero significare iterare su meno entità alimentari in totale, che a un certo punto si rivolge a tuo favore. Se sospetti che sia così, prova.

Se il cibo non si sposta, puoi aggiungere le entità alimentari alla griglia al momento della creazione, in modo da non dover cercare quali entità si trovano nella cella. Invece si esegue una query sulla cella e ha l'elenco.

Se rendi la dimensione delle cellule una potenza di due, puoi trovare la cella su cui si trova la creatura semplicemente troncando le sue coordinate.

Puoi lavorare a distanza quadrata (ovvero non fare sqrt) durante il confronto per trovare quello più vicino. Meno operazioni sqrt significano un'esecuzione più rapida.


Nuovo cibo aggiunto

Quando viene aggiunto nuovo cibo, solo le creature vicine devono essere risvegliate. È la stessa idea, tranne ora è necessario ottenere invece l'elenco di creature nelle celle.

Molto più interessante, se annoti nella creatura quanto è lontano dal cibo che sta inseguendo ... puoi controllare direttamente contro quella distanza.

Un'altra cosa che ti aiuterà è avere il cibo consapevole di quali creature lo stanno inseguendo. Ciò ti consentirà di eseguire il codice per trovare cibo per tutte le creature che stanno inseguendo un pezzo di cibo appena mangiato.

In effetti, inizia la simulazione senza cibo e tutte le creature hanno una distanza annotata di infinito. Quindi iniziare ad aggiungere cibo. Aggiorna le distanze mentre le creature si muovono ... Quando il cibo viene mangiato, prendi l'elenco delle creature che lo stavano inseguendo e poi trova un nuovo bersaglio. Oltre a questo caso, tutti gli altri aggiornamenti vengono gestiti quando viene aggiunto cibo.


Simulazione saltante

Conoscendo la velocità di una creatura, sai quanto è fino a quando non raggiunge il suo obiettivo. Se tutte le creature hanno la stessa velocità, quella che raggiungerà per prima è quella con la minima distanza annotata.

Se conosci anche il tempo prima di aggiungere altro cibo ... E spero che tu abbia una prevedibilità simile per la riproduzione e la morte, allora conosci il tempo per il prossimo evento (o aggiunto cibo o una creatura che mangia).

Salta a quel momento. Non è necessario simulare le creature che si muovono.


1
"e cerca solo lì." e le cellule vicine ai vicini - il che significa 9 cellule in totale. Perché 9 Perché se la creatura fosse proprio nell'angolo di una cella.
UKMonkey

1
@UKMonkey "Rendi le celle almeno il raggio dei cerchi che vuoi scontrare", se il lato della cella è il raggio e la creatura è nell'angolo ... beh, suppongo che devi solo cercarne quattro in quel caso. Tuttavia, certo, possiamo rendere le cellule più piccole, il che potrebbe essere utile se c'è troppo cibo e troppe poche creature. Modifica: chiarirò.
Theraot,

2
Certo - se vuoi allenarti se devi cercare in celle extra ... ma dato che la maggior parte delle cellule non avrà cibo (dall'immagine data); sarà più veloce cercare solo 9 celle, piuttosto che capire quale 4 è necessario cercare.
UKMonkey

@UKMonkey che è il motivo per cui non ne ho parlato inizialmente.
Theraot

16

È necessario adottare un algoritmo di partizionamento dello spazio come BVH per ridurre la complessità. Per essere specifici per il tuo caso, devi creare un albero composto da scatole di delimitazione allineate agli assi che contengono pezzi di cibo.

Per creare una gerarchia, avvicina i pezzi di cibo gli uni agli altri in AABB, quindi posiziona gli AABB in AABB più grandi, di nuovo, in base alla distanza tra loro. Fallo finché non hai un nodo radice.

Per utilizzare l'albero, eseguire prima un test di intersezione cerchio-AABB su un nodo radice, quindi se si verifica una collisione, testare i figli di ciascun nodo consecutivo. Alla fine dovresti avere un gruppo di pezzi di cibo.

È inoltre possibile utilizzare la libreria AABB.cc.


1
Ciò ridurrebbe effettivamente la complessità a N log N, ma sarebbe anche costoso eseguire il partizionamento. Visto che dovrei fare il partizionamento di ogni tick (dato che le creature muovono ogni tick) ne varrebbe comunque la pena? Ci sono soluzioni per aiutare la partizione meno spesso?
Alexandre Rodrigues,

3
@AlexandreRodrigues non è necessario ricostruire l'intero albero ogni tick, aggiornare solo le parti che si spostano e solo quando qualcosa va fuori da un particolare contenitore AABB. Per migliorare ulteriormente le prestazioni, potresti voler ingrassare i nodi (lasciando un po 'di spazio tra i bambini) in modo da non dover ricostruire l'intero ramo su un aggiornamento foglia.
Ocelot,

6
Penso che un BVH potrebbe essere troppo complesso qui - una griglia uniforme implementata come una tabella hash è abbastanza buona.
Steven,

1
@Steven implementando BVH è possibile estendere facilmente la scala della simulazione in futuro. E non perdi davvero nulla se lo fai anche per una simulazione su piccola scala.
Ocelot,

2

Mentre i metodi di partizione spaziale descritti possono effettivamente ridurre il tempo il problema principale non è solo la ricerca. È il semplice volume di ricerche che fai che rallenta il tuo compito. Quindi ottimizzi il loop interno, ma puoi anche ottimizzare il loop esterno.

Il tuo problema è che conservi i dati di polling. È un po 'come avere bambini sul sedile posteriore che chiedono per la millesima volta "ci siamo ancora", non c'è bisogno di fare che l'autista ti informerà quando ci sei.

Invece dovresti sforzarti, se possibile, di risolvere ogni azione al suo completamento inserendola in una coda e far uscire quegli eventi bolla, questo potrebbe apportare modifiche alla coda ma va bene. Questo si chiama simulazione di eventi discreti. Se è possibile implementare la simulazione in questo modo, si sta cercando uno speedup considerevole che superi di gran lunga lo speedup che si può ottenere dalla migliore ricerca della partizione spaziale.

Per sottolineare il punto di una precedente carriera, ho realizzato simulatori di fabbrica. Con questo metodo abbiamo simulato settimane di grandi fabbriche / aeroporti con l'intero flusso di materiale a livello di articolo in meno di un'ora. Mentre la simulazione basata sul timestep poteva simulare solo 4-5 volte più velocemente del tempo reale.

Anche come un frutto davvero basso pensate di disaccoppiare le vostre routine di disegno dalla vostra simulazione. Anche se la tua simulazione è semplice, c'è ancora qualche sovraccarico nel disegnare cose. Peggio ancora, il driver dello schermo potrebbe limitarti a x aggiornamenti al secondo, mentre in realtà i tuoi processori potrebbero fare le cose 100 volte più velocemente. Ciò evidenzia la necessità di profilazione.


@Theraot non sappiamo come sono strutturate le cose del disegno. Ma sì, i drawcall diventeranno colli di bottiglia una volta che sarai abbastanza veloce comunque
joojaa

1

È possibile utilizzare un algoritmo sweep-line per ridurre la complessità a Nlog (N). La teoria è quella dei diagrammi di Voronoi, che rende una divisione dell'area circostante una creatura in regioni costituite da tutti i punti più vicini a quella creatura rispetto a qualsiasi altro.

Il cosiddetto algoritmo di Fortune lo fa per te in Nlog (N) e la pagina wiki che contiene lo pseudo codice per implementarlo. Sono sicuro che ci sono anche implementazioni di librerie. https://en.wikipedia.org/wiki/Fortune%27s_algorithm


Benvenuto in GDSE e grazie per la risposta. Come applichereste esattamente questo alla situazione di OP? La descrizione del problema dice che un'entità dovrebbe considerare tutto il cibo alla sua distanza di vista e selezionare quello migliore. Un Voronoi tradizionale escluderebbe nel raggio di portata il cibo più vicino a un'altra entità. Non sto dicendo che un Voronoi non funzionerebbe, ma dalla tua descrizione non è ovvio come OP ne debba usare uno per il problema descritto.
Pikalek,

Mi piace questa idea, vorrei vederla ampliata. Come si rappresenta il diagramma voronoi (come nella struttura dei dati della memoria)? Come lo interroghi?
Theraot,

@Theraot non hai bisogno del diagramma voronoi solo per la stessa idea di sweepline.
joojaa,

-2

La soluzione più semplice sarebbe integrare un motore fisico e usare solo l'algoritmo di rilevamento delle collisioni. Basta costruire un cerchio / sfera attorno a ciascuna entità e lasciare che il motore fisico calcoli le collisioni. Per 2D suggerirei Box2D o Chipmunk e Bullet per 3D.

Se ritieni che l'integrazione di un intero motore fisico sia troppo, suggerirei di esaminare gli algoritmi di collisione specifici. La maggior parte delle librerie di rilevamento delle collisioni funziona in due passaggi:

  • Ampia rilevazione di fase: l'obiettivo di questa fase è ottenere l'elenco delle coppie candidate di oggetti che potrebbero scontrarsi, il più rapidamente possibile. Due opzioni comuni sono:
    • Spazzare e potare : ordina i riquadri di delimitazione lungo l'asse X e segna la coppia di oggetti che si intersecano. Ripetere l'operazione per ogni altro asse. Se una coppia candidata supera tutti i test, passa alla fase successiva. Questo algoritmo è molto efficace nello sfruttare la coerenza temporale: è possibile mantenere gli elenchi di entità ordinate e aggiornarle in ogni frame, ma poiché sono quasi ordinate, sarà molto veloce. Sfrutta anche la coerenza spaziale: poiché le entità sono ordinate in ordine spaziale crescente, quando si controllano le collisioni, è possibile interrompere non appena un'entità non si scontra, perché tutte le successive saranno più lontane.
    • Strutture di dati per il partizionamento spaziale, come quadre, octrees e griglie. Le griglie sono facili da implementare, ma possono essere molto dispendiose se la densità dell'entità è bassa e molto difficile da implementare per lo spazio illimitato. Anche gli alberi spaziali statici sono facili da implementare, ma difficili da bilanciare o aggiornare sul posto, quindi dovresti ricostruirlo ogni frame.
  • Fase stretta: le coppie candidate trovate sulla fase ampia vengono ulteriormente testate con algoritmi più precisi.
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.