2010-01-05 14 views
6

Esto está en C++.Buffering doble para objetos de juego, ¿qué es una buena forma genérica y limpia de C++?

Por lo tanto, estoy empezando desde cero escribiendo un motor de juego para divertirme y aprender desde cero. Una de las ideas que quiero implementar es hacer que el estado del objeto del juego (una estructura) sea de doble buffer. Por ejemplo, puedo hacer que los subsistemas actualicen los nuevos datos del objeto del juego mientras se renderiza un hilo de renderización a partir de los datos anteriores, garantizando que haya un estado consistente almacenado dentro del objeto del juego (los datos de la última vez). Después de que el renderizado anterior y la actualización de nuevo hayan finalizado, puedo intercambiar buffers y volver a hacerlo.

La pregunta es, ¿cuál es una buena forma de OOP genérica y con visión de futuro para exponer esto a mis clases al tratar de ocultar los detalles de implementación tanto como sea posible? Me gustaría saber sus pensamientos y consideraciones.

Pensaba que se podía utilizar la sobrecarga del operador, pero ¿cómo sobrecarga la asignación para un miembro de la clase de plantilla dentro de mi clase de memoria intermedia?

por ejemplo, creo que este es un ejemplo de lo que quiero:

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 

Si esto es posible, que podía elegir para activar o desactivar estructuras de doble búfer sin cambiar mucho código.

Esto es lo que yo estaba teniendo en cuenta:

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

y un objeto de juego sería así:

struct MyObjectData{ 
    int x; 
    float afloat; 
} 

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

    functions... 
} 

Lo que tengo en este momento es las funciones que devuelven punteros a la memoria intermedia de viejo y nuevo , y creo que cualquier clase que los use debe ser consciente de esto. ¿Hay una mejor manera?

+0

cambio pequeño para mayor claridad – gtrak

+4

Gary: La sobrecarga del operador es una mala opción denotational. No estás haciendo una variante de un operador aceptado, estás haciendo algo bastante único. Desea una llamada de función que observe la singularidad de la solución. –

+0

sí, una versión más concreta de mi idea original era que podía tener operator + call operator + en el miembro apropiado de la estructura de datos apropiada y pasar el argumento. Sin embargo, veo lo que quieres decir, podría ser confuso. La ventaja en mi mente es que los datos con doble copia de seguridad podrían ser un reemplazo en reemplazo de los datos normales si los utilizo, si impongo algunas reglas, como que los miembros solo pueden ser números o agregados. – gtrak

Respuesta

5

Hace poco traté de un deseo similar de forma general mediante la "instantánea" de una estructura de datos que usaba Copy-On-Write debajo del capó. Un aspecto que me gusta de esta estrategia es que puedes hacer muchas instantáneas si las necesitas, o simplemente tener una a la vez para obtener tu "doble buffer".

sin sudar demasiados detalles de implementación, aquí algo de pseudocódigo:

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 

En su caso, usted Snapshot su estado y lo pasa a prestar. Luego, permitiría que su actualización funcione en el objeto original. Leerlo con const access a través del readable() no desencadenaría una copia ... mientras se accede con writable() activaría una copia.

Utilicé algunos trucos sobre Qt's QSharedDataPointer para hacer esto. Diferencian el acceso const y el no const a través de (- >), de modo que las lecturas de un objeto const no activen la copia en la mecánica de escritura.

+0

ooo, muy interesante, definitivamente voy a investigar esto, gracias. – gtrak

+0

suena como que podría ahorrar algo de memoria también – gtrak

+0

Puede copiar varias maneras, pero si está interesado en una solución de seguridad de subprocesos utilizando QSharedDataPointer, mi proyecto en construcción es de código abierto. Algunos de los demonios en los detalles se encuentran en la instantánea.h: http://gitorious.org/thinker-qt/thinker-qt/blobs/master/include/thinkerqt/snapshottable.h – HostileFork

5

No haría nada 'inteligente' con la sobrecarga del operador si fuera usted. Úselo para cosas completamente inesperadas lo más cercano posible a lo que haría el operador nativo, y nada más.

No es del todo claro que su esquema sea particularmente útil con varios hilos de escritura: ¿cómo sabe cuál de ellos 'gana' cuando varios hilos leen el estado antiguo y escriben en el mismo estado nuevo, sobrescribiendo las escrituras anteriores?

Pero si es una técnica útil en su aplicación, entonces tendría los métodos 'GetOldState' y 'GetNewState' que dejan completamente en claro lo que está sucediendo.

+0

gracias, sí, varios hilos de escritura que tendré que resolver más adelante. Solo quiero hacerlo más fácil cuando tengo cosas como la física loca, pero ¿no sería ese problema independiente de esta organización de datos? – gtrak

2

No estoy seguro de que tiene efectivamente dos estados va a significar que no necesita ninguna sincronización cuando se accede al estado grabable si tiene varios hilos de escritura, pero ...

Creo que la siguiente es un patrón simple y obvio (para mantener y comprender) que podría usar con poca sobrecarga.

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

Básicamente he hecho algo similar a su sugerencia, pero el uso de la sobrecarga. Si quieres ser cuidadoso/paranoico, tendría una clase vacía con métodos getter/setter virtuales como clase base en lugar de como arriba, así el compilador mantiene el código correcto.

Esto le da una versión readOnly del estado que solo cambiará cuando llame a swap y una interfaz limpia donde la persona que llama puede ignorar el problema de doble buffer cuando se trata del estado (todo lo que no necesita conocimiento de y los nuevos estados pueden tratar con la "interfaz" de MyRealState) o puede bajar/requerir la interfaz DoubleBufferedState si le importan los estados anteriores y posteriores (lo cual es probable en mi humilde opinión).

El código de limpieza es más fácil de entender (por todos, incluyéndolo a usted) y más fácil de probar, por lo que me mantendría alejado de la sobrecarga del operador personalmente.

Lo siento por cualquier error de sintaxis de C++, ahora soy un poco java.

+0

Gracias, creo que algo así es lo que estoy buscando, tal vez para ahorrar algo de repetición, puedo usar la herencia con plantillas de todos mis diversos objetos de estado. – gtrak

+0

todavía, el problema que tengo con esto es que quiero una forma genérica para hacer esto. Todas mis estructuras de datos pueden no tener necesariamente una función getdata1(). – gtrak

2

Cuanto mayor sea el estado del juego, más costoso será mantener dos copias sincronizadas. Sería igual de simple crear una copia del estado del juego para el hilo de render en cada tic; Tendrás que copiar todos los datos desde el buffer de adelante hacia atrás, así que también puedes hacerlo sobre la marcha.

Siempre se puede tratar de minimizar la cantidad de copias entre los búferes, pero luego tiene la ventaja de hacer un seguimiento de los campos que han cambiado para que sepa qué copiar. Esa será una solución menos que estelar en el núcleo de un motor de videojuegos donde el rendimiento es muy importante.

+0

No tiene que mantenerlos sincronizados explícitamente, necesariamente. Por ejemplo, puedo hacer que mi motor de física lea de todos los buffers antiguos ... haga sus operaciones y escriba en el nuevo. Lo primero que cambia los objetos en mi bucle de juego debería hacer suficiente cálculo para mantenerlos sincronizados. – gtrak

1

Quizás incluso desee crear un nuevo estado de representación en cada tilde. De esta manera, tu lógica de juego es el productor y tu procesador es el consumidor de los estados de renderizado. El estado anterior es de solo lectura y se puede usar como referencia para la representación y el nuevo estado. Una vez renderizado, lo eliminas.

En cuanto a los objetos pequeños, el patrón Flyweight podría ser adecuado.

+0

hmm, esta es una idea interesante. Ahora mismo tengo mi estructura de datos principal como un gráfico de escena, y los nodos poseen los datos. Si entiendo correctamente, su sugerencia requeriría separar los datos en una estructura de datos diferente y tener los nodos vinculados a ella, pero eso también podría proporcionar algunas ventajas. No lo he considerado. – gtrak

1

que tiene que hacer dos cosas:

  1. estado propio del objeto separado y su relación con otros objetos
  2. uso VACA para el propio estado del objeto

¿Por qué?

Para fines de representación, solo necesita "respaldar" las propiedades del objeto que afectan el procesamiento (como posición, orientación, etc.) pero no necesita relaciones de objeto.Esto lo liberará de punteros colgantes y le permitirá actualizar el estado del juego. COW (copy-on-write) debe tener un nivel de profundidad, porque solo necesita un "otro" buffer.

En resumen: Creo que la elección de la sobrecarga del operador es completamente ortogonal a este problema. Es solo azúcar sintético. Si escribe + = o setNewState es completamente irrelevante ya que ambos usan el mismo tiempo de CPU.

+0

Llego un poco tarde a la fiesta, pero ¿está seguro de que las relaciones de objeto no afectan el procesamiento? ¿Qué tal un gráfico de escena mutable? Las relaciones en el gráfico pueden cambiar entre fotogramas (por ejemplo, agregar/eliminar un objeto) y, por lo tanto, afectar el renderizado, suponiendo que el hilo de renderizado atraviesa el gráfico de escena para determinar las operaciones de renderización necesarias. La única alternativa, en la que el subproceso de actualización enumera objetos de escena renderizables en una lista de búfer en segundo plano, reduce el paralelismo al mover el trabajo de representación al subproceso de actualización. – Dylan

+0

Buen punto. Realmente depende de qué tipo de juego es y cuáles son las posibles relaciones entre los objetos. –

1

Como regla, solo debe usar la sobrecarga del operador cuando es natural. Si está buscando un operador adecuado para alguna funcionalidad, es una buena señal de que no debe forzar a los operadores a sobrecargar su problema.

Habiendo dicho eso, lo que estás tratando de hacer es tener un objeto proxy que distribuya eventos de lectura y escritura a uno de un par de objetos. El objeto de proxy frecuentemente sobrecarga el operador -> para proporcionar una semántica similar a un puntero. (No se puede sobrecargar ..)

Mientras que usted podría tener dos sobrecargas de -> diferenciados por const -ness, quisiera advertir contra esto ya que es problemático para las acciones de lectura. La sobrecarga se selecciona según si el objeto se referencia a través de una referencia const o non-const y no si la acción es realmente una lectura o una escritura. Este hecho hace que el error de aproximación sea propenso.

Lo que puede hacer es dividir el acceso desde el almacenamiento y crear una plantilla de clase multi-buffer y una plantilla de acceso de buffer que acceda al miembro apropiado, usando operator-> para facilidad sintáctica.

Esta clase almacena varias instancias del parámetro de plantilla T y almacena un desplazamiento para que varios usuarios puedan recuperar el búfer frontal/activo u otros búferes por desplazamiento relativo. El uso de un parámetro de plantilla de n == 1 significa que solo hay una instancia de T y que el multibúbrado está desactivado efectivamente.

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

Esta clase abstrae la selección del búfer. Hace referencia al MultiBuffer a través de la referencia, por lo que debe garantizar que su vida útil es corta que la MultiBuffer que utiliza. Tiene su propio desplazamiento que se agrega al desplazamiento MultiBuffer para que diferentes BufferAccess puedan hacer referencia a diferentes miembros de la matriz (por ejemplo, parámetro de plantilla n = 0 para el acceso del búfer frontal y 1 para el acceso del búfer posterior).

Nota que el BufferAccess offset es un miembro y no un parámetro de plantilla de modo que los métodos que operan sobre BufferAccess objetos no están atados a trabajar sólo en un desfase particular o tener que ser plantillas sí mismos. Hice que el objeto cuente como un parámetro de plantilla ya que, según su descripción, es probable que sea una opción de configuración y esto le da al compilador la máxima oportunidad para la optimización.

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

Poniendo todo junto con una clase de prueba, tenga en cuenta que por la sobrecarga -> podemos llamar fácilmente los miembros de la clase de prueba de la BufferAccess ejemplo, sin la necesidad de ningún BufferAccess conocimiento de lo que los miembros de la clase de prueba tiene.

Tampoco el cambio de un solo interruptor cambia entre el almacenamiento en búfer simple y doble. Triple buffering también es trivial para lograr si pudiera encontrar una necesidad.

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