¿Cómo puedo soportar la comunicación de componente a objeto de forma segura y con almacenamiento de componentes amigable con la caché?

9

Estoy creando un juego que utiliza objetos de juego basados ​​en componentes, y estoy teniendo dificultades para implementar una forma para que cada componente se comunique con su objeto de juego. En lugar de explicar todo de una vez, explicaré cada parte del código de muestra relevante:

class GameObjectManager {
    public:
        //Updates all the game objects
        void update(Time dt);

        //Sends a message to all game objects
        void sendMessage(Message m);

    private:
        //Vector of all the game objects
        std::vector<GameObject> gameObjects;

        //vectors of the different types of components
        std::vector<InputComponent> input;
        std::vector<PhysicsComponent> ai;
        ...
        std::vector<RenderComponent> render;
}

El GameObjectManagertiene todos los objetos del juego y sus componentes. También es responsable de actualizar los objetos del juego. Lo hace actualizando los vectores componentes en un orden específico. Utilizo vectores en lugar de matrices para que prácticamente no haya límite en la cantidad de objetos de juego que pueden existir a la vez.

class GameObject {
    public:
        //Sends a message to the components in this game object
        void sendMessage(Message m);

    private:
        //id to keep track of components in the manager
        const int id;

        //Pointers to components in the game object manager
        std::vector<Component*> components;
}

La GameObjectclase sabe cuáles son sus componentes y puede enviarles mensajes.

class Component {
    public:
        //Receives messages and acts accordingly
        virtual void handleMessage(Message m) = 0;

        virtual void update(Time dt) = 0;

    protected:
        //Calls GameObject's sendMessage
        void sendMessageToObject(Message m);

        //Calls GameObjectManager's sendMessage
        void sendMessageToWorld(Message m);
}

La Componentclase es puramente virtual, de modo que las clases para los diferentes tipos de componentes pueden implementar cómo manejar los mensajes y actualizarlos. También puede enviar mensajes.

Ahora surge el problema de cómo los componentes pueden llamar a las sendMessagefunciones en GameObjecty GameObjectManager. Se me ocurrieron dos posibles soluciones:

  1. Dar Componentun puntero a su GameObject.

Sin embargo, dado que los objetos del juego están en un vector, los punteros podrían invalidarse rápidamente (lo mismo podría decirse del vector en GameObject, pero es de esperar que la solución a este problema también pueda resolverlo). Podría poner los objetos del juego en una matriz, pero luego tendría que pasar un número arbitrario para el tamaño, que fácilmente podría ser innecesariamente alto y desperdiciar memoria.

  1. Dar Componentun puntero a la GameObjectManager.

Sin embargo, no quiero que los componentes puedan llamar a la función de actualización del administrador. Soy la única persona que trabaja en este proyecto, pero no quiero acostumbrarme a escribir código potencialmente peligroso.

¿Cómo puedo resolver este problema mientras mantengo mi código seguro y amigable con la caché?

AlecM
fuente

Respuestas:

6

Su modelo de comunicación parece estar bien, y la opción uno funcionaría bien si solo pudiera almacenar esos punteros de forma segura. Puede resolver ese problema eligiendo una estructura de datos diferente para el almacenamiento de componentes.

A std::vector<T>fue una primera opción razonable. Sin embargo, el comportamiento de invalidación del iterador del contenedor es un problema. Lo que desea es una estructura de datos que sea rápida y coherente en caché para iterar, y que también mantenga la estabilidad del iterador al insertar o eliminar elementos.

Puedes construir una estructura de datos de este tipo. Consiste en una lista vinculada de páginas . Cada página tiene una capacidad fija y contiene todos sus elementos en una matriz. Se utiliza un recuento para indicar cuántos elementos de esa matriz están activos. Una página también tiene una lista libre (lo que permite la reutilización de las entradas despejadas) y una lista de salto (la posibilidad de saltar sobre las entradas despejados, mientras que la iteración.

En otras palabras, conceptualmente algo como:

struct Page {
   int count;
   int capacity;           // Optional if every page is a fixed size.
   T * m_storage;
   bool * m_skip;          // Skip list; can be bit-compressed.
   std::stack<int> m_free; // Can be replaced with a specialized stack.

   Page * next;
   Page * prior;           // Optional, allows reverse iteration
};

Sin imaginación llamo a esta estructura de datos un libro (porque es una colección de páginas que itera), pero la estructura tiene varios otros nombres.

Matthew Bentley lo llama una "colonia". La implementación de Matthew utiliza un campo de salto de conteo de saltos (disculpas por el enlace MediaFire, pero así es como Bentley mismo aloja el documento) que es superior a la lista de saltos basada en booleanos más típica en este tipo de estructuras. La biblioteca de Bentley es solo de encabezado y fácil de colocar en cualquier proyecto de C ++, por lo que le aconsejo que simplemente use eso en lugar de rodar el suyo. Hay muchas sutilezas y optimizaciones que estoy pasando por alto aquí.

Debido a que esta estructura de datos nunca mueve elementos una vez que se agregan, los punteros e iteradores de ese elemento siguen siendo válidos hasta que se elimina ese elemento (o se borra el contenedor). Debido a que almacena fragmentos de elementos asignados de forma contigua, la iteración es rápida y mayormente coherente con la memoria caché. La inserción y la extracción son razonables.

No es perfecto; Es posible arruinar la coherencia de la memoria caché con un patrón de uso que implica eliminar en gran medida los puntos aleatorios efectivos en el contenedor y luego iterar sobre ese contenedor antes de que las inserciones posteriores hayan rellenado los elementos. Si se encuentra en ese escenario con frecuencia, omitirá regiones de memoria potencialmente grandes a la vez. Sin embargo, en la práctica, creo que este contenedor es una opción razonable para su escenario.

Otros enfoques, que dejaré para cubrir otras respuestas, podrían incluir un enfoque basado en el identificador o un tipo de estructura de mapa de ranuras (donde tiene una matriz asociativa de "valores" enteros "valores" enteros, los valores son índices en una matriz de respaldo, que le permite iterar sobre un vector mediante el acceso por "índice" con alguna indirección adicional).


fuente
¡Hola! ¿Hay algún recurso donde pueda aprender más sobre las alternativas a la "colonia" que mencionó en el último párrafo? ¿Se implementan en alguna parte? He estado investigando este tema durante algún tiempo y estoy realmente interesado.
Rinat Veliakhmedov
5

Ser 'amigable con el caché' es una preocupación que tienen los grandes juegos . Esto parece ser una optimización prematura para mí.


Una forma de resolver esto sin ser 'amigable con la caché' sería crear su objeto en el montón en lugar de en la pila: use newpunteros (inteligentes) para sus objetos. De esta manera, podrá hacer referencia a sus objetos y su referencia no se invalidará.

Para una solución más amigable con la memoria caché, puede administrar la desasignación / asignación de objetos usted mismo y usar controladores para estos objetos.

Básicamente, en la inicialización de su programa, un objeto reserva una porción de memoria en el montón (llamémoslo MemMan), luego, cuando desea crear un componente, le dice a MemMan que necesita un componente de tamaño X, ' Lo reservaremos para usted, creará un identificador y mantendremos internamente dónde en su asignación está el objeto para ese identificador. Devolverá el identificador, y eso es lo único que mantendrá sobre el objeto, nunca un puntero a su ubicación en la memoria.

Como necesita el componente, le pedirá a MemMan que acceda a este objeto, lo que con gusto lo hará. Pero no guardes la referencia porque ...

Uno de los trabajos de MemMan es mantener los objetos cerca uno del otro en la memoria. Una vez cada pocos fotogramas del juego, puede decirle a MemMan que reorganice los objetos en la memoria (o podría hacerlo automáticamente cuando cree / elimine objetos). Actualizará su mapa de ubicación de manejador a memoria. Sus identificadores siempre serán válidos, pero si mantuvo una referencia al espacio de memoria (un puntero o una referencia ), encontrará solo desesperación y desolación.

Los libros de texto dicen que esta forma de administrar su memoria tiene al menos 2 ventajas:

  1. menos errores de caché porque los objetos están cerca uno del otro en la memoria y
  2. reduce la cantidad de llamadas de des / asignación de memoria que hará al sistema operativo, que se dice que demoran un poco .

Tenga en cuenta que la forma en que usa MemMan y cómo organizará la memoria internamente depende realmente de cómo usará sus componentes. Si itera a través de ellos según su tipo, querrá mantener los componentes por tipo, si itera a través de ellos en función de su objeto de juego, deberá encontrar una manera de asegurarse de que estén cerca de uno otro basado en eso, etc.

Vaillancourt
fuente