Separando dados / lógica do jogo da renderização

21

Estou escrevendo um jogo usando C ++ e OpenGL 2.1. Eu estava pensando em como eu poderia separar os dados / lógica da renderização. No momento, uso uma classe base 'Renderable' que fornece um método virtual puro para implementar o desenho. Mas todo objeto tem um código tão especializado que somente ele sabe como definir adequadamente os uniformes de shader e organizar os dados do buffer da matriz de vértices. Acabo com muitas chamadas de função gl * por todo o meu código. Existe alguma maneira genérica de desenhar os objetos?

felipe
fonte
4
Use a composição para realmente anexar uma renderização ao seu objeto e fazer com que ele interaja com esse m_renderablemembro. Dessa forma, você pode separar melhor sua lógica. Não imponha a "interface" renderizável em objetos gerais que também possuem física, ai e outros enfeites. Depois disso, você pode gerenciar os renderizáveis ​​separadamente. Você precisa de uma camada de abstração nas chamadas de função do OpenGL para dissociar ainda mais as coisas. Portanto, não espere que um bom mecanismo tenha chamadas de API GL em suas várias implementações renderizáveis. É isso, em poucas palavras.
Teodron #
1
@teodron: Por que você não colocou isso em resposta?
Tapio
1
@ Tapio: porque não é muita resposta; é mais uma sugestão.
Teodron #

Respostas:

20

Uma idéia é usar o padrão de design do Visitor. Você precisa de uma implementação do Renderer que saiba renderizar adereços. Todo objeto pode chamar a instância do renderizador para manipular o trabalho de renderização.

Em algumas linhas 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 ) };
}

O material gl * é implementado pelos métodos do renderizador, e os objetos armazenam apenas os dados necessários para renderização, posição, tipo de textura, tamanho ... etc.

Além disso, você pode configurar diferentes renderizadores (debugRenderer, hqRenderer, ... etc) e usá-los dinamicamente, sem alterar os objetos.

Isso também pode ser fácil combinado com os sistemas de entidade / componente.

Zhen
fonte
1
Esta é uma resposta bastante boa! Você poderia ter enfatizado Entity/Componentum pouco mais a alternativa, pois ela pode ajudar a separar os provedores de geometria de outras partes do motor (IA, Física, Redes ou jogabilidade geral). +1!
Teodron #
1
@teodron, não vou explicar a alternativa de E / C porque isso complica as coisas. Mas, eu acho que você deve mudar ObjectAe ObjectBpor DrawableComponentAe DrawableComponentB, dentro e tornar métodos, utilizar outros componentes, se você precisar dele, como: position = component->getComponent("Position");E no circuito principal, você tem uma lista de componentes drawable para chamar desenhar com.
Zhen
Por que não apenas ter uma interface (como Renderable) que possui uma draw(Renderer&)função e todos os objetos que podem ser renderizados os implementam? Nesse caso, Renderersó precisa de uma função que aceite qualquer objeto que implemente a interface e a chamada comuns renderable.draw(*this);?
Vite Falcon
1
@ViteFalcon, desculpe se não me deixar claro, mas para uma explicação detalhada, preciso de mais espaço e código. Basicamente, minha solução move as gl_*funções para o renderizador (separando a lógica da renderização), mas sua solução move as gl_*chamadas para os objetos.
Zhen
Dessa forma, as funções gl * são realmente movidas para fora do código do objeto, mas ainda mantenho as variáveis ​​de identificador usadas na renderização, como IDs de buffer / textura, localizações uniformes / de atributos.
Felipe #
4

Sei que você já aceitou a resposta de Zhen, mas gostaria de colocar outra por aí, caso isso ajude mais alguém.

Para reiterar o problema, o OP deseja manter o código de renderização separado da lógica e dos dados.

Minha solução é usar uma classe diferente todos juntos para renderizar o componente, que é separado da Rendererclasse e da lógica. Primeiro, é necessário haver uma Renderableinterface que tenha uma função bool render(Renderer& renderer);e a Rendererclasse use o padrão de visitante para recuperar todas as Renderableinstâncias, dada a lista de se GameObjectrenderiza os objetos que possuem uma Renderableinstância. Dessa forma, o Renderer não precisa saber de cada tipo de objeto existente e ainda é responsabilidade de cada tipo de objeto informá-lo por Renderablemeio da getRenderable()função. Ou, como alternativa, você pode criar uma RenderableVisitorclasse que visite todos os GameObjects e, com base nas GameObjectcondições individuais , eles podem optar por adicionar / não adicionar sua renderização ao visitante. De qualquer forma, o principal é que ogl_*todas as chamadas estão fora do próprio objeto e residem em uma classe que conhece detalhes íntimos do próprio objeto, em vez de fazer parte dele Renderer.

AVISO LEGAL : Eu escrevi essas classes manualmente no editor, então há uma boa chance de que eu tenha perdido algo no código, mas espero que você entenda.

Para mostrar um exemplo (parcial):

Renderable interface

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

GameObject classe:

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

RendererClasse (parcial) .

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 classe:

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 classe:

// 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 classe:

#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
fonte
4

Construa um sistema de comando de renderização. Um objeto de alto nível, que tem acesso aos OpenGLRendererobjetos de cenário e de jogo / game, iterará o gráfico da cena ou os objetos de jogo e criará um lote RenderCmds, que será submetido ao OpenGLRendererque irá desenhar cada um por sua vez e, portanto, contendo todo o OpenGL código relacionado.

Há mais vantagens nisso do que apenas abstração; eventualmente, à medida que sua complexidade de renderização aumenta, você pode classificar e agrupar cada comando de renderização por textura ou sombreador, por exemplo, Render()para eliminar muitos gargalos nas chamadas de desenho que podem fazer uma enorme diferença no desempenho.

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
fonte
3

Depende completamente se você pode fazer suposições sobre o que é comum para todas as entidades renderizáveis ​​ou não. No meu mecanismo, todos os objetos são renderizados da mesma maneira, portanto, basta fornecer vbos, texturas e transformações. Em seguida, o renderizador busca todos eles, portanto, nenhuma chamada de função do OpenGL é necessária nos diferentes objetos.

danijar
fonte
1
weather = chuva, sol, quente, frio: P -> tempo
Tobias Kienzler 02/10
3
@TobiasKienzler Se você estiver indo para corrigir a ortografia, tentar soletrar se corretamente :-)
TASagent
@ TASagent What, e frear a Lei de Muphry ? m- /
Tobias Kienzler
1
corrigido esse erro de digitação
danijar
2

Definitivamente coloque o código de renderização e a lógica do jogo em diferentes classes. A composição (como sugerido pelo teodron) é provavelmente a melhor maneira de fazer isso; cada Entidade no mundo do jogo terá seu próprio Renderable - ou talvez um conjunto deles.

Você ainda pode ter várias subclasses de Renderable, por exemplo, para lidar com animação esquelética, emissores de partículas e shaders complexos, além do shader básico de textura e iluminação. A classe Renderable e suas subclasses devem conter apenas as informações necessárias para a renderização: geometria, texturas e shaders.

Além disso, você deve separar uma instância de uma determinada malha da própria malha. Digamos que você tenha centenas de árvores na tela, cada uma usando a mesma malha. Você deseja armazenar a geometria apenas uma vez, mas precisará de matrizes de localização e rotação separadas para cada árvore. Objetos mais complexos, como humanóides animados, também terão informações adicionais sobre o estado (como um esqueleto, o conjunto de animações atualmente aplicadas etc.).

Para renderizar, a abordagem ingênua é iterar sobre todas as entidades do jogo e dizer a ela para se render. Como alternativa, cada entidade (quando gera) pode inserir seus objetos renderizáveis ​​em um objeto de cena. Então, sua função de renderização informa à cena para renderizar. Isso permite que a cena faça coisas complexas relacionadas à renderização sem incorporar esse código nas entidades do jogo ou em uma subclasse renderizável específica.

AndrewS
fonte
2

Esse conselho não é realmente específico da renderização, mas deve ajudar a criar um sistema que mantenha as coisas em grande parte separadas. Primeiro, tente manter os dados 'GameObject' separados das informações de posição.

Vale a pena notar que informações posicionais simples de XYZ podem não ser tão simples. Se você estiver usando um mecanismo de física, os dados de posição poderão ser armazenados no mecanismo de terceiros. Você precisaria sincronizar entre eles (o que envolveria muita cópia inútil da memória) ou consultar as informações diretamente do mecanismo. Mas nem todos os objetos precisam de física, alguns serão fixados no local, então um simples conjunto de carros alegóricos funciona bem lá. Alguns podem até ser anexados a outros objetos, portanto, sua posição é na verdade um deslocamento de outra posição. Em uma configuração avançada, você pode ter a posição armazenada apenas na GPU; o único momento em que seria necessário no lado do computador é para scripts, armazenamento e replicação de rede. Portanto, você provavelmente terá várias opções possíveis para seus dados posicionais. Aqui faz sentido usar herança.

Em vez de um objeto possuir sua posição, ele deve pertencer a uma estrutura de dados de indexação. Por exemplo, um 'Nível' pode ter um Octree, ou talvez uma 'cena' de um mecanismo de física. Quando você deseja renderizar (ou configurar uma cena de renderização), consulta sua estrutura especial em busca de objetos visíveis para a câmera.

Isso também ajuda a fornecer um bom gerenciamento de memória. Dessa forma, um objeto que não está realmente em uma área nem sequer tem uma posição que faça sentido, em vez de retornar 0,0 ou mais cordões que ele tinha na última vez em uma área.

Se você não mantiver mais as coordenadas no objeto, em vez de object.getX (), você acabará tendo level.getX (objeto). O problema com isso é procurar o objeto no nível provavelmente será uma operação lenta, pois terá que examinar todos os seus objetos e corresponder ao que você está consultando.

Para evitar isso, eu provavelmente criaria uma classe 'link' especial. Um que se liga entre um nível e um objeto. Eu chamo isso de "Localização". Isso conteria as coordenadas xyz, bem como a alça para o nível e uma alça para o objeto. Essa classe de link seria armazenada na estrutura / nível espacial e o objeto teria uma referência fraca a ela (se o nível / localização for destruído, a atualização dos objetos precisará ser atualizada para nula. Também pode valer a pena ter a classe Location realmente 'possua' o objeto, assim, se um nível for excluído, a estrutura especial do índice, os locais que ele contém e seus Objetos.

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

Agora, as informações de posição são armazenadas apenas em um único local. Não duplicado entre o objeto, a estrutura de indexação espacial, o renderizador e assim por diante.

Estruturas de dados espaciais como Octrees geralmente nem precisam ter as coordenadas dos objetos que armazenam. Essa posição é armazenada na localização relativa dos nós na própria estrutura (pode ser considerada como uma espécie de compactação com perdas, sacrificando a precisão por tempos de pesquisa rápidos). Com o objeto de localização no Octree, as coordenadas reais são encontradas dentro dele depois que a consulta é concluída.

Ou, se você estiver usando um mecanismo de física para gerenciar as localizações dos objetos ou uma mistura entre as duas, a classe Location deve lidar com isso de forma transparente, mantendo todo o seu código em um único local.

Outra vantagem é agora a posição e a referência ao nível são armazenadas no mesmo local. Você pode implementar object.TeleportTo (other_object) e fazê-lo funcionar em vários níveis. Da mesma forma, a descoberta de caminhos da IA ​​pode seguir algo em uma área diferente.

Com relação à renderização. Sua renderização pode ter uma ligação semelhante ao local. Exceto que teria o material específico de renderização lá. Você provavelmente não precisa que o 'Objeto' ou 'Nível' seja armazenado nessa estrutura. O objeto pode ser útil se você estiver tentando fazer algo como escolher cores ou renderizar uma barra de hit flutuando acima dele, mas, caso contrário, o renderizador se preocupa apenas com a malha e tal. RenderableStuff seria uma malha, também poderia ter caixas delimitadoras e assim por diante.

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

Talvez você não precise fazer isso em todos os quadros, pode garantir uma região maior do que a câmera atualmente mostra. Coloque em cache, rastreie os movimentos do objeto para ver se a caixa delimitadora está ao alcance, rastreie o movimento da câmera e assim por diante. Mas não comece a mexer com esse tipo de coisa até que você o compare.

O próprio mecanismo de física pode ter uma abstração semelhante, pois também não precisa dos dados do objeto, apenas da malha de colisão e das propriedades físicas.

Todos os dados do objeto principal conteriam o nome da malha que o objeto usa. O mecanismo de jogo pode então prosseguir e carregá-lo no formato que desejar, sem sobrecarregar sua classe de objeto com várias coisas específicas de renderização (que podem ser específicas à sua API de renderização, por exemplo, DirectX vs OpenGL).

Também mantém diferentes componentes separados. Isso facilita fazer coisas como substituir o seu mecanismo de física, já que essas coisas são praticamente independentes em um único local. Isso também facilita muito o desestímulo. Você pode testar itens como consultas de física sem ter que configurar objetos falsos reais, pois tudo o que você precisa é da classe Location. Você também pode otimizar as coisas mais facilmente. Isso torna mais óbvias as consultas que você precisa executar em quais classes e locais únicos para otimizá-las (por exemplo, o level.getVisibleObject acima seria o local onde você poderia armazenar em cache as coisas se a câmera não se mover muito).

David C. Bishop
fonte