Skip to content

Encapsulating Graphics Work

Philipp Dresselmann edited this page Jan 21, 2024 · 6 revisions

Whether you are designing a library to be used by others or modularizing your code, it is important to be able to encapsulate graphics code in a way that offers the user code as much flexibility as possible.

The following pattern I call the "middleware pattern" and works well both in separate libraries and just regular modules.

Middleware Libraries

Middleware is a piece of software that fits unintrusively into an existing application, giving some extra functionality. In the case of wgpu, middleware are libraries that use the wgpu context that the user provides to do their work. If a library creates the wgpu adapter, device, etc for you, it isn't middleware, but would more likely be called a framework. A partial list of existing wgpu middleware is available on the Applications and Libraries page.

API Design

There are two main api patterns that make up the middleware pattern, external and internal render data.

This does not have to be the extent of the api, you may have more (or different) arguments or more functions, but this is the gist of the core of the interactions with wgpu.

External Render Data

impl MiddlewareRenderer {
    fn new(&Device, &TextureFormat, ..) -> Self;

    // Prepare for rendering, create all resources used during render, storing render data in PerFrameData
    fn prepare(&self, ..) -> PerFrameData; 

    // Render using data in PerFrameData and user provided renderpass
    fn render<'rpass>(&'rpass self, &mut RenderPass<'rpass>, &'rpass PerFrameData); 
}

Internal Render Data

impl MiddlewareRenderer {
    fn new(&Device, &TextureFormat, ..) -> Self;

    // Prepare for rendering, create all resources used during render, storing render data internally
    fn prepare(&mut self, ..);

    // Render using internal data and user provided renderpass
    fn render<'rpass>(&'rpass self, &mut RenderPass<'rpass>);
}

Functions

The goal of this api is to use a fewest possible render passes as possible. Reducing render passes is a very important for lower end hardware. These GPUs use a method called "tiled rendering" where there is significant cost to ending a render pass.

Middleware should not call queue.submit unless absolutely necessary. It is an extremely expensive function and should only be called once per frame. If the middleware generates a CommandBuffer, hand that buffer back to the user to submit themselves.

New

fn new(&Device, &TextureFormat, ..) -> Self;

This is where you create your renderer and set up all the static resources. Things like pipelines, buffers, or textures should be created and uploaded here. Favor accepting a TextureFormat, width, and height over a SwapchainDescriptor, as the user may not be rendering to the swapchain image.

Prepare

fn prepare(&self, ..) -> PerFrameData; 
fn prepare(&mut self, ..);

The split between prepare and render is extremely important. Because render passes need all data they use to last as long as they do, all resources that are going to be used in the render pass need to be created ahead of time. By having a prepare function that gets everything ready ahead of time, the data (stored in either self or PerFrameData) will live the right amount of time. This also allows the user to control where exactly they want your middleware to do its work.

While both methods are valid, having external data is a way to encode the need for prepare to be called before render directly in the api. This makes it harder to use the api improperly. At the same time, there are many situations where no resources have to be recreated, but only modified. In this case it would make sense to have internal data.

Ideally there should be a minimal amount of resources created per frame, but that is often hard to avoid.

Render

fn render<'rpass>(&'rpass self, &mut RenderPass<'rpass>, &'rpass PerFrameData); 
fn render<'rpass>(&'rpass self, &mut RenderPass<'rpass>);

This is where the magic happens! Using the render data created during prepare, render everything using the provided render pass. All data is guaranteed to live long enough by the lifetimes.

Note that the &mut on the render pass is not the same lifetime. It is general advice that &'a mut Thing<'a> is always wrong, but specifically we want to say that things are going to be flowing into the render pass, not that our borrow of the render pass needs to last as long as the render pass does.

Multiple Render Targets

If your piece of middleware has to render to multiple targets, it is pretty unavoidable to have multiple render targets. As much as is possible, this pattern should be used as a guideline for the design of your api, but it doesn't work for every possible piece of middleware out there.

Sharing Common GPU Data

Todo...

Sample Middleware

Todo...