SwiftUIでMetal Shaderを扱う

この記事は[クラスター Advent Calendar 2023](https://qiita.com/advent-calendar/2023/cluster)シリーズ2の8日目の記事です。

こんにちは。クラスター株式会社でソフトウェアエンジニアをやっている[shindyu（しんじゅ）](https://twitter.com/shindyu)です。

前日の記事は 獏星(ばくすたー) さんの「[オフィスアワーをやってみている話](https://note.com/baku_dreameater/n/n58c416b0b029)」でした！
普段から気軽に話せるオフィスアワーがあることで業務にも良い影響を与えていそうです。

# SwiftUIでMetal Shaderを扱う
さて、clusterのiOSアプリ開発では新しい画面作成時には積極的にSwiftUIを利用しています。
そして今年出たiOS 17からはSwiftUIでも手軽にMetal Shaderを扱えるようになりました。

これまでMetalについて興味はあったものの難しそうだな、と少し敬遠していたのですが
アドベントカレンダー駆動ということでこの機会に試してみます。


iOS 17から追加されたcolorEffect, distortionEffect, layerEffectを使うことで、SwiftUIのViewに対してMetal Shaderを簡単に適用することができます。

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:)


SwiftUIでこちらのsample画像に対してcolorEffectを適用してみましょう。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130686/4dcc8f08-7e9b-06b3-93bb-27364a4f5174.png">


まずはMetal Shaderを作成します。作成する際はFile > NewからMetal Fileを選択します。

![スクリーンショット 2023-12-07 21.35.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130686/794a9ece-c4f2-fe68-ee26-5ed3a54d8967.png)

Metal Fileの中身は以下のようになります。
今回はsampleGradientというShader関数を作成しました。
シェーダー関数がカラーフィルターとして機能するためには、関数シグネチャが以下と一致している必要があります。
`[[ stitchable ]] half4 name(float2 position, half4 color, args...)`

`position`はピクセルの位置、`color`はピクセルの色で、その後に続く`float time`が自分で定義した引数になります。

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

[[ stitchable ]] half4 sampleGradient(float2 position,
                                    half4 color,
                                    float time) {
    // 元の色(color)を時間経過(time)で変化させた色を返している
    return half4(abs(color.r + cos(time) / 2),
                 abs(color.g + sin(time) / 3),
                 abs(color.b + sin(time) / 5),
                 1.0);
}
```

次に、作成したsampleGradientをSwiftUIのcolorEffectから利用します。

[ShaderLibrary](https://developer.apple.com/documentation/swiftui/shaderlibrary)は@dynamicMemberLookupを実装しているため、先ほど作成したMetal Shaderを扱う際は以下のように書くことができます。

```swift
struct Sample: View {
    private let date = Date()

    var body: some View {
        // TimelineViewは指定したスケジュールで更新を行なってくれるView
        TimelineView(.animation) {
            let time = date.timeIntervalSince1970 -  $0.date.timeIntervalSince1970
            
            Image(.sample)
                .foregroundStyle(Color.white)
                .aspectRatio(contentMode: .fit)
                .colorEffect(ShaderLibrary.sampleGradient(.float(time)))
        }
    }
}
```

時間経過で元の画像の色を変化させることができました！簡単！

![sample_image.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130686/64a2f040-ad71-ff9c-e3bb-262f425fb704.gif)



時間変化させる代わりにCoreMotionを使えばiPhoneの傾きに応じて見え方が変わるホログラム画像のような表現になります。

```swift: 例
import SwiftUI
import CoreMotion

struct Sample: View {
    private let motionManager = CMMotionManager()
    @State private var acceleration: CMAcceleration = .init()

    var body: some View {
        Image(.sample)
            .resizable()
            .aspectRatio(contentMode: .fit)
            .colorEffect(
                ShaderLibrary.sampleGradientMotion(
                    .boundingRect,
                    .float(acceleration.x),
                    .float(acceleration.y),
                    .float(acceleration.z)
                )
            )
            .onAppear() {
                motionManager.accelerometerUpdateInterval = 1/60
                motionManager.startAccelerometerUpdates(to: .main) { data, error in
                    if let data = data {
                        acceleration = data.acceleration
                    }
                }
            }
        }
    }
}
```


ちなみにTextにもShaderを適用できます。
`foregroundStyle`でMetal Shaderを指定するだけです。

![sample_text.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130686/c083b6ac-b609-cf61-edd8-89373222bf27.gif)

```swift		
	Text("Sample Effect")
		.font(.largeTitle)
		.foregroundStyle(ShaderLibrary.sampleGradientText(float(time)))
}
```

```cpp
[[ stitchable ]] half4 sampleGradientText(float2 position,
                                    float time) {
        return half4(abs(sin(time)),
                     abs(cos(time)),
                     abs(sin(time)),
                     1.0);
}
```

# まとめ
SwiftUIで簡単にMetal Shaderを扱うことができました。
今回Shader内の処理はいずれも簡単な実装になっていますが、[Shadertoy](https://www.shadertoy.com/)などを参考にすればもっと面白いShaderを作ることもできそうです。

機会があればclusterアプリでもMetal Shaderを使ったリッチな表現にチャレンジしていきたいですね。


明日は[chouge](https://qiita.com/chouge)さんの「[cluster のスクリプトを WSL 上の Vim で編集する](https://qiita.com/chouge/items/187d84dc07f7561eaed1)」です。