Real-Time Shader Programming in Vulkan

hello triangle

Real-time shader editing is a tool that I have always wanted to integrate into my budding engine. Since modifying external shader files doesn’t change the binary, there is no reason we need to rerun the app every time we make a small change. This can save lots of (precious) coding time. Not to mention the sooner it is implemented the more time I’ll save in the long run.

I want to make sure that the solution has a focus on performance and stability. If the changes are propagated quicker then I’ll save even more time, and also I don’t want any changes to potentially crash the system - I imagine this will involve tracking the last known good shader.

This blog post will assume you have a basic understanding of how shaders fit into the world of Vulkan - at pipeline creation time we need a pipeline layout and a set of matching shader stages. This means that any small change we make to our shader files could mean we need to create a new pipeline layout. We will use SPIRV-Cross to reflect our resources.

I also use glslang to compile my shaders at run-time.

Real-time shader editing requires platform-specific knowledge. I currently develop my engine on Windows, so I will be focusing on that. Having said this, if you are developing on Linux, you can swap out the Windows notification bits with something like inotify and the rest can still apply! :-)

Anyway, let’s get cracking.


Problem 1: Runtime Notifications

The app currently processes events received from the platform, such as keyboard strokes or mouse movements. We can extend this architecture and implement a new class called FileChangedEvent.

This will simply be a wrapper class containing file names.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void RenderPipeline::process_event(const Event &event)
{
    if (event.is<FileChangedEvent>())
    {
        const auto &files_changed_event{event.get<FileChangedEvent>()};
        for (const auto &file_changed : files_changed_event.get_modified_files())
        {
            shader_manager.invalidate(file_changed);
        }
    }
}

Here you can see us handling a FileChangedEvent. We tell the shader manager to invalidate the shader source for each file name.

How can we tell when a shader file has changed though?


Detecting changed files in C++ on Windows

There are two ways to approach this.

The first way (the easier but more naive approach) is to poll each of the shader files every frame to detect a change in the last time it was written to. We could simply call a stat() on each of the files inside our shaders/ directory - but we’d then need to keep track of the files and their states (not to mention also constantly interrogating our poor shaders). This can become ugly very quickly.

The second (better and more scalable approach) is to monitor notifications offered by the operating system. We will go with this.

The first functions I stumbled upon that I thought could help me in my quest were FindFirstChangeNotification and WaitForMultipleObjects. This is a start, but it doesn’t tell us what file changed, only that there was a change in the directory. We could just wait for this notification and then stat all the files, but we’d need to again have some sort of state tracking which I want to avoid.

I then soon after found my friends CreateFile() and ReadDirectoryChangesW(). CreateFile() is how you open directories in Windows (I know, “OpenDirectory()” wouldn’t make sense at all), and then ReadDirectoryChangesW() function will return to us exactly what changed and when - perfect!

A general outline for this method will be:

  • Use CreateFile() to open a directory at startup
  • Wait (“watch”) for a notification, relating to the directory, that signals a file has been modified using ReadDirectoryChangesW()
  • Report back to the app if and when any new changes happened using FilesChangedEvent

Implementing the watcher

Inside our WindowsPlatform class, we will spawn a detached worker thread that will effectively do our “watching”. This will just be a function that opens a directory and checks back with it in a loop until the application is terminated.

In the future, it would be nice to encapsulate this functionality inside a Watcher class. For now, we’ll bake it into the platform code.

First off, we want to create a handle to the directory by opening it inside our application. We do this with CreateFile():

1
2
3
4
5
6
7
8
auto directory_handle{CreateFile(
    directory.c_str(),
    FILE_LIST_DIRECTORY,
    FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
    NULL,
    OPEN_EXISTING,
    FILE_FLAG_BACKUP_SEMANTICS,
    NULL)};

I won’t go into detail explaining this function as the arguments should remain similar for opening an existing directory.

Next, we must allocate some scratch space on the stack so we can populate it with any returned notifications.

1
2
const int allocation_size{4096};
auto *    buffer{new BYTE[allocation_size]};

Since we are focusing on modified shader files we can assume that the actual contents of this buffer will be small. We are expecting that shader files are modified once at a time (and infrequently), so 4kB should be more than enough memory.


On Windows when a file is changed inside a directory the OS will fire two notifications.

The first notification is for a file that had its modification date changed, whereas the second notification is for the directory entry for that file (metafile). The metafile stores metadata for its respective file, such as the file size which will need changing - therefore sending a duplicated event.

There is no way to distinguish these two writes inside our code, we can only see the fact that the same file was changed twice.

This is annoying at best, and given that performance is a concern for us this is undesirable even more so due to the redundancy of reloading a shader twice mid-frame.

To combat this duplication we make use of std::set, and in addition to this we will decouple our monitoring logic from our reporting logic using std::chrono to prevent duplicated events:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
std::set<std::string> modified_files;

auto start_time{std::chrono::steady_clock::now()};

while(app)
{
    // ReadDirectoryChangesW() code goes here
    // ...

    auto now{std::chrono::steady_clock::now()};
    auto elapsed{static_cast<size_t>(std::chrono::duration_cast<std::chrono::milliseconds>(now - start_time).count())};

    if (elapsed > 50.0f && !modified_files.empty())
    {
        app->process_event(FileChangedEvent{*this, modified_files});
        modified_files.clear();
        start_time = now;
    }
    else
    {
        modified_files.clear();
    }
}

Note that this code won’t block the first notification from being fired straight away. The first notification always makes it through, however, the follow-up duplicate will be blocked by the timer - and cleared.


Now for the actual reading of notifications using ReadDirectoryChangesW():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
while (app)
{
    auto result{ReadDirectoryChangesW(
        directory_handle,
        buffer,
        allocation_size,
        TRUE,
        FILE_NOTIFY_CHANGE_LAST_WRITE,
        &buffer_size,
        NULL,
        NULL);

    if (result != 0)
    {
        // Handle notification buffer
        // ...
    }
}

The call to ReadDirectoryChangesW() will block the current thread while it waits for a notification.

We can filter the types of notifications we want to wait on using the arguments. The noteworthy ones being arg4 and arg5 (TRUE and FILE_NOTIFY_CHANGE_LAST_WRITE respectively). As we might want to organize our shaders into different folders, we set arg4 to TRUE to monitor the directory recursively. Then we set arg5 to FILE_NOTIFY_CHANGE_LAST_WRITE as we are only interested in modified files.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
if (result != 0)
{
    FILE_NOTIFY_INFORMATION *info{reinterpret_cast<FILE_NOTIFY_INFORMATION *>(buffer)};

    do
    {
        if (info->Action == FILE_ACTION_MODIFIED)
        {
            std::wstring wide_string{info->FileName, info->FileNameLength / sizeof(wchar_t)};
            std::string  filename{to_string(wide_string)};
            modified_files.insert(filename);
        }

        info = reinterpret_cast<FILE_NOTIFY_INFORMATION *>(reinterpret_cast<BYTE *>(info) + info->NextEntryOffset);
    } while (info->NextEntryOffset > 0);
}

Finally, we need to handle the returned notifications.

When the wait condition is satisfied in ReadDirectoryChangesW(), our buffer will then be filled with a FILE_NOTIFY_INFORMATION struct for each notification we received.

To get the FILE_NOTIFY_INFORMATION structs from the BYTE *buffer; we use a reinterpret cast:

1
FILE_NOTIFY_INFORMATION *info = reinterpret_cast<FILE_NOTIFY_INFORMATION *>(buffer);

This aligns the pointer to 14 bytes, starting at the first element, meaning info now points to the first FILE_NOTIFY_INFORMATION struct.

The loop will run at least once. The spec states that when FILE_NOTIFY_INFORMATION::NextEntryOffset is 0, then it is the last notification in the returned buffer. So we loop while info->NextEntryOffset > 0.

Inside the loop we add a further check to see if the action that was returned is FILE_ACTION_MODIFIED:

1
2
3
4
5
6
if (info->Action == FILE_ACTION_MODIFIED)
{
    std::wstring wide_string{info->FileName, info->FileNameLength / sizeof(wchar_t)};
    auto         filename = to_string(wide_string);
    modified_files.insert(filename);
}

We then get the WCHAR filename and length and convert it into a std::wstring(), and then convert the std::wstring() into a std::string which is inserted into our modified_files set ready to be pinged off to the application.

Don’t forget to also advance the pointer using the offset returned:

1
info = reinterpret_cast<FILE_NOTIFY_INFORMATION *>(reinterpret_cast<BYTE *>(info) + info->NextEntryOffset);

Problem 2: Runtime Re-compilation

Tracking dependencies

The main reason for using shader partials is to avoid code duplication, so it is common to see a file used by multiple different shaders.

It is unnecessary to load the same thing multiple times if we only need to do it once. Hence why the shader sources now track their dependencies inside a hash map.

This means that when we go to invalidate a shader source, we can retrieve the already read in GLSL byte code for its dependency, rather than re-open the file and read from it.


Tracking parents

Our shader sources get precompiled so that the #include’s are replaced with their respective files, ultimately giving us a result GLSL file which we can then go compile.

It stands to reason that if we make a small change in a dependency, the parent will need to be reloaded as well so that it can include the new GLSL that just changed in a dependency. In other words: if a shader source becomes invalid, we need to make sure we invalidate all of its parents.

This will mean if there is any change anywhere in the shader source tree, the root shader source will always become invalid.

Here is the invalidate code (parents are tracked in a vector):

1
2
3
4
5
6
7
8
9
void ShaderSource::invalidate()
{
    invalid = true;

    for (auto &parent : parents) // std::vector<ShaderSource *> parents ;
    {
        parent->invalidate();
    }
}

Requesting a shader

We have a shader manager that we can request shaders from. They’re currently stored inside a local cache that the shader manager oversees personally.

They are requested using ShaderModule structs (these are simple bindings between a filename and a shader stage). The returned shaders are then used to grab an existing pipeline layout (or create a new one if a compatible one doesn’t already exist). Then these can be used to flush our pipeline state.

When we request the shader from the shader manager, the filename is first checked in the shader source store to see if it exists. A shader source is identified by its filename, so when its contents change a new shader isn’t generated - instead it replaces (or updates) the current shader it is used by.

You could generate a new shader for each small change to its respective shader source. This would be useful if you were tweaking changes back and forth, however, your memory would begin to grow. You’d need to consider implementing an eviction policy on your cache for stale shaders - a cool idea but perhaps unnecessary for now.

Here is the full code for requesting a shader:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
Shader &ShaderManager::request_shader(const ShaderModule &module)
{
    avn::ShaderSource *source{nullptr};

    // Request the GLSL shader source
    auto &source_it{source_map.find(module.filename)};
    if (source_it == source_map.end())
    {
        // If a GLSL source doesn't exist for this file, allocate a new one and register it
        source = register_source(module.filename);
    }
    else
    {
        // Otherwise grab the existing one
        source = source_it->second.get();
    }

    // Request a shader from the cache (uniqued hashed by the filename, stage and variant)
    // Hash the shader GLSL filename and the VkShaderStageFlagBits
    size_t hash{0U};
    avn::hash_arguments(hash, module.filename, module.stage);

    // If resource not found, create it
    auto &shader_it{shader_cache.find(hash)};
    if (shader_it == shader_cache.end())
    {
        auto new_shader{std::make_unique<Shader>(*source, module.stage)};
        shader_cache.emplace(hash, std::move(new_shader));

        shader_it = shader_cache.find(hash);
    }

    // Build the shader (will skip if already built)
    shader_it->second->build(*this);

    // Return a valid and built shader
    return *shader_it->second;
}

Recompiling

We then call build on our shader. This function no-ops if there is SPIRV bytecode existing inside the shader and the underlying shader source is not invalid. The former is for the initial building of a new shader, and the latter is for mid-run changes.

If both of those conditions are not met then we know we need to build the shader. We then check to see if the shader source is invalid and reload it. This ensures we are building (or rebuilding) with the most up-to-date GLSL.

Then finally we use glslang to compile our GLSL bytecode into SPIRV bytecode. If we detect an error in compilation, the SPIRV bytecode isn’t set to the shader:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (source.invalid)
{
    source.reload();
}

auto new_spirv{manager.get_glsl_compiler().compile(source.get_glsl(), stage)};

if (new_spirv.empty())
{
    LOGE("[Shaders] Error compiling GLSL file: {}", source.get_filename());

    if (spirv.empty())
    {
        throw std::runtime_error(fmt::format("[Shaders] Error building shader: {}", source.get_filename()));
    }
}
else
{
    spirv = new_spirv;
}

resources = manager.get_spirv_reflector().reflect(stage, spirv);

Conclusion

Perhaps a slightly lengthier post than usual, but I have it working:

The colors are built into the shader using an #include, and the changes appear immediately in my Hello Triangle demo when I save the file, which is a lot faster than I expected. Also, I can add shader partials mid-frame and include them as I see fit and the changes work.

Load Comments?