Intro to gfx-hal • Part 2: Push constants

2020-04-01 · Learning gfx-hal

This is Part 2 of a series of tutorials on graphics programming in Rust using gfx-hal.

In Part 1 of the series, we embarked on a grand expedition to draw a single triangle.

(If you haven’t read Part 1 and want to skip ahead, you can copy the code here which we’ll build on top of in this part.)

It was a tremendously exciting example - but not the most flexible one.

That’s why in this part, we want to be able to draw more interesting triangles. To do that, we’ll have to supply some inputs to the shaders - specifically things like color, position, and scale. This means we’ll need a way to send some data from our CPU-side app over to the GPU.

Now we’re already doing that when we submit commands in command buffers. Can’t we just send some data the same way?

Yes! Yes we can. They’re called push constants and they allow us to include a small amount of data in our command buffers which will be made available to our shaders.

Read on to learn more, or look at the full code for this part, with comments, here: part-2-push-constants.

    const APP_NAME: &'static str = "Part 2: Push constants";

Push constants allow us to dynamically change how things are drawn, even within a single command buffer. In my view, they’re the simplest way to do so - they don’t require any memory allocation, synchronization, or really much setup at all.

The first thing to do is to update our shaders so that they can make use of push constants. We’re going to be drawing triangles, just like the one from Part 1. But this time, we want to be able to vary the color, position, and scale of each one:

// shaders/part-2.vert
#version 450
#extension GL_ARB_separate_shader_objects : enable

layout(push_constant) uniform PushConstants {
    vec4 color;
    vec2 pos;
    vec2 scale;
} push_constants;

layout(location = 0) out vec4 vertex_color;

void main() {
    vec2 position;
    if (gl_VertexIndex == 0) {
        position = vec2(0.0, -0.5);
    } else if (gl_VertexIndex == 1) {
        position = vec2(-0.5, 0.5);
    } else if (gl_VertexIndex == 2) {
        position = vec2(0.5, 0.5);
    }

    vec2 pos = position * push_constants.scale;
    vertex_color = push_constants.color;
    gl_Position = vec4((pos + push_constants.pos), 0.0, 1.0);
}

You can see we included a struct of the three properties we want to change. The special layout(push_constant) tells the shader to look in the command buffer’s push constants for that data.

We also added a vertex_color output, which the fragment shader will just pass straight through:

// shaders/part-2.frag
#version 450
#extension GL_ARB_separate_shader_objects : enable

layout(location = 0) in vec4 vertex_color;

layout(location = 0) out vec4 fragment_color;

void main() {
    fragment_color = vertex_color;
}

Last thing to do here is to make sure our pipeline is loading the new shaders. Change the filenames of the shaders where we create the pipeline:

    // ...

    let vertex_shader = include_str!("shaders/part-2.vert");
    let fragment_shader = include_str!("shaders/part-2.frag");

    // ...

Now we’ve seen how to use the push constant data within a shader, but that doesn’t really explain what they are. In essence, push constants are just a small number of bytes (at least 128) that you can do whatever you like with.

When we defined the PushConstants struct in our shader, we were just asking it to interpret some of those bytes as a struct. If we do the same in our Rust code, we make it easier to send that data:

#[repr(C)]
#[derive(Debug, Clone, Copy)]
struct PushConstants {
    color: [f32; 4],
    pos: [f32; 2],
    scale: [f32; 2],
}

Note the repr(C) attribute which tells the compiler to lay out this struct in memory the way C would. This (as far as I’m aware) is also how structs are laid out in shader code. By ensuring the layouts are the same, we can easily copy the Rust struct straight into push constants without worrying about individual fields.

Next we have to make a small change to our pipeline layout to enable push constants:

    let pipeline_layout = unsafe {
        use gfx_hal::pso::ShaderStageFlags;

        let push_constant_bytes = std::mem::size_of::<PushConstants>() as u32;

        device
            .create_pipeline_layout(&[], &[(ShaderStageFlags::VERTEX, 0..push_constant_bytes)])
            .expect("Out of memory")
    };

For each draw call, we’re going to supply one of our PushConstants structs. However, the GPU doesn’t know anything about structs - all it knows is that we’re going to give it some bytes. What it wants to know is which of those bytes it should use for each specific shader stage. In our case, we only care about the vertex shader, and the number of bytes is however big the struct is.

As a final touch before our main loop, let’s also give ourselves a time parameter, so we can animate things.

    // We'll use the elapsed time to drive some animations later on.
    let start_time = std::time::Instant::now();

    event_loop.run(move |event, _, control_flow| {
        // ...

Now we can move on to the render loop. You’ll see later that we’re going to need to use the pipeline_layout, so let’s take a reference to it at the start of the loop:

            Event::RedrawRequested(_) => {
                let res: &mut Resources<_> = &mut resource_holder.0;
                let render_pass = &res.render_passes[0];
                let pipeline_layout = &res.pipeline_layouts[0];
                let pipeline = &res.pipelines[0];

                // ...

Next we’ll create some structs representing each triangle we want to draw. We can vary the position, color, and scale of each one, and we can use start_time.elapsed() to vary some of those properties over time - allowing us to animate them:

                // This `anim` will be a number that oscillates smoothly
                // between 0.0 and 1.0.
                let anim = start_time.elapsed().as_secs_f32().sin() * 0.5 + 0.5;

                let small = [0.33, 0.33];

                let triangles = &[
                    // Red triangle
                    PushConstants {
                        color: [1.0, 0.0, 0.0, 1.0],
                        pos: [-0.5, -0.5],
                        scale: small,
                    },
                    // Green triangle
                    PushConstants {
                        color: [0.0, 1.0, 0.0, 1.0],
                        pos: [0.0, -0.5],
                        scale: small,
                    },
                    // Blue triangle
                    PushConstants {
                        color: [0.0, 0.0, 1.0, 1.0],
                        pos: [0.5, -0.5],
                        scale: small,
                    },
                    // Blue <-> cyan animated triangle
                    PushConstants {
                        color: [0.0, anim, 1.0, 1.0],
                        pos: [-0.5, 0.5],
                        scale: small,
                    },
                    // Down <-> up animated triangle
                    PushConstants {
                        color: [1.0, 1.0, 1.0, 1.0],
                        pos: [0.0, 0.5 - anim * 0.5],
                        scale: small,
                    },
                    // Small <-> big animated triangle
                    PushConstants {
                        color: [1.0, 1.0, 1.0, 1.0],
                        pos: [0.5, 0.5],
                        scale: [0.33 + anim * 0.33, 0.33 + anim * 0.33],
                    },
                ];

Now before we can actually use that data to draw, we have to pass it to the GPU as a sequence of bytes. They have to be 32-bit aligned, and so gfx-hal actually requires them to be passed as a sequence of u32 values. So, let’s write a function to convert our PushConstants struct into that format:

                /// Returns a view of a struct as a slice of `u32`s.
                unsafe fn push_constant_bytes<T>(push_constants: &T) -> &[u32] {
                    let size_in_bytes = std::mem::size_of::<T>();
                    let size_in_u32s = size_in_bytes / std::mem::size_of::<u32>();
                    let start_ptr = push_constants as *const T as *const u32;
                    std::slice::from_raw_parts(start_ptr, size_in_u32s)
                }

Now finally, we can make use of our push constants to render some things. So let’s replace our old draw call:

                    command_buffer.draw(0..3, 0..1);

— with something more interesting. We’ll loop over the triangles we defined with PushConstants structs. For each one, we’ll first write the push constant data to the command buffer, and then write the draw command:

                    for triangle in triangles {
                        use gfx_hal::pso::ShaderStageFlags;

                        command_buffer.push_graphics_constants(
                            pipeline_layout,
                            ShaderStageFlags::VERTEX,
                            0,
                            push_constant_bytes(triangle),
                        );

                        command_buffer.draw(0..3, 0..1);
                    }

If all is well, when you run the application, you should see six triangles like this:

Hopefully you see how versatile push constants can be. With relatively few changes we added a lot of flexibility in how we render our triangles. For certain limited graphical styles, you could even stop here - provided flat-colored shapes were all you needed.

Honestly though, I don’t know about you, but I’m absolutely sick of triangles. That’s why in Part 3 we’re going to learn how to use vertex buffers to draw full 3D models. I hope you found this tutorial useful, and that you’ll look forward to the next entry.

If you found this post useful and want to support me spending more time on things like this: