Compile several variants of one HLSL source at run time with preprocessor defines, and resolve #include from memory instead of the file system, with an asset pack, a database or an editor buffer as the source.
CompileOptions.Defines is the C# form of -D, and CompileOptions.IncludeResolver is a hook the compiler calls for every #include. Without a resolver, ShaderCompiler.Compile treats #include as an error. CompileFile reads includes from disk, searching the including file’s folder and then IncludeDirectories.
With RecursiveAI.OneShader 1.2.0:
using System.Security.Cryptography; using OneShader; // Shader sources held in memory: an asset pack, a database, an editor buffer. var files = new Dictionary<string, string> { ["blur.hlsl"] = """ #include "common/sampling.hlsli" Texture2D<float4> Source : register(t0); SamplerState Linear : register(s1); [shader("pixel")] float4 PSMain(float2 uv : TEXCOORD0) : SV_Target { float4 sum = 0; [unroll] for (int i = -RADIUS; i <= RADIUS; i++) sum += Source.Sample(Linear, uv + TexelOffset(i)); return sum / (2 * RADIUS + 1); } """, ["common/sampling.hlsli"] = """ #ifndef HORIZONTAL #define HORIZONTAL 1 #endif float2 TexelOffset(int i) { return HORIZONTAL ? float2(i / 1920.0, 0) : float2(0, i / 1080.0); } """, }; ResolvedInclude? Resolve(string requested, string includingFile, bool isSystemInclude) => files.TryGetValue(requested, out string? text) ? new ResolvedInclude(requested, text) : null; // One source, four variants. foreach (string radius in new[] { "2", "6" }) foreach (string horizontal in new[] { "1", "0" }) { CompileResult result = ShaderCompiler.Compile(files["blur.hlsl"], "blur.hlsl", new CompileOptions { Defines = [new("RADIUS", radius), new("HORIZONTAL", horizontal)], IncludeResolver = Resolve, }); string status = result.Success ? "SHA-256 " + Convert.ToHexStringLower(SHA256.HashData(result.Find("PSMain", CompileTarget.SpirV)!.Data))[..12] : string.Join("; ", result.Diagnostics); Console.WriteLine($"RADIUS={radius} HORIZONTAL={horizontal}: {status}, read {string.Join(", ", result.Files)}"); } // A missing include is a diagnostic on the #include line. files["blur.hlsl"] = files["blur.hlsl"].Replace("common/sampling.hlsli", "common/sample.hlsli"); CompileResult missing = ShaderCompiler.Compile(files["blur.hlsl"], "blur.hlsl", new CompileOptions { Defines = [new("RADIUS", "2")], IncludeResolver = Resolve, }); Console.WriteLine(missing.Diagnostics[0]);
RADIUS=2 HORIZONTAL=1: SHA-256 605dd910624c, read blur.hlsl, common/sampling.hlsli RADIUS=2 HORIZONTAL=0: SHA-256 2fcade900707, read blur.hlsl, common/sampling.hlsli RADIUS=6 HORIZONTAL=1: SHA-256 0479e8957f19, read blur.hlsl, common/sampling.hlsli RADIUS=6 HORIZONTAL=0: SHA-256 bd1d621c19f2, read blur.hlsl, common/sampling.hlsli blur.hlsl(1,10): error OSH0120: cannot find include file 'common/sample.hlsli'; searched: 'the include resolver'. Check the spelling, or add the directory that contains it to the include paths
Good to know
- Defines. Each entry is a name and a value. A
nullvalue defines the name as 1, like-D NAME. Defines apply before the first line of the source, so#ifndefdefaults in an include work as above. - The resolver gets the requested name, the including file and whether it was
<...>(system) or"...". Return aResolvedInclude(path, text), ornullfor “not found”. The path you return is what diagnostics andresult.Filesshow, and it’s theincludingFilethe resolver gets for includes nested inside that file, so relative includes can be resolved from it. - Variants are deterministic. The same source, defines and options always give the same bytes, so a hash of the inputs is a safe cache key.
- Errors in an include point at the include’s own line, under the path the resolver returned.
- The same options exist on the command line (
-D,-I) and in the build package (DefinesandIncludeDirectoriesmetadata, orOneShaderDefinesandOneShaderIncludeDirectories). See Compile HLSL in a C# project.