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.
The user can define a PSO in one place with a macro:
gfx_pipeline_base macro generates a trio of
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:
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
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.
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:
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:
There is a simpler version of this function too:
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:
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.
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:
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
PSO is the new paradigm of Gfx-rs interface. It deprecates a lot of the old core concepts (
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!
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_pipelineincludes the vertex buffers as well as the render targets. This effectively replaces all the old
Output/Frameconcepts, 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
Initstruct) rather then the compile-time. This was a major unresolved problem of the old solution.
- No need to specify the generic
Rparameter for the struct, or to have a
PhantomDatafor 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).
- The need to think about the struct trio (
Init), where only
Metais 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
We’ll work on polishing the PSO ergonomics, adding more components, and hopefully start the Vulkan backend!