Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- MetalCompilerPlugin: .metal files now compile automatically at build time —
checked-in metallib and manual tools/build_shaders.sh step are gone

- StyleReel + StyleReelVertical (9:16) — 12-aesthetic showcase scenes with
card-zoom transitions + tools/make_reel_audio.py

Expand Down
12 changes: 3 additions & 9 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,11 @@ open out/test.mp4

## Adding a shader

1. Add a `[[ stitchable ]]` function to `Sources/SwiftRender/Shaders/Cookbook.metal` (or create a new `.metal` file in `Shaders/`).
2. Rebuild the metallib:
```bash
cd Sources/SwiftRender/Shaders
xcrun -sdk macosx metal -c *.metal -o /tmp/all.air
xcrun -sdk macosx metallib /tmp/*.air -o ../Resources/default.metallib
```
1. Add a `[[ stitchable ]]` function to a `.metal` file in `Sources/SwiftRender/Shaders/`.
2. `swift build` — the MetalCompilerPlugin compiles all shaders into the
metallib automatically (requires full Xcode for the metal toolchain).
3. Use it in a scene via `ShaderLibrary.bundle(.module).yourShader(...)`.

> If you can automate step 2 via a SwiftPM build tool plugin, please open a PR — that's high on the wishlist.

## Style

- Keep scenes pure functions of `t`. No `@State`, no `Timer`, no `withAnimation(.repeatForever)`.
Expand Down
9 changes: 7 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,20 @@ let package = Package(
.target(
name: "SwiftRender",
dependencies: [],
exclude: ["Shaders"],
resources: [
.process("Resources"),
.process("Shaders"),
]
],
plugins: ["MetalCompilerPlugin"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

For // swift-tools-version: 5.10, is this valid in Package.swift`?
.target(name: "SwiftRender", plugins: ["MetalCompilerPlugin"])

Or must plugin usage be declared as:
.target(name: "SwiftRender", plugins: [.plugin(name: "MetalCompilerPlugin")])?`

💡 Result:

Yes, for // swift-tools-version: 5.10, the following is valid: .target(name: "SwiftRender", plugins: ["MetalCompilerPlugin"]) You must only use the explicit structured form when you need to specify the plugin’s package (i.e., the plugin is provided by another package). In that case the syntax is: .target(name: "SwiftRender", plugins: [.plugin(name: "MetalCompilerPlugin")]) If the plugin is from a dependency, you’d use the package-qualified form: .target(name: "SwiftRender", plugins: [.plugin(name: "MetalCompilerPlugin", package: "<package-name>")]) Evidence: Apple’s plugin product/target API documents the .plugin(name:capability:dependencies:path:exclude:sources:) and shows plugin usage in target declarations using .plugin(name: ...) (i.e., structured entries), and SwiftPM’s manifest/plugin usage is defined as plugins: [PluginUsage] where PluginUsage provides plugin(_ name: String, package: String? = nil). [1][2] Also, the SwiftPM evolution/proposal docs show the modern/typed syntax for target plugin usage as plugins: [ .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator") ], and it notes that when the plugin is within the same package you omit --package (so no package parameter is needed). [3]

Citations:


🌐 Web query:

SwiftPM 5.10 Package.swift target plugins: ["PluginName"] syntax valid?

💡 Result:

Yes, that specific syntax is valid (for SwiftPM 5.10 and the modern manifest API): in a target declaration you can use the plugins: parameter with an array of Target.PluginUsage, written like plugins: [.plugin(name: "PluginName")] (optionally with package: for plugins coming from another package) [1][2]. Example: let package = Package( name: "Example", targets: [.target( name: "MyTarget", plugins: [.plugin(name: "PluginName") ]) ]) Important constraints / common gotchas: 1) The plugins: parameter applies only to targets like .target(...), .executableTarget(...), and .testTarget(...) that use the manifest API supporting plugin usage [2]. 2) The plugin must be declared as a plugin target in the same Package.swift (or otherwise present in the dependency closure) and must have a compatible capability (e.g., if it’s used via plugins:, SwiftPM expects the plugin to support the buildTool capability, since plugins are “applied to a target” before the build system creates its dependency graph) [2]. 3) If you declare a plugin capability that doesn’t match what SwiftPM expects, you can get build errors complaining about mismatched capabilities (for instance, a declared command plugin being treated inconsistently) [3].

Citations:


🌐 Web query:

SwiftPM 5.10 plugins: ["PluginName"] in Package.swift target declaration valid or must use .plugin(name:)

💡 Result:

In SwiftPM 5.10, a target’s plugin usage must be declared with.plugin(name:) (optionally with package:), inside the target’s plugins: array. You cannot just list plugin names like ["PluginName"] in Package.swift’s target declaration. Authoritative syntax: - Define a plugin target in targets with.plugin(name:..., capability:...). [1][2] - Attach a plugin to a regular target using the plugins: parameter on.target /.executableTarget /.testTarget, with plugins: [.plugin(name: "OpenAPIGenerator", package: "...") ]. [3][4] So if your target looks like: - Wrong (not supported):.target(name: "X", plugins: ["PluginName"]) - Right (supported):.target(name: "X", plugins: [.plugin(name: "PluginName")]) (add package: only if the plugin comes from another package). [3][4]

Citations:


Fix SwiftPM target plugin declaration syntax in Package.swift (line 19).

plugins: ["MetalCompilerPlugin"] isn’t valid; target plugin usage must be declared as plugins: [.plugin(name: "MetalCompilerPlugin")] (add package: only if the plugin comes from another package).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Package.swift` at line 19, The Package.swift target's plugin declaration is
using invalid syntax; update the target's plugins entry (the one that currently
reads plugins: ["MetalCompilerPlugin"]) to use the SwiftPM plugin API by
replacing the string array with a plugin specification such as plugins:
[.plugin(name: "MetalCompilerPlugin")] (if the plugin is in another package use
.plugin(name: "MetalCompilerPlugin", package: "PackageName") instead) so the
target's plugin is declared with .plugin(name: ...) rather than a plain string.

),
.executableTarget(
name: "SwiftRenderCLI",
dependencies: ["SwiftRender"]
),
.plugin(
name: "MetalCompilerPlugin",
capability: .buildTool()
),
.testTarget(
name: "SwiftRenderTests",
dependencies: ["SwiftRender"]
Expand Down
31 changes: 31 additions & 0 deletions Plugins/MetalCompilerPlugin/plugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation
import PackagePlugin

/// Compiles Shaders/*.metal into default.metallib at build time, replacing
/// the checked-in metallib + tools/build_shaders.sh manual step.
@main
struct MetalCompilerPlugin: BuildToolPlugin {
func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
guard let target = target as? SourceModuleTarget else { return [] }
let shaderDir = target.directory.appending("Shaders")
let fm = FileManager.default
guard let entries = try? fm.contentsOfDirectory(atPath: shaderDir.string) else { return [] }
let metalFiles = entries.filter { $0.hasSuffix(".metal") }.sorted()
.map { shaderDir.appending($0) }
guard !metalFiles.isEmpty else { return [] }
Comment on lines +12 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast when shader discovery fails (don’t silently no-op).

Line 12 and Line 15 return [] on read failure / empty shader set, which can let build pass without producing default.metallib and defer the breakage to runtime.

Suggested fix
+enum MetalCompilerPluginError: Error {
+    case shaderDirectoryUnreadable(String)
+    case noMetalShadersFound(String)
+}
+
 `@main`
 struct MetalCompilerPlugin: BuildToolPlugin {
     func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
         guard let target = target as? SourceModuleTarget else { return [] }
         let shaderDir = target.directory.appending("Shaders")
         let fm = FileManager.default
-        guard let entries = try? fm.contentsOfDirectory(atPath: shaderDir.string) else { return [] }
+        let entries: [String]
+        do {
+            entries = try fm.contentsOfDirectory(atPath: shaderDir.string)
+        } catch {
+            throw MetalCompilerPluginError.shaderDirectoryUnreadable(shaderDir.string)
+        }
         let metalFiles = entries.filter { $0.hasSuffix(".metal") }.sorted()
             .map { shaderDir.appending($0) }
-        guard !metalFiles.isEmpty else { return [] }
+        guard !metalFiles.isEmpty else {
+            throw MetalCompilerPluginError.noMetalShadersFound(shaderDir.string)
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Plugins/MetalCompilerPlugin/plugin.swift` around lines 12 - 15, The plugin
currently swallows failures and empties by returning [] when directory reading
fails or no .metal files are found; change this to fail fast by propagating an
error (or calling fatalError) with a clear message including shaderDir and the
underlying error when contentsOfDirectory(atPath:) fails, and when
metalFiles.isEmpty throw/raise an error stating "no .metal files found in
\(shaderDir)" so the build fails instead of producing no default.metallib;
update the code paths around entries, metalFiles and shaderDir to return/throw
an appropriate Error type (or fatalError) rather than returning an empty array.


let out = context.pluginWorkDirectory.appending("default.metallib")
// the plugin sandbox only permits writes inside pluginWorkDirectory —
// metal's clang module cache must live there too or cold builds fail
let moduleCache = context.pluginWorkDirectory.appending("ModuleCache")
return [.buildCommand(
displayName: "Compiling \(metalFiles.count) Metal shaders → default.metallib",
executable: Path("/usr/bin/xcrun"),
arguments: ["-sdk", "macosx", "metal",
"-fmodules-cache-path=\(moduleCache.string)"]
+ metalFiles.map(\.string) + ["-o", out.string],
inputFiles: metalFiles,
outputFiles: [out]
)]
}
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ Pipe in JSON per record and render a thousand personalized variants — the AI/d

## Real Metal shaders

Drop a `.metal` file in `Sources/SwiftRender/Shaders/`, run `tools/build_shaders.sh`, call it on any view:
Drop a `.metal` file in `Sources/SwiftRender/Shaders/` and `swift build` — shaders compile automatically (SwiftPM build plugin). Call them on any view:

```swift
Rectangle().fill(.black).colorEffect(
Expand Down
Binary file removed Sources/SwiftRender/Resources/default.metallib
Binary file not shown.
Loading