Recursive AI

Compile HLSL to Metal Shading Language

All oneshader recipes

Compile HLSL vertex, pixel and compute shaders to Metal Shading Language with OneShader, and get from C# the function names, the buffer, texture and sampler index of every resource, and the MTLVertexDescriptor data.

OneShader writes one MSL source file per entry point, for Metal Shading Language 2.4 or newer (macOS 12 and iOS 15), from the same front end as its SPIR-V and with the same buffer byte layout. It writes MSL directly from its own IR, without going through SPIR-V and SPIRV-Cross.

With dotnet build

The project references RecursiveAI.OneShader.Build 1.2.0 with OneShaderTargets set to Msl and contains the Shaders/Sprite.hlsl of Getting started. Program.cs:

C#
using OneShader.Layouts.Metal;
using Metal.Shaders;
 
// The MSL source and the function name to pass to newFunction(name:).
File.WriteAllBytes("sprite.vs.metal", Sprite.VSMain.Msl.ToArray());
File.WriteAllBytes("sprite.ps.metal", Sprite.PSMain.Msl.ToArray());
Console.WriteLine($"vertex function {Sprite.VSMain.MslFunctionName}, fragment function {Sprite.PSMain.MslFunctionName}");
 
// The argument index of each resource, per stage: setVertexBuffer, setFragmentTexture, ...
if (Sprite.Pipeline.TryToMetal(out MetalPipelineLayout? metal, out var error))
    foreach (MetalStageLayout stage in metal!.Stages)
        foreach (MetalArgument arg in stage.Arguments)
            Console.WriteLine($"{stage.Stage}: {arg.Name} at [[{arg.Kind.ToString().ToLowerInvariant()}({arg.Index})]]");
else
    Console.WriteLine(error);
 
// MTLVertexDescriptor data for the interleaved vertex buffer.
MetalVertexDescriptor vd = Sprite.VSMain.Layout.Vertex!.ToMetal(Sprite.VSMain.MslVertexBufferIndex);
Console.WriteLine($"vertex buffer {vd.BufferIndex}, stride {vd.Stride}, {vd.StepFunction}");
foreach (MetalVertexAttribute a in vd.Attributes)
    Console.WriteLine($"  attributes[{a.AttributeIndex}]: {a.Format} ({(uint)a.Format}) offset {a.Offset}");
Text
vertex function VSMain, fragment function PSMain
Vertex: Frame at [[buffer(0)]]
Pixel: Atlas at [[texture(0)]]
Pixel: AtlasSampler at [[sampler(0)]]
vertex buffer 30, stride 20, PerVertex
  attributes[0]: Float2 (29) offset 0
  attributes[1]: Float2 (29) offset 8
  attributes[2]: UChar4Normalized (9) offset 16

The vertex function in sprite.vs.metal:

Text
vertex VSMain_out VSMain(
    VSMain_in stage_in [[stage_in]],
    constant Frame_1_mem& Frame [[buffer(0)]])
  • Argument indices are counted per class (buffers, textures, samplers) in declaration order, so Atlas is texture 0 although its Vulkan binding is 1. MetalStageLayout.TryFind(set, binding) goes from a Vulkan-style slot to the Metal index.
  • The vertex buffer is bound at index 30 by default, away from the resource buffers. The numbers after the formats are MTLVertexFormat values.
  • The Frame struct generated in C# has the bytes constant Frame_1_mem& expects, the same as on Vulkan. The MSL file carries static_asserts on its size and offsets, compiled when ONESHADER_CHECK_LAYOUT is defined.

From the command line

Terminal
oneshader -T ps_6_0 -E main --target msl Tint.hlsl -Fo tint.metal

MSL reserves main, so the fragment function is main0:

Text
fragment main0_out main0(

MslFunctionName in generated code, and MslBackend.EntryFunctionName at run time, give the name to pass to newFunction(name:).

Limits in 1.2.0

From the MSL backend page, these are compile errors on Metal: double; 64-bit integer vectors in buffers; 64-bit atomics other than unsigned InterlockedMax/InterlockedMin whose result isn’t used; SV_CullDistance; GetDimensions on structured buffers (Metal can’t query their length); atomics on textures; WaveMatch and WaveMultiPrefix*; the wave lane index and count in vertex shaders; integer and bool matrices; unbounded resource arrays; jumps out of several nested loops at once; comparison sampling on 1D and 3D textures; texel offsets on cube and 1D textures; overlapping packoffset members.

With 16-bit types enabled, half, short and ushort are Metal’s native types, and long and ulong are native 64-bit integers. See 16-bit and 64-bit types.

And differences to know:

  • Clip-space Y points up in Metal and down in Vulkan. The shader isn’t adjusted, so draw with a viewport of negative height, as the repository’s Metal runner does.
  • Fast math is Metal’s default, so NaN and infinity handling isn’t guaranteed unless the host turns it off. precise is honored either way (The precise keyword).
  • Function constants. Specialization constants become [[function_constant(N)]], and a function that declares any must be created with makeFunction(name:constantValues:), even to get the defaults. See Specialization constants.
  • Signed integer overflow is undefined in MSL, where HLSL wraps.
  • Checking. The samples here ran on Linux, where Apple’s compiler isn’t available, so their .metal files weren’t compiled with it. The repository compiles every corpus file with xcrun metal and runs the corpus on a Metal GPU against DXC’s SPIR-V on Vulkan.

SPIRV-Cross, Metal Shader Converter and OneShader compared: HLSL to Metal converter.