SwiftUIにMetal ShaderでレアカードのホログラムみたいなEffect

# SwiftUIのViewにMetal Shaderを使ってレアカードのホログラムみたいなEffectをかけてみた

iOS17からSwiftUIにMetalのShaderを使ってEffectをかける機能が追加されました
[colorEffect](https://developer.apple.com/documentation/swiftui/view/coloreffect(_:isenabled:))、[distortionEffect](https://developer.apple.com/documentation/swiftui/view/distortioneffect(_:maxsampleoffset:isenabled:))、[layerEffect](https://developer.apple.com/documentation/swiftui/view/layereffect(_:maxsampleoffset:isenabled:))の3つのmodifierです
今回は、colorEffectを使って、TCGのレアカードのホログラムみたいなEffectをSwiftUIのViewにかけてみました

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/ec95043a-27db-74fc-0651-8356f4985fdb.gif" width="300">

View自体にEffectをかけるため、Imageだけではなく、TextやoverlayのborderにもEffectがかかっています
CIFilterを利用する場合は一度画像化する必要がありますが、colorEffectはTextなどViewに直接Effectをかけるため**動的なコンテンツで特に便利そう**です(今回の場合、"Rotate: X°"の部分)

![Frame 286 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/41bf9a88-b6a8-a702-f85a-f14d555d3947.png)


# 実装方法
ホログラムなどによく用いられるVoronoi図を用意し、0~1のグラデーションを色相に変換し、カラフルにします
この時、Voronoiの値に0~1に変換した傾きの値をOffsetとして足して小数点部分を取ることで、傾きに応じてリニアに色が変わるようします
カラフルにしたVoronoi図を元のViewに加算合成します
これらの処理のうち、色相変換と加算合成をMetal Shaderで実行しています

![Frame 287 (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/29c34016-5f76-b2ad-c364-cac89e7eb7b9.png)

## 色相変換部分の補足
Voronoiの値に0~1に変換した傾きの値をOffsetとして足して小数点部分を取ることで、傾きに応じてリニアに色が変わるようにする部分の補足です
例えば、Voronoiの値が0で傾きが0の場合は赤色になります
傾きが変わりOffsetが0.5になると水色に変化します
また、1を超えた値は少数点部分を取ることで色相を1周するので、ここも途切れずに色を変化させることができます

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/0c1195a6-956e-de57-4ed4-5bcfd00c5c1e.gif" width="300">

# 実装
## Effectをかける最終系
[colorEffect](https://developer.apple.com/documentation/swiftui/view/coloreffect(_:isenabled:))を使って任意のViewにEffectをかけます
`holographic`という名前のShaderを作成して、voronoiの画像とoffsetの値を渡します

```swift
struct ContentView: View {
    let voronoi: Image
    @State var offset: Float = 0
    var body: some View {
        VStack {
            // EffectをかけるView
            effectTargetView
                // colorEffect使ってEffectをかける
                .colorEffect(ShaderLibrary.holographic(.image(voronoi), .float(offset)))

            Slider(value: $offset, in: (-1.0...1.0))
        }
    }
}
```

## Shaderの実装
新しく.metalファイルを作成し、`holographic` shaderを実装します
colorEffectから参照する`ShaderLibrary.holographic(_:)`はビルド時に自動生成されます

[colorEffect](https://developer.apple.com/documentation/swiftui/view/coloreffect(_:isenabled:))がこのような↓形のshaderをサポートするので、これに従って書きます

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

[.iamge(\_:)引数](https://developer.apple.com/documentation/swiftui/shader/argument/image(_:))はtexture2d<half>、[.float(\_:)引数](https://developer.apple.com/documentation/swiftui/shader/argument/float(_:))はfloatでそれぞれ受け取ります

```c:Shader.metal
half4 hue2Rgba(half h) {
    half hueDeg = h * 360.0;
    half x = (1 - abs(fmod(hueDeg / 60.0, 2) - 1));
    half4 rgba;
    if (hueDeg < 60) rgba = half4(1, x, 0, 1);
    else if (hueDeg < 120) rgba = half4(x, 1, 0, 1);
    else if (hueDeg < 180) rgba = half4(0, 1, x, 1);
    else if (hueDeg < 240) rgba = half4(0, x, 1, 1);
    else if ( hueDeg < 300) rgba = half4(x, 0, 1, 1);
    else rgba = half4(1, 0, x, 1);
    return rgba;
}

// float2 position, half4 colorはcolorEffectに含まれるデフォルトの引数
// texture2d<half> voronoiは .image(voronoi)
// float offsetは .float(offset)
[[ stitchable ]] half4 holographic(float2 position, half4 color, texture2d<half> voronoi, float offset) {
    // positionがView上の位置なので、0〜1の値に正規化する(voronoiを3倍(Retina)サイズで作成しているのでx3してます）
    float2 coord = float2(position.x / voronoi.get_width() * 3, position.y / voronoi.get_height() * 3);
    // voronoiの値
    half4 sampled = voronoi.sample(metal::sampler(metal::filter::linear), coord);
    // offsetを足して少数部分を取り、色相からRGBに変換する
    half4 rgba = hue2Rgba(fract(sampled.x + offset));
    // 加算合成
    half4 mixed = mix(color, rgba, 0.04);
    mixed.a = color.a;
    return mixed;
}
```

## Voronoi図の作成
GameplayKitの[GKVoronoiNoiseSource](https://developer.apple.com/documentation/gameplaykit/gkvoronoinoisesource)を利用して作成します
Shaderに画像を渡す場合、[引数がImageなので](https://developer.apple.com/documentation/swiftui/shader/argument/image(_:))Imageに変換します

```swift
func makeVoronoi() -> Image {
    let voronoiNoiseSource = GKVoronoiNoiseSource(frequency: 20, displacement: 1, distanceEnabled: false, seed: 555)
    let noise = GKNoise(voronoiNoiseSource)
    let noiseMap = GKNoiseMap(noise, size: .init(x: 1, y: 1), origin: .zero, sampleCount: .init(x: 900, y: 900), seamless: true)
    let texture = SKTexture(noiseMap: noiseMap)
    let cgImage = texture.cgImage()
    return Image(cgImage, scale: 1, label: Text(""))
}
```

# まとめ
SwiftUIのcolorEffectを使ってレアカードのホログラムみたいなEffectを作ってみました
これまで、TextなどにEffectをかけようとすると、一度画像にしてCIFilterを使うなどする必要がありましたが、SwiftUIのMetal Shaderの機能を使うことでシンプルに実装できました
また、Textやアニメーションを伴う画面では、CIFilterなどでは都度画像化する必要がありましたが、この方法ではViewに直接Shaderを当てられるため、動的なコンテンツにもEffectをかけることができます
今回はシンプルなEffectでしたが、直接Metalに突っ込むことができるので、今後はよりリッチなEffectをSwiftUIで簡単に実現できそうです