Gfx-hal Tutorial part 2: Vertex buffers

2018-10-09 ·

This is Part 2 in a series about learning Gfx-hal - a low-level graphics API in Rust. If you haven’t already, I’d recommend starting with Part 0 to learn the basics.

This tutorial builds on the code we wrote in the previous part. You can find the new code here with comments explaining everything that’s changed, and run it to see what the end result will look like.

For the last two parts of this tutorial, all we’ve had to look at on-screen is a single blueish triangle. In this part, we want to display a more complex shape, with more variation in color. To do this, we’ll have to stop hard-coding our triangle mesh in the vertex shader. (And start hard-coding it in the source code!)

New shaders

First of all, let’s update our vertex and fragment shaders:

// Vertex shader
#version 450
#extension GL_ARB_separate_shader_objects : enable

layout(location = 0) in vec3 position;
layout(location = 1) in vec4 color;

layout(location = 0) out vec4 varying_color;

void main() {
    varying_color = color;
    gl_Position = vec4(position, 1.0);
}

We’ve scrapped the hard-coded triangle coordinates and replaced them with two vertex attributes: position and color. We also add a varying output to send color information to the fragment shader.

// Fragment shader
#version 450
#extension GL_ARB_separate_shader_objects : enable

layout(location = 0) in vec4 varying_color;

layout(location = 0) out vec4 target;

void main() {
    target = varying_color;
}

In the fragment shader, all we’re doing is replacing the hard-coded color with the value passed in from the vertex shader. This allows us to specify vertex colors and have the fragment shader blend between them.

Updating the rendering pipeline

Before we can hand our rendering pipeline a vertex buffer, we have to describe the format of that buffer. To do so, we have to add a few sections to our pipeline descriptor:

        pipeline_desc.vertex_buffers.push(VertexBufferDesc {
            binding: 0,
            stride: std::mem::size_of::<Vertex>() as u32,
            rate: 0,
        });

This first section describes the types of vertex buffers being used. Different types, with different attributes, would require different binding numbers, but we only have one for now. The stride is simply the size of one element in the buffer, and the rate isn’t relevant to us since we’re not using instanced rendering.

Next we have to describe the vertex attributes in the buffer:

        pipeline_desc.attributes.push(AttributeDesc {
            location: 0,
            binding: 0,
            element: Element {
                format: Format::Rgb32Float,
                offset: 0,
            },
        });

        pipeline_desc.attributes.push(AttributeDesc {
            location: 1,
            binding: 0,
            element: Element {
                format: Format::Rgba32Float,
                offset: 12,
            },
        });

These correspond to the attributes in our shaders. The location has to match the shader code, the binding has to match the earlier buffer description, and the format has to be correct for size of the attribute. For example, the position attribute is a vector of three 32-bit floats, hence RGB and 32Float. The color has four components, so we use RGBA.

(I don’t know why gfx uses RGB/RGBA instead of something like Vec3/Vec4, but I assume there’s a good reason for it.)

The last thing to note is that each attribute must have the correct offset, in bytes, from the start of the vertex. The first is obviously 0. Since the first attribute is three 4-byte floats wide, the offset of the second attribute from the start is 12 bytes. If there was a third attribute, it would have to be 12 + (4 x 4 = 16) = 28 bytes. I’m sure we’ll get to that eventually.

Creating some mesh data

We’ve defined the vertex attributes in our shaders and in our pipeline, so now let’s define a corresponding Vertex struct:

#[derive(Debug, Clone, Copy)]
#[repr(C)]
struct Vertex {
    position: [f32; 3],
    color: [f32; 4],
}

This should be pretty straightforward. The only thing to note is the #[repr(C)] attribute. This ensures that a struct will have the same layout in memory as it would have in C. We want the layout of our data to match between our Rust code and our shader code. Without this attribute, Rust makes no guarantees about the layout at all, whereas most of the time, the C and GLSL layouts will be the same.1

Now let’s make a “mesh” by storing an array of vertices:

const MESH: &[Vertex] = &[
    Vertex {
        position: [0.0, -1.0, 0.0],
        color: [1.0, 0.0, 0.0, 1.0],
    },
    ...
];

In the full code, I’ve used six vertices making up the two triangles of a quad, but you could use any shape.

Creating a vertex buffer

Now we have to actually create a vertex buffer and fill it with data. This will involve:

  1. Creating an unbound buffer which specifies the size and usage of the buffer.
  2. Allocating some buffer memory of the correct type and size.
  3. Binding the buffer to the memory.
  4. Copying our vertex data into the buffer.

So first, the unbound buffer:

        let item_count = mesh.len();
        let stride = std::mem::size_of::<Vertex>() as u64;
        let buffer_len = item_count as u64 * stride;

        let unbound_buffer = device
            .create_buffer(buffer_len, buffer::Usage::VERTEX)
            .unwrap();

The first three lines should be fairly self-explanatory. The size of the buffer is the number of elements multiplied by the size of one element. In create_buffer we specify that it will be used for storing vertex data.

Now that we’ve given our requirements for the buffer, we need to ask the device what its requirements are for storing it. Specifically, we need to know how much memory to allocate (in theory this could be more than we expect for alignment reasons) and what type of memory to allocate:

        let req = device.get_buffer_requirements(&unbound_buffer);
        let memory_types = physical_device.memory_properties().memory_types;

        let upload_type = memory_types
            .iter()
            .enumerate()
            .find(|(id, ty)| {
                let type_supported = req.type_mask & (1_u64 << id) != 0;
                type_supported && ty.properties.contains(Properties::CPU_VISIBLE)
            }).map(|(id, _ty)| MemoryTypeId(id))
            .expect("Could not find approprate vertex buffer memory type.");

Here we’re iterating over the memory_types (the types of memory supported by the device) and selecting one which is both valid for this buffer (according to req.type_mask) and CPU_VISIBLE (so that we can copy data to it from the CPU).

If this is confusing, there’s a more complete explanation in the full code.

Now we can allocate memory of the right type and size, and bind the buffer to it:

        let buffer_memory = device.allocate_memory(upload_type, req.size).unwrap();
        let buffer = device
            .bind_buffer_memory(&buffer_memory, 0, unbound_buffer)
            .unwrap();

Finally we need to copy our vertex data into the buffer:

        {
            let mut dest = device
                .acquire_mapping_writer::<Vertex>(&buffer_memory, 0..buffer_len)
                .unwrap();
            dest.copy_from_slice(mesh);
            device.release_mapping_writer(dest);
        }

We do this by mapping the buffer in memory, and directly copying the bytes of our vertex data into it. The acquire_mapping_writer function gives us back a writer that behaves as a slice of Vertex structs. Note that we added extra { } around this block, so that the borrow of buffer_memory doesn’t prevent us from freeing it later.

OK, now we’re ready to draw!

Binding and drawing

In our rendering code, where we bind our graphics pipeline, we now have to also bind the vertex buffer we’re going to use.

        command_buffer.bind_graphics_pipeline(&pipeline);
        command_buffer.bind_vertex_buffers(0, vec![(&vertex_buffer, 0)]);

The first 0 is the binding number we used earlier, and the second is an offset which we can ignore.

Now when we call our draw function, we just pass in the number of vertices in our mesh and it will render them from the vertex buffer:

            let num_vertices = MESH.len() as u32;
            encoder.draw(0..num_vertices, 0..1);

And lo, we have drawn our mesh!

A colorful diamond

But hey, that’s pretty boring!

So instead, if you clone the tutorial repo and run this example with an extra argument:

$ cargo run --bin part02-vertex-buffer teapot

You get a teapot:

The Utah teapot

Loading a teapot model is left as an exercise for the reader, however.

So now we can render all kinds of differently shaped meshes if we want to. Next time, we’ll introduce uniforms to make our rendering a little more dynamic.

Thanks for reading, and I hope you’ll look forward to Part 3.

  1. As far as I’m aware, this is not guaranteed, but it is at least deterministic.