Create a Swift Package plugin to compile CIFilter metal file (which requires the -fcimetal flag).

This plugin will compile all .cimetal file (special extension to avoid Xcode to rebuild/link theses files in default.metallib) in a specific shaders.metallib.

NOTE FOR ME: Create a swift Package.

NOTE 2: In some circumstances, in the Release configuration, the plugin doesn’t work. In this case, the tool ‘KernelsCompileAndLink’ should be used in binary form (See SwiftLint).

NOTE 3: Xcode 15/swift-tool-version >= 5.9, Add the option -fmodules-cache-path to specify a writable path (here self.outputPath should be sufficient)

Step 0 - The Structure

In the Target Swift Package folder, create theses folders.

	
	MySuperPackage
	|
	+-- Plugins
	   |
	   +-- KernerlsCompilePlugin
	...
	+-- Sources
	   |
	   +-- KernelsCompileAndLink

Step 1 - Create the Tool

KernelsCompileAndLink will call the metal compile for each file passed in parameters then link all the resultint file in a metalllib file. This tool uses ArgumentParserPackage (see Step 3).

In the folder Sources/KernelsCompileAndLink create a file main.swift and copy this.


//
//  KernelsCompileAndLink.swift
//
//  Created by Franck Brun on 15/10/2023.
//
//  Permission is hereby granted, free of charge, to any person obtaining
//  a copy of this software and associated documentation files (the
//  "Software"), to deal in the Software without restriction, including
//  without limitation the rights to use, copy, modify, merge, publish,
//  distribute, sublicense, and/or sell copies of the Software, and to
//  permit persons to whom the Software is furnished to do so, subject to
//  the following conditions:
//
//  The above copyright notice and this permission notice shall be
//  included in all copies or substantial portions of the Software.
//
//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
//  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
//  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
//  NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
//  LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
//  OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
//  WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

import Foundation
import ArgumentParser
import System

enum CmdError: Error {
  case missingInput
  case compileError
}

struct KernelsCompileAndLink: ParsableCommand {
  static var configuration = CommandConfiguration(
    abstract: "Compile and link CIFilter kernels",
    version: "0.1",
    subcommands: [])

  @Option(name:.customShort("p"), help: "Output path")
  var outputPath = "."

  @Option(name:.customShort("i"), help: "Input files")
  var inputFileNames = [String]()
  
  @Option(name:.customShort("o"), help: "Output file name (default ./default.metallib)")
  var outputFileName: String = "./default.metallib"
  
  @Flag(name:[.short, .long], help: "Verbose")
  var verbose = false
  
  func validate() throws {
    if self.inputFileNames.isEmpty {
      throw CmdError.missingInput
    }
  }
  
  func run() throws {
    
    var outputFileNames = [String]()
    for inputFileName in inputFileNames {
      let fileName = URL(fileURLWithPath: inputFileName).deletingPathExtension().appendingPathExtension("air").lastPathComponent
      let outputUrl = URL(fileURLWithPath: self.outputPath).appendingPathComponent(fileName)
      try compile(inputFileName: inputFileName, outputFileName: outputUrl.path)
      outputFileNames.append(outputUrl.path)
    }
   
    try link(inputFileNames: outputFileNames, outputFileName: self.outputFileName)    
  }
  
  func compile(inputFileName: String, outputFileName: String) throws {
    let arguments = [
      "-sdk",
      "macosx",
      "metal",
      "-fmodules-cache-path=\(self.outputPath)"
      "-fcikernel",
      "-c",
      "-o",
      outputFileName,
      "-x",
      "metal",
      inputFileName
    ]
    try xcrunTask(arguments: arguments)
  }
  
  func link(inputFileNames: [String], outputFileName: String) throws {
    var arguments = [
      "-sdk",
      "macosx",
      "metal",
      "-o",
      outputFileName,
      "-fcikernel"
    ]
    inputFileNames.forEach { arguments.append($0) }
    try xcrunTask(arguments: arguments)
  }
  
  func xcrunTask(arguments: [String]) throws {
    if self.verbose {
      print("/usr/bin/xcrun \(arguments.joined(separator: " "))")
    }
    
    let process = Process()
    process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
    process.arguments = arguments
    
    try process.run()
    process.waitUntilExit()
    
    if process.terminationStatus != EXIT_SUCCESS {
      throw CmdError.compileError
    }
  }
  
}

KernelsCompileAndLink.main()

Step 2 - Create the Plugin

Now the plugin which add a build step.

In the folder Plugins/KernelsCompilePlugin create a file KernelsCompilePlugin and copy this.


//
//  KernelsCompilePlugin.swift
//
//  Created by Franck Brun on 15/10/2023.
//
//  Permission is hereby granted, free of charge, to any person obtaining
//  a copy of this software and associated documentation files (the
//  "Software"), to deal in the Software without restriction, including
//  without limitation the rights to use, copy, modify, merge, publish,
//  distribute, sublicense, and/or sell copies of the Software, and to
//  permit persons to whom the Software is furnished to do so, subject to
//  the following conditions:
//
//  The above copyright notice and this permission notice shall be
//  included in all copies or substantial portions of the Software.
//
//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
//  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
//  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
//  NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
//  LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
//  OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
//  WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

import Foundation
import PackagePlugin

@main
struct KernelsCompilePlugin: BuildToolPlugin {
  func createBuildCommands(context: PackagePlugin.PluginContext, target: PackagePlugin.Target) async throws -> [PackagePlugin.Command] {
    guard let target = target as? SourceModuleTarget
    else { return [] }
    
    let cimetalFiles = target.sourceFiles(withSuffix: "cimetal").map(\.path)
    
    guard !cimetalFiles.isEmpty
    else { return [] }
    
    let shaderslib = context.pluginWorkDirectory.appending(subpath: "shaders.metallib")
    
    var arguments = [
      "-p",
      context.pluginWorkDirectory.string,
      "-o",
      shaderslib.string,
    ]

    cimetalFiles.forEach {
      arguments.append("-i")
      arguments.append($0.string)
    }
    
    let buildCommand =  PackagePlugin.Command.buildCommand(displayName: "Compile and link cifilters kernels...",
                                                           executable: try context.tool(named: "KernelsCompileAndLink").path,
                                                           arguments: arguments,
                                                           environment: [:],
                                                           inputFiles: cimetalFiles,
                                                           outputFiles: [shaderslib])
    
    return [buildCommand]
  }
}

Step 3 - Update the Package

The Package file can be modified as this (or you can create a package).


// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
  name: "MyPackage",
  platforms: [
  	...
  ],
  products: [
  	...
  ],
  dependencies: [
  	...,
    .package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.0"),
  ],
  targets: [
    .executableTarget(
      name: "KernelsCompileAndLink",
      dependencies: [
        .product(name: "ArgumentParser", package: "swift-argument-parser"),
      ]
    ),
    .plugin(
      name: "KernelsCompilePlugin",
      capability: .buildTool(),
      dependencies: ["KernelsCompileAndLink"]
    ),
    .target(
      name: "MyTarget",
      dependencies: [...],
      swiftSettings: [],
      linkerSettings: [...],
      plugins: [
        .plugin(name: "KernelsCompilePlugin")
      ]),
  ]
)

Step 4 - Build

After compilation, you should have a shaders.metallib file in the corresponding bundle of the package.

Here’s a sample Swift CIFilter that loads a function from the generated library


	public class SimpleWaves : CIFilter {
  
  static var kernel: CIKernel? = {
    let bundle = Bundle.module
    guard
      let url = bundle.url(forResource: "shaders", withExtension: "metallib"),
      let data = try? Data(contentsOf: url)
    else {
      fatalError("Unable to get metallib")
    }
    
    guard let kernel = try? CIKernel(functionName: "simple_waves", fromMetalLibraryData: data) else {
      fatalError("Unable to create kernel")
    }
    
    return kernel
  }()
  
  public var extend = CGRect(origin: .zero, size: CGSize(width: 200, height: 200))
  
  @objc dynamic var time: TimeInterval = 0;
    
  public override var outputImage: CIImage? {
    
    return SimpleWaves.kernel?.apply(extent: self.extend,
                                     roiCallback: { _, rect in rect },
                                     arguments: [
                                      CIVector(cgRect: self.extend),
                                      Float(self.time)
                                     ])
  }
  
}