Séparation des données / logique du jeu du rendu

21

J'écris un jeu en utilisant C ++ et OpenGL 2.1. Je pensais comment pourrais-je séparer les données / la logique du rendu. Pour le moment, j'utilise une classe de base «Renderable» qui donne une méthode virtuelle pure pour implémenter le dessin. Mais chaque objet a un code si spécialisé, seul l'objet sait comment définir correctement les uniformes de shader et organiser les données de tampon de tableau de vertex. Je me retrouve avec beaucoup d'appels de fonction gl * partout dans mon code. Existe-t-il un moyen générique de dessiner les objets?

Felipe
la source
4
Utilisez la composition pour attacher un rendu à votre objet et faire interagir votre objet avec ce m_renderablemembre. De cette façon, vous pouvez mieux séparer votre logique. N'appliquez pas l '"interface" de rendu sur les objets généraux qui ont également la physique, l'intelligence artificielle et ainsi de suite. Après cela, vous pouvez gérer les rendus séparément. Vous avez besoin d'une couche d'abstraction sur les appels de fonction OpenGL afin de découpler encore plus les choses. Donc, ne vous attendez pas à ce qu'un bon moteur ait des appels à l'API GL dans ses différentes implémentations de rendu. C'est tout, en un mot.
teodron
1
@teodron: Pourquoi n'avez-vous pas mis cela comme réponse?
Tapio
1
@Tapio: parce que ce n'est pas vraiment une réponse; il s'agit plutôt d'une suggestion.
teodron

Réponses:

20

Une idée est d'utiliser le modèle de conception de visiteur. Vous avez besoin d'une implémentation de rendu qui sait rendre les accessoires. Chaque objet peut appeler l'instance de rendu pour gérer le travail de rendu.

En quelques lignes de pseudocode:

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

Le truc gl * est implémenté par les méthodes du moteur de rendu, et les objets ne stockent que les données à rendre, la position, le type de texture, la taille, etc.

De plus, vous pouvez configurer différents moteurs de rendu (debugRenderer, hqRenderer, ... etc) et les utiliser dynamiquement, sans changer les objets.

Cela peut également être facilement combiné avec les systèmes Entité / Composant.

Zhen
la source
1
C'est une assez bonne réponse! Vous auriez pu mettre l'accent sur l' Entity/Componentalternative un peu plus car elle peut aider à séparer les fournisseurs de géométrie des autres parties du moteur (IA, physique, réseau ou gameplay général). +1!
teodron
1
@teodron, je n'expliquerai pas l'alternative E / C car cela compliquerait les choses. Mais, je pense que vous devriez changer ObjectAet ObjectBselon DrawableComponentAet DrawableComponentB, et à l'intérieur des méthodes de rendu, utiliser d'autres composants si vous en avez besoin, comme: position = component->getComponent("Position");Et dans la boucle principale, vous avez une liste de composants dessinables avec lesquels appeler draw.
Zhen
Pourquoi ne pas simplement avoir une interface (comme Renderable) qui a une draw(Renderer&)fonction et tous les objets qui peuvent être rendus les implémentent? Dans ce cas, il Renderersuffit d'une fonction qui accepte tout objet qui implémente l'interface commune et appelle renderable.draw(*this);?
Vite Falcon
1
@ViteFalcon, Désolé si je ne suis pas clair, mais pour une explication détaillée, je devrais avoir besoin de plus d'espace et de code. Fondamentalement, ma solution déplace les gl_*fonctions dans le moteur de rendu (séparant la logique du rendu), mais votre solution déplace les gl_*appels dans les objets.
Zhen
De cette façon, les fonctions gl * sont en effet déplacées hors du code objet, mais je garde toujours les variables de poignée utilisées dans le rendu, comme les ID de tampon / texture, les emplacements uniformes / d'attributs.
felipe
4

Je sais que vous avez déjà accepté la réponse de Zhen, mais j'aimerais en mettre une autre au cas où cela aiderait quelqu'un d'autre.

Pour réitérer le problème, l'OP souhaite pouvoir garder le code de rendu séparé de la logique et des données.

Ma solution consiste à utiliser une classe différente tous ensemble pour rendre le composant, qui est séparé de la Rendereret de la classe logique. Il doit d'abord y avoir une Renderableinterface qui a une fonction bool render(Renderer& renderer);et la Rendererclasse utilise le modèle de visiteur pour récupérer toutes les Renderableinstances, étant donné la liste de GameObjects et rend les objets qui ont une Renderableinstance. De cette façon, Renderer n'a pas besoin de connaître chaque type d'objet et il est toujours de la responsabilité de chaque type d'objet de l'informer Renderablevia la getRenderable()fonction. Ou bien, vous pouvez créer une RenderableVisitorclasse qui visite tous les GameObjects et en fonction de la GameObjectcondition individuelle , ils peuvent choisir d'ajouter / de ne pas ajouter leur rendu au visiteur. Quoi qu'il en soit, l'essentiel est que legl_*les appels sont tous en dehors de l'objet lui-même et résident dans une classe qui connaît les détails intimes de l'objet lui-même, au lieu que cela fasse partie de Renderer.

AVERTISSEMENT : J'ai écrit ces classes à la main dans l'éditeur, donc il y a de fortes chances que j'ai raté quelque chose dans le code, mais j'espère que vous aurez l'idée.

Pour afficher un exemple (partiel):

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 (partielle) .

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
la source
4

Construisez un système de commande de rendu. Un objet de haut niveau, qui a accès à la fois au OpenGLRendereret aux scénarios / objets de jeu, itérera le graphique de la scène ou les objets de jeu et en construira un lot RenderCmds, qui sera ensuite soumis à celui OpenGLRendererqui les dessinera tour à tour, et contenant ainsi tous les OpenGL code connexe.

Il y a plus d'avantages à cela que la simple abstraction; éventuellement, à mesure que votre complexité de rendu augmente, vous pouvez trier et grouper chaque commande de rendu par texture ou shader, par exemple, Render()pour éliminer de nombreux goulots d'étranglement dans les appels de tirage, ce qui peut faire une énorme différence dans les performances.

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
la source
3

Cela dépend complètement si vous pouvez faire des hypothèses sur ce qui est commun à toutes les entités pouvant être rendues ou non. Dans mon moteur, tous les objets sont rendus de la même manière, il suffit donc de fournir des vbos, des textures et des transformations. Ensuite, le moteur de rendu les récupère tous, donc aucun appel de fonctions OpenGL n'est nécessaire dans les différents objets.

danijar
la source
1
météo = pluie, soleil, chaud, froid: P -> que ce soit
Tobias Kienzler
3
@TobiasKienzler Si vous voulez corriger son orthographe, essayez de l'orthographier correctement :-)
TASagent
@TASagent Quoi, et freiner la loi de Muphry ? m- /
Tobias Kienzler
1
corrigé cette faute de frappe
danijar
2

Mettez définitivement le code de rendu et la logique du jeu dans différentes classes. La composition (comme le suggère le teodron) est probablement la meilleure façon de le faire; chaque entité dans le monde du jeu aura son propre rendu - ou peut-être un ensemble d'entre eux.

Vous pouvez toujours avoir plusieurs sous-classes de rendu, par exemple pour gérer l'animation squelettique, les émetteurs de particules et les shaders complexes, en plus de votre shader de base texturé et éclairé. La classe Renderable et ses sous-classes ne doivent contenir que les informations nécessaires au rendu: géométrie, textures et shaders.

De plus, vous devez séparer une instance d'un maillage donné du maillage lui-même. Supposons que vous ayez une centaine d'arbres à l'écran, chacun utilisant le même maillage. Vous ne voulez stocker la géométrie qu'une seule fois, mais vous aurez besoin de matrices d'emplacement et de rotation distinctes pour chaque arbre. Les objets plus complexes, tels que les humanoïdes animés, auront également des informations d'état supplémentaires (comme un squelette, l'ensemble des animations actuellement appliquées, etc.).

Pour le rendu, l'approche naïve consiste à parcourir chaque entité de jeu et à lui dire de se rendre. Alternativement, chaque entité (lorsqu'elle apparaît) peut insérer son ou ses objets pouvant être rendus dans un objet de scène. Ensuite, votre fonction de rendu indique à la scène de rendre. Cela permet à la scène de faire des choses complexes liées au rendu sans incorporer ce code dans des entités de jeu ou dans une sous-classe de rendu spécifique.

AndrewS
la source
2

Ce conseil n'est pas vraiment spécifique au rendu mais devrait aider à trouver un système qui garde les choses largement séparées. Tout d'abord, essayez de séparer les données «GameObject» des informations de position.

Il convient de noter que les informations de position XYZ simples peuvent ne pas être aussi simples. Si vous utilisez un moteur physique, les données de position peuvent être stockées dans le moteur tiers. Vous devrez soit synchroniser entre eux (ce qui impliquerait beaucoup de copie de mémoire inutile) ou interroger les informations directement à partir du moteur. Mais tous les objets n'ont pas besoin de physique, certains seront fixés en place, donc un simple ensemble de flotteurs fonctionne bien là-bas. Certains peuvent même être attachés à d'autres objets, donc leur position est en fait un décalage d'une autre position. Dans une configuration avancée, il se peut que la position ne soit stockée que sur le GPU, la seule fois où l'ordinateur serait nécessaire pour les scripts, le stockage et la réplication réseau. Vous aurez donc probablement plusieurs choix possibles pour vos données de position. Ici, il est logique d'utiliser l'héritage.

Plutôt qu'un objet possédant sa position, cet objet doit lui-même appartenir à une structure de données d'indexation. Par exemple, un «niveau» peut avoir un octree, ou peut-être une «scène» de moteur physique. Lorsque vous souhaitez effectuer un rendu (ou configurer une scène de rendu), vous interrogez votre structure spéciale pour les objets visibles par la caméra.

Cela permet également une bonne gestion de la mémoire. De cette façon, un objet qui n'est pas réellement dans une zone n'a même pas une position qui a du sens plutôt que de renvoyer 0,0 cordes ou les cordes qu'il avait lors de son dernier passage dans une zone.

Si vous ne conservez plus les coordonnées dans l'objet, au lieu de object.getX (), vous finirez par avoir level.getX (object). Le problème avec cela recherche l'objet dans le niveau sera probablement une opération lente car il devra parcourir tous ses objets et correspondre à celui que vous interrogez.

Pour éviter cela, je créerais probablement une classe spéciale «lien». Celui qui lie entre un niveau et un objet. Je l'appelle un "emplacement". Cela contiendrait les coordonnées xyz ainsi que la poignée au niveau et une poignée à l'objet. Cette classe de liens serait stockée dans la structure / niveau spatial et l'objet aurait une faible référence à celui-ci (si le niveau / emplacement est détruit, la réfraction des objets doit être mise à jour à null. Il pourrait également être utile d'avoir la classe Location réellement «posséder» l'objet, de cette façon si un niveau est supprimé, il en est de même de la structure d'index spéciale, des emplacements qu'il contient et de ses objets.

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

Maintenant, les informations de position sont stockées uniquement au même endroit. Non dupliqué entre l'objet, la structure d'indexation spatiale, le rendu, etc.

Les structures de données spatiales comme Octrees n'ont souvent même pas besoin d'avoir les coordonnées des objets qu'elles stockent. Cette position est stockée à l'emplacement relatif des nœuds dans la structure elle-même (elle pourrait être considérée comme une sorte de compression avec perte, sacrifiant la précision pour des temps de recherche rapides). Avec l'objet de localisation dans l'octree, les coordonnées réelles sont trouvées à l'intérieur une fois la requête terminée.

Ou si vous utilisez un moteur physique pour gérer les emplacements de vos objets ou un mélange entre les deux, la classe Location doit gérer cela de manière transparente tout en conservant tout votre code au même endroit.

Un autre avantage est maintenant que la position et la réfraction au niveau sont stockées au même endroit. Vous pouvez implémenter object.TeleportTo (other_object) et le faire fonctionner sur plusieurs niveaux. De même, la recherche de chemin de l'IA pourrait suivre quelque chose dans une zone différente.

En ce qui concerne le rendu. Votre rendu peut avoir une liaison similaire à l'emplacement. Sauf qu'il y aurait du rendu spécifique là-dedans. Vous n'avez probablement pas besoin que l '«objet» ou le «niveau» soit stocké dans cette structure. L'objet peut être utile si vous essayez de faire quelque chose comme la sélection des couleurs ou le rendu d'une barre de frappe flottant au-dessus, etc., sinon le rendu ne se soucie que du maillage, etc. RenderableStuff serait un maillage, pourrait également avoir des boîtes englobantes et ainsi de suite.

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

Vous n'aurez peut-être pas besoin de le faire à chaque image, vous pouvez vous assurer de prendre une région plus grande que celle que la caméra montre actuellement. Mettez-le en cache, suivez les mouvements des objets pour voir si la boîte englobante se trouve à portée, suivez les mouvements de la caméra, etc. Mais ne commencez pas à jouer avec ce genre de choses avant de l'avoir comparé.

Votre moteur physique lui-même peut avoir une abstraction similaire, car il n'a pas non plus besoin des données d'objet, juste du maillage de collision et des propriétés physiques.

Toutes les données de votre objet principal contiendraient serait le nom du maillage utilisé par l'objet. Le moteur de jeu peut alors aller de l'avant et le charger dans le format qu'il souhaite sans surcharger votre classe d'objets avec un tas de choses spécifiques au rendu (qui pourraient être spécifiques à votre API de rendu, c'est-à-dire DirectX vs OpenGL).

Il garde également différents composants séparés. Cela permet de faire facilement des choses comme remplacer votre moteur physique, car ces éléments sont principalement autonomes au même endroit. Cela facilite également beaucoup le test. Vous pouvez tester des choses comme les requêtes physiques sans avoir à configurer de faux objets réels car tout ce dont vous avez besoin est la classe Location. Vous pouvez également optimiser les choses plus facilement. Cela rend plus évidentes les requêtes que vous devez effectuer sur les classes et les emplacements uniques pour les optimiser (par exemple, le level.getVisibleObject ci-dessus serait l'endroit où vous pourriez mettre en cache les choses si la caméra ne bouge pas trop).

David C. Bishop
la source