在 SwiftUI 中使用 Metal Shader

westwindrest發表於2024-03-22

簡介

從 iOS 17/macOS 14 開始,SwiftUI 支援使用 Metal shader 來實現一些特效。主要提供三個 View Modifier:colorEffectdistortionEffectlayerEffect 。每個 modifier 的第一個引數是傳入的 Shader 例項。

此外,View 例項還新增了一個 visualEffect modifier,用於暴露修飾內容的佈局資訊。函式簽名為 func visualEffect(_ effect: @escaping (EmptyVisualEffect, GeometryProxy) -> some VisualEffect) -> some View ,在這個閉包中給 EmptyVisualEffect 新增上面的三種 shader modifier,透過 GeometryProxy 引數來獲取所修飾內容的 size 等資訊,可以進一步傳遞給 shader function。

可惜的是,這些 modifier 只適用於 SwiftUI 的 View,不適用於 UIKit/AppKit 包的 View。

用法

Shader Function

Shader 建構函式為 init(function: ShaderFunction, arguments: [Shader.Argument],而 ShaderFunction 的建構函式為 init(library: ShaderLibrary, name: String)。ShaderLibrary 有一個 static 成員 default,表示 app 的 main bundle 中的 shader library。此外 ShaderLibrary 還提供了 static subscript(dynamicMember _: String) -> ShaderFunction 方法,返回 default shader library 中名字為 name 的 MSL function。

三個 View Modifier 分別操作不同的元素,實現不同的效果,也對 MSL 函式有著各自不同的要求,下面一一介紹。

colorEffect

簽名如下:

func colorEffect(
    _ shader: Shader,
    isEnabled: Bool = true
) -> some View

該 modifier 用來操作每個單獨的畫素,要求提供的 MSL 函式的簽名必須和下面的匹配:

[[ stitchable ]] half4 name(float2 position, half4 color, args...)

其中 position 和 color 引數在執行 shader 函式的時候會自動傳入,position 表示畫素在 user-space 座標系下的座標(相對的,Metal 的 clip-space 座標系區域為 (-1.0, -1.0) 到 (1.0, 1.0)),color 是當前 position 對應畫素的顏色。我們也可以透過 args… 可變引數傳入自定義的資料。該 shader 函式返回處理後的畫素顏色(Fragment shader)。

示例 Shader:

[[ stitchable ]] half4 colorCircle(float2 position, half4 currentColor, float2 size, float radius, half4 circleColor) {
    float2 center = size / 2; // 計算 view 的中心點
    if (length(position - center) < radius) {
        return circleColor * currentColor.a;
    } else {
        return currentColor;
    }
}

在上面的 shader 函式中,除了會預設提供的兩個引數 position 和 currentColor 外,我們還額外提供了三個引數 size,radius,circleColor,這三個函式需要在SwiftUI 中進行指定,如下所示:

struct ContentView: View {
    let start = Date()

    var body: some View {
        ZStack {
            TimelineView(.animation) { _ in
                Text("𰻞")
                    .font(.system(size: 80, weight: .black))
                    .visualEffect { content, geometryProxy in
                        content
                            .colorEffect(ShaderLibrary.colorCircle(
                                .float2(geometryProxy.size),
                                .float(abs(start.timeIntervalSinceNow) * 10),
                                .color(.purple)
                            ))
                    }
            }
        }
        .padding()
    }
}

執行效果:

在 SwiftUI 中使用 Metal Shader

layerEffect

layerEffect 類似於 colorEffect,也是一個 fragment shader,返回處理後的畫素顏色,但是不同於 colorEffect shader 函式引數只給我們提供 position 位置對應的單個畫素的顏色,layerEffect 給我們提供了被修飾 View 的整個 layer,這樣我們就可以實現一些上下文相關的效果,比如高斯模糊。該 modifier 簽名如下:

func layerEffect(
    _ shader: Shader,
    maxSampleOffset: CGSize, // 該引數說明見下
    isEnabled: Bool = true
) -> some View

要求提供的 MSL 函式的簽名必須和下面的匹配:

[[ stitchable ]] half4 name(float2 position, SwiftUI::Layer layer, args...)

SwiftUI::Layer 只暴露出了一個 half4 sample(float2 p) 函式,返回的是被修飾內容裡,座標 p 處的線性插值顏色值,該函式的實現在標頭檔案裡給出了,程式碼如下:

  half4 sample(float2 p) const {
    p = metal::fma(p.x, info[0], metal::fma(p.y, info[1], info[2]));
    p = metal::clamp(p, info[3], info[4]);
    return tex.sample(metal::sampler(metal::filter::linear), p);
  }

這裡看起來會對傳入的座標 p 做 clamp,線下試過傳越界值的時候返回的是透明色值,但是因為不知道 info 是什麼資料,也沒用找到明確的文件說明,如果比較謹慎的話可以自己對 p 做越界處理。

回過頭來看 modifier 的 maxSampleOffset 引數,該引數是指在 shader 函式中,對 layer 呼叫 sample 取畫素色值時,如果傳入的座標不是當前的座標 position 而是其他座標,則可以計算出一個相對當前左邊的偏移距離 distance,maxSampleOffset 則是所有呼叫中的 distance 的最大值。(但是線下測試時傳 .zero 卻沒有出現問題,比較奇怪)

Shader,需要引用相關標頭檔案:

#include <SwiftUI/SwiftUI_Metal.h>

[[ stitchable ]] half4 gaussianBlur(float2 position, SwiftUI::Layer layer) {
    return
    layer.sample(position) * 0.0707355 +
    layer.sample(position + float2(-1, -1)) * 0.0453542 +
    layer.sample(position + float2(0, -1)) * 0.0566406 +
    layer.sample(position + float2(1, -1)) * 0.0453542 +
    layer.sample(position + float2(-1, 0)) * 0.0566406 +
    layer.sample(position + float2(1, 0)) * 0.0566406 +
    layer.sample(position + float2(-1, 1)) * 0.0453542 +
    layer.sample(position + float2(0, 1)) * 0.0566406 +
    layer.sample(position + float2(1, 1)) * 0.0453542;
}

示例:

struct ContentView: View {
    var body: some View {
        HStack {
            Text("𰻞")
                .font(.system(size: 80, weight: .black))

            Text("𰻞")
                .font(.system(size: 80, weight: .black))
                .layerEffect(ShaderLibrary.gaussianBlur(),
                             maxSampleOffset: .init(width: 3, height: 3))
        }
        .padding()
    }
}

執行效果:

在 SwiftUI 中使用 Metal Shader

distortionEffect

不同於前兩者,distortionEffect 使用的是一個 vertex shader,即返回的不是一個 half4 型別的顏色值,而是一個 float2 型別的座標值,即改變每一個畫素的位置,從而實現一些扭曲變形的效果。該 modifier 的簽名如下:

func distortionEffect(
    _ shader: Shader,
    maxSampleOffset: CGSize, // 該引數含義同 layerEffect
    isEnabled: Bool = true
) -> some View

要求提供的 MSL 函式的簽名必須和下面的匹配:

[[ stitchable ]] float2 name(float2 position, args...)

示例 Shader:

[[ stitchable ]] float2 stretch(float2 position, float2 size) {
    float midY = size.y / 2;
    return position + float2(30 * abs((position.y - midY) / midY), 0);
}

示例:

struct ContentView: View {
    var body: some View {
        ZStack {
            Text("𰻞")
                .font(.system(size: 80, weight: .black))
                .visualEffect { context, proxy in
                    context
                        .distortionEffect(
                            ShaderLibrary.stretch(.float2(proxy.size)),
                            maxSampleOffset: .init(width: proxy.size.width / 2, height: proxy.size.height / 2))
                }
        }
        .padding()
    }
}

執行效果:

在 SwiftUI 中使用 Metal Shader

References

  • How to add Metal shaders to SwiftUI views using layer effects
  • https://developer.apple.com/documentation/swiftui/view/coloreffect(_:isenabled:)
  • https://developer.apple.com/documentation/swiftui/view/distortioneffect(_:maxsampleoffset:isenabled:)
  • https://developer.apple.com/documentation/swiftui/view/layereffect(_:maxsampleoffset:isenabled:)

相關文章