VPP  0.8
A high-level modern C++ API for Vulkan
How do render graphs work

How do render graphs work

Render graph basics

Render graph represents a high-level structure of the rendering engine.

The graph is constructed from building blocks connected by dependency arcs. Two main types of these blocks are:

  • vpp::Attachment nodes, which represent images on which the renderer operate.
  • vpp::Process nodes, which represent active processes working on these images.

A process node may consume one or more attachments and produce one or more attachments. An attachment may be produced by one process, and then consumed by another one. The latter process is dependent on the former one. In case of many processes, this forms a dependency graph.

Processes on GPU are being executed in parallel. Dependency graph ensures that the data consumed by a process are already prepared by the producing process. Usually this is not being done on whole image level, but rather the image is divided into smaller segments, which may be as small as single pixel. This enables better parallelism.

Additionally, there can also be two special nodes in ther graph: vpp::Preprocess and vpp::Postprocess. The former one is executed before entire graph and allows for preparation steps. The latter one is executed in the end, after entire graph is finished. It allows for finalization steps.

Instead of an vpp::Attachment node, you can use a vpp::Display node which is just a special kind of attachment associated with a vpp::Surface (on-screen display object).

In order to define a render graph, derive your class from the vpp::RenderGraph base class. Inside your class, put fields of type vpp::Process, vpp::Preprocess, vpp::Postprocess, vpp::Attachment and vpp::Display. These classes are explained on their respective documentation pages.

The most important information stored by vpp::Process, vpp::Preprocess and vpp::Postprocess nodes is the command sequence. The sequence is made of commands performing things like:

  • selecting current pipeline for the rendering process (as there might be more than one defined),
  • selecting data buffers,
  • drawing geometry primitives from selected buffers,
  • synchronization,
  • filling and copying images and buffers,
  • setting certain pipeline parameters which are dynamic,
  • managing queries and timestamps.

You can write a complete rendering program by using these commands. This is being done by means of C++ lambda functions. These functions are registered in corresponding vpp::Process, vpp::Preprocess or vpp::Postprocess node by using the << operator. An example of simple render graph and command sequence definition is presented below. For brevity, details concerning objects other than render graph are omitted.

class MyRenderGraph : public vpp::RenderGraph
{
public:
MyRenderGraph ( const vpp::Surface& hSurface ) :
m_display ( hSurface )
{
// Add output attachment (image). This one will be a display surface.
m_render.addColorOutput ( m_display );
}
public:
// Initialization node.
// Rendering node.
vpp::Process m_render;
// Output image node.
vpp::Display m_display;
};
class MyRenderer
{
public:
MyRenderer (
const vpp::Surface& hSurface,
const vpp::Device& hDevice );
private:
// ...
MyRenderGraph m_renderGraph;
vpp::RenderPass m_renderPass;
vpp::ShaderDataBlock m_dataBlock;
// ...
// Data buffers - type definitions not shown for brevity.
VertexAttrBuffer m_vertexBuffer;
vpp::Indices m_indexBuffer;
CFrameParBuffer m_frameParBuffer;
};
MyRenderer :: MyRenderer (
const vpp::Surface& hSurface
const vpp::Device& hDevice ) :
m_renderGraph ( hSurface ),
m_renderPass ( m_renderGraph, hDevice ),
// ...
{
// Register a command sequence for the initialization node.
// Note that a C++ lambda function is being used in conjunction
// with operator<<.
m_renderGraph.m_init << [ this ]()
{
// Commands for synchronization of the buffers.
m_vertexBuffer.cmdCommit();
m_indexBuffer.cmdCommit();
m_frameParBuffer.cmdCommit();
};
// Register a command sequence for the rendering node.
// Note that a C++ lambda function is being used in conjunction
// with operator<<.
m_renderGraph.m_render << [ this ]()
{
// This command selects a pipeline for subsequent commands.
// There may be multiple pipelines.
// Pipeline definition and registration are not shown in this example.
m_renderPass.pipeline ( 0, 0 ).cmdBind();
// This command selects current set of data buffers.
// There may be multiple sets.
m_dataBlock.cmdBind();
// This command binds vertex buffers to the pipeline.
m_pipelineLayout.definition().bindVertices ( m_vertexBuffer, m_indexBuffer );
// This command requests indexed draw operation.
m_renderGraph.cmdDrawIndexed ( 3, 1, 0, 0, 0 );
};
}

There are a couple of important things about this example.

First of all, as it can be seen clearly, render graphs work together with other objects: pipelines, data blocks, buffers. Commands inside registered sequences can (and in fact must) access other objects. Many of them are actually methods of these objects, intended to be called from inside lambda functions of render graphs. Those methods have usually the cmd prefix in their names. As lambda functions must access external objects, the most convenient way to initialize render graphs is to do it from the enclosing object, in this case MyRenderer. The lifetime of accessed objects must not be shorter than the render graph itself. The pattern shown in the example assures it.

Defined command sequence need not to be static (like in the example). You can use any kind of algorithm to generate commands, presumably corresponding to mesh organization in your rendering engine. There may be many draw calls, interleaved by vertex and other buffer binds, ane even pipeline changes. Lambda functions give you the full flexibility.

Render pass basics

Another question is, what is the vpp::RenderPass object and what it does. The vpp::RenderPass is really a compiled form of render graph. A render graph is abstract representation existing on VPP level only. A render pass is the actual Vulkan object, compiled from the render graph for specified device.

That is, a render pass is constructed from the render graph and device as in the example below.

class MyRenderGraph : public vpp::RenderGraph
{
public:
MyRenderGraph ( const vpp::Surface& hSurface );
// ...
};
class MyRenderer
{
public:
MyRenderer (
const vpp::Surface& hSurface,
const vpp::Device& hDevice );
private:
// This is the render graph object.
MyRenderGraph m_renderGraph;
// This is the render pass object.
vpp::RenderPass m_renderPass;
// ...
};
MyRenderer :: MyRenderer (
const vpp::Surface& hSurface
const vpp::Device& hDevice ) :
// Initialize render graph with your custom arguments required by the constructor.
m_renderGraph ( hSurface ),
// Initialize render pass with render graph and device.
m_renderPass ( m_renderGraph, hDevice ),
// ...
{
// ...
}

Some VPP operations require abstract form (vpp::RenderGraph) and some others take compiled form (vpp::RenderPass). One notable example of the latter is registering rendering pipelines for render graph processes. This is being done by means of vpp::RenderPass::addPipeline() method and requires a compiled form of a pipeline (vpp::PipelineLayout) as well.

Advanced topics

The following considerations are more advanced and you can skip them on first read (esp. if you are not familiar with core Vulkan).

A question that might arise is where these lambda functions are called from. Directly this is done by the vpp::CommandBufferRecorder class, which takes a vpp::RenderPass object (among other parameters), gets source render graph for it, gather all registered lambdas and record them into supplied Vulkan command buffer.

You can use vpp::CommandBufferRecorder directly, but this is considered advanced usage. A simple way is the vpp::RenderManager class, which just have simple render() method that hides all these details. The method first calls vpp::CommandBufferRecorder for a command buffer, and then sends the buffer to the device for execution.

Lambda functions provide something we call implicit context. As it can be seen in the example, you do not provide any reference to Vulkan command buffer inside the lambdas – although core Vulkan requires supplying that parameter. This is because the commands can detect that they are being called from lambdas and obtain appropriate command buffer reference from calling vpp::CommandBufferRecorder object. This mechanism simplifies syntax and reduces amount of possible errors.

How VPP abstractions used in the example maps to core Vulkan concepts and objects? Here is the translation table: