Table of Contents
The problem
Since iOS 17 and macOS 14, SwiftUI can run Metal shaders on any view through three modifiers:
colorEffect, distortionEffect and layerEffect. The API is small, but using it raw means
writing a .metal file, loading it from the right bundle, passing arguments in exactly the
order the Metal function declares them, and knowing what the GPU does with a NaN, a zero divisor
or a value too large for a 32-bit float. A shader has no error handling: a bad value draws
garbage or nothing, and a call whose arguments do not match the function draws nothing at all.
SwiftShaders packages that work. An effect is one modifier on any view, such as
.halftone(dotSize: 6), .wave(amplitude: 5) or .transition(.dissolve). Version 2.0.0 ships
26 view modifiers, 3 fills and 5 transitions, backed by 31 [[ stitchable ]] Metal functions,
with no dependencies.
Design decisions and alternatives
SwiftUI's shader modifiers instead of an MTKView. Wrapping an MTKView in a
UIViewRepresentable would allow compute passes and multi-pass rendering. It would also take the
effect out of SwiftUI's view tree, so layout, animation, hit testing, dark mode and
ImageRenderer would all need to be rebuilt around it. Staying on colorEffect,
distortionEffect and layerEffect keeps an effect a normal view modifier. The cost is
SwiftUI's limits: one pass per shader and iOS 17 as the minimum.
One modifier underneath every effect. Each public effect is a few lines that check the
arguments and hand a shader to an internal ShaderEffect modifier. That modifier measures the
view with visualEffect, runs a TimelineView for animated effects, stops the animation when
Reduce Motion is on, and picks one of three kinds: color, distortion or layer. The alternative,
one ViewModifier per effect, is what the v1.0.0 tag did; with 31 shaders it would mean 31 copies of the
same timing and measuring logic.
Layer effects on a compositing group. Without compositingGroup(), SwiftUI applies a layer
effect to each part of a view on its own, for example to a background and to the text in front
of it. Shaders that compare neighboring pixels, such as sketch and emboss, would then find
edges between the parts that are not there. The comment in ShaderEffect.swift and the
"How effects are drawn" DocC article record why the group is there.
Every public argument is checked before it reaches the GPU. A view modifier cannot throw,
so the package converts bad input to safe values instead. ShaderInput holds all of these
checks: ranges are clamped, NaN switches an effect off, angles are folded into one turn, and
positions are limited to ±1,000,000 points. Sample offsets are capped at the view's diagonal,
because SwiftUI draws distortion and layer effects into an offscreen layer that is larger than
the view by that offset on every side. An unbounded offset could allocate a huge layer, and
content farther away than the diagonal is outside the view and transparent anyway. The test
sampleOffsetsAreBounded passes .infinity and 1e12 to eight effects and expects offsets no
larger than the diagonal.
Time wraps every hour. Shaders compute in 32-bit floats, which lose precision as numbers
grow. ShaderClock wraps elapsed time at 3,600 seconds, and wraps it before multiplying by the
speed so the product stays finite. Animated effects take a ShaderTime: .animated,
.animated(speed:), or .fixed(_:) to show one frame in previews, snapshot tests or an
animation you drive yourself.
Premultiplied color discipline. SwiftUI hands shaders premultiplied colors. Effects that
change hue or brightness convert to straight color with a helper in ShaderCommon.h, do their
work, and multiply by alpha again, so antialiased text edges do not darken. Effects that read
neighbors clamp their reads into the view, which keeps sharpened or sketched views free of a
dark or bright border.
A separable blur. progressiveBlur runs a horizontal and a vertical pass of 49 samples each
instead of one pass of 49 × 49, so the cost per pixel does not depend on the radius.
Shaders load from the package's bundle. ShaderLibrary.swiftShaders is
ShaderLibrary.bundle(.module), and every function name starts with ss so it cannot collide
with shaders in an app.
What broke, and what I learned
The 2.0.0 changelog opens with an admission. The February 2026 code, meaning the v1.0.0 tag
on the first commit plus the untagged shaders added from 3 to 5 February, did not work in an
app. The June refactor (09a2dad) deleted that code. According to the changelog and to
ff67521, the commit that explains why it was not brought back, several .metal files used SwiftUI::Layer without including SwiftUI_Metal.h, the Swift wrappers
passed arguments that did not match the Metal signatures, and the shaders were looked up in the
app's main bundle instead of the package's. The missing include is in the untagged files added
after the tag, such as CRTShader.metal and SwirlShader.metal; the three .metal files in
v1.0.0 include the header. The bundle problem is visible in the v1.0.0 source:
its shader cache resolves functions from ShaderLibrary.default, while the .metal files live
in the package's resource bundle. Even a build that compiled would have shown no effect.
In June 2026 that code was not fixed but replaced with placeholders: a modifier that only
lowered opacity, plus circuit-breaker, retry and event-bus types unrelated to shaders. The
README still advertised a large shader collection, and CI stayed green because its swift test
step was marked continue-on-error: true. A green check proved nothing.
Version 2.0.0 was written from scratch in October 2026, and its tests are designed to fail
loudly where the February code failed silently. LibraryTests loads the compiled default.metallib from the
package bundle and checks that it holds exactly the 31 function names the Swift code calls, so a
rename on either side breaks a test instead of drawing nothing.
Review of the new code still found two shader bugs, fixed in f9205c3:
ssGradientMapblendscolors[index]andcolors[index + 1]. The Swift API always passes at
least two colors, but ShaderLibrary.swiftShaders is public, and a direct call with an empty
or one-color array made the shader read GPU memory it was not given. It now returns the
original color for an empty array, and for a single color it mixes the original toward that
color by amount. The test
directGradientMapCalls calls the shader directly with zero and one color.
ssDitherconverted the pixel position to an unsigned integer. A color effect can see negative
positions on content drawn outside its view, and that conversion is undefined in Metal. The
fix uses signed integers and a bitwise AND, which gives 0...3 for any position.
The dither half of that fix shipped without a test. The follow-up commit 4642ff9 adds
ditherNegativePositions: a gray area drawn partly at negative x must show the same 4 × 4
pattern on both sides. Its message notes that the test fails with the old uint2(...) % 4 and
passes with the fix.
A smaller find (e4a813c): the doc comment for ShaderLibrary.swiftShaders called ssSepia
with a single float, but the function takes the view's size first, so the example drew nothing.
It now uses ssCheckerboard, whose arguments all come from the caller.
The lesson I took from this repository is that the number of features matters less than proof
that each one works: a test that fails when the effect is broken, and CI that is allowed to go
red.
Usage
Add the package in Xcode with File › Add Package Dependencies…, or in Package.swift:
1.package(url: "https://github.com/muhittincamdali/SwiftShaders.git", from: "2.0.0")The README's quick start, which is also the example app's ContentView and is compiled and
rendered by the test suite:
1import SwiftShaders2import SwiftUI3 4struct ContentView: View {5 var body: some View {6 Image(systemName: "mountain.2.fill")7 .font(.system(size: 96))8 .foregroundStyle(.blue)9 .padding(40)10 .background(.blue.opacity(0.15), in: .rect(cornerRadius: 24))11 .halftone(dotSize: 6, ink: .blue, paper: .white)12 }13}Effects combine and run in order, from the view outward:
1struct RetroScreenSample: View {2 var body: some View {3 Text("GAME OVER")4 .font(.system(size: 44, weight: .black, design: .monospaced))5 .foregroundStyle(.cyan)6 .padding(32)7 .background(Color(red: 0.02, green: 0.07, blue: 0.2))8 .chromaticAberration(offset: 2)9 .scanlines(spacing: 3, intensity: 0.4)10 .vignette(intensity: 0.7)11 }12}The tests render views with ImageRenderer at scale 1 and compare pixels with values computed in
Swift. Because ImageRenderer can return an earlier image when a view draws nothing, every view
is rendered twice, over black and over white, and the color and alpha of each pixel are
recovered from the pair. On tag v2.0.0, swift test with Swift 6.4 reports 80 tests in 12
suites passing; 3 of them only generate the README images and are skipped unless
SCREENSHOT_DIR is set, so 77 run.
Limits
- Platforms: iOS 17 and macOS 14 only. tvOS and visionOS were listed in
v1.0.0but never
built; they stay out of the manifest until they are tested.
- Toolchain:
swift-tools-version: 6.2, so Xcode 26 or later. From the command line,
swift build and swift test need Swift 6.4, whose Swift Build engine compiles .metal files;
the older native build system copies them uncompiled and effects are not drawn.
SwiftShaders.isMetalLibraryAvailable tells you which case you are in. For the same reason
the shared CI workflow skips swift test, and the tests run through xcodebuild on macOS and
on an iPhone simulator.
- One pass per shader: effects that need several passes or compute, such as fluid or particle
simulations, are out of scope. The fire, raymarching, particle and fluid shaders added in
February 2026, after the v1.0.0 tag, were not brought back.
- Not measured for speed: there are no benchmarks or device GPU profiles. The tests check
pixel correctness on macOS and the simulator, not frame time, and pixel output has not been
verified on a physical device.
- Roadmap (from the README): a Swift Package Index listing (
.spi.ymlis ready), tvOS and
visionOS after testing, and a masked progressive blur that follows any shape.
Source code: github.com/muhittincamdali/SwiftShaders
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

