iOS 17 天気アプリの雨粒演出を作ってみた

こんにちは!エンジニアの藤井(touyou)です!

ついに2023年9月19日、iOS 17がリリースされましたね🙌

連絡先がiPhoneを近づけるだけで交換できる機能など、コミュニケーション面でのアップデートが目立った今回のアップデートでしたが、みなさんは何に注目したでしょうか?

弊社ではiOS/iPadOS 17の天気アプリに追加された、あるマイナーアップデートが注目を集めました。 それがこちらです。

かなりわかりにくいのですが、以前からあったパネルの上部に雨粒が上から当たって跳ねるアニメーションに加えて、パネルの裏側に水滴がついて滴るような演出が追加されています。

切り抜いて明るくしてみるとこのような感じです。 とてもリアルな表現になっていますね!

これはやってみたい!

ということで、こちらをSwiftUIで再現してみようと思い立ちました。 そこでこの記事では、再現に至るまでにしたこととその中で使っている技術について紹介していこうと思います。

iOS 17の新機能、layerEffect

今回肝になったのはiOS 17から使えるようになったSwiftUIでシェーダー利用をするための機能の一つ、layerEffectです。

iOS 17には他にもcolorEffectdistortionEffectが追加されており、これによってSwiftUIでMetalのシェーダーを使うハードルがグッと下がりました。

その中でもlayerEffectというのは、エフェクトをかけるViewの中で注目しているピクセルとは別の場所のピクセル情報を使って計算をするようなシェーダーに対応した機能になります。 colorEffectやdistortionEffectはそれぞれ色や位置に特化したエフェクトなので、雨粒エフェクトのような複雑なエフェクトを実現するにはlayerEffectが妥当であると考えました。

SwiftUI側の使い方は非常に簡単で、ShaderFunctionというシェーダー関数を読み込んだ構造体を作ったら、あとはlayerEffect modifierに渡してあげるだけで動きます。具体的には次のような形です。

struct ContentView: View {
    let shaderFunction = ShaderFunction(library: .bundle(.module), name: "someShader")

    var body: some View {
        Rectangle()
            .fill(.red)
            .frame(width: 300, height: 200)
            .layerEffect(
                Shader(function: shaderFunction, arguments: [.float(10)]), 
                maxSampleOffset: .zero
           )
    }
}

ShaderFunctionのlibraryにはMetalのファイルが入っている場所に応じた値を渡します。 上のコードはSPM化した同じフォルダ内に入れた場合で、他にもMetalファイルとSwiftコードの管理には様々な方法があると思うので、その他の書き方は公式のセッションなどを確認してください。

そしてShaderのargumentsという引数に渡しているのがシェーダーの関数に渡す引数に対応します。

これらによって様々なMetalシェーダーをmodifierの形でSwiftUIのViewに適用できます。

あわせて便利になったMSLの書き方

続いて肝心のMetalの書き方、すなわちMetal Shading Language(以下MSL)の書き方について紹介します。

iOS 17より前まであった、MetalKitを活用して読み込めるコードは次のような書き方でした。

#include <metal_stdlib>
#include <CoreImage/CoreImage.h>
using namespace metal;

[[stitchable]] float4 makeBlackTransparent(coreimage::sample_t sample, float threshold) {
    float4 filtered = (sample.r < threshold && sample.g < threshold && sample.b < threshold) == true ? float4(0) : float4(sample.r, sample.g, sample.b, sample.a);
    return filtered;
}

こちらは閾値以下の色を透過するというシェーダー関数ですが、ここからさらに改変していくためにはMetalKitの使い方はもちろんのこと、Core Imageの理解であったりかなり幅広い知識が求められていきます。 また独特のインターフェイスで、世間で多く使われているOpenGLなどのインターフェイスと若干の違いがある上、MSL自体もかなりアップデートが入っているということもあり、ネット上に参考資料が極端に少なく学習が難しいという課題がありました。

一方同じシェーダーを今回のSwiftUI用インターフェイスに直したものが以下です。

#include <metal_stdlib>
using namespace metal;

[[stitchable]] half4 makeBlackTransparent(float2 position, half4 color, float threshold) {
    half4 filtered = (color.r < threshold && color.g < threshold && color.b < threshold) == true ? half4(0) : half4(color.r, color.g, color.b, color.a);
    return filtered;
}

ポイントとしては引数の名前です。positioncolorと一目で役割のわかるインターフェイスになりました。 こちらはcolorEffectの例ですが、他でも同様です。

今回の記事の主役であるlayerEffect用シェーダー関数で、最低限の部分のみを書いたものは以下のようになります。

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

[[ stitchable ]] half4 someShader(float2 position, SwiftUI::Layer layer) {
    half4 result = layer.sample(position);
    return result;
}

layerという引数が加わっていることで、あるpositionのシェーダー関数から別のpositionのシェーダーの色情報を持ってくることが可能になります。 もちろん似た機能自体は以前にもあったのですが、全体的にかなりわかりやすくなったというのが今回のアップデートの一番のポイントでした。

雨粒シェーダーを作ってみよう

前提となる新機能は以上で、早速本題となる雨粒シェーダーの実装に取り組んでいきましょう。

といってもゼロから実装できるほど自分はシェーダー力があるわけではないので、すでに世の中にある雨粒シェーダーの移植という形で挑戦します。 今回もとにしたのはShadertoyに公開されており様々なプラットフォームで移植版が実装されているこちらです。

www.shadertoy.com

そして実際に移植したものが以下のコードになります。

細かい仕組みは解説できないので、今回改変したところに絞って紹介していきます。

座標の正規化

まず一個目は座標の正規化部分です。シェーダーでは処理を単純にするため、座標を一度解像度で割ることで正規化するというお決まりの流れがあります。

今回はこちらの記事などを参考に、boundingRectというものを活用しました。 これを計算しやすくするために以下のようなextensionをSwiftUI側で実装しています。

extension View {
    func rainDropEffect(function: ShaderFunction, seconds: Double, alpha: Double, zoom: Double, value: Double) -> some View {
        self.layerEffect(
            Shader(
                function: function,
                arguments: [.boundingRect, .float(seconds), .float(alpha), .float(zoom), .float(value)]
            ),
            maxSampleOffset: .zero
        )
    }
}

そしてシェーダー側では以下のように書いています。

float2 uv = (position - .5 * bounds.zw) / bounds.w * float2(1.0, -1.0);
float2 UV = position / bounds.zw * float2(1.0, -1.0) + float2(1.0, 0.3);

何やら追加で足し算などが増えていますが、移植する際にポイントとなるのは* float2(1.0, -1.0)の部分です。 iOSなどで使われる座標系とOpenGLなどで使われている座標系は上下が反転しているため、それを補正しています。 これは昔からある差異で、こちらの記事でも言及されていますが、今回は1を引く対応だとうまくいかなかったので愚直に正負を反転しています。

sample関数の差異

続いて考えなければいけないのが、元のコードにあるtextureLodの存在です。 これはほとんどのシェーダー言語に存在する機能で、LodというのがLevel of detailの略になっています。

その名の通り詳細度を決めてテクスチャの情報をとってくるという機能で、具体的なアルゴリズムが結局見つけられなかったのですが、おそらく詳細度が低いほどざっくり隣接したピクセルの平均値をとってくるようになるといった挙動をするのだと考えています。 実際参考にしているShadertoyでtextureLodに渡しているfocusを1.0の値に置き換えると完全に透過したガラスに水滴がついているような表現を見ることができます。

これを再現する上で、元からあったMetalKitを活用する方のMSLには対応する関数があるのですが、今回はそれがlayerEffectに渡すMSL単体では存在しないということがわかりました。 その証拠としてlayerEffectのドキュメントにははっきりとSwiftUI::Layerという構造体が以下のようなsample関数一個のみを持ったものであると明記されています。

namespace SwiftUI {
  struct Layer {
    half4 sample(float2 position) const;
  };
};

ではtextureLod相当の機能がないのか?ということなのですが、これに関して自分はSwiftUI側にあるmaxSampleOffsetという引数がこれを担っているのではないかと予想しています。引数の説明自体は他のピクセルから色をとってくるときにそのピクセルまでの距離を制限すると書かれているのですが、これが使い方次第では詳細度の役割になるのではないかという予想です。 こちら実際のところどのような想定で作られているのかは探してみたのですが見つけられなかったので、詳しい方がいればぜひご教授いただきたいです。

さて、対応するものがあるかもしれない、というところまではわかったのですが、今回はシェーダー内で計算した値を詳細度に渡す必要があるため、このままだと再現できません。 ただ曇りガラス風にするのは必須ではないですし、必要に応じて同じくiOS 17から追加されたMaterialを使うことで簡単に曇りガラス風の表現を加えることができるため、今回は諦めることにしました。

最後の調整

というわけで大まかに書き換えたところで最後の調整です。 前項でも触れた通り、今回はtextureLodの再現を諦めていたりといくつかオリジナルのコードからずれている部分もあり、そのままではいい感じの水滴が表現できませんでした。

そこで以下のようにSwiftUIのコードを組んで、valueを様々な部分の値に適用しながらSliderを動かしてちょうどいい表現というものを探っていきました。 載せているMetalのコードをよく見ると、最後の方で本来詳細度として使うfocusをなぜかnにかけて使っています。これは本来のアルゴリズムからすれば意味不明な使い方なのですが、Sliderでの実験をした結果ここに使うといい感じになりそうという所感が得られたためにこのようにしています。

public struct RaindropView: View {
    @State var value: Double = 1.0
    @State var isSetting: Bool = false
    let shaderFunction = ShaderFunction(library: .bundle(.module), name: "raindrop")
    let start = Date()
    
    public init() {}
    
    public var body: some View {
        TimelineView(.animation) { context in
            ZStack {
                Assets.image("shaderBackground")
                    .resizable()
                    .scaledToFill()
                    .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
                VStack(spacing: 32) {
                    ZStack {
                        Assets.image("shaderBackground")
                            .frame(width: 300, height: 200)
                            .clipped()
                            .rainDropEffect(function: shaderFunction, seconds: context.date.timeIntervalSince1970 - start.timeIntervalSince1970, alpha: 1.0, zoom: 0.1, value: value)
                            .opacity(0.8)
                        Text("Hello iOS17")
                            .font(.title)
                            .bold()
                            .multilineTextAlignment(.center)
                            .foregroundStyle(.white)
                            .padding()
                    }
                    .frame(width: 300, height: 200)
                    .clipShape(RoundedRectangle(cornerRadius: 8.0))
                    .blendMode(.normal)
                    if isSetting {
                        HStack {
                            Text("\(value)")
                            Slider(value: $value, in: -3.0...3.0)
                        }
                    }
                }
            }
            .onTapGesture {
                isSetting.toggle()
            }
        }
        .ignoresSafeArea(.all)
    }
}

extension View {
    func rainDropEffect(function: ShaderFunction, seconds: Double, alpha: Double, zoom: Double, value: Double) -> some View {
        self.layerEffect(
            Shader(
                function: function,
                arguments: [.boundingRect, .float(seconds), .float(alpha), .float(zoom), .float(value)]
            ),
            maxSampleOffset: .zero
        )
    }
}

というわけで力技や理解不足な点もありつつ、最終的に落ち着いた実際の画面がこちらです。

完全再現という形ではないですが、なかなかおしゃれな表現に仕上がったのではないでしょうか。

余談ですが使用した写真は自分のGoogle Photosでrainと検索し、もっとも雰囲気が出そうな写真を選びました。Google PhotosのAIも便利になってますね💪

まとめ

今回はiOS 17から導入されるSwiftUIへのシェーダーの適用の仕方などを紹介し、それを活用して雨粒シェーダーを実際にSwiftUIで実現した過程について紹介しました。

かなりリアル感があり、場合によってはスキューモフィズムの再来ではないか、という見方もできるこのような演出。 個人的にはこれはやはりApple Vision Proの登場というところが大きく関わってきているのではないかなと考えています。

Apple Vision Proによって今後もっとUIが現実世界に溶け込む表現というのが重視されていく、そんな流れの一環なのではないでしょうか。

先日発表のiPhone 15 ProではApple Vision Proで見れる空間ビデオの収録が可能になると紹介されるなど、発売が近づくにつれてますますAppleの空間コンピューティングの開拓は加速していっています。 そんな世の中を楽しみにしつつ、こうしていろんな表現にチャレンジしていきたいですね。

ここまで読んでいただきありがとうございました。それでは最後に一言。

Hello iOS 17!!


Goodpatchではデザイン好きなエンジニアの仲間を募集しています。 少しでもご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう!


2023年10月20日追記:完全版

こちらの記事を読んでいただいたチャネルトークの開発者の方から、textureLodをうまく再現できない問題についての解決方法を教えていただきました。

その方によるとSwiftUI::Layerにあるsample関数の実装で、samplerのaddress::clamp_to_edgeというオプションをつけないままsamplerを固定しているためにlayerEffectで画像サイズを取得できないという問題があるようです。 そのため自分でsamplerを用意し、layer.texでtextureに直接アクセスして適用することで問題を解決できるということです。

実際にやってみたコードがこちらです。

エフェクトは上下反転していて、画像は正しい向きになっているという現象が発生したので先に上下を反転しています。 また、実装したところ参考記事にあった1から引くという対応がうまく動くようになったので、そちらを反映するようにしました。 そして出来上がったものがこちらになります。

ぼかしがうまくかからなかったですが、かなりやりたかったところに近しいようになったのかなと思います。 これから挑戦する人はぜひ試してみてください。