Lava Vulkan Utilities

Lava Vulkan Utilities


# Overview top

Lava is a toy C++ library composed of classes that make it easy to create and manage Vulkan objects. It is open sourced under the MIT license, available at prideout/lava.

Lava is just an experimental playground that I made for myself, I do not recommend using it in a production setting. In fact I don't even recommend it for learning Vulkan.

In addition to reading this documentation page, a good way to learn the API is to study the demos and the headers.

Lava only creates and destroys Vulkan objects; it rarely adds to the command buffer. For example, the client application must invoke vkCmdDraw on its own, as well as any of the VkCmdBind* functions.

Each Lava class is completely independent of every other class, so clients can choose a subset of functionality as needed.

Lava classes are defined in the par namespace, instanced using static create methods that consume simple POD structures. We recommend using initializer syntax to populate these structs. For example:

#include <par/LavaGpuBuffer.h>

using namespace par;

LavaGpuBuffer* lavabuffer = LavaGpuBuffer::create({ // pass in a Config struct
    .device = device,
    .gpu = physicalDevice,
    .size = sizeof(TRIANGLE_VERTICES),
const VkBuffer vkbuffer = lavabuffer->getBuffer();

// do stuff with the VkBuffer here...

delete lavabuffer;

If desired, you can wrap your Lava objects in unique_ptr or shared_ptr, but please note that destruction order matters. For example, you will often want to ensure that LavaContext gets deleted after every other Lava object, since it destroys the VkDevice and VkInstance.


# Building and Running the Demos top

For Android, see the README in extras/android.

For Linux, do this first:

sudo apt-get install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libcurl4-openssl-dev
export CC=clang CXX=clang++

On macOS, you first need clang (which comes with Xcode) and homebrew, then do this:

brew install cmake ninja

The demos require the contents of the extras folder (including submodules) but the core Lava library has zero dependencies and can be built without fetching submodules.

Then, for any platform, do this:

  1. Clone this repo with --recursive to get the submodules, or do git submodule update --init after cloning.
  2. Install the LunarG Vulkan SDK.
  3. Invoke the following commands in your terminal.

    cd [path to repo]
    mkdir .debug ; cd .debug ; cmake .. -G Ninja
    ninja && ./05_spinny_double

If you want to run every demo in sequence, do:

find 0* -exec ./{} \;

# LunarG SDK Instructions top

  1. Download the tarball from their website.
  2. Copy or move its contents to ~/Vulkan. For example: mv ~/Downloads/vulkansdk-macos- ~/Vulkan
  3. Add this to your .bashrc, replacing macOS as needed.

export VULKAN_SDK=$HOME/Vulkan
export VK_LAYER_PATH=$VULKAN_SDK/macOS/etc/vulkan/explicit_layers.d
export VK_ICD_FILENAMES=$VULKAN_SDK/macOS/etc/vulkan/icd.d/MoltenVK_icd.json
export PATH="$VULKAN_SDK/macOS/bin:$PATH"

# Lava Components top


# LavaContext top

Use this class to create the standard litany of init-time Vulkan objects: an instance, a device, a couple command buffers, etc. It requires a callback function that will create the window surface, which can easily be provided as a lambda.

Lava does not contain platform-specific code so it cannot know about the windowing system. The app must provide a callback to create the VkSurfaceKHR.

For example, if your app uses GLFW, you could initialize LavaContext like so:

LavaContext* context = LavaContext::create({
    .depthBuffer = false,
    .validation = true,
    .samples = VK_SAMPLE_COUNT_4_BIT,
    .createSurface = [window] (VkInstance instance) {
        VkSurfaceKHR surface;
        glfwCreateWindowSurface(instance, window, nullptr, &surface);
        return surface;

After constructing the context, you can immediately extract the objects you need, such as the device and backbuffer resolution:

const VkDevice device = context->getDevice();
const VkExtent2D extent = context->getSize();

To see all the getter methods and Config fields, take a look at LavaContext.h.


# Frame API top

You can use LavaContext as an aid for submitting command buffers and presenting the swap chain. Simply wrap your command sequence in beginFrame / endFrame like this:

VkCommandBuffer cmdbuffer = context->beginFrame();
vkCmdBeginRenderPass(cmdbuffer, &rpbi, VK_SUBPASS_CONTENTS_INLINE);
vkCmdBindPipeline(cmdbuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
vkCmdBindVertexBuffers(cmdbuffer, 0, 1, buffer, offsets);
vkCmdDraw(cmdbuffer, 3, 1, 0, 0);

The beginFrame method provides a double-buffered command buffer and waits for the previous buffer submission to finish executing. The endFrame method submits the command buffer and presents the backbuffer.

In addition to beginFrame and endFrame, the context provides a waitFrame method, which allows clients to wait until the most recently rendered frame presents itself.


# Work API top

The work API in LavaContext is similar to beginFrame / endFrame, the main difference being that it does not automatically perform presentation or swapping.

class LavaContext {
    // ...
    VkCommandBuffer beginWork() noexcept;
    void endWork() noexcept;
    void waitWork() noexcept;

The work API is especially useful for invoking vkCmdCopy* since it can be done at initialization time, before drawing a frame. See LavaCpuBuffer for an example.


# Recording API top

Another way to obtain a VkCommandBuffer from LavaContext is via the recording API:

class LavaContext {
    // ...
    LavaRecording* createRecording() noexcept;
    VkCommandBuffer beginRecording(LavaRecording*, uint32_t i) noexcept;
    void endRecording() noexcept;
    void presentRecording(LavaRecording*) noexcept;
    void freeRecording(LavaRecording*) noexcept;
    void waitRecording(LavaRecording*) noexcept;

Unlike endFrame and endWork, the endRecording method does not immediately submit the command buffer. For a usage example, see the 04_triangle_recorded demo.


# LavaDescCache top

Upon construction, this consumes a count of uniform buffers and samplers and immediately creates a VkDescriptorSetLayout and VkDescriptorPool. Over its lifetime, it creates and evicts VkDescriptorSet according to the bindings that you push to it.

For example, let's say you need only one binding for uniform buffers, and up to two textures. You can create the layout like this:

VkDevice device = ...;
VkBuffer ubo = ...;

LavaDescCache* descriptors = LavaDescCache::create({
    .device = device,
    .uniformBuffers = { ubo }, // If you don't have a UBO yet, just say: { 0 }
    .imageSamplers = { {}, {} }  // Declare two image samplers (not known yet)
const VkDescriptorSetLayout dlayout = descriptors->getLayout();

You can generate new descriptors by changing the bindings with setUniformBuffer and setImageSampler. For example:

descriptors->setUniformBuffer(0, buf1);
vkCmdBindDescriptorSets(cmdbuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, playout, 0, 1,
        descriptors->getDescPointer(), 0, 0);
// draw here...

descriptors->setUniformBuffer(0, buf2);
descriptors->setImageSampler(1, myTexture);
vkCmdBindDescriptorSets(cmdbuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, playout, 0, 1,
        descriptors->getDescPointer(), 0, 0);
// draw here...

You can periodically evict unused descriptors by calling releaseUnused, which frees up descriptors that have been unused for a specified amount of time. For example:

void MyRenderer::drawFrame() {
    // ...
    const uint64_t milliseconds = 1000;

To see the complete API, take a look at LavaDescCache.h.


# LavaPipeCache top

This class makes it easy to create pipeline objects and modify rasterization state. Its construction config structure includes the vertex topology, descriptor set layouts (if any), render pass, and shaders. For example:

LavaPipeCache* pipelines = LavaPipeCache::create({
    .device = device,
    .descriptorLayouts = {},
    .renderPass = renderPass,
    .vshader = vertexShaderModule,
    .fshader = fragmentShaderModule,
    .vertex = {
        .attributes = { {
            .binding = 0u,
            .format = VK_FORMAT_R32G32B32_SFLOAT,
            .location = 0u,
            .offset = 0u,
        } },
        .buffers = { {
            .binding = 0u,
            .stride = 12,
        } }

You can push changes to any of the above properties except the device and descriptor set layouts. After pushing a change, the subsequent call to getPipeline() will either create a new pipeline object, or return one from the cache. For example:

VkPipeline pipeline = pipelines->getPipeline();
// Draw stuff here...

pipeline = pipelines->getPipeline();
// Draw stuff here...

Similar to LavaDescCache, unused descriptors can be evicted by calling releaseUnused. To see the complete API, take a look at LavaPipeCache.h.


# LavaCpuBuffer top

This creates a single VkBuffer, using vk_mem_alloc to consolidate VkDeviceMemory objects. Here's a usage example:

LavaCpuBuffer* stage = LavaCpuBuffer::create({
    .device = device,
    .gpu = gpu,
    .size = sizeof(TRIANGLE_VERTICES),
    .source = pverts,

delete pverts;

VkCommandBuffer cmdbuffer = context->beginWork();
const VkBufferCopy region = { .size = sizeof(TRIANGLE_VERTICES) };
vkCmdCopyBuffer(cmdbuffer, stage->getBuffer(), destBuffer, 1, &region);

delete stage;

# LavaGpuBuffer top

Similar to LavaCpuBuffer, but creates device-only memory.


# LavaTexture top

This class won't load a texture from disk or decode a PNG file. However it does help create a VkImage, VkImageLayout, staging buffer, and layout transition.

Constructing a texture is easy. Here's an example that uses stb_image to decode a PNG file from disk:

uint8_t* texels = stbi_load(TEXTURE_FILENAME, &width, &height, 0, 4);
LavaTexture* texture = LavaTexture::create({
    .device = device, .gpu = gpu,
    .size = width * height * 4u,
    .source = texels,
    .width = width,
    .height = height,
    .format = VK_FORMAT_R8G8B8A8_UNORM,

At this point, the staging buffer is populated but the image is not. The next step is to copy the staging data and transition the image layout. LavaTexture makes this convenient via the uploadStage method, which takes a VkCommandBuffer for input:

// Copy the data to device-only memory and transition to an optimal layout:

// At this point the texture is ready to be sampled from, and we can free the staging buffer:

// Extract the Vulkan objects we need to set up a sampler:
VkImageView imageView = texture->getImageView();
VkImage image = texture->getImage();

# Amber Components top

The Lava core has very few dependencies, so we created an optional utility layer called Amber which is allowed to depend on outside repositories such as glslang. This makes it easier to write simple demo apps. For now, the only Amber classes are:


# Internal Guidelines top


# Visual Studio Code top

Do this to enable intellisense and markdown highlighting:

cd [path to repo]
ln -s extras/vscode .vscode

# C++ Style top

The code is vertically compact, but no single line should be longer than 100 characters. All public-facing Lava types live in the par namespace.

For #include, always use angle brackets unless including a private header that lives in the same directory. Includes are arranged in blocks, where each block is an alphabetized list of headers. The first block is composed of par headers, followed by a sorted list of blocks for each extras/ library, followed by a block of C++ STL headers, followed by the block of standard C headers, followed by the block of private headers. For example:

#include <par/LavaContext.h>
#include <par/LavaLog.h>

#include <SPIRV/GlslangToSpv.h>

#include <string>
#include <vector>

#include "LavaInternal.h"

Methods and functions should have comments that are descriptive (“Opens the file”) rather than imperative (“Open the file”).

formatted by Markdeep 1.01