その後のその後

iOSエンジニア 堤 修一のブログ github.com/shu223

iOS/MetalのシェーダをWebGL/GLSLから移植する

Metalでグラフィック処理を行うにしろ並列演算を行うにしろ、GPUに処理をさせるためのシェーダを書かないといけないわけですが、これがまだ情報が少なくて、「こういうシェーダを書きたいんだけど、誰かもう書いてないかな・・・」というときに参考になる近いものとかはそうそう都合よく出てこないわけです。


ただ、WebGL/GLSLの情報はググると山ほどあって、GLSL Sandbox という、Web上で編集できてプレビューできてシェアできるサイトもあり、何がどうなってそうなるのか理解できない難しそうなものから、ただの円といったシンプルなものまで、既に偉大な先人たちのサンプルがたくさんアップされています




Metalのシェーダというのは正しくは Metal Shading Language といいまして、C++をベースとする独自言語なのですが、まー概ねGLSLと一緒です。


実際にやってみたところ、GLSL -> Metal Shader の移植はほとんど単純置き換えで済み、Swift 2をSwift 3に直すのよりも簡単という感覚でした。



いずれも画像等のリソースは使用しておらず、iOSデバイス上で、GPUによってリアルタイム計算されたものです。


実際のところ自分はゲーム開発やVJをやったりしているわけではないので、こういう派手なエフェクトではなく、線とか円とかグラデーションとかのもっと単純なものをMetalで動的描画したいだけだったりするのですが *1、移植が簡単に行えることがわかっていれば、GLSLを参考にMetalシェーダを書けるというのはもちろんのこと、GLSL Sandboxで動作確認しつつシェーダを書いて、できたものをiOSに移植する、ということもできるので、個人的にはMetalシェーダを書く敷居がグッと下がりました。


というわけで、以下GLSLのコードをMetalに移植する際のメモです。

雛形となるプロジェクトを作成する

プロジェクトテンプレートに "OpenGL ES" はあっても "Metal" というのはないのですが、"Game" というのがあり、次の画面で [Game Technology] の欄で Metal を選択すると、シンプルなMetalのプロジェクトが生成されます。



これをベースに、GLSLのコードを移植して来やすいように次のように手を入れました。(現時点ではGLSL SandboxのコードをiOSで動かしてみるべく、フラグメントシェーダだけに手を入れています)

1.「画面の解像度」をフラグメントシェーダに渡す

多くのGLSLのサンプルでは、xy平面における座標を 0.0〜1.0 に正規化した状態で取り扱っています。ピクセルベースの座標値をシェーダ側で正規化できるよう、画面の解像度をシェーダに渡すよう修正します。


まずはシェーダ側。下記のように引数に float2型の "resolution" を追加します。

fragment float4 practiceFragment(VertexInOut     inFrag      [[stage_in]],
                                   constant float2 &resolution [[buffer(0)]])


次にSwift側。下記のようにバッファを用意して、

var resolutionBuffer: MTLBuffer! = nil
let screenSize = UIScreen.main.nativeBounds.size
let resolutionData = [Float(screenSize.width), Float(screenSize.height)]
let resolutionSize = resolutionData.count * MemoryLayout<Float>.size
resolutionBuffer = device.makeBuffer(bytes: resolutionData, length: resolutionSize, options: [])


フラグメントシェーダ関数の引数にバッファをセットします。

renderEncoder.setFragmentBuffer(resolutionBuffer, offset: 0, at: 0)


こんな感じで正規化した座標値を算出します。

float p_x = inFrag.position.x / resolution.x;
float p_y = inFrag.position.y / resolution.x;
float2 p = float2(p_x, p_y);

GLSL Sandboxはスクリーンが必ず正方形なのですが、iOSデバイスはそうではないので、比率が変わらないようどちらもx方向の解像度(つまり幅)で割っています。

2. 「経過時間」をフラグメントシェーダに渡す

ほとんど同様です。シェーダ側では、引数に float型の "time" を追加します。

fragment float4 practiceFragment(VertexInOut     inFrag      [[stage_in]],
                                   constant float2 &resolution [[buffer(0)]],
                                   constant float  &time       [[buffer(1)]])


Swift側。下記のようにバッファを用意して、

var timeBuffer: MTLBuffer! = nil
timeBuffer = device.makeBuffer(length: MemoryLayout<Float>.size, options: [])
timeBuffer.label = "time"


フラグメントシェーダ関数の引数にバッファをセットします。インデックスが変わる点に注意。

renderEncoder.setFragmentBuffer(timeBuffer, offset: 0, at: 1)


時刻の更新時にバッファを更新します。

let pTimeData = timeBuffer.contents()
let vTimeData = pTimeData.bindMemory(to: Float.self, capacity: 1 / MemoryLayout<Float>.stride)
vTimeData[0] = Float(Date().timeIntervalSince(startDate))

GLSLを移植する際の改変点

GLSL を Metal に移植してくる準備が整いました。ほとんど同じ、と書きましたが細部はやはり違います。以下、大まかな移行ポイントです。

  • GLSLのフラグメントシェーダでは、最後に gl_FragColor にvec4値をセットすることで出力とするが、return で float4 なり half4 なりを返す

(例)GLSLの場合

gl_FragColor = vec4(color, 1.0 );

Metalの場合

return float4(color, 1.0);
  • 関数はプロトタイプ宣言が必要
    • これがないと、ビルド時に "No previous prototype for function 〜" というwarningが出る
  • vec2, vec3, vec4 -> float2, float3, float4
  • ivec2, ivec3, ivec4 -> int2, int3, int4
  • mat2, mat3, mat4 -> float2x2, float3x3, float4x4
    • コンストラクタも微妙に違う(float2x2ならfloat2を2つ渡す。公式ドキュメントp15あたり)
  • const -> constant
  • mouse.x, mouse.y -> 適当に0.5とか


また出てきたら追記します。

GLSL Sandboxから移植してみる

GLSL Sandboxでいくつかピックアップして上記手順でMetalに移植し、iOSで動かしてみました。それぞれの移植にかかった時間は5分ぐらいです。ほとんど単純置き換えで済みました。

http://glslsandbox.com/e#36538.3

※デフォルトのITERATIONS 128では3fpsぐらいしか出なかったので、ITERATIONS 64に変更

まとめ

GLSLをMetalに移植する手順について書きました。


上にも書きましたが、Metalをさわりはじめたときはシンプルなものすら書き方がわからなくて四苦八苦したので、「GLSLのコードも参考になるんだ!」と気付いたときはMetalの敷居がグッと下がった気がしました。


MetalはiOS 10からニューラルネットワーク計算のライブラリも追加されたこともあり、自分的に今一番熱い分野です。引き続きシェーダの具体的な書き方や、SceneKitを併用して3D空間内のノード上にMetalでテクスチャを動的描画する方法、デバッグツールの使い方等、色々と書いていきたいと思います。



*1:線とか円とかの単純なものでも、カメラプレビューで動的かつリアルタイムに、かつ他の重い画像処理と一緒に、といった場合、そして描画数が多かったり毎フレームの更新が必要な場合、やはりUIKitやCoreGraphicsでは厳しい場面が出てきます。