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:
// 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:
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:
<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:
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}");
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 as1or not defined at all, so test it with#ifor#ifdef. - A choice (
LIGHTING=NONE|LAMBERT|PBR) definesLIGHTINGas the value, andLIGHTING_PBR(for example) as1, so#if LIGHTING_PBRworks. - Order is the cartesian product with the first axis changing slowest.
Key.Indexis the position inVariants.All. - Names are
<Axis>On/<Axis>Offfor a flag and<Axis><Value>for a choice, PascalCased and joined by_. Choices become enums (Material.LightingValue), flagsbools, and named setsVariantName. - 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 (Framehere) 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 eitherAxesorOneShaderVariantitems 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:
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:
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:
/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 SkyinSky.hlslis 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.