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

VPP completely removes the need of using external languages (like GLSL or GLSLang) to write shaders. Just write them in C++. Also it does not need any special support from the compiler. It suffices to have a C++14 compliant compiler.

You write shaders just as regular methods of your vpp::PipelineConfig (or vpp::ComputePipelineConfig) derived subclass. Rendering resources (vertices, textures, buffers, etc.) are accessed just as class fields. You can call other methods from these methods. You can create reusable libraries of shader code. You can use templates, classes, virtual functions, object-oriented code, generic code, anything you want – or almost, as there are some limitations.

To understand what these are, you need to know how the mechanism work. It is actually quite simple.

In order to run code on GPU under Vulkan, you have to supply it in SPIR-V format. SPIR-V is a standard defining a kind of virtual machine, just like Java VM. But this code is not being run by an interpreter (although it could, e.g. for debugging purposes), but rather translated inside a GPU driver to the machine code of the GPU. So this is basically a JIT compilation going on. All these details are hidden, you just need to generate a SPIR-V module and send it to Vulkan.

How to generate a SPIR-V module? This is just some data block in specific format. So VPP can do it without any problems. But how to generate such code from the C++ code, that is an integral part of the process we are running and which generates the SPIR-V? Do we need source code, or special compiler introspection features (like Java)? The answer is: no!

The trick is that we have a C++ regular method which does one thing when being called: it translates ITSELF into SPIR-V. That's all.

This process is correct, because C++ executes code just in the same order as it is written, according to strict rules. There are some ambiguities, e.g. subexpression evaluation order is undefined. But exactly the same situation we have in GLSL/GLSLang. So we can have e.g. an overloaded addition operator, which does just this:

  • assume that C++ already evaluated both operands in unspecified order (C++ standard guarantees that),
  • get expression identifiers of the operands,
  • issue an OpFAdd instruction to SPIR-V output stream.

As long as dependencies are satisfied (this process guarantees that), every expression will be correctly translated to SPIR-V.

But what about control constructs, like if, for, switch, etc? There is more complicated work to be done. First of all, we can't use regular C++ keywords. They are not overloadable in C++ so we can't force them to do other things for us than those they were made to. Therefore, VPP introduces its own counterparts starting with capital letters: vpp:If(), vpp::For(), vpp::Switch(), etc.

Now, thanks to some clever planning and bookkeeping, VPP can generate proper SPIR-V control structures from these.

Combining extensive operator overloading with some special functions, VPP can achieve the goal: have a method in C++ being translated into SPIR-V as it is being run.

What are the drawbacks and limitations of the process? There are some, but in practice they are not that severe. for example:

  • For block control constructs, you must use opening and closing keyword (like in old languages like Algol or Modula-2), for example:

    using namespace vpp;
    Bool bExpression = ...; // some value
    // 'if' example
    If ( bExpression );
    // ... code
    Else();
    // ... another code
    Fi();
    // 'for' example
    Int nIterations = ...; // some value
    VInt i;
    For ( i, 0, nIterations );
    {
    // iterated code, i goes from 0 to nIterations-1
    // Also this one creates a C++ scope, allowing local variables.
    }
    Rof();

    As you can see, the code looks slightly different than regular C++, but not really that different.

  • The compiler will not detect all of errors in the code. It will complain on all syntax and type errors, but will miss some errors like missing Rof() or Fi(). Such errors are easily found by hand, though. Just remember that every If() must have matching Fi(), and so on.
  • Static variables of GPU types do not have much sense. However, if you want to store some constants as static const variables (e.g. the value of PI), there will be no problem. They will be just regular C++ constants.
  • C++ is generally more strict about data types than GLSLang, so you might need explicit cast sometimes, e.g. to cast Int to UInt.
  • VPP distinguishes between mutable variables like vpp::VInt and immutable ones like vpp::Int. This is however performance-oriented decision rather than limitation. Mutable variables on GPU exhaust register pool very quickly, leading to horribly slow shaders when abused. Therefore the plain Int is immutable and slightly more fancy VInt is mutable.

There are as well some good things, that might not be apparent initially:

  • Interoperability between C++ constructs and VPP constructs is quite useful. Actually C++ acts like a macro language to the GPU code. So you can still use if or switch to select blocks of code, based on C++ expression which is constant during execution of GPU code. This is easy method to make parameterized shader variants, e.g. to use different lighting equations depending on a parameter. Also regular for construct might be useful to create unfolded loops with predefined iteration counts.
  • Any C++ variable used in GPU expression will be treated as constant and its current value will be compiled into SPIR-V. This is another opportunity for parametrization.
  • When you call a C++ function from a shader method, all shader code generated inside the called function will be appropriately inlined inside the calling shader. You can pass references to mutable or immutable variables and they will just work as expected. The function may come from any source, even external library or some template. (In addition to that, VPP of course offers a special syntax for regular GPU-level functions, so you may avoid inlining if it is undesirable).
  • You can define classes having GPU-typed fields and running GPU code. The limitation is that they can be used only as local objects (no heap allocation, as there is no such thing on GPU).
  • You can define templates of functions and classes and they will work as expected.
  • Alternatively you can use preprocessor macros instead and this also will work as expected.
  • Evaluation and execution order in resulting SPIR-V will exactly match evaluation and execution order in C++. This means that operators with well-defined evaluation order (like boolean ||) will behave just as expected in SPIR-V.
  • It is easy to define C++ functions that do various "special things" in SPIR-V, e.g. set an execution mode instead of generating an instruction. VPP uses this possibility liberally. One of more interesting applications is vpp::DebugProbe() debugging aid.
  • VPP shaders do not use location and binding indices explicitly to match resources. Instead you directly access binding points which are declared as fields in the class. All resources are matched by name, in type-safe manner.
  • Compute shaders are fully supported. This enables creation of reusable GPU libraries, e.g. for mathematical algorithms or other applications. For now, such libraries exist but require complex proprietary technologies, like CUDA. VPP allows creation of GPU libraries which have no external dependencies, are extremely easy to integrate and run on any hardware that supports GLCompute shader model (probably all Vulkan-supporting GPUs).

A simple example of coding shaders in VPP:

// Define data structure for vertices. There are two attributes.
// Blocks of 4*float will be interleaved.
template< vpp::ETag TAG >
struct TVertexAttr : public vpp::VertexStruct< TAG, TVertexAttr >
{
};
// Define GPU-level version of the structure.
typedef TVertexAttr< vpp::CPU > CVertexAttr;
// Define CPU-level version of the structure. GPU and CPU version
// layouts are compatible.
typedef TVertexAttr< vpp::GPU > GVertexAttr;
// Define data structure for frame parameters (matrices).
template< vpp::ETag TAG >
struct TFramePar : public vpp::UniformStruct< TAG, TFramePar >
{
inline TFramePar() {}
inline TFramePar (
const glm::mat4& m2w, const glm::mat4& w2v, const glm::mat4& v2p ) :
m_model2world ( m2w ), m_world2view ( w2v ), m_view2projected ( v2p )
{}
// This defines a field of 4x4 matrix type.
};
// Define GPU-level version of the structure.
typedef TFramePar< vpp::GPU > GFramePar;
// Define CPU-level version of the structure. GPU and CPU version
// layouts are compatible.
typedef TFramePar< vpp::CPU > CFramePar;
// Define user pipeline class.
class MyPipeline : public vpp::PipelineConfig
{
public:
MyPipeline (
const vpp::Process& pr, const vpp::Device& dev, const vpp::Display& outImage );
// GPU-level method defining a vertex shader.
void fVertexShader ( vpp::VertexShader* pShader );
// GPU-level method defining a fragment shader.
void fFragmentShader ( vpp::FragmentShader* pShader );
private:
// Binding point for vertices.
// Binding point for frame parameters (world matrix, etc.).
// Inter-shader communication variable to pass some data to fragment shader.
// This will by default create a 'varying' variable (with interpolation).
// Resulting image.
// Binding point for the vertex shader.
vpp::vertexShader m_vertexShader;
// Binding point for the fragment shader.
vpp::fragmentShader m_fragmentShader;
};
MyPipeline :: MyPipeline (
const vpp::Process& pr, const vpp::Device& dev, const vpp::Display& outImage ) :
// The pipeline needs to know which process it is accociated with.
vpp::PipelineConfig ( pr ),
// Output directly to the Display node (associated with some screen Surface).
m_outColor ( outImage ),
// Bind the vertex shader method.
m_vertexShader ( this, & MyPipeline::fVertexShader ),
// Bind the fragment shader method.
m_fragmentShader ( this, & MyPipeline::fFragmentShader )
{
}
void MyPipeline :: fVertexShader ( vpp::VertexShader* pShader )
{
using namespace vpp;
// This code will be translated to SPIR-V and execute on GPU!
// Accessor for m_framePar binding point.
// Just read the values in type-safe manner.
// Note that structure and fields are identified by name.
const Mat4 m2w = inFramePar [ & GFramePar::m_model2world ];
const Mat4 w2v = inFramePar [ & GFramePar::m_world2view ];
const Mat4 v2p = inFramePar [ & GFramePar::m_view2projected ];
// Read vertices just as above. Vertices do not need an accessor.
const Vec4 inPos = m_vertices [ & GVertexAttr::m_position ];
const Vec4 inColor = m_vertices [ & GVertexAttr::m_color ];
// Computation.
const Vec4 result = v2p * w2v * m2w * inPos;
// Write result to predefined shader variable.
pShader->outVertex.position = result;
// Accessor for additional output variable, passing data to the
// fragment shader.
Output< decltype ( m_ioColor ) > outColor ( m_ioColor );
// Write the data.
outColor = inColor;
}
void MyPipeline :: fFragmentShader ( vpp::FragmentShader* pShader )
{
using namespace vpp;
// This code will be translated to SPIR-V and execute on GPU!
// Accessor for an input variable, passing data from the
// vertex shader. Note it uses the same m_ioColor binding point
// as the counterpart on writing side.
Input< decltype ( m_ioColor ) > inColor ( m_ioColor );
// Just read the value.
const Vec4 color = inColor;
// Just write the value to the output image (output images
// do not need accessors).
m_outColor = color;
}

In general, VPP achieves the level of abstraction and conciseness of the code better than OpenGL, while maintaining high performance of core Vulkan, benefitting from type-safety of C++, and enjoying extreme simplicity and accessibility to new developers.