Skip to content

Renderers

harrand edited this page Jul 11, 2023 · 4 revisions

Summary

Renderers represent a single render/compute pass. They are comprised of:

Before creating a renderer, you must fill in all of this information in a tz::gl::renderer_info object.

ℹ️ Information

There is a common distinction between a "graphics renderer" and a "compute renderer". Any renderers that were created with a compute shader are considered compute renderers, everything else is a graphics renderer.

Render State

When you create a renderer, you should populate the Render State.

For graphics renderers, there is the following state:

  • Index Buffer, a resource handle (defaults to a null handle, meaning there is no index buffer)
  • Draw Indirect Buffer, a resource handle (defaults to a null handle, meaning there is no draw indirect buffer)
  • The clear colour, a four-dimensional floating-point vector clamped between [0.0f-1.0f], representing the background colour. (defaults to {0.0f, 0.0f, 0.0f, 0.0f}, i.e fully transparent black).
  • The triangle count, an unsigned integer representing the number of triangles that will be drawn in a single invocation of the renderer. (defaults to zero)

If you're just looking to render some test triangles, most likely the triangle count is the only state you will want to set. The triangle count and the clear colour can be changed later if you like, via a renderer edit!

ℹ️ Information

If a draw indirect buffer is specified, the triangle count is ignored, and the draws are sourced from the buffer instead.

For compute renderers, there is the following state:

  • A compute kernel, a three-dimensional unsigned integer vector, representing the workgroup sizes [X, Y, Z] that will be dispatched by the compute renderer. (defaults to {1u, 1u, 1u})

Creating a Renderer

To create a renderer, you must first access the global Device and then pass your filled-in tz::gl::renderer_info object to tz::gl::device::create_renderer(). This function returns a tz::gl::renderer_handle, an opaque handle representing the created renderer.

You should keep a hold of this handle for the duration of the renderer's lifetime, as you will need it to perform any operations or do any rendering with the renderer.

Using a Renderer

Once you have created a renderer, you will have already supplied it with any resources you need, and a shader created previously. If you did not specify a renderer output, the renderer will draw directly to the window images.

To use a renderer, you will need to do add it to the render graph using its handle. To better explain this, see the following example:

// Create our renderer.
tz::gl::renderer_info rinfo;
populate_my_renderer_info(&rinfo);
tz::gl::renderer_handle ren = tz::gl::get_device().create_renderer(rinfo);

// Add the renderer to the end of the timeline.
tz::gl::get_device().render_graph().timeline = {ren};

Everything should look reasonable here, aside from the last line.

Render Graph

We can proceed no further without understanding what a render graph is. However, this is a complicated topic.

In short, a render graph is a way of organising all of the components that perform rendering, handling dependencies, layout transitions, synchronisation etc... Without a render graph, it quickly becomes unfeasible to create complicated rendering setups.

Render graphs vary in their implementations between game engines. As a result, their meaning might be slightly different depending on context. For example, a Topaz Render Graph does not work exactly like a Granite Render Graph, or any other engine.

If you're not satisfied, render graphs have their own page, which I highly recommend you read.

Invoking a Renderer

At this point, we have created a renderer, and added it to the render graph timeline. Now, we'd like to start rendering. In the following example, we will continually invoke the device's render graph until the window is closed:

while(!tz::window().is_close_requested())
{
    tz::begin_frame();
    tz::gl::get_device().render();
    tz::end_frame();
}

When we invoke tz::gl::device::render(), the engine will iterate through the timeline in-order, invoking each renderer. The GPU work for each renderer will be synchronised with respect to any dependencies specified between renderers. In our example case though, we only added one renderer to the render graph, so we have no dependencies to worry about.

Renderer Edits

Edits represent heavy-duty modifications or operations to be carried out on a single renderer. Renderer edits are expensive, but allow you to greatly customise a renderer. Because of this, you should only perform an edit when you need to, and avoid doing it on your common code paths at all costs.

See here for a list of renderer edits.

Carrying out a Renderer Edit

To perform a renderer edit, you must first obtain a previously-created renderer object. It is recommended that you populate an object of type RendererEditBuilder with your specified edits. When you're ready, you should then invoke tz::gl::renderer::edit(RendererEditBuilder) to carry out the changes.

When carrying out the edit:

  • The edits are applied in chronological order. This means that it is valid for example to buffer_resize to increase the size of a buffer, and then resource_write to write data into it.
  • The operation is synchronous, meaning that edit(...) returns once all the changes are made.
  • If the renderer has GPU work currently in-flight, the call to edit(...) will block the current thread until it is complete, before carrying out the specified edits.
  • If an edit is considered superfluous (e.g, resizing a buffer to the same size), then it may be discarded. This means that if all the specified edits are superfluous, the entire operation will early-out, not waiting on any in-flight work to be complete.
    • It is invalid to use a renderer edit for any kind of synchronisation.