Shader Variants
Declare pipeline-level variant inputs, constrain legal combinations, and specialize passes at compile time.
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 EditEvery 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.
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
booland 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.
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, orcompute - compile-time branching and constant folding in
if, ternary, andswitchstatements
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.
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:
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.
1pipeline VariantDemo {2 enum LightingMode {3 Unlit4 Forward5 Clustered6 }7 8 variants {9 skinning: bool = false;10 lighting: LightingMode = LightingMode::Forward;11 }12 13 attributes {14 position: float315 normal: float316 }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:
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.
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, notvariants.<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.
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:
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.
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:
-variantis repeatable- boolean variants accept
true,false,1, or0 - enum variants accept either a bare member such as
Clusteredor a qualified name such asLightingMode::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.
bwslc shader.bwsl -dump-variant-space
Example output shape:
{
"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.