Lektion 4.9 · Stufe 4 – Profi

Shader in SwiftUI

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du eigene Shader in Metal schreiben und in SwiftUI anwenden: Farbeffekte mit colorEffect, Verzerrungen mit distortionEffect und Effekte, die benachbarte Pixel lesen, mit layerEffect. Du übergibst Parameter, animierst Shader über die Zeit mit TimelineView und visualEffect und weißt, worauf du bei Leistung und Barrierefreiheit achten musst.

Das Problem: Effekte für jeden einzelnen Pixel

Mit Modifiern kannst du Views einfärben, unscharf machen, drehen und verschieben. Aber manche Effekte gehen darüber hinaus:

  • ein Bild, das wie unter Wasser wellt,
  • ein Foto, das sich in Pixel auflöst,
  • ein schimmernder Glanz, der über eine Karte läuft,
  • eine Schwarz-Weiß-Darstellung nur für einen Teil der Oberfläche.

Solche Effekte entstehen, indem für jeden einzelnen Pixel etwas berechnet wird. Bei einem iPhone-Bildschirm sind das rund drei Millionen Pixel, 120-mal pro Sekunde. Das schafft kein normaler Swift-Code auf dem Main Actor.

Dafür gibt es Shader: kleine Programme, die auf der Grafikkarte (GPU) laufen, und zwar für alle Pixel gleichzeitig. Seit iOS 17 kannst du eigene Shader direkt auf SwiftUI-Views anwenden.

Ein Bild dafür

Die CPU ist wie ein sehr kluger Mathematiker, der eine Aufgabe nach der anderen löst. Die GPU ist wie ein Saal mit Tausenden Schülern, die alle dieselbe einfache Aufgabe gleichzeitig lösen, jeder für seinen eigenen Pixel. Ein Shader ist das Aufgabenblatt, das an alle verteilt wird.

Shader werden in Metal geschrieben

Shader schreibst du nicht in Swift, sondern in der Metal Shading Language, einer Sprache, die auf C++ basiert. Keine Sorge: Für SwiftUI-Shader brauchst du nur einen kleinen Teil davon, und die Syntax ist schnell gelernt.

Die Metal-Datei anlegen

  1. Datei → Neu → Datei aus Vorlage (⌘ N).
  2. Wähle Metal File und nenne sie Shaders.metal.
  3. Ersetze den Inhalt durch:
Metal
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;

Diese drei Zeilen binden die Metal-Bibliothek und die SwiftUI-Erweiterungen ein. Darunter kommen deine Shader.

Achtung

Metal-Dateien funktionieren nur in App-Projekten, nicht in Playgrounds. Die Vorschau im Canvas unterstützt Shader, ein echtes Gerät oder der Simulator ebenfalls.

Die wichtigsten Typen

MetalBedeutung
floatKommazahl, wie Float in Swift
float2zwei Kommazahlen, etwa eine Position (x, y)
half4vier Kommazahlen mit halber Genauigkeit, eine Farbe (r, g, b, a)
half3drei Kommazahlen, etwa nur die Farbkanäle r, g, b

Farbwerte liegen zwischen 0 und 1. Auf die Teile greifst du mit Punkten zu: color.r, color.a, color.rgb, position.x.

Art 1: Farbeffekte mit colorEffect

Ein Farbeffekt bekommt für jeden Pixel dessen Position und Farbe und gibt eine neue Farbe zurück.

Schwarz-Weiß

In Shaders.metal:

Metal
[[ stitchable ]] half4 grayscale(float2 position, half4 color) {
    half gray = dot(color.rgb, half3(0.299, 0.587, 0.114));
    return half4(gray, gray, gray, color.a);
}
  • [[ stitchable ]] markiert die Funktion als Shader, den SwiftUI verwenden kann.
  • Die Parameter sind fest vorgegeben: zuerst die Position, dann die Farbe.
  • dot(...) gewichtet Rot, Grün und Blau unterschiedlich und addiert sie. Diese Gewichte entsprechen dem, wie hell das menschliche Auge die Farben empfindet: Grün wirkt heller als Blau.
  • Zurück kommt eine graue Farbe mit dem ursprünglichen Alpha-Wert.

In SwiftUI:

Swift
Image("food")
    .resizable()
    .scaledToFit()
    .colorEffect(ShaderLibrary.grayscale())
  • ShaderLibrary findet alle stitchable-Funktionen in den Metal-Dateien deiner App automatisch. ShaderLibrary.grayscale() erzeugt einen Shader aus der gleichnamigen Funktion.
  • .colorEffect(...) wendet ihn auf jeden Pixel der View an.

Erwartetes Ergebnis

Das Foto erscheint in Graustufen. Das funktioniert nicht nur mit Bildern, sondern mit jeder View: Text, Buttons, ganze Bildschirme.

Parameter übergeben

Shader werden erst richtig nützlich, wenn du sie steuern kannst. Weitere Parameter hängst du hinten an:

Metal
[[ stitchable ]] half4 desaturate(float2 position, half4 color, float amount) {
    half gray = dot(color.rgb, half3(0.299, 0.587, 0.114));
    half3 result = mix(color.rgb, half3(gray), half(amount));
    return half4(result, color.a);
}

mix(a, b, t) mischt zwischen a und b: bei t = 0 ganz a, bei t = 1 ganz b.

Swift
@State private var amount = 0.0

Image("food")
    .resizable()
    .scaledToFit()
    .colorEffect(ShaderLibrary.desaturate(.float(amount)))

Slider(value: $amount)

Die Argumente übergibst du in derselben Reihenfolge wie in Metal, jeweils mit ihrem Typ:

SwiftMetal
.float(x)float
.float2(x, y) oder .float2(size)float2
.color(.red)half4
.image(Image("x"))texture2d<half>

Art 2: Verzerrungen mit distortionEffect

Ein Verzerrungseffekt ändert nicht die Farbe, sondern woher ein Pixel seine Farbe holt. Er bekommt eine Position und gibt eine andere Position zurück: „Zeig an dieser Stelle, was eigentlich dort drüben ist.“

Eine Welle

Metal
[[ stitchable ]] float2 wave(float2 position, float time, float amplitude) {
    float offset = sin(time * 4 + position.x / 20) * amplitude;
    return float2(position.x, position.y + offset);
}

Jeder Pixel holt seine Farbe von einer leicht nach oben oder unten verschobenen Stelle. Weil die Verschiebung von position.x und time abhängt, entsteht eine Welle, die sich bewegt.

Damit sie sich bewegt, braucht der Shader die Zeit. Die liefert TimelineView (Lektion 4.8):

Swift
struct WavyText: View {
    let start = Date.now

    var body: some View {
        TimelineView(.animation) { timeline in
            let time = timeline.date.timeIntervalSince(start)

            Text("Unter Wasser")
                .font(.system(size: 44, weight: .black))
                .foregroundStyle(.blue.gradient)
                .padding(20)
                .distortionEffect(
                    ShaderLibrary.wave(.float(time), .float(6)),
                    maxSampleOffset: CGSize(width: 0, height: 6)
                )
        }
    }
}
  • maxSampleOffset sagt SwiftUI, wie weit ein Pixel höchstens verschoben wird, hier 6 Punkte senkrecht. SwiftUI braucht diese Angabe, um genug Rand um die View herum vorzubereiten. Ist sie zu klein, werden Teile am Rand abgeschnitten.
  • Das .padding(20) gibt dem Text Platz, damit die Welle nicht an seinen Rändern abgeschnitten wird.

Erwartetes Ergebnis

Der Schriftzug „Unter Wasser“ wogt sanft auf und ab, als würde man ihn durch eine Wasseroberfläche betrachten.

Art 3: Ebeneneffekte mit layerEffect

Manche Effekte brauchen nicht nur die Farbe des eigenen Pixels, sondern die Farben anderer Pixel. Ein Pixel-Effekt zum Beispiel: Alle Pixel in einem kleinen Quadrat bekommen die Farbe des Pixels in dessen Mitte.

Dafür gibt es Ebeneneffekte. Sie bekommen statt der Farbe die ganze Ebene und können daraus an beliebigen Stellen lesen:

Metal
[[ stitchable ]] half4 pixelate(float2 position, SwiftUI::Layer layer, float size) {
    float2 cell = floor(position / size) * size + size / 2;
    return layer.sample(cell);
}
  • SwiftUI::Layer layer ist der gerenderte Inhalt der View.
  • floor(position / size) * size rundet die Position auf den Anfang ihres Quadrats ab. Plus die halbe Größe ergibt die Mitte.
  • layer.sample(cell) liest die Farbe an dieser Stelle.
Swift
@State private var pixelSize = 1.0

VStack {
    Image("food")
        .resizable()
        .scaledToFit()
        .layerEffect(ShaderLibrary.pixelate(.float(pixelSize)), maxSampleOffset: .zero)

    Slider(value: $pixelSize, in: 1...32)
}

Ziehst du den Slider nach rechts, löst sich das Bild in immer größere Pixel auf.

Shader-Argumente animieren nicht von selbst

Änderst du pixelSize mit withAnimation, springt der Effekt trotzdem: SwiftUI interpoliert keine Shader-Argumente. Für einen fließenden Übergang lieferst du die Werte selbst Bild für Bild, etwa aus einer TimelineView oder einem keyframeAnimator (Lektion 3.26), dessen aktuellen Wert du an den Shader übergibst.

Welche Art wann?

  • colorEffect: Die neue Farbe hängt nur von der eigenen Farbe und Position ab. Einfärben, Graustufen, Verläufe, Glanz.
  • distortionEffect: Pixel werden verschoben. Wellen, Wölbungen, Zoom-Effekte.
  • layerEffect: Die neue Farbe hängt von anderen Pixeln ab. Verpixeln, eigene Unschärfen, Kanten erkennen. Am vielseitigsten, aber auch am teuersten.

Shader mit Größe und Position: visualEffect

Viele Effekte hängen von der Größe der View ab. Ein Glanz soll etwa von der linken zur rechten Kante laufen. Im Shader kennst du aber nur Pixelpositionen, nicht die Gesamtgröße.

Der Modifier visualEffect gibt dir einen GeometryProxy, mit dem du die Größe an den Shader übergeben kannst:

Metal
[[ stitchable ]] half4 shimmer(float2 position, half4 color, float2 size, float progress) {
    float x = position.x / size.x;                       // 0 bis 1 über die Breite
    float band = 1 - smoothstep(0.0, 0.15, abs(x - progress));
    return half4(color.rgb + half3(band * 0.5) * color.a, color.a);
}
Swift
struct ShimmerCard: View {
    let start = Date.now

    var body: some View {
        TimelineView(.animation) { timeline in
            let t = timeline.date.timeIntervalSince(start)
            let progress = (t.truncatingRemainder(dividingBy: 2)) / 2 * 1.4 - 0.2

            RoundedRectangle(cornerRadius: 20)
                .fill(.orange.gradient)
                .frame(width: 300, height: 180)
                .visualEffect { content, proxy in
                    content.colorEffect(
                        ShaderLibrary.shimmer(.float2(proxy.size), .float(progress))
                    )
                }
        }
    }
}
  • smoothstep erzeugt einen weichen Übergang. Pixel nahe bei progress werden aufgehellt, weiter entfernte nicht.
  • Weil die Farbe mit vormultipliziertem Alpha vorliegt (die Farbkanäle sind schon mit der Deckkraft multipliziert), wird die Aufhellung mit color.a multipliziert. So bleiben durchsichtige Bereiche durchsichtig.
  • visualEffect ist ein Modifier, der nur visuelle Effekte anwendet und dabei die Geometrie liefert, ohne das Layout zu beeinflussen, ähnlich wie scrollTransition (Lektion 3.18).

Erwartetes Ergebnis

Ein heller Glanzstreifen gleitet alle zwei Sekunden von links nach rechts über die orange Karte, wie bei einer Kreditkarte, die im Licht gedreht wird.

Leistung und Barrierefreiheit

Shader laufen extrem schnell, aber ein paar Dinge solltest du beachten:

  • Erster Aufruf: Ein Shader wird beim ersten Verwenden für die Grafikkarte übersetzt. Das kann einen kleinen Ruckler verursachen. Seit iOS 18 kannst du ihn vorab übersetzen: try await shader.compile(as: .colorEffect), etwa beim Start der App.
  • TimelineView(.animation) lässt die View 120-mal pro Sekunde neu rendern. Halte sie an, wenn der Effekt nicht sichtbar ist.
  • layerEffect ist teurer als die anderen beiden, weil die View erst in eine Ebene gerendert werden muss.
  • Bewegung reduzieren: Wogende und schimmernde Effekte können Menschen mit Bewegungsempfindlichkeit stören. Prüf @Environment(\.accessibilityReduceMotion) und zeig dann eine ruhige Variante.
  • Lesbarkeit: Shader verändern das Aussehen, nicht den Inhalt. VoiceOver liest den Text weiterhin vor. Achte aber darauf, dass verzerrter Text noch lesbar bleibt.

Typische Fehler

ShaderLibrary findet die Funktion nicht

Der Name in Swift stimmt nicht mit dem Funktionsnamen in Metal überein, oder [[ stitchable ]] fehlt. Die Namen müssen exakt gleich sein.

Falsche Parameter-Reihenfolge

Die ersten Parameter sind fest vorgegeben: bei colorEffect Position und Farbe, bei distortionEffect nur die Position, bei layerEffect Position und Ebene. Deine eigenen Argumente kommen danach, in derselben Reihenfolge wie in Swift.

Ränder werden abgeschnitten

maxSampleOffset ist zu klein, oder die View hat keinen Rand für die Verzerrung. Erhöhe den Wert und gib der View etwas Padding.

Durchsichtige Bereiche werden farbig

Die Farbe hat vormultipliziertes Alpha. Wenn du Farbe hinzufügst, multipliziere sie mit color.a.

FAQ

Anfänger

Was bedeutet das überhaupt?

Muss ich Metal lernen, um Shader zu verwenden? Für einfache Effekte reichen die Typen und Funktionen aus dieser Lektion. Viele Effekte bestehen aus ein paar Zeilen Mathematik mit sin, mix, smoothstep und floor. Es gibt außerdem viele frei verfügbare Beispiele, die du anpassen kannst.

Funktionieren Shader auf allen Geräten? Ja, auf allen Geräten, die iOS 17 oder neuer unterstützen.

Fortgeschritten

Warum verhält es sich so?

Wie verwende ich ein Bild als Textur im Shader? Übergib es mit .image(Image("Rauschen")) und deklariere in Metal einen Parameter texture2d<half> noise. Zum Lesen brauchst du einen sampler, etwa constexpr sampler s(address::repeat, filter::linear); und dann noise.sample(s, uv). So lassen sich Rauschen, Muster oder Überblendungen mit einem zweiten Bild bauen.

Kann ich einen Shader als Hintergrund ohne Inhalt verwenden? Ja: Wende ihn auf ein Rectangle() an, etwa mit .colorEffect. Die Farbe des Rechtecks ist dann der Ausgangswert, den du im Shader beliebig ersetzen kannst, zum Beispiel durch einen animierten Farbverlauf aus Position und Zeit.

Profi

Was passiert unter der Haube?

Was bedeutet stitchable? SwiftUI baut aus allen Effekten, die auf eine View wirken, einen gemeinsamen Render-Durchgang. stitchable erlaubt es, deine Funktion in diesen Durchgang „einzunähen“, statt sie als separaten Schritt auszuführen. Deshalb sind SwiftUI-Shader so effizient, und deshalb ist die Signatur fest vorgegeben.

Wie debugge ich einen Shader? Ein print gibt es auf der GPU nicht. Der übliche Weg: Zwischenwerte als Farbe ausgeben, etwa return half4(x, 0, 0, 1), um zu sehen, wo x groß ist. Für tiefere Analysen gibt es in Xcode das Metal Debugger-Werkzeug über Debug → Capture GPU Workload.

Übungen

Anfänger

Übung 1: Farbe umkehren

Schreib einen colorEffect-Shader invert, der die Farben umkehrt (Rot wird zu Türkis, Weiß zu Schwarz). Achte auf das vormultiplizierte Alpha: Die umgekehrte Farbe ist color.a - color.rgb. Wende ihn auf ein SF Symbol und einen Text an.

Lösung Übung 1Erst selbst probieren!
Metal
[[ stitchable ]] half4 invert(float2 position, half4 color) {
    return half4(color.a - color.rgb, color.a);
}
Swift
VStack(spacing: 20) {
    Image(systemName: "sun.max.fill")
        .font(.system(size: 80))
        .foregroundStyle(.yellow)
    Text("Umgekehrt")
        .font(.largeTitle.bold())
        .foregroundStyle(.blue)
}
.colorEffect(ShaderLibrary.invert())

Ohne Vormultiplizierung wäre 1 - color.rgb richtig. Weil die Farbkanäle aber schon mit Alpha multipliziert sind, würden durchsichtige Bereiche sonst weiß.

Fortgeschritten

Übung 2: Steuerbare Welle

Erweitere den Wellen-Shader um eine Frequenz. Baue eine Ansicht mit einem Bild und zwei Slidern für Amplitude und Frequenz. Ist „Bewegung reduzieren“ aktiv, steht die Welle still.

Profi

Übung 3: Wölbung unter dem Finger

Schreib einen distortionEffect, der das Bild an einer Position wie unter einer Lupe nach außen wölbt. Übergib die Fingerposition aus einer DragGesture (Lektion 3.27) und einen Radius. Pixel innerhalb des Radius holen ihre Farbe von einer Stelle näher an der Fingerposition. Beim Loslassen verschwindet die Wölbung mit einer Feder.

Zusammenfassung

  • Shader sind kleine Programme in Metal, die auf der GPU für jeden Pixel gleichzeitig laufen.
  • Sie stehen in einer .metal-Datei als [[ stitchable ]]-Funktionen und werden über ShaderLibrary.name(...) aufgerufen.
  • colorEffect: neue Farbe aus Position und Farbe. distortionEffect: Pixel verschieben, mit maxSampleOffset. layerEffect: andere Pixel lesen mit layer.sample.
  • Argumente mit .float, .float2, .color, .image in Metal-Reihenfolge übergeben.
  • TimelineView liefert die Zeit für bewegte Effekte, visualEffect die Größe der View.
  • Farben haben vormultipliziertes Alpha. Bewegte Effekte bei „Bewegung reduzieren“ ruhig stellen.

Spickzettel

Metal
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;

[[ stitchable ]] half4 tint(float2 pos, half4 color, half4 tintColor) { ... }
[[ stitchable ]] float2 wave(float2 pos, float time, float amp) { ... }
[[ stitchable ]] half4 pixelate(float2 pos, SwiftUI::Layer layer, float size) {
    return layer.sample(floor(pos / size) * size + size / 2);
}
Swift
view.colorEffect(ShaderLibrary.tint(.color(.orange)))
view.distortionEffect(ShaderLibrary.wave(.float(t), .float(6)),
                      maxSampleOffset: CGSize(width: 0, height: 6))
view.layerEffect(ShaderLibrary.pixelate(.float(12)), maxSampleOffset: .zero)

view.visualEffect { content, proxy in
    content.colorEffect(ShaderLibrary.shimmer(.float2(proxy.size), .float(p)))
}

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.