SwiftUIのdistortionEffect modifierでドラムロール変形シェーダー

# はじめに

この記事は、[Goodpatch Advent Calendar 2023](https://qiita.com/advent-calendar/2023/goodpatch) の16日目の記事です。

今年の WWDC にて導入された、Metal Shader を簡単に SwiftUI へ導入する modifier を試してみた話です。

[同僚が雨粒エフェクトの実装に取り組んでいた](https://goodpatch-tech.hatenablog.com/entry/raindrop_with_ios17)こと、さらに最近 [hackingwithswift.com](https://www.hackingwithswift.com) の Paul Hudson 氏がMetal Shaderのシェーダー集「Inferno」を発表したことがきっかけです。

https://github.com/twostraws/Inferno

こちらは、Metal Shader を扱うに置いて必要な MSL(Metal Shader Language) やシェーダーそのものの基礎知識、各シェーダー実装におけるアルゴリズムの解説に踏み込んでおり、非常に有益な教材となっています。

その中で、シェーダーで何を作ろうかと考えた際に、ドラムピッカー的な湾曲したエフェクトは、UI実装にあらたな手数を増やしてくれると思い、試してみることにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543004/8b832b22-19cc-f0e4-90c9-86a66d24d225.png)

# `distortionEffect` `colorEffect` `layerEffect` 

Metal Shaderを SwiftUI に適用する3つの modifier には、それぞれざっくりと以下のような特徴があります。

## `distortionEffect`

その名の通り表示対象を歪ませるもので、シェーダー関数のデフォルト引数からうけるピクセル位置をもとに、代わりにどの位置のピクセルを表示するか、定義するものです。

```c++
[[ stitchable ]] float2 myDistortionEffect(
                           float2 position, //デフォルト引数、操作対象ピクセルのローカル座標
                           ... /*追加定義の引数*/
                        )
{
    /* distortion 実装*/
    return float2(x, y); ////操作対象のピクセルに代わりに表示するピクセル座標X
}
```

もっとも単純な例だと、以下のように実装すると表示内容を任意px分並行移動させることになります。

```c++
[[ stitchable ]] float2 translate(
                           float2 position,
                           float2 translation
                        )
{
    //
    float x = position.x - translation.x;
    float y = position.y - translation.y;
    return float2(x, y);
}
```

```swift
Text("Lorem ipsum...")
   .distortionEffect(
      ShaderLibrary.default.translate
         .dynamicallyCall(
            withArguments: [.float2(100, 100)] //追加定義した引数 translation の指定
         ),
      maxSampleOffset: .zero
   )
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543004/3c441321-976e-9083-9c1f-28fee17ae550.png" width="320">

## `colorEffect`

こちらは、`distortionEffect` とは異なり、操作対象の位置とピクセルの色（RGBA）を受けて、代わりにどの色を表示するのか定義するものです。

```c++
[[ stitchable ]] half4 myColorEffect(
                           float2 position, //デフォルト引数、操作対象ピクセルのローカル座標
                           half4 color, // デフォルト引数、操作対象の色値
                           ... /*追加定義の引数*/
                        )
{
    /* color 変換実装*/
    return half4(r, g, b, a); ////操作対象のピクセルに代わりに表示するピクセル座標X
}
```

以下のように実装することで横方向に対して虹色に色相変換します。

```c++
[[ stitchable ]] float2 rainbow(
                           float2 position,
                           half4 color,
                           float2 size, //Viewサイズ
                        )
{
    half hue = 255.0 * position.x / size.x;
    hsv hsv = rgb2hsv({color[0], color[1], color[2]});
    rgb rgb = hsv2rgb({hue, hsv.s, hsv.v}); //色相のみ変換
    return half4(rgb.r, rgb.g, rgb.b, color[3]);
}
```

```swift
Text("Lorem ipsum...")
   .foregroundStyle(Color.red)
   .frame(width: 300, height: 200)
   .colorEffect(
      ShaderLibrary.default.rainbow
         .dynamicallyCall(
            withArguments: [.float2(300, 200)] //追加定義した引数 size の値指定
         )
    )
```

RGB、HSV変換の関数は以下から拝借しました。
[Algorithm to convert RGB to HSV and HSV to RGB in range 0-255 for both](https://stackoverflow.com/questions/3018313/algorithm-to-convert-rgb-to-hsv-and-hsv-to-rgb-in-range-0-255-for-both)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543004/57b62296-45d7-b0b0-d82b-5e93c3017df1.png" width="320">

## `layerEffect`

もっともシェーダー実装において自由度が高く、表示対象の任意のピクセルから色を抽出し、`distortionEffect` と `colorEffect` の両方を一度に処理することができます。

```c++
[[ stitchable ]] half4 myColorEffect(
                           float2 position, //デフォルト引数、操作対象ピクセルのローカル座標
                           SwiftUI::Layer layer, // デフォルト引数、表示対象のレイヤ
                           ... /*追加定義の引数*/
                        )
{
    /* 変換実装*/
    half4 xyPixelColor = layer.sample(x, y); //任意の座標(x, y)の色値を抽出
    return half4(r, g, b, a); ////操作対象ピクセルに代わりに表示するピクセル座標X
}
```

こちらの事例は、前述の[Inferno](https://github.com/twostraws/Inferno)に溢れていますので、ぜひ参考にしてみてください。（手抜き）


# ドラムロール変形を作ってみた

ドラムロールの変形においては、元の表示内容を筒状に歪ませるだけなので、`distortionEffect` で十分です。

この程度の変形なら探せばいくらでも転がってそうですが、頭の体操として自分でも考えてみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543004/8afb6d80-cede-cea9-c384-ef5107fb00dd.png)


$ D = \frac{2H}{\pi} $
$ h = y^{0}-\frac{H}{2} $
$ cos\theta = \frac{2h}{D} $
$ \theta = arccos\frac{2h}{D}\quad(0>h),\quad arccos\frac{2h}{D}+\frac{\pi}{2}\quad(0\leq h) $
$ y^{s} = \frac{D\theta}{2} $

これを、`.metal`ファイルを作成して実装します。

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

[[ stitchable ]] float2 drumroll(float2 position, float2 size) {
    float x0 = position.x;
    float y0 = position.y;
    float pi = M_PI_F;
    float H = size.y;
    float D = 2 * H / pi;

    float h = y0 - H * 0.5;
    float theta = acos(2 * h / D);
    if (h < 0) {
        theta += pi * 0.5;
    }
    
    float xs = position.x;
    float ys = H - D * 0.5 * acos(2 * h / D);
    
    return float2(xs, ys);
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543004/7474e67d-f725-548b-e0c6-e6057371e9f0.png" width="320">

これだけでは縦方向のみ圧縮されますが、実際は横方向も奥行きに従って湾曲するので、`xs`の算出を以下とすることでそれっぽくなりました。（奥行きに従った横方向の変形は、透視投影変換などより良い方法があったようには思いますが、今回は正弦波をかけるだけとしました。）

```c++
float deltaX = (x0 - size.x * 0.5) / sin(theta);
float xs = size.x * 0.5 + deltaX;
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543004/c2cf736d-ef6a-8c8d-d245-99f5286a67a8.png" width="320">

# おわりに

とっつきづらい、その機会もなかった Metal Shader ですが、基本的な概念はわかった気がしました。これを活かして、UIの表現の幅を広げていきたいところです。