Developing with Vulkan on Apple iOS - Khronos Blog

Developing with Vulkan on Apple iOS - Khronos Blog

Developing with Vulkan on Apple iOS


Vulkan® Portability™ is a Khronos® initiative to promote the consistent use of Vulkan functionality that is layered over other underlying APIs to enable the portable deployment of Vulkan applications on platforms without Vulkan native drivers, such as Apple’s macOS and iOS. In March 2024, Richard Wright from LunarG updated the State of Vulkan on Apple Devices white paper to reflect the latest availability of the Vulkan SDK on Apple platforms, and its ability to be used to develop applications that are fully compatible with the Apple App Store.

In this tutorial, Khronos member Steve Winston of Holochip uses the latest Khronos Vulkan samples to illustrate how to work with Vulkan on iOS.

MoltenVK on iOS

Working with Vulkan on iOS means working with MoltenVK™. MoltenVK is a Khronos-hosted open source project that layers Vulkan functionality over Apple’s Metal API, including translation of Vulkan SPIR-V shaders into the Metal Shading Language. MoltenVK provides close to Vukan 1.2 functionality, with ongoing work to build out to Vulkan 1.3, while maintaining performance characteristics that are competitive with native Metal.

Apple Bundles and the Vulkan SDK

Application packaging for Apple platforms requires that everything to run a program is included in a bundle and that, as far as possible, no system level changes are needed to install/uninstall an application, enabling an application to be effectively uninstalled by deleting its folder. This is in contrast to other platforms which often depend upon installing resources and drivers in known system locations.

This constraint is relaxed somewhat on MacOS to enable easier portability with Unix and FreeBSD-style applications. However, security concerns for iOS mandates that the application bundle contains everything with no installation to system locations. This means that Vulkan SDK elements such as the Vulkan loader and validation layers need to be packaged and installed as part of the application bundle itself, rather than installing the SDK separately.

The sections below outline how developers can ensure that any dependencies are correctly packaged with their application to ensure correct operation of the Vulkan loader and layers on iOS.

Packaging and Use of MoltenVK

This is how a developer would create an application bundle to use MoltenVK and Vulkan validation layers:

VulkanApplication
	/Assets ← folder containing UI assets such as fonts, sound files, and textures
	/Assets/info.plist ← catalogs the contents of the Assets folder
	/VulkanApplication ← the executable file
	/*.mobileprovision ← the provisioning profile which is necessary to run on devices.
	/Frameworks ← folder that holds all frameworks used by the application
	/Frameworks/MoltenVK.framework
	/Frameworks/vulkan.framework
	/Frameworks/VkLayer_khronos_validation.framework
	Info.plist ← required and standard for all Apple applications.
	*.spv ← compiled SPIRV shaders required by the Vulkan Application
	/vulkan/explicit_layer.d ← contains the JSON that tells the loader how to load some explicit layers
	/vulkan/explicit_layer.d/VkLayer_khronos_validation.json ← contains path to validation.framework
	/vulkan/icd.d ← contains the vulkan drivers or in this case, MoltenVK_icd.json
	/vulkan/icd.d/MoltenVK_icd.json ← tells the loader where to find the libMoltenVK.framework

The assets directory is used for assets that the UI storyboard needs direct access to. This folder is used to create the info.plist assets catalog. Because it’s used in a very specific manner, it’s best to not put compiled shaders into the Assets directory so that builds and installs are faster and easier.

There are several json files inside the /vulkan folder, each pointing to dynamic libraries. File paths within those json files are relative to the location of the json file itself. So, if the loader encounters vulkan/icd.d/MoltenVK_icd.json, then it must go up two directories to the top level of VulkanApplication, then into Frameworks and then identify the library to satisfy the icd library. In other words, in the example here: “../../Frameworks/MoltenVK.framework.”

This means that the json file /vulkan/icd.d/MoltenVK.json might look like this:

{
  "file_format_version": "1.0.0",
  "ICD": {
    "library_path": "../../Frameworks/MoltenVK.framework/MoltenVK",
    "api_version": "1.2.0",
    "is_portability_driver": true
  }
}

Similarly, the json should indicate where to find the layers dynamic library, and so the first 7 lines of /vulkan/explicit_layer.d/VkLayer_khronos_validation.json might look like this:

{
  "file_format_version": "1.2.0",
  "layer": {
    "name": "VK_LAYER_KHRONOS_validation",
    "type": "GLOBAL",
    "library_path": "../../Frameworks/VkLayer_khronos_validation.framework/VkLayer_khronos_validation",
    "api_version": "1.3.280",

In both cases, library_path is identifying where the dynamic library exists within the packaged bundle.

Testing on Devices

Traditionally, applications using MoltenVK on iOS statically link against the MoltenVK library. The newly released Vulkan SDK 1.3.280.0 provides access to each framework as needed so applications can dynamically use the Vulkan loader, which provides the ability to use layers, such as Vulkan validation layers, in contrast to static linking which provides a normal runtime. Both static and dynamic linking will work with TestFlight and the Apple App Store.

These different build types are easily achieved with the build tools. Let’s look at how to use modern CMake to build Vulkan for iOS.

CMake Build Configuration

Below are some recommended CMake configurations with explanations about what each of them do.

We first want CMake to generate the default scheme so XCode doesn’t need to regenerate it each time the configuration stage of CMake is run. This also allows the –build argument to work, which allows for CI command line building.

set(CMAKE_XCODE_GENERATE_SCHEME TRUE)

Note that generating the default scheme is not destructive to any changes made by hand to the scheme once a project is created. Those settings can be found here:

https://cmake.org/cmake/help/latest/prop_tgt/XCODE_GENERATE_SCHEME.html

Additionally, the build and runtime requirements in the MoltenVK Runtime User Guide recommends disabling both Metal API validation and GPU Frame capture within the scheme. So let’s do that in our CMake settings:

set(CMAKE_XCODE_SCHEME_ENABLE_GPU_API_VALIDATION FALSE)
set(CMAKE_XCODE_SCHEME_ENABLE_GPU_FRAME_CAPTURE_MODE DISABLED)

Next, CMake is normally configured to look at system paths when it is resolving find_package and find_library calls. However, this won’t work in this case as the libraries at the system paths of the development machine are NOT the libraries that are compiled and linked for the architecture of the mobile device, so we either need to add instructions on each find_library / find_package call to ignore the system library paths or globally adjust the method the find_* calls use to resolve library locations:

set (CMAKE_FIND_ROOT_PATH_MODE_LIBRARY BOTH)
set (CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH)

Now, we need to set up XCode to understand that we’re targeting iOS by changing the XCode attribute target device family to 1,2 so we cover iPhone and iPad devices. Additionally, we can specify which frameworks from the Apple iPhone SDK to find and use with find_library. Below are the minimum libraries required if not using Link Frameworks Automatically and Enable Modules for C and Objective C; we recommend enabling automatic framework linking settings for convenience, but we are showing the manual links here to demonstrate how framework linking works.

if(IOS)
 set(CMAKE_XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2")
 find_library(UIKIT_LIB UiKit)
 find_library(COREMEDIA_LIB CoreMedia)
 find_library(METAL_LIB Metal)
endif()

We next need to tell CMake how to find a FindVulkan.cmake file which signposts to everything in the Vulkan SDK. To do that, we need to bring it in from Vulkan Samples which can be found here: VulkanSamples/bldsys/cmake/module/FindVulkan.cmake:

list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

Now we need to find the MoltenVK package. This can be done:

find_package(Vulkan REQUIRED COMPONENTS MoltenVK)

CMake Build Application Targets

When we define an application target for iOS we include the iOS/share/vulkan directory from the SDK, which contains the json files that we want in our application bundle. Including it in source properties results in the Vulkan directory getting copied to the correct position in the bundle:

add_executable(VulkanApplication MACOSX_BUNDLE
    main.m
    AppDelegate.m
    ViewController.m
    basic.storyboard
    launch.storyboard
    Media.xcassets
    Assets
    $ENV{VULKAN_SDK}/../iOS/share/vulkan
)

Now, we need to tell CMake that some of those source files are actually resources so they just get copied:

set_source_files_properties(basic.storyboard launch.storyboard Media.xcassets Assets
    $ENV{VULKAN_SDK}/../iOS/share/vulkan
    PROPERTIES
    MACOSX_PACKAGE_LOCATION Resources
)

Next, we need to specify that our project bundle's info.plist should use the template info.plist for project configuration:

set_target_properties(VulkanApplication PROPERTIES
    MACOSX_BUNDLE_INFO_PLIST ${PROJECT_SOURCE_DIR}/Info.plist.in
)

Note that Info.plist files are configured so that most of the MACOSX_BUNDLE_ attributes will have no effect if ${PROJECT_SOURCE_DIR}/Info.plist does not contain references to them. It is possible to not specify a template plist and utilize the plist generation default template.

Now, let’s link in the libraries and frameworks that we need. Because we’re demonstrating explicitly defining the frameworks used above, here’s how to link with them:

target_link_libraries(VulkanApplication PUBLIC ${UIKIT_LIB} ${COREMEDIA_LIB} ${METAL_LIB})

With the 1.3.280 release of Vulkan, we can link directly against the Vulkan loader as we do on other platforms and the App Store will accept our new framework setup:

target_link_libraries(VulkanApplication PUBLIC Vulkan::Vulkan)

Next, we need to set all the other XCode attribute settings that define the application:

set(CMAKE_MACOSX_BUNDLE YES)
set(MACOSX_BUNDLE_GUI_IDENTIFIER com.khronos.vulkanapplication)
set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "NO") ← this isn’t required unless no custom libraries are in the application.
set_target_properties(VulkanApplication PROPERTIES
    BUNDLE_IDENTIFIER com.khronos.vulkanapplication
    XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER com.khronos.vulkanapplication
    XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "YES"
    XCODE_ATTRIBUTE_CLANG_ENABLE_MODULES "YES"
    XCODE_ATTRIBUTE_LD_RUNPATH_SEARCH_PATHS "@executable_path/Frameworks"
    XCODE_ATTRIBUTE_CODE_SIGN_STYLE "Automatic" # already default value
    XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "iPhone Developer"
    MACOSX_BUNDLE_SHORT_VERSION_STRING 1.0.0
    MACOSX_BUNDLE_BUNDLE_VERSION 1.0.0
    XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "XXX" ← replace this with your TEAM id.
    XCODE_EMBED_FRAMEWORKS "${Vulkan_MoltenVK_LIBRARY};${Vulkan_LIBRARIES};"
    XCODE_EMBED_FRAMEWORKS_CODE_SIGN_ON_COPY		"YES"
    XCODE_EMBED_FRAMEWORKS_REMOVE_HEADERS_ON_COPY	"YES"
    XCODE_ATTRIBUTE_SKIP_INSTALL NO
    XCODE_ATTRIBUTE_INSTALL_PATH "$(LOCAL_APPS_DIR)"
    XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon"
    XCODE_ATTRIBUTE_DEAD_CODE_STRIPPING NO
 )

Note that  using the the above settings, if we wish to run a layer, we need to add it to the list of XCODE_EMBED_FRAMEWORKS so for validation it would look like:

"${Vulkan_MoltenVK_LIBRARY};${Vulkan_LIBRARIES};${Vulkan_Layer_VALIDATION}"

Finally, the only thing left to do is ensure that the built shaders are installed to the app bundle. This can be done like so:

function(target_add_spirv_shader TARGET INPUT_FILE)
  find_program(GLSLC glslc)
  set(optionalArgs ${ARGN})
  set(OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/shader-spv")
  list(LENGTH optionalArgs numArgs)
  if(${numArgs} GREATER 1)
    message(FATAL_ERROR "target_add_spirv_shader called incorrectly, add_spirv_shader(target INPUT_FILE [OUTPUT_FILE])")
  endif()
  if(${numArgs} EQUAL 1)
    list(GET optionalArgs 0 OUTPUT_DIR)
  endif()
  file(MAKE_DIRECTORY ${OUTPUT_DIR})
  get_filename_component(bare_name ${INPUT_FILE} NAME_WE)
  get_filename_component(extension ${INPUT_FILE} LAST_EXT)
  string(REGEX REPLACE "[.]+" "" extension ${extension})
  set(OUTPUT_FILE "${OUTPUT_DIR}/${bare_name}-${extension}.spv")
  add_custom_command( PRE_BUILD
      OUTPUT ${OUTPUT_FILE}
      COMMAND ${GLSLC} --target-env=vulkan1.2 ${INPUT_FILE} -o ${OUTPUT_FILE}
      MAIN_DEPENDENCY ${INPUT_FILE}
      WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
      )
  add_custom_target(${bare_name}-${extension} DEPENDS ${OUTPUT_FILE})
  add_dependencies(${TARGET} ${bare_name}-${extension})
  target_sources(${TARGET} PUBLIC ${OUTPUT_FILE})
  set_source_files_properties(${OUTPUT_FILE} PROPERTIES
      MACOSX_PACKAGE_LOCATION Resources
  )
endfunction()

After defining a CMake function such as the above, it can be called like this:

target_add_spirv_shader(basic ${CMAKE_CURRENT_SOURCE_DIR}/Shaders/texture.vert)
target_add_spirv_shader(basic ${CMAKE_CURRENT_SOURCE_DIR}/Shaders/texture.frag)

This will produce texture-vert.spv and texture-frag.spv in the application bundle.

Vulkan Portability Extension

When using MoltenVK it is important to be able to determine what subset of Vulkan functionality works on the target device. This can be achieved using the Vulkan VK_KHR_portability_subset extension.

First, we need to enable the portability enumeration flag so the Vulkan loader will know to allow listing of drivers that are not completely conformant such as the MoltenVK driver:

VkInstanceCreateInfo instanceCreateInfo = {};
 instanceCreateInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
 instanceCreateInfo.pNext = nullptr;
 instanceCreateInfo.pApplicationInfo = &appInfo;

#if TARGET_OS_IOS
  instanceCreateInfo.flags |= VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR;
#endif
std::vector<const char*> instanceExtensions = {VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME };
  instanceCreateInfo.enabledExtensionCount = instanceExtensions.size();
  instanceCreateInfo.ppEnabledExtensionNames = instanceExtensions.data();

Note that TARGET_OS_IOS will get defined by including TargetConditionals.h.

Build the Application

Now, we can use CMake to create the XCode project from the command line like so:

cd VulkanApplicationSrc
mkdir build && cd build
Set environment variable VULKAN_SDK:
source ~/VulkanSDK/1.3.280.1/iOS/setup-env.sh
cmake -G Xcode -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_DEPLOYMENT_TARGET=14.0 -DCMAKE_IOS_INSTALL_COMBINED=NO ..

The build directory will now have an XCode project. We can open it and build it from XCode, or you can continue to use the command line like so:

cmake --target VulkanApplication --config Debug -- -sdk iphoneos -allowProvisioningUpdates

Run the Application

From here, developers can use XCode to both build and debug the application with xcodebuild install. Developers also have the choice of debugging an application using the LLDB debugger. However, it’s usually easier to use XCode as an IDE once the CMake configuration is complete. However, the CMakesetup can be used in CI/CD environments so the XCode IDE isn’t required beyond providing the XCode tools which are installed with xcode-select.

Conclusion

Vulkan is an open standard API that uniquely provides application portability across multiple platforms including Android, Windows, Linux, and MacOS. With the new Vulkan SDK, and the instructions above, it is now easier than ever to deploy Vulkan applications on iOS as well. We hope this has been informative and we encourage everyone to take a look at the Khronos Vulkan Samples which now can be run on iOS!