Gfx-rs nuts and bolts

Gfx-rs is a low-level graphics abstraction layer in Rust. This blog supposedly hosts the major milestones, concepts, and recaps of the project.


Pipeline State Objects

Introduction

Pipeline state objects (PSO) appear to be an integral part of the next-gen graphics APIs: DX12 has them, and so does Metal. At Gfx-rs, we couldn’t ignore this concept, and we went much further than simply adding support for it. We ditched our old abstractions (based around Batch concept) and put PSO at the center of the interface. This move has cleaned up Gfx-rs and brought it closer to the bare API level. The main challenge was making the PSO management type-safe and convenient without any run-time overhead. What is PSO? I think of it as a shell around the shader program that connects all the memory buffers to it. It includes the descriptions of the vertex attributes, color and depth/stencil targets, their states, and more. With PSO, you’ll only need the following parameters fully defining a draw call:

  • Pipeline State Object
  • non-PSO state (scissor, stencil/blend ref values)
  • buffer sets for vertices, textures, constant buffers, unordered buffers, and targets
  • draw slice (which may include the index buffer)

The trickiest part here is “buffer sets”: obviously, we can’t expect the user to supply all the buffers for all the slots. They have to match the shader program entry points, and we don’t need to ask for more than what’s used. Moreover, the user shouldn’t even care about which slot a particular buffer is bound to. Supposing these buffer sets are provided within some sort of a structure, let’s call it a Data struct. Since it doesn’t carry any information about the exact slots that the data should come into, there is going to be another structure (in the shadow) containing this mapping, let’s call it a Meta struct. This one doesn’t change with user input and is kept as a part of our PSO. Finally, for PSO creation the user needs to supply some data for every component that they need: blend mode for a color target, attribute name and format for a vertex attribute, etc. Let’s call this one an Init struct. This trio is what it takes to manage a PSO.

How do we define it? One observation is that all of these 3 structs just carry some information for each PSO component: we could use the same field names and just store different data. Another observation is that all 3 are connected and can be generated from a single definition. E.g. for a constant buffer, the Init struct would have it’s name (and potentially the layout of the elements), the Meta struct would have the constant buffer index to bind to, and the Data struct would just contain a Buffer<T>. We got a nice table of the components here for the reference.

Gfx-rs Solution

The user can define a PSO in one place with a macro:

use gfx::format::{Depth, Rgba8}; // Used texture formats
gfx_pipeline_base!( pipe {
    vertex: gfx::VertexBuffer<Vertex>,  // `Vertex` implements `gfx::Structure`
    const_locals: gfx::ConstantBuffer<Local>, // same as `Local`, these are formatted structs
    tex_diffuse: gfx::TextureSampler<Rgba8>,
    buf_noise: gfx::ShaderResource<[i32; 4]>,
    pixel_color: gfx::RenderTarget<Rgba8>,
    depth: gfx::DepthTarget<Depth>,
});

The gfx_pipeline_base macro generates a trio of Data, Meta, and Init structs (inside the new module pipe) from this definiton, where the user-specified types are exactly what the Meta part is going to have. The other two generated structs will look like this:

use gfx::handle::{Buffer, ShaderResourceView, Sampler, RenderTargetView, DepthStencilView};
struct Data {
    vertex: Buffer<Vertex>,
    const_locals: Buffer<Local>, // notice that these are both just buffer handles
    tex_diffuse: (ShaderResourceView<Rgba8>, Sampler),
    buf_noise: ShaderResourceView<[i32; 4]>,
    pixel_color: RenderTargetView<Rgba8>,
    depth: DepthStencilView<Depth>,
}
struct Init<'a> {
    vertex: (),
    const_locals: &'a str, // this is the shader-visible name
    tex_diffuse: &'a str,
    buf_noise: &'a str,,
    pixel_color: &'a str,
    depth: gfx::state::Depth,
}

Since the Init one will need to be passed for PSO construction by the user, it’s clear that our solution doesn’t require (or even allow) the user to specify any redundant information, or miss anything. Let’s say the user wants to enable blending for the pixel_color. All that needs to be done is changing the meta type from RenderTarget<T> into BlendTarget<T>. This would not change the corresponding Data component (which will still be RenderTargetView<R, T>), but it would add a mandatory gfx::state::Blend to the Init struct field.

In-place Init

For simple applications the PSO initialization format is often known in advance. To save the user from a burden of instantiating the Init struct, we provide a convenient extended macro to specify the values in place:

gfx_pipeline!( pipe {
    vertex: gfx::VertexBuffer<Vertex> = (),
    const_locals: gfx::ConstantBuffer<Local> = "Locals",
    tex_diffuse: gfx::TextureSampler<Rgba8> = "Diffuse"
    buf_noise: gfx::ShaderResource<[i32; 4]> = "Noise",
    pixel_color: gfx::RenderTarget<Rgba8> = "Color",
    depth: gfx::DepthTarget<Depth> = gfx::state::Depth {
        fun: gfx::state::Comparison::LessEqual,
        write: false,
    },
});

This extended version will also generate the trio of structs, but in addition have the Init::new() method to give you an instance right away! Here is an example code for PSO creation after this macro is invoked:

let pso = factory.create_pipeline_state( // defined in `gfx::traits::FactoryExt`
    &shaders, // `gfx::ShaderSet<R>` - has all the shaders
    gfx::Primitive::PointList, // primitive topology
    gfx::state::Rasterizer::new_fill(gfx::state::CullFace::Nothing),
    Init::new() // our new shiny initializer
).unwrap();

There is a simpler version of this function too:

    let pso = factory.create_pipeline_simple( // defined in `gfx::traits::FactoryExt`
        &vertex_shader, &fragment_shader,
        gfx::state::CullFace::Nothing, Init::new()
    ).unwrap();

Drawing

The resulting type will be gfx::PipelineState<pipe::Meta>, but fortunately the compiler can infer that for you. Using this PSO is rather trivial - the user is only required to construct the Data portion themselves:

let data = pipe::Data {
    vertex: .., const_locals: ..,
    tex_diffuse: .., buf_noise: ..,
    pixel_color: .., depth: ..,
};
let slice = gfx::mesh::Slice {...};
encoder.draw(&slice, &pso, &data);

This is rather minimalistic, but, more importantly, there is next to no opportunity to shoot yourself in the foot! All the inputs of the PSO are guaranteed at compile time. Performance-wise the solution is also perfect - all the mapping from the user data and the PSO inputs is already ready for us (contained in the Meta, which is constructed during PSO creation), so the actual binding doesn’t involve any lookups.

Structure

Some PSO components operate on structured data. Namely, vertex buffers and constant buffers are supposed to map to Rust structures. In order to assist the user in defining one, we have a special macro:

gfx_vertex_struct!(Vertex {
    x@ _x: i8,
    y@ _y: f32,
    //shader_name@ field_name: type,
});

The macro will create Vertex struct that implements gfx::Structure<gfx::format::Format>, allowing it to be used as a generic parameter to vertex buffers. The rust fields are then guaranteed to map the corresponding shader fields, at run-time during PSO creation.

A similar macro is introduced for the constant buffers, it implements gfx::Structure<gfx::shade::ConstFormat>:

gfx_constant_struct!(Local {
    x: i8,
    y: f32,
});

Analysis

PSO is the new paradigm of Gfx-rs interface. It deprecates a lot of the old core concepts (Batch, Output, Mesh, and others), and cleans up the internal structure quite a bit. The implementation can almost be seen as a rewrite of Gfx-rs as we knew it. Moreover, it is perfectly compatible with DX12 and Metal, while still allowing older APIs to emulate PSOs efficiently (see our GL backend). The new solution provides no run-time overhead, high ergonimics, and maximum safety. The drawing part is completely simplified now, and the only way to screw up there is to provide an incompatible slice (even then, out of bounds vertices are handled by the hardware). Overall, we see it as a bold step into the future!

Comparison

Prior to PSO, we had ShaderParam trait with the corresponding macro for deriving it. The new solution macro can be seen as a radical extension of the old approach, giving the following benefits:

  • gfx_pipeline includes the vertex buffers as well as the render targets. This effectively replaces all the old Mesh and Output/Frame concepts, improving the safety and ergonomics of the user code.
  • It is now possible to provide the shader-visible names at run-time (since they are a part of the Init struct) rather then the compile-time. This was a major unresolved problem of the old solution.
  • No need to specify the generic R parameter for the struct, or to have a PhantomData for it at the end (this was annoying!). The definitions are now simpler, although at a cost of the complexity (the actual data definition is hidden).

Problems

  1. The need to think about the struct trio (Data, Meta, Init), where only Meta is explicitly defined by the user. Other two are not visible in either the user code, or the documentation. This may theoretically complicate the understanding of the code, but any screwups will be reported by the compiler anyway.
  2. Run-time loading of the data. It’s difficult to make the PSO definition loadable. Possible solutions: * allow generic parameters inside PSO macro, so that the run-time could have a range of supported PSO formats to chose from * introduce alternative PSO components for loadable data

Future Work

We’ll work on polishing the PSO ergonomics, adding more components, and hopefully start the Vulkan backend!