---
title: "Shader variants with Axes in dotnet build"
url: https://recursiveai.org/oneshader/cookbook/shader-variants-axes/
description: "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…"
version: "1.2.0"
---

# Shader variants with Axes in dotnet build

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`:

```csharp
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 `bool`s, 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](https://recursiveai.org/oneshader/cookbook/specialization-constants/) instead. To compile variants at run time, see [Shader variants with defines and includes](https://recursiveai.org/oneshader/cookbook/shader-variants-defines-includes/) and [Cache shader compiles](https://recursiveai.org/oneshader/cookbook/shader-compile-cache/).

The generated members are listed under Variants in [Generated bindings](https://recursiveai.org/oneshader/docs/generated-bindings/), and the MSBuild side in [the build package](https://recursiveai.org/oneshader/docs/build-package/).
