To compile HLSL to SPIR-V for Vulkan, run an HLSL compiler with a SPIR-V back end: dxc -spirv -T ps_6_0 -E main shader.hlsl -Fo shader.spv with Microsoft’s DirectX Shader Compiler (DXC), or the same flags with oneshader, a .NET HLSL compiler that also writes Metal and WGSL. Then check the result with spirv-val. The command is the easy part. Most of the problems come from how HLSL registers turn into Vulkan descriptor bindings, and which target environment the compiler assumes.
This page compares the three compilers you’ll find for HLSL to SPIR-V and shows each one on the same shaders. Every command below was run with DXC from the LunarG Vulkan SDK 1.4.363.0 (dxcompiler 1.9.0.5512), glslang 16.6.0, SPIRV-Tools v2026.4 and OneShader 1.2.0, and the output shown is what they printed.
HLSL to SPIR-V compilers at a glance
| Compiler, version read | HLSL it accepts | Outputs | Runs as | License |
|---|---|---|---|---|
| DXC v1.9.2609, released 29 September 2026 | Shader Model 6.x, every stage, including ray tracing and mesh shaders on Vulkan (Vulkan Guide) | DXIL (Direct3D 12) by default, SPIR-V with -spirv | Native binary and library; the release ships Windows and Linux builds | University of Illinois/NCSA (LICENSE.TXT) |
| glslang 16.6.0, released 11 September 2026 | “An approximation of HLSL”; the HLSL front end is deprecated as of April 2026 (README) | SPIR-V (its main job is GLSL) | Native binary and library | Several licenses, listed in its LICENSE.txt |
| OneShader 1.2.0 | Modern HLSL (Shader Model 6 style, HLSL 2021) for vertex, pixel and compute shaders | SPIR-V 1.5 for Vulkan 1.2, Metal Shading Language, WGSL, reflection JSON | .NET 10: a dotnet tool, an MSBuild package and a library | MIT OR Apache-2.0 |
DXC is the reference. The Vulkan Guide calls it “the reference HLSL to SPIR-V compiler” and says it “has the most complete and up-to-date support and is the recommended way of generating SPIR-V from HLSL” (HLSL in Vulkan). glslang is the reference GLSL compiler. Its HLSL support no longer takes bug reports, and it will be removed at glslang’s next major version, with at least 18 months’ notice (issue #4210). OneShader covers a narrower set of stages than DXC. In return it writes three targets from one front end and runs anywhere .NET 10 runs. It doesn’t write DXIL, and it has no geometry, tessellation, mesh or ray-tracing stages.
SPIR-V matters on Direct3D too. Microsoft announced in September 2024 that “once Shader Model 7 is released, DirectX 12 will accept shaders compiled to SPIR-V” (DirectX Developer Blog), describing it as a multi-year transition.
Compile HLSL to SPIR-V with DXC
Here is a pixel shader written the way many Direct3D shaders are, with registers and no Vulkan annotations:
cbuffer Material : register(b0) { float4 Tint; float Roughness; }; Texture2D<float4> Albedo : register(t0); SamplerState LinearWrap : register(s0); float4 main(float2 uv : TEXCOORD0) : SV_Target { return Albedo.Sample(LinearWrap, uv) * Tint * (1 - Roughness); }
DXC compiles it, and spirv-val accepts the module:
dxc -spirv -T ps_6_0 -E main Lit.hlsl -Fo lit.spv spirv-val --target-env vulkan1.2 lit.spv spirv-dis lit.spv | grep -E "OpDecorate .* Binding"
OpDecorate %Material Binding 0 OpDecorate %Albedo Binding 0 OpDecorate %LinearWrap Binding 0
All three resources landed on binding 0. DXC’s HLSL to SPIR-V feature mapping manual explains why: with :register(xX, spaceY), the resource goes to descriptor set Y and binding X “regardless of the register type”, and “this will cause binding number collision”. Direct3D has separate b, t, s and u register spaces, while Vulkan has one binding space per set. The module is still valid SPIR-V, because spirv-val checks the module, not your descriptor set layout. The collision shows up later, when you build the pipeline.
DXC’s fix is a shift per register type and space, -fvk-t-shift N M and friends:
dxc -spirv -T ps_6_0 -E main -fvk-t-shift 1 0 -fvk-s-shift 2 0 Lit.hlsl -Fo lit.spv spirv-dis lit.spv | grep -E "OpDecorate .* Binding"
OpDecorate %Material Binding 0 OpDecorate %Albedo Binding 1 OpDecorate %LinearWrap Binding 2
Shifts keep the shader source unchanged, at the price of a convention every build script has to repeat.
The target environment flag
The second thing that catches people is DXC’s default target environment. It assumes Vulkan 1.0, so anything newer, such as wave intrinsics, fails until you name the environment. This compute shader uses a wave intrinsic and an HLSL 2021 template:
RWStructuredBuffer<uint> Counts : register(u0); template<typename T> T Square(T v) { return v * v; } [numthreads(64, 1, 1)] void main(uint3 id : SV_DispatchThreadID) { uint n = WaveActiveCountBits(id.x % 3 == 0); if (WaveIsFirstLane()) InterlockedAdd(Counts[0], Square(n)); }
dxc -spirv -T cs_6_0 -E main -HV 2021 Wave.hlsl -Fo wave.spvWave.hlsl:8:14: error: Vulkan 1.1 is required for Wave Operation but not permitted to use uint n = WaveActiveCountBits(id.x % 3 == 0); ^ note: please specify your target environment via command line option -fspv-target-env= Wave.hlsl:9:9: error: Vulkan 1.1 is required for Wave Operation but not permitted to use if (WaveIsFirstLane()) ^ note: please specify your target environment via command line option -fspv-target-env=
Add -fspv-target-env=vulkan1.2 (or vulkan1.1, vulkan1.3) and it compiles. OneShader always writes SPIR-V 1.5 for the Vulkan 1.2 environment, so it needs no flag, and it accepts -fspv-target-env=vulkan1.2 when a build script passes it:
dxc -spirv -fspv-target-env=vulkan1.2 -T cs_6_0 -E main -HV 2021 Wave.hlsl -Fo wave.dxc.spv oneshader -spirv -T cs_6_0 -E main -HV 2021 Wave.hlsl -Fo wave.spv spirv-val --target-env vulkan1.2 wave.dxc.spv spirv-val --target-env vulkan1.2 wave.spv
Both modules pass spirv-val, which prints nothing on success.
Compile HLSL with glslang
glslang compiles HLSL with -D, but it parses an older dialect. The same compute shader stops at the template:
glslangValidator -D -V --target-env vulkan1.2 -S comp -e main Wave.hlsl -o wave.glslang.spvWave.hlsl ERROR: Wave.hlsl:3: 'template' : Reserved word. ERROR: 1 compilation errors. No code generated. WARNING: Linking compute stage: Entry point not found SPIR-V is not generated for failed compile or link
Without the template, glslang compiles the wave intrinsics too. Given the deprecation notice, it’s a fine choice for an existing GLSL pipeline that also has a few HLSL files, and a poor one to start new HLSL work on.
Compile HLSL to SPIR-V with OneShader
The oneshader command takes DXC’s core flags (-T, -E, -D, -I, -Fo, -spirv, -O0 to -O3, -Zpr/-Zpc, -HV 2021, -WX, -enable-16bit-types), so most DXC command lines run unchanged. Install it as a .NET tool:
dotnet tool install -g RecursiveAI.OneShader.Tool --version 1.2.0Give it the first shader, the one DXC compiled with three resources on binding 0:
oneshader -spirv -T ps_6_0 -E main Lit.hlsl -Fo lit.spvLit.hlsl(2,19): error OSH0330: The resources 'Material' and 'Albedo' both use set 0, binding 0. Add [[vk::binding(binding, set)]] to one of them, or give them different register numbers (t, s, b and u registers share binding numbers on Vulkan). Lit.hlsl(3,14): error OSH0330: The resources 'Material' and 'LinearWrap' both use set 0, binding 0. Add [[vk::binding(binding, set)]] to one of them, or give them different register numbers (t, s, b and u registers share binding numbers on Vulkan).
OneShader maps registers to bindings exactly as DXC does by default. The difference is that it reports a collision at the HLSL line, where DXC writes a module that fails later, at pipeline creation. It doesn’t take the -fvk-*-shift options (they stop with a message naming the option), so the numbers go in the shader with [[vk::binding(binding, set)]]. That is also what the Vulkan Guide recommends: “Always specify explicit descriptor set and binding indices”.
cbuffer Material : register(b0) { float4 Tint; float Roughness; }; [[vk::binding(1)]] Texture2D<float4> Albedo : register(t0); [[vk::binding(2)]] SamplerState LinearWrap : register(s0); float4 main(float2 uv : TEXCOORD0) : SV_Target { return Albedo.Sample(LinearWrap, uv) * Tint * (1 - Roughness); }
oneshader -spirv -T ps_6_0 -E main LitBound.hlsl -Fo lit.spv spirv-val --target-env vulkan1.2 lit.spv spirv-dis lit.spv | grep -E "OpDecorate .* Binding"
OpDecorate %Material Binding 0 OpDecorate %Albedo Binding 1 OpDecorate %LinearWrap Binding 2
DXC gives the same bindings for this file. Keeping register(t0) next to [[vk::binding]] keeps the source valid for a Direct3D build with DXC.
Metal and WGSL from the same command
The same front end writes Metal Shading Language and WGSL with the same bindings and buffer byte layout. Add --target once per output, and --reflect for a JSON description of every resource, binding and buffer offset:
oneshader -T ps_6_0 -E main --target spirv --target msl --target wgsl LitBound.hlsl -Fo out/lit --reflect out/lit.json ls out
lit.json lit.metal lit.spv lit.wgsl
lit.spv passes spirv-val and lit.wgsl passes naga. The details are in HLSL to WGSL and HLSL to Metal.
From C# or dotnet build
In a .NET application the compiler is a library call, ShaderCompiler.CompileFile, with no native DLL to ship, as shown in the compile HLSL to SPIR-V recipe. With the RecursiveAI.OneShader.Build package, every *.hlsl file compiles during dotnet build, and the SPIR-V is embedded next to generated C# structs. Compiling HLSL in a C# project compares that with calling DXC.
Validate the SPIR-V
Run spirv-val from the Vulkan SDK on every module, with the environment you target: spirv-val --target-env vulkan1.2 shader.spv. It prints nothing and exits 0 when the module is valid. Validation catches malformed SPIR-V. It doesn’t catch resources that collide in a descriptor set, a constant buffer layout your C# struct disagrees with, or a feature the device lacks. For those, enable the Vulkan validation layers when you create the pipeline, and read the bindings back with spirv-dis shader.spv | grep Binding, or from reflection.
The OneShader repository validates every corpus shader with spirv-val and runs each one on GPUs next to the same shader compiled by DXC, with the validation layers on (overview).
Which HLSL to SPIR-V compiler to use
- DXC when you need geometry, tessellation, mesh or ray-tracing shaders, DXIL for Direct3D 12 from the same source, the SPIR-V extensions it exposes through
-fspv-extension, or binding remapping on the command line. - OneShader when your shaders are vertex, pixel and compute, and you want Vulkan, Metal and WebGPU from one HLSL source, a compiler that runs inside a .NET build or app without native binaries, or errors such as binding collisions at the HLSL line. Its DXC flag table lists what carries over from a DXC command line.
- glslang for GLSL. For HLSL, only to keep an existing pipeline running while you move off its deprecated front end.
Common questions
How do I convert HLSL to SPIR-V?
Compile it with a SPIR-V back end: dxc -spirv -fspv-target-env=vulkan1.2 -T ps_6_0 -E main shader.hlsl -Fo shader.spv, or oneshader -T ps_6_0 -E main shader.hlsl -Fo shader.spv. Pick the profile for the stage (vs_6_0, ps_6_0, cs_6_0) and name the entry point with -E.
Why do all my HLSL resources get binding 0 on Vulkan?
Because register(b0), register(t0) and register(s0) all map to binding 0 in set 0. Give each resource its own number with [[vk::binding(n, set)]], use different register numbers, or shift each register type with DXC’s -fvk-b-shift, -fvk-t-shift, -fvk-s-shift and -fvk-u-shift.
Can SPIR-V be converted back to HLSL?
Yes, with SPIRV-Cross, which converts SPIR-V to GLSL, HLSL and Metal (README). OneShader goes one way only, from HLSL.
To try OneShader, install the tool with dotnet tool install -g RecursiveAI.OneShader.Tool and follow getting started. The command line reference lists every flag, and the SPIR-V backend page shows how HLSL maps to SPIR-V.