SwiftUIでメタルシェーダーのエフェクト使ってみた

2024/12/23に公開

この記事は株式会社ガラパゴス(有志) Advent Calendar 2024の23日目の記事です。

前書き

こんにちは、@samechaaaです。
2024年7月からモバイルアプリエンジニアとしてガラパゴスにジョインしました。
初めて技術記事というものを書くので読みづらい点が多々あると思いますが、遠慮なくご指摘いただけると幸いです。

導入

最近、SwiftUIのVisualEffectにてメタルシェーダーを使えるmodifierがある、ということを知りました。
Metalをガッツリ書くとなると敷居が高いですが、シェーダーだけならいけそうなのでちょっと触ってみるか〜という感じで触ってみました。

基本

大体のことは公式で紹介されている通りですね。

実践

どんな効果をつけるか迷いますが、とりあえず簡単なところでDissolve効果を作成してみます。
いい画像がなかったのでiOSDC2024の弊社のブースの画像を使います。
弊社ブース

ここにいらすとやさんの迷彩柄の画像を使ってフィルターします。
迷彩

シェーダーコードは以下の通り。
ビューのピクセル位置に対応する色を迷彩画像からサンプリングし、グレースケールを算出します。
グレースケールが閾値(threshold)を下回ったらそのピクセルは白に置き換える、というコードです。

#include <metal_stdlib>
using namespace metal;

constexpr sampler textureSampler (mag_filter::linear, min_filter::linear);

[[ stitchable ]] half4 Dissolve(float2 position, half4 color, texture2d<float> texture, float threshold) {
    const float4 colorSample = texture.sample(textureSampler, position);
    const float y = 0.299 * colorSample.r + 0.587 * colorSample.g + 0.114 * colorSample.b;
    if(y < threshold) {
        return half4(0, 0, 0, 0);
    } else {
        return color;
    }
}

UI側では閾値をスライダーで変更できるようにしました。

struct ContentView: View {
    @State var threshold: Float = 0.0
    let texture = Image(.imageTexture)
    
    var body: some View {
        VStack {
            Spacer()
            Slider(value: $threshold, in: 0.0...1.0)
            Image(.imageBooth)
                .resizable()
                .scaledToFit()
                .visualEffect { [threshold] content, proxy in
                    content.colorEffect(ShaderLibrary.Dissolve(
                        .image(texture), .float(threshold)))
                }
            Spacer()
        }.padding(.all, 10)
    }
}

結果


あれ?

修正

色々調べたりいじったりした結果、シェーダー側に渡っているpositionはピクセル位置であり、texture coodinateのように0.0~1.0ではないということが判明しました。
ということで各コードを修正。
ビューのサイズを使ってピクセル位置からサンプリング位置(0.0~1.0)を求めるように修正しました。

シェーダーコード

#include <metal_stdlib>
using namespace metal;

constexpr sampler textureSampler (mag_filter::linear, min_filter::linear);

- [[ stitchable ]] half4 Dissolve(float2 position, half4 color, float2 size, texture2d<float> texture, float threshold) {
+ [[ stitchable ]] half4 Dissolve(float2 position, half4 color, texture2d<float> texture, float threshold) {
+    position.x = position.x / size.x;
+    position.y = position.y / size.y;
    const float4 colorSample = texture.sample(textureSampler, position);
    const float y = 0.299 * colorSample.r + 0.587 * colorSample.g + 0.114 * colorSample.b;
    if(y < threshold) {
        return half4(0, 0, 0, 0);
    } else {
        return color;
    }
}

SwiftUI側ではサイズを渡すように修正します。

Image(.imageBooth)
    .resizable()
    .scaledToFit()
    .visualEffect { [threshold] content, proxy in
        content.colorEffect(ShaderLibrary.Dissolve(
-            .image(texture), .float(threshold)))
+            .float2(proxy.size), .image(texture), .float(threshold)))
        }

最終結果

想定通りの効果がつきました!
最終結果

参考

まとめ

GPUやMetalなどのローレベルな領域となると敷居が高いですが、ちょっと豪華なエフェクトつけたいな〜ぐらいであれば少しシェーダーを書くだけで済むので入りやすそうです。

株式会社ガラパゴス(有志)

Discussion