Utilizzo di un'architettura di sistema di entità con parallelismo basato su attività


9

sfondo

Ho lavorato alla creazione di un motore di gioco multithreading nel mio tempo libero e attualmente sto cercando di decidere il modo migliore per far funzionare un sistema di entità in ciò che ho già creato. Finora ho usato questo articolo di Intel come punto di partenza per il mio motore. Finora ho implementato il normale ciclo di gioco usando le attività, e ora sto passando a ottenere alcuni dei sistemi e / o sistemi di entità incorporati. Ho usato qualcosa di simile ad Artemide in passato, ma il parallelismo mi sta gettando via.

L'articolo di Intel sembra sostenere la presenza di più copie dei dati delle entità e la necessità di apportare modifiche a ciascuna entità internamente distribuita al termine di un aggiornamento completo. Ciò significa che il rendering sarà sempre un frame dietro, ma sembra un compromesso accettabile visti i vantaggi in termini di prestazioni che dovrebbero essere ottenuti. Quando si tratta di un sistema di entità come Artemis, tuttavia, avere ciascuna entità duplicata per ciascun sistema significa che anche ogni componente dovrà essere duplicato. Questo è fattibile ma a me sembra che consumerebbe molta memoria. Le parti del documento Intel che discusano questo sono principalmente 2.2 e 3.2.2. Ho fatto qualche ricerca per vedere se potevo trovare dei buoni riferimenti per l'integrazione delle architetture che sto cercando, ma non sono ancora riuscito a trovare qualcosa di utile.

Nota: sto usando C ++ 11 per questo progetto, ma immagino che la maggior parte di ciò che sto chiedendo dovrebbe essere agnostico in termini di linguaggio.

Possibile soluzione

Avere un EntityManager globale che viene utilizzato per la creazione e la gestione di Entity e EntityAttributes. Consentire l'accesso in lettura ad essi solo durante la fase di aggiornamento e archiviare tutte le modifiche in una coda per thread. Una volta completate tutte le attività, le code vengono combinate e vengono applicate le modifiche in ciascuna. Ciò potrebbe avere problemi con più scritture negli stessi campi, ma sono sicuro che potrebbe esserci un sistema prioritario o un timestamp per risolverlo. Questo mi sembra un buon approccio perché i sistemi possono essere informati delle modifiche alle entità in modo abbastanza naturale durante la fase di distribuzione delle modifiche.

Domanda

Sto cercando un feedback sulla mia soluzione per vedere se ha senso. Non mentirò e pretenderò di essere un esperto di multithreading, e lo sto facendo in gran parte per la pratica. Posso prevedere alcuni complicati problemi derivanti dalla mia soluzione in cui più sistemi leggono / scrivono più valori. La coda di modifica che ho citato potrebbe anche essere difficile da formattare in modo tale che qualsiasi possibile modifica possa essere facilmente comunicata quando non lavoro con POD.

Qualsiasi feedback / consiglio sarebbe molto apprezzato! Grazie!

link

Risposte:


12

Fork-join

Non hai bisogno di copie separate dei componenti. Basta usare un modello fork-join, che è (estremamente male) menzionato in quell'articolo di Intel.

In un ECS, hai effettivamente un circuito simile a:

while in game:
  for each system:
    for each component in system:
      update component

Cambia questo in qualcosa del tipo:

while in game:
  for each system:
    divide components into groups
    for each group:
      start thread (
        for each component in group:
          update component
      )
    wait for all threads to finish

La parte difficile è il bit "dividi i componenti in gruppi". Per la grafica non c'è quasi bisogno di dati condivisi, quindi è semplice (dividere gli oggetti renderizzabili in modo uniforme per numero di thread di lavoro disponibili). Per la fisica e l'IA, vuoi trovare "isole" logiche di oggetti che non interagiscono e metterle insieme. Meno interazione tra i componenti, meglio è.

Per l'interazione che deve esistere, i messaggi ritardati funzionano meglio. Se l'oggetto A deve dire all'oggetto B di subire danni, A può semplicemente accodare un messaggio in un pool per thread. Quando i thread vengono uniti, i pool vengono tutti concatenati in un singolo pool. Sebbene non sia direttamente correlato al threading, vedi la serie di eventi degli articoli degli sviluppatori BitSquid (in effetti, leggi l'intero blog; non sono d'accordo con tutto lì, ma è una risorsa fantastica).

Nota che "fork-join" non significa usare fork()(che crea processi, non thread), né implica che devi effettivamente unire i thread. Significa solo che esegui una singola attività, la dividi in pezzi più piccoli per essere gestita dal tuo pool di thread di lavoro, quindi attendi l'elaborazione di tutti i pacchi.

Proxy

Questo approccio può essere utilizzato da solo o in combinazione con il metodo fork-join per rendere meno importante la necessità di una separazione rigorosa.

Puoi essere più amichevole nell'interagire i thread usando un semplice approccio a due livelli. Hanno entità "autorevoli" ed entità "proxy". Le entità autorevoli possono essere modificate solo da un singolo thread che è il chiaro proprietario dell'entità autorevole. Le entità proxy non possono essere modificate, solo leggere. A un punto di sincronizzazione nel ciclo di gioco, propaga tutte le modifiche da entità autorevoli ai proxy corrispondenti.

Sostituisci "entità" con "componenti" come appropriato. L'essenza è che sono necessarie al massimo due copie di qualsiasi oggetto, e ci sono chiari punti "sync" nel loop del gioco quando è possibile copiare l'uno dall'altro nei progetti di motori di gioco più logici.

È possibile espandere i proxy per consentire comunque (un sottoinsieme di) metodi / messaggi da utilizzare semplicemente facendo avanzare tutte queste cose in una coda che viene consegnata all'oggetto autorevole nel frame successivo.

Si noti che l'approccio proxy è un design fantastico da avere a un livello superiore in quanto rende il supporto di rete super facile.


Avevo letto alcune cose sul fork fork che hai menzionato prima e avevo l'impressione che mentre ti consente di utilizzare un po 'di parallelismo, ci sono situazioni in cui alcuni thread di lavoro potrebbero attendere che un gruppo finisca. Idealmente, sto cercando di evitare quella situazione. L'idea del proxy è interessante e ricorda leggermente ciò su cui stavo lavorando. Un'entità ha EntityAttributes e questi sono wrapper per i valori effettivamente archiviati dall'entità. Quindi i valori possono essere letti da loro in qualsiasi momento, ma impostati solo in determinati momenti e potrebbero contenere un valore proxy nell'attributo, giusto?
Ross Hays,

1
C'è una buona probabilità che nel tentativo di evitare l'attesa passi così tanto tempo ad analizzare il grafico delle dipendenze da perdere del tempo in generale.
Patrick Hughes,

@roflha: sì, potresti mettere i proxy a livello di EntityAttribute. Oppure crea un'entità separata con un secondo set di attributi. O semplicemente abbandonare del tutto il concetto di attributi e utilizzare un design dei componenti meno granulare.
Sean Middleditch,

@SeanMiddleditch Quando dico attributo, mi riferisco essenzialmente ai componenti che penso. Gli attributi non sono solo valori singoli come float e stringhe se è quello che ho fatto sembrare. Piuttosto sono classi che contengono informazioni specifiche come PositionAttribute. Se componente è il nome accettato per quello, allora forse dovrei cambiare. Ma consiglieresti il ​​proxy a livello di entità piuttosto che a livello di componente / attributo?
Ross Hays,

1
Raccomando qualunque cosa trovi più facile da implementare. Ricorda solo il punto in cui sarei in grado di interrogare i proxy senza prendere alcun lock, senza usare alcun atomico e senza deadlock.
Sean Middleditch,
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.