HDK
All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Friends Macros Groups Pages
Vulkan Rendering with RV

Overview

Houdini's RV library is a thin wrapper around Vulkan. It wraps many of the Vulkan objects in classes and handles most of the burden of sychronization and memory allocation. It is also meant to be somewhat close to Houdini's RE library for OpenGL.

The main components of RV are:

  • RV_Geometry - defines all the necessary elements to draw geometry (vertex inputs, instancing, index buffers)
  • RV_ShaderProgram - defines a SPIR-V shader module, which is used to form a pipeline object used to render geometry.
  • RV_VKImage - defines a Vulkan Image, used for textures referenced by shaders.
  • RV_VKBuffer - defines a Vulkan buffer, used to back many different vulkan objects.
  • RV_ShaderBlock - defines a Uniform or Shared Shader Block, used to store shader parameters.
  • RV_ShaderVariableSet - defines a Descriptor Set, created from a pipeline layout of a shader, which contains references to shader blocks and images.
  • RV_Instance - The Vulkan Instance with many of the high level functions for managing Vulkan.
  • RV_Render - A per-thread wrapper around the Vulkan Instance and Device. RV_ShaderVariableSet and RV_ShaderProgam objects are bound to this object. The pipeline state is set on this object (blending, depth tests, etc).

At a high level, your hook will be passed an RE_RenderContext, which has a RV_Render in it when Vulkan is active. This is the main object used to create geometry, shaders, textures, descriptor sets, and buffers. Before rendering is enabled, the geometry and any textures need to be created and uploaded. Once the shader is selected, descriptor sets can be generated from it, and the uniform buffers and textures are attached to it. The descriptor sets and shader are then bound to the RV_Render, beginRendering() is called, @ RV_Geometry draw calls are issued, and then endRendering() finishes the cycle.

This section is not meant to be a primer on Vulkan. It is recommended that you have at least a high level knowledge of how Vulkan works before attempting to use the RV library. While the RV library hides the need to know the specific VK functions and enums needed to render and compute in Vulkan, having an idea of the shader pipeline, GLSL for Vulkan, and the various Vulkan objects is fairly necessary to write a Vulkan render hook.

Differences between Vulkan and OpenGL

Vulkan rendering differs from OpenGL in several aspects:

  • There is a difference between rendering and setup. Draw calls can only be issued when rendering, and uploads of data cannot be issued while rendering.
  • Buffers and textures cannot be updated while a draw that uses them is still pending without some form of synchronization if within the same frame. OpenGL would automatically serialize these operations; Vulkan does not. You can either use multiple buffers or textures, or insert synchronization between the draw and the next update to help with this case.
  • Uniforms need to be in shader blocks (RV_ShaderBlock); they can't be declared as individual entities.
  • Shader blocks and textures need to be bound to descriptor sets (RV_ShaderVariableSet), which are then bound to the RV_Render object before drawing.
  • Objects cannot be deleted until all command buffers they are present in have been executed. If you need to delete an object after a draw call, call RVdestroyVKPtr(), defined in RV_Render.h, to schedule it for deletion after the command buffer has executed.

GLSL can still be used with Vulkan. Houdini will compile GLSL files to SPIR-V, which Vulkan can ingest. The main difference between GLSL for OpenGL and GLSL for Vulkan is how resources are bound to the shader.

There is also no global state like in a GL context, though RV_Render can provide a temporary sense of state by setting the pipeline parameters, such as the blend and depth modes.

HDK_ViewportRV_Geometry

The first thing that generally needs to be done is to create some geometry to render. The RV_Geometry class has all the methods to create, update, and draw geometry. It holds the vertex buffers and index buffers that define the primitives to be drawn. This process is similar to working with RE_Geometry, but there is an extra step:

  1. Define the number of points in the geometry (@ setNumPoints()).
  2. Declare the format and sizes of all attributes used as vertex shader inputs. -% Declare any instanced attributes, along with the number of instances.
  3. Define size and type the index buffers (if any) for the conenction groups.
  4. Call populateBuffers() to optimally create buffers for each attribute.
  5. For each attribute, fetch the RV_Buffer and call uploadBuffer() with its data.

When the geometry is ready to be drawn, call one of the draw() methods. This must be done within a begin/endRendering() block or it will fail. Similarly, all of the above must be done outside a begin/endRendering() block or it will fail.

// given RV_Geometry *geo, and RV_Render *rv, create a quad
static UT_StringHolder Pname("P");
static UT_StringHolder Nname("N");
geo->setNumPoints(4);
// Set up a connection group to draw
// No index buffer
// Index buffer
geo->connectIndexPrims(0, RV_PRIM_TRIANGLES, 6);
// Allocate all buffers for our data
geo->populateBuffers(rv);
// Fill all buffers with our data
const fpreal32 pdata[] = { 0.,0.,0., 1.,0.,0., 0.,1.,0, 0.,1.,0., 1.,1.,0. };
geo->getAttribute(Pname)->uploadData(rv, pdata);
const fpreal32 ndata[] = { 0.,0.,1., 0.,0.,1., 0.,0.,1, 0.,0.,0., 0.,0.,1. };
geo->getAttribute(Nname)->uploadData(rv, ndata);
// For the index buffer case only; 2 triangles for a quad
const int32 index[] = { 0,1,2, 0,3,2 };
geo->getIndeXbuffer(0)->uploadData(rv, index);

Later, when rendering, to draw the quad just do:

geo->draw(rv, 0); // draw connection group 0

However, in order to see anything, a shader needs to be bound with its resources or nothing will happen.

Creating and Using Textures

Textures are backed by Images in Vulkan, and consist of an image and a sampler. The image defines the resolution, format, type, and data of the texture, while the sampler describes how it is accessed by the shader. RV Textures are a bit closer to how Vulkan sees tham than the high-level RV_Geometry class. You make a RV_VKImageCreateInfo with all the creation parameters for your image, pass that to RV_VKImage::create(), and that allocates your Image, Image View (if needed), and Sampler. This makes creating cubemap or 2D array textures a bit easier than in straight Vulkan. Instead of needing to create a 2D texture with 6 layers and a cube image view, you just need to set the image type to RV_IMAGE_CUBE.

One important thing to note is that 3-channel textures are generally not supported by Vulkan drivers. OpenGL drivers would silently convert 3-channel textures to 4-channel; Vulkan does not. Promote these to 4-channel textures before creating them.

// Given RV_Render *rv
// image and image view parameters (this is immutable)
tex_info.setSize( width, height );
tex_info.setMaxLevelCount( RV_MAX_MIP_LEVEL ); // for mipmapping
// sampler parameters
// (note the last RV_WRAP_REPEAT is ignored for 2D textures)
tex_info.setTextureWrap( RV_WRAP_REPEAT, RV_WRAP_REPEAT, RV_WRAP_REPEAT);
tex_info.setTextureMagFilter(RV_FILTER_LINEAR); // bilinear
tex_info.setTextureMipMode(RV_FILTER_LINEAR); // linear blend between mip levels
// Create the image
RV_VKImage *image = RV_VKImage::allocateImage(rv->instance(),&tex_info));
// Upload the image data to the image. data should be large enough to fill the
// image.
image->uploadData(rv, image_data, data_size_in_bytes);
// if mipmapping and you want to generate all higher mip levels rather than
// uploading each level:
image->generateMipmaps(rv);
// Make the image available for sampling from a shader.
image->transitionToSampling(r->getCurrentCB());
@code
To use a texture in a shader, it must be attached to a @c RV_ShaderVariableSet and declared in the shader; more on that in the next section.
@section HDK_ViewportRV_Shader Configuring a Shader
A shader defines where a piece of geometry appears in the scene and how it looks. It's part of a pipeline object, which defines the depth, blend, scissor and other rendering state. In RV, the idea of a pipeline object is abstracted away. @c RV_Render handles all the non-shader pipeline state, and @c RV_ShaderProgram handles everything related to the shader within the pipeline object.
Compute shaders are simpler and have no graphics state associated with them.
Shaders can be created from a @c .prog file or a list of text files.
@code
// Program file (.prog) method
const UT_StringHolder filename("path/to/shader.prog");
RV_ShaderProgram *sh = RV_ShaderProgram::loadShaderProgram(rv->instance(), filename);
// separate files method
UT_StringArray filenames;
filenames.append("path/to/vertex_stage.vert");
filenames.append("path/to/fragment_stage.frag");
RV_ShaderProgram *sh = RV_ShaderProgram:::createShaderProgram(rv->instance(), filenames);
UT_ASSERT(sh); // check that the shader was found, compiled and linked properly!

Compute shaders only have one stage (.comp) so it's often simpler to use the second method for them, though .prog files can be used for them as well.

Once you have the program loaded, you can then create any descriptor sets for its resources. A descriptor set is implemented by RV_ShaderVariableSet, which points to the various uniform buffers and textures used by the shader. More than one set can be used; Houdini uses up to 5 for its shaders, and 8 is the usual maximum for most desktop implementations.

There is a GR_Utils function to create the set for you:

// Given RV_ShaderProgram *sh, RV_Render *rv, RV_ShaderBlock *block, RV_VKImage *tex
#include <GR/GR_UtilsVK.h>
// Ensure a set is available for set binding #2 (as defined by the shader)
if(GR_Utils::createOrReuseSet(rv, set, 2, sh))
{
// success! set exists on the shader. Attach resources to it and bind it.
static UT_StringHolder blockname("block1");
rv->attachBufferBlock( rv->instance(), blockname, block);
static UT_StringHolder samplername("tex1");
rv->attachTexture(rv-instance(), samplername, tex);
}
// For drawing:
// Bind to the RV_Render in preparation for drawing
if(set)
rv->bindSet(set.get(), sh);
// Make this shader the current shader for future draws.
rv->setShader(sh);

Blocks and textures can be attached by either their binding index in the shader or their binding name. Binding by name is slightly safer, though in generate the shader and the code configuring the set and blocks needs to be kept in synch.

Using Shader Blocks

All shader parameters need to either go into a shader block or a push constant. This section will go over how to create and assign values to a shader block.

Shader blocks are usually created from a shader program:

#include <GR/GR_UtilsVK.h>
// Given RV_Render *rv, RV_ShaderProgram *sh, RV_ShaderVariableSet *set
if(GR_Utils::initBuiltinBlock(r, block, "blockName", set, sh))
{
// successfully created the block and assigned it to 'set'
}

You can also create the block without GR_Utils, by using:

if(!block)
{
const RV_VKDescriptorBinding *binding = shader->getBinding("blockName");
if(binding)
block.reset(RV_ShaderBlock::create(rv->instance(), *binding));
}

...and attach it to the set using:

set->attachBufferBlock(rv->instance(), blockName, block.get());

Once the buffer is created and attached to the set, you can update the contents whenever needed. To fill in the buffer's data, you can either fill the buffer directly from a structure (if you know the packing rules for UBOs and SSBOs) or on a per-uniform basic.

// assign data to it.
block->bindInt("myint", 4);
block->bindFloat("myfloat", 10.0);
block->bindVector(UT_Vector2F(0.5, 0.4));
block->bindVector(UT_Vector3F(1.0, 0.5, 0.2));
block->bindVector(UT_Vector4F(0.3, 0.6, 0.1, 1.0));
block->bindMatrix(UT_Matrix4F(1.0));
// for fixed arrays...
fpreal32 fdata[] = { 1.0, 2.0, 3.0, 4.0, 5.0 };
block->bindFloats("myfloats", fdata, 5);
// assign to a particular array element (#4).
block->bindFloat("myarray", 10.0, 3);
// or, to upload a C++ structure formated with the exact padding of the block
// NOTE: you need to know the UBO/SSBO padding rules very well to do this!
block->fillBuffer( struct_data, 0 /*at_byte_offset*/, sizeof(struct_data));
// To upload data to the variable array portion of an SSBO:
UT_ASSERT(block->getArrayLength() != 0);
block->uploadArray(rv, data, data_size_in_bytes, 0 /*at_byte_offset*/);
// Upload the buffer to the GPU (don't forget!)
block->uploadBuffer(rv);
}

The memory given to the buffer is completely Uninitialized, so if a uniform is not assigned a value with one of the bind or fill methods, it will be undefined.

Using Push Constants

Push Constants are a fast way to set shader values without using buffers. They have very limited space (usually 128B-256B) and are not associated with any shader. Unlike buffer objects, push constants need to be updated with every draw, as some other draw may have modified them. This is a very specific optimization which should be the result of deliberate shader profiling.

To fetch the push constants, call RV_Render::getPushConstants(). Push Constants use the same binding functions as shader blocks.