I started writing my first Vulkan renderer, nicknamed ivy, in November of 2020 and worked on it in spare time until around August 2021. Here’s what I learned!

My main goals for this project were to

  • get a better understanding of Vulkan
  • build a renderer/graphics backend that was easy to use
  • implement some nice graphics techniques
ivy volumetric fog screenshot
A screenshot of ivy showcasing dynamic point light shadows, volumetric fog, and physically-based shading

Graphics Pass Creation

Since I didn’t want to be writing raw Vulkan everywhere, I tried to wrap it and create a “simple” interface for pipeline creation. I basically ended up with a lot of code that looked like this:

// Main gbuffer and shading pass
passes_.emplace_back(
    gfx::GraphicsPassBuilder(device_)
    .addAttachmentSwapchain()
    .addAttachment("diffuse", VK_FORMAT_R8G8B8A8_UNORM)
    .addAttachment("normal", VK_FORMAT_R16G16B16A16_SFLOAT)
    .addAttachment("occlusion_roughness_metallic", VK_FORMAT_R8G8B8A8_UNORM)
    .addAttachment("depth", depthFormat)
    .addSubpass("gbuffer_pass",
                gfx::SubpassBuilder()
                .addShader(gfx::Shader::StageEnum::VERTEX, "../assets/shaders/gbuffer.vert.spv")
                .addShader(gfx::Shader::StageEnum::FRAGMENT, "../assets/shaders/gbuffer.frag.spv")
                .addVertexDescription(gfx::VertexP3N3T3B3UV2::getBindingDescriptions(),
                                      gfx::VertexP3N3T3B3UV2::getAttributeDescriptions())
                .addColorAttachment("diffuse", 0)
                .addColorAttachment("normal", 1)
                .addColorAttachment("occlusion_roughness_metallic", 2)
                .addDepthAttachment("depth")
                .addUniformBufferDescriptor(0, 0, VK_SHADER_STAGE_VERTEX_BIT)
                .addTextureDescriptor(1, 0, VK_SHADER_STAGE_FRAGMENT_BIT)
                .addTextureDescriptor(1, 1, VK_SHADER_STAGE_FRAGMENT_BIT)
                .addTextureDescriptor(1, 2, VK_SHADER_STAGE_FRAGMENT_BIT)
                .addTextureDescriptor(1, 3, VK_SHADER_STAGE_FRAGMENT_BIT)
                .addTextureDescriptor(1, 4, VK_SHADER_STAGE_FRAGMENT_BIT)
                .build()
               )
    .addSubpass("lighting_pass",
                gfx::SubpassBuilder()
                .addShader(gfx::Shader::StageEnum::VERTEX, "../assets/shaders/fullscreen.vert.spv")
                .addShader(gfx::Shader::StageEnum::FRAGMENT, "../assets/shaders/lighting.frag.spv")
                .setColorBlending(VK_BLEND_OP_ADD, VK_BLEND_FACTOR_ONE, VK_BLEND_FACTOR_ONE)
                .addColorAttachment(gfx::GraphicsPass::SwapchainName, 0)
                .addInputAttachmentDescriptor(0, 0, "diffuse")
                .addInputAttachmentDescriptor(0, 1, "normal")
                .addInputAttachmentDescriptor(0, 2, "occlusion_roughness_metallic")
                .addInputAttachmentDescriptor(0, 3, "depth")
                .addUniformBufferDescriptor(1, 0, VK_SHADER_STAGE_FRAGMENT_BIT)
                .addTextureDescriptor(1, 1, VK_SHADER_STAGE_FRAGMENT_BIT)
                .addTextureDescriptor(1, 2, VK_SHADER_STAGE_FRAGMENT_BIT)
                .addTextureDescriptor(1, 3, VK_SHADER_STAGE_FRAGMENT_BIT)
                .addUniformBufferDescriptor(2, 0, VK_SHADER_STAGE_FRAGMENT_BIT)
                .build()
               )
    .addSubpassDependency(gfx::GraphicsPass::SwapchainName, "gbuffer_pass",
                          VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT,
                          VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT,
                          0,
                          VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT)
    .addSubpassDependency("gbuffer_pass", "lighting_pass",
                          VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT,
                          VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
                          VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT,
                          VK_ACCESS_SHADER_READ_BIT)
    .build()
);

… which is a lot. A lot of the description here could actually be read from the shaders. Currently I’m working on another Vulkan renderer and making a “graphics pass” look more like this thanks to shader reflection:

gfx::GraphicsPassDesc pass_desc = {};
pass_desc.render_area      = {0, 0, core_.get_config().get_window_width(), core_.get_config().get_window_height()};
pass_desc.vert_shader_path = "../data/shaders/triangle.vert.spv";
pass_desc.frag_shader_path = "../data/shaders/triangle.frag.spv";

gfx::GraphicsPass pass(core_, gfx_, pass_desc);

No need to specify descriptors! It’s nice to cut down on the amount of code that’s duplicated between C++ and shaders. It also makes it easier to make changes/prototype/iterate which is a big win.

Also, in this next renderer, I’m probably dropping subpasses from the frontend. Maybe if there’s a need to use them for a specific platform they can be described somehow, but I think I’m ok without them for now. We’ll see :)

Textures

Making a texture is really easy. Here’s some code to generate cubemap textures for point lights to draw shadows into:

point light shadow maps

// Create point shadow cubemap textures
pointLightShadowAtlas_ = gfx::TextureBuilder(device_)
                         .setExtentCubemap(shadowMapSizePoint_)
                         .setFormat(depthFormat)
                         .setImageAspect(VK_IMAGE_ASPECT_DEPTH_BIT)
                         .setAdditionalUsage(VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT)
                         .setArrayLength(maxShadowCastingPointLights_)
                         .build();

Simple! The texture builder also does some simple error checking for you, so if you mix things up or enter a wrong value somewhere, it’ll catch it. Most of my wrappers do some sort of error checking in debug mode. The Vulkan validation layers are really nice, but these catch errors sooner.

Descriptors

I didn’t really like how descriptor sets ended up, they were a little clunky and hard to use.

Here’s an example of setting a struct, it’s not easy to tell what exactly is happening in the renderer code without referencing the shader:

// in shader
layout (set = 0, binding = 0) uniform PerLight {
    mat4 viewProjection;
} uPerLight;
// in renderer
PerLightDirectionalShadowPass perLight = {};
perLight.viewProjection = light->calculateViewProjectionMatrix(cameraTransform, camera);

gfx::DescriptorSet perLightSet(shadowPassDirectional, 0, 0);
perLightSet.setUniformBuffer(0, perLight);
cmd.setDescriptorSet(device_, shadowPassDirectional, perLightSet);

The zeros in the constructor of DescriptorSet are just describing the set index and binding. So, this also means if you re-arrange something in the shader, you need to change a couple of places in the C++ side of things as well.

In my work-in-progress renderer, I’m utilizing shader reflection once again to allow me to write code that’s easier to read by glancing at it. As you might notice, now we’re using strings which might be a performance hit, we’ll see how it works in the long run but for the time being it’s much nicer to use:

// in shader
layout (std430, set = 0, binding = 0) readonly buffer VertexBuffer {
    float data[];
} u_vertices;
// in renderer
gfx::DescriptorWrites writes;
writes.set_buffer("u_vertices", gfx_.get_unified_vertex_buffer());
pass.set_descriptors(cmd, writes);

Conclusion

Overall, I’m pretty happy with how this project went. The end code is not perfect by any means, but I learned a bunch about how Vulkan works and how you would use it in a game.

It was also great to use Vulkan in a more serious context than “following a tutorial that draws a triangle”. It made me appreciate things like the validation layers a lot and gave me a deeper understanding of how everything connects.

Bonus images

drawing all shadows into the same cube face volumetric fog without and with blue noise
Drawing all shadows into the same cube face Volumetric fog without using blue noise (left) and with blue noise (right)