Shader modules

Unlike earlier APIs, shader code in Vulkan has to be specified in a bytecode format as opposed to human-readable syntax like GLSL SLANG, and HLSL. This bytecode format is called SPIR-V and is designed to be used with Vulkan (a Khronos API). It is a format that can be used to write graphics and compute shaders, but we will focus on shaders used in Vulkan’s graphics pipelines in this tutorial.

The advantage of using a bytecode format is that the compilers written by GPU vendors to turn shader code into native code are significantly less complex. The past has shown that with human-readable syntax like GLSL, some GPU vendors were rather flexible with their interpretation of the standard. If you happen to write non-trivial shaders with a GPU from one of these vendors, then you’d risk another vendor’s drivers rejecting your code due to syntax errors, or worse, your shader running differently because of compiler bugs. With a straightforward bytecode format like SPIR-V that will hopefully be avoided.

However, that does not mean that we need to write this bytecode by hand. Khronos has released their own vendor-independent compiler that compiles Slang to SPIR-V. This compiler is designed to verify that your shader code is fully standards compliant and produces one SPIR-V binary that you can ship with your program. You can also include this compiler as a library to produce SPIR-V at runtime, but we won’t be doing that in this tutorial, until we get into reflection sometime in a future chapter. Although we can use this compiler directly via slangc, we will be using slangc in our cmake build process instead.

Slang is a shading language with a C-style syntax. Programs written in it have a main entry point which is invoked for every object. Like HLSL, Slang uses parameters and return values for input and output with annotations to help describe what those variables relate to. The language includes many features to aid in graphics programming, like built-in vector and matrix primitives. Functions for operations like cross-products, matrix-vector products, auto differentiation for AI, and reflections around a vector are included. The vector type is called float with a number indicating the number of elements. For example, a 3D position would be stored in a float3.

It is possible to access single components through members like .x called the swizzle operator, but it’s also possible to create a new vector from multiple components at the same time. For example, the expression float3(1.0,2.0, 3.0).xy would result in float2. The constructors of vectors can also take combinations of vector objects and scalar values. For example, a float3 can be constructed with float3(float2(1.0, 2.0), 3.0).

As the previous chapter mentioned, we need to write a vertex shader and a fragment shader to get a triangle on the screen. The next two sections will cover the Slang code those, and after that I’ll show you how to produce one SPIR-V binaries and load it into the program.

Vertex shader

The vertex shader processes each incoming vertex. It takes its attributes, like world position, color, normal and texture coordinates as input. The output is the final position in clip coordinates and the attributes that need to be passed on to the fragment shader, like color and texture coordinates. These values will then be interpolated over the fragments by the rasterizer to produce a smooth gradient.

A clip coordinate is a four-dimensional vector from the vertex shader that is subsequently turned into a normalized device coordinate by dividing the whole vector by its last component. These normalized device coordinates are homogeneous coordinates that map the framebuffer to a [-1, 1] by [-1, 1] coordinate system that looks like the following:

normalized device coordinates

You should already be familiar with these if you have dabbled in computer graphics before. If you have used OpenGL before, then you’ll notice that the sign of the Y coordinates is now flipped. The Z coordinate now uses the same range as it does in Direct3D, from 0 to 1.

For our first triangle we won’t be applying any transformations, we’ll just specify the positions of the three vertices directly as normalized device coordinates to create the following shape:

triangle coordinates

We can directly output normalized device coordinates by outputting them as clip coordinates from the vertex shader with the last component set to 1. That way, the division to transform clip coordinates to normalized device coordinates will not change anything.

Normally these coordinates would be stored in a vertex buffer, but creating a vertex buffer in Vulkan and filling it with data is not trivial. Therefore, I’ve decided to postpone that until after we’ve had the satisfaction of seeing a triangle pop up on the screen. We’re going to do something a little unorthodox in the meanwhile: include the coordinates directly inside the vertex shader. The code looks like this:

static float2 positions[3] = float2[](
    float2(0.0, -0.5),
    float2(0.5, 0.5),
    float2(-0.5, 0.5)
);

struct VertexOutput {
    float4 sv_position : SV_Position;
};

[shader("vertex")]
VertexOutput vertMain(uint vid : SV_VertexID) {
    VertexOutput output;
    output.sv_position = float4(positions[vid], 0.0, 1.0);
    return output;
}

The vertMain function is invoked for every vertex. The built-in SV_VertexID annotated variable in the parameters contains the index of the current vertex. This is usually an index into the vertex buffer, but in our case, it will be an index into a hardcoded array of vertex data. The position of each vertex is accessed from the constant array in the shader and combined with dummy z and w components to produce a position in clip coordinates. The built-in annotation SV_Position functions as the output. Within the VertexOutput struct. Something worth mentioning if you’re familiar with other shading languages like GLSL or HLSL, there are no instructions for bindings. This is a feature of Slang. Slang is designed to automatically infer the bindings by the order of declaration. The struct for positions is a static to inform the compiler that we don’t need any bindings in our shader. Studious observers will notice that we’re calling our main function vertMain instead of main, this is because Slang and SPIR-V both support having multiple entry points in one file. This is important when you’re dealing with pipelines which have more than just a single vert/frag shader combo. Ray-tracing for instance, for even simple demos would have four or more shaders all small yet needing their own file which can become cumbersome. Another major feature of Slang is the ability to create shader libraries or modules; an exercise left to the reader is to explore more about this feature rich shading language.

In this tutorial, we’re going to demonstrate best practices by keeping the shaders to a single file. If you know GLSL, there’s GLSL versions of the shaders in the attachments folder which are direct translations.

Fragment shader

The triangle formed by the positions from the vertex shader fills an area on the screen with fragments. The fragment shader is invoked on these fragments to produce a color and depth for the framebuffer (or framebuffers). A simple fragment shader that outputs the color red for the entire triangle looks like this:

[shader("fragment")]
float4 fragMain() : SV_Target
{
    return float4(1.0, 0.0, 0.0, 1.0);
}

The fragMain entry point function is called for every fragment just like the vertex shader vertMain function is called for every vertex. Colors in Slang are 4-component vectors with the R, G, B and alpha channels within the [0, 1] ranges. Unlike SV_Position in the vertex shader, there is no built-in variable to output a color for the current fragment. You have to specify your own output variable for each framebuffer where the SV_TARGET annotation specifies the index of the framebuffer. The color red is written to this outColor variable that is linked to the first (and only) framebuffer at index 0.

Per-vertex colors

Making the entire triangle red is not very interesting, wouldn’t something like the following look a lot nicer?

triangle coordinates colors

We have to make a couple of changes to both shaders to achieve this. First off, we need to specify a distinct color for each of the three vertices. The vertex shader should now include an array with colors just like it does for positions:

static float3 colors[3] = float3[](
    float3(1.0, 0.0, 0.0),
    float3(0.0, 1.0, 0.0),
    float3(0.0, 0.0, 1.0)
);

Now we just need to pass these per-vertex colors to the fragment shader so it can output their interpolated values to the framebuffer. Add an output for color to the vertex shader and write to it in the vertMain function:

struct VertexOutput {
    float3 color;
    float4 sv_position : SV_Position;
};

[shader("vertex")]
VertexOutput vertMain(uint vid : SV_VertexID) {
    VertexOutput output;
    output.sv_position = float4(positions[vid], 0.0, 1.0);
    output.color = colors[vid];
    return output;
}

Next, we need to add a matching parameter in the fragment shader:

[shader("fragment")]
float4 fragMain(VertexOutput inVert) : SV_Target
{
    float3 color = inVert.color;
    return float4(color, 1.0);
}

The input variable does not necessarily have to use the same name, however, if they are in the same file, it really is convenient to not repeat ourselves. But either way, they will be linked together using the indexes specified by the location directives. The fragMain function has been modified to output the color along with an alpha value. As shown in the image above, the values for fragColor will be automatically interpolated for the fragments between the three vertices, resulting in a smooth gradient.

Compiling the shaders

Create a directory called shaders in the root directory of your project and store the shaders in a file called shader.slang

The contents of shader.slang should be:

static float2 positions[3] = float2[](
    float2(0.0, -0.5),
    float2(0.5, 0.5),
    float2(-0.5, 0.5)
);

static float3 colors[3] = float3[](
    float3(1.0, 0.0, 0.0),
    float3(0.0, 1.0, 0.0),
    float3(0.0, 0.0, 1.0)
);

struct VertexOutput {
    float3 color;
    float4 sv_position : SV_Position;
};

[shader("vertex")]
VertexOutput vertMain(uint vid : SV_VertexID) {
    VertexOutput output;
    output.sv_position = float4(positions[vid], 0.0, 1.0);
    output.color = colors[vid];
    return output;
}

[shader("fragment")]
float4 fragMain(VertexOutput inVert) : SV_Target
{
    float3 color = inVert.color;
    return float4(color, 1.0);
}

We’re now going to compile these into SPIR-V bytecode using the slangc program.

Windows

Create a compile.bat file with the following contents:

C:/VulkanSDK/x.x.x.x/bin/slangc.exe shader.slang -target spirv -profile spirv_1_4 -emit-spirv-directly -fvk-use-entrypoint-name -entry vertMain -entry fragMain -o slang.spv

Replace the path to slangc.exe with the path to where you installed the Vulkan SDK. Double-click the file to run it.

Linux

Create a compile.sh file with the following contents:

/home/user/VulkanSDK/x.x.x.x/x86_64/bin/slangc shader.slang -target spirv -profile spirv_1_4 -emit-spirv-directly -fvk-use-entrypoint-name -entry vertMain -entry fragMain -o slang.spv

Replace the path to slangc with the path to where you installed the Vulkan SDK. Make the script executable with chmod +x compile.sh and run it.

End of platform-specific instructions

These two commands tell the compiler to read the Slang source file and output a SPIR-V 1.4 bytecode file directly using the -o (output) flag.

Note: At the time of writing SlangC will natively support SPIR-V 1.3 and above without needing to go through emitting GLSL to get to SPIR-V. While everything in this tutorial could work in SPIR-V 1.0, it would require us to break the Slang shaders up into multiple files which begs the question, what’s the point? Plus, SPIR-V 1.4 starting from 1.4 means you’ll be familiar with the latest the standard has to offer rather than starting from an older version.

If your shader contains a syntax error, then the compiler will tell you the line number and problem, as you would expect. Try leaving out a semicolon, for example, and run the compiler script again. Also try running the compiler without any arguments to see what kinds of flags it supports. It can, for example, also output the bytecode into a human-readable format, so you can see exactly what your shader is doing and any optimizations that have been applied at this stage.

Compiling shaders on the commandline is one of the most straightforward options, yet the best path and one we use in this tutorial is to create a CMake function:

function (add_slang_shader_target TARGET)
  cmake_parse_arguments ("SHADER" "" "SOURCES" ${ARGN})
  set (SHADERS_DIR ${CMAKE_CURRENT_LIST_DIR}/shaders)
  set (ENTRY_POINTS -entry vertMain -entry fragMain)
  add_custom_command (
          OUTPUT ${SHADERS_DIR}
          COMMAND ${CMAKE_COMMAND} -E make_directory ${SHADERS_DIR}
  )
  add_custom_command (
          OUTPUT  ${SHADERS_DIR}/slang.spv
          COMMAND ${SLANGC_EXECUTABLE} ${SHADER_SOURCES} -target spirv -profile spirv_1_4 -emit-spirv-directly -fvk-use-entrypoint-name ${ENTRY_POINTS} -o slang.spv
          WORKING_DIRECTORY ${SHADERS_DIR}
          DEPENDS ${SHADERS_DIR} ${SHADER_SOURCES}
          COMMENT "Compiling Slang Shaders"
          VERBATIM
  )
  add_custom_target (${TARGET} DEPENDS ${SHADERS_DIR}/slang.spv)
endfunction()

Then you can add the Slang build step to your target like this:

add_slang_shader_target( foo SOURCES ${SHADER_SLANG_SOURCES})
target_add_dependencies(bar PUBLIC foo)

Loading a shader

Now that we have a way of producing SPIR-V shaders, it’s time to load them into our program to plug them into the graphics pipeline at some point. We’ll first write a simple helper function to load the binary data from the files.

#include <fstream>

...

static std::vector<char> readFile(const std::string& filename) {
    std::ifstream file(filename, std::ios::ate | std::ios::binary);

    if (!file.is_open()) {
        throw std::runtime_error("failed to open file!");
    }
}

The readFile function will read all the bytes from the specified file and return them in a byte array managed by std::vector. We start by opening the file with two flags:

  • ate: Start reading at the end of the file

  • binary: Read the file as a binary file (avoid text transformations)

The advantage of starting to read at the end of the file is that we can use the read position to determine the size of the file and allocate a buffer:

std::vector<char> buffer(file.tellg());

After that, we can seek back to the beginning of the file and read all the bytes at once:

file.seekg(0, std::ios::beg);
file.read(buffer.data(), static_cast<std::streamsize>(buffer.size()));

And finally, close the file and return the bytes:

file.close();

return buffer;

We’ll now call this function from createGraphicsPipeline to load the bytecode of the two shaders:

void createGraphicsPipeline() {
    auto shaderCode = readFile("shaders/slang.spv");
}

Make sure that the shaders are loaded correctly by printing the size of the buffers and checking if they match the actual file size in bytes. Note that the code doesn’t need to be null terminated since it’s binary code, and we will later be explicit about its size.

Creating shader modules

Before we can pass the code to the pipeline, we have to wrap it in a VkShaderModule object. Let’s create a helper function createShaderModule to do that.

[[nodiscard]] vk::raii::ShaderModule createShaderModule(const std::vector<char>& code) const {

}

The function will take a buffer with the bytecode as parameter and create a VkShaderModule from it.

Creating a shader module is straightforward, we only need to specify a pointer to the buffer with the bytecode and the length of it. This information is specified in a VkShaderModuleCreateInfo structure. The one catch is that the size of the bytecode is specified in bytes, but the bytecode pointer is a uint32_t pointer rather than a char pointer. Therefore, we will need to cast the pointer with reinterpret_cast as shown below. When you perform a cast like this, you also need to ensure that the data satisfies the alignment requirements of uint32_t. Lucky for us, the data is stored in an std::vector where the default allocator already ensures that the data satisfies the worst case alignment requirements.

vk::ShaderModuleCreateInfo createInfo{ .codeSize = code.size() * sizeof(char), .pCode = reinterpret_cast<const uint32_t*>(code.data()) };

The VkShaderModule can then be created with a call to vkCreateShaderModule:

vk::raii::ShaderModule shaderModule{ device, createInfo };

The parameters are the same as those in previous object creation functions: the logical device, pointer to create info structure, optional pointer to custom allocators and handle output variable. The buffer with the code can be freed immediately after creating the shader module. Remember to return the created shader module:

return shaderModule;

Shader modules are just a thin wrapper around the shader bytecode that we’ve previously loaded from a file and the functions defined in it. The compilation and linking of the SPIR-V bytecode to machine code for execution by the GPU doesn’t happen until the graphics pipeline is created. That means that we’re allowed to destroy the shader modules again as soon as pipeline creation is finished, which is why we’ll make them local variables in the createGraphicsPipeline function instead of class members:

void createGraphicsPipeline() {
    vk::raii::ShaderModule shaderModule = createShaderModule(readFile("shaders/slang.spv"));

Shader stage creation

To actually use the shaders, we’ll need to assign them to a specific pipeline stage through VkPipelineShaderStageCreateInfo structures as part of the actual pipeline creation process.

We’ll start by filling in the structure for the vertex shader, again in the createGraphicsPipeline function.

vk::PipelineShaderStageCreateInfo vertShaderStageInfo{ .stage = vk::ShaderStageFlagBits::eVertex, .module = shaderModule,  .pName = "vertMain" };

The first two parameters are the flags and the stage that we’re operating in. The next two parameters specify the shader module containing the code, and the function to invoke, known as the entrypoint. That means that it’s possible to combine multiple fragment shaders into a single shader module and use different entry points to differentiate between their behaviors.

There is one more (optional) member, pSpecializationInfo, which we won’t be using here, but is worth discussing. It allows you to specify values for shader constants. You can use a single shader module where its behavior can be configured in pipeline creation by specifying different values for the constants used in it. This is more efficient than configuring the shader using variables at render time, because the compiler can do optimizations like eliminating if statements that depend on these values. If you don’t have any constants like that, then you can set the member to nullptr, which our struct initialization does automatically.

Modifying the structure to suit the fragment shader is easy:

vk::PipelineShaderStageCreateInfo fragShaderStageInfo{ .stage = vk::ShaderStageFlagBits::eFragment, .module = shaderModule, .pName = "fragMain" };

Finish by defining an array that contains these two structs, which we’ll later use to reference them in the actual pipeline creation step.

vk::PipelineShaderStageCreateInfo shaderStages[] = {vertShaderStageInfo, fragShaderStageInfo};

That’s all there is describing the programmable stages of the pipeline. In the next chapter, we’ll look at the fixed-function stages.