Compile CIFilter in Swift Package
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)
])
}
}