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:
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}"); }
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.Fileslists the file and every#includeit 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.Compilecan be called from several threads at once, and gives the same bytes as a serial compile. - Keep scratch copies out of the project. Every
*.hlslunder the project folder is compiled by the build, edited copies included. Sprite.Reflectionis 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#.