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 oldMesh
andOutput/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 aPhantomData
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
- The need to think about the struct trio (
Data
,Meta
,Init
), where onlyMeta
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. - 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!