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
| Path | Input | Output | Runs on | Requirements |
|---|---|---|---|---|
| DXC, then SPIRV-Cross (Apache-2.0) | HLSL, through SPIR-V | MSL source | Anywhere both build: native C++ tools and libraries | The 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 later | Libraries 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) | HLSL | MSL source | .NET 10, any OS | MSL 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:
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
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
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
oneshader --target msl Mesh.hlsl -Fo mesh.metal grep -h -A5 -E "^(vertex|fragment)" mesh.VSMain.metal mesh.PSMain.metal
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:
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;
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.
precisesurvives 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,GetDimensionson 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.