1
\$\begingroup\$

I'm working on a game engine as a fun project over the spring+summer. I've decided that to allow myself to explore rendering APIs I'm unfamiliar with, and force myself to write reusable code, I'd separate my renderer from the main project as a library - or multiple libraries - and attempt to support multiple rendering APIs.

I started by making a library, sg, which served as a simple way for me to represent models - it provides simple concepts like a "Node", "Mesh", "Image", and "Light" in such a way that data can be easily fed into buffers for use in renderers. (I use an Assimp-based preprocessing program to convert models and images in distinct formats into this single format.)

After this, I made sgrender, which provides this interface:

namespace sgrender
{
    /** Models, images, and other resources being managed by some particular renderer.
        Ideally, all the models and images for a scene are loaded by the engine at the
        start of the scene and fed into ModelSet, so that each rendering API can use it
        to allocate buffers and store everything in an optimal format. */
    class ModelSet
    {
    public:
        virtual ~ModelSet() = default;
    };

    /** Generic renderer interface. */
    class Renderer
    {
    public:
        /** Get any SDL_Window flags that the engine should pass for this renderer to work,
            e.g. SDL_WINDOW_VULKAN, SDL_WINDOW_OPENGL, etc. The OpenGL renderer also takes
            this opportunity to call SDL_GL_SetAttribute. */
        virtual SDL_WindowFlags getWindowFlags() = 0;

        /** Initialize the rendering backend, targeting the given SDL_Window to draw to. */
        virtual void useWindow(SDL_Window *window) = 0;

        /** Clean up! */
        virtual ~Renderer() = default;

        /** Begin the process of drawing - after this point, drawModel can be called.
            In the Vulkan renderer, this is where a target image and
            command buffer are selected for the next frame, and basic
            setup (begin render pass) is written into the buffer.
            In the OpenGL renderer, this is basically a glorified glClear*/
        virtual void beginDraw() = 0;

        /** End the process of drawing and display the frame. */
        virtual void endDraw() = 0;

        /** @brief Create a renderer-specific ModelSet from a set of SG models and
            all the images they use (mapped by name.) Each Model passed into the ModelSet
            can be referenced for drawing by whichever index it occupied when passed in. */
        virtual std::shared_ptr<ModelSet> makeModelSet(const std::vector<sg::Model> &models, const std::map<std::string, sg::Image>& images) = 0;

        /** @brief Draw a model w/ the given transform - ensure beginDraw() was called first!
            Ideally, all drawModel calls for some particular modelSet are grouped back-to-back. */
        virtual void drawModel(ModelSet *modelSet, size_t model, glm::mat4 transform) = 0;
    };
}

This is implemented by two render backends, sgvk and sggl, which both have their own concept of a ModelSet that keeps track of things like VAOs and textures. I figured this method of bulk-allocating everything needed for a particular scene gave the opportunity to allow my rendering backends to make optimizations specific to the renderer, such as packing all textures into a texture array (Vulkan) and creating shared vertex and index buffers so that I can draw particular models with just some integer index.

The trouble is, this seems to be too high-level of a renderer - I'm running into quite a bit of duplicate code between the OpenGL and Vulkan renderers I've created so far, specifically around the time when I create a particular ModelSet, and around the time when I draw.

But I'm also not sure how I could make a lower-level abstraction that allows all the explicit setup (e.g. pipeline layouts, memory allocation, descriptor set layouts) used for Vulkan while not being overly specific to one API.

One big thing right now is shaders - at the moment, I'm just working with a single shader pair that draws an object in 3D space with some particular projection, and applies a Diffuse texture and simple lighting... but when I get into my goal of deferred, PBR rendering later, I might want to allow specific models to use a custom shader (...and create a whole new pipeline on Vulkan? How would I handle re-creating it on resize if it's not managed by the renderer? And switching pipelines for one object sounds expensive...)

I've looked at other abstract renderers, such as the one used for DOOM 3 (via this interesting blog series), and they seem to have much lower-level abstractions than mine - but I'm finding it hard to understand how they managed that, especially when it comes to APIs like Vulkan where you have to do a lot of initialization before you can freely tell your renderer, "Draw this!."

\$\endgroup\$
3
  • \$\begingroup\$ Have you checked out open-source renderer abstractions on GitHub, such as bgfx, and how they implement the abstractions they provide? \$\endgroup\$
    – user35344
    May 7, 2021 at 19:55
  • \$\begingroup\$ I've looked at tools like that a bit, but I generally honestly find it pretty hard to understand a massive project someone else wrote, because every time I see something I want to go look at it and figure out how it works to get the whole picture and get sidetracked... \$\endgroup\$ May 7, 2021 at 20:39
  • \$\begingroup\$ Sebastian Aaltonen has been sharing a lot of information about his work on a new cross-platform renderer for HypeHype — both in Twitter posts and conference talks — which may have some useful ideas. \$\endgroup\$
    – DMGregory
    Jun 15, 2023 at 10:55

1 Answer 1

0
\$\begingroup\$

Old question, and hey if OP wants time chime in how it worked out (or not), go for it!

My own experience is doing exactly that, what you are describing for like 10 years in the industry.

I am familiar with vulkan but haven't personally had a project to use it. But. I've seen games coded against gl style calls, directx direct c++ calls, and switch or ps4 sdk calls.

Now in theory, if I was building this from scratch: I'd try using the most constraining of the supported platforms, and use it as the common API.

That means your going to be managing your own stateblocks. If that stateblock gets interpreted as 6 differed gl calls, that's fine, but you already have a stateblock to change just the things you needed to, rather than if you coded against gl directly you'd have an entire different codebase to write!

\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .