MetalDay 2

[iOS] Metalシェーダことはじめ - WebGL/GLSLの豊富なサンプルを参考にMSLを書く

More than 1 year has passed since last update.

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

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

glsl.jpg

Metalのシェーダというのは正しくは Metal Shading Language といいまして、C++をベースとする独自言語なのですが、やはり同じGPUに実行させるプログラムだけあって、概ねGLSLと一緒です。

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

ss.jpg

いずれも画像等のリソースは使用しておらず、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#36694.0

sample1.gif


http://glslsandbox.com/e#36538.3

sample2_64.gif

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


http://glslsandbox.com/e#36614.0

sample3.gif





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