Language6 min read

Shader Variants

Declare pipeline-level variant inputs, constrain legal combinations, and specialize passes at compile time.

Reading Time
6 min
Word Count
1,002
Sections
9
Try It Live

Pressure-test the syntax

Take the concept from this page into the playground and deliberately break a pass, binding, or type signature to see how the compiler responds.

Try a Live Edit

Every real-time renderer ships families of shaders, not single shaders. The same lighting shader needs a skinned variant and a static one, a clustered path and an unlit path, a version with normal maps and a version without. Traditionally you express that family with preprocessor #ifdef soup, a pile of string macros, and an external build system that has to know — separately from the shader — which combinations are even legal. The permutation count explodes, dead combinations get compiled anyway, and the rules live in three different places that drift apart.

BWSL makes the variant space a first-class part of the language. You declare it once at pipeline scope, branch on it with ordinary variants.<name> expressions, constrain the legal combinations with rules, and let the compiler specialize, prune dead code, and validate selections for you. One small block describes the entire family, and the compiler treats illegal combinations as compile errors instead of runtime surprises.

Built for scale

This is the feature BWSL was born to solve. A handful of declarations can describe hundreds of legal shaders — the bundled Morphing Shapes playground example generates 80 unique variants from 7 lines of variant syntax. Because specialization happens on demand, you compile only the variants you actually ship, not the whole combinatorial space.

Declaring Variants

Variants live in a variants block inside a pipeline.

bwsl
pipeline LitMesh {
enum LightingMode {
Unlit
Forward
Clustered
}
attributes {
position: float3
normal: float3
}
variants {
skinning: bool = false;
lighting: LightingMode = LightingMode::Forward;
}
}

Rules:

  • variant declarations are pipeline-scoped
  • supported variant types are bool and plain enum types
  • the default value must be a compile-time constant
  • variant names must be unique within the pipeline

Using Variants in Passes

Access variants through the variants namespace.

bwsl
skinnedVertex :: () -> vertex_function {
vertex {
output.position = float4(0.25, 0.0, 0.0, 1.0);
}
}
staticVertex :: () -> vertex_function {
vertex {
output.position = float4(0.5, 0.0, 0.0, 1.0);
}
}
pass "Main" {
use attributes { position, normal? }
vertex = variants.skinning ? skinnedVertex() : staticVertex()
fragment {
float brightness = variants.skinning ? 0.75 : 0.5;
if (variants.has_normal) {
brightness = brightness + 0.25;
}
if (variants.lighting == LightingMode::Unlit) {
output.color = float4(1.0, 0.0, 0.0, 1.0);
} else {
float blue = variants.lighting == LightingMode::Clustered ? 1.0 : 0.5;
output.color = float4(brightness, 0.0, blue, 1.0);
}
}
}

Variants can drive:

  • stage assignment expressions such as vertex = ...
  • ordinary expressions inside vertex, fragment, or compute
  • compile-time branching and constant folding in if, ternary, and switch statements

When a variant selection is chosen, BWSL specializes the pipeline before lowering. The selected values become compile-time constants, so if (variants.lighting == LightingMode::Unlit) collapses to a known branch, switch (variants.lighting) lowers only the selected case, the dead sides are deleted, and an unused vertex = ... stage path never reaches the backend. The variant booleans don't survive into the generated SPIR-V/Metal/HLSL — there is no runtime branch and no uniform to set. Each specialized shader is exactly as lean as one you'd have hand-written for that single configuration.

Switch Specialization

Enum and boolean variants can also be used directly as switch selectors. This is useful when an enum variant has more than two modes and an if/else if ladder would hide the shape of the specialization.

bwsl
fragment {
float brightness = 0.0;
switch (variants.lighting) {
case LightingMode::Unlit:
brightness = 1.0;
break;
case LightingMode::Forward:
brightness = 0.75;
break;
case LightingMode::Clustered:
brightness = variants.skinning ? 0.9 : 0.8;
break;
default:
brightness = 0.5;
break;
}
output.color = float4(brightness, brightness, brightness, 1.0);
}

For a compile such as -variant lighting=Clustered, the switch selector is known during specialization. BWSL keeps only the LightingMode::Clustered arm, removes the other cases, and then continues folding inside that arm. If no explicit case matches, the default arm is used.

Boolean variants work the same way:

bwsl
switch (variants.skinning) {
case true:
output.color = float4(0.2, 0.7, 1.0, 1.0);
break;
case false:
output.color = float4(1.0, 0.8, 0.2, 1.0);
break;
}

A specialized switch must resolve to at most one case arm. If duplicate case arms both match the selected variant value, compilation fails instead of silently choosing one.

The visualizer below makes this concrete: step through the selections and watch the source re-fold in place — branches that can't be taken for the current selection ghost out, and folded expressions collapse to their constant result. That ghosted-out code is precisely what the compiler strips from the output.

Variant Visualizer
12 concrete shaders from one pipeline
4 / 12
skinning
lighting
attribute mask: normalOptional `normal?` maps to the implicit `variants.has_normal` fact from bit 1.
Branch Pruning
if (variants.skinning)emit vertex wave deformation
elsekeep static vertex path
if (variants.has_normal)read the optional normal stream
else / fallback pathuse the generated fallback normal path
lighting == Unlitemit warm unlit color
lighting == Forwardemit forward-lit blue channel
lighting == Clusteredemit clustered blue channel
N
skinning=falselighting=Forwardhas_normal=trueattributeMask=0b10
Input
One BWSL pipeline with declared variants and an optional attribute.
Selection
Declared variants plus the attribute mask fold the branches.
Output
Dead paths disappear from the specialized shader.
active variant conditioninactive pruned codenormal? -> variants.has_normal from attributeMask bit 1
1pipeline VariantDemo {
2 enum LightingMode {
3 Unlit
4 Forward
5 Clustered
6 }
7
8 variants {
9 skinning: bool = false;
10 lighting: LightingMode = LightingMode::Forward;
11 }
12
13 attributes {
14 position: float3
15 normal: float3
16 }
17
18 pass "Main" {
19 use attributes { position, normal? }
20
21 vertex {
22 float3 p = attributes.position;
23 float3 n = float3(0.0, 0.0, 1.0);
24 if (variants.skinning) {
25 p.y = p.y + sin(p.x * 4.0) * 0.08;
26 }
27 if (variants.has_normal) {
28 n = attributes.normal;
29 }
30 output.position = float4(p, 1.0);
31 output.normal = n;
32 }
33
34 fragment {
35 float brightness = variants.skinning ? 0.85 : 0.55;
36 float normalBoost = -0.05;
37 if (variants.has_normal) {
38 normalBoost = 0.18;
39 }
40 if (variants.lighting == LightingMode::Unlit) {
41 output.color = float4(brightness + normalBoost, 0.25, 0.15, 1.0);
42 } else {
43 float blue = variants.lighting == LightingMode::Clustered ? 1.0 : 0.55;
44 output.color = float4(brightness + normalBoost, 0.35, blue, 1.0);
45 }
46 }
47 }
48}

One Block, Hundreds of Shaders

The reason variants matter at scale is combinatorial. The variant space is the product of every dimension's size, and rules carve the illegal region out of that product. A few declarations describe a very large family:

bwsl
variants {
roundedShapes: bool = true;
softBlend: bool = true;
colorCycle: bool = true;
warmPalette: bool = false;
neonMaterial: bool = false;
debugDistance: bool = false;
glow: bool = true;
rules {
conflict debugDistance, neonMaterial;
conflict debugDistance, glow;
}
}

Seven booleans is 2^7 = 128 raw combinations. The two conflict rules remove every selection where debugDistance is on together with neonMaterial or glow — 48 illegal combinations — leaving exactly 80 legal variants. That is the entire Morphing pipeline's shader family, declared in seven lines, with the legality rules living right next to the variants they constrain instead of in an external build script.

Why AAA pipelines care

Shipping renderers routinely manage thousands of shader permutations, and the hard part is never writing one shader — it's keeping the space coherent: which flags combine, which are mutually exclusive, and not paying compile or memory cost for combinations you never ship. BWSL puts the whole space, its rules, and its specialization in the language and the compiler, where they can be validated and visualized, rather than spread across macros, tooling, and tribal knowledge.

Rules

The optional rules block constrains which selections are legal.

bwsl
variants {
skinning: bool = false;
lighting: LightingMode = LightingMode::Forward;
rules {
require skinning -> has_normal;
conflict lighting == LightingMode::Unlit, skinning;
}
}

Supported rule forms:

  • require <expr> -> <expr>;
  • conflict <expr>, <expr>;

Notes:

  • rule expressions must evaluate to compile-time booleans
  • inside rules, you reference variant names directly, not variants.<name>
  • invalid selections are rejected during specialization

For example, the combination skinning=true and lighting=Unlit is illegal in the rules above and fails compilation.

Implicit Attribute Variants

Optional attributes create implicit boolean variant facts.

bwsl
pass "Main" {
use attributes { position, normal? }
}

The normal? marker introduces variants.has_normal.

Use this when the same pipeline should specialize for different available vertex streams:

bwsl
fragment {
if (variants.has_normal) {
output.color = float4(normalize(input.normal) * 0.5 + 0.5, 1.0);
} else {
output.color = float4(1.0, 1.0, 1.0, 1.0);
}
}

In plain bwslc CLI usage, implicit attribute facts default to true. Engine-side compiler-service integration can provide an attribute mask so those has_<attribute> values reflect the actual enabled vertex streams for a compiled variant.

CLI Specialization

Variants are specialized on demand: each compile produces exactly the one selection you ask for, so you only pay for the variants you actually use. There is no "compile the whole space" step — you drive specialization from your build system, asset pipeline, or shader cache, requesting variants as they're needed and caching the results.

Use -variant to pin a concrete variant value at compile time. Repeat it to set each dimension of the selection.

bash
bwslc shader.bwsl \
  -variant skinning=true \
  -variant lighting=Clustered \
  -metal -hlsl

Any dimension you leave unspecified falls back to its declared default, so partially specifying a selection is fine. To enumerate the legal space ahead of time — for a build matrix or a warm shader cache — pair this with -dump-variant-space below.

Details:

  • -variant is repeatable
  • boolean variants accept true, false, 1, or 0
  • enum variants accept either a bare member such as Clustered or a qualified name such as LightingMode::Clustered
  • implicit has_<attribute> variants cannot be overridden directly

Inspecting Variant Space

Use -dump-variant-space to inspect the declared variants, implicit variants, rules, and currently selected values without emitting shader binaries.

bash
bwslc shader.bwsl -dump-variant-space

Example output shape:

json
{
  "declared": [
    { "name": "skinning", "type": "bool", "default": "false" },
    { "name": "lighting", "type": "LightingMode", "default": "Forward" }
  ],
  "implicit": [
    { "name": "has_normal", "type": "bool", "attributeIndex": 1 }
  ],
  "selected": [
    { "name": "skinning", "type": "bool", "value": "false", "implicit": false },
    { "name": "lighting", "type": "LightingMode", "value": "Forward", "implicit": false },
    { "name": "has_normal", "type": "bool", "value": "true", "implicit": true, "attributeIndex": 1 }
  ]
}

This is useful for tooling, build validation, and engine-side variant caching.