Separar los datos / lógica del juego del renderizado

21

Estoy escribiendo un juego usando C ++ y OpenGL 2.1. Estaba pensando cómo podría separar los datos / la lógica del renderizado. Por el momento utilizo una clase base 'Renderable' que proporciona un método virtual puro para implementar el dibujo. Pero cada objeto tiene un código tan especializado que solo el objeto sabe cómo configurar correctamente los uniformes de sombreador y organizar los datos del búfer de la matriz de vértices. Termino con muchas llamadas de función gl * en todo mi código. ¿Hay alguna forma genérica de dibujar los objetos?

felipe
fuente
44
Use la composición para adjuntar realmente un renderizable a su objeto y haga que su objeto interactúe con ese m_renderablemiembro. De esa manera, puedes separar mejor tu lógica. No aplique la "interfaz" renderizable en objetos generales que también tengan física, ai y otras cosas. Después de eso, puede administrar los renderizables por separado. Necesita una capa de abstracción sobre las llamadas a la función OpenGL para desacoplar las cosas aún más. Por lo tanto, no espere que un buen motor tenga llamadas GL API dentro de sus diversas implementaciones renderizables. Eso es todo, en pocas palabras.
Teodron
1
@teodron: ¿Por qué no pones eso como respuesta?
Tapio
1
@Tapio: porque no es una gran respuesta; es más una sugerencia en su lugar.
Teodron

Respuestas:

20

Una idea es utilizar el patrón de diseño Visitante. Necesita una implementación de Renderer que sepa cómo renderizar accesorios. Cada objeto puede llamar a la instancia del renderizador para manejar el trabajo de renderizado.

En unas pocas líneas de pseudocódigo:

class Renderer {
public:
    void render( const ObjectA & obj );
    void render( const ObjectB & obj );
};


class ObjectA{
public:
    void draw( Renderer & r ){ r.render( *this ) };
}

class ObjectB{
public:
    void draw( Renderer & r ){ r.render( *this ) };
}

El material gl * se implementa mediante los métodos del renderizador, y los objetos solo almacenan los datos necesarios para renderizar, posición, tipo de textura, tamaño ... etc.

Además, puede configurar diferentes renderizadores (debugRenderer, hqRenderer, ... etc.) y usarlos dinámicamente, sin cambiar los objetos.

Esto también se puede combinar fácilmente con los sistemas de entidad / componente.

Zhen
fuente
1
¡Esta es una respuesta bastante buena! Podría haber enfatizado Entity/Componentun poco más la alternativa, ya que puede ayudar a separar a los proveedores de geometría de otras partes del motor (IA, física, redes o juego en general). +1!
Teodron
1
@teodron, no explicaré la alternativa de E / C porque completaría las cosas. Pero, creo que debería cambiar ObjectAy ObjectBpor DrawableComponentAy DrawableComponentB, y dentro de los métodos de renderizado, usar otros componentes si lo necesita, como: position = component->getComponent("Position");Y en el bucle principal, tiene una lista de componentes dibujables para llamar a draw.
Zhen
¿Por qué no simplemente tener una interfaz (como Renderable) que tiene una draw(Renderer&)función y todos los objetos que se pueden representar los implementan? ¿En qué caso Renderersolo necesita una función que acepte cualquier objeto que implemente la interfaz común y la llamada renderable.draw(*this);?
Vite Falcon el
1
@ViteFalcon, lo siento si no me aclaro, pero para una explicación detallada, debería necesitar más espacio y código. Básicamente, mi solución mueve las gl_*funciones al renderizador (separando la lógica del renderizado), pero su solución mueve las gl_*llamadas a los objetos.
Zhen
De esta manera, las funciones gl * se eliminan del código de objeto, pero aún mantengo las variables de manejo utilizadas en la representación, como las ubicaciones de uniformes / atributos, identificaciones de búfer / textura.
felipe
4

Sé que ya has aceptado la respuesta de Zhen, pero me gustaría publicar otra en caso de que ayude a alguien más.

Para reiterar el problema, el OP quiere la capacidad de mantener el código de representación separado de la lógica y los datos.

Mi solución es usar una clase diferente todos juntos para representar el componente, que es independiente de la Rendererclase y la lógica. Primero debe haber una Renderableinterfaz que tenga una función bool render(Renderer& renderer);y la Rendererclase usa el patrón de visitante para recuperar todas las Renderableinstancias, dada la lista de GameObjectsy representa los objetos que tienen una Renderableinstancia. De esta manera, Renderer no necesita saber de cada tipo de objeto y sigue siendo responsabilidad de cada tipo de objeto informarlo a Renderabletravés de la getRenderable()función. O bien, puede crear una RenderableVisitorclase que visite todos los GameObjects y, en función de la GameObjectcondición individual , pueden elegir agregar / no agregar su representación al visitante. De cualquier manera, la esencia principal es que elgl_*todas las llamadas están fuera del objeto en sí y residen en una clase que conoce detalles íntimos del objeto en sí, en lugar de ser parte de eso Renderer.

DESCARGO DE RESPONSABILIDAD : escribí a mano estas clases en el editor, por lo que hay una buena posibilidad de que me haya perdido algo en el código, pero con suerte, entenderás la idea.

Para mostrar un ejemplo (parcial):

Renderable interfaz

class Renderable {
public:
    Renderable(){}
    virtual ~Renderable(){}
    virtual void render(Renderer& renderer) const = 0;
};

GameObject clase:

class GameObject {
public:
    GameObject()
        : mVisible(true)
        , mMarkedForDelete(false) {}

    virtual ~GameObject(){}

    virtual Renderable* getRenderable() {
        // By default, all GameObjects are missing their Renderable
        return NULL;
    }

    void setVisible(bool visible) {
        mVisible = visible;
    }

    bool isVisible() const {
        return getRenderable() != null && !isMarkedForDeletion() && mVisible;
    }

    void markForDeletion() {
        mMarkedForDelete = true;
    }

    bool isMarkedForDeletion() const {
        return mMarkedForDelete;
    }

    // More GameObject functions

private:
    bool mVisible;
    bool mMarkedForDelete;
};

(Parcial) Rendererclase.

class Renderer {
public:
    void renderObjects(std::vector<GameObject>& gameObjects) {
        // If you want to do something fancy with the renderable GameObjects,
        // create a visitor class to return the list of GameObjects that
        // are visible instead of rendering them straight-away
        std::list<GameObject>::iterator itr = gameObjects.begin(), end = gameObjects.end();
        while (itr != end) {
            GameObject* gameObject = *itr++;
            if (gameObject == null || !gameObject->isVisible()) {
                continue;
            }
            gameObject->getRenderable()->render(*this);
        }
    }

};

RenderableObject clase:

template <typename T>
class RenderableObject : public Renderable {
public:
    RenderableObject(T& object)
        :mObject(object) {}
    virtual ~RenderableObject(){}

    virtual void render(Renderer& renderer) {
        return render(renderer, mObject);
    }

protected:
    virtual void render(Renderer& renderer, T& object) = 0;
};

ObjectA clase:

// Forward delcare ObjectARenderable and make sure the constructor
// definition in the CPP file where ObjectARenderable gets included
class ObjectARenderable;

class ObjectA : public GameObject {
public:
    ObjectA()
        : mRenderable(new ObjectARenderable(*this)) {}

    // All data/logic

    Renderable* getRenderable() {
        return mRenderable.get();
    }

protected:
    // boost or std shared_ptr to make sure that the renderable instance is
    // cleaned up with the destruction of this object.
    shared_ptr<Renderable> mRenderable;
};

ObjectARenderable clase:

#include "ObjectA.h"

class ObjectARenderable : public RenderableObject<ObjectA> {
public:
    ObjectARenderable(ObjectA& instance) {
        : RenderableObject<ObjectA>(instance) {}

protected:
    virtual void render(Renderer& renderer, T& object) {
        // gl_* class to render ObjectA
    }
};
Vite Falcon
fuente
4

Construye un sistema de renderizado-comando. Un objeto de alto nivel, que tiene acceso tanto a OpenGLRendererla escena como a los objetos de juego / gráfico, iterará el gráfico de escena o los objetos de juego y creará un lote de RenderCmds, que luego se enviará al OpenGLRendererque dibujará cada uno a su vez y, por lo tanto, contendrá todo OpenGL código relacionado en el mismo.

Hay más ventajas en esto que solo la abstracción; eventualmente, a medida que crece su complejidad de renderizado, puede ordenar y agrupar cada comando de renderizado por textura o sombreador, por ejemplo, Render()para eliminar muchos cuellos de botella en las llamadas de dibujo que pueden marcar una gran diferencia en el rendimiento.

class OpenGLRenderer
{
public:
    typedef GLuint GeometryBuffer;
    typedef GLuint TextureID;
    typedef std::vector<RenderCmd> RenderBatch; 

    void Render(const RenderBatch& renderBatch);   // set shaders, set active textures, draw geometry, ...

    MeshID CreateGeometryBuffer(...);
    TextureID CreateTexture(...);

    // ....
}

struct RenderCmd
{
    GeometryBuffer mGeometryBuffer;
    TextureID mTexture;
    Mat4& mWorldMatrix;
    bool mLightingEnabled;
    // .....
}

std::vector<GameObject> gYourGameObjects;
RenderBatch BuildRenderBatch()
{
    RenderBatch ret;

    for (GameObject& object : gYourGameObjects)
    { 
        // ....
    }

    return ret;
}
KaiserJohaan
fuente
3

Depende completamente de si puedes hacer suposiciones sobre lo que es común para todas las entidades renderizables o no. En mi motor, todos los objetos se representan de la misma manera, por lo que solo necesito proporcionar vbos, texturas y transformaciones. Luego, el renderizador los busca a todos, por lo que no se necesitan llamadas a funciones OpenGL en los diferentes objetos.

danijar
fuente
1
clima = lluvia, sol, calor, frío: P -> si
Tobias Kienzler
3
@TobiasKienzler Si va a corregir su ortografía, intente deletrear si es correcto :-)
TASagent
@TASagent What, y romper la Ley de Muphry ? m- /
Tobias Kienzler
1
corregido ese error tipográfico
danijar
2

Definitivamente ponga el código de representación y la lógica del juego en diferentes clases. La composición (como sugirió el teodron) es probablemente la mejor manera de hacer esto; Cada entidad en el mundo del juego tendrá su propio Renderable, o tal vez un conjunto de ellos.

Es posible que aún tenga varias subclases de Renderable, por ejemplo, para manejar animación esquelética, emisores de partículas y sombreadores complejos, además de su sombreador básico con textura e iluminación. La clase Renderable y sus subclases solo deben contener la información necesaria para el renderizado: geometría, texturas y sombreadores.

Además, debe separar una instancia de una malla dada de la malla misma. Digamos que tienes cien árboles en la pantalla, cada uno con la misma malla. Solo desea almacenar la geometría una vez, pero necesitará matrices separadas de ubicación y rotación para cada árbol. Los objetos más complejos, como los humanoides animados, también tendrán información de estado adicional (como un esqueleto, el conjunto de animaciones aplicadas actualmente, etc.).

Para renderizar, el enfoque ingenuo es iterar sobre cada entidad del juego y decirle que se procese solo. Alternativamente, cada entidad (cuando se genera) puede insertar sus objetos renderizables en un objeto de escena. Luego, su función de renderizado le dice a la escena que renderice. Esto permite que la escena haga cosas complejas relacionadas con el renderizado sin incrustar ese código en entidades del juego o en una subclase renderizable específica.

AndrewS
fuente
2

Este consejo no es realmente específico para el renderizado, pero debería ayudar a crear un sistema que mantenga las cosas en gran medida separadas. En primer lugar, intente mantener los datos de 'GameObject' separados de la información de posición.

Vale la pena señalar que la simple información posicional de XYZ podría no ser tan simple. Si está utilizando un motor de física, los datos de posición podrían almacenarse en el motor de terceros. Debería sincronizar entre ellos (lo que implicaría una gran cantidad de copia de memoria sin sentido) o consultar la información directamente desde el motor. Pero no todos los objetos necesitan física, algunos se fijarán en su lugar, por lo que un conjunto simple de flotadores funciona bien allí. Algunos incluso pueden estar unidos a otros objetos, por lo que su posición es en realidad un desplazamiento de otra posición. En una configuración avanzada, es posible que tenga una posición almacenada solo en la GPU, la única vez que se necesitaría el lado de la computadora es para secuencias de comandos, almacenamiento y replicación de red. Por lo tanto, es probable que tenga varias opciones posibles para sus datos posicionales. Aquí tiene sentido usar la herencia.

En lugar de un objeto que posee su posición, ese objeto debería ser propiedad de una estructura de datos de indexación. Por ejemplo, un 'Nivel' podría tener un Octree, o tal vez una 'escena' de motor de física. Cuando desea renderizar (o configurar una escena de renderizado), consulta su estructura especial para los objetos que son visibles para la cámara.

Esto también ayuda a administrar bien la memoria. De esta manera, un objeto que no está realmente en un área ni siquiera tiene una posición que tenga sentido en lugar de devolver 0.0 coords o los coords que tenía cuando era el último en un área.

Si ya no mantiene las coordenadas en el objeto, en lugar de object.getX () terminaría teniendo level.getX (objeto). El problema con eso es buscar el objeto en el nivel probablemente será una operación lenta ya que tendrá que mirar a través de todos sus objetos y coincidir con el que está consultando.

Para evitar eso, probablemente crearía una clase especial de 'enlace'. Uno que se une entre un nivel y un objeto. Yo lo llamo una "ubicación". Esto contendría las coordenadas xyz, así como el controlador del nivel y el controlador del objeto. Esta clase de enlace se almacenaría en la estructura / nivel espacial y el objeto tendría una referencia débil (si el nivel / ubicación se destruye, la referencia de los objetos debe actualizarse a nulo). También podría valer la pena tener la clase Ubicación 'posee' el objeto, de esa manera si se elimina un nivel, también lo es la estructura de índice especial, las ubicaciones que contiene y sus objetos.

typedef std::tuple<Level, Object, PositionXYZ> Location;

Ahora la información de posición se almacena solo en un lugar. No se duplica entre el objeto, la estructura de indexación espacial, el renderizador, etc.

Las estructuras de datos espaciales como los octrees a menudo ni siquiera necesitan tener las coordenadas de los objetos que almacenan. Su posición se almacena en la ubicación relativa de los nodos en la estructura misma (podría considerarse como una especie de compresión con pérdida, sacrificando la precisión para tiempos de búsqueda rápidos). Con el objeto de ubicación en Octree, las coordenadas reales se encuentran dentro de él una vez que se realiza la consulta.

O si está utilizando un motor de física para administrar las ubicaciones de sus objetos o una mezcla entre los dos, la clase Ubicación debe manejarlo de manera transparente mientras mantiene todo su código en un solo lugar.

Otra ventaja es que ahora la posición y la referencia al nivel se almacenan en la misma ubicación. Puede implementar object.TeleportTo (other_object) y hacer que funcione en todos los niveles. Del mismo modo, la búsqueda de rutas de IA podría seguir algo en un área diferente.

Con respecto a la representación. Su render puede tener un enlace similar a la ubicación. Excepto que tendría el material específico de representación allí. Probablemente no necesite el 'Objeto' o 'Nivel' para ser almacenado en esta estructura. El Objeto podría ser útil si está tratando de hacer algo como elegir un color o representar una barra de golpe flotando sobre él, etc., pero de lo contrario, el renderizador solo se preocupa por la malla y demás. RenderableStuff sería una malla, también podría tener cuadros delimitadores, etc.

typedef std::pair<RenderableStuff, PositionXYZ> RenderThing;

renderer.render(level, camera);
renderer: object = level.getVisibleObjects(camera);
level: physics.getObjectsInArea(physics.getCameraFrustrum(camera));
for(object in objects) {
    //This could be depth sorted, meshes could be broken up and sorted by material for batch rendering or whatever
    rendering_que.addObjectToRender(object);
}

Es posible que no necesite hacer esto cada fotograma, puede asegurarse de tomar una región más grande que la que la cámara muestra actualmente. Cachéalo, rastrea los movimientos de objetos para ver si hay un cuadro delimitador dentro del rango, rastrea el movimiento de la cámara, etc. Pero no comiences a jugar con ese tipo de cosas hasta que lo hayas evaluado.

El motor de física en sí podría tener una abstracción similar, ya que tampoco necesita los datos del Objeto, solo la malla de colisión y las propiedades físicas.

Todos los datos del objeto central que contendrían serían el nombre de la malla que usa el objeto. El motor del juego puede continuar y cargar esto en el formato que desee sin cargar su clase de objeto con un montón de cosas específicas de renderizado (que pueden ser específicas de su API de renderizado, es decir, DirectX vs OpenGL).

También mantiene diferentes componentes separados. Esto hace que sea fácil hacer cosas como reemplazar su motor de física, ya que esas cosas son en su mayoría autónomas en una ubicación. También hace que las pruebas unitarias sean mucho más fáciles. Puede probar cosas como consultas de física sin tener que configurar ningún objeto falso real, ya que todo lo que necesita es la clase Ubicación. También puedes optimizar las cosas más fácilmente. Esto hace que sea más obvio qué consultas debe realizar en qué clases y ubicaciones individuales para optimizarlas (por ejemplo, el nivel anterior. GetVisibleObject sería donde podría almacenar en caché las cosas si la cámara no se mueve demasiado).

David C. Bishop
fuente