Vulkan conditional rendering

Introduction

Note: Source code that demonstrates this feature can be found in this new example at my open source C++ Vulkan examples repository.

With the new VK_EXT_conditional_rendering extension, Vulkan gains the possibility to execute certain rendering and dispatch commands conditionally, based on values stored in a dedicated buffer.

So instead of having to rebuild command buffers if the visibility of objects change, it’s now to possible to just change a single buffer value to control if the rendering commands for that object are executed without the need to touch any command buffers.

This article and the example will present a simple use-case for this feature, but as with other buffer-based rendering conditions like indirect draw it’s also possible to update such a buffer using compute shaders, not needing any roundtrip to the host at all.

Static command buffers

For this sample we will be rendering the hierarchical node structure of a glTF model:

Drawing nodes is done by a function like this that recursively draws the nodes of the glTF model:

void renderNode(vkglTF::Node *node, VkCommandBuffer commandBuffer) {	
	// Use multiple descriptor sets to pass scene and model node matrices
	const std::vector<VkDescriptorSet> descriptorsets = {
		scene.descriptorSet,
		node->mesh->uniformBuffer.descriptorSet
	};
	vkCmdBindDescriptorSets(...);

	// Use push constants to pass material parameters
	vkCmdPushConstants(...);

	// Draw the current node
	vkCmdDrawIndexed(...);

	// Draw child nodes
	for (auto child : node->children) {
		renderNode(child, commandBuffer);
	}
}

Which is then called at command buffer creation time for all root nodes in the scene:

void buildCommandBuffers() {

	vkBeginCommandBuffer(...);

	...

	// Bind vertex and index buffer of the glTF scene
	vkCmdBindVertexBuffers(...);
	vkCmdBindIndexBuffer(...);

	// render nodes recursively
	for (auto node : scene.nodes) {
		// at some point calls  vkCmdDrawIndexed(...);
		renderNode(node, commandBuffer);
	}

	...

	vkEndCommandBuffer(commandBuffer);
}

With this traditional setup, changing visibility of a single node would require you to rebuild the command buffer.

The conditional buffer

As mentioned in the introduction a buffer is used to conditionally execute the rendering and dispatch commands. So the first step is setting up this buffer. As this is similar to setting other buffers like uniform, vertex, index and shader storage I won’t go into detail.

The important parts here are:

  • The new buffer type VK_BUFFER_USAGE_CONDITIONAL_RENDERING_BIT_EXT is introduced
  • The buffer format is fixed to consecutive** 32-bit values**
  • Offset is also aligned at 32-bits

The later one makes this a good match for your typical C/C++ host structures, e.g. a simple vector:

std::vector<int32_t> conditionalVisibility

Setting up the buffer itself will then look like this:

VkBufferCreateInfo bufferCI{};
bufferCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferCI.usage = VK_BUFFER_USAGE_CONDITIONAL_RENDERING_BIT_EXT;
bufferCI.size = sizeof(int32_t) * conditionalVisibility.size();
vkCreateBuffer(device, &bufferCI, nullptr, &conditionalBuffer.buffer)

VkMemoryRequirements memReqs{};
vkGetBufferMemoryRequirements(device, conditionalBuffer.buffer, &memReqs);

VkMemoryAllocateInfo memAllocInfo{};
memAllocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
memAllocInfo.allocationSize = memReqs.size;
memAllocInfo.memoryTypeIndex = getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
vkAllocateMemory(device, &memAllocInfo, nullptr, &conditionalBuffer.memory);

vkBindBufferMemory(device, conditionalBuffer.buffer, conditionalBuffer.memory, 0);
vkMapMemory(device, conditionalBuffer.memory, 0, bufferCI.size, 0, &conditionalBuffer.mapped);

With this we get a buffer that matches the size and layout of the host application. Note that for better performance and more complex use-cases you’d create a device local buffer instead and either update using staging or via command buffers.

Adding conditional execution

The VK_EXT_conditional_rendering extension introduces two new functions that allow us to mark regions of a command buffer for conditional execution:

void vkCmdBeginConditionalRenderingEXT(VkCommandBuffer commandBuffer, const VkConditionalRenderingBeginInfoEXT* pConditionalRenderingBegin)

and

void vkCmdEndConditionalRenderingEXT(VkCommandBuffer commandBuffer)

Wrapping drawing and dispatch commands in such a region means that they will only be executed if our conditional buffer contains a non-zero value at the given offset.

A basic example of this would look like this:

VkConditionalRenderingBeginInfoEXT conditionalRenderingBeginInfo{};
conditionalRenderingBeginInfo.sType = VK_STRUCTURE_TYPE_CONDITIONAL_RENDERING_BEGIN_INFO_EXT;
conditionalRenderingBeginInfo.buffer = conditionalBuffer.buffer;
conditionalRenderingBeginInfo.offset = 0;

vkCmdBeginConditionalRenderingEXT(commandBuffer, &conditionalRenderingBeginInfo);

// This command will only be executed if the conditional buffer value at offset zero is != 0
vkCmdDrawIndexed(...);

vkCmdEndConditionalRenderingEXT(commandBuffer);

The conditionalRenderinBeginInfo structure contains the parameters used by the vkCmdBeginConditionalRenderingEXT function to determine if the commands in that region are to be executed.

So for this basic example if the 32-bit conditional buffer value at offset 0 is zero, the vkCmdDrawIndexed will not be executed.

Now changing the buffer value at offset 0 to 1 will have the draw command executed:

conditionalVisibility[0] = 1;
memcpy(conditionalBuffer.mapped, &conditionalVisibility, sizeof(conditionalVisibility));

Once the buffer is synchronized to the device, the draw call would be executed without the need to update our command buffers.

Moving to our actual example we create a conditional buffer with one 32-bit value per glTF scene node:

conditionalVisibility.resize(scene.linearNodeCount);
std::fill(conditionalVisibility.begin(), conditionalVisibility.end(), 1);

...

VkBufferCreateInfo bufferCI{};
bufferCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferCI.usage = VK_BUFFER_USAGE_CONDITIONAL_RENDERING_BIT_EXT;
bufferCI.size = sizeof(int32_t) * scene.linearNodeCount;
VK_CHECK_RESULT(vkCreateBuffer(device, &bufferCI, nullptr, &conditionalBuffer.buffer));

Using this setup, each visible glTF node maps to an entry in the conditionalVisibility by it’s unique node index:

With above layout in mind we can now add conditional rendering to our node drawing function:

void renderNode(vkglTF::Node *node, VkCommandBuffer commandBuffer) {
	// Use multiple descriptor sets to pass scene and model node matrices
	const std::vector<VkDescriptorSet> descriptorsets = {
		scene.descriptorSet,
		node->mesh->uniformBuffer.descriptorSet
	};
	vkCmdBindDescriptorSets(...);

	// Use push constants to pass material parameters
	vkCmdPushConstants(...);

	// Conditional rendering parameters for this node
	VkConditionalRenderingBeginInfoEXT conditionalRenderingBeginInfo{};
	conditionalRenderingBeginInfo.sType = VK_STRUCTURE_TYPE_CONDITIONAL_RENDERING_BEGIN_INFO_EXT;
	conditionalRenderingBeginInfo.buffer = conditionalBuffer.buffer;
	// Offset is defined by the actual index of this node
	conditionalRenderingBeginInfo.offset = sizeof(int32_t) * node->index;

	//Begin conditionally rendered region
	vkCmdBeginConditionalRenderingEXT(commandBuffer, &conditionalRenderingBeginInfo);

	// Will only be executed if the buffer value at the given offset is != 0
	vkCmdDrawIndexed(commandBuffer, primitive->indexCount, 1, primitive->firstIndex, 0, 0);

	// End this conditionally rendered region
	vkCmdEndConditionalRenderingEXT(commandBuffer);

	for (auto child : node->children) {
		renderNode(child, commandBuffer);
	}
}

And that’s it! With above code we can now toggle visibility for every glTF model node, in our case by adding checkboxes to the user interface, without ever having to rebuild our command buffer:

Closing words

While the use-cases for conditional rendering in a real-world application might be limited, as most of the time command buffers are constantly regenerated anyway, it’s a nice addition to Vulkan, esp. when combined with compute shaders. Combining these two you could e.g. do your visibility calculations and also update the conditional buffer on the GPU without having to do a round-trip to the host.