Custom Fragment Shaders with SDL3’s Render API

Where I explain how to make use of the new SDL3 GPU API to use your custom fragment shaders so as to get fullscreen effects on your 2D games.

Motivation

I have recently been working on a 2D game, that I am trying to spawn into existence from as-scratch-as-is-reasonable to learn more about systems engineering, game design, pixel art, interactive writing and all the things you need to become half competent at when you do this solo.

So, I have been using SDL3 in order to quickly get sprites shown on screen through the use of their very simple render API. The problem with this used to be that you had little control over what was happening under the hood. But recent developments in SDL3, in particular the addition of their excellent GPU API, have allowed to get closer to the metal with something substantially simpler than Vulkan and the like.

From my end, I got the best of both worlds, where I could just use the render API to keep a very simple 2D renderer to draw my sprites on screen, but with now the option to switch-up the GPU render state so as to have my custom fragment shader, either on some sprite, or as a fullscreen post-processing effect (in my case CRT scanlines, glitchy screens, and the like).

Main Idea

The main idea is pretty simple. You initialise your renderer as you normally would through the SDL3 API, which now gives you the option to grab a handle to the GPU device the renderer is using.

You create an intermediate render-target texture (effectively playing the role of a framebuffer) that you render your entities sprite to, and then you switch the GPU render state, using your custom shader, and render that texture onto the actual screen, and swap back to the normal render state.

Let’s see how to set it up, and a few things to be aware of shader-side.

SDL3 Setup

First, we do the usual SDL renderer initialisation dance:

SDL_Init(SDL_INIT_VIDEO);
SDL_Window *window = SDL_CreateWindow("GameTitle", windowWidth, windowHeight, 0);
SDL_Renderer *renderer = SDL_CreateRenderer(window, SDL_GPU_RENDERER);

and while we’re at it, we create a target texture:

SDL_Texture *target = SDL_CreateTexture(
    renderer,
    SDL_PIXELFORMAT_RGBA32,
    SDL_TEXTUREACCESS_TARGET,
    renderWidth,
    renderHeight
    );

With the renderer created this way, it is possible to get a handle to your GPU device like so:

SDL_GPUDevice *device = SDL_GetGPURendererDevice(renderer);

Note that doing it this way is the most simple to get started. You are free to do it the other way, first using the GPU API to grab the device you’d like to use to render and then create the renderer using this device. This would look like:

// formatFlags are used to specify what shader format the device should be compatible with and using
SDL_GPUDevice *device = SDL_CreateGPUDevice(formatFlags, debug, NULL);
SDL_Renderer *renderer = SDL_CreateGPURenderer(device, window);

Now that you have a handle to your device, the goal is to create a shader and eventually pass it to a render state so we can issue draw calls that make use of it.

First, we create a shader:

SDL_GPUShaderCreateInfo shaderCreateInfo;
SDL_zero(shaderCreateInfo);
// uint8_t* to the code
shaderCreateInfo.code                = shaderCode; 
// size_t that's the length of the code array
shaderCreateInfo.code_size           = shaderSize; 
shaderCreateInfo.entrypoint          = "main";
// shader format you'll be using
shaderCreateInfo.format              = SDL_GPU_SHADERFORMAT_SPIRV;
// we want a fragment shader for the purposes of fullscreen effect,
// but you can specify a vertex shader here
shaderCreateInfo.stage               = SDL_GPU_SHADERSTAGE_FRAGMENT; 
// we need a sampler to access the target texture pixel values
shaderCreateInfo.num_samplers        = 1;
// uniform buffer so as to pass values from CPU to the shader (resolution, time, etc...)
shaderCreateInfo.num_uniform_buffers = 1;

SDL_GPUShader *myShader = SDL_CreateGPUShader(device, &shaderCreateInfo);

Now, we need to pass it to a render state, so we can use it during the render draw calls.

SDL_GPURenderStateCreateInfo renderStateCreateInfo;
SDL_zero(renderStateCreateInfo);
renderStateCreateInfo.fragment_shader = myShader;

SDL_GPURenderState *myShaderEffect = SDL_CreateGPURenderState(renderer, &renderStateCreateInfo);

Since we specified the need for a uniform buffer, let’s also set it up:

struct {
    float w;
    float h;
} uniformBuffer;

uniformBuffer.w = target->w;
uniformBuffer.h = target->h;

SDL_SetGPURenderStateFragmentUniforms(myShaderEffect, 0, &uniformBuffer, sizeof(uniformBuffer));

And now you have everything you need to have a fullscreen effect via a shader. The process looks something like this:

SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);

while(running) {
    SDL_SetRenderTarget(target); // From there on, we render to the target texture
    SDL_RenderClear();

    render_my_sprites(); // we do all our draw calls here 

    // We apply the fullscreen effect
    SDL_SetGPURenderState(renderer, myShaderEffect); // shader will be applied for the next draw calls
    SDL_SetRenderTarget(NULL); // we will render to the actual screen
    SDL_RenderTexture(renderer, target, NULL, NULL); // draw call to the screen
    SDL_SetGPURenderState(renderer, NULL); // back to the render state without our custom shader

    SDL_RenderPresent(renderer);
}

Shader Setup

OK, we’ve set things up such that we could use a custom shader, but now we need to write one.

In this article, we have been targeting the SPIR-V format, which is an intermediate representation and very much not the language in which you’d write a shader. In what follows, we’ll write the shader shader.frag with GLSL, and we can compile it to SPIR-V like so:

glslangValidator -V shader.frag -o shader.frag.spv

Because we did not touch the vertex shader or set things up ourselves, and relied on the render API to do so, we can’t choose the layouts of our inputs, outputs, sampler, uniform buffer (or additional texture, should we have one). With the way we’ve set things up, this should be the layout of input/output and uniforms, in glsl:

#version 450

layout(location=0) in vec4 i_colour;
layout(location=1) in vec2 i_uv;

layout(location=0) out vec4 o_colour;

layout(set=2, binding=0) uniform sampler2D u_sampler;

layout(set=3, binding=0) uniform UniformBlock {
    float w;
    float h;
};

Here i_colour and i_uv are the inputs of our fragment shader, passed from the output of the vertex shader (the default SDL one from the GPU renderer we’ve created) and o_colour is the output of this shader i.e. the colour of the shaded pixel at uv coordinates i_uv.

The sampler2D u_sampler allows to get texels values on the texture target (because we are effectively rendering a quad bound to that texture with the SDL_RenderTexture(renderer, target, NULL, NULL) call).

The uniforms (here the resolution) also need to be wrapped in a uniform block.

In general, here are the layouts for textures, samplers and uniforms, for fragment shaders, taken from the official doc:

// Any samplers come first in the set, in SDL bind slot order
layout(set = 2, binding = 0) uniform sampler2D samplerBoundToSlot0;
layout(set = 2, binding = 1) uniform sampler2D samplerBoundToSlot1;
// Any storage textures come next in the set, in SDL bind slot order
layout(set = 2, binding = 2) uniform image2D storageTextureBoundToSlot0;
layout(set = 2, binding = 3) uniform image2D storageTextureBoundToSlot1;
// Any storage buffers come next in the set, in SDL bind slot order
layout(set = 2, binding = 4) buffer storageBufferBoundToSlot0 { ... };
layout(set = 2, binding = 5) buffer storageBufferBoundToSlot1 { ... };
// Any uniform buffers are in their own set, in SDL slot order
layout(set = 3, binding = 0) uniform UniformDataBoundToSlot0 { ... };
layout(set = 3, binding = 1) uniform UniformDataBoundToSlot1 { ... };

More details can be found on the SDL_CreateGPUShader doc page.

Now, the aim of the game is to set o_colour to the value we wish to see at uv coordinate i_uv.

Here is a very contrived scanlines example.

#version 450

layout(location = 0) in vec4 i_colour;
layout(location = 1) in vec2 i_uv;
layout(location = 0) out vec4 o_colour;

layout(set = 3, binding = 0) uniform UB {
    float w;
    float h;
};

layout(set = 2, binding = 0) uniform sampler2D u_sampler;

const float scanline_freq = 3.0f;

void main() {
    float scanline = sin(i_uv.y * h * scanline_freq) * 0.25 + 0.75f;

    o_colour = texture(u_sampler, i_uv) * scanline * i_colour;
}

Here is some forgotten OS desktop, displayed with raw pixels:

A forgotten OS desktop without scanlines

And here is the same output, with the shader above injected in the render pipeline (and some Moiré patterns to boot):

A forgotten OS desktop with scanlines

A Few Words on Shader Embedding

I gave just a rapid overview of the main setup steps, but glossed over many details here. There is one that I feel I should just touch on quickly, both to make a couple technical points that may be useful to people getting started with shaders, and to give a massive amount of credit to JeanHeyd Meneide who apparently had to fight tooth and nail so the #embed directive could exist in C23 and make many a devs’ life much easier, and making the embedding of assets and shader code a triviality, nay, a joy, in C.

In section SDL3 Setup, we need to give a pointer to the code bytes. There are several approaches to this. We could do one of two things:

  1. Read from the compiled shader binaries at runtime, or
  2. Embed the shader binaries in the game’s executable.

Option 1 is especially useful for writing your shaders, because you can also add a watcher to the compiled binary and reload the code and update your shader and render state at runtime very easily. This can also be achieved with option 2 but you’d need to make it so that your game—or at least the right translation unit—can be hot reloaded, so that’s maybe a bit more effort, and platform dependent.

Option 2 can be great for release builds so you never have to mess around with paths and files on the user’s machine, everything being in one binary (as long as you can realistically do that…).

With option 2, you only need to declare, somewhere:

const uint8_t shaderCode[] = {
#embed "path/to/your/compiled/shader"
};

constexpr size_t shaderSize = sizeof(shaderCode) / sizeof(*shaderCode);

And you just need to recompile the translation unit that contains it after recompiling your shaders so your game has the updated render state. Isn’t life just fucking beautiful sometimes?

Conclusion

GPU programming is, to a large degree, quite a mess. We have very powerful but hard-to-get-in APIs such as Vulkan and DX12, vendor fragmentation, and the generally accepted idea that if you want to do games—even just 2D ones— and you’re not some GPU wizard you should use a game engine because, in part, of all that mess.

In such a context, I find it an extraordinary, albeit necessary, effort the SDL devs are doing here. Not only is the barrier to entry lowered a lot if you want to play with fragment shaders for 2D rendering, but SDL is so modular that if you do want to get lower, you can start by using its GPU API to make your own renderer and remove the need for the render API, and then, if you’re still hungry, you can again remove that and do some Vulkan or DX12 from scratch.

If you’re even remotely interested in programming your GPU, want an entry point where you write some actual code and can work your way to the metal with a much gentler curve, or stay there and have a shit-ton of fun, go for it.

And congrats and thanks to the SDL team, you’ve been bringing me so much joy.