Trennung von Spieldaten / Logik vom Rendering

21

Ich schreibe ein Spiel mit C ++ und OpenGL 2.1. Ich überlegte, wie ich die Daten / Logik vom Rendern trennen könnte. Im Moment verwende ich eine Basisklasse 'Renderable', die eine rein virtuelle Methode zum Implementieren des Zeichnens bietet. Aber jedes Objekt hat so speziellen Code, dass nur das Objekt weiß, wie man Shader-Uniformen richtig einstellt und Vertex-Array-Pufferdaten organisiert. Ich habe am Ende eine Menge gl * -Funktionsaufrufe in meinem gesamten Code. Gibt es eine generische Möglichkeit, die Objekte zu zeichnen?

Felipe
quelle
4
Verwenden Sie die Komposition, um ein Rendering-Objekt an Ihr Objekt anzuhängen und Ihr Objekt mit diesem m_renderableMitglied interagieren zu lassen . Auf diese Weise können Sie Ihre Logik besser trennen. Erzwingen Sie die renderbare "Schnittstelle" nicht für allgemeine Objekte, die auch über Physik, AI und so weiter verfügen. Danach können Sie renderbare Objekte separat verwalten. Sie benötigen eine Abstraktionsebene über OpenGL-Funktionsaufrufe, um die Dinge noch mehr zu entkoppeln. Erwarten Sie also nicht, dass eine gute Engine GL-API-Aufrufe in ihren verschiedenen darstellbaren Implementierungen enthält. Das war's in aller Kürze.
Teodron
1
@teodron: Warum hast du das nicht als Antwort gegeben?
Tapio
1
@ Tapio: weil es nicht so sehr eine Antwort ist; Es ist eher ein Vorschlag.
Teodron

Antworten:

20

Eine Idee ist, das Besucher-Entwurfsmuster zu verwenden. Sie benötigen eine Renderer-Implementierung, die es versteht, Requisiten zu rendern. Jedes Objekt kann die Renderer-Instanz aufrufen, um den Render-Job zu verarbeiten.

In ein paar Zeilen 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 ) };
}

Das gl * Zeug wird von den Methoden des Renderers implementiert, und die Objekte speichern nur die Daten, die zum Rendern benötigt werden, Position, Texturtyp, Größe usw.

Sie können auch verschiedene Renderer (debugRenderer, hqRenderer, ... usw.) einrichten und diese dynamisch verwenden, ohne die Objekte zu ändern.

Dies kann auch einfach mit Entity / Component-Systemen kombiniert werden.

Zhen
quelle
1
Das ist eine ziemlich gute Antwort! Sie hätten die Entity/ComponentAlternative etwas mehr betonen können, da dies dazu beitragen kann, Geometrieanbieter von anderen Motorteilen (KI, Physik, Netzwerk oder allgemeines Gameplay) zu trennen. +1!
Teodron
1
@teodron, ich werde die E / C-Alternative nicht erklären, weil sie die Dinge komplizieren würde. Aber ich denke, dass Sie ändern ObjectAund ObjectBpro DrawableComponentAund DrawableComponentBin Render-Methoden, verwenden Sie andere Komponenten, wenn Sie es brauchen, wie: position = component->getComponent("Position");Und in der Hauptschleife haben Sie eine Liste der zeichnungsfähigen Komponenten, mit denen Sie zeichnen können.
Zhen
Warum nicht einfach eine Schnittstelle (wie Renderable) mit einer draw(Renderer&)Funktion haben und alle Objekte, die gerendert werden können, diese implementieren? In welchem ​​Fall wird Renderernur eine Funktion benötigt, die ein Objekt akzeptiert, das die gemeinsame Schnittstelle und den Aufruf implementiert renderable.draw(*this);?
Vite Falcon
1
@ViteFalcon, Entschuldigung, wenn ich mich nicht klar mache, aber für eine detaillierte Erklärung sollte ich mehr Speicherplatz und Code benötigen. Grundsätzlich verschiebt meine Lösung die gl_*Funktionen in den Renderer (Trennung von Logik und Rendering), aber Ihre Lösung verschiebt die gl_*Aufrufe in die Objekte.
Zhen
Auf diese Weise werden die gl * -Funktionen zwar aus dem Objektcode verschoben, aber ich behalte immer noch die beim Rendern verwendeten Handle-Variablen, wie Puffer- / Textur-IDs, Uniform- / Attributpositionen.
Felipe
4

Ich weiß, dass Sie Zhens Antwort bereits akzeptiert haben, aber ich möchte noch eine herausbringen, nur für den Fall, dass es jemand anderem hilft.

Um das Problem zu wiederholen, möchte das OP, dass der Rendering-Code von der Logik und den Daten getrennt bleibt.

Meine Lösung besteht darin, eine andere Klasse zusammen zu verwenden, um die Komponente zu rendern, die von der Rendererund der Logikklasse getrennt ist. Zuerst muss es eine RenderableSchnittstelle geben, die eine Funktion hat, bool render(Renderer& renderer);und die RendererKlasse verwendet das Besuchermuster, um alle RenderableInstanzen abzurufen , wenn die Liste mit GameObjects angegeben ist, und rendert die Objekte, die eine RenderableInstanz haben. Auf diese Weise muss Renderer nicht jeden Objekttyp da draußen kennen und es liegt immer noch in der Verantwortung jedes Objekttyps, ihn Renderableüber die getRenderable()Funktion zu informieren . Alternativ können Sie eine RenderableVisitorKlasse erstellen , die alle GameObjects besucht und basierend auf den individuellen GameObjectBedingungen die Möglichkeit hat, dem Besucher das Rendering hinzuzufügen oder nicht hinzuzufügen. Wie auch immer, das Wichtigste ist, dass diegl_*Aufrufe befinden sich alle außerhalb des Objekts selbst und befinden sich in einer Klasse, die intime Details des Objekts selbst kennt, anstatt dass sie Teil dieses Objekts sind Renderer.

HAFTUNGSAUSSCHLUSS : Ich habe diese Klassen im Editor von Hand geschrieben, sodass die Wahrscheinlichkeit groß ist, dass ich etwas im Code verpasst habe, aber Sie werden hoffentlich auf die Idee kommen.

So zeigen Sie ein (Teil-) Beispiel:

Renderable Schnittstelle

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

GameObject Klasse:

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

(Teil-) RendererKlasse.

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

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

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

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

Erstellen Sie ein Renderbefehlssystem. Ein OpenGLRendererübergeordnetes Objekt, das sowohl auf das Szenegraphen- als auch auf das Spielobjekt zugreifen kann, iteriert den Szenegraphen oder die Spielobjekte und erstellt einen Stapel von Objekten, die dann der Reihe RenderCmdsnach an die OpenGLRendererjeweils Zeichnenden übergeben werden und dabei alle OpenGL- Objekte enthalten verwandter Code darin.

Das hat mehr Vorteile als nur Abstraktion; Mit zunehmender Komplexität beim Rendern können Sie jeden Renderbefehl nach Textur oder Shader sortieren und gruppieren, um beispielsweise Render()viele Engpässe bei den Zeichenaufrufen zu beseitigen, die einen großen Unterschied in der Leistung bewirken können.

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

Es hängt völlig davon ab, ob Sie Annahmen darüber treffen können, was für alle darstellbaren Entitäten gemeinsam ist oder nicht. In meiner Engine werden alle Objekte auf dieselbe Weise gerendert, sodass nur VBOS, Texturen und Transformationen bereitgestellt werden müssen. Dann holt der Renderer sie alle, so dass in den verschiedenen Objekten überhaupt keine OpenGL-Funktionsaufrufe erforderlich sind.

danijar
quelle
1
wetter = regen, sonne, heiss, kalt: P -> ob
Tobias Kienzler
3
@TobiasKienzler Wenn du seine Rechtschreibung korrigieren willst, versuche zu buchstabieren, ob das richtig ist :-)
TASagent
@TASagent Was, und Muphrys Gesetz bremsen ? m- /
Tobias Kienzler
1
korrigierte diesen Tippfehler
danijar
2

Stellen Sie Rendering-Code und Spielelogik auf jeden Fall in verschiedene Klassen. Die Komposition (wie von Teodron vorgeschlagen) ist wahrscheinlich der beste Weg, dies zu tun. Jede Entität in der Spielwelt hat ihre eigene Renderbarkeit - oder vielleicht eine Reihe davon.

Möglicherweise verfügen Sie noch über mehrere Unterklassen von Renderable, z. B. für Skelettanimationen, Partikelemitter und komplexe Shader, zusätzlich zu Ihrem grundlegenden strukturierten und beleuchteten Shader. Die Renderable-Klasse und ihre Unterklassen sollten nur Informationen enthalten, die zum Rendern benötigt werden: Geometrie, Texturen und Shader.

Außerdem sollten Sie eine Instanz eines bestimmten Netzes vom Netz selbst trennen. Angenommen, Sie haben hundert Bäume auf dem Bildschirm, von denen jeder dasselbe Netz verwendet. Sie möchten die Geometrie nur einmal speichern, benötigen jedoch separate Positions- und Rotationsmatrizen für jeden Baum. Komplexere Objekte, z. B. animierte Humanoide, verfügen auch über zusätzliche Statusinformationen (z. B. ein Skelett, die aktuell angewendeten Animationen usw.).

Beim Rendern besteht der naive Ansatz darin, jedes Spielelement zu durchlaufen und es anzuweisen, sich selbst zu rendern. Alternativ kann jede Entität (wenn sie erzeugt wird) ihre darstellbaren Objekte in ein Szenenobjekt einfügen. Anschließend weist Ihre Renderfunktion die Szene an, zu rendern. Auf diese Weise kann die Szene komplexe renderbezogene Aufgaben ausführen, ohne den Code in Spielelemente oder eine bestimmte renderbare Unterklasse einzubetten.

AndrewS
quelle
2

Dieser Rat ist nicht wirklich spezifisch für das Rendern, sollte aber helfen, ein System zu entwickeln, das die Dinge weitgehend getrennt hält. Versuchen Sie zunächst, die 'GameObject'-Daten von den Positionsinformationen zu trennen.

Es ist erwähnenswert, dass einfache XYZ-Positionsinformationen möglicherweise nicht so einfach sind. Wenn Sie eine Physik-Engine verwenden, können Ihre Positionsdaten in der Engine eines Drittanbieters gespeichert werden. Sie müssten entweder eine Synchronisierung zwischen ihnen durchführen (was viel sinnloses Kopieren des Speichers bedeuten würde) oder die Informationen direkt von der Engine abfragen. Aber nicht alle Objekte benötigen Physik, einige werden an Ort und Stelle fixiert, so dass ein einfacher Satz von Schwimmern dort gut funktioniert. Einige können sogar an andere Objekte angehängt sein, sodass ihre Position tatsächlich ein Versatz von einer anderen Position ist. In einer erweiterten Konfiguration ist die Position möglicherweise nur auf der GPU gespeichert. Dies ist auf der Computerseite nur für die Skripterstellung, Speicherung und Netzwerkreplikation erforderlich. Sie haben also wahrscheinlich mehrere Möglichkeiten für Ihre Positionsdaten. Hier ist es sinnvoll, Vererbung zu verwenden.

Anstelle eines Objekts, dessen Position es besitzt, sollte dieses Objekt selbst einer Indexdatenstruktur gehören. Zum Beispiel kann ein Level eine Octree-Szene oder eine Physik-Engine-Szene haben. Wenn Sie eine Renderszene rendern (oder einrichten) möchten, fragen Sie Ihre spezielle Struktur nach Objekten ab, die für die Kamera sichtbar sind.

Dies trägt auch zu einer guten Speicherverwaltung bei. Auf diese Weise hat ein Objekt, das sich nicht in einem Bereich befindet, nicht einmal eine sinnvolle Position, anstatt 0.0-Koordinaten oder die Koordinaten zurückzugeben, die es hatte, als es das letzte Mal in einem Bereich war.

Wenn Sie die Koordinaten nicht mehr im Objekt behalten, erhalten Sie anstelle von object.getX () level.getX (object). Das Problem dabei ist, dass das Objekt in der Ebene gesucht wird, wahrscheinlich ein langsamer Vorgang, da es alle Objekte durchsuchen und mit dem übereinstimmen muss, den Sie abfragen.

Um dies zu vermeiden, würde ich wahrscheinlich eine spezielle 'Link'-Klasse erstellen. Eine, die zwischen einer Ebene und einem Objekt bindet. Ich nenne es einen "Ort". Dies würde die xyz-Koordinaten sowie das Handle für die Ebene und ein Handle für das Objekt enthalten. Diese Verknüpfungsklasse würde in der räumlichen Struktur / Ebene gespeichert und das Objekt würde einen schwachen Verweis darauf haben (wenn die Ebene / Position zerstört wird, muss die Objektreferenz auf null aktualisiert werden. Es könnte sich auch lohnen, die Standortklasse tatsächlich zu haben "besitzen" Sie das Objekt, so dass, wenn eine Ebene gelöscht wird, auch die spezielle Indexstruktur, die darin enthaltenen Positionen und ihre Objekte.

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

Jetzt werden die Positionsinformationen nur noch an einem Ort gespeichert. Nicht dupliziert zwischen dem Objekt, der räumlichen Indexstruktur, dem Renderer usw.

Räumliche Datenstrukturen wie Octrees müssen oft nicht einmal die Koordinaten der Objekte haben, die sie speichern. Diese Position wird an der relativen Position der Knoten in der Struktur selbst gespeichert (dies könnte als eine Art verlustbehaftete Komprimierung angesehen werden, die Genauigkeit für schnelle Nachschlagezeiten opfert). Mit dem Standortobjekt im Octree werden dann die tatsächlichen Koordinaten darin gefunden, sobald die Abfrage abgeschlossen ist.

Oder wenn Sie eine Physik-Engine zum Verwalten Ihrer Objektpositionen oder einer Mischung aus beiden verwenden, sollte die Location-Klasse dies transparent handhaben und Ihren gesamten Code an einem Ort aufbewahren.

Ein weiterer Vorteil ist nun, dass die Position und die Referenz zum Level am selben Ort gespeichert werden. Sie können object.TeleportTo (other_object) implementieren und über mehrere Ebenen hinweg ausführen. In ähnlicher Weise könnte die KI-Wegfindung etwas in einen anderen Bereich verfolgen.

In Bezug auf das Rendern. Ihr Render kann eine ähnliche Bindung zum Ort haben. Außer, dass es das Rendering-spezifische Zeug gibt. Wahrscheinlich müssen Sie das Objekt oder die Ebene nicht in dieser Struktur speichern. Das Objekt kann nützlich sein, wenn Sie versuchen, etwas wie eine Farbauswahl vorzunehmen oder eine darüber schwebende Trefferleiste usw. zu rendern. Andernfalls kümmert sich der Renderer nur um das Netz und dergleichen. RenderableStuff wäre ein Mesh, könnte auch Rahmen haben und so weiter.

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

Möglicherweise müssen Sie dies nicht für jedes Bild einzeln durchführen. Sie können auch sicherstellen, dass Sie einen größeren Bereich aufnehmen, als von der Kamera aktuell angezeigt wird. Zwischenspeichern, Objektbewegungen verfolgen, um festzustellen, ob der Begrenzungsrahmen in Reichweite ist, Kamerabewegung verfolgen usw. Aber fangen Sie erst an, mit solchen Dingen herumzuspielen, wenn Sie sie getestet haben.

Ihre Physik-Engine selbst verfügt möglicherweise über eine ähnliche Abstraktion, da sie nicht nur die Kollisionsgitter- und Physik-Eigenschaften, sondern auch die Objektdaten benötigt.

Alle Ihre Kernobjektdaten enthalten würde, wäre der Name des Netzes, das das Objekt verwendet. Die Spiel-Engine kann diese dann in jedem beliebigen Format laden, ohne Ihre Objektklasse mit einer Reihe von renderspezifischen Dingen zu belasten (die möglicherweise für Ihre Rendering-API spezifisch sind, z. B. DirectX vs OpenGL).

Es hält auch verschiedene Komponenten getrennt. Dies macht es einfach, Dinge wie das Ersetzen Ihrer Physik-Engine zu tun, da dieses Zeug meist in sich geschlossen an einem Ort ist. Es macht auch das Testen viel einfacher. Sie können Dinge wie physikalische Abfragen testen, ohne dass Sie tatsächlich gefälschte Objekte einrichten müssen, da Sie lediglich die Location-Klasse benötigen. Sie können auch Dinge einfacher optimieren. Es wird klarer, welche Abfragen Sie für welche Klassen und einzelnen Standorte ausführen müssen, um sie zu optimieren (z. B. in der obigen Ebene. GetVisibleObject können Sie Dinge zwischenspeichern, wenn sich die Kamera nicht zu stark bewegt).

David C. Bishop
quelle