UIにMetalが使えないかいろいろ試してみた話

こんにちは、LINEマンガiOSクライアント担当のMasaki Haga(@hagmas)と申します。この記事はLINE Advent Calendar 2017の9日目の記事です。

2014年になって初めて発表されたMetalですが、2017年になってさらに効率化、洗練され、VRのサポートも組み込まれたMetal2が発表されたり、Appleのさまざまなフレームワークの基幹部分にMetalが導入されていたりと、Metalへの注目がさらに高まりつつあります。そんなMetalに、みなさんは「Metalを触ってみたいけど、どこから、何から始めたらいいのかわからない」または「Metalに興味はあるんだけど、自分のアプリケーションにはいまいち関係なさそうだ」などと感じたことはないでしょうか。

私が担当しているLINEマンガのようなWebアプリケーションのiOSクライアントでは、ほとんどの場合、ゲームのような複雑な3D描写も大きなデータを並列計算で処理していくような場面もないかと思われます。しかし、「ユーザーにWOWと言わせるようなアプリ体験ってなんだろう」と考えている時、Metalのパワフルな並列計算と柔軟なAPIを使うことによって、UIKitでは実現できないような複雑で魅力的な描写をShaderで作りだして既存のUIにうまく溶け込ませることができれば、より新鮮なユーザー体験が実現できるかもしれないと思いたち、いろいろ試してみました。

ShaderでできることですのでOpenGLを使ってももちろん実現できますが、サンプルのプロジェクトなども複数掲載しておきましたので、それらを通してMetalのコーディングのしやすさや楽しさなど知ってもらえれば幸いです。

Metalを使った流体シミュレーション

このアニメーションは、Metalを使用してiPhone上でインタラクティブに流体シミュレーションを行ったサンプルアプリの画面キャプチャです。詳しくは「流体シミュレーションによる表現」を参照してください。

Metalとは

Metalは、「アップルのオペレーティングシステム上でサポートされる、オーバーヘッドの小さいローレベル(low level)なコンピュータグラフィックスAPI」です(ウィキペディアより抜粋)。汎用コンピュータグラフィックスAPIであるOpenGLに比べると、以下のようなさまざまな利点があります。

  • AppleのGPUハードウェアの性能をよりダイレクトに引き出すことができる。
  • グラフィックスと並列計算のための共通のモダンなAPIを持っている。
  • Appleの開発プラットフォームであるXcodeやInstrumentsと緊密に統合されているので、Shaderがアプリと同じタイミングでコンパイルされたり、GPU Frame Debuggerなどデバッグに便利なツールが整っていたりする。

発表当時はiOSのみの対応でしたが、その後macOSやtvOSでもサポートされ、Appleのさまざまなデバイスで使用できるようになりました。古いデバイスだと対応していないものがありますが、iPhoneに限っていえばMetalが使えるのはiPhone 5s以降のデバイスであり、iOS 11のサポートもiPhone 5s以降のデバイスですので、iOS 11のみサポートするアプリケーションであれば気兼ねなくMetalを使うことができます。

MetalでUIパーツを描く

Metalを使うといっても、ここで扱う例では3Dオブジェクトを扱うわけではないので、3Dプログラミングの知識は必要ありません。UIKitにより構成されるUIにGPUが得意とする並列計算で算出されたテクスチャを貼り付け、UIViewCALayerだけでは作ることが難しいUIを実現します。このようにUIパーツを作ると、以下のような利点があります。

  • コードの保守性が上がる。従来のUIKitで実現しようとすると複数のCALayerUIViewを組み合わせなければならず非常に複雑になってしまったり、大きなリソースを扱う必要があったりするもの、またはそもそも実現不可能なものをShaderだけで表現できるためです。
  • デザイナーの選択肢が増え、より魅力的なアプリを作ることができる可能性がある。

Metalでは、並列計算に使うCompute Shaderと、Vertex ShaderやFragment Shaderなどのグラフィックパイプラインに使うShaderを、同じMSL(Metal Shading Language)というC++をベースとした言語で記述できます。Compute Shaderは汎用性が高く、(後述のFragment Shader的な表現流体シミュレーションによる表現もCompute ShaderのKernel Functionとして記述されています)、テクスチャを算出する際により直感的にプログラミングできると感じました。

また、グラフィックパイプラインと並列計算で使うデータ型はMTLTextureMTLBufferといった共通のインターフェイスを持つので、MTLTextureに並列計算で何かしらのデータを記述し、それをグラフィックパイプラインにテクスチャとして渡す、または、グラフィックパイプラインを通さずにMTKViewCAMetalLayerのdrawableなテクスチャに直接書き込んで表示する、といったこともできます。

Fragment Shader的な表現

この節では、Compute ShaderによってどのようなUIパーツを描写できるのか、見ていきたいと思います。

既存のグラフィックパイプラインで使われるFragment Shaderでは、関数に渡される補間後の座標を元に各ピクセルの色が決定されますが、前述のように、Metalではグラフィックパイプラインを通さなくてもCompute ShaderのKernel Functionを使うだけで同じようなことができます。MTKViewがViewの内容を更新できるタイミングで、フレーム毎にMTKViewDelegatefunc draw(in view: MTKView)という関数が呼ばれますが、このタイミングで現在時刻と座標を元に各ピクセルの色を決定し、時間により描画内容を変えることでアニメーションさせます。

ここで使われているKernel Functionの基本形は次のようになります。

kernel void playgroundSample(texture2d<float, access::write> o[[texture(0)]],
                             constant float &time [[buffer(0)]],
                             constant float2 *touchEvent [[buffer(1)]],
                             constant int &numberOfTouches [[buffer(2)]],
                             ushort2 gid [[thread_position_in_grid]]) {
    o.write(float4(0.0), gid);
}

引数として、入力先のテクスチャ、現在時刻、ユーザーによるタッチイベントがある場合はその座標とタッチイベントの数、最後にそのピクセルの座標(テクスチャ左上を原点としたとき)をとります。関数の最後のo.write(float4(0.0), gid)の部分で、float4(0.0)の代わりにそのピクセルの色を書きこむというのが大まかな流れです。

この基本形から3つのUIパーツを作ってみました。残念ながら、これらの例はすべてGIFとして圧縮されているので画質が落ちていますが、実際のデバイス上(iPhone 6 Plus、iPhone 7、iPhone Xでテスト)では60FPSで動いています。

図形の描写

Shaderでは、直線、円、長方形などの基本的な図形を数式で書くことができます。この例では、丸角の長方形と横にスクロールするストライプ柄の背景を組み合わせてダウンロードメーターを作ってみました。サンプルコードは「参考:Downloading Iconの書き方」を参照してください。直線を描画するにしても、UIBezierPathのように各頂点を指定していくのではなく、対象のピクセルから描画しようとしている線までの距離を算出して色を変化させています。

downloading icon

有機的なアニメーションの描写

この例では有機的な動きをする輪郭を持ったポップアップを描画してみました。ポップアップの輪郭が波のような動きをしているのが見えると思います。ポップアップ中心から輪郭への距離をsin関数やcos関数を組み合わせることで変化させ、このような動きをつけることができます。ポップアップ下部のふわふわとしたアイコンは、GLSLSandboxというWebGLを使ってShaderを書くことができるサイトからお借りしてきました。

popup icon

ネオンのような光の表現

光源から各ピクセルまでの距離を計算してその距離によって光の強さを決定することで、ネオンサインのような表現もできます。ネオン特有のチカチカとした感じや時間経過によるオン/オフも簡単に作ることができます。

neon icon

これらの表現はShaderでできる表現のごく一部であり、アイデア次第で、より豊かでアプリの個性に合った表現ができると思います。

私個人のGitHubリポジトリで、ShaderViewというライブラリを公開しています。ShaderViewには、MTKViewを使うときに必要な、MTLLibraryMTLFunctionMTLComputePipelineStateMTLCommandQueueの生成、MTLCommandBufferへの各パラメータの設定などがラップされており、Shader関数プログラミングに集中できます。手っ取り早くMetalのShaderを使ってみたいときに向いていますし、ShaderViewが使えるPlaygroundもWorkspaceに入っていますので、ぜひダウンロードして遊んでみてください(よろしければスターを付けていただけるととてもうれしいです)。

https://github.com/hagmas/ShaderView

流体シミュレーションによる表現

次に、より複雑な、複数のテクスチャを用いたリアルタイムシミュレーションによる流体表現について紹介します。

流体シミュレーションにはいろいろな方法がありますが、ここではよりGPU上での並列計算に適したNavierStokes方程式によるものを紹介します。この例では実際に可視化される流体の色のテクスチャの他に、流体の速度、圧力、密度、温度のベクトル場やスカラー場をデータとして保存しておく複数のテクスチャを用いて、より複雑な表現を作り出しています。

シミュレーションにあたって、まず流体を非圧縮性で均一な流れと仮定し、以下のNavierStokes方程式により各時間(各フレーム)での速度場、圧力場を算出していきます。

navier stokes

左辺第一項が時間項、右辺第一項が移流項、第二項が圧力項、第三項が粘性項、第四項が外力項を表していますが、粘性項は今回のシミュレーションに含まれていません。大まかなシミュレーションの流れは、移流項、外力項を速度場に適応し、発散ありの速度場を算出、その後ポワソン方程式から圧力場の勾配を求め、発散なしの速度場から圧力場の勾配を引くことで発散なしの速度場を求める、となります。

速度場から流体の各量を移流させる時はもちろん、ポワソン方程式を解く際に用いられるヤコビ法という反復法はとてもGPUと相性がよく、CPUに比べて格段に効率よく計算を行うことができます。

詳しいNavierStokes方程式のGPU上への実装方法は「GPU Gems: Chapter 38. Fast Fluid Dynamics Simulation on the GPU」に詳しく載っていますので、興味のある方はぜひ参照してみてください。

下のGIFがこの流体シミュレーションを実際にUIに適応した例です。初期値としてCGImageからMTLTextureを生成し、NavierStokes方程式により算出された各フレームでの速度場により各ピクセルの色を移流させると、以下のようなアニメーションができあがります。

fluid simulation icon>

まずUIImageViewをBasicなUIViewの拡大アニメーションで表示した後、Shaderアニメーションを使って期限が切れたチケットが煙のようにふわっと消えていくようなアニメーションにしてみました。より「消えちゃった感」がでていて面白いアニメーションができたのではないかと思います。

この記事の冒頭に掲載したGIF画像のような、インタラクティブな流体シミュレーションをiOSデバイス上で遊べるデモをGitHub上に用意しました。実はこのデモが、チケットのアニメーションの元になっています。ソースコードも見られますので、ダウンロードして遊んでみてください(Metalの制約上、iPhoneシミュレーターでは動かず、実機でしか動きません。またこちらもスターを付けていただけるととてもうれしいです)。

https://github.com/hagmas/MobileFluidSimulation

この投稿ではMetalの基本的な使い方には触れていませんが、CPU側からGPU側への変数や関数といったデータの転送、MTKViewDelegatefunc draw(in view: MTKView)内でのMTLCommandQueueからMTLCommandBufferを作りコミットするまでの流れなどについては、上のMobileFluidSimulationやShaderViewを始め、Apple公式のサンプルプロジェクト(例:MetalGameOfLife)や他のオンラインリソースが参考になると思います。

最後に:Metalを使ってみて

ここまで、MetalのShaderを利用して描写したUIパーツの例を見てきましたが、今回初めてMetalを使ってみて、とにかく「使いやすい!楽しい!」という感想を持ちました。モダンな共通のインターフェイスでグラフィックスや並列計算のプログラミングができてしまうのはもちろん、Shaderへの変更内容をPlaygroundでただちに反映しながら試行錯誤できるなど、開発環境もとても恵まれていて、普通のアプリ開発にはない楽しさがありました。

残念ながらこれらの例をプロダクトで実際に使う機会はまだなく、また、実際にShaderプログラミングをアプリ開発に取り入れるのはチーム間での知識共有が必要になるなどかなりハードルが高いと思われますが、もし取り入れることができれば、よりユーザーにとってわかりやすい、「WOWな体験」を提供できる魅力的なアプリが作れるのではないかと思います。

Advent Calendar、明日の記事は高村さんによる「React Starter KitにみるWebフロントエンドに求められる機能と実装」です。お楽しみに!

参考:Downloading Iconの書き方

float stripe(float2 uv, float time, float width)
{
    float d = abs(fmod(uv.x+uv.y/tan(M_PI_H/3)-10*time, width*2)) - width;
    return smoothstep(width*0.5, width*0.5+2, abs(d));
}

float roundedRectangle(float2 uv, float2 pos, float2 size, float radius, float thickness)
{
    float d = length(max(abs(uv - pos), size) - size) - radius;
    return 1.0 - smoothstep(thickness, thickness+1, d);
}

float roundedFrame(float2 uv, float2 pos, float2 size, float radius, float thickness)
{
    float d = length(max(abs(uv - pos), size) - size) - radius;
    return 1.0 - smoothstep(thickness, thickness+1, abs(d));
}

kernel void progressBar(texture2d<float, access::write> o[[texture(0)]],
                        constant float &time [[buffer(0)]],
                        constant float2 *touchEvent [[buffer(1)]],
                        constant int &numberOfTouches [[buffer(2)]],
                        ushort2 gid [[thread_position_in_grid]]) {
    int width = o.get_width();
    int height = o.get_height();
    float2 res = float2(width, height);
    float2 coordinate = float2(gid) - res/2;
    
    // フレームの線の幅
    float thickness = 1.0;
    
    // フレームの角丸の半径
    float radius = 10;
    
    // フレームの横幅
    float2 size = float2(width/2*0.8, 0);
    
    // フレームの中心
    float2 center = float2(0);
    
    // 背景に使われるストライプの幅
    float liquidStripeWidth = 10;
    
    // ストライプを少しずつ右にスクロールさせます。時間とピクセルの座標から0.0~1.0の間での値を求め、mix関数によりメーター内部の色を決定します。
    float intensity = stripe(float2(gid), time, liquidStripeWidth);
    float4 liquidColor = mix(float4(0.4, 0.4, 0.4, 1.0), float4(0.2, 0.2, 0.2, 1.0), intensity);
    
    // 最終的なそのピクセルの色を代入しておくための変数です。
    // 各図形のパーツにそのピクセルが重なっているかどうかは上記のroundedRectangleやroundedFrameといった関数により
    // intensityとして算出し、mix関数で色を重ねていきます。
    float4 col = float4(0.0);
    
    float2 left = float2(-size.x, size.y);
    // ダウンロード進み具合を表す数値です。ここでは便宜上時間から算出していますが、実際はShaderへの引数として渡してもらいます。
    float percentage = (time - floor(time/10) * 10) / 10;
    float2 liquidCenter = mix(left, center, percentage);
    float2 liquidSize = size*percentage;
    
    intensity = roundedRectangle(coordinate, liquidCenter, liquidSize, radius, thickness);
    col = mix(col, liquidColor, intensity);

    float4 frameColor = float4(0.6, 0.6, 0.6, 1.0);
    intensity = roundedFrame(coordinate, center, size, radius, thickness);
    col = mix(col, frameColor, intensity);

    o.write(float4(col), gid);
}

Related Post