SwiftUI×MetalでレアカードっぽいEffectを作る

## はじめに
レアカードって素敵ですよね。子供の頃は遊戯王とかでキラキラのレアカードを見てるだけでテンション上がってました。
iOSでもレアカードが実装できたらいいなと思ったので、
今回は、SwiftUI と Metal を使って、レアカードのように傾きによってキラキラする Effect を実装してみました。

## 環境

- iOS 17.0 以上
- Xcode 15.0 以上

## 完成系

![レアカード.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/1d7235a9-4ef7-4928-8941-cecdf65767f5.gif)

GIF では伝わりづらいですが、デバイスの傾きでも光の加減を変更したりしています。
なんかキラキラしていて、レアカードっぽいです。

この Effect は、Metal を使って色を変更することで実現しています。
ここではあまり詳しく取り扱いませんが、SwiftUI × Metalは、以下 modifierで簡単に実装できます。

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

modifier 名からも何となくわかりますが、色の変更は`colorEffect`で実現できます。

詳しい説明は、以下記事でわかりやすく説明されていました。

https://zenn.dev/ikeh1024/articles/cc51846dfad295

## `colorEffect`の素振り

完成系のレアカード実装を開設する前に、`colorEffect`をどのように使うのか軽くみておきましょう。
以下のベースを例として、表示している `Image` を`colorEffect`を使って赤っぽくしてみます。

<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/5034e66c-b1fe-4ad6-836b-a91ae289c8e5.png" />

```swift
struct ColorEffectSample: View {
    var body: some View {
        Image(.teaIcon)
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(width: 300, height: 300)
            .clipShape(RoundedRectangle(cornerRadius: 8))
    }
}
```

### Metal ファイルに定義した関数を呼び出してみる

まずは、Xcode のテンプレートから Metal File を選択して`.metal`ファイルを作成していきます。
Metal ファイルにSwiftUIから呼び出す関数を定義します。
`colorEffect`で使用する Sharder 関数は、[ドキュメント](<https://developer.apple.com/documentation/swiftui/view/coloreffect(_:isenabled:)#discussion>)に記載されている通りにする必要があります。

```c
[[ stitchable ]]
half4 colorEffectSample
(
 float2 position,
 half4 color
 ) {
    return color;
}
```

`colorEffect`で呼び出す関数は RGBA(赤、緑、青、透明度)を`half4`型として返すことで、色を変更することができます。
MetalはMetalが処理するUIのすべてのピクセルに対して、定義した関数の処理が実行されます。
つまりMetalはUIをピクセル単位で、制御することができます。
ピクセル単位で色などのUIを制御するため、デフォルトで存在する引数である、`position`、`color`を持っている。
`position`は処理するピクセルの座標を、`color`は処理するピクセルの色が入っています。

Xcodeで`colorEffect`の定義を見にいくと、以下のような説明がされています。

>
> [[ stitchable ]] half4 name(float2 position, half4 color, args...)
>
>where `position` is the user-space coordinates of the pixel applied to the shader and `color` its source color, as a pre-multiplied color in the destination color space. `args...` should be compatible with the uniform arguments bound to `shader`. The function should return the modified color value.

日本語訳
>ここで、`position` はシェーダに適用されるピクセルのユーザ空間座標であり、`color` はそのソースカラーを、出力カラースペースにおける乗算済みのカラーとして表します。`args...` は、`shader` にバインドされたユニフォーム引数と互換性がある必要があります。この関数は、変更されたカラー値を返します。

上記実装では、`color`をそのまま返して元の色から元の色に変更？しただけなので、結果的に何も変わらないという意味のない状態になっています。
`colorEffectSample`の中身は後ほどいじるので、ここでは一旦仮の処理としてこのような実装にしておきます。

Metal ファイルが実装できたら、SwiftUI の View に以下を足すことで、先ほど定義したMetalファイルで定義した関数を呼び出し、色の変更というEffectを付与することができます。

```diff_swift
struct ColorEffectSample: View {
    var body: some View {
        Image(.teaIcon)
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(width: 300, height: 300)
            .clipShape(RoundedRectangle(cornerRadius: 8))
+           .colorEffect(
+               ShaderLibrary.colorEffectSample()
+           )
    }
}
```

### `colorEffect`で画像を赤っぽくする

先ほどの Metal ファイルを以下のように修正してみます。
この関数で返した色が Effect として反映されるので、赤にしたいのであれば以下のように指定すれば良いです。

```diff_c
[[ stitchable ]]
half4 colorEffectSample
(
 float2 position,
 half4 color
 ) {
-    return color;
+    return half4(1.0, 0, 0, 1.0);
}
```

この変更を加えると、以下のように真っ赤になると思います。
なぜなら、画像のすべてのピクセルを問答無用で赤色に変更したためです。
<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/db6d1406-c8ee-4cc9-95de-536c44de17c6.png" />

このままでは赤っぽくではなく、真っ赤っかなので、少し改善しましょう。
元の色の RGBA 値に赤色の RGBA 値を掛け合わせることで、
元の色を赤色だけで表現した色になるので、元の画像のまま赤っぽさを表現できます。

```c
return half4(1.0, 0, 0, 1.0) * color;
```

<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/b2c755c9-09d6-4326-a41f-61db0658cc46.png" />

ここまでで、`colorEffect`を使って、Metal で色を変更する方法が何となくイメージついたら幸いです。

## レアカードの実装

Metal による色の変更がわかったので、続いて本命であるレアカードの実装を行っていきます。

### レアカード実装の方針

最初に紹介したレアカードのような Effect を実現するには、以下要件を実装する必要があります。

- 一定の周期で、光の線を左上から右下へ移動させる
- デバイスの傾きで光の加減を調整する
  - デバイスの傾きを取得する
  - デバイスを傾けた時に上に位置する部分を明るくする

### 一定の周期で、光の線を左上から右下へ移動させる実装

![レアカードの光沢Effect.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/e49aeb5b-60c4-4603-ba4d-a0cd09af3ca1.gif)

Metal のコード

```c
[[ stitchable ]] half4 rareCard
(
 float2 position,
 half4 color,
 float2 size, // Effectを付与するViewのサイズ
 float time // 経過時間
 ) {
    // -- ①正規化 --
    // 処理するピクセルの位置を0〜1へ変換
    float2 uv = position / size;
    // 左上 → 右下方向の座標を0〜1として取得
    // Metalでは左上端が(0,0)、右下端が(1,1)になるため、x+yを行うと0〜2の値を取ることになる
    // そのため、0.5を掛けて0〜1に変換している
    float diagCoord = (uv.x + uv.y) * 0.5;

    // -- ②時間の周期を調整 --
    // どのぐらいの速度、タイミングで、光の線が左上から右下へ移動するかどうかの値を算出する

    // 光の線が左上から右下へ移動する速度の定義(値を大きくすると速くなる)
    constexpr float speed = 0.6;
    // 経過時間 * speedの値を0〜1へループする値へ変換する
    // speedが高ければ高いほど、ループする速度が増す
    float t = fmod(time * speed, 1.0);
    // t(ループ速度)にSpring風に弾むようにglintPosが0→1へ変化するようにする
    // tをそのままglintPosに代入したら、一定の速度で移動するアニメーションになる
    float glintPos = 1.0 - exp(-4.0 * t) * cos(5.0 * t);

    // -- ③光の筋の強度を計算（中心で明るく、周辺で暗く） --

    // 光の線の横幅(0〜1の間で指定)
    constexpr float width = 0.3;
    // glintPos(光の線を表示する左上 → 右下方向の座標の値)からwidth(0.3)分離れた箇所を、光の強度を多めに設定する(0〜1)
    // smoothstep(edge0, edge1, x) は x が edge0〜edge1 の範囲に入ると 0→1 へ滑らかに増加する関数
    float intensity = smoothstep(glintPos - width, glintPos, diagCoord) *
    (1.0 - smoothstep(glintPos, glintPos + width, diagCoord));

    // -- ④元の色に光の線を加える --
    // intensityは処理中のピクセル位置が光の線を表示する部分に含まれている場合は、0 < x <= 1の値をとる
    // 逆に、光の線を表示する位置に含まれていないピクセルは、元の色を返す
    // 0.6は光の強度で、1.0に近づけると光の線はより白く表示される
    half3 glinted = color.rgb + half3(1.0) * half(intensity) * 0.6;
    return half4(glinted, color.a);
}
```

SwiftUI のコード

```swift
struct RareCardEffectView: View {

    @State private var startTime = Date.now

    var body: some View {
        TimelineView(.animation) { context in
            Image(.teaIcon)
              .resizable()
              .frame(width: 300, height: 300)
              .aspectRatio(contentMode: .fill)
              .visualEffect { [time = context.date.timeIntervalSince(startTime)] effect, proxy in
                  effect
                      .colorEffect(
                          ShaderLibrary.rareCard(
                              .float2(proxy.size),
                              .float(time)
                          )
                      )
              }
              .clipShape(RoundedRectangle(cornerRadius: 8))
        }
    }
}

#Preview {
    RareCardEffectView()
}
```

今回 Metal の関数ではデフォルトの引数である`position`、`color`の他に、サイズと経過時間の引数も必要になります。
その場合は、以下のように指定することができます。

```swift
.colorEffect(
  ShaderLibrary.rareCard(
      .float2(proxy.size),
      .float(time)
    )
)
```

サイズは`fload2`型なので、[`.float2(_ size: CGSize)`](<https://developer.apple.com/documentation/swiftui/shader/argument/float2(_:)-7uxd>)で指定します。
経過時間は、`flaot`型なので、[`float<T>(_ x: T)`](<https://developer.apple.com/documentation/swiftui/shader/argument/float(_:)>)で指定します。

また、サイズを取得するために、[`visualEffect`](<https://developer.apple.com/documentation/swiftui/view/visualeffect(_:)>)を使っています。

### デバイスの傾きで光の加減を調整する

#### デバイスの傾きを取得する

CoreMotion フレームワークで、デバイスの傾きが取得できます。
`CMMotionManager`の[`startAccelerometerUpdates`](<https://developer.apple.com/documentation/coremotion/cmmotionmanager/startaccelerometerupdates(to:withhandler:)>)メソッドで、[`accelerometerUpdateInterval`](https://developer.apple.com/documentation/coremotion/cmmotionmanager/accelerometerupdateinterval)で指定した秒数の間隔毎にデバイスの傾きを取得します。

![傾きの検証.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/3df3bec3-156b-4615-816d-b3d772a74c5f.gif)

```swift
import CoreMotion
import SwiftUI

struct MotionSampleEffectView: View {
    @State private var motionManager = CMMotionManager()
    @State private var x: Double = 0
    @State private var y: Double = 0

    var body: some View {
        VStack {
            controllerArea()
            Image(.teaIcon)
                .resizable()
                .frame(width: 300, height: 300)
                .aspectRatio(contentMode: .fill)
                .clipShape(RoundedRectangle(cornerRadius: 8))
        }
        .padding(.horizontal, 24)
        .onAppear {
            guard motionManager.isDeviceMotionAvailable else { return }
            motionManager.accelerometerUpdateInterval = 1.0 / 100 // 100Hz
            motionManager.startAccelerometerUpdates(to: .main) { data, _ in
                guard let acceleration = data?.acceleration else { return }
                x = acceleration.x
                y = acceleration.y
            }
        }
        .onDisappear {
            guard motionManager.isAccelerometerAvailable,
                  motionManager.isAccelerometerActive else { return }
            motionManager.stopAccelerometerUpdates()
        }
    }

    func controllerArea() -> some View {
        VStack {
            Slider(value: $x, in: -1...1)
            Slider(value: $y, in: -1...1)
            Text("x: \(x)")
            Text("y: \(y)")
        }
        .transition(.opacity)
    }
}
```

デバイスの傾きを取得する実装は以下を参考にしました。
- https://developer.apple.com/documentation/coremotion/getting-raw-accelerometer-events
- https://qiita.com/SNQ-2001/items/925567384e2783fb070a

### デバイスを傾けた時に上に位置する部分を明るくする

次に、取得したデバイスの傾きによって、傾きの上側を明るくする Effect を Metal で実装します。

![傾きと煌めきの検証.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/5a4cd971-f75b-4324-9fc7-83c3aefb7dae.gif)

Metal のコード

```c
[[ stitchable ]] half4 motionRareCard
(
 float2 position,
 half4 color,
 float2 size, // Effectを適用するViewのサイズ
 float2 acceleration // デバイスの傾き
 ) {
    // -- ①正規化 --

    // Metalではデフォルトでは、positionには左上端が(0,0)、右下端は(width,height)となっている
    // ピクセルの位置を、中心を0, 0として-1から1に正規化する
    float2 uv = (position / size) * 2.0 - 1.0;
    uv.y = -uv.y;

    // -- ②傾きによる光の強度を計算 --

    // x軸の傾きによる光の調整
    // 画面が左側へ向かうように傾けると、画面の右側が明るくなるようにする
    half3 horizontalLight = uv.x * acceleration.x * -1;
    // y軸の傾きによる光の調整
    // 画面が上側へ向かうように傾けると、画面の下側が明るくなるようにする
    half3 verticalLight = uv.y * acceleration.y * -1;

    // -- ③最終的な色を返す --
    // 最終的な色のrgbを計算
    // x軸とy軸の光の効果を元に色に足した色を返す
    // 元の色よりも明るさ(つまりrgb値が低い)場合は、元の色を返す
    half3 finalRgb = max(horizontalLight + verticalLight + baseColor.rgb, baseColor.rgb);
    return half4(finalRgb, baseColor.a);
}
```

SwiftUI の Sharder 適用部分

```swift
Image(.teaIcon)
  .resizable()
  .frame(width: 300, height: 300)
  .aspectRatio(contentMode: .fill)
  .visualEffect { [x, y] effect, proxy in
      effect
          .colorEffect(
              ShaderLibrary.motionRareCard(
                  .float2(proxy.size),
                  .float2(x, y)
              )
          )
  }
  .clipShape(RoundedRectangle(cornerRadius: 8))
```

## 最終的な実装

以下それぞれの実装ができたので、各 Effect を掛け合わせて、レアカードの最終的な実装を行います。

- 一定の周期で、光の線を左上から右下へ移動させる
- デバイスの傾きで光の加減を調整する

Metal のコード

```c
#include <metal_stdlib>
using namespace metal;

half4 getRareBaseColor
(
 float2 position,
 float2 size,
 half4 original,
 float time
 ) {
    // -- ①正規化 --
    // 処理するピクセルの位置を0〜1へ変換
    float2 uv = position / size;
    // 左上 → 右下方向の座標を0〜1として取得
    // Metalでは左上端が(0,0)、右下端が(1,1)になるため、x+yを行うと0〜2の値を取ることになる
    // そのため、0.5を掛けて0〜1に変換している
    float diagCoord = (uv.x + uv.y) * 0.5;

    // -- ②時間の周期を調整 --
    // どのぐらいの速度、タイミングで、光の線が左上から右下へ移動するかどうかの値を算出する

    // 光の線が左上から右下へ移動する速度の定義(値を大きくすると速くなる)
    constexpr float speed = 0.6;
    // 経過時間 * speedの値を0〜1へループする値へ変換する
    // speedが高ければ高いほど、ループする速度が増す
    float t = fmod(time * speed, 1.0);
    // t(ループ速度)にSpring風に弾むようにglintPosが0→1へ変化するようにする
    // tをそのままglintPosに代入したら、一定の速度で移動するアニメーションになる
    float glintPos = 1.0 - exp(-4.0 * t) * cos(5.0 * t);

    // -- ③光の筋の強度を計算（中心で明るく、周辺で暗く） --

    // 光の線の横幅(0〜1の間で指定)
    constexpr float width = 0.3;
    // glintPos(光の線を表示する左上 → 右下方向の座標の値)からwidth(0.3)分離れた箇所を、光の強度を多めに設定する(0〜1)
    // smoothstep(edge0, edge1, x) は x が edge0〜edge1 の範囲に入ると 0→1 へ滑らかに増加する関数
    float intensity = smoothstep(glintPos - width, glintPos, diagCoord) *
    (1.0 - smoothstep(glintPos, glintPos + width, diagCoord));

    // -- ④元の色に光の線を加える --

    // intensityは処理中のピクセル位置が光の線を表示する部分に含まれている場合は、0 < x <= 1の値をとる
    // 逆に、光の線を表示する位置に含まれていないピクセルは、元の色を返す
    // 0.6は光の強度で、1.0に近づけると光の線はより白く表示される
    half3 glinted = original.rgb + half3(1.0) * half(intensity) * 0.6;
    return half4(glinted, original.a);
}

half4 getMotionLightEffectColor
(
 float2 position,
 float2 size,
 half4 baseColor,
 float2 acceleration
 ) {
    // -- ①正規化 --

    // Metalではデフォルトでは、positionには左上端が(0,0)、右下端は(width,height)となっている
    // ピクセルの位置を、中心を0, 0として-1から1に正規化する
    float2 uv = (position / size) * 2.0 - 1.0;
    uv.y = -uv.y;

    // -- ②傾きによる光の強度を計算 --

    // x軸の傾きによる光の調整
    // 画面が左側へ向かうように傾けると、画面の右側が明るくなるようにする
    half3 horizontalLight = uv.x * acceleration.x * -1;
    // y軸の傾きによる光の調整
    // 画面が上側へ向かうように傾けると、画面の下側が明るくなるようにする
    half3 verticalLight = uv.y * acceleration.y * -1;

    // -- ③最終的な色を返す --
    // 最終的な色のrgbを計算
    // x軸とy軸の光の効果を元に色に足した色を返す
    // 元の色よりも明るさ(つまりrgb値が低い)場合は、元の色を返す
    half3 finalRgb = max(horizontalLight + verticalLight + baseColor.rgb, baseColor.rgb);
    return half4(finalRgb, baseColor.a);
}

[[ stitchable ]] half4 rareCard
(
 float2 position,
 half4 color,
 float2 size,
 float2 acceleration,
 float time
  ) {
    // 左上から左下へ、一定の間隔で光の線が移動するEffectを加える
    half4 baseColor = getRareBaseColor(position, size, color, time);
    // デバイスの傾きによって、傾きの上側が光が反射しているようなEffectを加える
    return getMotionLightEffectColor(position, size, baseColor, acceleration);
}
```

SwiftUI のコード

```swift
struct MotionEffectCardView: View {

    @State private var motionManager = CMMotionManager()
    @State private var x: Double = 0
    @State private var y: Double = 0
    @State private var startTime = Date.now

    var body: some View {
        TimelineView(.animation) { context in
            Image(.teaIcon)
              .resizable()
              .frame(width: 300, height: 300)
              .aspectRatio(contentMode: .fill)
              .visualEffect { [x, y, time = context.date.timeIntervalSince(startTime)] effect, proxy in
                  effect
                      .colorEffect(
                          ShaderLibrary.rareCard(
                              .float2(proxy.size),
                              .float2(x, y),
                              .float(time)
                          )
                      )
              }
              .clipShape(RoundedRectangle(cornerRadius: 8))
        }
        .onAppear {
            guard motionManager.isDeviceMotionAvailable else { return }
            motionManager.accelerometerUpdateInterval = 1.0 / 100
            motionManager.startAccelerometerUpdates(to: .main) { data, _ in
                guard let acceleration = data?.acceleration else { return }
                x = acceleration.x
                y = acceleration.y
            }
        }
        .onDisappear {
            guard motionManager.isAccelerometerAvailable,
                  motionManager.isAccelerometerActive else { return }
            motionManager.stopAccelerometerUpdates()
        }
    }
}
```

## おわり
いかがだったでしょうか！
Metalの中身の処理を理解するのは、少し苦労しますが、
今だとChatGPTなどに実装したいEffectのロジックを実装してもらうという方法で行ったので、
実装自体は想像していたよりもかなり簡単にできました。
また、画面の傾きによってEffectを変更する実装も、想像以上に簡単で驚きました。
この記事で、意外と簡単にMetalが使えることが、伝えられれば幸いです！

今回のレアカードの実装は、以下Githubリポジトリにもあるので、よければ参照ください。

https://github.com/stotic-dev/GraficsSmaple