2010-01-05 1 views
6

Questo è in C++.Double Buffering per oggetti di gioco, cos'è un modo C++ generico e pulito?

Quindi, sto iniziando da zero scrivendo un motore di gioco per divertirmi e imparare da zero. Una delle idee che voglio implementare è avere uno stato dell'oggetto di gioco (una struct) con doppio buffer. Ad esempio, posso avere sottosistemi che aggiornano i nuovi dati degli oggetti di gioco mentre un thread di rendering esegue il rendering dai vecchi dati garantendo che ci sia uno stato coerente memorizzato all'interno dell'oggetto di gioco (i dati dell'ultima volta). Dopo il rendering del vecchio e l'aggiornamento di nuovo è finito, posso scambiare i buffer e farlo di nuovo.

La domanda è, quale è un buon modo OOP di previsione e generico per esporre questo ai miei corsi mentre si tenta di nascondere i dettagli di implementazione il più possibile? Vorrei sapere i tuoi pensieri e le tue considerazioni.

Stavo pensando che potrebbe essere utilizzato l'overloading dell'operatore, ma come posso assegnare il sovraccarico per un membro di una classe con modello nella mia classe buffer?

per esempio, credo che questo è un esempio di quello che voglio:

doublebuffer<Vector3> data; 
data.x=5; //would write to the member x within the new buffer 
int a=data.x; //would read from the old buffer's x member 
data.x+=1; //I guess this shouldn't be allowed 

Se questo è possibile, ho potuto scegliere di attivare o disattivare le strutture doppio buffering senza cambiare molto codice.

Questo è ciò che mi stava prendendo in considerazione:

template <class T> 
class doublebuffer{ 
    T T1; 
    T T2; 
    T * current=T1; 
    T * old=T2; 
public: 
    doublebuffer(); 
    ~doublebuffer(); 
    void swap(); 
    operator=()?... 
}; 

e un oggetto di gioco sarebbe stato così:

struct MyObjectData{ 
    int x; 
    float afloat; 
} 

class MyObject: public Node { 
    doublebuffer<MyObjectData> data; 

    functions... 
} 

Quello che ho in questo momento è le funzioni che restituiscono puntatori al buffer vecchio e nuovo e credo che qualsiasi classe che li usa debba essere consapevole di questo. C'è un modo migliore?

+0

piccola modifica per chiarezza – gtrak

+4

Gary: il sovraccarico dell'operatore è una scelta denotazionale scarsa. Non stai facendo una variante di un operatore accettato, stai facendo qualcosa di abbastanza unico. Vuoi una chiamata alla funzione che rileva l'unicità della soluzione. –

+0

sì, una versione più dettagliata della mia idea originale era che potevo avere operatore + call operator + sul membro appropriato della struttura dati appropriata e passare l'argomento lungo. Capisco cosa intendi però, potrebbe diventare confuso. Il vantaggio nella mia mente è che i dati a doppio scambio potrebbero essere una sostituzione in sostituzione di un dato normale se lo uso, se imposto alcune regole come i membri possono essere solo numeri o aggregati. – gtrak

risposta

5

Recentemente ho affrontato un desiderio simile in modo generalizzato "snapshotting" una struttura dati che utilizzava Copy-On-Write sotto il cofano. Un aspetto che mi piace di questa strategia è che puoi creare molte istantanee se ne hai bisogno, o semplicemente averne uno alla volta per ottenere il tuo "doppio buffer".

senza sudare troppi dettagli di implementazione, ecco qualche pseudocodice:

snapshottable<Vector3> data; 
data.writable().x = 5; // write to the member x 

// take read-only snapshot 
const snapshottable<Vector3>::snapshot snap (data.createSnapshot()); 

// since no writes have happened yet, snap and data point to the same object 

int a = snap.x; //would read from the old buffer's x member, e.g. 5 

data.writable().x += 1; //this non-const access triggers a copy 

// data & snap are now pointing to different objects in memory 
// data.readable().x == 6, while snap.x == 5 

Nel tuo caso, si sarebbe snapshot vostro stato e passarlo al rendering. Quindi permetteresti al tuo aggiornamento di operare sull'oggetto originale. La lettura con accesso const tramite readable() non attiverebbe una copia ... mentre l'accesso con writable() farebbe scattare una copia da.

Ho usato alcuni trucchi su Qt's QSharedDataPointer per fare questo. Essi differenziano l'accesso const e non-const tramite (- >), in modo che la lettura da un oggetto const non inneschi la copia sulla meccanica di scrittura.

+0

ooo, molto interessante, sicuramente indagherò su questo, grazie. – gtrak

+0

suona come se potesse risparmiare anche memoria – gtrak

+0

È possibile eseguire la copia su scrittura in molti modi, ma se si è interessati a una soluzione thread-safe che utilizza QSharedDataPointer, il mio progetto di sottostrutturazione è open source. Alcuni dei diavoli in dettaglio sono nello snapshottable.h: http://gitorious.org/thinker-qt/thinker-qt/blobs/master/include/thinkerqt/snapshottable.h – HostileFork

5

Non farei niente di "intelligente" con il sovraccarico dell'operatore se fossi in te. Usalo per cose che non sorprendono, il più vicino possibile a ciò che l'operatore nativo farebbe e nient'altro.

Non è chiaro che il tuo schema sia particolarmente utile con più thread di scrittura in ogni caso - come fai a sapere quale "vince" quando diversi thread leggono il vecchio stato e scrivono nello stesso nuovo stato, sovrascrivendo qualsiasi scrittura precedente?

Ma se è una tecnica utile nella tua app, allora avrei i metodi 'GetOldState' e 'GetNewState' che rendono completamente chiaro cosa sta succedendo.

+0

grazie, sì, più thread di scrittura che dovrò risolvere in seguito. Voglio solo renderlo facile quando ho cose come la fisica pazzesca in corso, ma questo problema non sarebbe indipendente da questa organizzazione di dati? – gtrak

2

io non sono sicuro che avere efficacemente due stati sta a significare che non c'è bisogno alcuna sincronizzazione quando si accede allo stato scrivibile se si dispone di più thread di scrittura, ma ...

Penso che la segue è un modello semplice e ovvio (per mantenere e capire) che potresti usare con poco overhead.

class MyRealState { 
    int data1; 
    ... etc 

    protected: 
     void copyFrom(MyRealState other) { data1 = other.data1; } 

    public: 
     virtual int getData1() { return data1; } 
     virtual void setData1(int d) { data1 = d; } 
} 

class DoubleBufferedState : public MyRealState { 
    MyRealState readOnly; 
    MyRealState writable; 

    public: 
     // some sensible constructor 

     // deref all basic getters to readOnly 
     int getData1() { return readOnly.getData1(); } 

     // if you really need to know value as changed by others 
     int getWritableData1() { return writable.getData1(); } 

     // writes always go to the correct one 
     void setData1(int d) { writable.setData1(d); } 

     void swap() { readOnly.copyFrom(writable); } 
     MyRealState getReadOnly() { return readOnly; } 
} 

Fondamentalmente ho fatto qualcosa di simile al tuo suggerimento ma utilizzando l'overloading. Se vuoi essere attento/paranoico, avrei una classe vuota con metodi getter/setter virtuali come la classe base piuttosto che come sopra, quindi il compilatore mantiene il codice corretto.

Questo ti dà una sola versione dello stato che cambierà sempre solo quando chiami swap e un'interfaccia pulita in cui il chiamante può ignorare il problema del doppio buffer quando si ha a che fare con lo stato (tutto ciò che non ha bisogno della conoscenza del vecchio e i nuovi stati possono gestire l'interfaccia "MyRealState") oppure puoi downcast/richiedere l'interfaccia DoubleBufferedState se ti interessa prima e dopo gli stati (che è probabilmente imho).

È più probabile che il codice pulito venga compreso (da tutti compresi voi) e più semplice da testare, quindi eviterò di sovraccaricare l'operatore personalmente.

Ci scusiamo per eventuali errori di sintassi C++, ora sono un po 'java.

+0

Grazie, penso che una cosa del genere sia ciò che sto cercando, forse per salvare qualche ripetizione, posso usare l'eredità basata su modelli da tutti i miei vari oggetti di stato. – gtrak

+0

ancora, il problema che ho con questo è che voglio un modo generico per fare questo. Tutte le mie strutture dati potrebbero non avere necessariamente una funzione getdata1(). – gtrak

2

Più grande diventa lo stato del gioco, più costoso sarà mantenere due copie sincronizzate. Sarebbe altrettanto semplice creare una copia dello stato del gioco per ogni thread di rendering; dovrai copiare tutti i dati dal fronte al buffer posteriore, quindi potresti anche farlo al volo.

Si può sempre provare a ridurre al minimo la quantità di copia tra i buffer, ma poi si ha il sovraccarico di tenere traccia di quali campi sono stati modificati in modo da sapere cosa copiare. Questa sarà una soluzione meno che stellare nel cuore di un motore di videogiochi in cui le prestazioni sono piuttosto importanti.

+0

Non è necessario mantenerli sincronizzati in modo esplicito, necessariamente. Per esempio, posso far leggere il mio motore fisico da tutti i vecchi buffer ... fare le sue operazioni e scrivere nel nuovo. La prima cosa che cambia gli oggetti nel mio ciclo di gioco dovrebbe fare abbastanza calcolo per tenerli sincronizzati. – gtrak

1

Forse ti piacerebbe anche creare un nuovo stato di rendering in ogni spunta. In questo modo la logica di gioco è il produttore e il tuo renderer è il consumatore degli stati di rendering. Il vecchio stato è di sola lettura e può essere utilizzato come riferimento sia per il rendering che per il nuovo stato. Una volta eseguito il rendering, lo smaltisci.

Per quanto riguarda gli oggetti di piccole dimensioni, il modello Flyweight potrebbe essere adatto.

+0

hmm, questa è un'idea interessante. In questo momento ho la mia struttura dati principale come un grafico di scena, ei nodi possiedono i dati. Se ho capito bene, il tuo suggerimento richiederebbe la separazione dei dati in una diversa struttura dati e il collegamento dei nodi, ma ciò potrebbe anche dare dei vantaggi. Non l'ho considerato. – gtrak

1

Hai bisogno di fare due cose:

  1. proprio stato di oggetto separato ed è relazione con altri oggetti
  2. uso COW per il proprio stato dell'oggetto

Perché?

Per scopi di rendering è necessario solo le proprietà dell'oggetto "back-version" che influiscono sul rendering (come posizione, orientamento, ecc.) Ma non sono necessarie relazioni tra oggetti.Questo ti renderà libero da puntatori penzolanti e permetterà di aggiornare lo stato del gioco. COW (copy-on-write) dovrebbe essere a livello 1 profondo, perché è necessario un solo buffer "altro".

In breve: Penso che la scelta dell'overloading dell'operatore sia completamente ortogonale a questo problema. È solo zucchero sintatico. Se scrivi + = o setNewState è completamente irrilevante dal momento che entrambi utilizzano lo stesso tempo della CPU.

+0

Sono un po 'in ritardo per la festa, ma sei sicuro che le relazioni tra gli oggetti non influenzano il rendering? Che dire di un grafico di scena mutevole? Le relazioni nel grafico possono cambiare tra i frame (ad esempio aggiungere/rimuovere un oggetto) e quindi influenzare il rendering, assumendo che il thread di rendering attraversi il grafico di scena per determinare le operazioni di rendering richieste. L'unica alternativa, in cui il thread di aggiornamento enumera oggetti di scena renderizzabili in un elenco di buffer di riserva, riduce il parallelismo spostando il lavoro di rendering sul thread di aggiornamento. – Dylan

+0

Buon punto. Dipende davvero dal tipo di gioco e da quali sono le possibili relazioni tra gli oggetti. –

1

Come regola è necessario utilizzare il sovraccarico dell'operatore solo quando è naturale. Se stai cercando un operatore adatto per alcune funzionalità, allora è un buon segno che non dovresti forzare il sovraccarico dell'operatore sul tuo problema.

Detto questo, ciò che si sta tentando di fare è disporre di un oggetto proxy che invii gli eventi di lettura e scrittura a una coppia di oggetti. L'oggetto Proxying sovrascrive frequentemente l'operatore -> per fornire semantica di tipo puntatore. (Non è possibile sovraccaricare ..)

Mentre si potevano avere due sovraccarichi di -> differenziati di const -ness, farei attenzione a questo poiché è problematico per le azioni di lettura. Il sovraccarico viene selezionato in base al fatto che l'oggetto sia referenziato tramite un riferimento const o non-const e non se l'azione sia effettivamente una lettura o una scrittura. Questo fatto rende incline l'errore di approccio.

Quello che puoi fare è dividere l'accesso dalla memoria e creare un modello di classe multi-buffer e un modello di accesso al buffer che accede al membro appropriato, usando operator-> per facilità sintattica.

Questa classe memorizza più istanze del parametro modello T e memorizza uno scostamento in modo che vari utenti di accesso possano recuperare il buffer anteriore/attivo o altri buffer dall'offset relativo. L'utilizzo di un parametro di modello di n == 1 significa che esiste solo un'istanza T e il multi-buffer è disattivato in modo efficace.

template< class T, std::size_t n > 
struct MultiBuffer 
{ 
    MultiBuffer() : _active_offset(0) {} 

    void ChangeBuffers() { ++_active_offset; } 
    T* GetInstance(std::size_t k) { return &_objects[ (_active_offset + k) % n ]; } 

private: 
    T _objects[n]; 
    std::size_t _active_offset; 
}; 

Questa classe astrae la selezione del buffer. Fa riferimento allo MultiBuffer tramite riferimento, pertanto è necessario garantire che la sua durata sia inferiore allo MultiBuffer che utilizza. Ha il proprio offset che viene aggiunto allo scostamento MultiBuffer in modo che diversi BufferAccess possano fare riferimento a membri diversi dell'array (ad esempio, il parametro n = 0 per l'accesso al buffer anteriore e 1 per l'accesso al buffer posteriore).

Si noti che l'offset BufferAccess è un membro e non un parametro di modello in modo che i metodi che operano sugli oggetti non siano legati solo a lavorare su un offset specifico o debbano essere modelli stessi. Ho fatto in modo che l'oggetto contasse un parametro del modello poiché, dalla descrizione, è probabile che sia un'opzione di configurazione e ciò offre al compilatore la massima opportunità di ottimizzazione.

template< class T, std::size_t n > 
class BufferAccess 
{ 
public: 
    BufferAccess(MultiBuffer< T, n >& buf, std::size_t offset) 
     : _buffer(buf), _offset(offset) 
    { 
    } 

    T* operator->() const 
    { 
     return _buffer.GetInstance(_offset); 
    } 

private: 
    MultiBuffer< T, n >& _buffer; 
    const std::size_t _offset; 
}; 

Mettere tutto insieme con una classe di test, si noti che sovraccaricando -> possiamo facilmente chiamare i membri della classe di test dall'istanza BufferAccess senza BufferAccess bisogno di alcuna conoscenza di ciò che i membri della classe di test ha.

Inoltre, non un singolo passaggio tra il buffering singolo e doppio. Il triplo buffering è anche banale da raggiungere se si riesce a trovarne il bisogno.

class TestClass 
{ 
public: 
    TestClass() : _n(0) {} 

    int get() const { return _n; } 
    void set(int n) { _n = n; } 

private: 
    int _n; 
}; 

#include <iostream> 
#include <ostream> 

int main() 
{ 
    const std::size_t buffers = 2; 

    MultiBuffer<TestClass, buffers> mbuf; 

    BufferAccess<TestClass, buffers> frontBuffer(mbuf, 0); 
    BufferAccess<TestClass, buffers> backBuffer(mbuf, 1); 

    std::cout << "set front to 5\n"; 
    frontBuffer->set(5); 

    std::cout << "back = " << backBuffer->get() << '\n'; 

    std::cout << "swap buffers\n"; 
    ++mbuf.offset; 

    std::cout << "set front to 10\n"; 
    frontBuffer->set(10); 

    std::cout << "back = " << backBuffer->get() << '\n'; 
    std::cout << "front = " << frontBuffer->get() << '\n'; 

    return 0; 
}