Recursive AI

Shader variants with Axes in dotnet build

All oneshader recipes

Compile every combination of an HLSL shader’s keywords during dotnet build with Axes, or hand-picked define sets with OneShaderVariant, and pick a variant at run time with a typed key and no allocation.

A material shader often has keywords such as alpha test on or off, and a lighting model. In RecursiveAI.OneShader.Build 1.2.0, one item metadata compiles the cartesian product of the keywords, once per variant and target, in parallel. The generated class gets a typed Key, a Variants table and one static class per variant. The shader is the repository’s samples/Variants/Shaders/Material.hlsl:

HLSL
// A sprite material with two keyword axes. The build compiles it once per combination, defining
//   ALPHA_TEST            (a flag: defined as 1, or not defined at all)
//   LIGHTING              (the value: NONE, LAMBERT or PBR) and LIGHTING_NONE / LIGHTING_LAMBERT / LIGHTING_PBR as 1.
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, normalize(float3(0.3, 0.5, 0.8))));
#elif LIGHTING_PBR
    float3 n = NormalMap.Sample(AtlasSampler, i.Uv).xyz * 2.0 - 1.0;
    float3 l = normalize(float3(0.3, 0.5, 0.8));
    float d = saturate(dot(n, l));
    c.rgb = c.rgb * d + pow(d, 16.0) * 0.04;
#endif
    return c;
}

A second shader, Shaders/Sky.hlsl, gets two named define sets instead:

HLSL
cbuffer SkyParams : register(b0) { float3 SunDirection; float Time; };
 
[shader("pixel")]
float4 PSMain(float3 dir : TEXCOORD0) : SV_Target
{
    float sun = saturate(dot(normalize(dir), SunDirection));
#if NIGHT
    float3 c = float3(0.01, 0.02, 0.05);
#ifdef STARS
    c += step(0.999, frac(sin(dot(dir.xy, float2(12.9898, 78.233))) * 43758.5453));
#endif
#else
    float3 c = lerp(float3(0.4, 0.6, 0.9), float3(1.0, 0.9, 0.7), sun);
#endif
    return float4(c, 1);
}

The project:

XML
<Project Sdk="Microsoft.NET.Sdk">
 
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
 
  <ItemGroup>
    <PackageReference Include="RecursiveAI.OneShader.Build" Version="1.2.0" />
    <!-- ALPHA_TEST on or off, times LIGHTING NONE, LAMBERT or PBR: 6 variants. -->
    <OneShader Include="Shaders/Material.hlsl" Axes="ALPHA_TEST;LIGHTING=NONE|LAMBERT|PBR" />
    <!-- Hand-picked define sets for another shader. -->
    <OneShaderVariant Include="Day" Shader="Shaders/Sky.hlsl" Defines="NIGHT=0" />
    <OneShaderVariant Include="Night" Shader="Shaders/Sky.hlsl" Defines="NIGHT=1;STARS" />
  </ItemGroup>
 
</Project>

Program.cs:

C#
using Axes.Shaders;
 
Console.WriteLine($"{Material.Variants.Count} variants");
foreach (Material.Variant v in Material.Variants.All)
    Console.WriteLine($"{v.Index} {v.Name,-28} {v.Defines,-29} pixel {v.PSMain.SpirV.Length} bytes, {v.Bindings.Length} bindings");
 
// A typed key picks one variant: an array index, no allocation.
var key = new Material.Key(alphaTest: true, lighting: Material.LightingValue.Pbr);
Material.Variant pbr = Material.Variants.Get(key);
Console.WriteLine($"Get({key.AlphaTest}, {key.Lighting}) -> {pbr.Name}");
foreach (var b in pbr.Bindings)
    Console.WriteLine($"  {b.Name}: set {b.Set}, binding {b.Binding}");
 
// Every variant has the same Frame layout, so the struct is generated once.
var frame = new Material.Frame { Tint = System.Numerics.Vector4.One };
Console.WriteLine($"Frame: {Material.Frame.Size} bytes, shared by all variants");
 
// The same variant as a static class, shaped like a file without variants.
Console.WriteLine($"Material.Variants.AlphaTestOn_LightingPbr.PSMain: {Material.Variants.AlphaTestOn_LightingPbr.PSMain.SpirV.Length} bytes");
 
// Named variants: one per OneShaderVariant item.
foreach (Sky.Variant v in Sky.Variants.All)
    Console.WriteLine($"Sky {v.Name}: {v.Defines}, pixel {v.PSMain.SpirV.Length} bytes");
Sky.Variant night = Sky.Variants.Get(new Sky.Key(Sky.VariantName.Night));
Console.WriteLine($"Get(Night) -> {night.Name}");
Text
6 variants
0 AlphaTestOff_LightingNone    LIGHTING=NONE                 pixel 1248 bytes, 3 bindings
1 AlphaTestOff_LightingLambert LIGHTING=LAMBERT              pixel 1956 bytes, 4 bindings
2 AlphaTestOff_LightingPbr     LIGHTING=PBR                  pixel 2080 bytes, 4 bindings
3 AlphaTestOn_LightingNone     ALPHA_TEST=1 LIGHTING=NONE    pixel 1396 bytes, 3 bindings
4 AlphaTestOn_LightingLambert  ALPHA_TEST=1 LIGHTING=LAMBERT pixel 2072 bytes, 4 bindings
5 AlphaTestOn_LightingPbr      ALPHA_TEST=1 LIGHTING=PBR     pixel 2196 bytes, 4 bindings
Get(True, Pbr) -> AlphaTestOn_LightingPbr
  Frame: set 0, binding 0
  Atlas: set 0, binding 1
  AtlasSampler: set 0, binding 2
  NormalMap: set 0, binding 3
Frame: 80 bytes, shared by all variants
Material.Variants.AlphaTestOn_LightingPbr.PSMain: 2196 bytes
Sky Day: NIGHT=0, pixel 1092 bytes
Sky Night: NIGHT=1 STARS, pixel 864 bytes
Get(Night) -> Night

How the axes become defines

  • A flag (ALPHA_TEST) is either defined as 1 or not defined at all, so test it with #if or #ifdef.
  • A choice (LIGHTING=NONE|LAMBERT|PBR) defines LIGHTING as the value, and LIGHTING_PBR (for example) as 1, so #if LIGHTING_PBR works.
  • Order is the cartesian product with the first axis changing slowest. Key.Index is the position in Variants.All.
  • Names are <Axis>On/<Axis>Off for a flag and <Axis><Value> for a choice, PascalCased and joined by _. Choices become enums (Material.LightingValue), flags bools, and named sets VariantName.
  • Bindings can differ. The normal map exists only in the lit variants, so read slots from the variant (v.Bindings, v.Pipeline), not from the file. A struct that every variant lays out the same way (Frame here) is generated once. If a member sits under #if, each variant gets its own struct instead.
  • Other metadata (Defines, Targets, $(OneShaderDefines)) applies to every variant. Use either Axes or OneShaderVariant items on one shader, not both.

Building incrementally

Only the variants whose define set, shader, includes or options changed are compiled again. With dotnet build -v:d, a build with nothing changed logs:

Text
         OneShader: 0 compiled, 8 up to date.

After adding TOON to the lighting axis (LIGHTING=NONE|LAMBERT|PBR|TOON), just the two new combinations compile:

Text
         OneShader: 2 compiled, 8 up to date.

Errors under some variants

An error is reported once, on the HLSL line, with the variants it happened in. Misspelling d in the PBR branch gives:

Text
/tmp/oneshader-docs/cookbook/Axes/Shaders/Material.hlsl(38,29): error OSH0300: 'dd' is not declared. Did you mean 'd'? (variants LIGHTING=PBR; ALPHA_TEST=1, LIGHTING=PBR) [/tmp/oneshader-docs/cookbook/Axes/Axes.csproj]

Limits

  • 64 variants per shader by default. More is error OSH0905 naming the fix: raise it with MaxVariants="256" on the item or <OneShaderMaxVariants> in the project. Each variant is compiled for every selected target, so the product grows fast.
  • A cbuffer, struct or entry point can’t share the file’s name. A cbuffer Sky in Sky.hlsl is error OSH0951, as in any file.
  • For keyword values that change per draw without recompiling, use specialization constants instead. To compile variants at run time, see Shader variants with defines and includes and Cache shader compiles.

The generated members are listed under Variants in Generated bindings, and the MSBuild side in the build package.