Oddzielanie danych / logiki gry od renderowania

21

Piszę grę przy użyciu C ++ i OpenGL 2.1. Zastanawiałem się, jak oddzielić dane / logikę od renderowania. W tej chwili używam klasy bazowej „Renderable”, która daje czystą wirtualną metodę implementacji rysunku. Ale każdy obiekt ma tak wyspecjalizowany kod, tylko obiekt wie, jak prawidłowo ustawić mundury modułu cieniującego i organizować dane bufora tablicy wierzchołków. W moim kodzie pojawia się wiele wywołań funkcji gl *. Czy istnieje jakiś ogólny sposób rysowania obiektów?

Felipe
źródło
4
Użyj kompozycji, aby faktycznie dołączyć renderowany obiekt do swojego obiektu i pozwolić twojemu obiektowi na interakcję z tym elementem m_renderable. W ten sposób możesz lepiej oddzielić swoją logikę. Nie wymuszaj renderowania „interfejsu” na obiektach ogólnych, które również mają fizykę, ai i tak dalej. Następnie możesz zarządzać renderowanymi osobno. Potrzebujesz warstwy abstrakcji nad wywołaniami funkcji OpenGL, aby jeszcze bardziej oddzielić rzeczy. Nie spodziewaj się więc, że dobry silnik będzie zawierał wywołania GL API w swoich różnych możliwych do renderowania implementacjach. To wszystko w mikro-pigułce.
teodron
1
@teodron: Dlaczego nie podałeś tego jako odpowiedzi?
Tapio
1
@ Tapio: ponieważ nie jest to zbyt duża odpowiedź; jest to raczej sugestia.
teodron

Odpowiedzi:

20

Pomysł polega na użyciu wzorca projektu Visitor. Potrzebujesz implementacji Renderer, która umie renderować rekwizyty. Każdy obiekt może wywołać instancję modułu renderującego w celu obsługi zadania renderowania.

W kilku wierszach pseudokodu:

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

Pliki gl * są implementowane metodami renderera, a obiekty przechowują tylko dane potrzebne do renderowania, pozycję, typ tekstury, rozmiar ... itd.

Ponadto można skonfigurować różne rendery (debugRenderer, hqRenderer, ... itd.) I używać ich dynamicznie, bez zmiany obiektów.

Można to również łatwo połączyć z systemami Entity / Component.

Zhen
źródło
1
To raczej dobra odpowiedź! Mógłbyś Entity/Componentnieco bardziej podkreślić alternatywę, ponieważ może ona pomóc w oddzieleniu dostawców geometrii od innych części silnika (AI, fizyka, sieć lub ogólna rozgrywka). +1!
teodron
1
@teodron, nie wyjaśnię alternatywy dla E / C, ponieważ to skomplikowałoby rzeczy. Ale myślę, że powinieneś zmienić ObjectAi ObjectBza pomocą DrawableComponentAoraz DrawableComponentB, i w wewnętrznych metodach renderowania, użyć innych komponentów, jeśli są potrzebne, takich jak: position = component->getComponent("Position");A w głównej pętli masz listę rysowalnych komponentów, z którymi można wywołać draw.
Zhen
Dlaczego nie mieć interfejsu (podobnego Renderable), który ma draw(Renderer&)funkcję i wszystkie obiekty, które można renderować, implementują je? W którym przypadku Rendererpotrzebna jest tylko jedna funkcja, która akceptuje dowolny obiekt implementujący wspólny interfejs i wywołanie renderable.draw(*this);?
Vite Falcon
1
@ViteFalcon, przepraszam, jeśli nie wyrażę się jasno, ale aby uzyskać szczegółowe wyjaśnienie, potrzebuję więcej miejsca i kodu. Zasadniczo moje rozwiązanie przenosi gl_*funkcje do renderera (oddzielając logikę od renderowania), ale twoje rozwiązanie przenosi gl_*wywołania do obiektów.
Zhen
W ten sposób funkcje gl * są rzeczywiście usuwane z kodu obiektowego, ale nadal trzymam zmienne uchwytu używane do renderowania, takie jak identyfikator bufora / identyfikatora tekstury, umiejscowienie munduru / atrybutu.
felipe
4

Wiem, że już zaakceptowałeś odpowiedź Zhena, ale chciałbym opublikować inną, na wypadek, gdyby pomógł komukolwiek innemu.

Aby powtórzyć problem, OP chce mieć możliwość oddzielenia kodu renderującego od logiki i danych.

Moim rozwiązaniem jest użycie innej klasy razem do renderowania komponentu, który jest oddzielny od Rendererklasy logicznej i. Najpierw musi istnieć Renderableinterfejs, który ma funkcję, bool render(Renderer& renderer);a Rendererklasa używa wzorca gościa do pobierania wszystkich Renderableinstancji, biorąc pod uwagę listę GameObjects i renderuje te obiekty, które mają Renderableinstancję. W ten sposób Renderer nie musi wiedzieć o każdym typie obiektu i nadal obowiązkiem każdego typu obiektu jest poinformowanie go Renderableza pomocą getRenderable()funkcji. Lub alternatywnie, możesz stworzyć RenderableVisitorklasę, która odwiedza wszystkie GameObjects i na podstawie indywidualnych GameObjectwarunków, które mogą wybrać, aby dodać / nie dodawać swojego renderowania do odwiedzającego. Tak czy inaczej, główną istotą jest to, żegl_*wszystkie wywołania znajdują się poza samym obiektem i znajdują się w klasie, która zna intymne szczegóły samego obiektu, a nie jego część Renderer.

ZASTRZEŻENIE : Ręcznie napisałem te klasy w edytorze, więc jest duża szansa, że ​​coś przeoczyłem w kodzie, ale mam nadzieję, że wpadniesz na pomysł.

Aby pokazać (częściowy) przykład:

Renderable berło

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

GameObject klasa:

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

(Częściowa) Rendererklasa.

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

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

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

#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
źródło
4

Zbuduj system renderowania-polecenia. Obiekt wysokiego poziomu, który ma dostęp zarówno do OpenGLRendererscenografu, jak i do obiektów gry, wykona iterację wykresu sceny lub obiektów gry i zbuduje partię RenderCmds, która zostanie następnie przekazana temu, OpenGLRendererktóry losuje kolejno, a tym samym zawierający wszystkie OpenGL powiązany kod w nim.

Ma to więcej zalet niż tylko abstrakcja; ostatecznie wraz ze wzrostem złożoności renderowania możesz sortować i grupować każde polecenie renderowania według tekstury lub modułu cieniującego, na przykład w Render()celu wyeliminowania wielu wąskich gardeł w wywołaniach losowania, które mogą mieć ogromną różnicę w wydajności.

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
źródło
3

Zależy to całkowicie od tego, czy możesz założyć, co jest wspólne dla wszystkich jednostek renderowalnych, czy nie. W moim silniku wszystkie obiekty są renderowane w ten sam sposób, więc wystarczy podać vbos, tekstury i transformacje. Następnie renderer pobiera je wszystkie, dlatego żadne wywołania funkcji OpenGL nie są potrzebne w różnych obiektach.

danijar
źródło
1
pogoda = deszcz, słońce, gorąco, zimno: P -> wilgotniej
Tobias Kienzler
3
@TobiasKienzler Jeśli chcesz poprawić jego pisownię, spróbuj przeliterować, czy poprawnie :-)
TASagent
@TASagent What i zahamować prawo Muphry'ego ? m- /
Tobias Kienzler
1
poprawiono tę literówkę
danijar
2

Zdecydowanie umieść kod renderujący i logikę gry w różnych klasach. Kompozycja (jak sugeruje teodron) jest prawdopodobnie najlepszym sposobem na zrobienie tego; każda Istota w świecie gry będzie miała swój Renderowalny - a może ich zestaw.

Nadal możesz mieć wiele podklas renderowalnych, na przykład do obsługi animacji szkieletowych, emiterów cząstek i złożonych shaderów, oprócz podstawowego shadera z teksturą i podświetleniem. Klasa Renderable i jej podklasy powinny zawierać tylko informacje potrzebne do renderowania: geometrię, tekstury i shadery.

Ponadto należy oddzielić wystąpienie danej siatki od samej siatki. Powiedzmy, że masz sto drzew na ekranie, z których każde używa tej samej siatki. Chcesz zapisać geometrię tylko raz, ale będziesz potrzebować osobnych macierzy lokalizacji i obrotu dla każdego drzewa. Bardziej złożone obiekty, takie jak animowane humanoidy, będą również miały dodatkowe informacje o stanie (takie jak szkielet, zestaw aktualnie stosowanych animacji itp.).

Naiwnym podejściem jest iteracja każdej jednostki gry i nakazanie jej renderowania. Alternatywnie, każdy byt (gdy się spawnuje) może wstawić swój renderowany obiekt (obiekty) do obiektu sceny. Następnie funkcja renderowania informuje scenę do renderowania. Umożliwia to scenie wykonywanie złożonych czynności związanych z renderowaniem bez osadzania tego kodu w jednostkach gry lub w określonej podklasie renderowanej.

AndrewS
źródło
2

Ta rada nie jest tak naprawdę specyficzna dla renderowania, ale powinna pomóc w opracowaniu systemu, który oddziela rzeczy w dużej mierze od siebie. Najpierw spróbuj oddzielić dane „GameObject” od informacji o pozycji.

Warto zauważyć, że proste informacje o położeniu XYZ mogą nie być takie proste. Jeśli używasz silnika fizyki, dane pozycji mogą być przechowywane w silniku innej firmy. Trzeba będzie albo zsynchronizować między nimi (co wymagałoby dużo bezcelowego kopiowania pamięci) lub zapytać o informacje bezpośrednio z silnika. Ale nie wszystkie obiekty wymagają fizyki, niektóre zostaną ustalone na miejscu, więc prosty zestaw pływaków działa tam dobrze. Niektóre mogą być nawet przymocowane do innych obiektów, więc ich pozycja jest w rzeczywistości przesunięciem innej pozycji. W konfiguracji zaawansowanej pozycja może być przechowywana tylko na GPU, jedynym potrzebnym czasem po stronie komputera jest skrypty, przechowywanie i replikacja sieci. Prawdopodobnie będziesz mieć kilka możliwych wyborów dla swoich danych pozycyjnych. Tutaj warto zastosować dziedziczenie.

Zamiast obiektu posiadającego swoją pozycję, obiekt ten powinien być własnością struktury danych indeksujących. Na przykład „Poziom” może mieć scenę Octree, a może scenę z silnikiem fizyki. Kiedy chcesz wyrenderować (lub skonfigurować scenę renderowania), zapytaj swoją specjalną strukturę o obiekty widoczne dla kamery.

Pomaga to również zapewnić dobre zarządzanie pamięcią. W ten sposób obiekt, który tak naprawdę nie znajduje się w obszarze, nie ma nawet pozycji, która ma sens, zamiast zwracania 0,0 coordów lub coordów, które miał, kiedy był ostatni w obszarze.

Jeśli nie będziesz już utrzymywał współrzędnych w obiekcie, zamiast object.getX () uzyskasz poziom.getX (obiekt). Problem z wyszukiwaniem obiektu na poziomie będzie prawdopodobnie powolną operacją, ponieważ będzie musiał przejrzeć wszystkie obiekty i dopasować do tego, którego dotyczy zapytanie.

Aby tego uniknąć, prawdopodobnie stworzyłbym specjalną klasę „link”. Taki, który łączy poziom z obiektem. Nazywam to „Lokalizacja”. Zawierałby współrzędne xyz, a także uchwyt do poziomu i uchwyt do obiektu. Ta klasa łączy byłaby przechowywana w strukturze / poziomie przestrzennym, a obiekt miałby do niej słabe odniesienie (jeśli poziom / lokalizacja zostanie zniszczony, odwołanie do obiektów musi zostać zaktualizowane do wartości zerowej. Może być również warte posiadania klasy Lokalizacja „posiadać” obiekt, w ten sposób, jeśli poziom zostanie usunięty, podobnie jak specjalna struktura indeksu, lokalizacje, które on zawiera i jego obiekty.

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

Teraz informacje o pozycji są przechowywane tylko w jednym miejscu. Nie powielane między obiektem, strukturą indeksowania Spacial, rendererem i tak dalej.

Przestrzenne struktury danych, takie jak Octrees, często nie muszą nawet mieć współrzędnych przechowywanych obiektów. Pozycja jest zapisywana we względnej lokalizacji węzłów w samej strukturze (można by ją traktować jako rodzaj kompresji stratnej, poświęcającej dokładność dla krótkich czasów wyszukiwania). W przypadku obiektu lokalizacji w Octree po zakończeniu zapytania znajdują się w nim rzeczywiste współrzędne.

Lub jeśli używasz silnika fizyki do zarządzania lokalizacjami twoich obiektów lub ich mieszanką, klasa Location powinna sobie z tym poradzić w sposób transparentny, zachowując cały kod w jednym miejscu.

Kolejną zaletą jest teraz pozycja, a odniesienie do poziomu jest przechowywane w tej samej lokalizacji. Możesz zaimplementować object.TeleportTo (other_object) i pozwolić mu działać na różnych poziomach. Podobnie wyszukiwanie ścieżki AI może podążać za czymś w innym obszarze.

W odniesieniu do renderowania. Twój rendering może mieć podobne powiązanie z lokalizacją. Tyle że miałby tam specyficzne renderowanie. Prawdopodobnie nie potrzebujesz „Object” ani „Level” do przechowywania w tej strukturze. Obiekt może być przydatny, jeśli próbujesz zrobić coś takiego jak wybieranie kolorów lub renderowanie nad nim pływającego paska itp., Ale w przeciwnym razie renderer dba tylko o siatkę i tym podobne. RenderableStuff byłby siatką, mógłby również zawierać ramki ograniczające i tak dalej.

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

Być może nie będziesz musiał tego robić w każdej klatce, możesz upewnić się, że wybierasz większy region niż aktualnie pokazuje kamera. Buforuj go, śledź ruchy obiektów, aby sprawdzić, czy pole ograniczające znajduje się w zasięgu, śledź ruch kamery i tak dalej. Ale nie zaczynaj grzebać z tego rodzaju rzeczami, dopóki nie przetestujesz tego.

Sam silnik fizyki może mieć podobną abstrakcję, ponieważ nie potrzebuje również danych obiektu, tylko siatkę kolizji i właściwości fizyki.

Wszystkie podstawowe dane obiektu zawierałyby nazwę siatki używanej przez obiekt. Silnik gry może następnie załadować go w dowolnym formacie bez obciążania klasy obiektów mnóstwem rzeczy specyficznych do renderowania (które mogą być specyficzne dla interfejsu API renderowania, tj. DirectX vs OpenGL).

Utrzymuje także oddzielne elementy. Ułatwia to takie czynności, jak wymiana silnika fizyki, ponieważ te rzeczy są głównie samowystarczalne w jednym miejscu. Ułatwia to także nieprzytomność. Możesz testować takie rzeczy, jak zapytania fizyki, bez konieczności konfigurowania fałszywych obiektów, ponieważ wszystko, czego potrzebujesz, to klasa Location. Możesz także łatwiej zoptymalizować rzeczy. Sprawia, że ​​bardziej oczywiste są zapytania, które należy wykonać na jakich klasach i pojedynczych lokalizacjach, aby je zoptymalizować (na przykład na powyższym poziomie .getVisibleObject to miejsce, w którym można buforować rzeczy, jeśli kamera nie porusza się zbyt wiele).

David C. Bishop
źródło