在 SwiftUI 中使用 Metal Shader

简介

从 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

签名如下:

swift 复制代码
func colorEffect(
    _ shader: Shader,
    isEnabled: Bool = true
) -> some View

该 modifier 用来操作每个单独的像素,要求提供的 MSL 函数的签名必须和下面的匹配:

swift 复制代码
[[ 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:

cpp 复制代码
[[ 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 中进行指定,如下所示:

swift 复制代码
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()
    }
}

运行效果:

layerEffect

layerEffect 类似于 colorEffect,也是一个 fragment shader,返回处理后的像素颜色,但是不同于 colorEffect shader 函数参数只给我们提供 position 位置对应的单个像素的颜色,layerEffect 给我们提供了被修饰 View 的整个 layer,这样我们就可以实现一些上下文相关的效果,比如高斯模糊。该 modifier 签名如下:

swift 复制代码
func layerEffect(
    _ shader: Shader,
    maxSampleOffset: CGSize, // 该参数说明见下
    isEnabled: Bool = true
) -> some View

要求提供的 MSL 函数的签名必须和下面的匹配:

swift 复制代码
[[ stitchable ]] half4 name(float2 position, SwiftUI::Layer layer, args...)

SwiftUI::Layer 只暴露出了一个 half4 sample(float2 p) 函数,返回的是被修饰内容里,坐标 p 处的线性插值颜色值,该函数的实现在头文件里给出了,代码如下:

cpp 复制代码
  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,需要引用相关头文件:

cpp 复制代码
#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;
}

示例:

swift 复制代码
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()
    }
}

运行效果:

distortionEffect

不同于前两者,distortionEffect 使用的是一个 vertex shader,即返回的不是一个 half4 类型的颜色值,而是一个 float2 类型的坐标值,即改变每一个像素的位置,从而实现一些扭曲变形的效果。该 modifier 的签名如下:

swift 复制代码
func distortionEffect(
    _ shader: Shader,
    maxSampleOffset: CGSize, // 该参数含义同 layerEffect
    isEnabled: Bool = true
) -> some View

要求提供的 MSL 函数的签名必须和下面的匹配:

cpp 复制代码
[[ stitchable ]] float2 name(float2 position, args...)

示例 Shader:

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

示例:

cpp 复制代码
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()
    }
}

运行效果:

References

相关推荐
I烟雨云渊T7 小时前
iOS APP启动页及广告页的实现
ios
鸿蒙布道师9 小时前
鸿蒙NEXT开发动画案例9
android·ios·华为·harmonyos·鸿蒙系统·arkui·huawei
羑悻的小杀马特17 小时前
iOS:重新定义移动交互,引领智能生活新潮流
macos·ios·objective-c·cocoa·mac
I烟雨云渊T19 小时前
iOS热更新技术要点与风险分析
ios
Digitally19 小时前
如何解决Move to iOS 不起作用的问题?
macos·ios·cocoa
安和昂1 天前
iOS 内存分区
macos·ios·cocoa
Unlimitedz1 天前
iOS解码实现
ios
安和昂2 天前
iOS 工厂模式
ios
龙湾开发2 天前
轻量级高性能推理引擎MNN 学习笔记 03.在iOS运行MNN的示例
c++·学习·ios·图形渲染·mnn
初遇你时动了情2 天前
flutter 配置 安卓、Ios启动图
android·flutter·ios