If you have been following Vulkan lately, you will have heard about SPIR-V, the new shading language format used in Vulkan. We decided early on to standardize our internal engine on SPIR-V, as we needed a way to cleanly support both our OpenGL ES backend as well as Vulkan without modifying shaders.
From our experience, having #ifdefs in a shader which depend on the graphics API you are targeting is not maintainable. Our old system was based on text replacement which became more and more unmaintainable as new API features emerged. We wanted something more robust and having a standard IR format in SPIR-V was the final piece of the puzzle to enable this. Using our engine, we developed a demo showcasing Vulkan at GDC, please see my colleague's blog post for more information on the topic.
SPIR-V is a binary, intermediate shading language format. This is great because it means that you no longer have to deal with vendor-specific issues in the GLSL frontend of the driver. The flipside of this however is that you now have to consider how to compile a high level shading language down to SPIR-V.
The best alternatives for this currently is using Khronos' glslang library or Google's shaderc.
These tools can compile GLSL down to SPIR-V which you can then use in Vulkan. The optimal place to do this compilation is during your build system so that you don't have to ship shader source in your app. We're close then, at least in theory, to having a unified system for GLES and Vulkan.
While our intention to use GLSL as our high level language makes sense when targeting GL/Vulkan, there are problems, one of which is that there are at least 5 main dialects of GLSL:
Vulkan GLSL is a new entry to the list of GLSL variants. The reason for this is that we need a way to map GLSL to newer features found in Vulkan.
Vulkan GLSL adds some incompatibilities with all the other GLSL variants, for example:
This makes it problematic to write GLSL that can work in both GL and Vulkan at the same time and whilst it is always possible to use #ifdef VULKAN, this is a road we don't want to go down. As you might expect from the blog title, we solved this with SPIRV-Cross, but more on that later in the post
If you're starting out with simple applications in Vulkan, you don't have to deal with this topic quite yet, but once your engine starts to improve, you will very soon run into a fundamental difference between OpenGL ES/GLSL and Vulkan/SPIR-V. It is generally very useful to be able to query meta-data about the shader file you are working with. This is especially important in Vulkan since your pipeline creation very much depends on information that is found inside your shaders. Vulkan, being a very explicit API expects you as the API user to know this information up front and expects you to provide a VkPipelineLayout that describes which types of resources are used inside your shader.
Kind of like a function prototype for your shader.
VkGraphicsPipelineCreateInfo pipeline = { ... .layout = pipelineLayout, ... };
The pipeline layout describes which descriptor sets you are using as well as push constants. This serves as the "function prototype" for your shader.
VkPipelineLayoutCreateInfo layout = { .setLayoutCount = NELEMS(setLayouts), .pSetLayouts = setLayouts, ... };
Inside the set layouts is where you describe which resources you are using, for example
// For first descriptor set VkDescriptorSetLayoutBinding bindings[] = { { .binding = 0, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT }, { .binding = 2, .descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, .descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_VERTEX_BIT }, };
You might ask how you would know this information before you have compiled your pipeline, but Vulkan does not provide an API for this because this is vendor-neutral code that shouldn't need to be implemented the same way N times over by all vendors.
In your simple applications you probably know this information up front. After all, you wrote the shader, so you can fill in this layout information by hand, and you probably just have one or two resources anyway, so it's not that big a deal. However, start to consider more realistic shaders in a complex application and you soon realize that you need a better solution.
In GLES, the driver provides us with reflection to some extent, for example:
GLint location = glGetUniformLocation(program, "myUniform"); ... glUniform4fv(location, 1, uniformData); GLint attrib = glGetAttribLocation(program, "TexCoords");
To solve the problems of reflection and Vulkan GLSL/GLSL differences, we developed a tool and library, SPIRV-Cross, that will be hosted by Khronos and you can find it on GitHub.
This tool was originally published on our Github as spir2cross, but we have donated the tool to Khronos Group and future development will happen there. The primary focus of the library is to provide a comprehensive reflection API as well as supporting translating SPIR-V back to high level shader languages.
This allowed us to design our entire engine around Vulkan GLSL and SPIR-V while still supporting GLES and desktop GL.
We wanted to design our pipeline so that the Vulkan path was as straight forward as we could make it, and we could deal with how to get back to GL/GLES while still being robust and sensible.
We found that it is much simpler to deal with GL specifics when disassembling from SPIR-V, it is not trivial to meaningfully modify SPIR-V in its raw binary format. It therefore made sense to write all our shader code targeting Vulkan, and deal with GL semantics later.
In Vulkan, as we write in Vulkan GLSL, we simply use glslang to compile our sources down to SPIR-V. This shader source can then be directly given to the Vulkan driver. We still need reflection however, as we need to build pipeline layouts.
#include "spirv_cross.hpp" { // Read SPIR-V from disk or similar. std::vector<uint32_t> spirv_binary = load_spirv_file(); spirv_cross::Compiler comp(std::move(spirv_binary)); // The SPIR-V is now parsed, and we can perform reflection on it. spirv_cross::ShaderResources resources = comp.get_shader_resources(); // Get all sampled images in the shader. for (auto &resource : resources.sampled_images) { unsigned set = comp.get_decoration(resource.id, spv::DecorationDescriptorSet); unsigned binding = comp.get_decoration(resource.id, spv::DecorationBinding); add_sampled_image_to_layout(set, binding); } // And so on for other resource types. }
We also need to figure out if we are using push constants. We can get reflection information about all push constant variables which are actually in use per stage, and hence compute the range which should be part of the pipeline layout.
spirv_cross::BufferRanges ranges = compiler.get_active_buffer_ranges(resources.push_constant_buffers.front().id);
From this, we can easily build our push constant ranges.
In GLES, things are slightly more involved. Since our shader sources are in Vulkan GLSL we need to make some tranformations before converting back to GLSL.
The GLES backend consumes SPIR-V, so we still do not have to compile shader sources in runtime. From that, we perform the same kind of reflection as in Vulkan.
In SPIRV-Cross, push constants are implemented as uniform structs, which map very closely to push constants:
layout(push_constant, std430) uniform VulkanPushConstant { mat4 MVP; vec4 MaterialData; } registerMapped;
becomes:
struct VulkanPushConstant { mat4 MVP; vec4 MaterialData; }; uniform VulkanPushConstant registerMapped;
in GLSL.
Using the reflection API for push constants we can then build a list of glUniform calls which will implement push constants for us.
We also need to remap descriptor sets and bindings. OpenGL has a linear binding space which is sorted per type, e.g. binding = 0 for uniform buffers and binding = 0 for sampled images are two different binding points, but this is not the case in Vulkan. We chose a simple scheme which allocates linear binding space from the descriptor set layouts.
Let's say we have a vertex and fragment shader which collectively use these bindings.
In set 0, the range of uniform bindings used are [1, 1]. Textures are [2, 3]. We allocate the first binding in uniform buffer space to set 0, with binding offset -1.
To remap set/binding to linear binding space, it's a simple lookup.
linearBinding = SetsLayout[set].uniformBufferOffset + binding
textureBinding = SetsLayout[set].texturesOffset + binding
...
For set 1 for example, binding = 1 would be mapped to binding = 2 since set 0 consumed the two first bindings for textures. Similarly, for uniforms, the uniform buffer in set 1 would be mapped to binding = 1.
Before compilation back to GLSL we strip off all binding information using the SPIRV-Cross reflection API. After we have linked our GL program we can bind the resources to their new correct binding points.
Using this scheme we managed to use Vulkan GLSL and a Vulkan-style API in GLES without too many complications.
Our old system supported Pixel Local Storage, and we did not want to lose that by going to Vulkan GLSL so we use the SPIRV-Cross PLS API to convert subpass inputs to PLS inputs and output blocks.
vector<PlsRemap> inputs; vector<PlsRemap> outputs; // Using reflection API here, can have some magic variable names that will be used for PLS in GLES and subpass inputs in Vulkan. compiler.remap_pixel_local_storage(move(inputs), move(outputs));
Hopefully this blog entry gave you some insights into how you can integrate better with SPIR-V in your Vulkan apps.