Recursive AI

Compile HLSL to SPIR-V for Vulkan

All oneshader recipes

Compile an HLSL vertex or pixel shader to SPIR-V for Vulkan with the oneshader command and its DXC-style flags, or from C# with ShaderCompiler, and check the result with spirv-val.

OneShader writes SPIR-V 1.5 for the Vulkan 1.2 environment, with buffer layouts and descriptor bindings that follow DXC’s Vulkan rules. The sample shader is Sprite.hlsl from Getting started: a constant buffer at b0, a texture at [[vk::binding(1)]] and a sampler at [[vk::binding(2)]].

From the command line

Terminal
dotnet tool install -g RecursiveAI.OneShader.Tool --version 1.2.0
 
oneshader -T vs_6_0 -E VSMain -spirv Sprite.hlsl -Fo sprite.vs.spv
oneshader -T ps_6_0 -E PSMain -spirv Sprite.hlsl -Fo sprite.ps.spv
 
spirv-val --target-env vulkan1.2 sprite.vs.spv
spirv-val --target-env vulkan1.2 sprite.ps.spv
spirv-dis sprite.ps.spv | grep -E "Binding|DescriptorSet"

Both files pass spirv-val (from the Vulkan SDK) silently, and the bindings are where the shader put them:

Text
               OpDecorate %Atlas DescriptorSet 0
               OpDecorate %Atlas Binding 1
               OpDecorate %AtlasSampler DescriptorSet 0
               OpDecorate %AtlasSampler Binding 2

The same command line with dxc instead of oneshader gives the same set and binding numbers.

From C#

With the RecursiveAI.OneShader package (dotnet add package RecursiveAI.OneShader --version 1.2.0):

C#
using OneShader;
 
string source = File.ReadAllText("Sprite.hlsl");
CompileResult result = ShaderCompiler.Compile(source, "Sprite.hlsl", new CompileOptions
{
    // The C# form of -T ps_6_0 -E PSMain; leave EntryPoints empty to compile every [shader("...")] function.
    EntryPoints = [new EntryPointRequest("PSMain", ShaderStage.Pixel)],
});
 
foreach (var d in result.Diagnostics)
    Console.WriteLine(d);
if (!result.Success)
    return 1;
 
ShaderBlob ps = result.Find("PSMain", CompileTarget.SpirV)!;
File.WriteAllBytes("sprite.ps.spv", ps.ToArray());
uint magic = BitConverter.ToUInt32(ps.Data[..4]);
Console.WriteLine($"{ps.EntryPoint}: {ps.Data.Length} bytes, magic 0x{magic:x8}");
return 0;
Text
PSMain: 856 bytes, magic 0x07230203

0x07230203 is the SPIR-V magic number. The file written here is byte for byte what the command line wrote, and it passes spirv-val too.

Good to know

  • Bindings. Use [[vk::binding(n)]] or [[vk::binding(n, set)]], or HLSL registers, which map to bindings as DXC maps them by default. Unannotated resources are numbered in declaration order. -fvk-* remapping options aren’t supported: put the numbers in the shader.
  • Debug names. By default the SPIR-V keeps source names (%Atlas above). --no-names (or KeepNames = false) leaves them out: here the pixel shader goes from 856 to 692 bytes.
  • Optimization is on by default (-O3 and -O0 work as in DXC; Optimize = false in C#).
  • Validation. The repository validates every corpus shader with spirv-val and runs it on GPUs next to the same shader compiled by DXC, with the Vulkan validation layers on.
  • Not in 1.2.0: DXIL (Direct3D 12) output, and geometry, tessellation, mesh and ray-tracing stages. -T gs_6_0 is a command-line error naming the supported profiles.

The full SPIR-V mapping is in SPIR-V (Vulkan). To compile the same shader during dotnet build, see Compile HLSL in a C# project.

Choosing a compiler: HLSL to SPIR-V compiler: DXC, glslang and OneShader compared.