Recursive AI

Reload shaders while the app runs

All oneshader recipes

Recompile an HLSL shader when its file changes, swap in the new code while the app keeps running, and find out when an edit changed the interface the generated C# was built against, such as a constant-buffer offset.

A project built with RecursiveAI.OneShader.Build embeds its shaders and generates C# against their layout. During development you can recompile at run time with ShaderCompiler.CompileFile. An edit to a function body can be swapped in. An edit that moves a constant-buffer member can’t, because the generated Frame struct would write the wrong bytes. InterfaceCompatibility.Compare tells the two apart.

The project references RecursiveAI.OneShader.Build 1.2.0 and contains the Shaders/Sprite.hlsl of Getting started. Program.cs runs four edits through the same reload function:

C#
using OneShader;
using OneShader.Reflection;
using HotReload.Shaders;
 
// What the C# was built against: the reflection embedded by the build.
ShaderReflection built = Sprite.Reflection;
 
// 1. Recompile the shader as it is on disk (in a real loop: when the file watcher fires).
Reload("Shaders/Sprite.hlsl");
 
// 2. An edit to a function body: still compatible.
string source = File.ReadAllText("Shaders/Sprite.hlsl");
string edited = Path.Combine(Path.GetTempPath(), "Sprite.hlsl");   // outside the project, or the build compiles it too
File.WriteAllText(edited, source.Replace("* i.Color;", "* i.Color * 0.5;"));
Reload(edited);
 
// 3. A new constant-buffer member before Tint: Tint moves, so the generated Frame struct is wrong.
File.WriteAllText(edited, source.Replace("float4x4 ViewProjection;", "float4x4 ViewProjection; float4 Fog;"));
Reload(edited);
 
// 4. A typo: diagnostics, not an exception.
File.WriteAllText(edited, source.Replace("AtlasSampler, i.Uv", "AtlasSampler, i.UV"));
Reload(edited);
 
void Reload(string path)
{
    CompileResult result = ShaderCompiler.CompileFile(path);
    Console.WriteLine($"{path}: watch {string.Join(", ", result.Files.Select(Path.GetFileName))}");
    if (!result.Success)
    {
        foreach (var d in result.Diagnostics) Console.WriteLine(d);   // keep running the last good shader
        return;
    }
    CompatibilityReport report = InterfaceCompatibility.Compare(built, result.Reflection!);
    if (report.IsCompatible)
        Console.WriteLine($"  compatible: swap in {result.Find("PSMain", CompileTarget.SpirV)!.Data.Length} bytes of SPIR-V");
    else
        Console.WriteLine($"  not compatible:\n{report}");
}
Text
Shaders/Sprite.hlsl: watch Sprite.hlsl
  compatible: swap in 856 bytes of SPIR-V
/tmp/Sprite.hlsl: watch Sprite.hlsl
  compatible: swap in 920 bytes of SPIR-V
/tmp/Sprite.hlsl: watch Sprite.hlsl
  not compatible:
incompatible with the compiled-in C# bindings:
  Frame.Tint moved from offset 64 to 80
  Frame.Fog was added at offset 64
Rebuild the project to update the generated C# bindings.
/tmp/Sprite.hlsl: watch Sprite.hlsl
/tmp/Sprite.hlsl(24,74): error OSH0308: The struct 'VSOut' has no member 'UV'. Did you mean 'Uv'?

Good to know

  • What to watch. result.Files lists the file and every #include it read, even when the compile fails, so the watcher keeps following a shader that’s broken mid-edit.
  • Compatible means everything the generated C# relies on is unchanged: constant- and structured-buffer layouts (offsets, types, strides, sizes), vertex input locations, formats and offsets, each resource’s set, binding, kind, count and stages, the entry points and their stages, and compute thread-group sizes. Edits to function bodies, new entry points, and resources that become visible to fewer stages are compatible.
  • Errors never throw. A failed compile returns diagnostics, so the loop keeps the last good shader running.
  • Threads. ShaderCompiler.Compile can be called from several threads at once, and gives the same bytes as a serial compile.
  • Keep scratch copies out of the project. Every *.hlsl under the project folder is compiled by the build, edited copies included.
  • Sprite.Reflection is stored as text in the assembly (about 1 to 4 KB per shader) and parsed on first read. A program that never reads it carries no parser after trimming.

The repository’s samples/HotReload is a complete console loop with a file watcher. For reading the reflection itself, see Read shader reflection in C#.