Declare HLSL specialization constants once with [[vk::constant_id]] and set them when the pipeline is created: VkSpecializationInfo on Vulkan, function constants on Metal and pipeline-overridable constants on WebGPU, from one compiled shader.
A specialization constant is a value the shader reads like a constant but the pipeline chooses, so one compiled shader serves many variants without recompiling. OneShader 1.2.0 compiles [[vk::constant_id(N)]] const T Name = default; (bool, int, uint or float) to each target’s mechanism, and generates the C# to fill them in.
Shaders/Blur.hlsl, a separable Gaussian blur whose radius, direction and sigma are pipeline-time constants:
// Pipeline-time constants: one compiled shader, many pipelines. [[vk::constant_id(0)]] const int Radius = 4; [[vk::constant_id(1)]] const bool Horizontal = true; [[vk::constant_id(2)]] const float Sigma = 2.0; Texture2D<float4> Source : register(t0); [[vk::image_format("rgba8")]] RWTexture2D<float4> Target : register(u1); [shader("compute")] [numthreads(8, 8, 1)] void CSMain(uint3 id : SV_DispatchThreadID) { int2 step = Horizontal ? int2(1, 0) : int2(0, 1); float4 sum = 0; float total = 0; for (int i = -Radius; i <= Radius; i++) { float w = exp(-(i * i) / (2 * Sigma * Sigma)); sum += Source[int2(id.xy) + step * i] * w; total += w; } Target[id.xy] = sum / total; }
The project references RecursiveAI.OneShader.Build 1.2.0 with OneShaderTargets set to SpirV;Msl;Wgsl. Program.cs:
using OneShader.Layouts; using OneShader.Layouts.Metal; using OneShader.Layouts.Vulkan; using Spec.Shaders; // The constants, their ids and defaults, generated from the shader. Console.WriteLine($"Radius: id {Blur.SpecializationConstants.Radius.Id}, default {Blur.SpecializationConstants.Radius.Default}"); Console.WriteLine($"CSMain reads ids [{string.Join(", ", Blur.CSMain.SpecConstantIds.ToArray())}]"); // A vertical blur with radius 8: set fields on the generated struct. Blur.Specialization s = Blur.Specialization.Defaults; s.Radius = 8; s.Horizontal = 0; // a bool is a 4-byte 0 or 1 // Vulkan: the contents of VkSpecializationInfo. SpecializationInfo vk = s.ToVulkan(Blur.CSMain.SpecConstantIds); foreach (SpecializationMapEntry e in vk.MapEntries) Console.WriteLine($"Vulkan: constantID {e.ConstantId}, offset {e.Offset}, size {e.Size}"); Console.WriteLine($"Vulkan: data {Convert.ToHexString(vk.Data)}"); // Metal: MTLFunctionConstantValues entries, by index and MTLDataType. foreach (FunctionConstantValue v in s.ToMetal(Blur.CSMain.SpecConstantIds)) Console.WriteLine($"Metal: index {v.Index}, MTLDataType {v.Type.DataType}, bits 0x{v.Bits:x8}"); // WebGPU: the compute stage's constants record, keyed by decimal id. Console.WriteLine($"WebGPU: {Record(s.ToWebGpu(Blur.CSMain.SpecConstantIds))}"); // The same by name, from the entry layout (this also works for shaders compiled at run time). SpecializationValues byName = SpecializationValues.For(Blur.CSMain.Layout).Set("Radius", 8).Set("Horizontal", false); Console.WriteLine($"By name: {Record(byName.ToWebGpu())}"); // What each target compiled the constant to. Console.WriteLine(Line(Blur.CSMain.Wgsl, "override Radius")); Console.WriteLine(Line(Blur.CSMain.Msl, "function_constant(0)")); static string Record(IReadOnlyDictionary<string, double> c) => "{ " + string.Join(", ", c.Select(kv => $"\"{kv.Key}\": {kv.Value}")) + " }"; static string Line(ReadOnlySpan<byte> utf8, string text) => System.Text.Encoding.UTF8.GetString(utf8).Split('\n').First(l => l.Contains(text)).Trim();
Radius: id 0, default 4 CSMain reads ids [0, 1, 2] Vulkan: constantID 0, offset 0, size 4 Vulkan: constantID 1, offset 4, size 4 Vulkan: constantID 2, offset 8, size 4 Vulkan: data 080000000000000000000040 Metal: index 0, MTLDataType 29, bits 0x00000008 Metal: index 1, MTLDataType 53, bits 0x00000000 Metal: index 2, MTLDataType 3, bits 0x40000000 WebGPU: { "0": 8, "1": 0, "2": 2 } By name: { "0": 8, "1": 0, "2": 2 } @id(0) override Radius: i32 = 4i; constant int Radius [[function_constant(0)]];
The SPIR-V passes spirv-val and the WGSL passes naga. On SPIR-V the constants are OpSpecConstant (and OpSpecConstantTrue for the bool) with SpecId 0, 1 and 2, the ids DXC writes for the same source.
Passing them to each API
- Vulkan:
MapEntriesandDataare the contents ofVkSpecializationInfo(pMapEntries,pData). Each constant is 4 bytes, in declaration order:08000000is the radius 8,00000000thefalse,00000040the float 2.0. - Metal: set each value on an
MTLFunctionConstantValuesat its index, with theMTLDataTypenumber (29 isint, 53bool, 3float). A function that declares function constants has to be created withmakeFunction(name:constantValues:), even to get the defaults: pass an emptyMTLFunctionConstantValuesfor those. The MSL reads each constant throughis_function_constant_defined, with the default as the fallback. - WebGPU: the record goes in the stage’s
constantswhen creating the pipeline. Pass the entry point’sSpecConstantIds, as above, because WebGPU rejects constants the entry point doesn’t declare. Vulkan and Metal ignore extra ones. - By name:
SpecializationValues.For(layout)takes anEntryLayoutfrom the generated code or from run-time reflection (EntryLayout.TryCreate), with typedSetoverloads. A value of the wrong type or an unknown name throws.
Fixing constants at compile time
To bake some values in instead, compile with CompileOptions.SpecializationOverrides (RecursiveAI.OneShader 1.2.0):
using OneShader; // Fix Radius at compile time: it becomes a plain constant that the optimizer folds. // The others stay pipeline-time constants. CompileResult result = ShaderCompiler.CompileFile("Shaders/Blur.hlsl", new CompileOptions { SpecializationOverrides = [new("Radius", 2), new("Blurr", 1)], }); foreach (var d in result.Diagnostics) Console.WriteLine(d); Console.WriteLine($"left as constants: {string.Join(", ", result.Reflection!.SpecConstants.Select(c => $"{c.Name} (id {c.Id})"))}"); CompileResult bad = ShaderCompiler.CompileFile("Shaders/Blur.hlsl", new CompileOptions { SpecializationOverrides = [new("Radius", 2.5)], }); foreach (var d in bad.Diagnostics) Console.WriteLine(d);
Shaders/Blur.hlsl(1,1): warning OSH0335: The shader declares no specialization constant named 'Blurr', so the override is ignored. Check the spelling against the [[vk::constant_id]] declarations. left as constants: Horizontal (id 1), Sigma (id 2) Shaders/Blur.hlsl(1,1): error OSH0335: The value 2.5 does not fit the int specialization constant 'Radius': use a whole number in its range.
Limits
Types:
bool,int,uintandfloatscalars. A vector or struct,static, a missing or non-constant default, a negative id or a duplicate id is error OSH0335.Not where the size must be known at compile time: an array size, a
[numthreads]argument or acaselabel that uses one is error OSH0310, as DXC refuses the same uses:Text /tmp/nt.hlsl(3,33): error OSH0310: Argument 1 of 'numthreads' must be an integer constant. It uses a specialization constant, whose value is only known when the pipeline is created: use a literal or a 'static const' there, or choose the value with a #define.Hot reload:
InterfaceCompatibilityreports a removed constant or a changed id or type. New constants and changed defaults stay compatible.
For variants that change resources or code structure, compile them separately with Axes. The generated members are under Specialization constants in Generated bindings.