Recursive AI

Compile HLSL with in-memory includes in C#

OneShader is a free, open-source HLSL compiler library for .NET from Recursive AI. MIT or Apache-2.0, no native dependencies.

Supply #include contents as strings instead of saving shader headers to disk.

Terminal
dotnet add package RecursiveAI.OneShader

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

C#
using OneShader;
 
var result = ShaderCompiler.Compile(
    "#include \"shared.hlsl\"", "main.hlsl",
    new CompileOptions { IncludeResolver =
        (name, parent, system) =>
            name == "shared.hlsl"
            ? new ResolvedInclude("shared.hlsl",
                "[shader(\"pixel\")] " +
                "float4 P() : SV_Target " +
                "{ return 1; }")
            : null });
Console.WriteLine(result.Success);
foreach (var error in result.Diagnostics)
    Console.WriteLine(error);
Text
True

Compiles a pixel shader to SPIR-V in memory; it writes no shader files.

Unzip the example, then dotnet run.

The full program

The download’s Program.cs supplies the main shader and its shared function as two strings.

C#
using OneShader;
 
const string source = """
#include "shared.hlsl"
[shader("pixel")]
float4 PSMain() : SV_Target { return Shade(); }
""";
const string shared = "float4 Shade() { return float4(1, 0, 0, 1); }";
var result = ShaderCompiler.Compile(source, "main.hlsl", new CompileOptions
{
    IncludeResolver = (requested, includingFile, isSystemInclude) =>
        requested == "shared.hlsl"
            ? new ResolvedInclude("shared.hlsl", shared)
            : null,
});
Console.WriteLine($"Success: {result.Success}");
Console.WriteLine($"Files: {string.Join(", ", result.Files)}");
foreach (var diagnostic in result.Diagnostics)
    Console.WriteLine(diagnostic);
 
var missing = ShaderCompiler.Compile(source, "main.hlsl", new CompileOptions
{
    IncludeResolver = (requested, includingFile, isSystemInclude) => null,
});
Console.WriteLine($"Missing success: {missing.Success}");
Console.WriteLine($"Missing files: {string.Join(", ", missing.Files)}");
foreach (var diagnostic in missing.Diagnostics)
    Console.WriteLine(diagnostic);
Text
Success: True
Files: main.hlsl, shared.hlsl
Missing success: False
Missing files: main.hlsl
main.hlsl(1,10): error OSH0120: cannot find include file 'shared.hlsl'; searched: 'the include resolver'. Check the spelling, or add the directory that contains it to the include paths
main.hlsl(3,38): error OSH0300: The function 'Shade' is not declared. Declare it before this call, or check the spelling.

A missing include returns Success == false and diagnostics on the include line; the later undeclared-function diagnostic follows because Shade was never loaded. With a custom resolver set, fix OSH0120 by making the resolver return the file, despite the diagnostic’s generic suggestion to add include paths.

Variations

Read the compiled entry point. Add this after the successful compile in the full program. PSMain is the function marked [shader("pixel")] there.

C#
if (result.Success)
    Console.WriteLine(result.Find("PSMain", CompileTarget.SpirV) is not null);
Text
True

Handle angle-bracket includes. Replace #include "shared.hlsl" inside the main shader string with #include <shared.hlsl>. The same resolver works: it accepts that name in either form. The full program’s success, file list and missing-include diagnostics remain the same.


The short answer

Use ShaderCompiler.Compile(source, path, options) with CompileOptions.IncludeResolver. The callback supplies a ResolvedInclude containing the include’s reported path and its text. Return null for a name you cannot resolve. The compiler returns diagnostics, reflection and compiled output; you do not need to create a graphics device to compile the shader.

The resolver has three arguments: the requested name, the including file’s path, and a Boolean that distinguishes angle brackets from quotes. These are the inputs your application needs to choose a virtual file. OneShader’s runtime compiler guide documents compilation from strings and tracking the files read.

How do I compile HLSL with includes in C#?

Keep the main source and shared headers in memory, then map each requested include name to the correct text. The full program uses an exact name match rather than reading the disk. Its ResolvedInclude("shared.hlsl", shared) supplies a logical path, not a command to open a file. That path appears in diagnostics and is passed back as the parent path when the included text requests another include.

For a larger virtual directory tree, resolve relative names against includingFile yourself. The callback receives the include name as written; your application decides how that name maps to stored strings. Keep returned paths consistent so errors point to the same virtual file each time. A name alone is sufficient for this flat example, but two folders containing a header with the same name need a parent-aware lookup.

The [shader("pixel")] attribute identifies the entry point. A function named PSMain is not, by its name alone, an entry point in this API. The program’s shared function is ordinary HLSL; it becomes part of the main source through preprocessing. The compiler’s API declaration specifies the complete resolver signature.

result.Files lists the main file and includes actually read. In the successful example it contains main.hlsl and shared.hlsl. This is useful when an editor or asset system needs to know which stored texts contributed to a result. It is not a list of attempted include names: when the resolver returns null, the missing header is not added as a successfully read file.

Why is my HLSL include file not found?

With a custom resolver, the lookup succeeds only when that callback returns a ResolvedInclude. In the failure run above, the callback deliberately returns null for every request. Adding a disk include directory does not repair a resolver that refuses the requested name: the custom resolver is the lookup path for that compile.

Check the spelling and your virtual path mapping first. Then check which parent file requested the header and whether your callback distinguishes quotes from angle brackets. The example deliberately accepts both forms. Microsoft’s include directive reference describes the HLSL syntax; lookup policy in this example belongs to the supplied callback.

Treat Success as the decision to accept a new compiled shader. Print all diagnostics, but fix the first missing include before chasing errors caused by the absent declarations. The full program demonstrates that distinction: OSH0120 is the failed lookup, while OSH0300 reports the function that the missing header would have declared.

All oneshader recipes