Recursive AI

Cache shader compiles at run time

All oneshader recipes

Keep compiled HLSL variants in memory with ShaderCompileCache, so a material system or editor that asks for the same shader again gets it without recompiling. An edited include is noticed, and concurrent requests for one variant compile it once.

ShaderCompileCache, new in RecursiveAI.OneShader 1.2.0, answers a compile from memory when the same inputs were compiled before. The key is the source text, the path, every option (targets, defines, include directories, entry points, matrix packing, 16-bit types, optimization, names) and the content of every include the earlier compile read. Includes are looked up again on each call, so editing one makes the next call compile.

The shader is the material from Shader variants with Axes, with the light direction moved into an include. Shaders/Common/Lighting.hlsli:

HLSL
static const float3 LightDir = normalize(float3(0.3, 0.5, 0.8));

Shaders/Material.hlsl, from its fourth line:

HLSL
#include "Lighting.hlsli"
cbuffer Frame : register(b0) { float4x4 ViewProjection; float4 Tint; };
Texture2D<float4> Atlas : register(t1);
SamplerState AtlasSampler : register(s2);
#if LIGHTING_LAMBERT || LIGHTING_PBR
Texture2D<float4> NormalMap : register(t3);
#endif
 
struct VSIn  { float2 Position : POSITION; float2 Uv : TEXCOORD0; float4 Color : COLOR0; };
struct VSOut { float4 Position : SV_Position; float2 Uv : TEXCOORD0; float4 Color : COLOR0; };
 
[shader("vertex")]
VSOut VSMain(VSIn i)
{
    VSOut o;
    o.Position = mul(ViewProjection, float4(i.Position, 0, 1));
    o.Uv = i.Uv;
    o.Color = i.Color;
    return o;
}
 
[shader("pixel")]
float4 PSMain(VSOut i) : SV_Target
{
    float4 c = Atlas.Sample(AtlasSampler, i.Uv) * i.Color * Tint;
#if ALPHA_TEST
    clip(c.a - 0.5);
#endif
#if LIGHTING_LAMBERT
    float3 n = NormalMap.Sample(AtlasSampler, i.Uv).xyz * 2.0 - 1.0;
    c.rgb *= saturate(dot(n, LightDir));
#elif LIGHTING_PBR
    float3 n = NormalMap.Sample(AtlasSampler, i.Uv).xyz * 2.0 - 1.0;
    float3 l = LightDir;
    float d = saturate(dot(n, l));
    c.rgb = c.rgb * d + pow(d, 16.0) * 0.04;
#endif
    return c;
}

Program.cs, with RecursiveAI.OneShader 1.2.0:

C#
using OneShader;
 
// A material system asks for the same few variants again and again.
var cache = new ShaderCompileCache(maxEntries: 64);   // thread-safe; least recently used results are dropped
string[] lighting = ["NONE", "LAMBERT", "PBR"];
 
CompileResult Get(string light, bool alphaTest)
{
    List<KeyValuePair<string, string?>> defines = [new("LIGHTING_" + light, "1")];
    if (alphaTest)
        defines.Add(new("ALPHA_TEST", null));   // null defines it as 1
    return cache.CompileFile("Shaders/Material.hlsl", new CompileOptions
    {
        Defines = defines,
        IncludeDirectories = ["Shaders/Common"],
    });
}
 
// Three frames ask for all six variants each, from four threads at once.
for (int frame = 0; frame < 3; frame++)
{
    Parallel.ForEach(lighting.SelectMany(l => new[] { (l, false), (l, true) }),
        new ParallelOptions { MaxDegreeOfParallelism = 4 },
        v => { if (!Get(v.Item1, v.Item2).Success) throw new Exception("compile failed"); });
    Console.WriteLine($"frame {frame}: {cache.Misses} compiles, {cache.Hits} hits, {cache.Count} cached");
}
 
// A cached result has the bytes a direct compile gives (and that call was one more hit).
byte[] cached = Get("PBR", true).Find("PSMain", CompileTarget.SpirV)!.ToArray();
byte[] direct = ShaderCompiler.CompileFile("Shaders/Material.hlsl", new CompileOptions
{
    Defines = [new("LIGHTING_PBR", "1"), new("ALPHA_TEST", null)],
    IncludeDirectories = ["Shaders/Common"],
}).Find("PSMain", CompileTarget.SpirV)!.ToArray();
Console.WriteLine($"same bytes as a direct compile: {cached.AsSpan().SequenceEqual(direct)}");
 
// Editing an include is noticed: the next call for an affected variant compiles again.
File.AppendAllText("Shaders/Common/Lighting.hlsli", "// edited\n");
Get("PBR", true);
Console.WriteLine($"after editing Lighting.hlsli: {cache.Misses} compiles, {cache.Hits} hits");
 
cache.Clear();
Console.WriteLine($"after Clear(): {cache.Count} cached");
Text
frame 0: 6 compiles, 0 hits, 6 cached
frame 1: 6 compiles, 6 hits, 6 cached
frame 2: 6 compiles, 12 hits, 6 cached
same bytes as a direct compile: True
after editing Lighting.hlsli: 7 compiles, 13 hits
after Clear(): 0 cached

Good to know

  • Concurrency. The cache is thread-safe. Callers asking for the same key at the same time share one compile, and different keys compile in parallel.
  • Bounded. maxEntries (default 256) caps the number of results kept. The least recently used are dropped.
  • Errors are cached too. A failed compile is a result like any other, until its source, an include or an option changes. A cancelled call caches nothing.
  • Shared results. A cached CompileResult is one object handed to every caller, so treat it as read-only. Its bytes are the ones a direct ShaderCompiler.Compile gives.
  • Compile and CompileFile. cache.Compile(source, path, options) takes the text. cache.CompileFile(path, options) reads the file on every call, so an edit to the main file is noticed as well.

For variants known at build time, Axes in dotnet build compiles them ahead of time. For the reload loop around a cache, see Reload shaders while the app runs. The cache is described in the compiler package.