Introducing: SDL_shadercross

The SDL GPU API has been merged, and SDL3 is now in ABI-stable preview.

I’d like to draw your attention to the the following datatype of the API.

typedef Uint32 SDL_GPUShaderFormat;

#define SDL_GPU_SHADERFORMAT_INVALID  0
#define SDL_GPU_SHADERFORMAT_PRIVATE  (1u << 0) /**< Shaders for NDA'd platforms. */
#define SDL_GPU_SHADERFORMAT_SPIRV    (1u << 1) /**< SPIR-V shaders for Vulkan. */
#define SDL_GPU_SHADERFORMAT_DXBC     (1u << 2) /**< DXBC SM5_1 shaders for D3D12. */
#define SDL_GPU_SHADERFORMAT_DXIL     (1u << 3) /**< DXIL shaders for D3D12. */
#define SDL_GPU_SHADERFORMAT_MSL      (1u << 4) /**< MSL shaders for Metal. */
#define SDL_GPU_SHADERFORMAT_METALLIB (1u << 5) /**< Precompiled metallib shaders for Metal. */

As you can see, these formats refer to types of shader code. Each backend of the GPU API accepts different formats. If the current backend is Vulkan, you’ll need SPIRV. If the current backend is D3D12, you’ll need to pass in DXIL or DXBC shaders, and so on. For a thorough explanation of why this is, you can refer to my article Layers All The Way Down: The Untold Story of Shader Compilation.

As a client using the GPU API, you might think this is somewhat inconvenient. I agree, and that’s why I and some other members of the SDL team have created SDL_shadercross, a library for translating shaders to different formats intended for use with SDL’s GPU API.

The two input formats for shadercross are HLSL and SPIR-V. From these source formats, shadercross can emit shader code for any backend that the GPU API currently implements. Since HLSL is a high-level shader language, this is an ideal format to write your shaders in if you are planning to use the GPU API. Of course, since SPIR-V is an interchange format that we can also use to transpile shaders to different formats, nothing is stopping you from writing your shaders in a different shader language that compiles to SPIR-V if you prefer, like GLSL.

How it works

Shadercross is built on top of two existing tools. The first is SPIRV-Cross, which can disassemble SPIR-V into high-level source languages. The second is DirectXShaderCompiler, which can compile HLSL into either SPIR-V or DXIL. Thanks to these tools we have a pathway between many different shader formats.

Let’s say you write a shader in HLSL. How can shadercross emit all the different backends from this source?

First, HLSL can compile to SPIR-V, and also to DXIL, so that’s Vulkan and D3D12 taken care of. That just leaves Metal. Since SPIRV-Cross can produce MSL from SPIR-V, we’re all set.

If your source is SPIR-V it’s a similar story. Vulkan consumes it directly. SPIRV-Cross can produce MSL and HLSL from SPIR-V. Metal accepts MSL, and DirectXShaderCompiler can produce DXIL from HLSL.

As you can see, we are capable of producing shader code for every backend from these two source formats. So how can you integrate this tool into your application?

Offline compilation

Shadercross ships a command-line interface intended for building shaders as part of your game’s content baking procedure. This is efficient because it minimizes the amount of work the application has to do to load a shader, but it requires a bit more up-front setup.

Here’s a look at the CLI tool usage:

$ ./shadercross --help
Usage: shadercross <input> [options]
Required options:
  -s | --source <value>            Source language format. May be inferred from the filename. Values: [SPIRV, HLSL]
  -d | --dest <value>              Destination format. May be inferred from the filename. Values: [DXBC, DXIL, MSL, SPIRV, HLSL, JSON]
  -t | --stage <value>             Shader stage. May be inferred from the filename. Values: [vertex, fragment, compute]
  -e | --entrypoint <value>        Entrypoint function name. Default: "main".
  -o | --output <value>            Output file.
Optional options:
  -I | --include <value>           HLSL include directory. Only used with HLSL source.
  -D<value>                        HLSL define. Only used with HLSL source. Can be repeated.

As you can see, many of these options are inferred or have a default. So the usage can be as simple as:

$ shadercross myShader.frag.hlsl -o myShader.frag.spv

This will translate an HLSL fragment shader with an entrypoint of “main” to SPIR-V.

We have an examples repo which demonstrates some basic scenarios. This repo provides shader source in HLSL, and a simple script that calls shadercross to compile to SPIR-V, MSL, and DXIL.

# Requires shadercross CLI installed from SDL_shadercross
for filename in *.hlsl; do
    if [ -f "$filename" ]; then
        shadercross "$filename" -o "../Compiled/SPIRV/${filename/.hlsl/.spv}"
        shadercross "$filename" -o "../Compiled/MSL/${filename/.hlsl/.msl}"
        shadercross "$filename" -o "../Compiled/DXIL/${filename/.hlsl/.dxil}"
    fi
done

The application is then responsible for loading the correct format depending on the selected backend.

Online compilation

Shadercross can also be built to perform shader translation at runtime. This does add some overhead at runtime, but it’s much easier to get your project up and running quickly this way. For example:

SDL_GPUShader *myVertexShader = SDL_ShaderCross_CompileGraphicsShaderFromHLSL(
    myDevice,
    myHlslSource,
    "main",
    NULL,
    NULL,
    0,
    SDL_GPU_SHADERSTAGE_VERTEX,
    NULL);

Now you have a compiled shader object with no fuss.

You will have to ship the SDL3_shadercross library with your project if you want to use online compilation, and if you want to use HLSL source you will also need to ship the dxcompiler and dxil libraries.

You can also mix-and-match offline and online workflows. For example, you could have an offline step to compile HLSL to SPIR-V, and then do runtime translation of SPIR-V, so you could omit the dxcompiler dependency at runtime and only depend on SPIRV-Cross.

Here’s a demonstration that compiles and reloads the shader while the application is running:

Resource Reflection

Shadercross has one other powerful capability I’d like to point out.

typedef struct SDL_GPUShaderCreateInfo
{
    size_t code_size;             /**< The size in bytes of the code pointed to. */
    const Uint8 *code;            /**< A pointer to shader code. */
    const char *entrypoint;       /**< A pointer to a null-terminated UTF-8 string specifying the entry point function name for the shader. */
    SDL_GPUShaderFormat format;   /**< The format of the shader code. */
    SDL_GPUShaderStage stage;     /**< The stage the shader program corresponds to. */
    Uint32 num_samplers;          /**< The number of samplers defined in the shader. */
    Uint32 num_storage_textures;  /**< The number of storage textures defined in the shader. */
    Uint32 num_storage_buffers;   /**< The number of storage buffers defined in the shader. */
    Uint32 num_uniform_buffers;   /**< The number of uniform buffers defined in the shader. */

    SDL_PropertiesID props;       /**< A properties ID for extensions. Should be 0 if no extensions are needed. */
} SDL_GPUShaderCreateInfo;

The num fields of this struct have to be filled in by hand - if your shader uses 2 samplers and a uniform buffer, you need to declare that correctly or the application will behave unexpectedly.

However, if you’re using shadercross, you can just call one of the online compilation functions without filling those in. Thanks to SPIRV-Cross, shadercross can identify the resource usage in the shader automatically and also report it back.

SDL_ShaderCross_GraphicsShaderInfo info;
SDL_GPUShader *myVertexShader = SDL_ShaderCross_CompileGraphicsShaderFromHLSL(
    myDevice,
    myHlslSource,
    "main",
    NULL,
    NULL,
    0,
    SDL_GPU_SHADERSTAGE_VERTEX,
    &info);

Convenient! Resource reflection info can also be emitted in JSON format by the CLI for easier offline integration with your game’s asset system.

$ shadercross SpriteBatch.comp.hlsl -o SpriteBatch.comp.json
$ cat SpriteBatch.comp.json
{ "samplers": 0, "readOnlyStorageTextures": 0, "readOnlyStorageBuffers": 1, "readWriteStorageTextures": 0, "readWriteStorageBuffers": 1, "uniformBuffers": 0, "threadCountX": 64, "threadCountY": 1, "threadCountZ": 1 }

Future plans

The DirectX team recently announced that they are planning to integrate SPIR-V support directly into D3D12, and that they plan to upstream HLSL compilation into Clang. Both of those improvements are a few years away, but we’re definitely planning to update SDL_shadercross as soon as those changes are ready. HLSL-on-Clang in particular opens up an exciting possibility where platforms can just ship the HLSL compiler themselves, and we no longer have to provide it directly. HLSL having standardized support in this way would be a welcome development in the very fragmented world of shaders. It’s also nice to see SPIR-V become more widely adopted, as it further minimizes the need for online compilation.

We are also planning to add MetalLib output support into the CLI, which will speed up shader loading on Apple platforms.

Closing thoughts

I’m really pleased with how this tool has turned out - SDL_shadercross solves a lot of common workflow problems out of the box while also leaving the door open for clients to design the kinds of shader workflows they feel comfortable using. Flexibility has been a major goal of the GPU API and it’s very nice that we could preserve that even with an issue as complex as shader format fragmentation. I hope you find SDL_shadercross as useful as I have so far!