VPP  0.7
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:

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:

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

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.