Source Code on GitHub

If you’re using Xcode for managing your iOS/tvOS/macOS project, compiling Metal source code into shader libraries and bundling them into your app is taken care of by the IDE (mostly) automatically. However, when doing cross-platform development and Apple platforms are merely one of many possible targets, build generators or systems like Bazel or CMake are much more attractive – especially since you can generate Xcode projects when you really need to anyway, for debugging (natively with CMake and using Tulsi with Bazel). Of course, Metal shaders only run on Apple platforms, but imagine developing a game where the rest of your Linux+Vulkan and Windows+DX builds use CMake/Bazel, and you’d like to keep the build structure’s source of truth in one place.

CMake

In CMake we can cheat and use the -G Xcode configure flag to select the Xcode generator. Then, we merely need to identify the Metal source files:

set_source_files_properties(
  "shader.metal" PROPERTIES
  LANGUAGE METAL
  COMPILE_OPTIONS "-I${PROJECT_SOURCE_DIR}"
)

Then, we can just pop it into an Apple executable, and it will be delivered into the usual Resources/default.metallib location in the app bundle:

add_executable(
  "my_app" MACOSX_BUNDLE
  "vertex_type.h"
  "main.c"
  "shader.metal"
)

Notice how include search directories for Metal sources need to be included via the -I flag. This is necessary since target_include_directories does not pass on its paths to the Metal compilation stage – a bit of a hack, but it works, and it was so easy!

Bazel

Of course, Bazel does not allow us to play such tricks – when you generate an Xcode project in Tulsi, it creates a project that delegates its build out completely to Bazel. Thus, we don’t have the opportunity to hook into any build orchestration behaviors provided by Xcode. Instead, the “Bazel way” is to create new rules, like the built-in rules for C++, cc_binary and cc_library.

Thus, I present metal_library and metal_binary, which are perfect analogues to the previously mentioned. Here’s a (contrived) quick example where a C/C++ app executes a .metallib at runtime and also shares a header defining the vertex types with that shader at compile-time.

cc_binary(
    name = "app",
    srcs = [
        "vertex_types.h",
        "main1.cc",
    ],
    data = [":shaders"],
)

metal_binary(
    name = "shaders",
    srcs = [
        "vertex_function.metal",
        "fragment_function.metal",
    ],
    deps = [":vertex_types"],
)

metal_library(
    name = "vertex_types",
    hdrs = ["vertex_types.h"],
)

In this example, the shaders target produces a shaders.metallib output, which is then bundled with the app executable’s runfiles.

If you need to use features present only in Metal or in C/C++ but not in Metal, use the following preprocessor check to branch at compile-time:

#ifdef __METAL_VERSION__
// Metal only code in here
#else
// C/C++/Obj-C/Obj-C++ only code in here
#elif

In both cases you can #include <simd/simd.h>, so I find this technique very useful for defining structs and constants – like vertex descriptors and uniform locations – in shared headers usable by both CPU/GPU sides of the application. You can find my implementation of rules_metal on GitHub. Enjoy! 😃