DirectX 12 Renderer

Started by
6 comments, last by KielanT 6 days, 18 hours ago

Hi

I am creating a game engine for fun and learning. I have made an engine before with a DirectX 11 renderer. This time I want to implement a DirectX 12 renderer, I am using 3D game programming book with DirectX 12 by Frank Luna, to learn Dx12, I am trying to improve how rendering works. Currently my render works like this:

The model class which has the pos, rot and scale for the model and render data which contains mesh data and the transform matrix.

before the main game loop the models render data gets added to the renderer and stored into a vector.

then during the render loop it will render all the models using the vector.

The model owns the render data (using a pointer) and then the vector stores a pointer to the render data from the model, which allows me to change the transforms of the model.

Is this approach okay? it doesn't feel right, in my DirectX 11 renderer, the model class has the render code directly inside it but I want to make my Dx12 more abstract. (probably better to learn Dx12 before doing abstraction but where's the fun in that). Are there any good examples that can help me, most of the example I see only allow for the meshes to be created and passed to the renderer before the game loop

None

Advertisement

In my DX11 forward renderer (not a PBR renderer), meshes are responsible only for binding themselves to a shader and calling the drawIndexed function. Each mesh also stores its own transformation matrix. This transformation matrix never changes, as it is defined when the model file is created (i.e: it's an model transform created by blender, not actually a world transform)

class Mesh
{
public:
    void draw() const;
private:
    XMMATRIX m_modelTransform;
    
    // vertex buffer and index buffer also goes here
}

void Mesh::draw() const
{
    UINT stride = sizeof(Vertex);
    UINT offset = 0;

    // DX11 Context
    auto context = WND->getContext();

    // Bind vertex and index buffers to pipeline
    context->IASetVertexBuffers(0, 1, m_vertexBuffer.GetAddressOf(), &stride, &offset);
    context->IASetIndexBuffer(m_indexBuffer.Get(), DXGI_FORMAT_R32_UINT, 0);
    context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
    
    // Draw
    context->DrawIndexed(m_indexCount, 0, 0);
}

Meshes exist under a “MeshView” class:

class MeshView
{
public:
    // ...
private:
    Material const* m_material;
    Mesh const* m_mesh;
}

the material class contains a set of textures: albedo and roughness, as well as a metallic and roughness factor.

Since you can actually have a hierarchy of meshes in glTF, My “Model” class is effectively a list of MeshViews:

class Model
{
public:
    //...
private:
    vector<MeshView> const m_meshViews
}

The model class draw method takes a shader and world transform matrix, both arguments are required:

void Model::draw(
    Shader const& shader,
    XMMATRIX const& transform
) const
{
    // shader.use() calls VSSetShader and PSSetShader
    shader.use();
    
    for (MeshView const& vm : *m_viewModels)
    {
        vm.draw(shader, transform);
    }
}

The meshview draw method binds its material to the shader, multiplies the model's world transform matrix by the mesh's model transform matrix, and then draws the mesh:

void MeshView::draw(
    Shader const& shader,
    XMMATRIX const& transform
) const
{
    // bind albedo and roughness textures to shader
    if (m_material)
    {
        m_material->use(shader);
    }

    // the model buffer is a cbuffer that exists on every shader at register(b0)
    shader.bindModelBuffer(m_mesh->getModelTransform() * transform);
    
    m_mesh->draw();
}

Every shader requires this data:

  • A mesh (i.e: a vertex buffer and index buffer)
  • An albedo texture (and an optional roughness material) at register(t0)
  • A model buffer at register(b0)

Other shaders have other optional cbuffers. The logic of how to bind data to these is handled in “DrawableMixins". Every Object in my game has a pointer to an IDrawableMixin, which is an object that defines a single draw method:

class IDrawableMixin
{
public:
    virtual void draw(
        IWorldObject const* object,
        Shader const* shader = nullptr
    ) const = 0;
};

Where IWorldObject is the base class for all entities in my game. Each IDrawableMixin defines different behavior, but are still mostly the same. I have a “WorldShader”, which is a shader that performs a simple 3D perspective transform and textures my objects. This has an equivalent “WorldObjectDrawable”

void WorldObjectDrawable::draw(
    IWorldObject const* object,
    Shader const* shader // this argument allows the world object to override the specific shader used here
) const
{
    static Shader const* s_shader = ASSET->getShader("World"); // get the World Shader
    if (!shader)  // allows the object to override the shader used here
    {
        shader = s_shader;
    }
   
    auto model = object->getModel(); // worldObjects store a model
    if (model)
    {
        model->draw(*shader, object->getTransform());
    }
}

This is a very simple shader; it simply calls the model draw methods described above using the World shader.

For a more complicated example, I have the “UI shader”, which allows me to draw flat images on the User interface that also support sprite-based animations:

/* This is called "WeaponDrawable" because I use flat sprites like in
 * Doom or Wolfenstein 3D to draw the weapon the player is using
 */
void WeaponDrawable::draw(
    IWorldObject const* object,
    Shader const* shader
) const
{
    static Shader const* s_shader = ASSET->getShader("UI");
    
    /* The quad mesh is a simple 2D box. It requires no material properties
     * and has no other meshes in its hierarchy. All it requires is an albedo
     * texture to draw on it
     */
    static Mesh const* s_quad = ASSET->getQuad();
    
    /* WeaponDrawable requires the IWorldObject that calls it to be a child class
     * called IWeapon. Dynamic casts are slower than static casts so maybe don't do this
     */
    IWeapon const* weapon = dynamic_cast<IWeapon const*>(object);
    if (weapon == nullptr)
    {
        return;
    }

    /* This next section is about setting the UV coordinates on the quad so that it renders
     * a subsection of the animation sprite sheet */
    auto animation = weapon->getAnimation();
    SpriteAnimationBuffer buffer;
    buffer.m_src = XMFLOAT4(
        (animation->getFrameWidth() / (f32)texture->getWidth()) * (f32)animation->getFrame(),
        animation->getFrameHeight() / (f32)texture->getHeight() * animation->getRowNumber(),
        (animation->getFrameWidth() / (f32)texture->getWidth()),
        animation->getFrameHeight() / (f32)texture->getHeight()
    );
    XMStoreFloat4(&buffer.m_dest, weapon->getPos());

    /* This is a fun little animation that makes the gun bob back and forth as you walk
     */
    f32 x = cosf(weapon->getTheta() / 150.0f);
    f32 y = (sinf(weapon->getTheta() / 75.0f) + 1.0f) /2.0f;
    buffer.m_dest.y -= y * weapon->getSpeed() / 4.0f;
    buffer.m_dest.x -= x * weapon->getSpeed() / 2.0f;

    /* Most Shaders have a "custom cbuffer" which exists at register(b1). This allows
     * me to send shader-specific cbuffer configurations */
    s_shader->bindCBuffer(&buffer);
    
    s_shader->use();
    animation->getTexture()->use();
    s_quad->draw();
}

By no means would I say that what I'm doing is typical, or that you should emulate me. I'm just showing an example of how I did it and you can compare with yours. If you think my system is stupid and you hate it, that's fine. If you like some aspects of it, feel free to use those as well. My intention was to design a system where the objects for rendering more closely mirror the hierarchy of objects in glTF (the model format I'm using)

For another example of someone else's renderer, check out the DX11 tutorials on rastertek: https://rastertek.com/tutdx11win10.html​​ . His tutorials don't just show you simple examples of how to draw a triangle or do shadows; he develops a whole framework for a renderer that follows through each step of the tutorial, so it gives you an idea of how you might structure your project.

None

@shortround

I can see a major design flaw: because you have models/meshes draw themselves, there is no way to do any sorting or filtering of the draw calls. You might need to sort back-to-front for transparency, front-to-back for opaque objects to reduce overdraw, and might also want to sort by material/shader/texture to reduce state changes. You might want to only draw certain objects in certain render passes (e.g. shadow or reflection pass). You also don't seem to be doing any culling, which will hurt performance.

The solution to this is to introduce a RenderQueue class. Each model/mesh adds the necessary information to the queue for drawing itself, but doesn't actually submit anything to the graphics API. Then, the renderer can sort the queue once all draw items are collected, and finally submit the draw items in sorted order. RenderQueue can also store things like lights of different types, etc.

This part of my renderer looks something like this:

struct RenderRequest
{
    // LOD parameters, camera frustum for culling, screen resolution, render pass, flags for what to draw (shadows/reflection)
};

class RenderQueue
{
    struct Mesh
    {
        // Transform, vertex buffers, index buffer, material, bounding box
    };
    // lights, meshes to draw
};

// base class for scene, objects, meshes, sky box, etc.
class Renderable
{
    virtual getRenderables( RenderQueue& renderQueue, const RenderRequest& request, const AffineTranform& localToWorld ) = 0;
};

The specific logic for how anything is drawn is implemented in the getRenderables() function, which can do different things depending on the subtype of Renderable.

Also I'd highly advise against this pattern you are using with static function-local variables:

static Shader const* s_shader = ASSET->getShader("UI");

You shouldn't be loading assets inside your drawing functions. That should be done upfront before starting to render a frame. Function-local statics are a bad code smell and have potential issues with thread safety and multiple instances. What happens if you need to have 2 renderers with different graphics contexts, such as in a multi-window game editor? There might be a conflict if you have a single static shader. Why can't you use a class member variable instead? That will be more flexible since the shader is not fixed at compile time.

@Aressera Thanks for the reply! My game doesn't have any translucent textures (it has transparency but those just discard the fragment) and so sorting is unnecessary; I only need a regular depth buffer. When I look at which features to implement, I keep in mind the YAGNI principle: “you ain't gonna need it!”. My game is not very complicated and so I don't have a need for a render queue (this is not a general-purpose engine)

Culling is handled at the model level by comparing the AABB of each model against the frustum in an octree; it doesn't occur at the mesh level (meshes are not usually big enough to warrant their own culling mechanics)

As for “loading assets in the draw functions”: no such loading occurs. Shaders are loaded at startup time from cso files and placed into a dictionary. That static call simply gets a reference to the shader which has already been initialized. I add it there because I know that the draw call won't be invoked until long after the shader init code has run. Many of the IDrawableMixins have a single global object which I pass the reference to and rather than trying to make sure to call an init method for each one at the right time, this is simply lazyloading the reference once at draw time (really, there's no need for this to be a class/object; a function pointer with that single static variable would be enough). Thread safety is not a concern for me right now for two reasons:

  1. The vast majority of my assets are immutable const and so mutation across threads is not a problem
  2. My game is single-threaded, so the graphics context being mutated between multiple threads will not occur

Your hypothetical scenario of “What happens if you need to have 2 renderers with different graphics contexts, such as in a multi-window game editor” is not a problem for me, because I do not plan to do that.

In general, the answer to most of the concerns you posed are: “I will simply not do that thing”

None

@Aressera I had a similar idea, this is probably the route I'd take the render queue makes so much sense. Just need to improve my Dx12 knowledge

None

One thing I recommend for DX12 is look at bindless rendering it will decouple the complexity of descirptor tables and makes you not copy them all over the place.

Worked on titles: CMR:DiRT2, DiRT 3, DiRT: Showdown, GRID 2, theHunter, theHunter: Primal, Mad Max, Watch Dogs: Legion

@NightCreature83 I was going to do this after making this renderer

None

Advertisement