Recursive AI

Converting HLSL to Metal

Three ways to get HLSL onto Metal: DXC with SPIRV-Cross to MSL source, Apple’s Metal Shader Converter from DXIL, or OneShader’s direct MSL, compared and run.

Metal doesn’t compile HLSL, so an HLSL shader has to be turned into Metal Shading Language (MSL) source or into a compiled Metal library first. There are three ways. DXC can compile HLSL to SPIR-V for SPIRV-Cross to translate into MSL. Apple’s Metal Shader Converter turns DXIL from DXC into a .metallib. Or OneShader compiles HLSL to MSL directly, from the same front end as its SPIR-V and WGSL. Each is shown below on the same shader, with what you get back besides the code.

Versions used: DXC 1.9.0.5512 and SPIRV-Cross from the LunarG Vulkan SDK 1.4.363.0, and OneShader 1.2.0. Everything ran on Linux, where Apple’s metal compiler isn’t available, so the MSL shown wasn’t compiled with it here. Metal Shader Converter needs macOS or Windows and wasn’t run.

The options at a glance

PathInputOutputRuns onRequirements
DXC, then SPIRV-Cross (Apache-2.0)HLSL, through SPIR-VMSL sourceAnywhere both build: native C++ tools and librariesThe MSL version you pick (--msl-version)
Metal Shader Converter 4.0 (beta)DXIL from DXC.metallib (compiled)“macOS 13 Ventura and Xcode 15, or later”, or Windows 10 with Visual Studio 2019 or laterLibraries need “a device that supports Argument Buffers Tier 2, running macOS 14 Sonoma, iOS 17 or later”
OneShader 1.2.0 (MIT OR Apache-2.0)HLSLMSL source.NET 10, any OSMSL 2.4: macOS 12, iOS 15 (MSL backend)

Apple describes Metal Shader Converter as converting “shader intermediate representations in LLVM IR bytecode into bytecode suitable to be loaded into Metal”, in its basic form metal-shaderconverter shader.dxil -o shader.metallib (Apple Developer). Apple lists shader models 6.0 through 6.6 as supported, including ray tracing (SM 6.3) and mesh and amplification shaders (SM 6.5). The two source-to-source paths give you MSL you can read, debug in Xcode and compile with Apple’s toolchain like any other Metal shader.

The shader

A mesh shader pair with a push-constant block, a constant buffer, a texture and a sampler:

Mesh.hlslHLSL
struct DrawData { float4x4 Model; float4 Color; };
[[vk::push_constant]] DrawData Draw;
 
cbuffer Camera : register(b0) { float4x4 ViewProjection; };
[[vk::binding(1)]] Texture2D<float4> Albedo;
[[vk::binding(2)]] SamplerState LinearWrap;
 
struct VSOut { float4 Position : SV_Position; float2 Uv : TEXCOORD0; };
 
[shader("vertex")]
VSOut VSMain(float3 position : POSITION, float2 uv : TEXCOORD0)
{
    VSOut o;
    o.Position = mul(ViewProjection, mul(Draw.Model, float4(position, 1)));
    o.Uv = uv;
    return o;
}
 
[shader("pixel")]
float4 PSMain(VSOut i) : SV_Target { return Albedo.Sample(LinearWrap, i.Uv) * Draw.Color; }

Path 1: DXC and SPIRV-Cross

TerminalDXC 1.9.0.5512 · SPIRV-Cross (Vulkan SDK 1.4.363.0)
dxc -spirv -T vs_6_0 -E VSMain Mesh.hlsl -Fo mesh.vs.spv
dxc -spirv -T ps_6_0 -E PSMain Mesh.hlsl -Fo mesh.fs.spv
spirv-cross mesh.vs.spv --msl --msl-version 20400 --output cross.vs.metal
spirv-cross mesh.fs.spv --msl --msl-version 20400 --output cross.fs.metal
grep -h -E "^(vertex|fragment)" cross.vs.metal cross.fs.metal
Output
vertex VSMain_out VSMain(VSMain_in in [[stage_in]], constant type_PushConstant_DrawData& Draw [[buffer(0)]], constant type_Camera& Camera [[buffer(1)]])
fragment PSMain_out PSMain(PSMain_in in [[stage_in]], constant type_PushConstant_DrawData& Draw [[buffer(0)]], texture2d<float> Albedo [[texture(0)]], sampler LinearWrap [[sampler(0)]])

For this shader SPIRV-Cross put the push constants and Camera at buffers 0 and 1, and the texture and sampler at 0 each: buffers, textures and samplers are numbered separately. It writes clean, readable MSL. Its README lists converting SPIR-V to “readable, usable and efficient Metal Shading Language” and a reflection API among its features. What you build around it: two native tools in the pipeline, one run per entry point and stage, and a mapping from the Vulkan bindings your code uses to the Metal indices SPIRV-Cross chose.

Path 2: OneShader, HLSL to MSL directly

TerminalOneShader 1.2.0
oneshader --target msl Mesh.hlsl -Fo mesh.metal
grep -h -A5 -E "^(vertex|fragment)" mesh.VSMain.metal mesh.PSMain.metal
Output
vertex VSMain_out VSMain(
    VSMain_in stage_in [[stage_in]],
    constant DrawData_mem& Draw [[buffer(0)]],
    constant Camera_1_mem& Camera [[buffer(1)]])
{
    float3 position = stage_in.position;
--
fragment PSMain_out PSMain(
    PSMain_in stage_in [[stage_in]],
    float4 frag_coord [[position]],
    constant DrawData_mem& Draw [[buffer(0)]],
    texture2d<float, access::sample> Albedo [[texture(0)]],
    sampler LinearWrap [[sampler(0)]])

Without -T, oneshader compiles every entry point in the file, one .metal file each. The indices match SPIRV-Cross’s for this shader: push constants and Camera at buffers 0 and 1, the texture and sampler at 0. The _mem structs are written with packed vectors and explicit padding so that every member sits at the same byte offset as in the SPIR-V and WGSL output. One C# struct, or one buffer, then serves all three APIs. Compile the file with -DONESHADER_CHECK_LAYOUT and it static_asserts each size and offset.

The indices don’t have to be read from the source. From C#, the same compile gives the Metal argument table and the MTLVertexDescriptor data:

Program.csRecursiveAI.OneShader 1.2.0
using OneShader;
using OneShader.Layouts;
using OneShader.Layouts.Metal;
 
CompileResult result = ShaderCompiler.CompileFile("Shaders/Mesh.hlsl", new CompileOptions { Targets = [CompileTarget.Msl] });
foreach (var d in result.Diagnostics) Console.WriteLine(d);
if (!result.Success) return 1;
 
File.WriteAllText("mesh.vs.metal", result.Find("VSMain", CompileTarget.Msl)!.AsText());
File.WriteAllText("mesh.fs.metal", result.Find("PSMain", CompileTarget.Msl)!.AsText());
 
EntryLayout.TryCreate(result.Reflection!, "VSMain", out EntryLayout? vs, out _);
EntryLayout.TryCreate(result.Reflection!, "PSMain", out EntryLayout? fs, out _);
if (!PipelineLayout.Create(vs!, fs!).TryToMetal(out MetalPipelineLayout? metal, out LayoutError? error))
{
    Console.WriteLine(error);
    return 1;
}
 
// The index to pass to setVertexBuffer, setFragmentTexture, setFragmentSamplerState, setVertexBytes ...
foreach (MetalStageLayout stage in metal!.Stages)
{
    foreach (MetalArgument arg in stage.Arguments)
        Console.WriteLine($"{stage.Stage}: {arg.Name} -> {arg.Kind} index {arg.Index}");
    if (stage.PushConstant is MetalPushConstant push)
        Console.WriteLine($"{stage.Stage}: {push.Name} (push constants) -> buffer index {push.BufferIndex}, {push.Size} bytes");
}
 
// MTLVertexDescriptor data for the interleaved vertex buffer.
MetalVertexDescriptor vd = vs!.Vertex!.ToMetal();
Console.WriteLine($"vertex buffer index {vd.BufferIndex}, stride {vd.Stride}");
foreach (MetalVertexAttribute a in vd.Attributes)
    Console.WriteLine($"  attributes[{a.AttributeIndex}]: {a.Format}, offset {a.Offset}");
return 0;
Output
Vertex: Camera -> Buffer index 1
Vertex: Draw (push constants) -> buffer index 0, 80 bytes
Pixel: Albedo -> Texture index 0
Pixel: LinearWrap -> Sampler index 0
Pixel: Draw (push constants) -> buffer index 0, 80 bytes
vertex buffer index 30, stride 20
  attributes[0]: Float3, offset 0
  attributes[1]: Float2, offset 12

The push-constant block is a constant DrawData_mem& argument at buffer 0, filled with setVertexBytes and setFragmentBytes. Vertex data goes through buffer index 30, out of the way of the resources. The format names are MTLVertexFormat cases. With RecursiveAI.OneShader.Build and <OneShaderTargets>Msl</OneShaderTargets>, the same data is generated as constants at build time (compile HLSL to Metal recipe).

Differences that matter on Metal

  • Clip-space Y. It points up in Metal and down in Vulkan. Neither OneShader nor SPIRV-Cross with default options changed this shader’s position for it, so flip with a viewport of negative height, as the OneShader repository’s Metal runner does.
  • Fast math is Metal’s default. precise survives both paths: OneShader turns off contraction and reassociation with Clang pragmas, and SPIRV-Cross routes decorated adds through an [[clang::optnone]] helper (precise and determinism). With OneShader, NaN and infinity handling isn’t guaranteed unless the host turns fast math off.
  • Specialization constants become function constants. A function that declares any has to be created with makeFunction(name:constantValues:), even for the defaults (specialization constants recipe).
  • What Metal can’t express. OneShader reports these as errors naming Metal, only when Metal is a selected target: double, most 64-bit atomics, SV_CullDistance, GetDimensions on structured buffers, atomics on textures, integer matrices and the wave lane index in vertex shaders. The full list is on the MSL backend page.
  • Stages. OneShader compiles vertex, pixel and compute shaders. Metal Shader Converter lists mesh and ray-tracing shader models among those it supports. For other stages, check what the converter you pick supports for Metal.

The OneShader repository compiles every corpus shader’s MSL with xcrun metal and runs it on a Metal GPU, with API and shader validation on, against DXC’s SPIR-V on Vulkan (how it’s checked).

Which to use

  • Metal Shader Converter if you ship Direct3D 12 and want the DXIL you already build on Apple devices, with the argument-buffer model and the OS requirements above.
  • DXC and SPIRV-Cross if you already have a SPIR-V pipeline in C++, or want SPIRV-Cross’s other outputs (GLSL, HLSL) from the same SPIR-V.
  • OneShader if your engine or tools are in .NET, your shaders are vertex, pixel and compute, and you want Metal, Vulkan and WebGPU from one HLSL source with one buffer layout and the binding tables as C# data.

Common questions

Does Metal support HLSL?

Not directly. Metal compiles MSL, and loads .metallib libraries. HLSL reaches it through one of the converters above.

Can I convert SPIR-V to MSL?

Yes, with SPIRV-Cross: spirv-cross shader.spv --msl --output shader.metal. naga also writes MSL from SPIR-V. OneShader starts from HLSL source, not SPIR-V.

To try it, run dotnet tool install -g RecursiveAI.OneShader.Tool, then oneshader --target msl shader.hlsl -Fo shader.metal, or start with getting started.