Vulkan Tutorial in C - 012 - Uniform and Vertex Buffers Part 2

Update Descriptor Set

Now, when updating the descriptor set, we'll add a VkDescriptorBufferInfo for our uniform buffer, along with a VkWriteDescriptorSet:
VkDescriptorImageInfo descImageInfo =
{
    texSampler,
    texImageView,
    VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
};

VkDescriptorBufferInfo descBufferInfo =
{
    uniformBuffer,
    0, // offset
    uniBufferSize
};

VkWriteDescriptorSet writeDescSet1 =
{
    VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
    NULL,
    descSet,
    0, // dstBinding
    0, // dstArrayElement
    1, // descriptorCount
    VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
    &descImageInfo,
    NULL, // pBufferInfo
    NULL // pTexelBufferView
};

VkWriteDescriptorSet writeDescSet2 =
{
    VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
    NULL,
    descSet,
    1, // dstBinding
    0, // dstArrayElement
    1, // descriptorCount
    VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
    NULL, // pImageInfo
    &descBufferInfo, // pBufferInfo
    NULL // pTexelBufferView
};

VkWriteDescriptorSet writeDescSets[] =
{
    writeDescSet1,
    writeDescSet2
};

vkUpdateDescriptorSets(vk.device,
                       array_count(writeDescSets),
                       writeDescSets,
                       0, NULL);

Create Vertex Buffer Staging Buffer

Finally, let’s create a staging buffer for our vertex buffer. In later tutorials, we’ll introduce the concept of a vertex buffer that doesn’t use a staging buffer. Instead, it will be mapped permanently, allowing us to update it every frame. But for now, we’ll handle it differently.

In 2D games, it's common to use both static and dynamic vertex buffers. Constantly updating a vertex buffer every frame can be expensive, so we try to avoid it when objects don’t need frequent changes.

Static vertex buffers store data that rarely changes, like tilemaps or backgrounds. Dynamic vertex buffers handle objects that update every frame, such as sprites, characters, and particles.

3D games, however, are a different challenge. Instead of thousands of vertices like in 2D, a typical 3D scene has millions, making simple batch rendering impractical. To handle this complexity, 3D engines use advanced techniques like instancing, skinned meshes, and GPU-driven rendering, which go far beyond the scope of this beginner tutorial series.

In this tutorial, we’re starting with a static vertex buffer, but later we’ll work with a dynamic one as well:
float s = 100; // Size
    
f32 vertices[] =
{
    0, 0,    0, 0,
    s, 0,    1, 0,
    s, s,    1, 1,
    
    0, 0,    0, 0,
    s, s,    1, 1,
    0, s,    0, 1
};

u32 vertBufferSize = sizeof(vertices);

vk_create_buffer(&vk,
                 vertBufferSize,
                 VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
                 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
                 VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
                 &vertStagingBuffer, &vertStagingBufferMemory);

{
    void *data;
    vkMapMemory(vk.device, vertStagingBufferMemory, 0, vertBufferSize, 0,
                &data);
    
    memcpy(data, vertices, vertBufferSize);
    
    vkUnmapMemory(vk.device, vertStagingBufferMemory);
}
Notice that I’m now padding the position and UV data in the vertex buffer. Previously, we had them hardcoded in the vertex shader (in fact, we haven’t updated the shader yet, so they’re still there), and they were stored in separate arrays.

However, now that we're passing them in the vertex buffer, I’ve interleaved the data. This is a common practice, but it can be confusing if you haven’t seen it before (though I doubt that's the case). I’ve added spaces between the position and UV data to highlight that.

Create Vertex Buffer (GPU only memory)

Now, let’s create the vertex buffer. Thanks to our helper function, this is very easy. Just make sure to use the correct usage flags (VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT) and memory property flags (VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT), as this is a common source of careless mistakes:
vk_create_buffer(&vk, vertBufferSize,
                 VK_BUFFER_USAGE_VERTEX_BUFFER_BIT |
                 VK_BUFFER_USAGE_TRANSFER_DST_BIT,
                 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
                 &vertexBuffer, &vertexBufferMemory);

Copy Memory from Staging Buffer to Vertex Buffer

Now, copying the memory from our staging buffer to the vertex buffer is also made easy by our helper functions:
VkCommandBuffer vertCommandBuffer = vk_begin_single_time_commands(&vk);
    
VkBufferCopy copyRegion =
{
    0,
    0,
    vertBufferSize
};

VkBufferCopy copyRegions[] = { copyRegion };

vkCmdCopyBuffer(vertCommandBuffer,
                vertStagingBuffer,
                vertexBuffer,
                array_count(copyRegions),
                copyRegions);

vk_end_single_time_commands(&vk, vertCommandBuffer);
After that, we can destroy and free our staging buffer:
vkDestroyBuffer(vk.device, vertStagingBuffer, NULL);
vkFreeMemory(vk.device, vertStagingBufferMemory, NULL);

Define Vertex Input Layout

Now, right above where we define the vertex input create info, let's add a new section to define the vertex input layout:
u32 stride = sizeof(f32) * 4;
    
VkVertexInputBindingDescription vertInputBindDesc =
{
    0, // binding index
    stride,
    VK_VERTEX_INPUT_RATE_VERTEX // per-vertex data (not per-instance)
};

VkVertexInputBindingDescription vertInputBindDescs[] =
{
    vertInputBindDesc
};

VkVertexInputAttributeDescription vertInputAttrDesc1 =
{
    0, // location in the shader
    0, // binding (same as buffer binding)
    VK_FORMAT_R32G32_SFLOAT,
    0 // byte offset in the struct
};

VkVertexInputAttributeDescription vertInputAttrDesc2 =
{
    1, // location in the shader
    0, // binding
    VK_FORMAT_R32G32_SFLOAT,
    sizeof(f32) * 2 // byte offset
};

VkVertexInputAttributeDescription vertInputAttrDescs[] =
{
    vertInputAttrDesc1,
    vertInputAttrDesc2
};
This is nothing fancy; we’re simply defining the stride of our vertex buffer, which is currently the size of 4 floats (2 for position and 2 for UV coordinates). We also define one VkVertexInputAttributeDescription for the position and one for the UV coordinates, specifying where each attribute starts. Specifically, the UV coordinates start 2 floats after the position, which is clear when you look at the vertex data we defined earlier.

Now, we can use these in the VkPipelineVertexInputStateCreateInfo we had. Before, we were passing NULL since we didn’t have a vertex buffer, but now we can pass the arrays we just defined:
VkPipelineVertexInputStateCreateInfo vertexInputStateInfo =
{
    VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO,
    NULL,
    0,
    array_count(vertInputBindDescs),
    vertInputBindDescs,
    array_count(vertInputAttrDescs),
    vertInputAttrDescs
};

Bind Vertex Buffer

Now, for the last change in our C code. Right after we bind the descriptor set, we also need to bind our new, shiny vertex buffer:
VkDeviceSize offsets[] = { 0 };
VkBuffer vertexBuffers[] = { vertexBuffer };
vkCmdBindVertexBuffers(graphicsCommandBuffer, 0,
                       array_count(vertexBuffers),
                       vertexBuffers,
                       offsets);

Update Vertex Shader

The last thing we need to do is update our vertex shader. Note that we’re now passing both position and UV data to it, along with a uniform buffer. We’re also multiplying the position by the projection matrix from the uniform buffer.
If you’re using libraries to handle your projection matrices, remember to check whether the library uses row-major or column-major notation. For example, the widely used glm library uses column-major matrices, so you’d need to adapt the shader code to work with that instead.

Also, make sure to verify that the matrix you’re using accounts for Vulkan’s NDC being top-left, as opposed to OpenGL, which uses bottom-left. This detail can flip everything upside-down or even result in a 'black screen' due to culling.
#version 450

layout(location = 0) in vec2 inPosition;
layout(location = 1) in vec2 inUV;

layout(location = 0) out vec2 outUV;

layout(set = 0, binding = 1) uniform UniformBufferObject
{
    layout(row_major) mat4 projection;
} ubo;

void main()
{
    gl_Position = ubo.projection * vec4(inPosition, 0.0, 1.0);
    outUV = inUV;
}
Remember to recompile the shader with glslc shader.vert -o vert.spv. That’s it! This marks the end of yet another tutorial in our series. At this point, we have a pretty functional app with textures, uniform buffers, and vertex buffers!

As always, you can find the full source code for this tutorial here. If you run into any issues while following along, feel free to let me know in the comments.

In the next tutorials, we’ll introduce dynamic vertex buffers and index buffers. We might even start working with real images using stb_image.h and render text ourselves with the stb_truetype.h library. See you there!

Comments

Popular Posts