Intro to gfx-hal • Part 3: Vertex buffers

2020-04-16 · Learning gfx-hal

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

In Part 2 of the series, we learned how to use push constants to change the position, color, and size of the triangles we were drawing.

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

Push constants open up a lot of possibilities - but at the end of the day, just drawing individual triangles can only be so much fun. That’s why in this part, we’re going to draw an actual 3D model. Which 3D model? Why, the Utah teapot of course!

We’re going to update our shaders to use vertex attributes, create a vertex buffer full of teapot data, and finally update our pipeline to connect them together.

You can follow along step-by-step with this tutorial, or look here: part-3-vertex-buffers - for the full source code, with comments.

That said, let’s begin!

    const APP_NAME: &'static str = "Part 3: Vertex buffers";

As mentioned, we’re going to be drawing a teapot today. Teapots - as a rule - tend to have tens of thousands of vertices (instead of just three) so it would be a little prohibitive to try and hard-code them in our vertex shader, the way we did before.

Instead what we’re going to do is efficiently store those vertices in a buffer, and then pass them as inputs to the shader via vertex attributes. We will naturally have a position input, but our teapot also contains a vertex normal, so we’ll include that too:

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

layout(location = 0) in vec3 position;
layout(location = 1) in vec3 normal;

layout(push_constant) uniform PushConstants {
    mat4 transform;
} push_constants;

layout(location = 0) out vec4 vertex_color;

void main() {
    vertex_color = vec4(abs(normal), 1.0);
    gl_Position = push_constants.transform * vec4(position, 1.0);
}

A few things to note: first the location of the attribute, secondly that we’re passing the absolute value of the normal as the vertex color, and lastly that we’ve changed our push constants to contain a single matrix.

The location is - for our purposes - just an ID number. We’ll use them later when we’re updating our pipeline. As for the vertex color - this is just a nice way to visualize normals. We don’t need to do this, but it’s more interesting than a flat color. Finally the transform matrix replaces the position and scale parameters we had before. Now we can apply an arbitrary 3D transformation to our vertices, which we’ll use to orient the teapot.

Happily, the fragment shader is unchanged from last time:

// shaders/part-3.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;
}

And again, we now update the filenames of the shaders we’re loading:

    // ...

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

    // ...

Onto the Rust code.

Our goal is to create a vertex buffer, but first let’s find some data to put in it. In the repo is a mesh of the Utah teapot, serialized with the bincode crate. Really it’s just a Vec of vertices efficiently packed into a binary file. To deserialize it, we’ll need to define a compatible Vertex struct:

#[derive(serde::Deserialize)]
#[repr(C)]
struct Vertex {
    position: [f32; 3],
    normal: [f32; 3],
}

Once we have this struct defined, we can load and deserialize the mesh:

    // The `teapot_mesh.bin` is just a `Vec<Vertex>` that was serialized
    // using the `bincode` crate. So we can deserialize it directly.
    let binary_mesh_data = include_bytes!("../../assets/teapot_mesh.bin");
    let mesh: Vec<Vertex> =
        bincode::deserialize(binary_mesh_data).expect("Failed to deserialize mesh");

(Note of course that we do this outside our render loop - we don’t have to do this every frame.)

Now to create the buffer itself. We’re going to have to make more buffers in future, so let’s create a function for this. You’ll thank me later, I promise:

    /// Create an empty buffer with the given size and properties.
    unsafe fn make_buffer<B: gfx_hal::Backend>(
        device: &B::Device,
        physical_device: &B::PhysicalDevice,
        buffer_len: usize,
        usage: gfx_hal::buffer::Usage,
        properties: gfx_hal::memory::Properties,
    ) -> (B::Memory, B::Buffer) {
        todo!()
    }

This function is going to give us an empty buffer - but we have to specify the kind of buffer we want, not just the size of it. The usage parameter specifies how we plan to use the buffer, while the properties define what kind of memory to store it in (which is largely a matter of optimization). We’ll see those parameters in more detail when we call the function.

But before that, let’s fill in the function body:

    // fn make_buffer(...) {
        use gfx_hal::{adapter::PhysicalDevice, MemoryTypeId};

        let mut buffer = device
            .create_buffer(buffer_len as u64, usage)
            .expect("Failed to create buffer");

        let req = device.get_buffer_requirements(&buffer);

        let memory_types = physical_device.memory_properties().memory_types;

        let memory_type = memory_types
            .iter()
            .enumerate()
            .find(|(id, mem_type)| {
                let type_supported = req.type_mask & (1_u32 << id) != 0;
                type_supported && mem_type.properties.contains(properties)
            })
            .map(|(id, _ty)| MemoryTypeId(id))
            .expect("No compatible memory type available");

        let buffer_memory = device
            .allocate_memory(memory_type, req.size)
            .expect("Failed to allocate buffer memory");

        device
            .bind_buffer_memory(&buffer_memory, 0, &mut buffer)
            .expect("Failed to bind buffer memory");

        (buffer_memory, buffer)
    }

To break down the above, what we’re doing is:

  1. Create an opaque buffer object.

  2. Determine a memory_type that’s compatible with our requirements.

  3. Allocate a big enough block of that memory.

  4. Bind the buffer_memory to the buffer, so the buffer knows where its contents are stored.

Possibly the hardest part to understand is how the memory_type is selected. The GPU provides different heaps of memory with different performance characteristics. In the above code, we get a list of memory types, and also a type_mask from our buffer. The type_mask is a bit mask defining whether each item in the list of memory types is suitable for this buffer. The comments in the code itself may do a better job of explaining.

With the make_buffer function defined, we can, um, make our buffer:

    let vertex_buffer_len = mesh.len() * std::mem::size_of::<Vertex>();

    let (mut vertex_buffer_memory, vertex_buffer) = unsafe {
        use gfx_hal::buffer::Usage;
        use gfx_hal::memory::Properties;

        make_buffer::<backend::Backend>(
            &device,
            &adapter.physical_device,
            vertex_buffer_len,
            Usage::VERTEX,
            Properties::CPU_VISIBLE,
        )
    };

We pass parameters to say that we want a VERTEX buffer, and that we want it to be CPU_VISIBLE so that we can write to it from our CPU-side Rust code.

That last parameter is important, because the buffer we just created is currently empty. The next thing we have to do is fill it with our mesh data. We already have some memory allocated for the buffer, so we just have to copy the vertex data into it:

    unsafe {
        use gfx_hal::memory::Segment;

        let mapped_memory = device
            .map_memory(&mut vertex_buffer_memory, Segment::ALL)
            .expect("Failed to map memory");

        std::ptr::copy_nonoverlapping(mesh.as_ptr() as *const u8, mapped_memory, vertex_buffer_len);

        device
            .flush_mapped_memory_ranges(once((&vertex_buffer_memory, Segment::ALL)))
            .expect("Out of memory");

        device.unmap_memory(&mut vertex_buffer_memory);
    }

The first thing we do is map the buffer memory, which gives us a pointer to it. The benefit of that memory being CPU visible is that we can do this. Then we simply copy our deserialized mesh straight to that pointer, and flush the mapped memory to ensure it actually makes it to the GPU.

So now we have an honest-to-god vertex buffer ready to read from. But it won’t do us any good until we teach our pipeline how to interpret that data.

To do that, we have to return to the make_pipeline function we wrote all the way back in Part 1 and extend it a little.

In particular, we need to describe the vertex buffer and attributes in our primitive assembler:

        let primitive_assembler = {
            use gfx_hal::format::Format;
            use gfx_hal::pso::{AttributeDesc, Element, VertexBufferDesc, VertexInputRate};

            PrimitiveAssemblerDesc::Vertex {
                buffers: &[VertexBufferDesc {
                    binding: 0,
                    stride: std::mem::size_of::<Vertex>() as u32,
                    rate: VertexInputRate::Vertex,
                }],

                attributes: &[
                    AttributeDesc {
                        location: 0,
                        binding: 0,
                        element: Element {
                            format: Format::Rgb32Sfloat,
                            offset: 0,
                        },
                    },
                    AttributeDesc {
                        location: 1,
                        binding: 0,
                        element: Element {
                            format: Format::Rgb32Sfloat,
                            offset: 12,
                        },
                    },
                ],
                input_assembler: InputAssemblerDesc::new(Primitive::TriangleList),
                vertex: vs_entry,
                tessellation: None,
                geometry: None,
            }
        };

Note the binding number of 0, which is sort-of an ID number for this particular kind of vertex buffer we’re describing. Each attribute is also tied to that same binding number to indicate that those are the attributes for this kind of vertex buffer. That number will also come up again in our rendering loop.

Note also the location parameters in the attributes. These should match the ones in the vertex shader - so 0 for position, and 1 for normal.

The format of both is Rgb32Sfloat (which is just a needlessly obtuse way of saying vec3), and the second one has a 12-byte offset (because the previous attribute is 12 bytes in size, the size of three f32 values).

With our vertex buffer created, and our primitive assembler extended, we now have everything we need to render a teapot. And you know what that means!

That’s right - more manual memory management!

Because we created a buffer, and allocated memory for it - we want to make sure we clean those up at the end. Like we did in Part 1, we’re going to add these two things to our Resources struct:

    struct Resources<B: gfx_hal::Backend> {
        instance: B::Instance,
        surface: B::Surface,
        device: B::Device,
        render_passes: Vec<B::RenderPass>,
        pipeline_layouts: Vec<B::PipelineLayout>,
        pipelines: Vec<B::GraphicsPipeline>,
        command_pool: B::CommandPool,
        submission_complete_fence: B::Fence,
        rendering_complete_semaphore: B::Semaphore,

        // Add these two fields:
        vertex_buffer_memory: B::Memory,
        vertex_buffer: B::Buffer,
    }

And we also have to update the drop method of the ResourceHolder struct to delete the buffer and free its memory:

    struct ResourceHolder<B: gfx_hal::Backend>(ManuallyDrop<Resources<B>>);

    impl<B: gfx_hal::Backend> Drop for ResourceHolder<B> {
        fn drop(&mut self) {
            unsafe {
                let Resources {
                    instance,
                    mut surface,
                    device,
                    command_pool,
                    render_passes,
                    pipeline_layouts,
                    pipelines,
                    submission_complete_fence,
                    rendering_complete_semaphore,

                    // And these:
                    vertex_buffer_memory,
                    vertex_buffer,

                } = ManuallyDrop::take(&mut self.0);


                // And also this:
                device.free_memory(vertex_buffer_memory);
                device.destroy_buffer(vertex_buffer);

                // ...

Finally, when we create our Resources struct, we need to supply the vertex buffer and its memory:

    let mut resource_holder: ResourceHolder<backend::Backend> =
        ResourceHolder(ManuallyDrop::new(Resources {
            instance,
            surface,
            device,
            command_pool,
            render_passes: vec![render_pass],
            pipeline_layouts: vec![pipeline_layout],
            pipelines: vec![pipeline],
            submission_complete_fence,
            rendering_complete_semaphore,

            // Don't forget these lines also:
            vertex_buffer_memory,
            vertex_buffer,
        }));

Only now can we delve back into our rendering loop.

Previously we defined a set of triangles to draw as a list of PushConstants structs. We’re going to keep that strategy, but we changed the format of the PushConstants struct in our shader to include a transformation matrix.

So we need to update the Rust equivalent to match it. We’ll represent this 4x4 matrix with a 4x4 array-of-arrays:

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

Let’s also define a helper function to create those matrices so we don’t have to do it by hand every time:

                /// Create a matrix that positions, scales, and rotates.
                fn make_transform(translate: [f32; 3], angle: f32, scale: f32) -> [[f32; 4]; 4] {
                    let c = angle.cos() * scale;
                    let s = angle.sin() * scale;
                    let [dx, dy, dz] = translate;

                    [
                        [c, 0., s, 0.],
                        [0., scale, 0., 0.],
                        [-s, 0., c, 0.],
                        [dx, dy, dz, 1.],
                    ]
                }

If you’re not familiar with matrix math, that’s fine - for these tutorials, you can just take my word that this works.

Let’s next replace the list of triangles we had before with a “list” of one single teapot. We’ll also use the elapsed time to animate an angle parameter. This should let us animate our teapot rotating:

                let angle = start_time.elapsed().as_secs_f32();

                // This replaces `let triangles = ...`
                let teapots = &[PushConstants {
                    transform: make_transform([0., 0., 0.5], angle, 1.0),
                }];

Finally, the actually drawing stuff part. Just before we begin the render pass, we can bind the vertex buffer we want to render from:

                    command_buffer.bind_vertex_buffers(
                        0,
                        once((&res.vertex_buffer, gfx_hal::buffer::SubRange::WHOLE)),
                    );

We then have to replace the loop we used to draw our triangles with a new one, and we have to make sure we draw the full range of vertices in the buffer. For the triangles, we always used 0..3, but here, we want to use the number of vertices in the mesh:

                    // This replaces `for triangle in triangles { ... }`
                    for teapot in teapots {
                        use gfx_hal::pso::ShaderStageFlags;

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

                        let vertex_count = mesh.len() as u32;
                        command_buffer.draw(0..vertex_count, 0..1);
                    }

And with that, we are finished!

Behold, like the background of a particularly underwhelming PlayStation demo disc - one multicolor teapot:

The challenge of loading your own 3D models is left to you - but as long as you can get yourself a list of vertices for a shape, you’re golden!

So now we’re in a position where we can draw whatever we want with any parameters we like. What more could there be to learn?

Although, something looks a little off with that teapot - particularly around the handle and spout. Maybe it’s just me, or maybe it’s an elaborate set up for Part 5, but I wonder if maybe shedding some light on it in the next part will help…

So as always, I hope you learned some stuff! Look forward to Part 4 where we will learn how to use descriptor sets to share lighting information between multiple draw calls, and render some lovely, well-lit teapots!

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