LoginSignup
6
7

More than 3 years have passed since last update.

UIKit+Metal でちょっとリッチなアニメーションをする背景を作ってみる

Last updated at Posted at 2021-04-17

 UIKitベースのアプリで、ちょっと変わったアニメーションを背景にしたアプリをたまに見かけます。個人的に使うMVNOのアプリではこんな感じのアニメーションが使われています。
demo.gif

 UIKitのアニメーション機能だけで実装するのは大変そうです。

 この記事では、シェーダーでUIKitのアニメーション機能だけでは表現が難しいアニメーションを実装する方法を紹介します。
(ちなみに、某MVNOのアプリがシェーダーで実装してるのかどうかはわかりません)。

次のようなサンプルプログラムをGithubに置いています。
demo.gif

Shader Artの世界

 シェーダー(GLSL)で美しいアニメーションを作る天才さんたちが数多くの作品を公開する世界があります。例えば、次のサイトに行くと美麗なアニメーションを見ることができます。

 こんなアニメーションを作り出すのは大変だと思いますが、今回作成したサンプルもようなちょっと画像を歪めるアニメーションをさせるくらいなら、難しくありません。

MSL(Metal Shading Language)

 サンプルはAppleのMSLというシェーダー言語で記述しています。C++をベースにした言語で、独特な修飾子が使われるので戸惑う部分もありますが、この記事では「そんな部分もあるのね。」と、一旦脇に置いて、描画ロジックに着目します。

 ちなみに、MSLはGLSLとそっくりなので、こちらの記事『Metalシェーダことはじめ - WebGL/GLSLの豊富なサンプルを参考にMSLを書く』で紹介されているように、主に型名を変換するくらいでGLSLから移植できます。

シェーダーで星を回転させてみる

それでは、シェーダーアニメーションの手始めとして星の回転アニメーションを実装します。

まず、円を書いてみます。
サンプルプログラムの shader.metalファイルにある circleShader() に次のように記述します。

shader.metal
fragment half4 circleShader(ColorInOut in [[ stage_in ]]) {
    float2 uv = in.texCoord;
    // 縦方向でビューのアスペクト比を調整
    uv.y *= in.aspectRatio;
    // 左上(0,0)からの距離を取得
    float r = length(uv);
    // step()関数を使って、rが0.1以下なら 1.0、大きければ 0.0 に二値化
    half color = step(r, 0.1);
    // RGBA の順番。Redだけ色を指定する
    return half4(color, 0.0, 0.0, 1.0);
}

circle1.png

左上に1/4の赤い円が表示されました。

  • in.texCoord にはこれから描画する点の座標が格納されています。
  • シェーダーは描画する点ごとに呼び出されます。
  • Metalは左上が(0, 0)で右下が(1, 1)です。
  • in.aspectRatioで、これから描画する点のy座標を変更しています。これはシェーダーで描画するサイズがViewのサイズに関係なく0.0~1.0の範囲(座標系)なので、Viewのアスペクト比を使って描画が潰れないように調整するためのものです。
  • 円は中心からの距離が同じ位置が境界となるので、length(uv) でこれから描画する点と(0, 0)との距離を計算しています。
  • step(r, 0.1)で、半径が0.1以下の場合に color に 1.0 を設定します。
  • half4(color, 0.0, 0.0, 1.0) で描画する点の色を作ります。RGBAの順番なので、ここでは赤だけ設定しています。half というのは見慣れない型ですが、これは16bitの浮動小数点型で、色を扱うには十分な精度があることからAppleのサンプルでもよく使われています。

補足:length()step() はMSL組み込みの関数です。MSLではないですがこちらの記事『GLSLについてのメモ』が参考になります。

次に、円をビューの中心に表示させます。

shader.metal
fragment half4 circleShader(ColorInOut in [[ stage_in ]]) {
    float2 uv = in.texCoord;
    uv -= 0.5;  // この1行を追加

circle2.png

円が中心に表示されました。

  • 左上が(0, 0)だったので、(0.5, 0.5)の位置が原点になるように、uv内のx, yともに0.5引きます。一瞬、引くんじゃなくて足すんじゃない?と思うかもしれません。私もそう思いましたが、Metalが(0.5, 0.5)の座標を描画しようとした時に、「そこは(0.0, 0.0)として計算したい!」ので、0.5を引きます。

次にこの円を星形にしてみます。

shader.metal
fragment half4 circleShader(ColorInOut in [[ stage_in ]]) {
    float2 uv = in.texCoord;
    uv -= 0.5;
    // 縦方向でビューのアスペクト比を調整
    uv.y *= in.aspectRatio;
    // 左上(0,0)からの距離を取得
    float r = length(uv);

    // ここから -----------------------
    // 中心から描画する点の角度を求める
    float theta = atan2(uv.y, uv.x);
    // sin関数で1周で5つの山谷を作ります
    float wave = sin(5 * theta);
    // 中心からの距離に、上記で作った波の値を小さめにして(0.02とかかけて)足しこみます。
    // これで中心からの距離が波の高さ分変わるので、星っぽくなります。
    r += wave * 0.02;
    // ----------------------- ここまで追加

    // step()関数を使って、rが0.1以下なら 1.0、大きければ 0.0 に二値化
    half color = step(r, 0.1);
    // RGBA の順番。Redだけ色を指定する
    return half4(color, 0.0, 0.0, 1.0);
}

circle3.png

星形になりました。

  • 中心からの距離を変化させることで星形にします。
  • 変化は正弦波を使います(山谷がつくれれば正弦波でなくても良いです)。
  • 正弦波に与える角度を atan2(uv.y, uv.x) で計算します。描画する点が中心位置からどの角度にあるのかを求めています。

次に星を回転アニメーションさせてみます。
追加・修正するのは次の2箇所だけです。

shader.metal
    float time = in.u_time / 10;  // 追加
    float wave = sin(5 * theta + time);  // time を足しこむ

demo.gif

星が回転しました(動画の上から2番目の部分)。

  • 正弦波に与える角度に連続して変化する値(in.u_time)を追加しています。これで波が起きるのでアニメーションすることになります。
  • in.u_timeはフレームが描画される(iPhoneの場合、60fpsで描画)ごとに1増える値です。10で割って回転速度を調整します。

冒頭の「たぷたぷしたアニメーション」はこの星のアニメーションを組み合わせて実現しました。

ShaderArtView.swift

サンプルプログラムでは、面倒なMetal部分のセットアップを書かずに、作成したシェーダー名を設定するだけでUIViewと同様に扱える ShaderArtView を用意しています。

こんな感じで使えます。

ViewController.swift
let artView = ShaderArtView()
artView.setupView(shaderName: "circleShader")
view.addSubview(artView)

MTKView を継承しているので、isPaused でアニメーションを止めたり、preferredFramesPerSecond を設定することでフレームレートの変更が可能です。

画像を波うたせてみる

次に任意の画像をシェーダーに与えて、アニメーションをさせてみます。
冒頭のGIFアニメーションにある「波打つアニメーション」は画像を与えて、それに波打つ加工をしています。

shader.metal
fragment half4 waveShader(ColorInOut in [[ stage_in ]],
                          texture2d<half> texture [[ texture(0) ]]) {
    // 1秒に1すすむ値にする
    float time = in.u_time / 60;
    // 中心が(0, 0)になるように座標変換
    float2 uv = in.texCoord - 0.5;
    // 縦方向でビューのアスペクト比を調整
    uv.y *= in.aspectRatio;
    // 中心から描画する点の角度を求める
    float theta = atan2(uv.y, uv.x);
    // 中心から描画点までの距離
    float r = length(uv);
    // 中心からの距離に正弦波で算出した値を加える(背景画像の取得位置を変更する)
    r += sin(-r * 20 + time * 3) * 0.05;
    // 中心から上記で計算した距離の色を取得
    float2 distR = float2(r * cos(theta), r * sin(theta)) + 0.5;
    half4 colorSample = texture.sample(s, distR);

    return colorSample;
}

前半は星の描画と同様ですが、後半の次の部分が異なります。

shader.metal
    // 中心から上記で計算した距離の色を取得
    float2 distR = float2(r * cos(theta), r * sin(theta)) + 0.5;
    half4 colorSample = texture.sample(s, distR);

やっていることは、

  • 与えられた画像から色を取得する。
  • 色を取得する位置は、これから描画しようとしている位置の座標ではなく、そこからズレた位置の座標から取得する。これにより画像を歪ませる。

です。

  • float2(r * cos(theta), r * sin(theta)) + 0.5 の部分が、中心からの距離と角度を使って、色を取得する位置を計算している部分です。r は、sin(-r * 20 + time * 3) * 0.05 で時間により増減する値になっているので、その分の距離だけズレた位置になります。
  • texture.sample(s, distR) は画像から色を取得する関数です。上記で計算したズレた位置にある色を取得します。

ちなみに、この画像はどこで受け取っているかと言うと、この部分です。

fragment half4 waveShader(ColorInOut in [[ stage_in ]],
                          texture2d<half> texture [[ texture(0) ]]) {

この [[ stage_in ]][[ texture(0) ]] 部分が??となると思います。[[ stage_in ]] は「描画する点の位置や時刻の情報が入っている」という目印で、[[ texture(0) ]] が「CPU側とGPU側で画像の受け渡しに使う」という目印で、複数の画像(テクスチャ)をやりとりできる中で「0番目の画像」という意味です。

このあたり(Metal仕様周り)の詳細はわかりにくいので、興味のある方はMetalの入門書籍を1冊読むのが良いと思います。

  • swift側から画像を与えているのはこの部分です。
ViewController.swift
artView3.setupView(shaderName: "waveShader", imageName: "sampleImage")

Assetsの画像名を指定することでシェーダー側に画像を渡しています。

最後に

 シェーダーの学習は、こちらのサイトwebgl developer orgがわかりやすいです。WebGLでの解説ですがMSLに読み替えることは容易ですし、シェーダー意外のテクニックも豊富に丁寧に説明があり参考になります(自分もあんまり理解できていませんが)。

6
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
7